@octane-xplat/camera 0.14.0 → 0.16.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 (83) 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 +8 -18
  10. package/src/CameraView.ios.tsrx +4 -17
  11. package/src/CameraView.macos.tsrx +4 -17
  12. package/src/CameraView.web.tsrx +11 -0
  13. package/src/CameraView.windows.tsrx +6 -18
  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/layout-child.ts +39 -18
  20. package/src/layout-child.windows.ts +5 -2
  21. package/src/preview-tap.ts +34 -0
  22. package/src/props.ts +8 -1
  23. package/src/session-backend.android.ts +42 -0
  24. package/src/session-backend.ios.ts +183 -17
  25. package/src/session-backend.linux.ts +22 -4
  26. package/src/session-backend.macos.ts +100 -17
  27. package/src/session-backend.ts +2 -0
  28. package/src/session-backend.web.ts +17 -0
  29. package/src/session-backend.windows.ts +88 -6
  30. package/src/session-core.ts +73 -9
  31. package/src/synthetic-session.ts +10 -0
  32. package/src/types.ts +28 -0
  33. package/types/props.d.ts +8 -1
  34. package/types/session-core.d.ts +11 -1
  35. package/types/synthetic-session.d.ts +4 -1
  36. package/types/types.d.ts +27 -0
  37. package/dist/linux/CameraView.web.tsrx.js +0 -337
  38. package/dist/linux/host-movie.web.js +0 -100
  39. package/dist/linux/index.js +0 -5
  40. package/dist/linux/layout-child.web.js +0 -29
  41. package/dist/linux/movie-metadata.web.js +0 -65
  42. package/dist/linux/octane-client-build.json +0 -8
  43. package/dist/linux/session-backend.linux.js +0 -479
  44. package/dist/linux/session-core.js +0 -368
  45. package/dist/linux/session.js +0 -29
  46. package/dist/linux/synthetic-session.js +0 -280
  47. package/dist/linux/types.js +0 -19
  48. package/dist/native/CameraView.android.js +0 -190
  49. package/dist/native/CameraView.ios.js +0 -154
  50. package/dist/native/CameraView.windows.js +0 -173
  51. package/dist/native/camera-permission.mobile.js +0 -45
  52. package/dist/native/camera-permission.windows.js +0 -43
  53. package/dist/native/camera-session.ios.js +0 -45
  54. package/dist/native/camera-session.windows.js +0 -78
  55. package/dist/native/cx.mobile.js +0 -9
  56. package/dist/native/cx.windows.js +0 -9
  57. package/dist/native/index.js +0 -5
  58. package/dist/native/layout-child.js +0 -53
  59. package/dist/native/layout-child.windows.js +0 -53
  60. package/dist/native/octane-client-build.json +0 -8
  61. package/dist/native/session-backend.android.js +0 -234
  62. package/dist/native/session-backend.ios.js +0 -682
  63. package/dist/native/session-backend.js +0 -55
  64. package/dist/native/session-backend.windows.js +0 -905
  65. package/dist/native/session-core.js +0 -368
  66. package/dist/native/session.js +0 -29
  67. package/dist/native/synthetic-session.js +0 -280
  68. package/dist/native/types.js +0 -19
  69. package/dist/web/CameraView.web.tsrx.js +0 -337
  70. package/dist/web/host-movie.web.js +0 -100
  71. package/dist/web/index.js +0 -5
  72. package/dist/web/layout-child.web.js +0 -29
  73. package/dist/web/movie-metadata.web.js +0 -65
  74. package/dist/web/movie-storage.web.js +0 -119
  75. package/dist/web/octane-client-build.json +0 -8
  76. package/dist/web/session-backend.web.js +0 -490
  77. package/dist/web/session-core.js +0 -368
  78. package/dist/web/session.js +0 -29
  79. package/dist/web/synthetic-session.js +0 -280
  80. package/dist/web/types.js +0 -19
  81. package/src/cx.macos.ts +0 -5
  82. package/src/cx.mobile.ts +0 -6
  83. 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,21 +128,9 @@ export function CameraView(props: CameraViewProps) @{
126
128
  applyEscapeProps(view.android, props)
127
129
  }, [props.ref, props.android])
