@octane-xplat/camera 0.14.0 → 0.15.0

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 (81) hide show
  1. package/README.md +26 -0
  2. package/package.json +22 -16
  3. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +9 -0
  4. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +31 -0
  5. package/platforms/macos/src/CameraOwnership.swift +22 -0
  6. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  7. package/platforms/macos/src/XplatCameraHost.swift +139 -13
  8. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  9. package/src/CameraView.android.tsrx +6 -2
  10. package/src/CameraView.ios.tsrx +4 -2
  11. package/src/CameraView.macos.tsrx +4 -2
  12. package/src/CameraView.web.tsrx +11 -0
  13. package/src/CameraView.windows.tsrx +4 -2
  14. package/src/camera-permission.mobile.ts +18 -4
  15. package/src/camera-session.windows.ts +4 -1
  16. package/src/host-movie.web.ts +141 -26
  17. package/src/index.ts +1 -0
  18. package/src/index.web.ts +1 -0
  19. package/src/preview-tap.ts +34 -0
  20. package/src/props.ts +8 -1
  21. package/src/session-backend.android.ts +42 -0
  22. package/src/session-backend.ios.ts +183 -17
  23. package/src/session-backend.linux.ts +22 -4
  24. package/src/session-backend.macos.ts +100 -17
  25. package/src/session-backend.ts +2 -0
  26. package/src/session-backend.web.ts +17 -0
  27. package/src/session-backend.windows.ts +88 -6
  28. package/src/session-core.ts +73 -9
  29. package/src/synthetic-session.ts +10 -0
  30. package/src/types.ts +28 -0
  31. package/types/props.d.ts +8 -1
  32. package/types/session-core.d.ts +11 -1
  33. package/types/synthetic-session.d.ts +4 -1
  34. package/types/types.d.ts +27 -0
  35. package/dist/linux/CameraView.web.tsrx.js +0 -337
  36. package/dist/linux/host-movie.web.js +0 -100
  37. package/dist/linux/index.js +0 -5
  38. package/dist/linux/layout-child.web.js +0 -29
  39. package/dist/linux/movie-metadata.web.js +0 -65
  40. package/dist/linux/octane-client-build.json +0 -8
  41. package/dist/linux/session-backend.linux.js +0 -479
  42. package/dist/linux/session-core.js +0 -368
  43. package/dist/linux/session.js +0 -29
  44. package/dist/linux/synthetic-session.js +0 -280
  45. package/dist/linux/types.js +0 -19
  46. package/dist/native/CameraView.android.js +0 -190
  47. package/dist/native/CameraView.ios.js +0 -154
  48. package/dist/native/CameraView.windows.js +0 -173
  49. package/dist/native/camera-permission.mobile.js +0 -45
  50. package/dist/native/camera-permission.windows.js +0 -43
  51. package/dist/native/camera-session.ios.js +0 -45
  52. package/dist/native/camera-session.windows.js +0 -78
  53. package/dist/native/cx.mobile.js +0 -9
  54. package/dist/native/cx.windows.js +0 -9
  55. package/dist/native/index.js +0 -5
  56. package/dist/native/layout-child.js +0 -53
  57. package/dist/native/layout-child.windows.js +0 -53
  58. package/dist/native/octane-client-build.json +0 -8
  59. package/dist/native/session-backend.android.js +0 -234
  60. package/dist/native/session-backend.ios.js +0 -682
  61. package/dist/native/session-backend.js +0 -55
  62. package/dist/native/session-backend.windows.js +0 -905
  63. package/dist/native/session-core.js +0 -368
  64. package/dist/native/session.js +0 -29
  65. package/dist/native/synthetic-session.js +0 -280
  66. package/dist/native/types.js +0 -19
  67. package/dist/web/CameraView.web.tsrx.js +0 -337
  68. package/dist/web/host-movie.web.js +0 -100
  69. package/dist/web/index.js +0 -5
  70. package/dist/web/layout-child.web.js +0 -29
  71. package/dist/web/movie-metadata.web.js +0 -65
  72. package/dist/web/movie-storage.web.js +0 -119
  73. package/dist/web/octane-client-build.json +0 -8
  74. package/dist/web/session-backend.web.js +0 -490
  75. package/dist/web/session-core.js +0 -368
  76. package/dist/web/session.js +0 -29
  77. package/dist/web/synthetic-session.js +0 -280
  78. package/dist/web/types.js +0 -19
  79. package/src/cx.macos.ts +0 -5
  80. package/src/cx.mobile.ts +0 -6
  81. package/src/cx.windows.ts +0 -6
