@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,164 @@
|
|
|
1
|
+
import { CameraCaptureError } from './types'
|
|
2
|
+
import type { MovieClip, MovieOutput, MovieOutputHandle } from './types'
|
|
3
|
+
|
|
4
|
+
const databaseName = 'octane-xplat-camera'
|
|
5
|
+
const storeName = 'movies'
|
|
6
|
+
|
|
7
|
+
type StoredMovie = { id: string; clip?: MovieClip; bytes?: Blob }
|
|
8
|
+
|
|
9
|
+
const storageError = (cause: unknown) =>
|
|
10
|
+
new CameraCaptureError({
|
|
11
|
+
kind:
|
|
12
|
+
cause instanceof DOMException && cause.name === 'QuotaExceededError'
|
|
13
|
+
? 'insufficientStorage'
|
|
14
|
+
: 'storageFailed',
|
|
15
|
+
operation: 'storage',
|
|
16
|
+
message: 'The movie could not be committed to origin-local storage',
|
|
17
|
+
cause,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** Each operation closes its database; playback survives session and page lifetimes. */
|
|
21
|
+
async function transact<T>(
|
|
22
|
+
mode: IDBTransactionMode,
|
|
23
|
+
operation: (store: IDBObjectStore, result: (value: T) => void) => void,
|
|
24
|
+
): Promise<T> {
|
|
25
|
+
return new Promise<T>((resolve, reject) => {
|
|
26
|
+
let database: IDBDatabase | undefined
|
|
27
|
+
let transaction: IDBTransaction | undefined
|
|
28
|
+
let result: T
|
|
29
|
+
let done = false
|
|
30
|
+
const finish = (cause?: unknown) => {
|
|
31
|
+
if (done) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
done = true
|
|
36
|
+
clearTimeout(timer)
|
|
37
|
+
database?.close()
|
|
38
|
+
if (cause) {
|
|
39
|
+
reject(storageError(cause))
|
|
40
|
+
} else {
|
|
41
|
+
resolve(result)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
transaction?.abort()
|
|
47
|
+
finish(new Error('Storage transaction timed out'))
|
|
48
|
+
}, 15_000)
|
|
49
|
+
|
|
50
|
+
const request = indexedDB.open(databaseName, 1)
|
|
51
|
+
request.onupgradeneeded = () => request.result.createObjectStore(storeName, { keyPath: 'id' })
|
|
52
|
+
request.onerror = () => finish(request.error)
|
|
53
|
+
request.onblocked = () => finish(new Error('Storage upgrade is blocked by another page'))
|
|
54
|
+
request.onsuccess = () => {
|
|
55
|
+
database = request.result
|
|
56
|
+
if (done) {
|
|
57
|
+
database.close()
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
transaction = database.transaction(storeName, mode, { durability: 'strict' })
|
|
63
|
+
transaction.oncomplete = () => finish()
|
|
64
|
+
transaction.onabort = () =>
|
|
65
|
+
finish(transaction?.error ?? new Error('Storage transaction aborted'))
|
|
66
|
+
|
|
67
|
+
transaction.onerror = () =>
|
|
68
|
+
finish(transaction?.error ?? new Error('Storage transaction failed'))
|
|
69
|
+
|
|
70
|
+
operation(transaction.objectStore(storeName), (value) => {
|
|
71
|
+
result = value
|
|
72
|
+
})
|
|
73
|
+
} catch (cause) {
|
|
74
|
+
finish(cause)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function reserveMovie(): Promise<string> {
|
|
81
|
+
const estimate = await navigator.storage.estimate()
|
|
82
|
+
if (
|
|
83
|
+
estimate.quota !== undefined &&
|
|
84
|
+
estimate.usage !== undefined &&
|
|
85
|
+
estimate.quota - estimate.usage < 1_048_576
|
|
86
|
+
) {
|
|
87
|
+
throw new CameraCaptureError({
|
|
88
|
+
kind: 'insufficientStorage',
|
|
89
|
+
operation: 'reserve',
|
|
90
|
+
message: 'Origin storage has less than 1 MiB free',
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const id = crypto.randomUUID()
|
|
95
|
+
await transact<void>('readwrite', (store) => {
|
|
96
|
+
store.add({ id })
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return id
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function commitMovie(id: string, bytes: Blob, clip: MovieClip): Promise<void> {
|
|
103
|
+
if (!(await navigator.storage.persisted())) {
|
|
104
|
+
throw new CameraCaptureError({
|
|
105
|
+
kind: 'destinationUnavailable',
|
|
106
|
+
operation: 'storage',
|
|
107
|
+
message: 'Persistent origin storage is no longer granted',
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await transact<void>('readwrite', (store) => {
|
|
112
|
+
store.put({ id, bytes, clip } satisfies StoredMovie)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Reopen the committed entry before handing ownership to the app.
|
|
116
|
+
const stored = await readMovie(id)
|
|
117
|
+
if (!stored.bytes || stored.bytes.size !== bytes.size || !stored.clip) {
|
|
118
|
+
throw storageError(new Error('Committed movie cannot be reopened'))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function removeReservation(id: string): Promise<void> {
|
|
123
|
+
await transact<void>('readwrite', (store) => {
|
|
124
|
+
store.delete(id)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readMovie(id: string): Promise<StoredMovie> {
|
|
129
|
+
return transact<StoredMovie>('readonly', (store, result) => {
|
|
130
|
+
const request = store.get(id)
|
|
131
|
+
request.onsuccess = () => result(request.result ?? { id })
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function openStoredMovie(output: MovieOutput): Promise<MovieOutputHandle> {
|
|
136
|
+
if (output.kind !== 'browserStorage' || output.synthetic) {
|
|
137
|
+
throw new CameraCaptureError({
|
|
138
|
+
kind: 'invalidArgument',
|
|
139
|
+
operation: 'openOutput',
|
|
140
|
+
message: 'Expected an origin-local movie reference',
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const stored = await readMovie(output.resourceId)
|
|
145
|
+
if (!stored.bytes || !stored.clip) {
|
|
146
|
+
throw new CameraCaptureError({
|
|
147
|
+
kind: 'destinationUnavailable',
|
|
148
|
+
operation: 'openOutput',
|
|
149
|
+
message: 'Movie is missing or site data was cleared',
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const url = URL.createObjectURL(stored.bytes)
|
|
154
|
+
let released = false
|
|
155
|
+
return {
|
|
156
|
+
url,
|
|
157
|
+
release: () => {
|
|
158
|
+
if (!released) {
|
|
159
|
+
released = true
|
|
160
|
+
URL.revokeObjectURL(url)
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CameraPreviewPoint } from './types'
|
|
2
|
+
|
|
3
|
+
/** Attach a notification-only 'tap' observer to a preview host view and
|
|
4
|
+
* report each tap as a `CameraPreviewPoint` normalized to the view's
|
|
5
|
+
* bounds. The tap gesture never consumes the touch — recognizers layered
|
|
6
|
+
* above the preview still see it. Platforms whose runtime never
|
|
7
|
+
* dispatches 'tap' simply never fire the callback. */
|
|
8
|
+
export function observePreviewTap(
|
|
9
|
+
view: any,
|
|
10
|
+
onTap: ((point: CameraPreviewPoint) => void) | undefined,
|
|
11
|
+
): () => void {
|
|
12
|
+
if (!view || !onTap || typeof view.on !== 'function') {
|
|
13
|
+
return () => {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const handler = (args: any) => {
|
|
17
|
+
const size = view.getActualSize?.()
|
|
18
|
+
const width = size?.width ?? 0
|
|
19
|
+
const height = size?.height ?? 0
|
|
20
|
+
const x = typeof args?.getX === 'function' ? args.getX() : NaN
|
|
21
|
+
const y = typeof args?.getY === 'function' ? args.getY() : NaN
|
|
22
|
+
if (!(width > 0) || !(height > 0) || !Number.isFinite(x) || !Number.isFinite(y)) {
|
|
23
|
+
return
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
onTap({
|
|
27
|
+
x: Math.min(1, Math.max(0, x / width)),
|
|
28
|
+
y: Math.min(1, Math.max(0, y / height)),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
view.on('tap', handler)
|
|
33
|
+
return () => view.off?.('tap', handler)
|
|
34
|
+
}
|
package/src/props.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Octane } from 'octane/jsx-runtime'
|
|
2
|
+
import type { CameraPreviewPoint, CameraSession } from './types'
|
|
3
|
+
|
|
4
|
+
export type CameraAccessibilityRole =
|
|
5
|
+
| 'button'
|
|
6
|
+
| 'link'
|
|
7
|
+
| 'search'
|
|
8
|
+
| 'image'
|
|
9
|
+
| 'heading'
|
|
10
|
+
| 'adjustable'
|
|
11
|
+
| 'summary'
|
|
12
|
+
| 'text'
|
|
13
|
+
| 'none'
|
|
14
|
+
| 'progressbar'
|
|
15
|
+
| 'checkbox'
|
|
16
|
+
| 'switch'
|
|
17
|
+
| 'radio'
|
|
18
|
+
| 'spinbutton'
|
|
19
|
+
| 'tab'
|
|
20
|
+
|
|
21
|
+
export interface CameraViewHandle {
|
|
22
|
+
/** The native preview surface (`HTMLVideoElement` on web). */
|
|
23
|
+
native: any
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CameraViewProps {
|
|
27
|
+
className?: any
|
|
28
|
+
style?: any
|
|
29
|
+
id?: string
|
|
30
|
+
row?: number
|
|
31
|
+
col?: number
|
|
32
|
+
rowSpan?: number
|
|
33
|
+
colSpan?: number
|
|
34
|
+
dock?: 'left' | 'top' | 'right' | 'bottom'
|
|
35
|
+
left?: number
|
|
36
|
+
top?: number
|
|
37
|
+
flexGrow?: number
|
|
38
|
+
flexShrink?: number
|
|
39
|
+
alignSelf?: string
|
|
40
|
+
order?: number
|
|
41
|
+
accessible?: boolean
|
|
42
|
+
accessibilityLabel?: string
|
|
43
|
+
accessibilityRole?: CameraAccessibilityRole
|
|
44
|
+
accessibilityHint?: string
|
|
45
|
+
accessibilityValue?: string
|
|
46
|
+
accessibilityState?: {
|
|
47
|
+
disabled?: boolean
|
|
48
|
+
selected?: boolean
|
|
49
|
+
checked?: boolean
|
|
50
|
+
}
|
|
51
|
+
accessibilityLiveRegion?: 'none' | 'polite' | 'assertive'
|
|
52
|
+
/** Shared camera owner enabling movie recording. When set, the preview
|
|
53
|
+
* attaches to the session's acquisition instead of owning one, and
|
|
54
|
+
* `facing` must be omitted — camera selection lives on the session. */
|
|
55
|
+
session?: CameraSession
|
|
56
|
+
/** Lens — defaults to the rear camera. Invalid together with `session`. */
|
|
57
|
+
facing?: 'front' | 'back'
|
|
58
|
+
/** Default true. False releases the preview session. */
|
|
59
|
+
active?: boolean
|
|
60
|
+
/** Fires after the preview surface starts. */
|
|
61
|
+
onReady?: () => void
|
|
62
|
+
onError?: (error: { message?: string }) => void
|
|
63
|
+
/** Notification-only tap event — fires with the normalized
|
|
64
|
+
* preview-space position (`CameraPreviewPoint`) and never consumes
|
|
65
|
+
* the gesture, so recognizers layered above the preview (double-tap,
|
|
66
|
+
* pan) still run. Pair with `session.focusAtPoint` for tap-to-focus.
|
|
67
|
+
* Delivered on iOS, Android, and web; desktop runtimes do not dispatch
|
|
68
|
+
* it yet. */
|
|
69
|
+
onTap?: (point: CameraPreviewPoint) => void
|
|
70
|
+
/** Native preview surface for platform-specific controls. */
|
|
71
|
+
ref?: Octane.Ref<CameraViewHandle>
|
|
72
|
+
/** Platform-specific properties applied to the preview surface. */
|
|
73
|
+
ios?: Record<string, any>
|
|
74
|
+
android?: Record<string, any>
|
|
75
|
+
web?: Record<string, any>
|
|
76
|
+
windows?: Record<string, any>
|
|
77
|
+
}
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { Application } from '@nativescript/core'
|
|
2
|
+
import { CameraCaptureError } from './types'
|
|
3
|
+
import { canonicalizePath } from './session-core'
|
|
4
|
+
import type {
|
|
5
|
+
BackendCaptureSink,
|
|
6
|
+
BackendObserver,
|
|
7
|
+
BackendPreviewListener,
|
|
8
|
+
CameraSessionBackend,
|
|
9
|
+
} from './session-core'
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CameraPermissionKind,
|
|
13
|
+
CameraPermissionStatus,
|
|
14
|
+
CameraSessionConfig,
|
|
15
|
+
CaptureIssue,
|
|
16
|
+
} from './types'
|
|
17
|
+
|
|
18
|
+
let requestCode = 7820
|
|
19
|
+
let permissionChain: Promise<unknown> = Promise.resolve()
|
|
20
|
+
let owner: object | undefined
|
|
21
|
+
|
|
22
|
+
const nativeBridge = () => (globalThis as any).com?.octanexplat?.camera?.CameraSessionBridge
|
|
23
|
+
const permissionName = (kind: CameraPermissionKind) =>
|
|
24
|
+
kind === 'camera' ? 'android.permission.CAMERA' : 'android.permission.RECORD_AUDIO'
|
|
25
|
+
|
|
26
|
+
const error = (kind: CaptureIssue['kind'], operation: string, message: string, cause?: unknown) =>
|
|
27
|
+
new CameraCaptureError({ kind, operation, message, cause })
|
|
28
|
+
|
|
29
|
+
const context = () => Application.android.context
|
|
30
|
+
const preferences = () => context().getSharedPreferences('octane-camera-permissions', 0)
|
|
31
|
+
|
|
32
|
+
async function checkPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
|
|
33
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity
|
|
34
|
+
if (!activity) {
|
|
35
|
+
return 'unavailable'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const permission = permissionName(kind)
|
|
39
|
+
if (activity.checkSelfPermission(permission) === 0) {
|
|
40
|
+
return 'granted'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return preferences().getBoolean(kind, false)
|
|
44
|
+
? activity.shouldShowRequestPermissionRationale(permission)
|
|
45
|
+
? 'denied'
|
|
46
|
+
: 'blocked'
|
|
47
|
+
: 'notDetermined'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requestPermission(kind: CameraPermissionKind): Promise<CameraPermissionStatus> {
|
|
51
|
+
const result = permissionChain.then(async () => {
|
|
52
|
+
const status = await checkPermission(kind)
|
|
53
|
+
if (status !== 'notDetermined') {
|
|
54
|
+
return status
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const activity = Application.android.foregroundActivity ?? Application.android.startActivity
|
|
58
|
+
if (!activity) {
|
|
59
|
+
return 'unavailable' as const
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise<CameraPermissionStatus>((resolve) => {
|
|
63
|
+
const code = requestCode++
|
|
64
|
+
const finish = (value: CameraPermissionStatus) => {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
Application.android.off(Application.android.activityRequestPermissionsEvent, onResult)
|
|
67
|
+
resolve(value)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const onResult = (args: any) => {
|
|
71
|
+
if (args.requestCode !== code) {
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!args.grantResults?.length) {
|
|
76
|
+
finish('unknown')
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
preferences().edit().putBoolean(kind, true).apply()
|
|
81
|
+
finish(
|
|
82
|
+
args.grantResults[0] === 0
|
|
83
|
+
? 'granted'
|
|
84
|
+
: activity.shouldShowRequestPermissionRationale(permissionName(kind))
|
|
85
|
+
? 'denied'
|
|
86
|
+
: 'blocked',
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const timer = setTimeout(() => finish('unknown'), 120_000)
|
|
91
|
+
Application.android.on(Application.android.activityRequestPermissionsEvent, onResult)
|
|
92
|
+
try {
|
|
93
|
+
activity.requestPermissions([permissionName(kind)], code)
|
|
94
|
+
} catch {
|
|
95
|
+
finish('unavailable')
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
permissionChain = result.catch(() => {})
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createSessionBackend(config: CameraSessionConfig): CameraSessionBackend {
|
|
105
|
+
let currentConfig = { ...config }
|
|
106
|
+
let observer: BackendObserver | undefined
|
|
107
|
+
let bridge: any
|
|
108
|
+
let host: any
|
|
109
|
+
let listener: BackendPreviewListener | undefined
|
|
110
|
+
let sink: BackendCaptureSink | undefined
|
|
111
|
+
let released = false
|
|
112
|
+
const lease = {}
|
|
113
|
+
const selected = () =>
|
|
114
|
+
typeof currentConfig.camera === 'object'
|
|
115
|
+
? `id:${currentConfig.camera.deviceId}`
|
|
116
|
+
: (currentConfig.camera ?? 'default')
|
|
117
|
+
|
|
118
|
+
const releaseLease = () => {
|
|
119
|
+
if (owner === lease) {
|
|
120
|
+
owner = undefined
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const onSuspend = () => bridge?.background()
|
|
125
|
+
const onResume = () => bridge?.foreground()
|
|
126
|
+
const ensureBridge = () => {
|
|
127
|
+
if (bridge) {
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const Bridge = nativeBridge()
|
|
132
|
+
if (!Bridge) {
|
|
133
|
+
throw error(
|
|
134
|
+
'unsupportedPlatform',
|
|
135
|
+
'preview',
|
|
136
|
+
'CameraX video bridge is missing from the native build',
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
bridge = new Bridge(
|
|
141
|
+
context(),
|
|
142
|
+
new Bridge.Listener({
|
|
143
|
+
event(type: string, payload: string) {
|
|
144
|
+
if (type === 'ready') {
|
|
145
|
+
observer?.previewReady(payload === 'true')
|
|
146
|
+
if (payload === 'true') {
|
|
147
|
+
listener?.onReady?.()
|
|
148
|
+
}
|
|
149
|
+
} else if (type === 'availability') {
|
|
150
|
+
observer?.availability(payload === 'true')
|
|
151
|
+
} else if (type === 'interruption') {
|
|
152
|
+
observer?.interruption('began', payload as any)
|
|
153
|
+
} else if (type === 'recovered') {
|
|
154
|
+
observer?.interruption('ended', payload as any)
|
|
155
|
+
} else if (type === 'previewError') {
|
|
156
|
+
observer?.availability(false)
|
|
157
|
+
listener?.onError?.(error('unavailable', 'preview', payload))
|
|
158
|
+
if (!host) {
|
|
159
|
+
releaseLease()
|
|
160
|
+
}
|
|
161
|
+
} else if (type === 'finishing') {
|
|
162
|
+
sink?.finishing(payload ? (payload as any) : undefined)
|
|
163
|
+
} else if (type === 'started') {
|
|
164
|
+
sink?.started(JSON.parse(payload))
|
|
165
|
+
} else if (type === 'finished' || type === 'failed') {
|
|
166
|
+
const target = sink
|
|
167
|
+
sink = undefined
|
|
168
|
+
if (!host) {
|
|
169
|
+
releaseLease()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const result = JSON.parse(payload)
|
|
173
|
+
target?.settled(
|
|
174
|
+
type === 'finished'
|
|
175
|
+
? result
|
|
176
|
+
: {
|
|
177
|
+
kind: 'failed',
|
|
178
|
+
stage: result.stage,
|
|
179
|
+
issue: error(result.kind, result.stage, result.message),
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
Application.on(Application.suspendEvent, onSuspend)
|
|
188
|
+
Application.on(Application.resumeEvent, onResume)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
platform: 'android',
|
|
193
|
+
get supported() {
|
|
194
|
+
return !!nativeBridge()
|
|
195
|
+
},
|
|
196
|
+
checkPermission,
|
|
197
|
+
requestPermission: async (kind) => {
|
|
198
|
+
const status = await requestPermission(kind)
|
|
199
|
+
if (
|
|
200
|
+
kind === 'camera' &&
|
|
201
|
+
status === 'granted' &&
|
|
202
|
+
host &&
|
|
203
|
+
!sink &&
|
|
204
|
+
!released &&
|
|
205
|
+
!bridge?.isReady()
|
|
206
|
+
) {
|
|
207
|
+
try {
|
|
208
|
+
ensureBridge()
|
|
209
|
+
bridge.attach(host, selected())
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
observer?.availability(false)
|
|
212
|
+
listener?.onError?.(
|
|
213
|
+
error(
|
|
214
|
+
'unavailable',
|
|
215
|
+
'preview',
|
|
216
|
+
'Could not activate CameraX after permission approval',
|
|
217
|
+
cause,
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return status
|
|
224
|
+
},
|
|
225
|
+
getCapabilities: async () => {
|
|
226
|
+
const value = bridge ? JSON.parse(bridge.capabilities()) : { cameras: [], profiles: [] }
|
|
227
|
+
const supported = !!nativeBridge()
|
|
228
|
+
return {
|
|
229
|
+
supported,
|
|
230
|
+
available: !!bridge?.isReady(),
|
|
231
|
+
reason: !supported
|
|
232
|
+
? 'CameraX video bridge is missing from the native build'
|
|
233
|
+
: !bridge?.isReady()
|
|
234
|
+
? 'An active permitted preview is required'
|
|
235
|
+
: undefined,
|
|
236
|
+
cameras: value.cameras,
|
|
237
|
+
profiles: value.profiles,
|
|
238
|
+
audio: context().getPackageManager().hasSystemFeature('android.hardware.microphone'),
|
|
239
|
+
// The bridge only reports metering support once a camera is
|
|
240
|
+
// bound — absent fields read as unsupported, which is honest
|
|
241
|
+
// for the current acquisition state.
|
|
242
|
+
focusPointOfInterest: value.focusPointOfInterest === true,
|
|
243
|
+
exposurePointOfInterest: value.exposurePointOfInterest === true,
|
|
244
|
+
orientations: [],
|
|
245
|
+
durationLimit: 'native',
|
|
246
|
+
elapsedTime: 'nativeMedia',
|
|
247
|
+
output: {
|
|
248
|
+
mimeType: 'video/mp4',
|
|
249
|
+
container: 'mp4',
|
|
250
|
+
storage: 'appPrivateFile',
|
|
251
|
+
destinationFileUrl: true,
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
previewReady: () => !!bridge?.isReady(),
|
|
256
|
+
validateStart: (options) => {
|
|
257
|
+
if (
|
|
258
|
+
options.maximumDurationMs !== undefined &&
|
|
259
|
+
options.maximumDurationMs > 9_223_372_036_854
|
|
260
|
+
) {
|
|
261
|
+
throw error(
|
|
262
|
+
'unsupportedConfiguration',
|
|
263
|
+
'startRecording',
|
|
264
|
+
'The duration exceeds CameraX media-time range',
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (options.orientation && options.orientation !== 'unspecified') {
|
|
269
|
+
throw error(
|
|
270
|
+
'unsupportedConfiguration',
|
|
271
|
+
'startRecording',
|
|
272
|
+
'Fixed cardinal orientation has not been qualified on this Android source',
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
attachPreview: (target, callbacks) => {
|
|
277
|
+
if (released || sink) {
|
|
278
|
+
throw error('invalidState', 'attachPreview', 'The camera is disposed or finalizing')
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (owner && owner !== lease) {
|
|
282
|
+
throw error('busy', 'attachPreview', 'Another camera session is still finalizing')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
owner = lease
|
|
286
|
+
host = target
|
|
287
|
+
listener = callbacks
|
|
288
|
+
try {
|
|
289
|
+
ensureBridge()
|
|
290
|
+
} catch (cause) {
|
|
291
|
+
host = undefined
|
|
292
|
+
listener = undefined
|
|
293
|
+
releaseLease()
|
|
294
|
+
throw error(
|
|
295
|
+
'unavailable',
|
|
296
|
+
'attachPreview',
|
|
297
|
+
'Could not construct the CameraX native bridge',
|
|
298
|
+
cause,
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
void checkPermission('camera').then((status) => {
|
|
303
|
+
if (released || !host) {
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (status !== 'granted') {
|
|
308
|
+
listener?.onError?.(
|
|
309
|
+
error(
|
|
310
|
+
'permissionDenied',
|
|
311
|
+
'preview',
|
|
312
|
+
'Request camera permission before activating this preview',
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
bridge.attach(host, selected())
|
|
321
|
+
} catch (cause) {
|
|
322
|
+
listener?.onError?.(
|
|
323
|
+
error('unavailable', 'preview', 'Could not bind shared CameraX preview', cause),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
return () => {
|
|
329
|
+
host = undefined
|
|
330
|
+
listener = undefined
|
|
331
|
+
bridge.detach()
|
|
332
|
+
if (!sink) {
|
|
333
|
+
releaseLease()
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
configure: (next) => {
|
|
338
|
+
const sourceChanged =
|
|
339
|
+
JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) ||
|
|
340
|
+
next.profile !== currentConfig.profile
|
|
341
|
+
|
|
342
|
+
currentConfig = { ...next }
|
|
343
|
+
if (host && sourceChanged) {
|
|
344
|
+
bridge.detach()
|
|
345
|
+
bridge.attach(host, selected())
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
focusAtPoint: (point) => {
|
|
349
|
+
try {
|
|
350
|
+
bridge?.focusAtPoint?.(point.x, point.y)
|
|
351
|
+
} catch {
|
|
352
|
+
// A missed metering pass must not fault the session.
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
startCapture: (request, target) => {
|
|
356
|
+
sink = target
|
|
357
|
+
if (request.destinationPath) {
|
|
358
|
+
// Durable output stays app-private like every other leaf —
|
|
359
|
+
// canonicalize so '..' cannot escape the files root.
|
|
360
|
+
const destination = canonicalizePath(request.destinationPath)
|
|
361
|
+
let filesRoot = ''
|
|
362
|
+
try {
|
|
363
|
+
filesRoot = context()?.getFilesDir?.().getAbsolutePath?.() ?? ''
|
|
364
|
+
} catch {
|
|
365
|
+
filesRoot = ''
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!filesRoot || !destination.startsWith(`${filesRoot}/`)) {
|
|
369
|
+
sink = undefined
|
|
370
|
+
target.settled({
|
|
371
|
+
kind: 'failed',
|
|
372
|
+
stage: 'preparation',
|
|
373
|
+
issue: error(
|
|
374
|
+
'destinationUnavailable',
|
|
375
|
+
'startRecording',
|
|
376
|
+
'destinationFileUrl must resolve inside the app-private files directory',
|
|
377
|
+
),
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
request = { ...request, destinationPath: destination }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
bridge.start(
|
|
388
|
+
request.audio,
|
|
389
|
+
request.destinationPath ?? '',
|
|
390
|
+
request.maximumDurationMs === undefined
|
|
391
|
+
? 0
|
|
392
|
+
: Math.max(1, Math.ceil(request.maximumDurationMs)),
|
|
393
|
+
)
|
|
394
|
+
} catch (cause) {
|
|
395
|
+
sink = undefined
|
|
396
|
+
target.settled({
|
|
397
|
+
kind: 'failed',
|
|
398
|
+
stage: 'preparation',
|
|
399
|
+
issue: error(
|
|
400
|
+
'captureFailed',
|
|
401
|
+
'startRecording',
|
|
402
|
+
'CameraX could not begin recording',
|
|
403
|
+
cause,
|
|
404
|
+
),
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
stopCapture: () => bridge?.stop(),
|
|
409
|
+
elapsedMs: () => Number(bridge?.elapsedMs() ?? 0),
|
|
410
|
+
openOutput: async (output) => {
|
|
411
|
+
if (output.kind !== 'nativeFile' || output.synthetic) {
|
|
412
|
+
throw error('invalidArgument', 'openOutput', 'Expected a native Android movie reference')
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
try {
|
|
416
|
+
const url = nativeBridge().openOutput(output.resourceId, output.fileUrl)
|
|
417
|
+
return { url, fileUrl: url, release: () => {} }
|
|
418
|
+
} catch (cause) {
|
|
419
|
+
throw error(
|
|
420
|
+
'destinationUnavailable',
|
|
421
|
+
'openOutput',
|
|
422
|
+
'The stored Android movie cannot be reopened',
|
|
423
|
+
cause,
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
release: async () => {
|
|
428
|
+
released = true
|
|
429
|
+
Application.off(Application.suspendEvent, onSuspend)
|
|
430
|
+
Application.off(Application.resumeEvent, onResume)
|
|
431
|
+
bridge?.release()
|
|
432
|
+
host = undefined
|
|
433
|
+
releaseLease()
|
|
434
|
+
},
|
|
435
|
+
setObserver: (value) => {
|
|
436
|
+
observer = value
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
}
|