@octane-xplat/camera 0.13.0 → 0.15.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 (58) hide show
  1. package/README.md +89 -3
  2. package/package.json +35 -11
  3. package/platforms/android/AndroidManifest.xml +2 -0
  4. package/platforms/android/include.gradle +1 -0
  5. package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +13 -0
  6. package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
  7. package/platforms/ios/Info.plist +2 -0
  8. package/platforms/macos/src/CameraOwnership.swift +22 -0
  9. package/platforms/macos/src/PhotoCaptureLifecycle.swift +34 -0
  10. package/platforms/macos/src/XplatCameraHost.swift +639 -0
  11. package/platforms/macos/src/XplatPhotoCaptureSheet.swift +192 -0
  12. package/src/CameraView.android.tsrx +27 -3
  13. package/src/CameraView.ios.tsrx +32 -4
  14. package/src/CameraView.macos.tsrx +95 -0
  15. package/src/CameraView.web.tsrx +31 -2
  16. package/src/CameraView.windows.tsrx +126 -0
  17. package/src/camera-permission.mobile.ts +18 -4
  18. package/src/camera-permission.windows.ts +76 -0
  19. package/src/camera-session.macos.ts +75 -0
  20. package/src/camera-session.windows.ts +126 -0
  21. package/src/host-movie.web.ts +295 -0
  22. package/src/index.ts +32 -0
  23. package/src/index.web.ts +32 -0
  24. package/src/layout-child.windows.ts +79 -0
  25. package/src/movie-metadata.web.ts +84 -0
  26. package/src/movie-storage.web.ts +164 -0
  27. package/src/preview-tap.ts +34 -0
  28. package/src/props.ts +14 -1
  29. package/src/session-backend.android.ts +439 -0
  30. package/src/session-backend.ios.ts +1258 -0
  31. package/src/session-backend.linux.ts +887 -0
  32. package/src/session-backend.macos.ts +707 -0
  33. package/src/session-backend.ts +59 -0
  34. package/src/session-backend.web.ts +891 -0
  35. package/src/session-backend.windows.ts +1599 -0
  36. package/src/session-core.ts +723 -0
  37. package/src/session.ts +37 -0
  38. package/src/synthetic-session.ts +377 -0
  39. package/src/types.ts +357 -0
  40. package/types/index.d.ts +43 -0
  41. package/types/props.d.ts +14 -1
  42. package/types/session-core.d.ts +139 -0
  43. package/types/synthetic-session.d.ts +51 -0
  44. package/types/types.d.ts +290 -0
  45. package/dist/native/CameraView.android.js +0 -166
  46. package/dist/native/CameraView.ios.js +0 -131
  47. package/dist/native/camera-permission.mobile.js +0 -45
  48. package/dist/native/camera-session.ios.js +0 -45
  49. package/dist/native/cx.mobile.js +0 -9
  50. package/dist/native/index.js +0 -2
  51. package/dist/native/layout-child.js +0 -53
  52. package/dist/native/octane-client-build.json +0 -8
  53. package/dist/web/CameraView.web.tsrx.js +0 -317
  54. package/dist/web/index.js +0 -2
  55. package/dist/web/layout-child.web.js +0 -29
  56. package/dist/web/octane-client-build.json +0 -8
  57. package/src/CameraView.windows.ts +0 -5
  58. package/src/cx.mobile.ts +0 -6
@@ -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
+ }
@@ -4,9 +4,12 @@ import { Application, View } from '@nativescript/core'
4
4
  import { registerElement } from '@nativescript-community/octane'
5
5
  import type { Attributes } from '@nativescript-community/octane/intrinsics'
6
6
  import type { CameraViewProps } from './props'
7
+ import { attachSessionPreview } from './session'
7
8
  import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
8
9
  import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
9
- import { cx } from './cx.mobile'
10
+ import { observePreviewTap } from './preview-tap'
11
+ import { java } from '@octane-xplat/platform/android'
12
+
10
13
 
11
14
  class CameraPreview extends View {
12
15
  createNativeView() {
@@ -44,6 +47,25 @@ export function CameraView(props: CameraViewProps) @{
44
47
  if (!active) return () => nativeView.setCameraActive(false)
45
48
 
46
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
+
47
69
  if (!cameraHardwarePresent()) {
48
70
  fail('camera unsupported')
49
71
  return () => nativeView.setCameraActive(false)
@@ -98,7 +120,7 @@ export function CameraView(props: CameraViewProps) @{
98
120
  } catch {}
99
121
  }
100
122
  }
101
- }, [active, facing])
123
+ }, [active, facing, props.session])
102
124
 
