@octane-xplat/camera 0.0.0-nightly.g2e22171

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +135 -0
  3. package/package.json +104 -0
  4. package/platforms/android/AndroidManifest.xml +6 -0
  5. package/platforms/android/include.gradle +14 -0
  6. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +84 -0
  7. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
  8. package/platforms/ios/Info.plist +10 -0
  9. package/platforms/macos/src/CameraOwnership.swift +22 -0
  10. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  11. package/platforms/macos/src/XplatCameraHost.swift +639 -0
  12. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  13. package/src/CameraView.android.tsrx +150 -0
  14. package/src/CameraView.ios.tsrx +102 -0
  15. package/src/CameraView.macos.tsrx +95 -0
  16. package/src/CameraView.mobile.ts +6 -0
  17. package/src/CameraView.web.tsrx +132 -0
  18. package/src/CameraView.windows.tsrx +126 -0
  19. package/src/camera-permission.mobile.ts +91 -0
  20. package/src/camera-permission.windows.ts +76 -0
  21. package/src/camera-session.ios.ts +65 -0
  22. package/src/camera-session.macos.ts +75 -0
  23. package/src/camera-session.windows.ts +126 -0
  24. package/src/host-movie.web.ts +295 -0
  25. package/src/index.ts +36 -0
  26. package/src/index.web.ts +34 -0
  27. package/src/layout-child.ts +81 -0
  28. package/src/layout-child.web.ts +66 -0
  29. package/src/layout-child.windows.ts +79 -0
  30. package/src/movie-metadata.web.ts +84 -0
  31. package/src/movie-storage.web.ts +164 -0
  32. package/src/preview-tap.ts +34 -0
  33. package/src/props.ts +77 -0
  34. package/src/session-backend.android.ts +439 -0
  35. package/src/session-backend.ios.ts +1258 -0
  36. package/src/session-backend.linux.ts +887 -0
  37. package/src/session-backend.macos.ts +707 -0
  38. package/src/session-backend.ts +59 -0
  39. package/src/session-backend.web.ts +891 -0
  40. package/src/session-backend.windows.ts +1599 -0
  41. package/src/session-core.ts +723 -0
  42. package/src/session.ts +37 -0
  43. package/src/synthetic-session.ts +377 -0
  44. package/src/types.ts +357 -0
  45. package/types/index.d.ts +48 -0
  46. package/types/props.d.ts +61 -0
  47. package/types/session-core.d.ts +139 -0
  48. package/types/synthetic-session.d.ts +51 -0
  49. package/types/types.d.ts +290 -0
