@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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/package.json +104 -0
- package/platforms/android/AndroidManifest.xml +6 -0
- package/platforms/android/include.gradle +14 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +84 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +10 -0
- package/platforms/macos/src/CameraOwnership.swift +22 -0
- package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
- package/platforms/macos/src/XplatCameraHost.swift +639 -0
- package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
- package/src/CameraView.android.tsrx +150 -0
- package/src/CameraView.ios.tsrx +102 -0
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.mobile.ts +6 -0
- package/src/CameraView.web.tsrx +132 -0
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +91 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.ios.ts +65 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +126 -0
- package/src/host-movie.web.ts +295 -0
- package/src/index.ts +36 -0
- package/src/index.web.ts +34 -0
- package/src/layout-child.ts +81 -0
- package/src/layout-child.web.ts +66 -0
- package/src/layout-child.windows.ts +79 -0
- package/src/movie-metadata.web.ts +84 -0
- package/src/movie-storage.web.ts +164 -0
- package/src/preview-tap.ts +34 -0
- package/src/props.ts +77 -0
- package/src/session-backend.android.ts +439 -0
- package/src/session-backend.ios.ts +1258 -0
- package/src/session-backend.linux.ts +887 -0
- package/src/session-backend.macos.ts +707 -0
- package/src/session-backend.ts +59 -0
- package/src/session-backend.web.ts +891 -0
- package/src/session-backend.windows.ts +1599 -0
- package/src/session-core.ts +723 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +377 -0
- package/src/types.ts +357 -0
- package/types/index.d.ts +48 -0
- package/types/props.d.ts +61 -0
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- 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
|
+
}
|