@@ -7,7 +7,9 @@ import type { CameraViewProps } from './props'
7
7
  import { attachSessionPreview } from './session'
8
8
  import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
9
9
  import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
10
- import { cx } from './cx.mobile'
10
+ import { observePreviewTap } from './preview-tap'
11
+ import { java } from '@octane-xplat/platform/android'
12
+
11
13
 
12
14
  class CameraPreview extends View {
13
15
  createNativeView() {
@@ -126,11 +128,13 @@ export function CameraView(props: CameraViewProps) @{
126
128
  applyEscapeProps(view.android, props)
127
129
  }, [props.ref, props.android])
128
130
 
131
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
132
+
129
133
  <gridlayout
130
134
  id={props.id}
131
135
  rows="*"
132
136
  columns="*"
133
- className={cx('vx-layers', 'vx-cameraview', props.className)}
137
+ className={['vx-layers', 'vx-cameraview', props.className]}
134
138
  style={{ backgroundColor: '#000', ...props.style }}
135
139
  {...layoutChildProps(props)}
136
140
  accessible={props.accessible}
@@ -5,7 +5,7 @@ import { cameraHardwarePresent, ensureCameraPermission } from './camera-permissi
5
5
  import { startCameraPreview } from './camera-session.ios'
6
6
  import { attachSessionPreview } from './session'
7
7
  import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
8
- import { cx } from './cx.mobile'
8
+ import { observePreviewTap } from './preview-tap'
9
9
 
10
10
  /** Live iOS preview backed by AVFoundation. With `session`, the preview
11
11
  * attaches to the session-owned AVCaptureSession so movie recording can
@@ -81,12 +81,14 @@ export function CameraView(props: CameraViewProps) @{
81
81
  applyEscapeProps(view.ios ?? view.nativeViewProtected, props)
82
82
  }, [props.ref, props.ios])
83
83
 
84
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
85
+
84
86
  <gridlayout
85
87
  ref={bindHost}
86
88
  id={props.id}
87
89
  rows="*"
88
90
  columns="*"
89
- className={cx('vx-layers', 'vx-cameraview', props.className)}
91
+ className={['vx-layers', 'vx-cameraview', props.className]}
90
92
  style={{ backgroundColor: '#000', ...props.style }}
91
93
  {...layoutChildProps(props)}
92
94
  accessible={props.accessible}
@@ -4,7 +4,7 @@ import type { CameraViewProps } from './props'
4
4
  import { cameraHardwarePresent, ensureCameraPermission, startCameraPreview } from './camera-session.macos'
5
5
  import { attachSessionPreview } from './session'
6
6
  import { layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
7
- import { cx } from './cx.macos'
7
+ import { observePreviewTap } from './preview-tap'
8
8
 
9
9
  /** Live AppKit preview backed by AVFoundation. With `session`, the preview
10
10
  * attaches to the session-owned AVCaptureSession so movie recording can
@@ -74,12 +74,14 @@ export function CameraView(props: CameraViewProps) @{
74
74
  }
75
75
  }, [active, facing, props.session])
76
76
 
77
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
78
+
77
79
  <gridlayout
78
80
  ref={bindHost}
79
81
  id={props.id}
80
82
  rows="*"
81
83
  columns="*"
82
- className={cx('vx-layers', 'vx-cameraview', props.className)}
84
+ className={['vx-layers', 'vx-cameraview', props.className]}
83
85
  style={{ backgroundColor: '#000000', ...props.style }}
84
86
  {...layoutChildProps(props)}
85
87
  accessible={props.accessible}
@@ -103,6 +103,17 @@ export function CameraView(props: CameraViewProps) @{
103
103
  muted={true}
104
104
  autoplay={true}
105
105
  playsInline={true}
106
+ onClick={(event: MouseEvent) => {
107
+ const onTap = props.onTap
108
+ const target = event.currentTarget as HTMLElement | null
109
+ if (!onTap || !target) return
110
+ const rect = target.getBoundingClientRect()
111
+ if (!(rect.width > 0) || !(rect.height > 0)) return
112
+ onTap({
113
+ x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
114
+ y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
115
+ })
116
+ }}
106
117
  style={{
107
118
  display: 'block',
108
119
  width: '100%',
@@ -8,7 +8,7 @@ import { attachSessionPreview } from './session'
8
8
  import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.windows'
9
9
  import { startCameraPreview } from './camera-session.windows'
10
10
  import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child.windows'
11
- import { cx } from './cx.windows'
11
+ import { observePreviewTap } from './preview-tap'
12
12
 
13
13
  class CameraPreview extends View {
14
14
  createNativeView() {
@@ -104,11 +104,13 @@ export function CameraView(props: CameraViewProps) @{
104
104
  applyEscapeProps(view.nativeViewProtected, props)
105
105
  }, [props.ref, props.windows])
106
106
 
107
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
108
+
107
109
  <gridlayout
108
110
  id={props.id}
109
111
  rows="*"
110
112
  columns="*"
111
- className={cx('vx-layers', 'vx-cameraview', props.className)}
113
+ className={['vx-layers', 'vx-cameraview', props.className]}
112
114
  style={{ backgroundColor: '#000', ...props.style }}
113
115
  {...layoutChildProps(props)}
114
116
  accessible={props.accessible}
@@ -1,4 +1,5 @@
1
1
  import { Application, isAndroid, isIOS } from '@nativescript/core'
2
+ import { android } from '@octane-xplat/platform/android'
2
3
 
3
4
  let requestCode = 7719
4
5
 
@@ -54,19 +55,32 @@ export async function ensureCameraPermission(): Promise<boolean> {
54
55
 
55
56
  return await new Promise<boolean>((resolve) => {
56
57
  const currentRequest = requestCode++
58
+ const finish = (granted: boolean) => {
59
+ clearTimeout(timer)
60
+ Application.android.off(Application.android.activityRequestPermissionsEvent, onResult)
61
+
62
+ resolve(granted)
63
+ }
64
+
57
65
  const onResult = (args: any) => {
58
66
  if (args.requestCode !== currentRequest) {
59
67
  return
60
68
  }
61
69
 
62
- Application.android.off(Application.android.activityRequestPermissionsEvent, onResult)
63
-
64
- resolve(args.grantResults?.[0] === android.content.pm.PackageManager.PERMISSION_GRANTED)
70
+ finish(args.grantResults?.[0] === android.content.pm.PackageManager.PERMISSION_GRANTED)
65
71
  }
66
72
 
73
+ // Same bound the session adapter uses — an activity destroyed or
74
+ // rotated before the result must not hang the caller or leak the
75
+ // listener.
76
+ const timer = setTimeout(() => finish(false), 120_000)
67
77
  Application.android.on(Application.android.activityRequestPermissionsEvent, onResult)
68
78
 
69
- activity.requestPermissions([permission], currentRequest)
79
+ try {
80
+ activity.requestPermissions([permission], currentRequest)
81
+ } catch {
82
+ finish(false)
83
+ }
70
84
  })
71
85
  }
72
86
  } catch {
@@ -87,7 +87,10 @@ export async function startCameraPreview(
87
87
  const element = view?.nativeViewProtected ?? view
88
88
  element.Source = capture
89
89
  try {
90
- if (Number(device?.EnclosureLocation?.Panel) === 1 && typeof capture.SetPreviewMirroring === 'function') {
90
+ if (
91
+ Number(device?.EnclosureLocation?.Panel) === 1 &&
92
+ typeof capture.SetPreviewMirroring === 'function'
93
+ ) {
91
94
  capture.SetPreviewMirroring(true)
92
95
  }
93
96
  } catch {
@@ -26,6 +26,19 @@ export interface CameraHostServices {
26
26
  }): Promise<{ info: { exists: boolean; fileUrl?: string; size?: number } | null }>
27
27
  readMovieFile(options: { fileUrl: string }): Promise<{ base64: string | null }>
28
28
  deleteMovieFile(options: { fileUrl: string }): Promise<{ removed: boolean }>
29
+ /** Optional chunked variants — a multi-hundred-MB movie must not
30
+ * round-trip through the bridge as one base64 payload. Absent on
31
+ * hosts built before the chunked contract; detected per client. */
32
+ writeMovieFileChunk?(options: {
33
+ fileUrl: string
34
+ base64: string
35
+ final?: boolean
36
+ }): Promise<{ file: { name: string; uri: string } | { ok: boolean } | null }>
37
+ readMovieFileChunk?(options: {
38
+ fileUrl: string
39
+ offset: number
40
+ length: number
41
+ }): Promise<{ base64: string | null }>
29
42
  }
