@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,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
|
+
}
|