@limrun/ui 0.14.3 → 0.15.1
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 +17 -0
- package/dist/components/remote-control.d.ts +16 -76
- package/dist/core/ax-fetcher.d.ts +5 -0
- package/dist/core/camera.d.ts +58 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1453 -1402
- package/package.json +2 -3
- package/src/components/remote-control.tsx +299 -416
- package/src/core/ax-fetcher.ts +15 -5
- package/src/core/camera.test.ts +155 -0
- package/src/core/camera.ts +133 -0
- package/src/index.ts +8 -2
package/src/core/ax-fetcher.ts
CHANGED
|
@@ -51,10 +51,15 @@ export type AxStatus = 'idle' | 'starting' | 'ready' | 'unavailable' | 'error';
|
|
|
51
51
|
|
|
52
52
|
export type AxFetcherSendFn = (payload: Record<string, unknown>) => boolean;
|
|
53
53
|
|
|
54
|
+
export interface AndroidElementTreeOptions {
|
|
55
|
+
waitForIdleTimeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
54
58
|
export interface AxFetcherOptions {
|
|
55
59
|
platform: AxPlatform;
|
|
56
60
|
send: AxFetcherSendFn;
|
|
57
61
|
onSnapshot: (snapshot: AxSnapshot | null) => void;
|
|
62
|
+
androidElementTreeOptions?: AndroidElementTreeOptions;
|
|
58
63
|
// Optional: notified on every status transition (deduplicated — no
|
|
59
64
|
// self-loops are emitted). `error` provides the error message when the
|
|
60
65
|
// status is `error` or `unavailable`.
|
|
@@ -81,6 +86,7 @@ export class AxFetcher {
|
|
|
81
86
|
private readonly onStatusChange?: (status: AxStatus, error?: string) => void;
|
|
82
87
|
private readonly baseIntervalMs: number;
|
|
83
88
|
private readonly maxBackoffMs: number;
|
|
89
|
+
private readonly androidElementTreeOptions?: AndroidElementTreeOptions;
|
|
84
90
|
private readonly pending: Map<string, PendingResolver> = new Map();
|
|
85
91
|
|
|
86
92
|
private running = false;
|
|
@@ -108,6 +114,7 @@ export class AxFetcher {
|
|
|
108
114
|
this.onStatusChange = opts.onStatusChange;
|
|
109
115
|
this.baseIntervalMs = opts.baseIntervalMs ?? DEFAULT_BASE_INTERVAL_MS;
|
|
110
116
|
this.maxBackoffMs = opts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
117
|
+
this.androidElementTreeOptions = opts.androidElementTreeOptions;
|
|
111
118
|
this.currentInterval = this.baseIntervalMs;
|
|
112
119
|
}
|
|
113
120
|
|
|
@@ -257,16 +264,19 @@ export class AxFetcher {
|
|
|
257
264
|
if (this.platform === 'ios') {
|
|
258
265
|
return { type: 'elementTree', id };
|
|
259
266
|
}
|
|
260
|
-
return { type: 'getElementTree', id };
|
|
267
|
+
return { type: 'getElementTree', id, ...this.androidElementTreeOptions };
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
private async requestOnce(): Promise<AxSnapshot> {
|
|
264
271
|
const id = generateRequestId();
|
|
265
272
|
return new Promise<AxSnapshot>((resolve, reject) => {
|
|
266
|
-
const timer = window.setTimeout(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
273
|
+
const timer = window.setTimeout(
|
|
274
|
+
() => {
|
|
275
|
+
this.pending.delete(id);
|
|
276
|
+
reject(new Error('elementTree request timed out'));
|
|
277
|
+
},
|
|
278
|
+
REQUEST_TIMEOUT_MS + (this.androidElementTreeOptions?.waitForIdleTimeoutMs ?? 0),
|
|
279
|
+
);
|
|
270
280
|
this.pending.set(id, { resolve, reject, timer });
|
|
271
281
|
const ok = this.send(this.buildRequest(id));
|
|
272
282
|
if (!ok) {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CameraOperationCoordinator,
|
|
4
|
+
cameraCaptureConstraints,
|
|
5
|
+
createGrantedCameraResult,
|
|
6
|
+
parseCameraRequest,
|
|
7
|
+
shouldReacquireCamera,
|
|
8
|
+
} from './camera';
|
|
9
|
+
|
|
10
|
+
describe('parseCameraRequest', () => {
|
|
11
|
+
test('parses active camera facing', () => {
|
|
12
|
+
expect(
|
|
13
|
+
parseCameraRequest({
|
|
14
|
+
type: 'cameraRequest',
|
|
15
|
+
active: true,
|
|
16
|
+
facingMode: 'environment',
|
|
17
|
+
}),
|
|
18
|
+
).toEqual({
|
|
19
|
+
active: true,
|
|
20
|
+
facingMode: 'environment',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('rejects messages without a boolean active field', () => {
|
|
25
|
+
expect(parseCameraRequest({ type: 'cameraRequest', active: 'true' })).toBeNull();
|
|
26
|
+
expect(parseCameraRequest({ type: 'other', active: true })).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('cameraCaptureConstraints', () => {
|
|
31
|
+
test('uses ideal facing and frame-rate preferences without geometry', () => {
|
|
32
|
+
const constraints = cameraCaptureConstraints({
|
|
33
|
+
active: true,
|
|
34
|
+
facingMode: 'environment',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(constraints).toEqual({
|
|
38
|
+
frameRate: { ideal: 30, max: 30 },
|
|
39
|
+
facingMode: { ideal: 'environment' },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('shouldReacquireCamera', () => {
|
|
45
|
+
test('reuses an existing track only for the same facing', () => {
|
|
46
|
+
expect(shouldReacquireCamera({ facingMode: 'user' }, { facingMode: 'user' })).toBe(false);
|
|
47
|
+
expect(shouldReacquireCamera({}, {})).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('reacquires when facing changes', () => {
|
|
51
|
+
expect(shouldReacquireCamera({ facingMode: 'user' }, { facingMode: 'environment' })).toBe(true);
|
|
52
|
+
expect(shouldReacquireCamera(undefined, { facingMode: 'user' })).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('CameraOperationCoordinator', () => {
|
|
57
|
+
test('invalidates suspended work before serially starting the next operation', async () => {
|
|
58
|
+
const coordinator = new CameraOperationCoordinator();
|
|
59
|
+
const events: string[] = [];
|
|
60
|
+
let releaseFirst!: () => void;
|
|
61
|
+
let markFirstStarted!: () => void;
|
|
62
|
+
const firstStarted = new Promise<void>((resolve) => {
|
|
63
|
+
markFirstStarted = resolve;
|
|
64
|
+
});
|
|
65
|
+
const firstDeferred = new Promise<void>((resolve) => {
|
|
66
|
+
releaseFirst = resolve;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const first = coordinator.enqueue(async ({ isCurrent }) => {
|
|
70
|
+
events.push('first:start');
|
|
71
|
+
markFirstStarted();
|
|
72
|
+
await firstDeferred;
|
|
73
|
+
events.push(isCurrent() ? 'first:commit' : 'first:stale');
|
|
74
|
+
});
|
|
75
|
+
await firstStarted;
|
|
76
|
+
|
|
77
|
+
const second = coordinator.enqueue(async ({ isCurrent }) => {
|
|
78
|
+
events.push('second:start');
|
|
79
|
+
events.push(isCurrent() ? 'second:commit' : 'second:stale');
|
|
80
|
+
});
|
|
81
|
+
const endedCleanup = coordinator.enqueueCurrent(async ({ isCurrent }) => {
|
|
82
|
+
events.push('ended:start');
|
|
83
|
+
events.push(isCurrent() ? 'ended:current' : 'ended:stale');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(events).toEqual(['first:start']);
|
|
87
|
+
expect(coordinator.currentGeneration).toBe(2);
|
|
88
|
+
releaseFirst();
|
|
89
|
+
await Promise.all([first, second, endedCleanup]);
|
|
90
|
+
expect(events).toEqual([
|
|
91
|
+
'first:start',
|
|
92
|
+
'first:stale',
|
|
93
|
+
'second:start',
|
|
94
|
+
'second:commit',
|
|
95
|
+
'ended:start',
|
|
96
|
+
'ended:current',
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('continues serial processing after an operation rejects', async () => {
|
|
101
|
+
const coordinator = new CameraOperationCoordinator();
|
|
102
|
+
await expect(
|
|
103
|
+
coordinator.enqueue(async () => {
|
|
104
|
+
throw new Error('capture failed');
|
|
105
|
+
}),
|
|
106
|
+
).rejects.toThrow('capture failed');
|
|
107
|
+
|
|
108
|
+
const operation = vi.fn();
|
|
109
|
+
await coordinator.enqueue(async () => operation());
|
|
110
|
+
expect(operation).toHaveBeenCalledOnce();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('marks non-invalidating work stale when a later request arrives', async () => {
|
|
114
|
+
const coordinator = new CameraOperationCoordinator();
|
|
115
|
+
let release!: () => void;
|
|
116
|
+
const deferred = new Promise<void>((resolve) => {
|
|
117
|
+
release = resolve;
|
|
118
|
+
});
|
|
119
|
+
const first = coordinator.enqueue(async () => deferred);
|
|
120
|
+
const cleanup = coordinator.enqueueCurrent(async ({ isCurrent }) => {
|
|
121
|
+
expect(isCurrent()).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
const newerRequest = coordinator.enqueue(async ({ isCurrent }) => {
|
|
124
|
+
expect(isCurrent()).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
release();
|
|
128
|
+
await Promise.all([first, cleanup, newerRequest]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('camera result metadata', () => {
|
|
133
|
+
test('reads settings afresh for every acknowledgement', () => {
|
|
134
|
+
const getSettings = vi
|
|
135
|
+
.fn()
|
|
136
|
+
.mockReturnValueOnce({ width: 1280, height: 720, deviceId: 'front' })
|
|
137
|
+
.mockReturnValueOnce({ width: 720, height: 1280, deviceId: 'front' });
|
|
138
|
+
const track = {
|
|
139
|
+
getSettings,
|
|
140
|
+
label: 'Built-in Camera',
|
|
141
|
+
} as unknown as MediaStreamTrack;
|
|
142
|
+
|
|
143
|
+
expect(createGrantedCameraResult(track).camera).toMatchObject({
|
|
144
|
+
width: 1280,
|
|
145
|
+
height: 720,
|
|
146
|
+
label: 'Built-in Camera',
|
|
147
|
+
});
|
|
148
|
+
expect(createGrantedCameraResult(track).camera).toMatchObject({
|
|
149
|
+
width: 720,
|
|
150
|
+
height: 1280,
|
|
151
|
+
label: 'Built-in Camera',
|
|
152
|
+
});
|
|
153
|
+
expect(getSettings).toHaveBeenCalledTimes(2);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export type CameraFacingMode = 'user' | 'environment';
|
|
2
|
+
|
|
3
|
+
/** Camera selection requested by the instance host. */
|
|
4
|
+
export interface CameraRequest {
|
|
5
|
+
active: boolean;
|
|
6
|
+
facingMode?: CameraFacingMode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Browser capture metadata returned to the instance host. */
|
|
10
|
+
export interface CameraCaptureInfo {
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
frameRate?: number;
|
|
14
|
+
deviceId?: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
facingMode?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Acknowledgement sent after camera capture changes or is reused. */
|
|
20
|
+
export interface CameraResult {
|
|
21
|
+
type: 'cameraResult';
|
|
22
|
+
granted: boolean;
|
|
23
|
+
camera?: CameraCaptureInfo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CameraOperationContext {
|
|
27
|
+
generation: number;
|
|
28
|
+
isCurrent: () => boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Serializes camera mutations while invalidating queued or suspended work.
|
|
33
|
+
*
|
|
34
|
+
* `enqueue` increments the generation before returning, so an operation
|
|
35
|
+
* waiting on browser permission becomes stale as soon as a newer request
|
|
36
|
+
* arrives, even though the newer operation has not started yet.
|
|
37
|
+
*/
|
|
38
|
+
export class CameraOperationCoordinator {
|
|
39
|
+
private generation = 0;
|
|
40
|
+
private tail: Promise<void> = Promise.resolve();
|
|
41
|
+
|
|
42
|
+
get currentGeneration(): number {
|
|
43
|
+
return this.generation;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
enqueue(operation: (context: CameraOperationContext) => Promise<void>): Promise<void> {
|
|
47
|
+
const generation = ++this.generation;
|
|
48
|
+
return this.append(generation, operation);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Queues related work without superseding an already queued request.
|
|
53
|
+
*
|
|
54
|
+
* The operation retains the generation current at enqueue time and becomes
|
|
55
|
+
* stale if a later camera request increments it before this work starts.
|
|
56
|
+
*/
|
|
57
|
+
enqueueCurrent(operation: (context: CameraOperationContext) => Promise<void>): Promise<void> {
|
|
58
|
+
return this.append(this.generation, operation);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private append(
|
|
62
|
+
generation: number,
|
|
63
|
+
operation: (context: CameraOperationContext) => Promise<void>,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const context: CameraOperationContext = {
|
|
66
|
+
generation,
|
|
67
|
+
isCurrent: () => generation === this.generation,
|
|
68
|
+
};
|
|
69
|
+
const next = this.tail.then(() => operation(context));
|
|
70
|
+
this.tail = next.catch(() => undefined);
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
invalidate(): void {
|
|
75
|
+
++this.generation;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseCameraRequest(message: unknown): CameraRequest | null {
|
|
80
|
+
if (!message || typeof message !== 'object') return null;
|
|
81
|
+
const value = message as Record<string, unknown>;
|
|
82
|
+
if (value.type !== 'cameraRequest' || typeof value.active !== 'boolean') return null;
|
|
83
|
+
|
|
84
|
+
const request: CameraRequest = { active: value.active };
|
|
85
|
+
if (value.facingMode === 'user' || value.facingMode === 'environment') {
|
|
86
|
+
request.facingMode = value.facingMode;
|
|
87
|
+
}
|
|
88
|
+
return request;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds best-effort capture constraints.
|
|
93
|
+
*
|
|
94
|
+
* Facing is a preference so browsers can fall back when a phone-facing label
|
|
95
|
+
* or exact mode is unavailable. The browser chooses capture dimensions.
|
|
96
|
+
*/
|
|
97
|
+
export function cameraCaptureConstraints(request: CameraRequest): MediaTrackConstraints {
|
|
98
|
+
const constraints: MediaTrackConstraints = {
|
|
99
|
+
frameRate: { ideal: 30, max: 30 },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (request?.facingMode) {
|
|
103
|
+
constraints.facingMode = { ideal: request.facingMode };
|
|
104
|
+
}
|
|
105
|
+
return constraints;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function shouldReacquireCamera(
|
|
109
|
+
current: Pick<CameraRequest, 'facingMode'> | undefined,
|
|
110
|
+
requested: Pick<CameraRequest, 'facingMode'>,
|
|
111
|
+
): boolean {
|
|
112
|
+
return !current || current.facingMode !== requested.facingMode;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function cameraCaptureInfoFromTrack(track: MediaStreamTrack): CameraCaptureInfo {
|
|
116
|
+
const settings = track.getSettings();
|
|
117
|
+
return {
|
|
118
|
+
width: settings.width,
|
|
119
|
+
height: settings.height,
|
|
120
|
+
frameRate: settings.frameRate,
|
|
121
|
+
deviceId: settings.deviceId,
|
|
122
|
+
label: track.label || undefined,
|
|
123
|
+
facingMode: settings.facingMode,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createGrantedCameraResult(track: MediaStreamTrack): CameraResult {
|
|
128
|
+
return {
|
|
129
|
+
type: 'cameraResult',
|
|
130
|
+
granted: true,
|
|
131
|
+
camera: cameraCaptureInfoFromTrack(track),
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
export { RemoteControl } from './components/remote-control';
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
RemoteControlProps,
|
|
4
|
+
RemoteControlHandle,
|
|
5
|
+
MicrophoneState,
|
|
6
|
+
CameraStreamStats,
|
|
7
|
+
} from './components/remote-control';
|
|
8
|
+
export type { CameraCaptureInfo, CameraFacingMode, CameraRequest, CameraResult } from './core/camera';
|
|
3
9
|
|
|
4
10
|
// Accessibility / inspect-mode types and helpers. Exported so customers can
|
|
5
11
|
// build their own side panels, search UIs, or agent-driven inspectors on top
|
|
6
12
|
// of the snapshots delivered via `onAxSnapshotChange`.
|
|
7
13
|
export type { AxSnapshot, AxElement, AxRect, AxSelectors, AxPlatform } from './core/ax-tree';
|
|
8
|
-
export type { AxStatus } from './core/ax-fetcher';
|
|
14
|
+
export type { AndroidElementTreeOptions, AxStatus } from './core/ax-fetcher';
|
|
9
15
|
export {
|
|
10
16
|
axElementAtPoint,
|
|
11
17
|
axElementSelectorExpression,
|