30
43
  }
31
44
 
@@ -44,40 +57,49 @@ export type CameraHost = HostClient<CameraHostServices>
44
57
 
45
58
  let hostProbe: Promise<CameraHost | null> | undefined
46
59
 
47
- /** The camera-capable desktop host client, or null outside a qualified host. */
48
- export function cameraHost(): Promise<CameraHost | null> {
49
- hostProbe ??= (async () => {
50
- try {
51
- const transport = createWebKitTransport()
52
- if (!transport) {
53
- return null
54
- }
55
-
56
- const client = createHostClient<CameraHostServices>(transport)
57
- const capabilities = (await client.capabilities().catch(() => null)) as {
58
- camera?: string[]
59
- } | null
60
+ const probeHost = async (): Promise<CameraHost | null> => {
61
+ try {
62
+ const transport = createWebKitTransport()
63
+ if (!transport) {
64
+ return null
65
+ }
60
66
 
61
- const methods = capabilities?.camera
62
- if (Array.isArray(methods) && requiredMethods.every((name) => methods.includes(name))) {
63
- return client
64
- }
67
+ const client = createHostClient<CameraHostServices>(transport)
68
+ const capabilities = (await client.capabilities().catch(() => null)) as {
69
+ camera?: string[]
70
+ } | null
65
71
 
66
- client.dispose()
67
- return null
68
- } catch {
69
- return null
72
+ const methods = capabilities?.camera
73
+ if (Array.isArray(methods) && requiredMethods.every((name) => methods.includes(name))) {
74
+ return client
70
75
  }
71
- })()
72
76
 
73
- return hostProbe
77
+ client.dispose()
78
+ return null
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ /** The camera-capable desktop host client, or null outside a qualified host.
85
+ * Only a confirmed client stays cached — a null result may be a transient
86
+ * transport or capabilities failure, so the next call probes again rather
87
+ * than sticking 'absent' for the page lifetime. */
88
+ export async function cameraHost(): Promise<CameraHost | null> {
89
+ hostProbe ??= probeHost()
90
+ const host = await hostProbe
91
+ if (!host) {
92
+ hostProbe = probeHost()
93
+ return hostProbe
94
+ }
95
+
96
+ return host
74
97
  }
75
98
 
76
99
  const error = (kind: 'destinationUnavailable' | 'storageFailed' | 'unavailable', message: string) =>
77
100
  new CameraCaptureError({ kind, operation: 'storage', message })
78
101
 
79
- const blobToBase64 = async (bytes: Blob): Promise<string> => {
80
- const data = new Uint8Array(await bytes.arrayBuffer())
102
+ const dataToBase64 = (data: Uint8Array): string => {
81
103
  let binary = ''
82
104
  const chunk = 0x8000
83
105
  for (let offset = 0; offset < data.length; offset += chunk) {
@@ -87,6 +109,9 @@ const blobToBase64 = async (bytes: Blob): Promise<string> => {
87
109
  return btoa(binary)
88
110
  }
89
111
 
112
+ const blobToBase64 = async (bytes: Blob): Promise<string> =>
113
+ dataToBase64(new Uint8Array(await bytes.arrayBuffer()))
114
+
90
115
  const base64ToBlob = (base64: string, type: string): Blob => {
91
116
  const binary = atob(base64)
92
117
  const data = new Uint8Array(binary.length)
@@ -127,12 +152,64 @@ export const reserveHostMovie = async (
127
152
  return reservation
128
153
  }
129
154
 
130
- /** Commit finalized movie bytes to host-side app-private storage. */
155
+ /** Bytes bridged per call when the host exposes the chunked movie contract —
156
+ * large enough to amortize IPC, small enough to keep peak copies bounded. */
157
+ const MOVIE_CHUNK_BYTES = 4 * 1024 * 1024
158
+
159
+ const chunkedSupport = new WeakMap<CameraHost, Promise<boolean>>()
160
+
161
+ /** A host built before the chunked contract lacks the methods entirely —
162
+ * the capabilities probe answers that without a probe call. */
163
+ const supportsChunkedMovies = (host: CameraHost): Promise<boolean> => {
164
+ let probe = chunkedSupport.get(host)
165
+ if (!probe) {
166
+ probe = host
167
+ .capabilities()
168
+ .then((capabilities) => {
169
+ const methods = (capabilities as { camera?: string[] }).camera ?? []
170
+ return methods.includes('writeMovieFileChunk') && methods.includes('readMovieFileChunk')
171
+ })
172
+ .catch(() => false)
173
+
174
+ chunkedSupport.set(host, probe)
175
+ }
176
+
177
+ return probe
178
+ }
179
+
180
+ /** Commit finalized movie bytes to host-side app-private storage. Hosts
181
+ * with the chunked contract stream 4MB slices — a multi-hundred-MB
182
+ * recording no longer materializes ~3-4x its size across the bridge. */
131
183
  export const commitHostMovie = async (
132
184
  host: CameraHost,
133
185
  fileUrl: string,
134
186
  bytes: Blob,
135
187
  ): Promise<MovieOutput> => {
188
+ if (await supportsChunkedMovies(host)) {
189
+ for (let offset = 0; ; offset += MOVIE_CHUNK_BYTES) {
190
+ const slice = bytes.slice(offset, offset + MOVIE_CHUNK_BYTES)
191
+ const final = offset + MOVIE_CHUNK_BYTES >= bytes.size
192
+ const { file } = await host.call('camera', 'writeMovieFileChunk', {
193
+ fileUrl,
194
+ base64: dataToBase64(new Uint8Array(await slice.arrayBuffer())),
195
+ final,
196
+ })
197
+
198
+ if (final) {
199
+ const committed = file && 'uri' in file ? file : null
200
+ if (!committed?.uri) {
201
+ throw error('storageFailed', 'The host could not write the recorded movie')
202
+ }
203
+
204
+ return { kind: 'nativeFile', resourceId: committed.uri, fileUrl: committed.uri }
205
+ }
206
+
207
+ if (!file || !('ok' in file) || file.ok !== true) {
208
+ throw error('storageFailed', 'The host could not write a recorded movie chunk')
209
+ }
210
+ }
211
+ }
212
+
136
213
  const base64 = await blobToBase64(bytes)
137
214
  const { file } = await host.call('camera', 'writeMovieFile', { fileUrl, base64 })
138
215
  if (!file?.uri) {
@@ -166,6 +243,44 @@ export const openHostMovie = async (
166
243
  throw error('unavailable', `Movie file is missing at ${output.fileUrl}`)
167
244
  }
168
245
 
246
+ const size = Number(info.size ?? -1)
247
+ if (size > 0 && (await supportsChunkedMovies(host))) {
248
+ const data = new Uint8Array(size)
249
+ for (let offset = 0; offset < size; offset += MOVIE_CHUNK_BYTES) {
250
+ const { base64: chunk } = await host.call('camera', 'readMovieFileChunk', {
251
+ fileUrl: output.fileUrl,
252
+ offset,
253
+ length: Math.min(MOVIE_CHUNK_BYTES, size - offset),
254
+ })
255
+
256
+ if (chunk == null) {
257
+ throw error('unavailable', `Movie file could not be read at ${output.fileUrl}`)
258
+ }
259
+
260
+ let decoded: string
261
+ try {
262
+ decoded = atob(chunk)
263
+ } catch {
264
+ throw error('unavailable', 'The host returned invalid movie bytes')
265
+ }
266
+
267
+ if (decoded.length !== Math.min(MOVIE_CHUNK_BYTES, size - offset)) {
268
+ throw error('unavailable', 'The stored movie changed or could not be read completely')
269
+ }
270
+
271
+ for (let index = 0; index < decoded.length; index += 1) {
272
+ data[offset + index] = decoded.charCodeAt(index)
273
+ }
274
+ }
275
+
276
+ const url = URL.createObjectURL(new Blob([data], { type: mimeForFileUrl(output.fileUrl) }))
277
+ return {
278
+ url,
279
+ fileUrl: output.fileUrl,
280
+ release: () => URL.revokeObjectURL(url),
281
+ }
282
+ }
283
+
169
284
  const { base64 } = await host.call('camera', 'readMovieFile', { fileUrl: output.fileUrl })
170
285
  if (!base64) {
171
286
  throw error('unavailable', `Movie file could not be read at ${output.fileUrl}`)
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  CameraOrientation,
13
13
  CameraPermissionKind,
14
14
  CameraPermissionStatus,
15
+ CameraPreviewPoint,
15
16
  CameraProfileCapability,
16
17
  CameraRecorderState,
17
18
  CameraSelection,
package/src/index.web.ts CHANGED
@@ -10,6 +10,7 @@ export type {
10
10
  CameraOrientation,
11
11
  CameraPermissionKind,
12
12
  CameraPermissionStatus,
13
+ CameraPreviewPoint,
13
14
  CameraProfileCapability,
14
15
  CameraRecorderState,
15
16
  CameraSelection,
@@ -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 CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Octane } from 'octane/jsx-runtime'
2
- import type { CameraSession } from './types'
2
+ import type { CameraPreviewPoint, CameraSession } from './types'
3
3
 
4
4
  export type CameraAccessibilityRole =
5
5
  | 'button'
@@ -60,6 +60,13 @@ export interface CameraViewProps {
60
60
  /** Fires after the preview surface starts. */
61
61
  onReady?: () => void
62
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
63
70
  /** Native preview surface for platform-specific controls. */
64
71
  ref?: Octane.Ref<CameraViewHandle>
65
72
  /** Platform-specific properties applied to the preview surface. */
@@ -1,5 +1,6 @@
1
1
  import { Application } from '@nativescript/core'
2
2
  import { CameraCaptureError } from './types'
3
+ import { canonicalizePath } from './session-core'
3
4
  import type {
4
5
  BackendCaptureSink,
5
6
  BackendObserver,
@@ -235,6 +236,11 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
235
236
  cameras: value.cameras,
236
237
  profiles: value.profiles,
237
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,
238
244
  orientations: [],
239
245
  durationLimit: 'native',
240
246
  elapsedTime: 'nativeMedia',
@@ -339,8 +345,44 @@ export function createSessionBackend(config: CameraSessionConfig): CameraSession
339
345
  bridge.attach(host, selected())
340
346
  }
341
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
+ },
342
355
  startCapture: (request, target) => {
343
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
+
344
386
  try {
345
387
  bridge.start(
346
388
  request.audio,