@octane-xplat/camera 0.7.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.
@@ -0,0 +1,73 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useCallback, useEffect, useRef } from 'octane'
3
+ import type { CameraViewProps } from './props'
4
+ import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
5
+ import { startCameraPreview } from './camera-session.ios'
6
+ import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
7
+ import { cx } from './cx.mobile'
8
+
9
+ /** Live iOS preview backed by AVFoundation. */
10
+ export function CameraView(props: CameraViewProps) @{
11
+ const host = useRef<any>(null)
12
+ const active = props.active !== false
13
+ const facing = props.facing ?? 'back'
14
+ const bindHost = useCallback((view: any) => {
15
+ host.current = view
16
+ }, [])
17
+
18
+ useEffect(() => {
19
+ const view = host.current
20
+ if (!view || !active) return
21
+ let disposed = false
22
+ let stopPreview: (() => void) | undefined
23
+ const fail = (message: string) => props.onError?.({ message })
24
+
25
+ if (!cameraHardwarePresent()) {
26
+ fail('camera unsupported')
27
+ return
28
+ }
29
+
30
+ ensureCameraPermission().then((granted) => {
31
+ if (disposed) return
32
+ if (!granted) {
33
+ fail('camera permission denied')
34
+ return
35
+ }
36
+
37
+ try {
38
+ stopPreview = startCameraPreview(view, facing, props.onReady)
39
+ } catch (error: any) {
40
+ fail(error?.message ?? 'camera error')
41
+ }
42
+ })
43
+
44
+ return () => {
45
+ disposed = true
46
+ stopPreview?.()
47
+ }
48
+ }, [active, facing])
49
+
50
+ useEffect(() => {
51
+ const view = host.current
52
+ if (!view) return
53
+ applyEscapeProps(view.ios ?? view.nativeViewProtected, props)
54
+ props.bind?.({ native: view.ios ?? view.nativeViewProtected })
55
+ }, [props.bind, props.ios])
56
+
57
+ <gridlayout
58
+ ref={bindHost}
59
+ id={props.id}
60
+ rows="*"
61
+ columns="*"
62
+ className={cx('vx-stack', 'vx-cameraview', props.className)}
63
+ style={{ backgroundColor: '#000', ...props.style }}
64
+ {...layoutChildProps(props)}
65
+ accessible={props.accessible}
66
+ accessibilityLabel={props.accessibilityLabel}
67
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
68
+ accessibilityHint={props.accessibilityHint}
69
+ accessibilityValue={props.accessibilityValue}
70
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
71
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
72
+ />
73
+ }
@@ -0,0 +1,6 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { CameraViewProps } from './props'
3
+
4
+ /** TypeScript fallback; the NativeScript resolver selects the .ios/.android
5
+ * implementation first at runtime. */
6
+ export declare const CameraView: UniversalComponent<CameraViewProps>
@@ -0,0 +1,100 @@
1
+ import { useEffect, useRef } from 'octane'
2
+ import type { CameraViewProps } from './props'
3
+ import { applyEscapeProps, layoutChildProps } from './layout-child.web'
4
+
5
+ /** Live web preview backed by getUserMedia. */
6
+ export function CameraView(props: CameraViewProps) @{
7
+ const video = useRef<HTMLVideoElement | null>(null)
8
+ const facing = props.facing ?? 'back'
9
+ const active = props.active !== false
10
+ const readyFired = useRef(false)
11
+
12
+ useEffect(() => {
13
+ const element = video.current
14
+ if (!element || !active) return
15
+ const mediaDevices = navigator.mediaDevices
16
+ if (!mediaDevices?.getUserMedia) {
17
+ props.onError?.({ message: 'camera unsupported' })
18
+ return
19
+ }
20
+
21
+ let stream: MediaStream | null = null
22
+ let disposed = false
23
+ readyFired.current = false
24
+ mediaDevices
25
+ .getUserMedia({
26
+ video: { facingMode: facing === 'front' ? 'user' : 'environment' },
27
+ audio: false,
28
+ })
29
+ .then((nextStream) => {
30
+ if (disposed) {
31
+ for (const track of nextStream.getTracks()) track.stop()
32
+ return
33
+ }
34
+
35
+ stream = nextStream
36
+ element.srcObject = nextStream
37
+ element.play()?.catch(() => {})
38
+ })
39
+ .catch((error: any) => {
40
+ props.onError?.({
41
+ message:
42
+ error?.name === 'NotAllowedError'
43
+ ? 'camera permission denied'
44
+ : String(error?.name ?? error ?? 'camera error'),
45
+ })
46
+ })
47
+
48
+ return () => {
49
+ disposed = true
50
+ element.srcObject = null
51
+ if (stream) for (const track of stream.getTracks()) track.stop()
52
+ }
53
+ }, [active, facing])
54
+
55
+ <div
56
+ id={props.id}
57
+ className={['vx-stack', 'vx-cameraview', props.className]}
58
+ {...layoutChildProps(props, {
59
+ position: 'relative',
60
+ overflow: 'hidden',
61
+ backgroundColor: '#000',
62
+ ...props.style,
63
+ })}
64
+ role={props.accessibilityRole}
65
+ aria-label={props.accessibilityLabel}
66
+ aria-hidden={props.accessible === false ? true : undefined}
67
+ aria-description={props.accessibilityHint}
68
+ aria-valuetext={props.accessibilityValue}
69
+ aria-disabled={props.accessibilityState?.disabled}
70
+ aria-selected={props.accessibilityState?.selected}
71
+ aria-checked={props.accessibilityState?.checked}
72
+ aria-live={props.accessibilityLiveRegion === 'none' ? 'off' : props.accessibilityLiveRegion}
73
+ >
74
+ @if (active) {
75
+ <video
76
+ ref={(element: HTMLVideoElement | null) => {
77
+ video.current = element
78
+ if (!element) return
79
+ applyEscapeProps(element, props)
80
+ props.bind?.({ native: element })
81
+ }}
82
+ muted={true}
83
+ autoplay={true}
84
+ playsInline={true}
85
+ style={{
86
+ display: 'block',
87
+ width: '100%',
88
+ height: '100%',
89
+ objectFit: 'cover',
90
+ transform: facing === 'front' ? 'scaleX(-1)' : undefined,
91
+ }}
92
+ onPlaying={() => {
93
+ if (readyFired.current) return
94
+ readyFired.current = true
95
+ props.onReady?.()
96
+ }}
97
+ />
98
+ }
99
+ </div>
100
+ }
@@ -0,0 +1,66 @@
1
+ import { Application, isAndroid, isIOS } from '@nativescript/core'
2
+
3
+ let requestCode = 7719
4
+
5
+ export function cameraHardwarePresent(): boolean {
6
+ if (isIOS) {return typeof (globalThis as any).AVCaptureDevice !== 'undefined'}
7
+ if (!isAndroid) {return false}
8
+ try {
9
+ const context = Application.android?.context ?? Application.android?.startActivity
10
+ return context?.packageManager?.hasSystemFeature('android.hardware.camera.any') !== false
11
+ } catch {
12
+ return true
13
+ }
14
+ }
15
+
16
+ export async function ensureCameraPermission(): Promise<boolean> {
17
+ try {
18
+ if (isIOS) {
19
+ const av = (globalThis as any).AVCaptureDevice
20
+ const mediaType = (globalThis as any).AVMediaTypeVideo ?? 'vide'
21
+ const status = av?.authorizationStatusForMediaType?.(mediaType)
22
+ if (status === 3) {return true}
23
+ if (status === 1 || status === 2) {return false}
24
+ return await new Promise<boolean>((resolve) =>
25
+ av.requestAccessForMediaTypeCompletionHandler(mediaType, resolve),
26
+ )
27
+ }
28
+
29
+ if (isAndroid) {
30
+ const permission = 'android.permission.CAMERA'
31
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity
32
+ if (
33
+ activity?.checkSelfPermission?.(permission) ===
34
+ android.content.pm.PackageManager.PERMISSION_GRANTED
35
+ ) {return true}
36
+
37
+ if (!activity) {return false}
38
+ return await new Promise<boolean>((resolve) => {
39
+ const currentRequest = requestCode++
40
+ const onResult = (args: any) => {
41
+ if (args.requestCode !== currentRequest) {return}
42
+ Application.android.off(
43
+ Application.android.activityRequestPermissionsEvent,
44
+ onResult,
45
+ )
46
+
47
+ resolve(
48
+ args.grantResults?.[0] ===
49
+ android.content.pm.PackageManager.PERMISSION_GRANTED,
50
+ )
51
+ }
52
+
53
+ Application.android.on(
54
+ Application.android.activityRequestPermissionsEvent,
55
+ onResult,
56
+ )
57
+
58
+ activity.requestPermissions([permission], currentRequest)
59
+ })
60
+ }
61
+ } catch {
62
+ return false
63
+ }
64
+
65
+ return false
66
+ }
@@ -0,0 +1,54 @@
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 = facing === 'front'
10
+ ? av.AVCaptureDevicePositionFront ?? 2
11
+ : av.AVCaptureDevicePositionBack ?? 1
12
+
13
+ const devices = av.AVCaptureDevice.devicesWithMediaType(mediaType)
14
+ let device: any = null
15
+ for (let index = 0; index < devices.count; index++) {
16
+ const candidate = devices.objectAtIndex(index)
17
+ if (candidate.position === desiredPosition) {
18
+ device = candidate
19
+ break
20
+ }
21
+ }
22
+
23
+ if (!device) {throw new Error('camera unavailable')}
24
+
25
+ const session = av.AVCaptureSession.new()
26
+ const input = av.AVCaptureDeviceInput.deviceInputWithDeviceError(device)
27
+ if (!input || !session.canAddInput(input)) {throw new Error('camera input unavailable')}
28
+ session.addInput(input)
29
+ const layer = av.AVCaptureVideoPreviewLayer.layerWithSession(session)
30
+ layer.videoGravity = 'resizeAspectFill'
31
+ const connection = layer.connection
32
+ if (connection) {
33
+ connection.automaticallyAdjustsVideoMirroring = false
34
+ if (connection.isVideoMirroringSupported) {connection.isVideoMirrored = facing === 'front'}
35
+ }
36
+
37
+ const nativeView = view.ios ?? view.nativeViewProtected
38
+ if (!nativeView?.layer) {throw new Error('camera preview host unavailable')}
39
+ nativeView.layer.addSublayer(layer)
40
+ const updateFrame = () => {
41
+ layer.frame = nativeView.bounds
42
+ }
43
+
44
+ view.on?.('layoutChanged', updateFrame)
45
+ updateFrame()
46
+ session.startRunning()
47
+ onReady?.()
48
+
49
+ return () => {
50
+ view.off?.('layoutChanged', updateFrame)
51
+ session.stopRunning()
52
+ layer.removeFromSuperlayer()
53
+ }
54
+ }
@@ -0,0 +1,5 @@
1
+ /** NativeScript's universal driver stringifies className values; normalize
2
+ * arrays here so they stay space separated. */
3
+ export function cx(...parts: any[]): string {
4
+ return parts.flat(Infinity).filter(Boolean).join(' ')
5
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Keep this relative import extensionless so the NativeScript resolver picks
2
+ // the iOS or Android component for the current build target.
3
+ export { CameraView } from './CameraView'
4
+ export type { CameraAccessibilityRole, CameraViewHandle, CameraViewProps } from './props'
@@ -0,0 +1,2 @@
1
+ export { CameraView } from './CameraView.web.tsrx'
2
+ export type { CameraAccessibilityRole, CameraViewHandle, CameraViewProps } from './props'
@@ -0,0 +1,62 @@
1
+ import type { CameraViewProps } from './props'
2
+ import { isIOS } from '@nativescript/core'
3
+
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) {
20
+ if (props[key] !== undefined) {result[key] = props[key]}
21
+ }
22
+
23
+ return result
24
+ }
25
+
26
+ export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
27
+ if (!view) {return}
28
+ const bag = isIOS ? props.ios : props.android
29
+ if (bag) {Object.assign(view, bag)}
30
+ }
31
+
32
+ const nativeRoles: Record<string, string> = {
33
+ button: 'button',
34
+ link: 'link',
35
+ search: 'search',
36
+ image: 'image',
37
+ heading: 'header',
38
+ adjustable: 'adjustable',
39
+ summary: 'summary',
40
+ text: 'text',
41
+ none: 'none',
42
+ progressbar: 'progressBar',
43
+ checkbox: 'checkbox',
44
+ switch: 'switch',
45
+ radio: 'radioButton',
46
+ spinbutton: 'spinButton',
47
+ tab: 'button',
48
+ }
49
+
50
+ export function nativeAccessibilityRole(role?: string): string | undefined {
51
+ return role ? nativeRoles[role] : undefined
52
+ }
53
+
54
+ export function nativeAccessibilityState(state?: CameraViewProps['accessibilityState']):
55
+ | string
56
+ | undefined {
57
+ if (state?.disabled) {return 'disabled'}
58
+ if (state?.selected) {return 'selected'}
59
+ if (state?.checked === true) {return 'checked'}
60
+ if (state?.checked === false) {return 'unchecked'}
61
+ return undefined
62
+ }
@@ -0,0 +1,42 @@
1
+ import type { CameraViewProps } from './props'
2
+
3
+ export function layoutChildProps(props: CameraViewProps, baseStyle: any = props.style) {
4
+ const style = { ...baseStyle }
5
+ if (props.row !== undefined) {
6
+ style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
7
+ } else if (props.rowSpan !== undefined) {
8
+ style.gridRow = `span ${props.rowSpan}`
9
+ }
10
+
11
+ if (props.col !== undefined) {
12
+ style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
13
+ } else if (props.colSpan !== undefined) {
14
+ style.gridColumn = `span ${props.colSpan}`
15
+ }
16
+
17
+ if (props.left !== undefined) {style.left = props.left}
18
+ if (props.top !== undefined) {style.top = props.top}
19
+ if (props.flexGrow !== undefined) {style.flexGrow = props.flexGrow}
20
+ if (props.flexShrink !== undefined) {style.flexShrink = props.flexShrink}
21
+ if (props.alignSelf !== undefined) {style.alignSelf = props.alignSelf}
22
+ if (props.order !== undefined) {style.order = props.order}
23
+ return { style }
24
+ }
25
+
26
+ export function applyEscapeProps(element: any, props: { web?: any }): void {
27
+ if (!element || !props.web) {return}
28
+ for (const [key, value] of Object.entries(props.web)) {
29
+ if (key.includes('-')) {
30
+ if (value == null || value === false) {element.removeAttribute(key)}
31
+ else {element.setAttribute(key, value === true ? '' : String(value))}
32
+ } else if (key in element) {
33
+ try {
34
+ element[key] = value
35
+ } catch {
36
+ element.setAttribute(key, String(value))
37
+ }
38
+ } else if (value != null) {
39
+ element.setAttribute(key, String(value))
40
+ }
41
+ }
42
+ }
package/src/props.ts ADDED
@@ -0,0 +1,62 @@
1
+ export type CameraAccessibilityRole =
2
+ | 'button'
3
+ | 'link'
4
+ | 'search'
5
+ | 'image'
6
+ | 'heading'
7
+ | 'adjustable'
8
+ | 'summary'
9
+ | 'text'
10
+ | 'none'
11
+ | 'progressbar'
12
+ | 'checkbox'
13
+ | 'switch'
14
+ | 'radio'
15
+ | 'spinbutton'
16
+ | 'tab'
17
+
18
+ export interface CameraViewHandle {
19
+ /** The native preview surface (`HTMLVideoElement` on web). */
20
+ native: any
21
+ }
22
+
23
+ export interface CameraViewProps {
24
+ className?: any
25
+ style?: any
26
+ id?: string
27
+ row?: number
28
+ col?: number
29
+ rowSpan?: number
30
+ colSpan?: number
31
+ dock?: 'left' | 'top' | 'right' | 'bottom'
32
+ left?: number
33
+ top?: number
34
+ flexGrow?: number
35
+ flexShrink?: number
36
+ alignSelf?: string
37
+ order?: number
38
+ accessible?: boolean
39
+ accessibilityLabel?: string
40
+ accessibilityRole?: CameraAccessibilityRole
41
+ accessibilityHint?: string
42
+ accessibilityValue?: string
43
+ accessibilityState?: {
44
+ disabled?: boolean
45
+ selected?: boolean
46
+ checked?: boolean
47
+ }
48
+ accessibilityLiveRegion?: 'none' | 'polite' | 'assertive'
49
+ /** Lens — defaults to the rear camera. */
50
+ facing?: 'front' | 'back'
51
+ /** Default true. False releases the preview session. */
52
+ active?: boolean
53
+ /** Fires after the preview surface starts. */
54
+ onReady?: () => void
55
+ onError?: (error: { message?: string }) => void
56
+ /** Native preview surface for platform-specific controls. */
57
+ bind?: (handle: CameraViewHandle) => void
58
+ /** Platform-specific properties applied to the preview surface. */
59
+ ios?: Record<string, any>
60
+ android?: Record<string, any>
61
+ web?: Record<string, any>
62
+ }
@@ -0,0 +1,5 @@
1
+ import type { UniversalComponent } from 'octane/universal'
2
+ import type { CameraViewProps } from './props'
3
+
4
+ export declare const CameraView: UniversalComponent<CameraViewProps>
5
+ export type { CameraViewHandle, CameraViewProps, CameraAccessibilityRole } from './props'
@@ -0,0 +1,47 @@
1
+ export type CameraAccessibilityRole = 'button' | 'link' | 'search' | 'image' | 'heading' | 'adjustable' | 'summary' | 'text' | 'none' | 'progressbar' | 'checkbox' | 'switch' | 'radio' | 'spinbutton' | 'tab';
2
+ export interface CameraViewHandle {
3
+ /** The native preview surface (`HTMLVideoElement` on web). */
4
+ native: any;
5
+ }
6
+ export interface CameraViewProps {
7
+ className?: any;
8
+ style?: any;
9
+ id?: string;
10
+ row?: number;
11
+ col?: number;
12
+ rowSpan?: number;
13
+ colSpan?: number;
14
+ dock?: 'left' | 'top' | 'right' | 'bottom';
15
+ left?: number;
16
+ top?: number;
17
+ flexGrow?: number;
18
+ flexShrink?: number;
19
+ alignSelf?: string;
20
+ order?: number;
21
+ accessible?: boolean;
22
+ accessibilityLabel?: string;
23
+ accessibilityRole?: CameraAccessibilityRole;
24
+ accessibilityHint?: string;
25
+ accessibilityValue?: string;
26
+ accessibilityState?: {
27
+ disabled?: boolean;
28
+ selected?: boolean;
29
+ checked?: boolean;
30
+ };
31
+ accessibilityLiveRegion?: 'none' | 'polite' | 'assertive';
32
+ /** Lens — defaults to the rear camera. */
33
+ facing?: 'front' | 'back';
34
+ /** Default true. False releases the preview session. */
35
+ active?: boolean;
36
+ /** Fires after the preview surface starts. */
37
+ onReady?: () => void;
38
+ onError?: (error: {
39
+ message?: string;
40
+ }) => void;
41
+ /** Native preview surface for platform-specific controls. */
42
+ bind?: (handle: CameraViewHandle) => void;
43
+ /** Platform-specific properties applied to the preview surface. */
44
+ ios?: Record<string, any>;
45
+ android?: Record<string, any>;
46
+ web?: Record<string, any>;
47
+ }