@@ -0,0 +1,164 @@
1
+ import { CameraCaptureError } from './types'
2
+ import type { MovieClip, MovieOutput, MovieOutputHandle } from './types'
3
+
4
+ const databaseName = 'octane-xplat-camera'
5
+ const storeName = 'movies'
6
+
7
+ type StoredMovie = { id: string; clip?: MovieClip; bytes?: Blob }
8
+
9
+ const storageError = (cause: unknown) =>
10
+ new CameraCaptureError({
11
+ kind:
12
+ cause instanceof DOMException && cause.name === 'QuotaExceededError'
13
+ ? 'insufficientStorage'
14
+ : 'storageFailed',
15
+ operation: 'storage',
16
+ message: 'The movie could not be committed to origin-local storage',
17
+ cause,
18
+ })
19
+
20
+ /** Each operation closes its database; playback survives session and page lifetimes. */
21
+ async function transact<T>(
22
+ mode: IDBTransactionMode,
23
+ operation: (store: IDBObjectStore, result: (value: T) => void) => void,
24
+ ): Promise<T> {
25
+ return new Promise<T>((resolve, reject) => {
26
+ let database: IDBDatabase | undefined
27
+ let transaction: IDBTransaction | undefined
28
+ let result: T
29
+ let done = false
30
+ const finish = (cause?: unknown) => {
31
+ if (done) {
32
+ return
33
+ }
34
+
35
+ done = true
36
+ clearTimeout(timer)
37
+ database?.close()
38
+ if (cause) {
39
+ reject(storageError(cause))
40
+ } else {
41
+ resolve(result)
42
+ }
43
+ }
44
+
45
+ const timer = setTimeout(() => {
46
+ transaction?.abort()
47
+ finish(new Error('Storage transaction timed out'))
48
+ }, 15_000)
49
+
50
+ const request = indexedDB.open(databaseName, 1)
51
+ request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: 'id' })
52
+ request.onerror = () => finish(request.error)
53
+ request.onblocked = () => finish(new Error('Storage upgrade is blocked by another page'))
54
+ request.onsuccess = () => {
55
+ database = request.result
56
+ if (done) {
57
+ database.close()
58
+ return
59
+ }
60
+
61
+ try {
62
+ transaction = database.transaction(storeName, mode, { durability: 'strict' })
63
+ transaction.oncomplete = () => finish()
64
+ transaction.onabort = () =>
65
+ finish(transaction?.error ?? new Error('Storage transaction aborted'))
66
+
67
+ transaction.onerror = () =>
68
+ finish(transaction?.error ?? new Error('Storage transaction failed'))
69
+
70
+ operation(transaction.objectStore(storeName), (value) => {
71
+ result = value
72
+ })
73
+ } catch (cause) {
74
+ finish(cause)
75
+ }
76
+ }
77
+ })
78
+ }
79
+
80
+ export async function reserveMovie(): Promise<string> {
81
+ const estimate = await navigator.storage.estimate()
82
+ if (
83
+ estimate.quota !== undefined &&
84
+ estimate.usage !== undefined &&
85
+ estimate.quota - estimate.usage < 1_048_576
86
+ ) {
87
+ throw new CameraCaptureError({
88
+ kind: 'insufficientStorage',
89
+ operation: 'reserve',
90
+ message: 'Origin storage has less than 1 MiB free',
91
+ })
92
+ }
93
+
94
+ const id = crypto.randomUUID()
95
+ await transact<void>('readwrite', (store) => {
96
+ store.add({ id })
97
+ })
98
+
99
+ return id
100
+ }
101
+
102
+ export async function commitMovie(id: string, bytes: Blob, clip: MovieClip): Promise<void> {
103
+ if (!(await navigator.storage.persisted())) {
104
+ throw new CameraCaptureError({
105
+ kind: 'destinationUnavailable',
106
+ operation: 'storage',
107
+ message: 'Persistent origin storage is no longer granted',
108
+ })
109
+ }
110
+
111
+ await transact<void>('readwrite', (store) => {
112
+ store.put({ id, bytes, clip } satisfies StoredMovie)
113
+ })
114
+
115
+ // Reopen the committed entry before handing ownership to the app.
116
+ const stored = await readMovie(id)
117
+ if (!stored.bytes || stored.bytes.size !== bytes.size || !stored.clip) {
118
+ throw storageError(new Error('Committed movie cannot be reopened'))
119
+ }
120
+ }
121
+
122
+ export async function removeReservation(id: string): Promise<void> {
123
+ await transact<void>('readwrite', (store) => {
124
+ store.delete(id)
125
+ })
126
+ }
127
+
128
+ async function readMovie(id: string): Promise<StoredMovie> {
129
+ return transact<StoredMovie>('readonly', (store, result) => {
130
+ const request = store.get(id)
131
+ request.onsuccess = () => result(request.result ?? { id })
132
+ })
133
+ }
134
+
135
+ export async function openStoredMovie(output: MovieOutput): Promise<MovieOutputHandle> {
136
+ if (output.kind !== 'browserStorage' || output.synthetic) {
137
+ throw new CameraCaptureError({
138
+ kind: 'invalidArgument',
139
+ operation: 'openOutput',
140
+ message: 'Expected an origin-local movie reference',
141
+ })
142
+ }
143
+
144
+ const stored = await readMovie(output.resourceId)
145
+ if (!stored.bytes || !stored.clip) {
146
+ throw new CameraCaptureError({
147
+ kind: 'destinationUnavailable',
148
+ operation: 'openOutput',
149
+ message: 'Movie is missing or site data was cleared',
150
+ })
151
+ }
152
+
153
+ const url = URL.createObjectURL(stored.bytes)
154
+ let released = false
155
+ return {
156
+ url,
157
+ release: () => {
158
+ if (!released) {
159
+ released = true
160
+ URL.revokeObjectURL(url)
161
+ }
162
+ },
163
+ }
164
+ }
@@ -0,0 +1,34 @@
1
+ import type { CameraPreviewPoint } from './types'
2
+
3
+ /** Attach a notification-only 'tap' observer to a preview host view and
4
+ * report each tap as a `CameraPreviewPoint` normalized to the view's
5
+ * bounds. The tap gesture never consumes the touch — recognizers layered
6
+ * above the preview still see it. Platforms whose runtime never
7
+ * dispatches 'tap' simply never fire the callback. */
8
+ export function observePreviewTap(
9
+ view: any,
10
+ onTap: ((point: CameraPreviewPoint) => void) | undefined,
11
+ ): () => void {
12
+ if (!view || !onTap || typeof view.on !== 'function') {
13
+ return () => {}
14
+ }
15
+
16
+ const handler = (args: any) => {
17
+ const size = view.getActualSize?.()
18
+ const width = size?.width ?? 0
19
+ const height = size?.height ?? 0
20
+ const x = typeof args?.getX === 'function' ? args.getX() : NaN
21
+ const y = typeof args?.getY === 'function' ? args.getY() : NaN
22
+ if (!(width > 0) || !(height > 0) || !Number.isFinite(x) || !Number.isFinite(y)) {
23
+ return
24
+ }
25
+
26
+ onTap({
27
+ x: Math.min(1, Math.max(0, x / width)),
28
+ y: Math.min(1, Math.max(0, y / height)),
29
+ })
30
+ }
31
+
32
+ view.on('tap', handler)
33
+ return () => view.off?.('tap', handler)
34
+ }
package/src/props.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { Octane } from 'octane/jsx-runtime'
2
+ import type { CameraPreviewPoint, CameraSession } from './types'
3
+
4
+ export type CameraAccessibilityRole =
5
+ | 'button'
6
+ | 'link'
7
+ | 'search'
8
+ | 'image'
9
+ | 'heading'
10
+ | 'adjustable'
11
+ | 'summary'
12
+ | 'text'
13
+ | 'none'
14
+ | 'progressbar'
15
+ | 'checkbox'
16
+ | 'switch'
17
+ | 'radio'
18
+ | 'spinbutton'
19
+ | 'tab'
20
+
21
+ export interface CameraViewHandle {
22
+ /** The native preview surface (`HTMLVideoElement` on web). */
23
+ native: any
24
+ }
25
+
26
+ export interface CameraViewProps {
27
+ className?: any
28
+ style?: any
29
+ id?: string
30
+ row?: number
31
+ col?: number
32
+ rowSpan?: number
33
+ colSpan?: number
34
+ dock?: 'left' | 'top' | 'right' | 'bottom'
35
+ left?: number
36
+ top?: number
37
+ flexGrow?: number
38
+ flexShrink?: number
39
+ alignSelf?: string
40
+ order?: number
41
+ accessible?: boolean
42
+ accessibilityLabel?: string
43
+ accessibilityRole?: CameraAccessibilityRole
44
+ accessibilityHint?: string
45
+ accessibilityValue?: string
46
+ accessibilityState?: {
47
+ disabled?: boolean
48
+ selected?: boolean
49
+ checked?: boolean
50
+ }
51
+ accessibilityLiveRegion?: 'none' | 'polite' | 'assertive'
52
+ /** Shared camera owner enabling movie recording. When set, the preview
53
+ * attaches to the session's acquisition instead of owning one, and
54
+ * `facing` must be omitted — camera selection lives on the session. */
55
+ session?: CameraSession
56
+ /** Lens — defaults to the rear camera. Invalid together with `session`. */
57
+ facing?: 'front' | 'back'
58
+ /** Default true. False releases the preview session. */
59
+ active?: boolean
60
+ /** Fires after the preview surface starts. */
61
+ onReady?: () => void
62
+ onError?: (error: { message?: string }) => void
63
+ /** Notification-only tap event — fires with the normalized
64
+ * preview-space position (`CameraPreviewPoint`) and never consumes
65
+ * the gesture, so recognizers layered above the preview (double-tap,
66
+ * pan) still run. Pair with `session.focusAtPoint` for tap-to-focus.
67
+ * Delivered on iOS, Android, and web; desktop runtimes do not dispatch
68
+ * it yet. */
69
+ onTap?: (point: CameraPreviewPoint) => void
70
+ /** Native preview surface for platform-specific controls. */
71
+ ref?: Octane.Ref<CameraViewHandle>
72
+ /** Platform-specific properties applied to the preview surface. */
73
+ ios?: Record<string, any>
74
+ android?: Record<string, any>
75
+ web?: Record<string, any>
76
+ windows?: Record<string, any>
77
+ }
@@ -0,0 +1,439 @@
1
+ import { Application } from '@nativescript/core'
2
+ import { CameraCaptureError } from './types'
3
+ import { canonicalizePath } from './session-core'
4
+ import type {
5
+ BackendCaptureSink,
6
+ BackendObserver,
7
+ BackendPreviewListener,
8
+ CameraSessionBackend,
9
+ } from './session-core'
10
+
11
+ import type {
12
+ CameraPermissionKind,
13
+ CameraPermissionStatus,
14
+ CameraSessionConfig,
15
+ CaptureIssue,
16
+ } from './types'
17
+
18
+ let requestCode = 7820
19
+ let permissionChain: Promise<unknown> = Promise.resolve()
20
+ let owner: object | undefined
21
+
22
+ const nativeBridge = () => (globalThis as any).com?.octanexplat?.camera?.CameraSessionBridge
23
+ const permissionName = (kind: CameraPermissionKind) =>
24
+ kind === 'camera' ? 'android.permission.CAMERA' : 'android.permission.RECORD_AUDIO'
25
+
26
+ const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
27
+ new CameraCaptureError({ kind, operation, message, cause })
28
+
29
+ const context = () => Application.android.context
30
+ const preferences = () => context().getSharedPreferences('octane-camera-permissions', 0)
31
+
32
+ async function checkPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
33
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity
34
+ if (!activity) {
35
+ return 'unavailable'
36
+ }
37
+
38
+ const permission = permissionName(kind)
39
+ if (activity.checkSelfPermission(permission) === 0) {
40
+ return 'granted'
41
+ }
42
+
43
+ return preferences().getBoolean(kind, false)
44
+ ? activity.shouldShowRequestPermissionRationale(permission)
45
+ ? 'denied'
46
+ : 'blocked'
47
+ : 'notDetermined'
48
+ }
49
+
50
+ function requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
51
+ const result = permissionChain.then(async () => {
52
+ const status = await checkPermission(kind)
53
+ if (status !== 'notDetermined') {
54
+ return status
55
+ }
56
+
57
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity
58
+ if (!activity) {
59
+ return 'unavailable' as const
60
+ }
61
+
62
+ return new Promise<CameraPermissionStatus>((resolve) => {
63
+ const code = requestCode++
64
+ const finish = (value: CameraPermissionStatus) => {
65
+ clearTimeout(timer)
66
+ Application.android.off(Application.android.activityRequestPermissionsEvent, onResult)
67
+ resolve(value)
68
+ }
69
+
70
+ const onResult = (args: any) => {
71
+ if (args.requestCode !== code) {
72
+ return
73
+ }
74
+
75
+ if (!args.grantResults?.length) {
76
+ finish('unknown')
77
+ return
78
+ }
79
+
80
+ preferences().edit().putBoolean(kind, true).apply()
81
+ finish(
82
+ args.grantResults[0] === 0
83
+ ? 'granted'
84
+ : activity.shouldShowRequestPermissionRationale(permissionName(kind))
85
+ ? 'denied'
86
+ : 'blocked',
87
+ )
88
+ }
89
+
90
+ const timer = setTimeout(() => finish('unknown'), 120_000)
91
+ Application.android.on(Application.android.activityRequestPermissionsEvent, onResult)
92
+ try {
93
+ activity.requestPermissions([permissionName(kind)], code)
94
+ } catch {
95
+ finish('unavailable')
96
+ }
97
+ })
98
+ })
99
+
100
+ permissionChain = result.catch(() => {})
101
+ return result
102
+ }
103
+
104
+ export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
105
+ let currentConfig = { ...config }
106
+ let observer: BackendObserver | undefined
107
+ let bridge: any
108
+ let host: any
109
+ let listener: BackendPreviewListener | undefined
110
+ let sink: BackendCaptureSink | undefined
111
+ let released = false
112
+ const lease = {}
113
+ const selected = () =>
114
+ typeof currentConfig.camera === 'object'
115
+ ? `id:${currentConfig.camera.deviceId}`
116
+ : (currentConfig.camera ?? 'default')
117
+
118
+ const releaseLease = () => {
119
+ if (owner === lease) {
120
+ owner = undefined
121
+ }
122
+ }
123
+
124
+ const onSuspend = () => bridge?.background()
125
+ const onResume = () => bridge?.foreground()
126
+ const ensureBridge = () => {
127
+ if (bridge) {
128
+ return
129
+ }
130
+
131
+ const Bridge = nativeBridge()
132
+ if (!Bridge) {
133
+ throw error(
134
+ 'unsupportedPlatform',
135
+ 'preview',
136
+ 'CameraX video bridge is missing from the native build',
137
+ )
138
+ }
139
+
140
+ bridge = new Bridge(
141
+ context(),
142
+ new Bridge.Listener({
143
+ event(type: string, payload: string) {
144
+ if (type === 'ready') {
145
+ observer?.previewReady(payload === 'true')
146
+ if (payload === 'true') {
147
+ listener?.onReady?.()
148
+ }
149
+ } else if (type === 'availability') {
150
+ observer?.availability(payload === 'true')
151
+ } else if (type === 'interruption') {
152
+ observer?.interruption('began', payload as any)
153
+ } else if (type === 'recovered') {
154
+ observer?.interruption('ended', payload as any)
155
+ } else if (type === 'previewError') {
156
+ observer?.availability(false)
157
+ listener?.onError?.(error('unavailable', 'preview', payload))
158
+ if (!host) {
159
+ releaseLease()
160
+ }
161
+ } else if (type === 'finishing') {
162
+ sink?.finishing(payload ? (payload as any) : undefined)
163
+ } else if (type === 'started') {
164
+ sink?.started(JSON.parse(payload))
165
+ } else if (type === 'finished' || type === 'failed') {
166
+ const target = sink
167
+ sink = undefined
168
+ if (!host) {
169
+ releaseLease()
170
+ }
171
+
172
+ const result = JSON.parse(payload)
173
+ target?.settled(
174
+ type === 'finished'
175
+ ? result
176
+ : {
177
+ kind: 'failed',
178
+ stage: result.stage,
179
+ issue: error(result.kind, result.stage, result.message),
180
+ },
181
+ )
182
+ }
183
+ },
184
+ }),
185
+ )
186
+
187
+ Application.on(Application.suspendEvent, onSuspend)
188
+ Application.on(Application.resumeEvent, onResume)
189
+ }
190
+
191
+ return {
192
+ platform: 'android',
193
+ get supported() {
194
+ return !!nativeBridge()
195
+ },
196
+ checkPermission,
197
+ requestPermission: async (kind) => {
198
+ const status = await requestPermission(kind)
199
+ if (
200
+ kind === 'camera' &&
201
+ status === 'granted' &&
202
+ host &&
203
+ !sink &&
204
+ !released &&
205
+ !bridge?.isReady()
206
+ ) {
207
+ try {
208
+ ensureBridge()
209
+ bridge.attach(host, selected())
210
+ } catch (cause) {
211
+ observer?.availability(false)
212
+ listener?.onError?.(
213
+ error(
214
+ 'unavailable',
215
+ 'preview',
216
+ 'Could not activate CameraX after permission approval',
217
+ cause,
218
+ ),
219
+ )
220
+ }
221
+ }
222
+
223
+ return status
224
+ },
225
+ getCapabilities: async () => {
226
+ const value = bridge ? JSON.parse(bridge.capabilities()) : { cameras: [], profiles: [] }
227
+ const supported = !!nativeBridge()
228
+ return {
229
+ supported,
230
+ available: !!bridge?.isReady(),
231
+ reason: !supported
232
+ ? 'CameraX video bridge is missing from the native build'
233
+ : !bridge?.isReady()
234
+ ? 'An active permitted preview is required'
235
+ : undefined,
236
+ cameras: value.cameras,
237
+ profiles: value.profiles,
238
+ audio: context().getPackageManager().hasSystemFeature('android.hardware.microphone'),
239
+ // The bridge only reports metering support once a camera is
240
+ // bound — absent fields read as unsupported, which is honest
241
+ // for the current acquisition state.
242
+ focusPointOfInterest: value.focusPointOfInterest === true,
243
+ exposurePointOfInterest: value.exposurePointOfInterest === true,
244
+ orientations: [],
245
+ durationLimit: 'native',
246
+ elapsedTime: 'nativeMedia',
247
+ output: {
248
+ mimeType: 'video/mp4',
249
+ container: 'mp4',
250
+ storage: 'appPrivateFile',
251
+ destinationFileUrl: true,
252
+ },
253
+ }
254
+ },
255
+ previewReady: () => !!bridge?.isReady(),
256
+ validateStart: (options) => {
257
+ if (
258
+ options.maximumDurationMs !== undefined &&
259
+ options.maximumDurationMs > 9_223_372_036_854
260
+ ) {
261
+ throw error(
262
+ 'unsupportedConfiguration',
263
+ 'startRecording',
264
+ 'The duration exceeds CameraX media-time range',
265
+ )
266
+ }
267
+
268
+ if (options.orientation && options.orientation !== 'unspecified') {
269
+ throw error(
270
+ 'unsupportedConfiguration',
271
+ 'startRecording',
272
+ 'Fixed cardinal orientation has not been qualified on this Android source',
273
+ )
274
+ }
275
+ },
276
+ attachPreview: (target, callbacks) => {
277
+ if (released || sink) {
278
+ throw error('invalidState', 'attachPreview', 'The camera is disposed or finalizing')
279
+ }
280
+
281
+ if (owner && owner !== lease) {
282
+ throw error('busy', 'attachPreview', 'Another camera session is still finalizing')
283
+ }
284
+
285
+ owner = lease
286
+ host = target
287
+ listener = callbacks
288
+ try {
289
+ ensureBridge()
290
+ } catch (cause) {
291
+ host = undefined
292
+ listener = undefined
293
+ releaseLease()
294
+ throw error(
295
+ 'unavailable',
296
+ 'attachPreview',
297
+ 'Could not construct the CameraX native bridge',
298
+ cause,
299
+ )
300
+ }
301
+
302
+ void checkPermission('camera').then((status) => {
303
+ if (released || !host) {
304
+ return
305
+ }
306
+
307
+ if (status !== 'granted') {
308
+ listener?.onError?.(
309
+ error(
310
+ 'permissionDenied',
311
+ 'preview',
312
+ 'Request camera permission before activating this preview',
313
+ ),
314
+ )
315
+
316
+ return
317
+ }
318
+
319
+ try {
320
+ bridge.attach(host, selected())
321
+ } catch (cause) {
322
+ listener?.onError?.(
323
+ error('unavailable', 'preview', 'Could not bind shared CameraX preview', cause),
324
+ )
325
+ }
326
+ })
327
+
328
+ return () => {
329
+ host = undefined
330
+ listener = undefined
331
+ bridge.detach()
332
+ if (!sink) {
333
+ releaseLease()
334
+ }
335
+ }
336
+ },
337
+ configure: (next) => {
338
+ const sourceChanged =
339
+ JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) ||
340
+ next.profile !== currentConfig.profile
341
+
342
+ currentConfig = { ...next }
343
+ if (host && sourceChanged) {
344
+ bridge.detach()
345
+ bridge.attach(host, selected())
346
+ }
347
+ },
348
+ focusAtPoint: (point) => {
349
+ try {
350
+ bridge?.focusAtPoint?.(point.x, point.y)
351
+ } catch {
352
+ // A missed metering pass must not fault the session.
353
+ }
354
+ },
355
+ startCapture: (request, target) => {
356
+ sink = target
357
+ if (request.destinationPath) {
358
+ // Durable output stays app-private like every other leaf —
359
+ // canonicalize so '..' cannot escape the files root.
360
+ const destination = canonicalizePath(request.destinationPath)
361
+ let filesRoot = ''
362
+ try {
363
+ filesRoot = context()?.getFilesDir?.().getAbsolutePath?.() ?? ''
364
+ } catch {
365
+ filesRoot = ''
366
+ }
367
+
368
+ if (!filesRoot || !destination.startsWith(`${filesRoot}/`)) {
369
+ sink = undefined
370
+ target.settled({
371
+ kind: 'failed',
372
+ stage: 'preparation',
373
+ issue: error(
374
+ 'destinationUnavailable',
375
+ 'startRecording',
376
+ 'destinationFileUrl must resolve inside the app-private files directory',
377
+ ),
378
+ })
379
+
380
+ return
381
+ }
382
+
383
+ request = { ...request, destinationPath: destination }
384
+ }
385
+
386
+ try {
387
+ bridge.start(
388
+ request.audio,
389
+ request.destinationPath ?? '',
390
+ request.maximumDurationMs === undefined
391
+ ? 0
392
+ : Math.max(1, Math.ceil(request.maximumDurationMs)),
393
+ )
394
+ } catch (cause) {
395
+ sink = undefined
396
+ target.settled({
397
+ kind: 'failed',
398
+ stage: 'preparation',
399
+ issue: error(
400
+ 'captureFailed',
401
+ 'startRecording',
402
+ 'CameraX could not begin recording',
403
+ cause,
404
+ ),
405
+ })
406
+ }
407
+ },
408
+ stopCapture: () => bridge?.stop(),
409
+ elapsedMs: () => Number(bridge?.elapsedMs() ?? 0),
410
+ openOutput: async (output) => {
411
+ if (output.kind !== 'nativeFile' || output.synthetic) {
412
+ throw error('invalidArgument', 'openOutput', 'Expected a native Android movie reference')
413
+ }
414
+
415
+ try {
416
+ const url = nativeBridge().openOutput(output.resourceId, output.fileUrl)
417
+ return { url, fileUrl: url, release: () => {} }
418
+ } catch (cause) {
419
+ throw error(
420
+ 'destinationUnavailable',
421
+ 'openOutput',
422
+ 'The stored Android movie cannot be reopened',
423
+ cause,
424
+ )
425
+ }
426
+ },
427
+ release: async () => {
428
+ released = true
429
+ Application.off(Application.suspendEvent, onSuspend)
430
+ Application.off(Application.resumeEvent, onResume)
431
+ bridge?.release()
432
+ host = undefined
433
+ releaseLease()
434
+ },
435
+ setObserver: (value) => {
436
+ observer = value
437
+ },
438
+ }
439
+ }