@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
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@ pnpm add @octane-xplat/camera
|
|
|
5
5
|
```
|
|
6
6
|
|
|
7
7
|
Live camera preview for Octane xplat apps: `getUserMedia` video on web,
|
|
8
|
-
`AVCaptureSession` on iOS, CameraX on Android
|
|
8
|
+
`AVCaptureSession` on iOS, CameraX on Android,
|
|
9
|
+
`AVCaptureSession` + `AVCaptureVideoPreviewLayer` on macOS (AppKit), and
|
|
10
|
+
`MediaCapture` + `CaptureElement` on the Windows (WinUI 3) target.
|
|
9
11
|
|
|
10
12
|
```tsx
|
|
11
13
|
import { CameraView } from '@octane-xplat/camera'
|
|
@@ -26,8 +28,11 @@ This is the preview surface only — for one-shot still capture and library
|
|
|
26
28
|
picking use [`@octane-xplat/media`](../media/README.md). Permissions are
|
|
27
29
|
requested when the session starts; `onError` is how a denial shows up.
|
|
28
30
|
`ref` hands you the platform surface (`HTMLVideoElement` on web) for
|
|
29
|
-
controls the shared props don't cover, and the `ios`/`android`/`web
|
|
30
|
-
escape props apply after the shared props.
|
|
31
|
+
controls the shared props don't cover, and the `ios`/`android`/`web`/
|
|
32
|
+
`windows` escape props apply after the shared props. On macOS the leaf is a
|
|
33
|
+
compiled Swift `XplatCameraHost` that owns the capture session; the app bundle
|
|
34
|
+
needs `NSCameraUsageDescription` (and `NSMicrophoneUsageDescription` for
|
|
35
|
+
audio) declared through `xplat.targets.macos.package.infoPlist`.
|
|
31
36
|
|
|
32
37
|
```tsx
|
|
33
38
|
// Preview.web.tsx — browser properties belong in this platform file.
|
|
@@ -44,6 +49,87 @@ export function Preview() {
|
|
|
44
49
|
}
|
|
45
50
|
```
|
|
46
51
|
|
|
52
|
+
## Tap-to-focus
|
|
53
|
+
|
|
54
|
+
`CameraView` reports where the user tapped as a normalized point —
|
|
55
|
+
`{x: 0, y: 0}` is the top-left corner of the preview, `{x: 1, y: 1}` the
|
|
56
|
+
bottom-right. Hand the point to a session's `focusAtPoint` to meter one
|
|
57
|
+
autofocus and auto-exposure pass there:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { CameraView, createCameraSession } from '@octane-xplat/camera'
|
|
61
|
+
|
|
62
|
+
const session = createCameraSession()
|
|
63
|
+
|
|
64
|
+
export function FocusablePreview() {
|
|
65
|
+
return <CameraView session={session} onTap={(point) => session.focusAtPoint(point)} />
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`onTap` is a pure notification — it never takes over the gesture, so
|
|
70
|
+
double-tap and pan recognizers layered over the preview keep working.
|
|
71
|
+
`focusAtPoint` needs a session (it drives the camera the preview
|
|
72
|
+
attaches) and quietly does nothing on cameras that cannot meter at a
|
|
73
|
+
point — front cameras are often fixed-focus. Check
|
|
74
|
+
`capabilities().focusPointOfInterest` and `exposurePointOfInterest`
|
|
75
|
+
before showing focus UI. Metering lands on iOS, Android, and macOS; the
|
|
76
|
+
tap event fires on iOS, Android, and web.
|
|
77
|
+
|
|
78
|
+
## Movie recording
|
|
79
|
+
|
|
80
|
+
`createCameraSession` creates a shared camera owner: the same session can
|
|
81
|
+
drive a `CameraView` preview and one movie-recording attempt at a time.
|
|
82
|
+
Pass the session to `CameraView` instead of `facing`; the preview attaches
|
|
83
|
+
the camera the session owns. Recording requires an attached, ready preview.
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { CameraView, createCameraSession } from '@octane-xplat/camera'
|
|
87
|
+
|
|
88
|
+
const session = createCameraSession({ camera: 'default' })
|
|
89
|
+
|
|
90
|
+
export function Recorder() {
|
|
91
|
+
return <CameraView session={session} />
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A take settles after the movie is finalized, metadata is verified, and local
|
|
96
|
+
storage is committed. Native output lives in app-private files — including the
|
|
97
|
+
packaged Linux WebKitGTK host, which writes under the app's data directory;
|
|
98
|
+
Web uses persistent origin storage. Reopen it with `session.openOutput(output)`.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const take = session.startRecording({ maximumDurationMs: 30_000 })
|
|
102
|
+
// … later, on a stop press or a maximum-duration limit:
|
|
103
|
+
const outcome = await take.stop()
|
|
104
|
+
if (outcome.kind === 'clip') {
|
|
105
|
+
const clip = outcome.clip
|
|
106
|
+
console.log(clip.durationMs, clip.width, clip.height, clip.output)
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
One preview and one recording per session; a second `startRecording` throws
|
|
111
|
+
`CameraCaptureError` with `kind: 'busy'`. Check `session.capabilities()` for
|
|
112
|
+
`supported`/`available` before presenting capture UI, and `session.snapshot()`
|
|
113
|
+
or `session.subscribe` for state. iOS, Android, Web, macOS, the Windows WinUI
|
|
114
|
+
target, and the packaged Linux WebKitGTK host have recording adapters; Web
|
|
115
|
+
requires persistent origin storage.
|
|
116
|
+
macOS writes app-private MOV files through `AVCaptureMovieFileOutput` and
|
|
117
|
+
reports finalized track metadata; on macOS 14+ the four cardinal orientations
|
|
118
|
+
are lockable. Inside a qualified `xplat` WKWebView host the Web backend stores
|
|
119
|
+
output in the app's private storage root instead of IndexedDB. Windows records
|
|
120
|
+
through `MediaCapture` into app-private MP4 files and is pending real-host
|
|
121
|
+
qualification. On Linux the GJS/WebKitGTK host owns media permissions and
|
|
122
|
+
writes MP4 clips under the app's data directory.
|
|
123
|
+
See [Record and reopen a camera movie](../../docs/app/movie-capture.md) for
|
|
124
|
+
explicit permission actions, audio, local output, and qualification limits.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
console.log(await session.permissions())
|
|
128
|
+
// Call from an app button:
|
|
129
|
+
await session.requestPermission('camera')
|
|
130
|
+
console.log(await session.capabilities())
|
|
131
|
+
```
|
|
132
|
+
|
|
47
133
|
Guide: [Building screens](../../docs/app/primitives.md) (leaf components);
|
|
48
134
|
component index: [`docs/app/components.md`](../../docs/app/components.md).
|
|
49
135
|
Exercised by [`CameraDemo`](../demos/src/CameraDemo.tsrx).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octane-xplat/camera",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Live camera preview for Octane xplat — web getUserMedia + native capture (AVCapture / CameraX)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|
|
@@ -23,31 +23,39 @@
|
|
|
23
23
|
"directory": "packages/camera"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
|
-
"dist",
|
|
27
26
|
"types",
|
|
28
27
|
"src",
|
|
29
|
-
"platforms"
|
|
28
|
+
"platforms",
|
|
29
|
+
"!src/**/*.test.*",
|
|
30
|
+
"!src/**/*.spec.*"
|
|
30
31
|
],
|
|
31
32
|
"type": "module",
|
|
32
33
|
"exports": {
|
|
33
34
|
".": {
|
|
34
35
|
"types": "./types/index.d.ts",
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
36
|
+
"linux": "./src/index.web.ts",
|
|
37
|
+
"web": "./src/index.web.ts",
|
|
38
|
+
"native": "./src/index.ts",
|
|
39
|
+
"default": "./src/index.ts"
|
|
38
40
|
}
|
|
39
41
|
},
|
|
40
42
|
"publishConfig": {
|
|
41
43
|
"access": "public"
|
|
42
44
|
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@octane-xplat/platform": "0.15.0",
|
|
47
|
+
"mediabunny": "1.61.3"
|
|
48
|
+
},
|
|
43
49
|
"devDependencies": {
|
|
44
|
-
"@nativescript-community/octane": "0.2.
|
|
50
|
+
"@nativescript-community/octane": "0.2.5",
|
|
45
51
|
"@nativescript/core": "9.1.3",
|
|
52
|
+
"@octane-xplat/macos-renderer": "0.15.0",
|
|
46
53
|
"@octanejs/vite-plugin": "0.1.61",
|
|
47
54
|
"octane": "0.11.0",
|
|
48
|
-
"tsrx-typegen": "0.
|
|
55
|
+
"tsrx-typegen": "0.3.0",
|
|
49
56
|
"typescript": "5.9.3",
|
|
50
|
-
"vite": "8.3.0"
|
|
57
|
+
"vite": "8.3.0",
|
|
58
|
+
"vitest": "4.1.11"
|
|
51
59
|
},
|
|
52
60
|
"peerDependencies": {
|
|
53
61
|
"@nativescript-community/octane": ">=0.2.1 <1",
|
|
@@ -71,10 +79,26 @@
|
|
|
71
79
|
"android": "7.0.0"
|
|
72
80
|
}
|
|
73
81
|
},
|
|
82
|
+
"xplat": {
|
|
83
|
+
"macos": {
|
|
84
|
+
"swiftHeaderImports": [
|
|
85
|
+
"AppKit/AppKit.h"
|
|
86
|
+
],
|
|
87
|
+
"frameworks": [
|
|
88
|
+
"AVFoundation",
|
|
89
|
+
"CoreMedia",
|
|
90
|
+
"AppKit"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
},
|
|
74
94
|
"scripts": {
|
|
75
|
-
"build": "vite build && vite build --mode native && pnpm typegen && node ../../scripts/check-native-dist.mjs",
|
|
76
95
|
"typegen": "tsc -p tsconfig.types.json",
|
|
77
96
|
"test:packed": "node tests/packed-consumer.mjs",
|
|
78
|
-
"pack:check": "tsrx-typegen --pack-check"
|
|
97
|
+
"pack:check": "tsrx-typegen --pack-check",
|
|
98
|
+
"test": "vitest run && node --test tests/*.test.mjs",
|
|
99
|
+
"test:macos-photo": "node tests/photo-native.macos.mjs",
|
|
100
|
+
"test:macos-photo-hardware": "node tests/photo-hardware.macos.mjs",
|
|
101
|
+
"test:web": "node tests/browser-conformance.web.mjs",
|
|
102
|
+
"test:webkitgtk": "node tests/webkitgtk-conformance.linux.mjs"
|
|
79
103
|
}
|
|
80
104
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
2
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
|
3
|
+
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
|
3
4
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
5
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
4
6
|
</manifest>
|
|
@@ -10,4 +10,5 @@ dependencies {
|
|
|
10
10
|
implementation "androidx.camera:camera-camera2:${cameraXVersion}"
|
|
11
11
|
implementation "androidx.camera:camera-lifecycle:${cameraXVersion}"
|
|
12
12
|
implementation "androidx.camera:camera-view:${cameraXVersion}"
|
|
13
|
+
implementation "androidx.camera:camera-video:${cameraXVersion}"
|
|
13
14
|
}
|
|
@@ -4,6 +4,7 @@ import android.content.Context;
|
|
|
4
4
|
import android.view.ViewGroup;
|
|
5
5
|
import android.widget.FrameLayout;
|
|
6
6
|
|
|
7
|
+
import androidx.camera.core.MeteringPoint;
|
|
7
8
|
import androidx.camera.core.Preview;
|
|
8
9
|
import androidx.camera.view.PreviewView;
|
|
9
10
|
import androidx.lifecycle.Lifecycle;
|
|
@@ -45,6 +46,18 @@ public final class CameraPreviewView extends FrameLayout implements LifecycleOwn
|
|
|
45
46
|
return previewView.getSurfaceProvider();
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** Converts a normalized view-space point (0-1, top-left origin) into a
|
|
50
|
+
* CameraX metering point. The PreviewView factory accounts for scale
|
|
51
|
+
* type, rotation, and front-camera mirroring. */
|
|
52
|
+
public MeteringPoint meteringPoint(float x, float y) {
|
|
53
|
+
return previewView.getMeteringPointFactory().createPoint(
|
|
54
|
+
x * previewView.getWidth(), y * previewView.getHeight());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public androidx.lifecycle.LiveData<PreviewView.StreamState> getStreamState() {
|
|
58
|
+
return previewView.getPreviewStreamState();
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
@Override
|
|
49
62
|
protected void onAttachedToWindow() {
|
|
50
63
|
super.onAttachedToWindow();
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
package com.octanexplat.camera;
|
|
2
|
+
|
|
3
|
+
import android.Manifest;
|
|
4
|
+
import android.content.Context;
|
|
5
|
+
import android.content.pm.PackageManager;
|
|
6
|
+
import android.media.MediaExtractor;
|
|
7
|
+
import android.media.MediaFormat;
|
|
8
|
+
import android.media.MediaMetadataRetriever;
|
|
9
|
+
import android.net.Uri;
|
|
10
|
+
import android.os.Handler;
|
|
11
|
+
import android.os.Looper;
|
|
12
|
+
import android.util.Size;
|
|
13
|
+
import android.view.Surface;
|
|
14
|
+
import androidx.camera.camera2.interop.Camera2CameraInfo;
|
|
15
|
+
import androidx.camera.core.Camera;
|
|
16
|
+
import androidx.camera.core.CameraInfo;
|
|
17
|
+
import androidx.camera.core.CameraSelector;
|
|
18
|
+
import androidx.camera.core.CameraState;
|
|
19
|
+
import androidx.camera.core.DynamicRange;
|
|
20
|
+
import androidx.camera.core.FocusMeteringAction;
|
|
21
|
+
import androidx.camera.core.MeteringPoint;
|
|
22
|
+
import androidx.camera.core.MirrorMode;
|
|
23
|
+
import androidx.camera.core.Preview;
|
|
24
|
+
import androidx.camera.lifecycle.ProcessCameraProvider;
|
|
25
|
+
import androidx.camera.video.FileOutputOptions;
|
|
26
|
+
import androidx.camera.video.PendingRecording;
|
|
27
|
+
import androidx.camera.video.Quality;
|
|
28
|
+
import androidx.camera.video.QualitySelector;
|
|
29
|
+
import androidx.camera.video.Recorder;
|
|
30
|
+
import androidx.camera.video.Recording;
|
|
31
|
+
import androidx.camera.video.VideoCapture;
|
|
32
|
+
import androidx.camera.video.VideoRecordEvent;
|
|
33
|
+
import androidx.core.content.ContextCompat;
|
|
34
|
+
import androidx.lifecycle.Lifecycle;
|
|
35
|
+
import androidx.lifecycle.LifecycleOwner;
|
|
36
|
+
import androidx.lifecycle.LifecycleRegistry;
|
|
37
|
+
import com.google.common.util.concurrent.ListenableFuture;
|
|
38
|
+
import java.io.File;
|
|
39
|
+
import java.io.FileInputStream;
|
|
40
|
+
import java.io.FileOutputStream;
|
|
41
|
+
import java.util.Collections;
|
|
42
|
+
import java.util.UUID;
|
|
43
|
+
import java.util.concurrent.TimeUnit;
|
|
44
|
+
import org.json.JSONArray;
|
|
45
|
+
import org.json.JSONObject;
|
|
46
|
+
|
|
47
|
+
/** CameraX is bound to this owner, not the preview's window lifetime. */
|
|
48
|
+
public final class CameraSessionBridge implements LifecycleOwner {
|
|
49
|
+
public interface Listener { void event(String type, String payload); }
|
|
50
|
+
private final Context context;
|
|
51
|
+
private final LifecycleRegistry lifecycle = new LifecycleRegistry(this);
|
|
52
|
+
private final Handler handler = new Handler(Looper.getMainLooper());
|
|
53
|
+
private Listener listener;
|
|
54
|
+
private ProcessCameraProvider provider;
|
|
55
|
+
private Preview preview;
|
|
56
|
+
private VideoCapture<Recorder> video;
|
|
57
|
+
private Camera camera;
|
|
58
|
+
private CameraPreviewView host;
|
|
59
|
+
private CameraPreviewView observedHost;
|
|
60
|
+
private Recording recording;
|
|
61
|
+
private File staging;
|
|
62
|
+
private File destination;
|
|
63
|
+
private boolean detached;
|
|
64
|
+
private boolean released;
|
|
65
|
+
private boolean backgrounded;
|
|
66
|
+
private boolean resumeAfterFinalization;
|
|
67
|
+
private boolean ownsDestination;
|
|
68
|
+
private boolean ready;
|
|
69
|
+
private boolean started;
|
|
70
|
+
private boolean finalizing;
|
|
71
|
+
private final java.util.concurrent.ExecutorService finalizer = java.util.concurrent.Executors.newSingleThreadExecutor();
|
|
72
|
+
private boolean audioRequested;
|
|
73
|
+
private long elapsed;
|
|
74
|
+
private volatile double sourceFrameRate;
|
|
75
|
+
private long lastFrameTime;
|
|
76
|
+
private long takeGeneration;
|
|
77
|
+
private volatile int generation;
|
|
78
|
+
private Runnable watchdog;
|
|
79
|
+
private Runnable startWatchdog;
|
|
80
|
+
private String selection = "default";
|
|
81
|
+
private String cameraId = "";
|
|
82
|
+
private String facing;
|
|
83
|
+
private int rotation;
|
|
84
|
+
|
|
85
|
+
public CameraSessionBridge(Context context, Listener listener) {
|
|
86
|
+
this.context = context.getApplicationContext();
|
|
87
|
+
this.listener = listener;
|
|
88
|
+
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);
|
|
89
|
+
}
|
|
90
|
+
@Override public Lifecycle getLifecycle() { return lifecycle; }
|
|
91
|
+
private void emit(String type, String value) { if (listener != null) listener.event(type, value); }
|
|
92
|
+
private void ready(boolean value) { ready = value; emit("ready", Boolean.toString(value)); }
|
|
93
|
+
public boolean isReady() { return ready && sourceFrameRate > 0 && !detached && !released; }
|
|
94
|
+
public long elapsedMs() { return elapsed; }
|
|
95
|
+
private void failure(String stage, String kind, String message) {
|
|
96
|
+
try { emit("failed", new JSONObject().put("stage", stage).put("kind", kind).put("message", message).toString()); }
|
|
97
|
+
catch (Exception ignored) { emit("failed", "{\"stage\":\"capture\",\"kind\":\"captureFailed\",\"message\":\"Camera failure\"}"); }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public void attach(CameraPreviewView view, String selected) {
|
|
101
|
+
if (host == view && !detached && preview != null && selection.equals(selected)) return;
|
|
102
|
+
if (recording != null || released) throw new IllegalStateException("Camera is finalizing or disposed");
|
|
103
|
+
unbind();
|
|
104
|
+
host = view;
|
|
105
|
+
detached = false;
|
|
106
|
+
selection = selected;
|
|
107
|
+
final int epoch = ++generation;
|
|
108
|
+
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START);
|
|
109
|
+
ListenableFuture<ProcessCameraProvider> future = ProcessCameraProvider.getInstance(context);
|
|
110
|
+
future.addListener(() -> {
|
|
111
|
+
if (released || detached || epoch != generation) return;
|
|
112
|
+
try {
|
|
113
|
+
provider = future.get();
|
|
114
|
+
CameraSelector selector = selectCamera(selected);
|
|
115
|
+
CameraInfo info = provider.getAvailableCameraInfos().stream().filter(i -> selector.filter(Collections.singletonList(i)).size() > 0).findFirst().orElseThrow(() -> new IllegalArgumentException("Selected camera unavailable"));
|
|
116
|
+
cameraId = Camera2CameraInfo.from(info).getCameraId();
|
|
117
|
+
Integer lens = info.getLensFacing();
|
|
118
|
+
facing = lens != null && lens == CameraSelector.LENS_FACING_FRONT ? "front" : lens != null && lens == CameraSelector.LENS_FACING_BACK ? "back" : null;
|
|
119
|
+
java.util.List<Quality> qualities = Recorder.getVideoCapabilities(info).getSupportedQualities(DynamicRange.SDR);
|
|
120
|
+
if (qualities.isEmpty()) throw new IllegalArgumentException("No SDR recording profile");
|
|
121
|
+
Quality quality = qualities.contains(Quality.HD) ? Quality.HD : qualities.get(0);
|
|
122
|
+
Recorder recorder = new Recorder.Builder().setQualitySelector(QualitySelector.from(quality)).build();
|
|
123
|
+
rotation = view.getDisplay() == null ? Surface.ROTATION_0 : view.getDisplay().getRotation();
|
|
124
|
+
video = new VideoCapture.Builder<>(recorder).setMirrorMode(MirrorMode.MIRROR_MODE_OFF).setTargetRotation(rotation).setTargetFrameRate(new android.util.Range<>(30, 30)).build();
|
|
125
|
+
Preview.Builder previewBuilder = new Preview.Builder().setTargetRotation(rotation);
|
|
126
|
+
sourceFrameRate = 0; lastFrameTime = 0;
|
|
127
|
+
new androidx.camera.camera2.interop.Camera2Interop.Extender<>(previewBuilder).setSessionCaptureCallback(new android.hardware.camera2.CameraCaptureSession.CaptureCallback() {
|
|
128
|
+
@Override public void onCaptureCompleted(android.hardware.camera2.CameraCaptureSession session, android.hardware.camera2.CaptureRequest request, android.hardware.camera2.TotalCaptureResult result) {
|
|
129
|
+
if (released || epoch != generation) return;
|
|
130
|
+
Long timestamp = result.get(android.hardware.camera2.CaptureResult.SENSOR_TIMESTAMP);
|
|
131
|
+
if (timestamp != null && timestamp > lastFrameTime) {
|
|
132
|
+
if (lastFrameTime > 0) sourceFrameRate = 1_000_000_000.0 / (timestamp - lastFrameTime);
|
|
133
|
+
lastFrameTime = timestamp;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
preview = previewBuilder.build();
|
|
138
|
+
preview.setSurfaceProvider(view.getSurfaceProvider());
|
|
139
|
+
camera = provider.bindToLifecycle(this, selector, preview, video);
|
|
140
|
+
camera.getCameraInfo().getCameraState().observe(this, state -> {
|
|
141
|
+
if (released || epoch != generation) return;
|
|
142
|
+
if (state.getError() != null || state.getType() == CameraState.Type.CLOSED) {
|
|
143
|
+
ready(false);
|
|
144
|
+
emit("availability", "false");
|
|
145
|
+
if (recording != null) { emit("interruption", "cameraInUse"); stop(); }
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
observedHost = view;
|
|
149
|
+
view.getStreamState().observe(this, state -> {
|
|
150
|
+
if (host != view || detached || released || epoch != generation) return;
|
|
151
|
+
boolean streaming = state == androidx.camera.view.PreviewView.StreamState.STREAMING;
|
|
152
|
+
ready(streaming);
|
|
153
|
+
if (streaming) { emit("availability", "true"); emit("recovered", "unknown"); }
|
|
154
|
+
});
|
|
155
|
+
} catch (Exception cause) { ready(false); emit("previewError", cause.toString()); unbind(); }
|
|
156
|
+
}, ContextCompat.getMainExecutor(context));
|
|
157
|
+
}
|
|
158
|
+
private CameraSelector selectCamera(String selected) throws androidx.camera.core.CameraInfoUnavailableException {
|
|
159
|
+
if (selected.startsWith("id:")) return new CameraSelector.Builder().addCameraFilter(infos -> {
|
|
160
|
+
java.util.List<CameraInfo> found = new java.util.ArrayList<>();
|
|
161
|
+
for (CameraInfo info : infos) if (Camera2CameraInfo.from(info).getCameraId().equals(selected.substring(3))) found.add(info);
|
|
162
|
+
return found;
|
|
163
|
+
}).build();
|
|
164
|
+
if ("front".equals(selected)) return CameraSelector.DEFAULT_FRONT_CAMERA;
|
|
165
|
+
if ("back".equals(selected)) return CameraSelector.DEFAULT_BACK_CAMERA;
|
|
166
|
+
return provider.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) ? CameraSelector.DEFAULT_BACK_CAMERA : new CameraSelector.Builder().build();
|
|
167
|
+
}
|
|
168
|
+
public void detach() {
|
|
169
|
+
detached = true;
|
|
170
|
+
generation++;
|
|
171
|
+
host = null;
|
|
172
|
+
ready(false);
|
|
173
|
+
if (recording == null) unbind();
|
|
174
|
+
}
|
|
175
|
+
public void background() { backgrounded = true; resumeAfterFinalization = false; ready(false); emit("interruption", "background"); if (recording != null) stop(); else unbind(); }
|
|
176
|
+
public void foreground() { backgrounded = false; emit("recovered", "background"); if (recording != null) { resumeAfterFinalization = true; } else if (host != null && !detached) { unbind(); attach(host, selection); } }
|
|
177
|
+
private void unbind() {
|
|
178
|
+
if (camera != null) camera.getCameraInfo().getCameraState().removeObservers(this);
|
|
179
|
+
if (observedHost != null) { observedHost.getStreamState().removeObservers(this); observedHost = null; }
|
|
180
|
+
if (provider != null) {
|
|
181
|
+
if (preview != null) provider.unbind(preview);
|
|
182
|
+
if (video != null) provider.unbind(video);
|
|
183
|
+
}
|
|
184
|
+
preview = null; video = null; camera = null;
|
|
185
|
+
if (lifecycle.getCurrentState().isAtLeast(Lifecycle.State.STARTED)) lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP);
|
|
186
|
+
}
|
|
187
|
+
public void release() { released = true; generation++; host = null; unbind(); lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); listener = null; finalizer.shutdown(); }
|
|
188
|
+
|
|
189
|
+
public String capabilities() {
|
|
190
|
+
try {
|
|
191
|
+
JSONObject result = new JSONObject();
|
|
192
|
+
JSONArray cameras = new JSONArray();
|
|
193
|
+
if (provider != null) for (CameraInfo info : provider.getAvailableCameraInfos()) {
|
|
194
|
+
JSONObject device = new JSONObject().put("id", Camera2CameraInfo.from(info).getCameraId()).put("label", "Camera " + Camera2CameraInfo.from(info).getCameraId());
|
|
195
|
+
Integer lens = info.getLensFacing();
|
|
196
|
+
if (lens != null && lens == CameraSelector.LENS_FACING_FRONT) device.put("facing", "front");
|
|
197
|
+
if (lens != null && lens == CameraSelector.LENS_FACING_BACK) device.put("facing", "back");
|
|
198
|
+
cameras.put(device);
|
|
199
|
+
}
|
|
200
|
+
JSONArray profiles = new JSONArray();
|
|
201
|
+
if (video != null && video.getResolutionInfo() != null && sourceFrameRate > 0) {
|
|
202
|
+
Size size = video.getResolutionInfo().getResolution();
|
|
203
|
+
profiles.put(new JSONObject().put("profile", "standard").put("width", size.getWidth()).put("height", size.getHeight()).put("frameRate", Math.round(sourceFrameRate * 100.0) / 100.0));
|
|
204
|
+
}
|
|
205
|
+
if (camera != null && host != null && host.getWidth() > 0) {
|
|
206
|
+
MeteringPoint center = host.meteringPoint(0.5f, 0.5f);
|
|
207
|
+
result.put("focusPointOfInterest", camera.getCameraInfo().isFocusMeteringSupported(
|
|
208
|
+
new FocusMeteringAction.Builder(center, FocusMeteringAction.FLAG_AF).build()));
|
|
209
|
+
result.put("exposurePointOfInterest", camera.getCameraInfo().isFocusMeteringSupported(
|
|
210
|
+
new FocusMeteringAction.Builder(center, FocusMeteringAction.FLAG_AE).build()));
|
|
211
|
+
}
|
|
212
|
+
return result.put("cameras", cameras).put("profiles", profiles).toString();
|
|
213
|
+
} catch (Exception cause) { return "{\"cameras\":[],\"profiles\":[]}"; }
|
|
214
|
+
}
|
|
215
|
+
/** Tap-to-focus: one AF+AE metering pass at a normalized preview point
|
|
216
|
+
* (0-1, top-left origin as the user sees it). Metering auto-cancels
|
|
217
|
+
* after three seconds rather than locking. Returns false when no
|
|
218
|
+
* camera is bound or the device cannot meter at a point. */
|
|
219
|
+
public boolean focusAtPoint(float x, float y) {
|
|
220
|
+
if (camera == null || host == null || detached || released || host.getWidth() <= 0) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
MeteringPoint point = host.meteringPoint(x, y);
|
|
225
|
+
FocusMeteringAction action = new FocusMeteringAction.Builder(
|
|
226
|
+
point, FocusMeteringAction.FLAG_AF | FocusMeteringAction.FLAG_AE)
|
|
227
|
+
.setAutoCancelDuration(3, TimeUnit.SECONDS)
|
|
228
|
+
.build();
|
|
229
|
+
if (!camera.getCameraInfo().isFocusMeteringSupported(action)) return false;
|
|
230
|
+
camera.getCameraControl().startFocusAndMetering(action);
|
|
231
|
+
return true;
|
|
232
|
+
} catch (Exception cause) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
private String orientation() {
|
|
237
|
+
// Display rotation is not a cardinal camera orientation on every device.
|
|
238
|
+
return "unspecified";
|
|
239
|
+
}
|
|
240
|
+
public void start(boolean audio, String destinationPath, long maximumDurationMs) {
|
|
241
|
+
if (!isReady() || video == null) { failure("preparation", "unavailable", "Preview source is inactive"); return; }
|
|
242
|
+
if (audio && ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { failure("preparation", "permissionDenied", "Requested microphone permission is missing"); return; }
|
|
243
|
+
audioRequested = audio; elapsed = 0; started = false;
|
|
244
|
+
final long take = ++takeGeneration;
|
|
245
|
+
rotation = host.getDisplay() == null ? Surface.ROTATION_0 : host.getDisplay().getRotation();
|
|
246
|
+
video.setTargetRotation(rotation);
|
|
247
|
+
try {
|
|
248
|
+
File directory = new File(context.getFilesDir(), "octane-camera");
|
|
249
|
+
if (!directory.isDirectory() && !directory.mkdirs()) throw new java.io.IOException("Cannot create private movie storage");
|
|
250
|
+
destination = destinationPath == null || destinationPath.isEmpty() ? new File(directory, UUID.randomUUID() + ".mp4") : new File(destinationPath);
|
|
251
|
+
if (!destination.getName().endsWith(".mp4") || !destination.isAbsolute()) { destination = null; failure("preparation", "destinationUnavailable", "Destination must be an absolute .mp4 file"); return; }
|
|
252
|
+
String path = destination.getCanonicalPath();
|
|
253
|
+
File cache = context.getCacheDir();
|
|
254
|
+
File externalCache = context.getExternalCacheDir();
|
|
255
|
+
if (path.startsWith(cache.getCanonicalPath() + File.separator) || externalCache != null && path.startsWith(externalCache.getCanonicalPath() + File.separator)) {
|
|
256
|
+
destination = null; failure("preparation", "destinationUnavailable", "Cache paths do not provide durable movie storage"); return;
|
|
257
|
+
}
|
|
258
|
+
if (!destination.createNewFile()) { destination = null; failure("preparation", "destinationUnavailable", "Destination already exists"); return; }
|
|
259
|
+
ownsDestination = true;
|
|
260
|
+
staging = new File(directory, UUID.randomUUID() + ".recording.mp4");
|
|
261
|
+
FileOutputOptions.Builder options = new FileOutputOptions.Builder(staging);
|
|
262
|
+
if (maximumDurationMs > 0) options.setDurationLimitMillis(maximumDurationMs);
|
|
263
|
+
PendingRecording pending = video.getOutput().prepareRecording(context, options.build());
|
|
264
|
+
if (audio) pending = pending.withAudioEnabled();
|
|
265
|
+
recording = pending.start(ContextCompat.getMainExecutor(context), event -> {
|
|
266
|
+
if (take != takeGeneration || recording == null) return;
|
|
267
|
+
elapsed = event.getRecordingStats().getRecordedDurationNanos() / 1_000_000;
|
|
268
|
+
if (event instanceof VideoRecordEvent.Start) {
|
|
269
|
+
started = true;
|
|
270
|
+
if (startWatchdog != null) handler.removeCallbacks(startWatchdog);
|
|
271
|
+
try {
|
|
272
|
+
Size size = video.getResolutionInfo().getResolution();
|
|
273
|
+
JSONObject effective = new JSONObject().put("cameraId", cameraId).put("audio", audio).put("width", size.getWidth()).put("height", size.getHeight()).put("frameRate", Math.round(sourceFrameRate * 100.0) / 100.0).put("orientation", orientation()).put("mimeType", "video/mp4").put("container", "mp4");
|
|
274
|
+
if (facing != null) effective.put("facing", facing);
|
|
275
|
+
emit("started", effective.toString());
|
|
276
|
+
} catch (Exception cause) { stop(); }
|
|
277
|
+
} else if (event instanceof VideoRecordEvent.Finalize) finish((VideoRecordEvent.Finalize) event);
|
|
278
|
+
});
|
|
279
|
+
startWatchdog = () -> { if (!started && recording != null) stop(); };
|
|
280
|
+
handler.postDelayed(startWatchdog, 15_000);
|
|
281
|
+
} catch (Exception cause) {
|
|
282
|
+
String kind = cause.toString().contains("ENOSPC") ? "insufficientStorage" : cause instanceof java.io.IOException || cause instanceof SecurityException && !ownsDestination ? "destinationUnavailable" : "captureFailed";
|
|
283
|
+
cleanup(); failure("preparation", kind, cause.toString());
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
public void stop() {
|
|
287
|
+
if (recording == null || watchdog != null || finalizing) return;
|
|
288
|
+
try { recording.stop(); } catch (Exception cause) { cleanup(); failure("capture", "captureFailed", cause.toString()); return; }
|
|
289
|
+
// A lost native terminal callback cannot leave completion dangling.
|
|
290
|
+
watchdog = () -> { if (recording != null) { recording.close(); cleanup(); failure("finalization", "finalizationFailed", "CameraX finalization timed out"); if (detached || released || backgrounded) unbind(); } };
|
|
291
|
+
handler.postDelayed(watchdog, 30_000);
|
|
292
|
+
}
|
|
293
|
+
private void finish(VideoRecordEvent.Finalize event) {
|
|
294
|
+
if (recording == null || finalizing) return;
|
|
295
|
+
finalizing = true;
|
|
296
|
+
if (watchdog != null) handler.removeCallbacks(watchdog);
|
|
297
|
+
if (startWatchdog != null) handler.removeCallbacks(startWatchdog);
|
|
298
|
+
watchdog = null; startWatchdog = null;
|
|
299
|
+
int nativeError = event.getError();
|
|
300
|
+
|
|
301
|
+
if (nativeError == VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE) emit("interruption", "unknown");
|
|
302
|
+
emit("finishing", nativeError == VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED ? "maximumDuration" : nativeError == VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE ? "interrupted" : nativeError == VideoRecordEvent.Finalize.ERROR_NONE ? "" : "captureError");
|
|
303
|
+
finalizer.execute(() -> finalizeFile(nativeError));
|
|
304
|
+
}
|
|
305
|
+
private void finalizeFile(int nativeError) {
|
|
306
|
+
String stage = "finalization";
|
|
307
|
+
try {
|
|
308
|
+
if (!started) throw new java.io.IOException("No capture start was observed");
|
|
309
|
+
inspect(staging, audioRequested);
|
|
310
|
+
stage = "storage";
|
|
311
|
+
// Sync the finalized media, then commit to our exclusively reserved file.
|
|
312
|
+
try (FileInputStream input = new FileInputStream(staging); FileOutputStream output = new FileOutputStream(destination)) {
|
|
313
|
+
byte[] buffer = new byte[65536]; int count;
|
|
314
|
+
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
|
|
315
|
+
output.getFD().sync();
|
|
316
|
+
}
|
|
317
|
+
JSONObject committed = inspect(destination, audioRequested);
|
|
318
|
+
if (destination.length() != staging.length()) throw new java.io.IOException("Committed file size mismatch");
|
|
319
|
+
String url = Uri.fromFile(destination).toString();
|
|
320
|
+
committed.put("output", new JSONObject().put("kind", "nativeFile").put("resourceId", url).put("fileUrl", url));
|
|
321
|
+
committed.put("kind", "clip").put("limitReached", nativeError == VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED);
|
|
322
|
+
if (nativeError != VideoRecordEvent.Finalize.ERROR_NONE && nativeError != VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED) {
|
|
323
|
+
committed.put("endReason", nativeError == VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE ? "interrupted" : "captureError");
|
|
324
|
+
committed.put("warning", new JSONObject().put("kind", "captureFailed").put("operation", "capture").put("message", "CameraX finalized with error " + nativeError));
|
|
325
|
+
}
|
|
326
|
+
final String result = committed.toString();
|
|
327
|
+
handler.post(() -> {
|
|
328
|
+
staging.delete(); staging = null; destination = null; ownsDestination = false; recording = null; finalizing = false;
|
|
329
|
+
if (detached || released || backgrounded) unbind();
|
|
330
|
+
emit("finished", result);
|
|
331
|
+
resumePreviewIfNeeded();
|
|
332
|
+
});
|
|
333
|
+
} catch (Exception cause) {
|
|
334
|
+
final boolean noMedia = staging == null || !staging.isFile() || staging.length() == 0 || !started;
|
|
335
|
+
final String failedStage = nativeError == VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE ? "storage" : stage;
|
|
336
|
+
final String kind = noMedia ? "noMedia" : nativeError == VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE ? "insufficientStorage" : "storage".equals(stage) ? "storageFailed" : cause.getMessage() != null && cause.getMessage().contains("Requested audio") ? "captureFailed" : "finalizationFailed";
|
|
337
|
+
handler.post(() -> {
|
|
338
|
+
cleanup();
|
|
339
|
+
if (detached || released || backgrounded) unbind();
|
|
340
|
+
failure(failedStage, kind, cause.toString());
|
|
341
|
+
resumePreviewIfNeeded();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
private void resumePreviewIfNeeded() {
|
|
346
|
+
if (resumeAfterFinalization && host != null && !detached && !released) { resumeAfterFinalization = false; unbind(); attach(host, selection); }
|
|
347
|
+
}
|
|
348
|
+
private void cleanup() {
|
|
349
|
+
finalizing = false;
|
|
350
|
+
if (watchdog != null) handler.removeCallbacks(watchdog);
|
|
351
|
+
if (startWatchdog != null) handler.removeCallbacks(startWatchdog);
|
|
352
|
+
watchdog = null; startWatchdog = null; recording = null; takeGeneration++;
|
|
353
|
+
if (staging != null) staging.delete();
|
|
354
|
+
if (destination != null && ownsDestination) destination.delete();
|
|
355
|
+
ownsDestination = false;
|
|
356
|
+
staging = null; destination = null;
|
|
357
|
+
}
|
|
358
|
+
private static JSONObject inspect(File file, boolean audioRequested) throws Exception {
|
|
359
|
+
if (file == null || !file.isFile() || file.length() == 0) throw new java.io.IOException("No finalized movie");
|
|
360
|
+
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
|
|
361
|
+
MediaExtractor extractor = new MediaExtractor();
|
|
362
|
+
try {
|
|
363
|
+
retriever.setDataSource(file.getAbsolutePath());
|
|
364
|
+
extractor.setDataSource(file.getAbsolutePath());
|
|
365
|
+
String containerMime = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE);
|
|
366
|
+
if (!"video/mp4".equals(containerMime)) throw new java.io.IOException("Finalized movie is not the advertised MP4 container: " + containerMime);
|
|
367
|
+
long duration = Long.parseLong(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
|
|
368
|
+
int width = Integer.parseInt(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH));
|
|
369
|
+
int height = Integer.parseInt(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT));
|
|
370
|
+
String rawRotation = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION);
|
|
371
|
+
int rotation = rawRotation == null ? 0 : Integer.parseInt(rawRotation);
|
|
372
|
+
if (rotation == 90 || rotation == 270) { int swap = width; width = height; height = swap; }
|
|
373
|
+
boolean hasVideo = false, hasAudio = false;
|
|
374
|
+
for (int i = 0; i < extractor.getTrackCount(); i++) {
|
|
375
|
+
MediaFormat format = extractor.getTrackFormat(i);
|
|
376
|
+
String mime = format.getString(MediaFormat.KEY_MIME);
|
|
377
|
+
extractor.selectTrack(i);
|
|
378
|
+
boolean samples = extractor.getSampleTime() >= 0;
|
|
379
|
+
extractor.unselectTrack(i);
|
|
380
|
+
if (samples && mime != null && mime.startsWith("video/")) hasVideo = true;
|
|
381
|
+
if (samples && mime != null && mime.startsWith("audio/")) hasAudio = true;
|
|
382
|
+
}
|
|
383
|
+
if (!hasVideo || duration <= 0 || width <= 0 || height <= 0) throw new java.io.IOException("Movie has no usable video");
|
|
384
|
+
if (audioRequested && !hasAudio) throw new java.io.IOException("Requested audio is absent");
|
|
385
|
+
return new JSONObject().put("mimeType", containerMime).put("durationMs", duration).put("width", width).put("height", height).put("orientation", "unspecified").put("hasAudio", hasAudio);
|
|
386
|
+
} finally { retriever.release(); extractor.release(); }
|
|
387
|
+
}
|
|
388
|
+
public static String openOutput(String resourceId, String fileUrl) throws Exception {
|
|
389
|
+
if (!resourceId.equals(fileUrl) || !"file".equals(Uri.parse(fileUrl).getScheme())) throw new IllegalArgumentException("Expected matching native file references");
|
|
390
|
+
File file = new File(Uri.parse(fileUrl).getPath());
|
|
391
|
+
inspect(file, false);
|
|
392
|
+
return Uri.fromFile(file).toString();
|
|
393
|
+
}
|
|
394
|
+
}
|
package/platforms/ios/Info.plist
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Shared by preview, movie and still-photo hosts. A busy source is never stolen.
|
|
4
|
+
enum CameraOwnership {
|
|
5
|
+
private static let lock = NSLock()
|
|
6
|
+
private static var owners: [String: ObjectIdentifier] = [:]
|
|
7
|
+
|
|
8
|
+
static func claim(_ id: String, owner: AnyObject) -> Bool {
|
|
9
|
+
lock.lock()
|
|
10
|
+
defer { lock.unlock() }
|
|
11
|
+
let token = ObjectIdentifier(owner)
|
|
12
|
+
guard owners[id] == nil || owners[id] == token else { return false }
|
|
13
|
+
owners[id] = token
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static func release(_ id: String, owner: AnyObject) {
|
|
18
|
+
lock.lock()
|
|
19
|
+
defer { lock.unlock() }
|
|
20
|
+
if owners[id] == ObjectIdentifier(owner) { owners.removeValue(forKey: id) }
|
|
21
|
+
}
|
|
22
|
+
}
|