@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,126 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
+ import { View } from '@nativescript/core'
4
+ import { registerElement } from '@nativescript-community/octane'
5
+ import type { Attributes } from '@nativescript-community/octane/intrinsics'
6
+ import type { CameraViewProps } from './props'
7
+ import { attachSessionPreview } from './session'
8
+ import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.windows'
9
+ import { startCameraPreview } from './camera-session.windows'
10
+ import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child.windows'
11
+ import { observePreviewTap } from './preview-tap'
12
+
13
+ class CameraPreview extends View {
14
+ createNativeView() {
15
+ const { Microsoft } = globalThis as any
16
+ return new Microsoft.UI.Xaml.Controls.CaptureElement()
17
+ }
18
+ }
19
+
20
+ declare module '@nativescript-community/octane/intrinsics' {
21
+ interface NativeScriptElements {
22
+ camerapreview: Attributes<typeof CameraPreview>
23
+ }
24
+ }
25
+
26
+ registerElement('camerapreview', CameraPreview)
27
+
28
+ /** Live Windows preview backed by WinUI `CaptureElement` + `MediaCapture`.
29
+ * With `session`, the preview attaches to the session-owned capture so
30
+ * movie recording shares it; without one the view owns its capture. */
31
+ export function CameraView(props: CameraViewProps) @{
32
+ const host = useRef<any>(null)
33
+ const active = props.active !== false
34
+ const facing = props.facing ?? 'back'
35
+ useImperativeHandle(props.ref, () => ({ native: host.current?.nativeViewProtected }), [])
36
+
37
+ const bindHost = useCallback((view: any) => {
38
+ host.current = view
39
+ }, [])
40
+
41
+ useEffect(() => {
42
+ const view = host.current
43
+ if (!view || !active) return
44
+ let disposed = false
45
+ let stopPreview: (() => Promise<void>) | undefined
46
+ const fail = (message: string) => props.onError?.({ message })
47
+
48
+ if (props.session) {
49
+ if (props.facing !== undefined) {
50
+ fail('camera session conflicts with facing')
51
+ return
52
+ }
53
+
54
+ try {
55
+ const detach = attachSessionPreview(props.session, view, {
56
+ onReady: () => {
57
+ if (!disposed) props.onReady?.()
58
+ },
59
+ onError: (issue) => {
60
+ if (!disposed) fail(issue.message)
61
+ },
62
+ })
63
+
64
+ return () => {
65
+ disposed = true
66
+ detach()
67
+ }
68
+ } catch (error: any) {
69
+ fail(error?.message ?? 'camera session error')
70
+ return
71
+ }
72
+ }
73
+
74
+ if (!cameraHardwarePresent()) {
75
+ fail('camera unsupported')
76
+ return
77
+ }
78
+
79
+ ensureCameraPermission().then(async (granted) => {
80
+ if (disposed) return
81
+ if (!granted) {
82
+ fail('camera permission denied')
83
+ return
84
+ }
85
+
86
+ try {
87
+ stopPreview = await startCameraPreview(view, facing, () => {
88
+ if (!disposed) props.onReady?.()
89
+ })
90
+ } catch (error: any) {
91
+ if (!disposed) fail(error?.message ?? 'camera error')
92
+ }
93
+ })
94
+
95
+ return () => {
96
+ disposed = true
97
+ void stopPreview?.()
98
+ }
99
+ }, [active, facing, props.session])
100
+
101
+ useEffect(() => {
102
+ const view = host.current
103
+ if (!view) return
104
+ applyEscapeProps(view.nativeViewProtected, props)
105
+ }, [props.ref, props.windows])
106
+
107
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
108
+
109
+ <gridlayout
110
+ id={props.id}
111
+ rows="*"
112
+ columns="*"
113
+ className={['vx-layers', 'vx-cameraview', props.className]}
114
+ style={{ backgroundColor: '#000', ...props.style }}
115
+ {...layoutChildProps(props)}
116
+ accessible={props.accessible}
117
+ accessibilityLabel={props.accessibilityLabel}
118
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
119
+ accessibilityHint={props.accessibilityHint}
120
+ accessibilityValue={props.accessibilityValue}
121
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
122
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
123
+ >
124
+ <camerapreview ref={bindHost} row={0} col={0} />
125
+ </gridlayout>
126
+ }
@@ -0,0 +1,91 @@
1
+ import { Application, isAndroid, isIOS } from '@nativescript/core'
2
+ import { android } from '@octane-xplat/platform/android'
3
+
4
+ let requestCode = 7719
5
+
6
+ export function cameraHardwarePresent(): boolean {
7
+ if (isIOS) {
8
+ return typeof (globalThis as any).AVCaptureDevice !== 'undefined'
9
+ }
10
+
11
+ if (!isAndroid) {
12
+ return false
13
+ }
14
+
15
+ try {
16
+ const context = Application.android?.context ?? Application.android?.startActivity
17
+ return context?.getPackageManager()?.hasSystemFeature('android.hardware.camera.any') !== false
18
+ } catch {
19
+ return true
20
+ }
21
+ }
22
+
23
+ export async function ensureCameraPermission(): Promise<boolean> {
24
+ try {
25
+ if (isIOS) {
26
+ const av = (globalThis as any).AVCaptureDevice
27
+ const mediaType = (globalThis as any).AVMediaTypeVideo ?? 'vide'
28
+ const status = av?.authorizationStatusForMediaType?.(mediaType)
29
+ if (status === 3) {
30
+ return true
31
+ }
32
+
33
+ if (status === 1 || status === 2) {
34
+ return false
35
+ }
36
+
37
+ return await new Promise<boolean>((resolve) =>
38
+ av.requestAccessForMediaTypeCompletionHandler(mediaType, resolve),
39
+ )
40
+ }
41
+
42
+ if (isAndroid) {
43
+ const permission = 'android.permission.CAMERA'
44
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity
45
+ if (
46
+ activity?.checkSelfPermission?.(permission) ===
47
+ android.content.pm.PackageManager.PERMISSION_GRANTED
48
+ ) {
49
+ return true
50
+ }
51
+
52
+ if (!activity) {
53
+ return false
54
+ }
55
+
56
+ return await new Promise<boolean>((resolve) => {
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
+
65
+ const onResult = (args: any) => {
66
+ if (args.requestCode !== currentRequest) {
67
+ return
68
+ }
69
+
70
+ finish(args.grantResults?.[0] === android.content.pm.PackageManager.PERMISSION_GRANTED)
71
+ }
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)
77
+ Application.android.on(Application.android.activityRequestPermissionsEvent, onResult)
78
+
79
+ try {
80
+ activity.requestPermissions([permission], currentRequest)
81
+ } catch {
82
+ finish(false)
83
+ }
84
+ })
85
+ }
86
+ } catch {
87
+ return false
88
+ }
89
+
90
+ return false
91
+ }
@@ -0,0 +1,76 @@
1
+ /** Windows camera-permission probe for preview-only `CameraView` callers.
2
+ * Uses `AppCapability` when projected and falls back to
3
+ * `DeviceAccessInformation`; both are non-prompting checks — only
4
+ * `RequestAccessAsync` may show the system prompt. */
5
+
6
+ const windows = () => globalThis as any
7
+
8
+ const toPromise = (op: any): Promise<any> => {
9
+ const { NSWinRT } = windows()
10
+ if (NSWinRT?.toPromise && op != null) {
11
+ return NSWinRT.toPromise(op)
12
+ }
13
+
14
+ if (op?.done) {
15
+ return new Promise((resolve, reject) => {
16
+ try {
17
+ op.done(resolve, reject)
18
+ } catch (cause) {
19
+ reject(cause)
20
+ }
21
+ })
22
+ }
23
+
24
+ return Promise.resolve(op)
25
+ }
26
+
27
+ export function cameraHardwarePresent(): boolean {
28
+ const { Windows, Microsoft } = windows()
29
+ return !!(
30
+ Windows?.Media?.Capture?.MediaCapture &&
31
+ Windows?.Devices?.Enumeration?.DeviceInformation &&
32
+ Microsoft?.UI?.Xaml?.Controls?.CaptureElement
33
+ )
34
+ }
35
+
36
+ export async function ensureCameraPermission(): Promise<boolean> {
37
+ try {
38
+ const { Windows } = windows()
39
+ const capability =
40
+ Windows?.Security?.Authorization?.AppCapabilityAccess?.AppCapability?.Create?.('webcam')
41
+
42
+ if (capability) {
43
+ const status = capability.CheckAccess()
44
+ // AppCapabilityAccessStatus: DeniedBySystem=0, NotDeclaredByApp=1,
45
+ // DeniedByUser=2, UserPromptRequired=3, Allowed=4.
46
+ if (status === 4) {
47
+ return true
48
+ }
49
+
50
+ if (status === 3) {
51
+ return (await toPromise(capability.RequestAccessAsync())) === 4
52
+ }
53
+
54
+ return false
55
+ }
56
+
57
+ const enumeration = Windows?.Devices?.Enumeration
58
+ const deviceClass = enumeration?.DeviceClass?.VideoCapture
59
+ const info =
60
+ deviceClass === undefined
61
+ ? undefined
62
+ : enumeration?.DeviceAccessInformation?.CreateFromDeviceClass(deviceClass)
63
+
64
+ if (info) {
65
+ // DeviceAccessStatus: Unspecified=0, Allowed=1, DeniedByUser=2,
66
+ // DeniedBySystem=3. The enumeration API cannot prompt — an
67
+ // Unspecified outcome is resolved by the preview's own
68
+ // InitializeAsync, which runs the OS consent flow.
69
+ return info.CurrentStatus !== 2 && info.CurrentStatus !== 3
70
+ }
71
+ } catch {
72
+ return false
73
+ }
74
+
75
+ return false
76
+ }
@@ -0,0 +1,65 @@
1
+ /** Attach an AVFoundation preview layer to a NativeScript UIView host. */
2
+ export function startCameraPreview(
3
+ view: any,
4
+ facing: 'front' | 'back',
5
+ onReady?: () => void,
6
+ ): () => void {
7
+ const av = globalThis as any
8
+ const mediaType = av.AVMediaTypeVideo ?? 'vide'
9
+ const desiredPosition =
10
+ facing === 'front'
11
+ ? (av.AVCaptureDevicePositionFront ?? 2)
12
+ : (av.AVCaptureDevicePositionBack ?? 1)
13
+
14
+ const devices = av.AVCaptureDevice.devicesWithMediaType(mediaType)
15
+ let device: any = null
16
+ for (let index = 0; index < devices.count; index++) {
17
+ const candidate = devices.objectAtIndex(index)
18
+ if (candidate.position === desiredPosition) {
19
+ device = candidate
20
+ break
21
+ }
22
+ }
23
+
24
+ if (!device) {
25
+ throw new Error('camera unavailable')
26
+ }
27
+
28
+ const session = av.AVCaptureSession.new()
29
+ const input = av.AVCaptureDeviceInput.deviceInputWithDeviceError(device)
30
+ if (!input || !session.canAddInput(input)) {
31
+ throw new Error('camera input unavailable')
32
+ }
33
+
34
+ session.addInput(input)
35
+ const layer = av.AVCaptureVideoPreviewLayer.layerWithSession(session)
36
+ layer.videoGravity = 'resizeAspectFill'
37
+ const connection = layer.connection
38
+ if (connection) {
39
+ connection.automaticallyAdjustsVideoMirroring = false
40
+ if (connection.isVideoMirroringSupported) {
41
+ connection.isVideoMirrored = facing === 'front'
42
+ }
43
+ }
44
+
45
+ const nativeView = view.ios ?? view.nativeViewProtected
46
+ if (!nativeView?.layer) {
47
+ throw new Error('camera preview host unavailable')
48
+ }
49
+
50
+ nativeView.layer.addSublayer(layer)
51
+ const updateFrame = () => {
52
+ layer.frame = nativeView.bounds
53
+ }
54
+
55
+ view.on?.('layoutChanged', updateFrame)
56
+ updateFrame()
57
+ session.startRunning()
58
+ onReady?.()
59
+
60
+ return () => {
61
+ view.off?.('layoutChanged', updateFrame)
62
+ session.stopRunning()
63
+ layer.removeFromSuperlayer()
64
+ }
65
+ }
@@ -0,0 +1,75 @@
1
+ import '@nativescript/macos-node-api'
2
+
3
+ interface CameraHost {
4
+ attachPreview(view: object): string | null
5
+ detachPreview(): void
6
+ installObserver(emit: (message: string) => void): void
7
+ permissionStatus(kind: string): string
8
+ requestPermissionReply(kind: string, reply: (status: string) => void): void
9
+ devices(): string
10
+ audioDeviceAvailable(): boolean
11
+ configure(options: { cameraId?: string; audio?: boolean; rotationAngle?: number }): string | null
12
+ dispose(): void
13
+ }
14
+
15
+ declare const XplatCameraHost: {
16
+ alloc(): { init(): CameraHost }
17
+ }
18
+
19
+ let helper: CameraHost | undefined
20
+ const helperHost = () => (helper ??= XplatCameraHost.alloc().init())
21
+
22
+ export function cameraHardwarePresent(): boolean {
23
+ try {
24
+ return JSON.parse(helperHost().devices()).length > 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ /** Preview-only permission entry point: prompts once from mount, mirrors the
31
+ * iOS legacy path. Session code should use `session.requestPermission` —
32
+ * explicit actions only. */
33
+ export function ensureCameraPermission(): Promise<boolean> {
34
+ const status = helperHost().permissionStatus('camera')
35
+ if (status === 'granted') {
36
+ return Promise.resolve(true)
37
+ }
38
+
39
+ if (status === 'undeclared') {
40
+ return Promise.resolve(false)
41
+ }
42
+
43
+ return new Promise((resolve) =>
44
+ helperHost().requestPermissionReply('camera', (next) => resolve(next === 'granted')),
45
+ )
46
+ }
47
+
48
+ /** Legacy preview-only path: a dedicated XplatCameraHost owns the preview
49
+ * session — shared with nothing, disposed with the view. The session-backed
50
+ * path goes through `attachSessionPreview` instead. */
51
+ export function startCameraPreview(
52
+ view: object,
53
+ _facing: 'front' | 'back',
54
+ onReady?: () => void,
55
+ ): () => void {
56
+ const host = XplatCameraHost.alloc().init()
57
+ host.installObserver(() => {})
58
+ const configured = host.configure({ audio: false })
59
+ if (configured) {
60
+ host.dispose()
61
+ throw new Error(`camera unavailable: ${configured}`)
62
+ }
63
+
64
+ const failure = host.attachPreview(view)
65
+ if (failure) {
66
+ host.dispose()
67
+ throw new Error(`camera unavailable: ${failure}`)
68
+ }
69
+
70
+ onReady?.()
71
+ return () => {
72
+ host.detachPreview()
73
+ host.dispose()
74
+ }
75
+ }
@@ -0,0 +1,126 @@
1
+ /** Standalone (non-session) WinUI preview for `CameraView` without a
2
+ * `session` prop. Owns a private `MediaCapture` bound to a
3
+ * `CaptureElement`; the returned function stops preview and closes the
4
+ * device. Session-backed previews share their capture through
5
+ * `session-backend.windows.ts` instead. */
6
+
7
+ const windows = () => globalThis as any
8
+
9
+ const toPromise = (op: any): Promise<any> => {
10
+ const { NSWinRT } = windows()
11
+ if (NSWinRT?.toPromise && op != null) {
12
+ return NSWinRT.toPromise(op)
13
+ }
14
+
15
+ if (op?.done) {
16
+ return new Promise((resolve, reject) => {
17
+ try {
18
+ op.done(resolve, reject)
19
+ } catch (cause) {
20
+ reject(cause)
21
+ }
22
+ })
23
+ }
24
+
25
+ return Promise.resolve(op)
26
+ }
27
+
28
+ const vectorToArray = (items: any): any[] => {
29
+ const result: any[] = []
30
+ const size = Number(items?.Size ?? items?.Count ?? 0)
31
+ for (let index = 0; index < size; index += 1) {
32
+ result.push(items.GetAt(index))
33
+ }
34
+
35
+ return result
36
+ }
37
+
38
+ /** Attach a MediaCapture preview to the view's `CaptureElement`.
39
+ * `facing` maps to enclosure panels; desktop cameras without a panel fall
40
+ * back to the OS default — front/back is a preference, never a fabrication. */
41
+ export async function startCameraPreview(
42
+ view: any,
43
+ facing: 'front' | 'back',
44
+ onReady?: () => void,
45
+ ): Promise<() => Promise<void>> {
46
+ const { Windows } = windows()
47
+ const enumeration = Windows?.Devices?.Enumeration
48
+ const captureApi = Windows?.Media?.Capture
49
+ if (!enumeration || !captureApi) {
50
+ throw new Error('camera preview unsupported')
51
+ }
52
+
53
+ const found = await toPromise(
54
+ enumeration.DeviceInformation.FindAllAsync(enumeration.DeviceClass.VideoCapture),
55
+ )
56
+
57
+ const devices = vectorToArray(found)
58
+ if (!devices.length) {
59
+ throw new Error('camera unavailable')
60
+ }
61
+
62
+ const wantedPanel = facing === 'front' ? 1 : 2
63
+ const device =
64
+ devices.find((entry: any) => Number(entry?.EnclosureLocation?.Panel) === wantedPanel) ??
65
+ devices[0]
66
+
67
+ const settings = new captureApi.MediaCaptureInitializationSettings()
68
+ settings.VideoDeviceId = String(device?.Id ?? device?.id ?? '')
69
+ settings.StreamingCaptureMode = 0 // Video
70
+ const capture = new captureApi.MediaCapture()
71
+ try {
72
+ await toPromise(capture.InitializeAsync(settings))
73
+ } catch (cause) {
74
+ try {
75
+ capture.Close()
76
+ } catch {
77
+ try {
78
+ capture.Dispose()
79
+ } catch {
80
+ // GC reclaims the device handle.
81
+ }
82
+ }
83
+
84
+ throw cause instanceof Error ? cause : new Error('camera unavailable')
85
+ }
86
+
87
+ const element = view?.nativeViewProtected ?? view
88
+ element.Source = capture
89
+ try {
90
+ if (
91
+ Number(device?.EnclosureLocation?.Panel) === 1 &&
92
+ typeof capture.SetPreviewMirroring === 'function'
93
+ ) {
94
+ capture.SetPreviewMirroring(true)
95
+ }
96
+ } catch {
97
+ // Mirrored preview is cosmetic.
98
+ }
99
+
100
+ await toPromise(capture.StartPreviewAsync())
101
+ onReady?.()
102
+
103
+ return async () => {
104
+ try {
105
+ element.Source = null
106
+ } catch {
107
+ // The element may already be gone.
108
+ }
109
+
110
+ try {
111
+ await toPromise(capture.StopPreviewAsync())
112
+ } catch {
113
+ // The pipeline may already be down.
114
+ }
115
+
116
+ try {
117
+ capture.Close()
118
+ } catch {
119
+ try {
120
+ capture.Dispose()
121
+ } catch {
122
+ // GC reclaims the device handle.
123
+ }
124
+ }
125
+ }
126
+ }