@octane-xplat/camera 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/camera",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Live camera preview for Octane xplat — web getUserMedia + native capture (AVCapture / CameraX)",
5
5
  "keywords": [
6
6
  "android",
@@ -43,16 +43,16 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@octane-xplat/platform": "0.16.0",
46
+ "@octane-xplat/platform": "0.17.0",
47
47
  "mediabunny": "1.61.3"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@nativescript-community/octane": "0.2.5",
51
51
  "@nativescript/core": "9.1.3",
52
- "@octane-xplat/macos-renderer": "0.16.0",
52
+ "@octane-xplat/macos-renderer": "0.17.0",
53
53
  "@octanejs/vite-plugin": "0.1.61",
54
- "octane": "0.11.0",
55
- "tsrx-typegen": "0.3.1",
54
+ "octane": "0.14.0",
55
+ "tsrx-typegen": "0.4.0",
56
56
  "typescript": "5.9.3",
57
57
  "vite": "8.3.0",
58
58
  "vitest": "4.1.11"
@@ -66,6 +66,7 @@ public final class CameraSessionBridge implements LifecycleOwner {
66
66
  private boolean resumeAfterFinalization;
67
67
  private boolean ownsDestination;
68
68
  private boolean ready;
69
+ private boolean streaming;
69
70
  private boolean started;
70
71
  private boolean finalizing;
71
72
  private final java.util.concurrent.ExecutorService finalizer = java.util.concurrent.Executors.newSingleThreadExecutor();
@@ -89,7 +90,12 @@ public final class CameraSessionBridge implements LifecycleOwner {
89
90
  }
90
91
  @Override public Lifecycle getLifecycle() { return lifecycle; }
91
92
  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
+ private void ready(boolean value) { if (ready == value) return; ready = value; emit("ready", Boolean.toString(value)); }
94
+ /** The "ready" event means the JS `previewReady()` gate is satisfied,
95
+ * not just that the preview view reports streaming — a stream-state
96
+ * event can lead the first measured frame, and a stale STREAMING
97
+ * replayed by LiveData on rebind can precede the new camera's open. */
98
+ private void updateReady() { ready(streaming && sourceFrameRate > 0); }
93
99
  public boolean isReady() { return ready && sourceFrameRate > 0 && !detached && !released; }
94
100
  public long elapsedMs() { return elapsed; }
95
101
  private void failure(String stage, String kind, String message) {
@@ -129,7 +135,12 @@ public final class CameraSessionBridge implements LifecycleOwner {
129
135
  if (released || epoch != generation) return;
130
136
  Long timestamp = result.get(android.hardware.camera2.CaptureResult.SENSOR_TIMESTAMP);
131
137
  if (timestamp != null && timestamp > lastFrameTime) {
132
- if (lastFrameTime > 0) sourceFrameRate = 1_000_000_000.0 / (timestamp - lastFrameTime);
138
+ if (lastFrameTime > 0) {
139
+ boolean firstRate = sourceFrameRate == 0;
140
+ sourceFrameRate = 1_000_000_000.0 / (timestamp - lastFrameTime);
141
+ // Runs on the camera executor; readiness is emitted on main.
142
+ if (firstRate) handler.post(() -> updateReady());
143
+ }
133
144
  lastFrameTime = timestamp;
134
145
  }
135
146
  }
@@ -139,18 +150,34 @@ public final class CameraSessionBridge implements LifecycleOwner {
139
150
  camera = provider.bindToLifecycle(this, selector, preview, video);
140
151
  camera.getCameraInfo().getCameraState().observe(this, state -> {
141
152
  if (released || epoch != generation) return;
142
- if (state.getError() != null || state.getType() == CameraState.Type.CLOSED) {
143
- ready(false);
153
+ if (state.getError() != null) {
144
154
  emit("availability", "false");
155
+ ready(false);
156
+ if (recording != null) { emit("interruption", "cameraInUse"); stop(); }
157
+ } else if (state.getType() == CameraState.Type.CLOSED) {
158
+ // Plain CLOSED is the camera's normal resting state —
159
+ // every bind starts here and it can be delivered late
160
+ // relative to stream state — so it must not flip
161
+ // availability while a live preview keeps streaming.
162
+ ready(false);
145
163
  if (recording != null) { emit("interruption", "cameraInUse"); stop(); }
164
+ } else if (state.getType() == CameraState.Type.OPEN) {
165
+ // An open recovers availability without waiting for a
166
+ // stream-state cycle, which never re-fires while
167
+ // STREAMING persists through an internal retry.
168
+ emit("availability", "true");
169
+ updateReady();
146
170
  }
147
171
  });
148
172
  observedHost = view;
149
173
  view.getStreamState().observe(this, state -> {
150
174
  if (host != view || detached || released || epoch != generation) return;
151
- boolean streaming = state == androidx.camera.view.PreviewView.StreamState.STREAMING;
152
- ready(streaming);
175
+ streaming = state == androidx.camera.view.PreviewView.StreamState.STREAMING;
176
+ // Availability/recovery must land before "ready": onReady
177
+ // consumers may admit recording synchronously, and the
178
+ // session gates on the flags these events update.
153
179
  if (streaming) { emit("availability", "true"); emit("recovered", "unknown"); }
180
+ updateReady();
154
181
  });
155
182
  } catch (Exception cause) { ready(false); emit("previewError", cause.toString()); unbind(); }
156
183
  }, ContextCompat.getMainExecutor(context));
@@ -167,14 +194,16 @@ public final class CameraSessionBridge implements LifecycleOwner {
167
194
  }
168
195
  public void detach() {
169
196
  detached = true;
197
+ streaming = false;
170
198
  generation++;
171
199
  host = null;
172
200
  ready(false);
173
201
  if (recording == null) unbind();
174
202
  }
175
- public void background() { backgrounded = true; resumeAfterFinalization = false; ready(false); emit("interruption", "background"); if (recording != null) stop(); else unbind(); }
203
+ public void background() { backgrounded = true; resumeAfterFinalization = false; streaming = false; ready(false); emit("interruption", "background"); if (recording != null) stop(); else unbind(); }
176
204
  public void foreground() { backgrounded = false; emit("recovered", "background"); if (recording != null) { resumeAfterFinalization = true; } else if (host != null && !detached) { unbind(); attach(host, selection); } }
177
205
  private void unbind() {
206
+ streaming = false;
178
207
  if (camera != null) camera.getCameraInfo().getCameraState().removeObservers(this);
179
208
  if (observedHost != null) { observedHost.getStreamState().removeObservers(this); observedHost = null; }
180
209
  if (provider != null) {
@@ -1,11 +1,12 @@
1
1
  import type { CameraViewProps } from './props'
2
2
  import { isIOS } from '@nativescript/core'
3
+ import { mergeStyle } from './style-merge'
3
4
 
4
5
  /** NativeScript reads layout fields from a child view during parent layout. */
5
6
  export function layoutChildProps(
6
7
  props: CameraViewProps,
7
- baseStyle = props.style,
8
- ): { style: Record<string, any> } {
8
+ baseStyle: Record<string, any> | string | undefined = props.style,
9
+ ) {
9
10
  const style: Record<string, any> = { ...(typeof baseStyle === 'object' ? baseStyle : {}) }
10
11
  if (props.row !== undefined) {
11
12
  style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
@@ -43,7 +44,7 @@ export function layoutChildProps(
43
44
  if ((props.left !== undefined || props.top !== undefined) && props.dock === undefined) {
44
45
  style.position = style.position ?? 'absolute'
45
46
  }
46
- return { style }
47
+ return { style: typeof baseStyle === 'string' ? mergeStyle(style, baseStyle) : style }
47
48
  }
48
49
 
49
50
  export function applyEscapeProps(view: any, props: { ios?: any; android?: any }): void {
@@ -1,4 +1,5 @@
1
1
  import type { CameraViewProps } from './props'
2
+ import { mergeStyle } from './style-merge'
2
3
 
3
4
  /** Windows twins of the layout-child helpers — the unsuffixed module
4
5
  * resolves the platform escape bag through `isIOS`, which has no Windows
@@ -7,28 +8,43 @@ import type { CameraViewProps } from './props'
7
8
 
8
9
  export function layoutChildProps(
9
10
  props: CameraViewProps,
10
- baseStyle = props.style,
11
- ): Record<string, any> {
12
- const result: Record<string, any> = { style: baseStyle }
13
- for (const key of [
14
- 'row',
15
- 'col',
16
- 'rowSpan',
17
- 'colSpan',
18
- 'dock',
19
- 'left',
20
- 'top',
21
- 'flexGrow',
22
- 'flexShrink',
23
- 'alignSelf',
24
- 'order',
25
- ] as const) {
11
+ baseStyle: Record<string, any> | string | undefined = props.style,
12
+ ) {
13
+ const style: Record<string, any> = { ...(typeof baseStyle === 'object' ? baseStyle : {}) }
14
+ if (props.row !== undefined) {
15
+ style.gridRow = `${props.row + 1} / span ${props.rowSpan ?? 1}`
16
+ } else if (props.rowSpan !== undefined) {
17
+ style.gridRow = `span ${props.rowSpan}`
18
+ }
19
+ if (props.col !== undefined) {
20
+ style.gridColumn = `${props.col + 1} / span ${props.colSpan ?? 1}`
21
+ } else if (props.colSpan !== undefined) {
22
+ style.gridColumn = `span ${props.colSpan}`
23
+ }
24
+ if (props.dock !== undefined) {
25
+ style.position = style.position ?? 'absolute'
26
+ if (props.dock === 'left' || props.dock === 'top' || props.dock === 'bottom') {
27
+ style.left = style.left ?? 0
28
+ }
29
+ if (props.dock === 'right' || props.dock === 'top' || props.dock === 'bottom') {
30
+ style.right = style.right ?? 0
31
+ }
32
+ if (props.dock === 'top' || props.dock === 'left' || props.dock === 'right') {
33
+ style.top = style.top ?? 0
34
+ }
35
+ if (props.dock === 'bottom' || props.dock === 'left' || props.dock === 'right') {
36
+ style.bottom = style.bottom ?? 0
37
+ }
38
+ }
39
+ for (const key of ['left', 'top', 'flexGrow', 'flexShrink', 'alignSelf', 'order'] as const) {
26
40
  if (props[key] !== undefined) {
27
- result[key] = props[key]
41
+ style[key] = props[key]
28
42
  }
29
43
  }
30
-
31
- return result
44
+ if ((props.left !== undefined || props.top !== undefined) && props.dock === undefined) {
45
+ style.position = style.position ?? 'absolute'
46
+ }
47
+ return { style: typeof baseStyle === 'string' ? mergeStyle(style, baseStyle) : style }
32
48
  }
33
49
 
34
50
  export function applyEscapeProps(view: any, props: { windows?: any }): void {
@@ -170,6 +170,7 @@ export class SharedCameraSession implements CameraSession {
170
170
  private cameraAvailable = true
171
171
  private previewAttached = false
172
172
  private interrupted = false
173
+ private pendingOnReady: (() => void) | undefined
173
174
  private disposeRequested = false
174
175
  private disposed = false
175
176
  private lastCapabilities: CameraSessionCapabilities | undefined
@@ -195,7 +196,14 @@ export class SharedCameraSession implements CameraSession {
195
196
  backend.setObserver({
196
197
  interruption: (phase, reason) => this.handleInterruption(phase, reason),
197
198
  availability: (available) => this.handleAvailability(available),
198
- previewReady: () => {
199
+ previewReady: (ready) => {
200
+ // A retracted readiness signal invalidates a pending onReady;
201
+ // a delivered one retries admission.
202
+ if (!ready) {
203
+ this.pendingOnReady = undefined
204
+ }
205
+
206
+ this.flushPendingReady()
199
207
  this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
200
208
  },
201
209
  })
@@ -212,7 +220,12 @@ export class SharedCameraSession implements CameraSession {
212
220
  }
213
221
 
214
222
  const detach = this.backend.attachPreview(host, {
215
- onReady: listener?.onReady,
223
+ onReady: listener?.onReady
224
+ ? () => {
225
+ this.pendingOnReady = listener.onReady
226
+ this.flushPendingReady()
227
+ }
228
+ : undefined,
216
229
  onError: (captureIssue) => {
217
230
  listener?.onError?.(captureIssue)
218
231
  },
@@ -235,6 +248,7 @@ export class SharedCameraSession implements CameraSession {
235
248
  }
236
249
 
237
250
  this.previewAttached = false
251
+ this.pendingOnReady = undefined
238
252
  const attempt = this.attempt
239
253
  if (attempt && (attempt.state === 'starting' || attempt.state === 'recording')) {
240
254
  this.requestStop('previewReleased')
@@ -245,6 +259,22 @@ export class SharedCameraSession implements CameraSession {
245
259
  this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
246
260
  }
247
261
 
262
+ /** `onReady` means "recording can start": it is delivered only while
263
+ * the session would actually admit `startRecording`. Adapters emit
264
+ * their availability/interruption observations in their own order, so
265
+ * a ready signal that arrives before those updates settles pends
266
+ * until the next lifecycle event makes admission possible — or is
267
+ * dropped if readiness is retracted first. */
268
+ private flushPendingReady() {
269
+ const pending = this.pendingOnReady
270
+ if (!pending || this.interrupted || !this.cameraAvailable || !this.backend.previewReady()) {
271
+ return
272
+ }
273
+
274
+ this.pendingOnReady = undefined
275
+ pending()
276
+ }
277
+
248
278
  private emit(event: CameraSessionEvent) {
249
279
  for (const listener of this.listeners) {
250
280
  try {
@@ -423,6 +453,7 @@ export class SharedCameraSession implements CameraSession {
423
453
  this.requestStop(cause)
424
454
  }
425
455
 
456
+ this.flushPendingReady()
426
457
  this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
427
458
  }
428
459
 
@@ -437,6 +468,7 @@ export class SharedCameraSession implements CameraSession {
437
468
  this.requestStop('cameraUnavailable')
438
469
  }
439
470
 
471
+ this.flushPendingReady()
440
472
  this.emit({ type: 'stateChanged', snapshot: this.snapshot() })
441
473
  }
442
474
 
@@ -713,6 +745,7 @@ export class SharedCameraSession implements CameraSession {
713
745
  }
714
746
 
715
747
  this.disposed = true
748
+ this.pendingOnReady = undefined
716
749
  this.backend.setObserver(undefined)
717
750
  try {
718
751
  await this.backend.release()
@@ -0,0 +1,59 @@
1
+ const kebab = (key: string) => key.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
2
+
3
+ /** Anything accepted as a style value, including nested arrays. */
4
+ export type StyleInput =
5
+ | string
6
+ | Record<string, any>
7
+ | null
8
+ | undefined
9
+ | readonly StyleInput[]
10
+
11
+ /** Merge component-emitted style defaults under the authored `style` prop.
12
+ * Authored keys win; array styles fold in order; a string style is appended
13
+ * after the serialized defaults so its declarations override them too. */
14
+ export function mergeStyle(
15
+ base: Record<string, any>,
16
+ style?: Record<string, any> | readonly (Record<string, any> | null | undefined)[] | null,
17
+ ): Record<string, any>
18
+ export function mergeStyle(
19
+ base: Record<string, any>,
20
+ style?: StyleInput,
21
+ ): string | Record<string, any>
22
+ export function mergeStyle(
23
+ base: Record<string, any>,
24
+ style?: StyleInput,
25
+ ): string | Record<string, any> {
26
+ if (style == null) {
27
+ return base
28
+ }
29
+
30
+ let obj: Record<string, any> = { ...base }
31
+ const strings: string[] = []
32
+ const add = (s: StyleInput) => {
33
+ if (s == null) {
34
+ return
35
+ }
36
+ if (typeof s === 'string') {
37
+ strings.push(s)
38
+ } else if (Array.isArray(s)) {
39
+ for (const part of s) {
40
+ add(part)
41
+ }
42
+ } else {
43
+ obj = { ...obj, ...s }
44
+ }
45
+ }
46
+ add(style)
47
+
48
+ if (strings.length === 0) {
49
+ return obj
50
+ }
51
+
52
+ const prefix = Object.entries(obj)
53
+ .filter(([, v]) => v != null)
54
+ .map(([k, v]) => `${kebab(k)}:${v}`)
55
+ .join(';')
56
+ return [prefix, ...strings].filter(Boolean).join(';')
57
+ }
58
+
59
+ export type StyleProp = string | Record<string, any> | readonly StyleProp[] | null | undefined
@@ -106,6 +106,7 @@ export declare class SharedCameraSession implements CameraSession {
106
106
  private cameraAvailable;
107
107
  private previewAttached;
108
108
  private interrupted;
109
+ private pendingOnReady;
109
110
  private disposeRequested;
110
111
  private disposed;
111
112
  private lastCapabilities;
@@ -116,6 +117,13 @@ export declare class SharedCameraSession implements CameraSession {
116
117
  attach(host: unknown, listener?: BackendPreviewListener): () => void;
117
118
  get attached(): boolean;
118
119
  private detachInternal;
120
+ /** `onReady` means "recording can start": it is delivered only while
121
+ * the session would actually admit `startRecording`. Adapters emit
122
+ * their availability/interruption observations in their own order, so
123
+ * a ready signal that arrives before those updates settles pends
124
+ * until the next lifecycle event makes admission possible — or is
125
+ * dropped if readiness is retracted first. */
126
+ private flushPendingReady;
119
127
  private emit;
120
128
  private setState;
121
129
  snapshot(): CameraSessionSnapshot;