@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,192 @@
1
+ import AppKit
2
+ import AVFoundation
3
+
4
+ /// App-owned still-photo UI. No microphone input, movie output or Photos writes.
5
+ @objc(XplatPhotoCaptureSheet)
6
+ public final class XplatPhotoCaptureSheet: NSObject, NSWindowDelegate {
7
+ private let host = XplatCameraHost()
8
+ private let lifecycle = PhotoCaptureLifecycle()
9
+ private var sheet: NSWindow?
10
+ private weak var parent: NSWindow?
11
+ private var reply: ((String?) -> Void)?
12
+ private var timeout: DispatchWorkItem?
13
+ private var closeObserver: NSObjectProtocol?
14
+ private var preview: NSView?
15
+ private var captureButton: NSButton?
16
+ private var statusLabel: NSTextField?
17
+ private var options: NSDictionary = [:]
18
+ private var disposed = false
19
+ var operationTimeout: TimeInterval = 15
20
+
21
+ @objc public func permissionStatus() -> NSString { host.permissionStatus("camera") }
22
+
23
+ @objc public func present(_ options: NSDictionary, reply: @escaping (String?) -> Void) {
24
+ guard let parent = NSApp.keyWindow ?? NSApp.mainWindow else { reply(nil); return }
25
+ presentOnWindow(parent, options: options, reply: reply)
26
+ }
27
+
28
+ // Explicit host window also allows deterministic AppKit lifecycle checks.
29
+ func presentOnWindow(_ parent: NSWindow, options: NSDictionary, reply: @escaping (String?) -> Void) {
30
+ guard !disposed, sheet == nil, parent.attachedSheet == nil,
31
+ !(options["saveToGallery"] as? Bool ?? false)
32
+ else { reply(nil); return }
33
+ self.options = options
34
+ self.reply = reply
35
+ self.parent = parent
36
+ let sheet = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 640, height: 480),
37
+ styleMask: [.titled, .closable], backing: .buffered, defer: false)
38
+ sheet.title = "Take a photo"
39
+ sheet.delegate = self
40
+ let content = NSView(frame: NSRect(x: 0, y: 0, width: 640, height: 480))
41
+ let preview = NSView(frame: NSRect(x: 16, y: 80, width: 608, height: 384))
42
+ preview.wantsLayer = true
43
+ preview.layer?.backgroundColor = NSColor.black.cgColor
44
+ content.addSubview(preview)
45
+ let status = NSTextField(labelWithString: "Choose Capture to start the camera.")
46
+ status.frame = NSRect(x: 16, y: 48, width: 440, height: 22)
47
+ content.addSubview(status)
48
+ let cancel = NSButton(title: "Cancel", target: self, action: #selector(cancelPhoto))
49
+ cancel.keyEquivalent = "\u{1b}"
50
+ cancel.frame = NSRect(x: 432, y: 12, width: 92, height: 32)
51
+ content.addSubview(cancel)
52
+ let capture = NSButton(title: "Capture", target: self, action: #selector(capturePhoto))
53
+ capture.keyEquivalent = "\r"
54
+ capture.frame = NSRect(x: 528, y: 12, width: 96, height: 32)
55
+ content.addSubview(capture)
56
+ sheet.contentView = content
57
+ self.sheet = sheet
58
+ self.preview = preview
59
+ statusLabel = status
60
+ captureButton = capture
61
+ closeObserver = NotificationCenter.default.addObserver(forName: NSWindow.willCloseNotification,
62
+ object: parent, queue: .main) { [weak self] _ in self?.complete(nil) }
63
+ parent.beginSheet(sheet) { [weak self] _ in self?.complete(nil) }
64
+ }
65
+
66
+ private func deadline(_ token: Int) {
67
+ timeout?.cancel()
68
+ let work = DispatchWorkItem { [weak self] in
69
+ guard let self, lifecycle.accepts(token) else { return }
70
+ complete(nil)
71
+ }
72
+ timeout = work
73
+ DispatchQueue.main.asyncAfter(deadline: .now() + operationTimeout, execute: work)
74
+ }
75
+
76
+ @objc private func capturePhoto() {
77
+ if let token = lifecycle.prepare() {
78
+ captureButton?.isEnabled = false
79
+ statusLabel?.stringValue = "Starting camera…"
80
+ deadline(token)
81
+ // Authorization happens only after the user presses Capture.
82
+ host.requestPermission("camera") { [weak self] status in
83
+ guard let self, lifecycle.accepts(token) else { return }
84
+ guard status == "granted" else { complete(nil); return }
85
+ DispatchQueue.global(qos: .userInitiated).async { [self] in
86
+ let failure = self.host.configure(["photo": true, "audio": false])
87
+ DispatchQueue.main.async { [self] in
88
+ guard self.lifecycle.accepts(token) else { return }
89
+ guard failure == nil, let preview = self.preview else { self.complete(nil); return }
90
+ self.host.installObserver { [weak self] message in
91
+ guard let self, lifecycle.accepts(token),
92
+ let data = message.data(using: .utf8),
93
+ let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
94
+ else { return }
95
+ if event["type"] as? String == "previewReady", event["ready"] as? Bool == true,
96
+ lifecycle.ready(token) {
97
+ timeout?.cancel()
98
+ captureButton?.isEnabled = true
99
+ statusLabel?.stringValue = "Frame your photo, then choose Capture."
100
+ } else if event["type"] as? String == "interrupted" ||
101
+ (event["type"] as? String == "availability" && event["available"] as? Bool == false) {
102
+ complete(nil)
103
+ }
104
+ }
105
+ if self.host.attachPreview(preview) != nil { self.complete(nil) }
106
+ }
107
+ }
108
+ }
109
+ return
110
+ }
111
+ guard let token = lifecycle.capture() else { return }
112
+ captureButton?.isEnabled = false
113
+ statusLabel?.stringValue = "Taking photo…"
114
+ deadline(token)
115
+ if host.capturePhoto({ [weak self] data in
116
+ guard let self, lifecycle.accepts(token) else { return }
117
+ guard let data, let result = persist(data as Data) else { complete(nil); return }
118
+ complete(result)
119
+ }) != nil { complete(nil) }
120
+ }
121
+
122
+ private func persist(_ data: Data) -> String? {
123
+ guard let source = NSBitmapImageRep(data: data) else { return nil }
124
+ let width = (options["width"] as? NSNumber)?.doubleValue ?? Double(source.pixelsWide)
125
+ let height = (options["height"] as? NSNumber)?.doubleValue ?? Double(source.pixelsHigh)
126
+ guard width.isFinite, height.isFinite, width >= 1, height >= 1, width <= 16384, height <= 16384, width * height <= 64_000_000 else { return nil }
127
+ let keepAspectRatio = options["keepAspectRatio"] as? Bool ?? true
128
+ let scale = min(width / Double(source.pixelsWide), height / Double(source.pixelsHigh), 1)
129
+ let size = NSSize(width: keepAspectRatio ? Double(source.pixelsWide) * scale : width,
130
+ height: keepAspectRatio ? Double(source.pixelsHigh) * scale : height)
131
+ guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width),
132
+ pixelsHigh: Int(size.height), bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
133
+ isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
134
+ let context = NSGraphicsContext(bitmapImageRep: bitmap) else { return nil }
135
+ NSGraphicsContext.saveGraphicsState()
136
+ NSGraphicsContext.current = context
137
+ let image = NSImage(size: NSSize(width: source.pixelsWide, height: source.pixelsHigh))
138
+ image.addRepresentation(source)
139
+ image.draw(in: NSRect(origin: .zero, size: size))
140
+ NSGraphicsContext.restoreGraphicsState()
141
+ guard let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) else { return nil }
142
+ let name = "octane-capture-\(UUID().uuidString).jpg"
143
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
144
+ do {
145
+ let payload: [String: String] = ["name": name, "uri": url.path,
146
+ "dataUrl": "data:image/jpeg;base64," + jpeg.base64EncodedString()]
147
+ let message = try JSONSerialization.data(withJSONObject: payload)
148
+ try jpeg.write(to: url, options: .atomic)
149
+ return String(data: message, encoding: .utf8)
150
+ } catch {
151
+ try? FileManager.default.removeItem(at: url)
152
+ return nil
153
+ }
154
+ }
155
+
156
+ @objc private func cancelPhoto() { complete(nil) }
157
+ public func windowShouldClose(_ sender: NSWindow) -> Bool { complete(nil); return false }
158
+
159
+ private func complete(_ result: String?) {
160
+ guard lifecycle.finish() else { return }
161
+ let callback = reply
162
+ reply = nil
163
+ dispose()
164
+ callback?(result)
165
+ }
166
+
167
+ @objc public func discard(_ path: String) {
168
+ let url = URL(fileURLWithPath: path).standardizedFileURL
169
+ guard url.deletingLastPathComponent() == FileManager.default.temporaryDirectory.standardizedFileURL,
170
+ url.lastPathComponent.hasPrefix("octane-capture-") else { return }
171
+ try? FileManager.default.removeItem(at: url)
172
+ }
173
+
174
+ @objc public func dispose() {
175
+ guard !disposed else { return }
176
+ disposed = true
177
+ timeout?.cancel()
178
+ timeout = nil
179
+ if let closeObserver { NotificationCenter.default.removeObserver(closeObserver) }
180
+ closeObserver = nil
181
+ host.dispose()
182
+ if let sheet { parent?.endSheet(sheet); sheet.orderOut(nil) }
183
+ sheet = nil
184
+ preview = nil
185
+ captureButton = nil
186
+ statusLabel = nil
187
+ // Disposal is also a cancellation, including an external owner teardown.
188
+ if lifecycle.finish() { let callback = reply; reply = nil; callback?(nil) }
189
+ }
190
+
191
+ deinit { dispose() }
192
+ }
@@ -0,0 +1,150 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
+ import { Application, 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.mobile'
9
+ import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
10
+ import { observePreviewTap } from './preview-tap'
11
+ import { java } from '@octane-xplat/platform/android'
12
+
13
+
14
+ class CameraPreview extends View {
15
+ createNativeView() {
16
+ const nativeClass = (globalThis as any).com.octanexplat.camera.CameraPreviewView
17
+ return new nativeClass(this._context)
18
+ }
19
+ }
20
+
21
+ declare module '@nativescript-community/octane/intrinsics' {
22
+ interface NativeScriptElements {
23
+ camerapreview: Attributes<typeof CameraPreview>
24
+ }
25
+ }
26
+
27
+ registerElement('camerapreview', CameraPreview)
28
+
29
+ /** Live Android preview backed by CameraX. */
30
+ export function CameraView(props: CameraViewProps) @{
31
+ const host = useRef<any>(null)
32
+ const active = props.active !== false
33
+ const facing = props.facing ?? 'back'
34
+ useImperativeHandle(props.ref, () => ({ native: host.current.android }), []);
35
+
36
+ const bindHost = useCallback((view: any) => {
37
+ host.current = view
38
+ }, [])
39
+
40
+ useEffect(() => {
41
+ const nativeView = host.current?.android
42
+ if (!nativeView) return
43
+ let disposed = false
44
+ let provider: any
45
+ let preview: any
46
+ nativeView.setCameraActive(active)
47
+ if (!active) return () => nativeView.setCameraActive(false)
48
+
49
+ const fail = (message: string) => props.onError?.({ message })
50
+ if (props.session) {
51
+ if (props.facing !== undefined) {
52
+ fail('camera session conflicts with facing')
53
+ return () => nativeView.setCameraActive(false)
54
+ }
55
+
56
+ try {
57
+ const detach = attachSessionPreview(props.session, nativeView, {
58
+ onReady: props.onReady,
59
+ onError: (issue) => fail(issue.message),
60
+ })
61
+
62
+ return () => { detach(); nativeView.setCameraActive(false) }
63
+ } catch (error: any) {
64
+ fail(error?.message ?? 'camera session error')
65
+ return () => nativeView.setCameraActive(false)
66
+ }
67
+ }
68
+
69
+ if (!cameraHardwarePresent()) {
70
+ fail('camera unsupported')
71
+ return () => nativeView.setCameraActive(false)
72
+ }
73
+
74
+ ensureCameraPermission().then((granted) => {
75
+ if (disposed) return
76
+ if (!granted) {
77
+ fail('camera permission denied')
78
+ return
79
+ }
80
+
81
+ try {
82
+ const context = Application.android.context
83
+ const cameraX = (globalThis as any).androidx.camera
84
+ const future = cameraX.lifecycle.ProcessCameraProvider.getInstance(context)
85
+ const executor = (globalThis as any).androidx.core.content.ContextCompat.getMainExecutor(context)
86
+ future.addListener(
87
+ new java.lang.Runnable({
88
+ run() {
89
+ if (disposed) return
90
+ try {
91
+ provider = future.get()
92
+ const selectorClass = cameraX.core.CameraSelector
93
+ const lens = facing === 'front'
94
+ ? selectorClass.LENS_FACING_FRONT
95
+ : selectorClass.LENS_FACING_BACK
96
+
97
+ const selector = new selectorClass.Builder().requireLensFacing(lens).build()
98
+ preview = new cameraX.core.Preview.Builder().build()
99
+ preview.setSurfaceProvider(nativeView.getSurfaceProvider())
100
+ provider.bindToLifecycle(nativeView, selector, preview)
101
+ props.onReady?.()
102
+ } catch (error: any) {
103
+ fail(error?.message ?? 'camera error')
104
+ }
105
+ },
106
+ }),
107
+ executor,
108
+ )
109
+ } catch (error: any) {
110
+ fail(error?.message ?? 'camera error')
111
+ }
112
+ })
113
+
114
+ return () => {
115
+ disposed = true
116
+ nativeView.setCameraActive(false)
117
+ if (provider && preview) {
118
+ try {
119
+ provider.unbind(preview)
120
+ } catch {}
121
+ }
122
+ }
123
+ }, [active, facing, props.session])
124
+
125
+ useEffect(() => {
126
+ const view = host.current
127
+ if (!view) return
128
+ applyEscapeProps(view.android, props)
129
+ }, [props.ref, props.android])
130
+
131
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
132
+
133
+ <gridlayout
134
+ id={props.id}
135
+ rows="*"
136
+ columns="*"
137
+ className={['vx-layers', 'vx-cameraview', props.className]}
138
+ style={{ backgroundColor: '#000', ...props.style }}
139
+ {...layoutChildProps(props)}
140
+ accessible={props.accessible}
141
+ accessibilityLabel={props.accessibilityLabel}
142
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
143
+ accessibilityHint={props.accessibilityHint}
144
+ accessibilityValue={props.accessibilityValue}
145
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
146
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
147
+ >
148
+ <camerapreview ref={bindHost} row={0} col={0} />
149
+ </gridlayout>
150
+ }
@@ -0,0 +1,102 @@
1
+ /** @jsxImportSource @nativescript-community/octane */
2
+ import { useImperativeHandle, 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 { attachSessionPreview } from './session'
7
+ import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
8
+ import { observePreviewTap } from './preview-tap'
9
+
10
+ /** Live iOS preview backed by AVFoundation. With `session`, the preview
11
+ * attaches to the session-owned AVCaptureSession so movie recording can
12
+ * share it; without one the view owns its session as before. */
13
+ export function CameraView(props: CameraViewProps) @{
14
+ const host = useRef<any>(null)
15
+ const active = props.active !== false
16
+ const facing = props.facing ?? 'back'
17
+ useImperativeHandle(props.ref, () => ({ native: host.current.ios ?? host.current.nativeViewProtected }), []);
18
+
19
+ const bindHost = useCallback((view: any) => {
20
+ host.current = view
21
+ }, [])
22
+
23
+ useEffect(() => {
24
+ const view = host.current
25
+ if (!view || !active) return
26
+ let disposed = false
27
+ let stopPreview: (() => void) | undefined
28
+ const fail = (message: string) => props.onError?.({ message })
29
+
30
+ if (props.session) {
31
+ if (props.facing !== undefined) {
32
+ fail('camera session conflicts with facing')
33
+ return
34
+ }
35
+
36
+ try {
37
+ const detach = attachSessionPreview(props.session, view, {
38
+ onReady: () => {
39
+ if (!disposed) props.onReady?.()
40
+ },
41
+ })
42
+
43
+ return () => {
44
+ disposed = true
45
+ detach()
46
+ }
47
+ } catch (error: any) {
48
+ fail(error?.message ?? 'camera session error')
49
+ return
50
+ }
51
+ }
52
+
53
+ if (!cameraHardwarePresent()) {
54
+ fail('camera unsupported')
55
+ return
56
+ }
57
+
58
+ ensureCameraPermission().then((granted) => {
59
+ if (disposed) return
60
+ if (!granted) {
61
+ fail('camera permission denied')
62
+ return
63
+ }
64
+
65
+ try {
66
+ stopPreview = startCameraPreview(view, facing, props.onReady)
67
+ } catch (error: any) {
68
+ fail(error?.message ?? 'camera error')
69
+ }
70
+ })
71
+
72
+ return () => {
73
+ disposed = true
74
+ stopPreview?.()
75
+ }
76
+ }, [active, facing, props.session])
77
+
78
+ useEffect(() => {
79
+ const view = host.current
80
+ if (!view) return
81
+ applyEscapeProps(view.ios ?? view.nativeViewProtected, props)
82
+ }, [props.ref, props.ios])
83
+
84
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
85
+
86
+ <gridlayout
87
+ ref={bindHost}
88
+ id={props.id}
89
+ rows="*"
90
+ columns="*"
91
+ className={['vx-layers', 'vx-cameraview', props.className]}
92
+ style={{ backgroundColor: '#000', ...props.style }}
93
+ {...layoutChildProps(props)}
94
+ accessible={props.accessible}
95
+ accessibilityLabel={props.accessibilityLabel}
96
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
97
+ accessibilityHint={props.accessibilityHint}
98
+ accessibilityValue={props.accessibilityValue}
99
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
100
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
101
+ />
102
+ }
@@ -0,0 +1,95 @@
1
+ /** @jsxImportSource @octane-xplat/macos-renderer */
2
+ import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
+ import type { CameraViewProps } from './props'
4
+ import { cameraHardwarePresent, ensureCameraPermission, startCameraPreview } from './camera-session.macos'
5
+ import { attachSessionPreview } from './session'
6
+ import { layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
7
+ import { observePreviewTap } from './preview-tap'
8
+
9
+ /** Live AppKit preview backed by AVFoundation. With `session`, the preview
10
+ * attaches to the session-owned AVCaptureSession so movie recording can
11
+ * share it; without one the view owns its session as before. */
12
+ export function CameraView(props: CameraViewProps) @{
13
+ const host = useRef<any>(null)
14
+ const active = props.active !== false
15
+ const facing = props.facing ?? 'back'
16
+ useImperativeHandle(props.ref, () => ({ native: host.current }), [])
17
+
18
+ const bindHost = useCallback((view: any) => {
19
+ host.current = view
20
+ }, [])
21
+
22
+ useEffect(() => {
23
+ const view = host.current
24
+ if (!view || !active) return
25
+ let disposed = false
26
+ let stopPreview: (() => void) | undefined
27
+ const fail = (message: string) => props.onError?.({ message })
28
+
29
+ if (props.session) {
30
+ if (props.facing !== undefined) {
31
+ fail('camera session conflicts with facing')
32
+ return
33
+ }
34
+
35
+ try {
36
+ const detach = attachSessionPreview(props.session, view, {
37
+ onReady: () => {
38
+ if (!disposed) props.onReady?.()
39
+ },
40
+ })
41
+
42
+ return () => {
43
+ disposed = true
44
+ detach()
45
+ }
46
+ } catch (error: any) {
47
+ fail(error?.message ?? 'camera session error')
48
+ return
49
+ }
50
+ }
51
+
52
+ if (!cameraHardwarePresent()) {
53
+ fail('camera unsupported')
54
+ return
55
+ }
56
+
57
+ ensureCameraPermission().then((granted) => {
58
+ if (disposed) return
59
+ if (!granted) {
60
+ fail('camera permission denied')
61
+ return
62
+ }
63
+
64
+ try {
65
+ stopPreview = startCameraPreview(view, facing, props.onReady)
66
+ } catch (error: any) {
67
+ fail(error?.message ?? 'camera error')
68
+ }
69
+ })
70
+
71
+ return () => {
72
+ disposed = true
73
+ stopPreview?.()
74
+ }
75
+ }, [active, facing, props.session])
76
+
77
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
78
+
79
+ <gridlayout
80
+ ref={bindHost}
81
+ id={props.id}
82
+ rows="*"
83
+ columns="*"
84
+ className={['vx-layers', 'vx-cameraview', props.className]}
85
+ style={{ backgroundColor: '#000000', ...props.style }}
86
+ {...layoutChildProps(props)}
87
+ accessible={props.accessible}
88
+ accessibilityLabel={props.accessibilityLabel}
89
+ accessibilityRole={nativeAccessibilityRole(props.accessibilityRole) as any}
90
+ accessibilityHint={props.accessibilityHint}
91
+ accessibilityValue={props.accessibilityValue}
92
+ accessibilityState={nativeAccessibilityState(props.accessibilityState) as any}
93
+ accessibilityLiveRegion={props.accessibilityLiveRegion as any}
94
+ />
95
+ }
@@ -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,132 @@
1
+ import { useImperativeHandle, useEffect, useRef } from 'octane'
2
+ import type { CameraViewProps } from './props'
3
+ import { attachSessionPreview } from './session'
4
+ import { applyEscapeProps, layoutChildProps } from './layout-child.web'
5
+
6
+ /** Live web preview backed by getUserMedia. */
7
+ export function CameraView(props: CameraViewProps) @{
8
+ const hostRef = useRef<any>(null);
9
+ const video = useRef<HTMLVideoElement | null>(null)
10
+ const facing = props.facing ?? 'back'
11
+ const active = props.active !== false
12
+ const readyFired = useRef(false)
13
+
14
+ useEffect(() => {
15
+ const element = video.current
16
+ if (!element || !active) return
17
+ if (props.session) {
18
+ if (props.facing !== undefined) {
19
+ props.onError?.({ message: 'camera session conflicts with facing' })
20
+ return
21
+ }
22
+
23
+ try {
24
+ return attachSessionPreview(props.session, element, {
25
+ onReady: props.onReady,
26
+ onError: (issue) => props.onError?.({ message: issue.message }),
27
+ })
28
+ } catch (error: any) {
29
+ props.onError?.({ message: error?.message ?? 'camera session error' })
30
+ return
31
+ }
32
+ }
33
+
34
+ const mediaDevices = navigator.mediaDevices
35
+ if (!mediaDevices?.getUserMedia) {
36
+ props.onError?.({ message: 'camera unsupported' })
37
+ return
38
+ }
39
+
40
+ let stream: MediaStream | null = null
41
+ let disposed = false
42
+ readyFired.current = false
43
+ mediaDevices
44
+ .getUserMedia({
45
+ video: { facingMode: facing === 'front' ? 'user' : 'environment' },
46
+ audio: false,
47
+ })
48
+ .then((nextStream) => {
49
+ if (disposed) {
50
+ for (const track of nextStream.getTracks()) track.stop()
51
+ return
52
+ }
53
+
54
+ stream = nextStream
55
+ element.srcObject = nextStream
56
+ element.play()?.catch(() => {})
57
+ })
58
+ .catch((error: any) => {
59
+ props.onError?.({
60
+ message:
61
+ error?.name === 'NotAllowedError'
62
+ ? 'camera permission denied'
63
+ : String(error?.name ?? error ?? 'camera error'),
64
+ })
65
+ })
66
+
67
+ return () => {
68
+ disposed = true
69
+ element.srcObject = null
70
+ if (stream) for (const track of stream.getTracks()) track.stop()
71
+ }
72
+ }, [active, facing, props.session])
73
+
74
+ useImperativeHandle(props.ref, () => ({ native: hostRef.current }), undefined);
75
+
76
+ <div
77
+ id={props.id}
78
+ className={['vx-layers', 'vx-cameraview', props.className]}
79
+ {...layoutChildProps(props, {
80
+ position: 'relative',
81
+ overflow: 'hidden',
82
+ backgroundColor: '#000',
83
+ ...props.style,
84
+ })}
85
+ role={props.accessibilityRole}
86
+ aria-label={props.accessibilityLabel}
87
+ aria-hidden={props.accessible === false ? true : undefined}
88
+ aria-description={props.accessibilityHint}
89
+ aria-valuetext={props.accessibilityValue}
90
+ aria-disabled={props.accessibilityState?.disabled}
91
+ aria-selected={props.accessibilityState?.selected}
92
+ aria-checked={props.accessibilityState?.checked}
93
+ aria-live={props.accessibilityLiveRegion === 'none' ? 'off' : props.accessibilityLiveRegion}
94
+ >
95
+ @if (active) {
96
+ <video
97
+ ref={(element: HTMLVideoElement | null) => {
98
+ hostRef.current = element;
99
+ video.current = element
100
+ if (!element) return
101
+ applyEscapeProps(element, props)
102
+ }}
103
+ muted={true}
104
+ autoplay={true}
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
+ }}
117
+ style={{
118
+ display: 'block',
119
+ width: '100%',
120
+ height: '100%',
121
+ objectFit: 'cover',
122
+ transform: facing === 'front' ? 'scaleX(-1)' : undefined,
123
+ }}
124
+ onPlaying={() => {
125
+ if (props.session || readyFired.current) return
126
+ readyFired.current = true
127
+ props.onReady?.()
128
+ }}
129
+ />
130
+ }
131
+ </div>
132
+ }