@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.
- package/README.md +89 -3
- package/package.json +35 -11
- package/platforms/android/AndroidManifest.xml +2 -0
- package/platforms/android/include.gradle +1 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +13 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +394 -0
- package/platforms/ios/Info.plist +2 -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 +27 -3
- package/src/CameraView.ios.tsrx +32 -4
- package/src/CameraView.macos.tsrx +95 -0
- package/src/CameraView.web.tsrx +31 -2
- package/src/CameraView.windows.tsrx +126 -0
- package/src/camera-permission.mobile.ts +18 -4
- package/src/camera-permission.windows.ts +76 -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 +32 -0
- package/src/index.web.ts +32 -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 +14 -1
- 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 +43 -0
- package/types/props.d.ts +14 -1
- package/types/session-core.d.ts +139 -0
- package/types/synthetic-session.d.ts +51 -0
- package/types/types.d.ts +290 -0
- package/dist/native/CameraView.android.js +0 -166
- package/dist/native/CameraView.ios.js +0 -131
- package/dist/native/camera-permission.mobile.js +0 -45
- package/dist/native/camera-session.ios.js +0 -45
- package/dist/native/cx.mobile.js +0 -9
- package/dist/native/index.js +0 -2
- package/dist/native/layout-child.js +0 -53
- package/dist/native/octane-client-build.json +0 -8
- package/dist/web/CameraView.web.tsrx.js +0 -317
- package/dist/web/index.js +0 -2
- package/dist/web/layout-child.web.js +0 -29
- package/dist/web/octane-client-build.json +0 -8
- package/src/CameraView.windows.ts +0 -5
- 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 {
|
|
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={
|
|
137
|
+
className={['vx-layers', 'vx-cameraview', props.className]}
|
|
114
138
|
style={{ backgroundColor: '#000', ...props.style }}
|
|
115
139
|
{...layoutChildProps(props)}
|
|
116
140
|
accessible={props.accessible}
|
package/src/CameraView.ios.tsrx
CHANGED
|
@@ -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 {
|
|
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={
|
|
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
|
+
}
|
package/src/CameraView.web.tsrx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
79
|
+
try {
|
|
80
|
+
activity.requestPermissions([permission], currentRequest)
|
|
81
|
+
} catch {
|
|
82
|
+
finish(false)
|
|
83
|
+
}
|
|
70
84
|
})
|
|
71
85
|
}
|
|
72
86
|
} catch {
|