103
125
  useEffect(() => {
104
126
  const view = host.current
@@ -106,11 +128,13 @@ export function CameraView(props: CameraViewProps) @{
106
128
  applyEscapeProps(view.android, props)
107
129
  }, [props.ref, props.android])
108
130
 
131
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
132
+
109
133
  <gridlayout
110
134
  id={props.id}
111
135
  rows="*"
112
136
  columns="*"
113
- className={cx('vx-layers', 'vx-cameraview', props.className)}
137
+ className={['vx-layers', 'vx-cameraview', props.className]}
114
138
  style={{ backgroundColor: '#000', ...props.style }}
115
139
  {...layoutChildProps(props)}
116
140
  accessible={props.accessible}
@@ -3,10 +3,13 @@ import { useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
3
  import type { CameraViewProps } from './props'
4
4
  import { cameraHardwarePresent, ensureCameraPermission } from './camera-permission.mobile'
5
5
  import { startCameraPreview } from './camera-session.ios'
6
+ import { attachSessionPreview } from './session'
6
7
  import { applyEscapeProps, layoutChildProps, nativeAccessibilityRole, nativeAccessibilityState } from './layout-child'
7
- import { cx } from './cx.mobile'
8
+ import { observePreviewTap } from './preview-tap'
8
9
 
9
- /** Live iOS preview backed by AVFoundation. */
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. */
10
13
  export function CameraView(props: CameraViewProps) @{
11
14
  const host = useRef<any>(null)
12
15
  const active = props.active !== false
@@ -24,6 +27,29 @@ export function CameraView(props: CameraViewProps) @{
24
27
  let stopPreview: (() => void) | undefined
25
28
  const fail = (message: string) => props.onError?.({ message })
26
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
+
27
53
  if (!cameraHardwarePresent()) {
28
54
  fail('camera unsupported')
29
55
  return
@@ -47,7 +73,7 @@ export function CameraView(props: CameraViewProps) @{
47
73
  disposed = true
48
74
  stopPreview?.()
49
75
  }
50
- }, [active, facing])
76
+ }, [active, facing, props.session])
51
77
 
52
78
  useEffect(() => {
53
79
  const view = host.current
@@ -55,12 +81,14 @@ export function CameraView(props: CameraViewProps) @{
55
81
  applyEscapeProps(view.ios ?? view.nativeViewProtected, props)
56
82
  }, [props.ref, props.ios])
57
83
 
84
+ useEffect(() => observePreviewTap(host.current, props.onTap), [props.onTap])
85
+
58
86
  <gridlayout
59
87
  ref={bindHost}
60
88
  id={props.id}
61
89
  rows="*"
62
90
  columns="*"
63
- className={cx('vx-layers', 'vx-cameraview', props.className)}
91
+ className={['vx-layers', 'vx-cameraview', props.className]}
64
92
  style={{ backgroundColor: '#000', ...props.style }}
65
93
  {...layoutChildProps(props)}
66
94
  accessible={props.accessible}
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { useImperativeHandle, useEffect, useRef } from 'octane'
2
2
  import type { CameraViewProps } from './props'
3
+ import { attachSessionPreview } from './session'
3
4
  import { applyEscapeProps, layoutChildProps } from './layout-child.web'
4
5
 
5
6
  /** Live web preview backed by getUserMedia. */
@@ -13,6 +14,23 @@ export function CameraView(props: CameraViewProps) @{
13
14
  useEffect(() => {
14
15
  const element = video.current
15
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
+
16
34
  const mediaDevices = navigator.mediaDevices
17
35
  if (!mediaDevices?.getUserMedia) {
18
36
  props.onError?.({ message: 'camera unsupported' })
@@ -51,7 +69,7 @@ export function CameraView(props: CameraViewProps) @{
51
69
  element.srcObject = null
52
70
  if (stream) for (const track of stream.getTracks()) track.stop()
53
71
  }
54
- }, [active, facing])
72
+ }, [active, facing, props.session])
55
73
 
56
74
  useImperativeHandle(props.ref, () => ({ native: hostRef.current }), undefined);
57
75
 
@@ -85,6 +103,17 @@ export function CameraView(props: CameraViewProps) @{
85
103
  muted={true}
86
104
  autoplay={true}
87
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
+ }}
88
117
  style={{
89
118
  display: 'block',
90
119
  width: '100%',
@@ -93,7 +122,7 @@ export function CameraView(props: CameraViewProps) @{
93
122
  transform: facing === 'front' ? 'scaleX(-1)' : undefined,
94
123
  }}
95
124
  onPlaying={() => {
96
- if (readyFired.current) return
125
+ if (props.session || readyFired.current) return
97
126
  readyFired.current = true
98
127
  props.onReady?.()
99
128
  }}
@@ -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
+ }
@@ -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 {