128
130
 
129
- <gridlayout
130
- id={props.id}
131
- rows="*"
132
- columns="*"
133
- className={cx('vx-layers', 'vx-cameraview', props.className)}
134
- style={{ backgroundColor: '#000', ...props.style }}
135
- {...layoutChildProps(props)}
136
- accessible={props.accessible}
137
- accessibilityLabel={props.accessibilityLabel}
138
- accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
139
- accessibilityHint={props.accessibilityHint}
140
- accessibilityValue={props.accessibilityValue}
141
- accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
142
- accessibilityLiveRegion={props.accessibilityLiveRegion as any}
143
- >
144
- <camerapreview ref={bindHost} row={0} col={0} />
145
- </gridlayout>
131
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
132
+
133
+ <masonview id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}>
134
+ <camerapreview ref={bindHost} style={{gridRow: '1', gridColumn: '1'}} />
135
+ </masonview>
146
136
  }
@@ -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,20 +81,7 @@ export function CameraView(props: CameraViewProps) @{
81
81
  applyEscapeProps(view.ios ?? view.nativeViewProtected, props)
82
82
  }, [props.ref, props.ios])
83
83
 
84
- <gridlayout
85
- ref={bindHost}
86
- id={props.id}
87
- rows="*"
88
- columns="*"
89
- className={cx('vx-layers', 'vx-cameraview', props.className)}
90
- style={{ backgroundColor: '#000', ...props.style }}
91
- {...layoutChildProps(props)}
92
- accessible={props.accessible}
93
- accessibilityLabel={props.accessibilityLabel}
94
- accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
95
- accessibilityHint={props.accessibilityHint}
96
- accessibilityValue={props.accessibilityValue}
97
- accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
98
- accessibilityLiveRegion={props.accessibilityLiveRegion as any}
99
- />
84
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
85
+
86
+ <masonview ref={bindHost} id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}/>
100
87
  }
@@ -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,20 +74,7 @@ export function CameraView(props: CameraViewProps) @{
74
74
  }
75
75
  }, [active, facing, props.session])
76
76
 
77
- <gridlayout
78
- ref={bindHost}
79
- id={props.id}
80
- rows="*"
81
- columns="*"
82
- className={cx('vx-layers', 'vx-cameraview', props.className)}
83
- style={{ backgroundColor: '#000000', ...props.style }}
84
- {...layoutChildProps(props)}
85
- accessible={props.accessible}
86
- accessibilityLabel={props.accessibilityLabel}
87
- accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
88
- accessibilityHint={props.accessibilityHint}
89
- accessibilityValue={props.accessibilityValue}
90
- accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
91
- accessibilityLiveRegion={props.accessibilityLiveRegion as any}
92
- />
77
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
78
+
79
+ <masonview ref={bindHost} id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}/>
93
80
  }
@@ -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,21 +104,9 @@ export function CameraView(props: CameraViewProps) @{
104
104
  applyEscapeProps(view.nativeViewProtected, props)
105
105
  }, [props.ref, props.windows])
106
106
 
107
- <gridlayout
108
- id={props.id}
109
- rows="*"
110
- columns="*"
111
- className={cx('vx-layers', 'vx-cameraview', props.className)}
112
- style={{ backgroundColor: '#000', ...props.style }}
113
- {...layoutChildProps(props)}
114
- accessible={props.accessible}
115
- accessibilityLabel={props.accessibilityLabel}
116
- accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
117
- accessibilityHint={props.accessibilityHint}
118
- accessibilityValue={props.accessibilityValue}
119
- accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
120
- accessibilityLiveRegion={props.accessibilityLiveRegion as any}
121
- >
122
- <camerapreview ref={bindHost} row={0} col={0} />
123
- </gridlayout>
107
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
108
+
109
+ <masonview id={props.id} className={['vx-layers', 'vx-cameraview', props.className]} {...layoutChildProps(props, {display: "grid", gridTemplateRows: "1fr", gridTemplateColumns: "1fr", ...({ backgroundColor: '#000', ...props.style })})} accessible={props.accessible} accessibilityLabel={props.accessibilityLabel} accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any} accessibilityHint={props.accessibilityHint} accessibilityValue={props.accessibilityValue} accessibilityState={nativeAccessibilityState(props.accessibilityState) as any} accessibilityLiveRegion={props.accessibilityLiveRegion as any}>
110
+ <camerapreview ref={bindHost} style={{gridRow: '1', gridColumn: '1'}} />
111
+ </masonview>
124
112
  }
