@limrun/ui 0.16.0 → 0.17.0-rc.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 +49 -0
- package/dist/GLTFLoader-BKnsBFsj.js +1 -0
- package/dist/GLTFLoader-DW6wpvyR.mjs +1679 -0
- package/dist/components/duo-button-hinge.d.ts +16 -0
- package/dist/components/duo-controls.d.ts +21 -0
- package/dist/components/duo-flat-frame.d.ts +7 -0
- package/dist/components/duo-flat-geometry.d.ts +52 -0
- package/dist/components/duo-fold-motion.d.ts +27 -0
- package/dist/components/duo-fold-renderer.d.ts +19 -0
- package/dist/components/duo-fold-timing.d.ts +1 -0
- package/dist/components/duo-frame.d.ts +16 -0
- package/dist/components/duo-hardware.d.ts +20 -0
- package/dist/components/duo-model.d.ts +19 -0
- package/dist/components/duo-screen-handoff.d.ts +4 -0
- package/dist/components/duo-view.d.ts +4 -0
- package/dist/components/remote-control.d.ts +5 -1
- package/dist/core/duo.d.ts +63 -0
- package/dist/duo-fold-renderer-BXejNHcY.mjs +257 -0
- package/dist/duo-fold-renderer-DdltAnF7.js +53 -0
- package/dist/duo-frame-BzyYljix.mjs +26767 -0
- package/dist/duo-frame-DjfA4gG-.js +4059 -0
- package/dist/duo-view-COZg8ieG.js +1 -0
- package/dist/duo-view-o1yxIzxu.mjs +654 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +2 -2
- package/dist/lite.cjs +1 -1
- package/dist/lite.js +1 -1
- package/dist/remote-control-CQjSEVDF.mjs +2673 -0
- package/dist/remote-control-DVRXfCR2.js +1 -0
- package/package.json +4 -2
- package/src/components/duo-button-hinge.test.tsx +138 -0
- package/src/components/duo-button-hinge.ts +75 -0
- package/src/components/duo-controls.tsx +142 -0
- package/src/components/duo-flat-frame.tsx +145 -0
- package/src/components/duo-flat-geometry.test.ts +63 -0
- package/src/components/duo-flat-geometry.ts +97 -0
- package/src/components/duo-fold-motion.test.tsx +245 -0
- package/src/components/duo-fold-motion.tsx +192 -0
- package/src/components/duo-fold-renderer.test.ts +29 -0
- package/src/components/duo-fold-renderer.ts +397 -0
- package/src/components/duo-fold-timing.test.ts +44 -0
- package/src/components/duo-fold-timing.ts +9 -0
- package/src/components/duo-frame.tsx +579 -0
- package/src/components/duo-hardware.test.ts +61 -0
- package/src/components/duo-hardware.ts +61 -0
- package/src/components/duo-model.test.ts +243 -0
- package/src/components/duo-model.ts +607 -0
- package/src/components/duo-screen-handoff.ts +52 -0
- package/src/components/duo-view.test.tsx +104 -0
- package/src/components/duo-view.tsx +315 -0
- package/src/components/remote-control.css +310 -0
- package/src/components/remote-control.tsx +150 -14
- package/src/core/duo.test.ts +142 -0
- package/src/core/duo.ts +156 -0
- package/src/demo.tsx +14 -0
- package/dist/remote-control-BRgbD7yo.mjs +0 -2516
- package/dist/remote-control-VISrP1zI.js +0 -1
|
@@ -129,3 +129,313 @@
|
|
|
129
129
|
line-height: 1.3;
|
|
130
130
|
color: #6b7280;
|
|
131
131
|
}
|
|
132
|
+
|
|
133
|
+
.rc-duo-view {
|
|
134
|
+
position: absolute;
|
|
135
|
+
inset: 0;
|
|
136
|
+
}
|
|
137
|
+
.rc-duo-mode {
|
|
138
|
+
position: absolute;
|
|
139
|
+
top: 12px;
|
|
140
|
+
right: 16px;
|
|
141
|
+
z-index: 3;
|
|
142
|
+
display: flex;
|
|
143
|
+
gap: 2px;
|
|
144
|
+
padding: 3px;
|
|
145
|
+
background: #fff;
|
|
146
|
+
border: 1px solid #d9dde4;
|
|
147
|
+
border-radius: 10px;
|
|
148
|
+
}
|
|
149
|
+
.rc-duo-mode button {
|
|
150
|
+
border: 0;
|
|
151
|
+
border-radius: 7px;
|
|
152
|
+
padding: 7px 13px;
|
|
153
|
+
font: 12px system-ui;
|
|
154
|
+
color: #29313e;
|
|
155
|
+
background: transparent;
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
.rc-duo-mode button[aria-pressed='true'] {
|
|
159
|
+
background: #202b3c;
|
|
160
|
+
color: white;
|
|
161
|
+
}
|
|
162
|
+
.rc-duo-flat-stage {
|
|
163
|
+
position: relative;
|
|
164
|
+
flex: 1;
|
|
165
|
+
min-height: 160px;
|
|
166
|
+
margin-top: 48px;
|
|
167
|
+
}
|
|
168
|
+
.rc-duo-flat-device {
|
|
169
|
+
position: absolute;
|
|
170
|
+
left: 50%;
|
|
171
|
+
top: 50%;
|
|
172
|
+
flex: none;
|
|
173
|
+
transform-origin: center;
|
|
174
|
+
}
|
|
175
|
+
.rc-duo-flat-frame {
|
|
176
|
+
position: absolute;
|
|
177
|
+
inset: 0;
|
|
178
|
+
width: 100%;
|
|
179
|
+
height: 100%;
|
|
180
|
+
overflow: visible;
|
|
181
|
+
pointer-events: none;
|
|
182
|
+
filter: drop-shadow(0 5px 6px #17202e1c);
|
|
183
|
+
}
|
|
184
|
+
.rc-duo-flat-screen {
|
|
185
|
+
position: absolute;
|
|
186
|
+
overflow: hidden;
|
|
187
|
+
background: #090b0d;
|
|
188
|
+
touch-action: none;
|
|
189
|
+
outline: none;
|
|
190
|
+
}
|
|
191
|
+
.rc-duo-flat-screen video,
|
|
192
|
+
.rc-duo-flat-screen canvas {
|
|
193
|
+
position: absolute;
|
|
194
|
+
left: 50%;
|
|
195
|
+
top: 50%;
|
|
196
|
+
max-width: none;
|
|
197
|
+
pointer-events: none;
|
|
198
|
+
}
|
|
199
|
+
.rc-duo-flat-stage[data-folding='true'] > .rc-duo-flat-device,
|
|
200
|
+
.rc-duo-flat-stage[data-folding='true'] > .rc-duo-hardware {
|
|
201
|
+
visibility: hidden;
|
|
202
|
+
pointer-events: none;
|
|
203
|
+
}
|
|
204
|
+
.rc-duo-fold-hold {
|
|
205
|
+
position: absolute;
|
|
206
|
+
left: 50%;
|
|
207
|
+
top: 50%;
|
|
208
|
+
pointer-events: none;
|
|
209
|
+
}
|
|
210
|
+
.rc-duo-fold-still {
|
|
211
|
+
position: relative;
|
|
212
|
+
}
|
|
213
|
+
.rc-duo-fold-canvas {
|
|
214
|
+
position: absolute;
|
|
215
|
+
inset: 0;
|
|
216
|
+
width: 100%;
|
|
217
|
+
height: 100%;
|
|
218
|
+
pointer-events: none;
|
|
219
|
+
}
|
|
220
|
+
@media (prefers-reduced-motion: reduce) {
|
|
221
|
+
.rc-duo-fold-canvas,
|
|
222
|
+
.rc-duo-fold-hold {
|
|
223
|
+
display: none;
|
|
224
|
+
}
|
|
225
|
+
.rc-duo-flat-stage[data-folding='true'] > .rc-duo-flat-device {
|
|
226
|
+
visibility: visible;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
.rc-duo .rc-duo-physical {
|
|
230
|
+
position: absolute;
|
|
231
|
+
z-index: 2;
|
|
232
|
+
padding: 0;
|
|
233
|
+
border-radius: 0;
|
|
234
|
+
touch-action: none;
|
|
235
|
+
}
|
|
236
|
+
.rc-duo-physical[data-edge='top'] {
|
|
237
|
+
transform: translate(-50%, -100%);
|
|
238
|
+
}
|
|
239
|
+
.rc-duo-physical[data-edge='right'] {
|
|
240
|
+
transform: translate(0, -50%);
|
|
241
|
+
}
|
|
242
|
+
.rc-duo-physical span {
|
|
243
|
+
position: absolute;
|
|
244
|
+
display: block;
|
|
245
|
+
border: 1px solid #7d8387;
|
|
246
|
+
border-radius: 2px;
|
|
247
|
+
box-sizing: border-box;
|
|
248
|
+
background: linear-gradient(180deg, #747d83, #fafcfb 32%, #afb5b9 64%, #636b70);
|
|
249
|
+
box-shadow: 0 0.5px 1px #0004;
|
|
250
|
+
transition: transform 110ms ease;
|
|
251
|
+
pointer-events: none;
|
|
252
|
+
}
|
|
253
|
+
.rc-duo-physical[data-edge='top'] span {
|
|
254
|
+
bottom: 0;
|
|
255
|
+
left: 50%;
|
|
256
|
+
transform: translateX(-50%);
|
|
257
|
+
}
|
|
258
|
+
.rc-duo-physical[data-edge='right'] span {
|
|
259
|
+
top: 50%;
|
|
260
|
+
left: 0;
|
|
261
|
+
transform: translateY(-50%);
|
|
262
|
+
background: linear-gradient(90deg, #747d83, #fafcfb 32%, #afb5b9 64%, #636b70);
|
|
263
|
+
}
|
|
264
|
+
.rc-duo-physical[data-edge='top'][data-highlighted='true'] span,
|
|
265
|
+
.rc-duo-physical[data-edge='top']:focus-visible span {
|
|
266
|
+
transform: translate(-50%, -2px);
|
|
267
|
+
}
|
|
268
|
+
.rc-duo-physical[data-edge='right'][data-highlighted='true'] span,
|
|
269
|
+
.rc-duo-physical[data-edge='right']:focus-visible span {
|
|
270
|
+
transform: translate(2px, -50%);
|
|
271
|
+
}
|
|
272
|
+
.rc-duo-physical[data-edge='top'][data-pressed='true'] span {
|
|
273
|
+
transform: translate(-50%, 0.5px);
|
|
274
|
+
}
|
|
275
|
+
.rc-duo-physical[data-edge='right'][data-pressed='true'] span {
|
|
276
|
+
transform: translate(-0.5px, -50%);
|
|
277
|
+
}
|
|
278
|
+
.rc-duo {
|
|
279
|
+
position: absolute;
|
|
280
|
+
inset: 0;
|
|
281
|
+
display: flex;
|
|
282
|
+
flex-direction: column;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
background: radial-gradient(ellipse at 50% 40%, #ffffff, #e8ebee);
|
|
285
|
+
border-radius: 16px;
|
|
286
|
+
color: #29313e;
|
|
287
|
+
}
|
|
288
|
+
.rc-duo-stage {
|
|
289
|
+
position: relative;
|
|
290
|
+
margin-top: 48px;
|
|
291
|
+
flex: 1;
|
|
292
|
+
min-height: 160px;
|
|
293
|
+
width: 100%;
|
|
294
|
+
outline: none;
|
|
295
|
+
touch-action: none;
|
|
296
|
+
}
|
|
297
|
+
.rc-duo-stage canvas {
|
|
298
|
+
display: block;
|
|
299
|
+
width: 100%;
|
|
300
|
+
height: 100%;
|
|
301
|
+
touch-action: none;
|
|
302
|
+
}
|
|
303
|
+
.rc-duo .rc-duo-hardware {
|
|
304
|
+
position: absolute;
|
|
305
|
+
display: grid;
|
|
306
|
+
place-items: center;
|
|
307
|
+
z-index: 1;
|
|
308
|
+
width: 44px;
|
|
309
|
+
height: 44px;
|
|
310
|
+
padding: 0;
|
|
311
|
+
transform: translate(-50%, -50%);
|
|
312
|
+
color: #73777c;
|
|
313
|
+
opacity: 0;
|
|
314
|
+
pointer-events: none;
|
|
315
|
+
transition:
|
|
316
|
+
opacity 120ms ease,
|
|
317
|
+
color 100ms ease;
|
|
318
|
+
}
|
|
319
|
+
.rc-duo .rc-duo-hardware[data-visible='true'],
|
|
320
|
+
.rc-duo .rc-duo-hardware:focus-visible {
|
|
321
|
+
opacity: 1;
|
|
322
|
+
pointer-events: auto;
|
|
323
|
+
}
|
|
324
|
+
.rc-duo .rc-duo-hardware svg {
|
|
325
|
+
width: var(--duo-hardware-icon-size, 24px);
|
|
326
|
+
height: var(--duo-hardware-icon-size, 24px);
|
|
327
|
+
transform: translate(var(--duo-hardware-icon-x, 0px), var(--duo-hardware-icon-y, 0px));
|
|
328
|
+
}
|
|
329
|
+
.rc-duo .rc-duo-hardware:hover,
|
|
330
|
+
.rc-duo .rc-duo-hardware:focus-visible,
|
|
331
|
+
.rc-duo .rc-duo-hardware[data-pressed='true'] {
|
|
332
|
+
color: #007aff;
|
|
333
|
+
}
|
|
334
|
+
.rc-duo-controls {
|
|
335
|
+
flex: none;
|
|
336
|
+
align-self: center;
|
|
337
|
+
width: min(540px, calc(100% - 40px));
|
|
338
|
+
box-sizing: border-box;
|
|
339
|
+
margin: 0 20px 20px;
|
|
340
|
+
padding: 12px;
|
|
341
|
+
background: rgba(255, 255, 255, 0.88);
|
|
342
|
+
border: 1px solid #d9dde4;
|
|
343
|
+
border-radius: 16px;
|
|
344
|
+
box-shadow: 0 6px 25px #13203610;
|
|
345
|
+
font:
|
|
346
|
+
12px system-ui,
|
|
347
|
+
sans-serif;
|
|
348
|
+
}
|
|
349
|
+
.rc-duo-hinge,
|
|
350
|
+
.rc-duo-views {
|
|
351
|
+
display: flex;
|
|
352
|
+
align-items: center;
|
|
353
|
+
gap: 10px;
|
|
354
|
+
flex-wrap: wrap;
|
|
355
|
+
}
|
|
356
|
+
.rc-duo-hinge {
|
|
357
|
+
flex-wrap: nowrap;
|
|
358
|
+
gap: 8px;
|
|
359
|
+
}
|
|
360
|
+
.rc-duo-hinge input {
|
|
361
|
+
flex: 1;
|
|
362
|
+
min-width: 30px;
|
|
363
|
+
width: 100%;
|
|
364
|
+
accent-color: #34435b;
|
|
365
|
+
}
|
|
366
|
+
.rc-duo-hinge output {
|
|
367
|
+
min-width: 32px;
|
|
368
|
+
font-variant-numeric: tabular-nums;
|
|
369
|
+
}
|
|
370
|
+
.rc-duo button {
|
|
371
|
+
border: 0;
|
|
372
|
+
padding: 7px 10px;
|
|
373
|
+
border-radius: 8px;
|
|
374
|
+
background: transparent;
|
|
375
|
+
color: inherit;
|
|
376
|
+
cursor: pointer;
|
|
377
|
+
font: inherit;
|
|
378
|
+
}
|
|
379
|
+
.rc-duo-hinge .rc-duo-fold {
|
|
380
|
+
flex: none;
|
|
381
|
+
background: #202b3c;
|
|
382
|
+
color: white;
|
|
383
|
+
min-width: 65px;
|
|
384
|
+
}
|
|
385
|
+
.rc-duo-views {
|
|
386
|
+
min-height: 29px;
|
|
387
|
+
margin-top: 8px;
|
|
388
|
+
gap: 2px;
|
|
389
|
+
font-size: 11px;
|
|
390
|
+
}
|
|
391
|
+
.rc-duo-views button {
|
|
392
|
+
padding: 6px 8px;
|
|
393
|
+
}
|
|
394
|
+
.rc-duo-view-label {
|
|
395
|
+
padding: 6px 8px;
|
|
396
|
+
color: #77808d;
|
|
397
|
+
}
|
|
398
|
+
.rc-duo .rc-duo-rotate {
|
|
399
|
+
display: flex;
|
|
400
|
+
align-items: center;
|
|
401
|
+
justify-content: center;
|
|
402
|
+
padding: 7px;
|
|
403
|
+
border: 1px solid #d9dde4;
|
|
404
|
+
}
|
|
405
|
+
.rc-duo button:disabled {
|
|
406
|
+
cursor: default;
|
|
407
|
+
opacity: 0.5;
|
|
408
|
+
}
|
|
409
|
+
.rc-duo-views button[aria-pressed='true'] {
|
|
410
|
+
background: #e5eaf0;
|
|
411
|
+
}
|
|
412
|
+
.rc-duo button:focus-visible,
|
|
413
|
+
.rc-duo input:focus-visible {
|
|
414
|
+
outline: 2px solid #4275c3;
|
|
415
|
+
outline-offset: 2px;
|
|
416
|
+
}
|
|
417
|
+
.rc-duo-error {
|
|
418
|
+
position: absolute;
|
|
419
|
+
top: 16px;
|
|
420
|
+
left: 16px;
|
|
421
|
+
right: 16px;
|
|
422
|
+
padding: 10px;
|
|
423
|
+
border-radius: 8px;
|
|
424
|
+
background: #fff0ee;
|
|
425
|
+
color: #9c2e26;
|
|
426
|
+
font: 13px system-ui;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.rc-duo .rc-duo-lock {
|
|
430
|
+
display: inline-flex;
|
|
431
|
+
align-items: center;
|
|
432
|
+
gap: 6px;
|
|
433
|
+
margin-left: auto;
|
|
434
|
+
border: 1px solid #d3dae3;
|
|
435
|
+
}
|
|
436
|
+
.rc-duo-stage[data-position-locked='false'] canvas {
|
|
437
|
+
cursor: grab;
|
|
438
|
+
}
|
|
439
|
+
.rc-duo-stage[data-position-locked='false'] canvas:active {
|
|
440
|
+
cursor: grabbing;
|
|
441
|
+
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
forwardRef,
|
|
7
|
+
useImperativeHandle,
|
|
8
|
+
lazy,
|
|
9
|
+
Suspense,
|
|
10
|
+
} from 'react';
|
|
2
11
|
import { clsx } from 'clsx';
|
|
3
12
|
import './remote-control.css';
|
|
4
13
|
|
|
14
|
+
import { createDisplayTouchMessage, type DuoState, type DuoOrientation } from '../core/duo';
|
|
15
|
+
const DuoView = lazy(() => import('./duo-view'));
|
|
16
|
+
|
|
5
17
|
import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
|
|
6
18
|
|
|
7
19
|
import {
|
|
@@ -58,6 +70,12 @@ export interface RemoteControlProps {
|
|
|
58
70
|
// around the video. Defaults to true.
|
|
59
71
|
showFrame?: boolean;
|
|
60
72
|
|
|
73
|
+
/** Enables the official iPhone Duo's native displays and interactive folding frame. */
|
|
74
|
+
deviceModel?: 'iphone-duo';
|
|
75
|
+
|
|
76
|
+
/** Optional prepared Duo GLB, supplied and licensed by the host application. */
|
|
77
|
+
duoModelUrl?: string;
|
|
78
|
+
|
|
61
79
|
// When true, drops after a working session auto-reconnect instead of
|
|
62
80
|
// surfacing the manual "Retry" button. Defaults to false.
|
|
63
81
|
autoReconnect?: boolean;
|
|
@@ -306,7 +324,7 @@ export interface ImperativeKeyboardEvent {
|
|
|
306
324
|
export interface RemoteControlHandle {
|
|
307
325
|
openUrl: (url: string) => void;
|
|
308
326
|
sendKeyEvent: (event: ImperativeKeyboardEvent) => void;
|
|
309
|
-
screenshot: () => Promise<ScreenshotData>;
|
|
327
|
+
screenshot: (display?: 'outer' | 'inner') => Promise<ScreenshotData>;
|
|
310
328
|
terminateApp: (bundleId: string) => Promise<void>;
|
|
311
329
|
reconnect: () => void;
|
|
312
330
|
|
|
@@ -491,6 +509,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
491
509
|
sessionId: propSessionId,
|
|
492
510
|
openUrl,
|
|
493
511
|
showFrame = true,
|
|
512
|
+
deviceModel,
|
|
513
|
+
duoModelUrl,
|
|
494
514
|
autoReconnect = false,
|
|
495
515
|
onTerminated,
|
|
496
516
|
assets,
|
|
@@ -512,6 +532,32 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
512
532
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
513
533
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
514
534
|
const frameRef = useRef<HTMLImageElement>(null);
|
|
535
|
+
const [detectedDuo, setDetectedDuo] = useState(false);
|
|
536
|
+
const isDuo = deviceModel === 'iphone-duo' || detectedDuo;
|
|
537
|
+
const innerVideoRef = useRef<HTMLVideoElement>(null);
|
|
538
|
+
const [duoState, setDuoState] = useState<DuoState | null>(null);
|
|
539
|
+
const [duoError, setDuoError] = useState<string>();
|
|
540
|
+
const duoRequests = useRef(
|
|
541
|
+
new Map<
|
|
542
|
+
string,
|
|
543
|
+
{ resolve: () => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
544
|
+
>(),
|
|
545
|
+
);
|
|
546
|
+
const sendDuoControl = (type: string, parameters: object): Promise<void> =>
|
|
547
|
+
new Promise((resolve, reject) => {
|
|
548
|
+
const socket = wsRef.current;
|
|
549
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
550
|
+
reject(new Error('Simulator disconnected'));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const id = crypto.randomUUID();
|
|
554
|
+
const timer = setTimeout(() => {
|
|
555
|
+
duoRequests.current.delete(id);
|
|
556
|
+
reject(new Error('Device control timed out'));
|
|
557
|
+
}, 5000);
|
|
558
|
+
duoRequests.current.set(id, { resolve, reject, timer });
|
|
559
|
+
socket.send(JSON.stringify({ type, id, ...parameters }));
|
|
560
|
+
});
|
|
515
561
|
const [videoLoaded, setVideoLoaded] = useState(false);
|
|
516
562
|
const [retryExhausted, setRetryExhausted] = useState(false);
|
|
517
563
|
// Set once we've concluded the instance is permanently gone (its
|
|
@@ -740,7 +786,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
740
786
|
const loadingLogo = platformAssets?.loadingLogo;
|
|
741
787
|
// Without a frame image (the lite entry) the video lays out frameless
|
|
742
788
|
// whatever showFrame says.
|
|
743
|
-
const frameVisible = showFrame && !!frameImageSrc;
|
|
789
|
+
const frameVisible = !isDuo && showFrame && !!frameImageSrc;
|
|
744
790
|
|
|
745
791
|
const updateStatus = (message: string) => {
|
|
746
792
|
// Use the wrapper for conditional logging
|
|
@@ -1239,6 +1285,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1239
1285
|
|
|
1240
1286
|
// Unified handler for both mouse and touch interactions
|
|
1241
1287
|
const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
|
|
1288
|
+
if (isDuo) return;
|
|
1242
1289
|
event.preventDefault();
|
|
1243
1290
|
event.stopPropagation();
|
|
1244
1291
|
|
|
@@ -1688,6 +1735,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1688
1735
|
const handleKeyboard = (event: React.KeyboardEvent) => {
|
|
1689
1736
|
event.preventDefault();
|
|
1690
1737
|
event.stopPropagation();
|
|
1738
|
+
const inputFocused =
|
|
1739
|
+
document.activeElement === videoRef.current ||
|
|
1740
|
+
(isDuo && document.activeElement === event.currentTarget);
|
|
1691
1741
|
// Use the wrapper for conditional logging
|
|
1692
1742
|
debugLog('Keyboard event:', {
|
|
1693
1743
|
type: event.type,
|
|
@@ -1695,12 +1745,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1695
1745
|
keyCode: event.keyCode,
|
|
1696
1746
|
code: event.code,
|
|
1697
1747
|
target: (event.target as HTMLElement).tagName,
|
|
1698
|
-
focused:
|
|
1748
|
+
focused: inputFocused,
|
|
1699
1749
|
});
|
|
1700
1750
|
|
|
1701
|
-
if (
|
|
1751
|
+
if (!inputFocused) {
|
|
1702
1752
|
// Use the wrapper for conditional warning
|
|
1703
|
-
debugWarn('
|
|
1753
|
+
debugWarn('Simulator input not focused, skipping keyboard event');
|
|
1704
1754
|
return;
|
|
1705
1755
|
}
|
|
1706
1756
|
|
|
@@ -2354,6 +2404,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2354
2404
|
};
|
|
2355
2405
|
|
|
2356
2406
|
const teardownConnection = () => {
|
|
2407
|
+
for (const request of duoRequests.current.values()) {
|
|
2408
|
+
clearTimeout(request.timer);
|
|
2409
|
+
request.reject(new Error('Simulator disconnected'));
|
|
2410
|
+
}
|
|
2411
|
+
duoRequests.current.clear();
|
|
2412
|
+
setDuoState(null);
|
|
2413
|
+
if (innerVideoRef.current) innerVideoRef.current.srcObject = null;
|
|
2357
2414
|
clearConnectionSuccessTimeout();
|
|
2358
2415
|
clearIceDisconnectedGrace();
|
|
2359
2416
|
stopRequestFrameLoop();
|
|
@@ -2644,6 +2701,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2644
2701
|
const rtcConfigPromise = new Promise<{
|
|
2645
2702
|
rtcConfiguration: RTCConfiguration;
|
|
2646
2703
|
cameraSupported: boolean;
|
|
2704
|
+
foldSupported: boolean;
|
|
2647
2705
|
}>((resolve, reject) => {
|
|
2648
2706
|
const settle = (callback: () => void) => {
|
|
2649
2707
|
window.clearTimeout(timeoutId);
|
|
@@ -2676,6 +2734,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2676
2734
|
resolve({
|
|
2677
2735
|
rtcConfiguration: message.rtcConfiguration,
|
|
2678
2736
|
cameraSupported: message.cameraSupported === true,
|
|
2737
|
+
foldSupported: message.foldSupported === true,
|
|
2679
2738
|
}),
|
|
2680
2739
|
);
|
|
2681
2740
|
}
|
|
@@ -2694,16 +2753,23 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2694
2753
|
);
|
|
2695
2754
|
});
|
|
2696
2755
|
|
|
2697
|
-
const {
|
|
2698
|
-
|
|
2756
|
+
const {
|
|
2757
|
+
rtcConfiguration: rtcConfig,
|
|
2758
|
+
cameraSupported: serverCameraSupported,
|
|
2759
|
+
foldSupported,
|
|
2760
|
+
} = await rtcConfigPromise;
|
|
2699
2761
|
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2700
2762
|
return;
|
|
2701
2763
|
}
|
|
2702
2764
|
|
|
2765
|
+
const isDuo = deviceModel === 'iphone-duo' || foldSupported;
|
|
2766
|
+
setDetectedDuo(isDuo);
|
|
2703
2767
|
const peerConnection = new RTCPeerConnection(rtcConfig);
|
|
2704
2768
|
peerConnectionRef.current = peerConnection;
|
|
2705
2769
|
peerConnection.addTransceiver('audio', { direction: 'recvonly' });
|
|
2706
2770
|
const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
|
|
2771
|
+
const duoTransceiver =
|
|
2772
|
+
isDuo ? peerConnection.addTransceiver('video', { direction: 'recvonly' }) : null;
|
|
2707
2773
|
|
|
2708
2774
|
// iOS always supports camera/mic injection; Android servers
|
|
2709
2775
|
// advertise it through `cameraSupported` (Android 15 and newer).
|
|
@@ -2757,6 +2823,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2757
2823
|
return getCodecPriority(a) - getCodecPriority(b);
|
|
2758
2824
|
});
|
|
2759
2825
|
videoTransceiver.setCodecPreferences(sortedCodecs);
|
|
2826
|
+
duoTransceiver?.setCodecPreferences(sortedCodecs);
|
|
2760
2827
|
debugLog('Set codec preferences:', sortedCodecs.map((c) => c.mimeType).join(', '));
|
|
2761
2828
|
}
|
|
2762
2829
|
}
|
|
@@ -2828,6 +2895,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2828
2895
|
controlChannelOpenedRef.current = true;
|
|
2829
2896
|
clearConnectionSuccessTimeout();
|
|
2830
2897
|
updateStatus('Control channel opened');
|
|
2898
|
+
if (isDuo) ws.send(JSON.stringify({ type: 'getFoldState', id: 'duo-capabilities' }));
|
|
2831
2899
|
|
|
2832
2900
|
// Spin up the AX fetcher now that we have a stable WS + control
|
|
2833
2901
|
// channel. The fetcher's send function reuses this WS; it stops
|
|
@@ -2996,7 +3064,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2996
3064
|
updateStatus('Received remote track: ' + event.track.kind);
|
|
2997
3065
|
if (event.track.kind === 'video' && videoRef.current) {
|
|
2998
3066
|
debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
|
|
2999
|
-
|
|
3067
|
+
const inner =
|
|
3068
|
+
event.track.id === 'duo-inner' || event.streams.some((stream) => stream.id === 'duo-inner');
|
|
3069
|
+
const target = inner ? innerVideoRef.current : videoRef.current;
|
|
3070
|
+
if (target) {
|
|
3071
|
+
target.srcObject =
|
|
3072
|
+
isDuo ? new MediaStream([event.track]) : event.streams[0] ?? new MediaStream([event.track]);
|
|
3073
|
+
void target.play().catch(() => undefined);
|
|
3074
|
+
}
|
|
3000
3075
|
}
|
|
3001
3076
|
};
|
|
3002
3077
|
|
|
@@ -3040,6 +3115,24 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3040
3115
|
}
|
|
3041
3116
|
updateStatus('Received: ' + message.type);
|
|
3042
3117
|
switch (message.type) {
|
|
3118
|
+
case 'performActionsResult':
|
|
3119
|
+
case 'foldStateResult':
|
|
3120
|
+
case 'foldStateChanged': {
|
|
3121
|
+
if (!isDuo) break;
|
|
3122
|
+
if (message.state) {
|
|
3123
|
+
setDuoState(message.state);
|
|
3124
|
+
setDuoError(undefined);
|
|
3125
|
+
} else if (message.id === 'duo-capabilities')
|
|
3126
|
+
setDuoError(message.error || 'This instance does not support native iPhone Duo displays.');
|
|
3127
|
+
const request = duoRequests.current.get(message.id);
|
|
3128
|
+
if (request) {
|
|
3129
|
+
clearTimeout(request.timer);
|
|
3130
|
+
duoRequests.current.delete(message.id);
|
|
3131
|
+
if (message.error) request.reject(new Error(message.error));
|
|
3132
|
+
else request.resolve();
|
|
3133
|
+
}
|
|
3134
|
+
break;
|
|
3135
|
+
}
|
|
3043
3136
|
case 'signalingError': {
|
|
3044
3137
|
if (message.requestType !== 'offer' || message.sessionId !== sessionId) {
|
|
3045
3138
|
break;
|
|
@@ -3244,6 +3337,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3244
3337
|
ws.send(
|
|
3245
3338
|
JSON.stringify({
|
|
3246
3339
|
type: 'offer',
|
|
3340
|
+
...(isDuo ? { displayStreams: true } : {}),
|
|
3247
3341
|
sdp: offer.sdp,
|
|
3248
3342
|
sessionId: sessionId,
|
|
3249
3343
|
}),
|
|
@@ -3320,7 +3414,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3320
3414
|
// connection effect doesn't need to bounce when the camera
|
|
3321
3415
|
// turns on/off — no SDP-affecting change.
|
|
3322
3416
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3323
|
-
}, [url, token, propSessionId]);
|
|
3417
|
+
}, [url, token, propSessionId, deviceModel]);
|
|
3324
3418
|
|
|
3325
3419
|
// Recompute the inspect-overlay geometry (container-local pixel rect of
|
|
3326
3420
|
// the actually-rendered video content) from the current mapping context.
|
|
@@ -3556,7 +3650,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3556
3650
|
sendBinaryControlMessage(message);
|
|
3557
3651
|
}
|
|
3558
3652
|
},
|
|
3559
|
-
screenshot: (): Promise<ScreenshotData> => {
|
|
3653
|
+
screenshot: (display?: 'outer' | 'inner'): Promise<ScreenshotData> => {
|
|
3560
3654
|
return new Promise<ScreenshotData>((resolve, reject) => {
|
|
3561
3655
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
3562
3656
|
debugWarn('WebSocket not open, cannot send screenshot command.');
|
|
@@ -3565,7 +3659,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3565
3659
|
|
|
3566
3660
|
const id = `ui-ss-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
3567
3661
|
const request = {
|
|
3568
|
-
type: 'screenshot',
|
|
3662
|
+
type: display ? 'screenshotDisplay' : 'screenshot',
|
|
3663
|
+
...(display ? { display } : {}),
|
|
3569
3664
|
id: id,
|
|
3570
3665
|
};
|
|
3571
3666
|
|
|
@@ -3732,6 +3827,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3732
3827
|
)}
|
|
3733
3828
|
style={{
|
|
3734
3829
|
...videoStyle,
|
|
3830
|
+
...(isDuo ? { opacity: 0, width: 1, height: 1, pointerEvents: 'none' as const } : {}),
|
|
3735
3831
|
...(loadingLogo ?
|
|
3736
3832
|
{
|
|
3737
3833
|
backgroundImage: `url("${loadingLogo}")`,
|
|
@@ -3744,7 +3840,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3744
3840
|
autoPlay
|
|
3745
3841
|
playsInline
|
|
3746
3842
|
muted
|
|
3747
|
-
tabIndex={0}
|
|
3843
|
+
tabIndex={isDuo ? -1 : 0}
|
|
3748
3844
|
onKeyDown={handleKeyboard}
|
|
3749
3845
|
onKeyUp={handleKeyboard}
|
|
3750
3846
|
onClick={handleVideoClick}
|
|
@@ -3760,7 +3856,47 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3760
3856
|
}
|
|
3761
3857
|
}}
|
|
3762
3858
|
/>
|
|
3763
|
-
{
|
|
3859
|
+
{isDuo && (
|
|
3860
|
+
<video
|
|
3861
|
+
ref={innerVideoRef}
|
|
3862
|
+
autoPlay
|
|
3863
|
+
playsInline
|
|
3864
|
+
muted
|
|
3865
|
+
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
|
|
3866
|
+
/>
|
|
3867
|
+
)}
|
|
3868
|
+
{isDuo && duoState && (
|
|
3869
|
+
<Suspense fallback={<div>Loading iPhone Duo…</div>}>
|
|
3870
|
+
<DuoView
|
|
3871
|
+
modelUrl={duoModelUrl}
|
|
3872
|
+
outer={videoRef.current}
|
|
3873
|
+
inner={innerVideoRef.current}
|
|
3874
|
+
state={duoState}
|
|
3875
|
+
setAngle={(angleDegrees) => sendDuoControl('setHingeAngle', { angleDegrees })}
|
|
3876
|
+
setOrientation={(orientation: DuoOrientation) =>
|
|
3877
|
+
sendDuoControl('setDuoOrientation', { orientation })
|
|
3878
|
+
}
|
|
3879
|
+
button={(button, down) =>
|
|
3880
|
+
sendDuoControl('performActions', {
|
|
3881
|
+
actions: [{ type: down ? 'buttonDown' : 'buttonUp', button }],
|
|
3882
|
+
})
|
|
3883
|
+
}
|
|
3884
|
+
touch={(action, screenId, x, y) => {
|
|
3885
|
+
const channel = dataChannelRef.current;
|
|
3886
|
+
if (channel?.readyState === 'open')
|
|
3887
|
+
channel.send(createDisplayTouchMessage(action, screenId, x, y));
|
|
3888
|
+
}}
|
|
3889
|
+
onKeyDown={handleKeyboard}
|
|
3890
|
+
onKeyUp={handleKeyboard}
|
|
3891
|
+
/>
|
|
3892
|
+
</Suspense>
|
|
3893
|
+
)}
|
|
3894
|
+
{isDuo && duoError && (
|
|
3895
|
+
<div role="alert" className="rc-duo-error">
|
|
3896
|
+
{duoError}
|
|
3897
|
+
</div>
|
|
3898
|
+
)}
|
|
3899
|
+
{!isDuo && inspectActive && (
|
|
3764
3900
|
<InspectOverlay
|
|
3765
3901
|
snapshot={axSnapshot}
|
|
3766
3902
|
geometry={overlayGeometry}
|