@@ -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,
@@ -2,27 +2,48 @@ import type { CameraViewProps } from './props'
2
2
  import { isIOS } from '@nativescript/core'
3
3
 
4
4
  /** NativeScript reads layout fields from a child view during parent layout. */
5
- export function layoutChildProps(props: CameraViewProps): Record<string, any> {
6
- const result: Record<string, any> = {}
7
- for (const key of [
8
- 'row',
9
- 'col',
10
- 'rowSpan',
11
- 'colSpan',
12
- 'dock',
13
- 'left',
14
- 'top',
15
- 'flexGrow',
16
- 'flexShrink',
17
- 'alignSelf',
18
- 'order',
19
- ] as const) {
5
+ export function layoutChildProps(
6
+ props: CameraViewProps,
7
+ baseStyle = props.style,
8
+ ): { style: Record<string, any> } {
9
+ const style: Record<string, any> = { ...(typeof baseStyle === 'object' ? baseStyle : {}) }
10
+ if (props.row !== undefined) {
11
+ style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
12
+ } else if (props.rowSpan !== undefined) {
13
+ style.gridRow = `span ${props.rowSpan}`
14
+ }
15
+ if (props.col !== undefined) {
16
+ style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
17
+ } else if (props.colSpan !== undefined) {
18
+ style.gridColumn = `span ${props.colSpan}`
19
+ }
20
+ if (props.dock !== undefined) {
21
+ style.position = style.position ?? 'absolute'
22
+ if (props.dock === 'left' || props.dock === 'top' || props.dock === 'bottom') {
23
+ style.left = style.left ?? 0
24
+ }
25
+
26
+ if (props.dock === 'right' || props.dock === 'top' || props.dock === 'bottom') {
27
+ style.right = style.right ?? 0
28
+ }
29
+
30
+ if (props.dock === 'top' || props.dock === 'left' || props.dock === 'right') {
31
+ style.top = style.top ?? 0
32
+ }
33
+
34
+ if (props.dock === 'bottom' || props.dock === 'left' || props.dock === 'right') {
35
+ style.bottom = style.bottom ?? 0
36
+ }
37
+ }
38
+ for (const key of ['left', 'top', 'flexGrow', 'flexShrink', 'alignSelf', 'order'] as const) {
20
39
  if (props[key] !== undefined) {
21
- result[key] = props[key]
40
+ style[key] = props[key]
22
41
  }
23
42
  }
24
-
25
- return result
43
+ if ((props.left !== undefined || props.top !== undefined) && props.dock === undefined) {
44
+ style.position = style.position ?? 'absolute'
45
+ }
46
+ return { style }
26
47
  }
27
48
 
28
49
  export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
@@ -5,8 +5,11 @@ import type { CameraViewProps } from './props'
5
5
  * branch. Pure helpers are re-declared here to keep divergence at the file
6
6
  * boundary. */
7
7
 
8
- export function layoutChildProps(props: CameraViewProps): Record<string, any> {
9
- const result: Record<string, any> = {}
8
+ export function layoutChildProps(
9
+ props: CameraViewProps,
10
+ baseStyle = props.style,
11
+ ): Record<string, any> {
12
+ const result: Record<string, any> = { style: baseStyle }
10
13
  for (const key of [
11
14
  'row',
12
15
  'col',
@@ -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,