@limrun/ui 0.15.1 → 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 +59 -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-framed.d.ts +2 -0
- package/dist/components/remote-control.d.ts +24 -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/frame-assets.d.ts +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +25 -2495
- package/dist/lite.cjs +1 -0
- package/dist/lite.d.ts +6 -0
- package/dist/lite.js +13 -0
- package/dist/remote-control-CQjSEVDF.mjs +2673 -0
- package/dist/remote-control-DVRXfCR2.js +1 -0
- package/package.json +13 -3
- 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-framed.tsx +14 -0
- package/src/components/remote-control.css +310 -0
- package/src/components/remote-control.tsx +259 -59
- package/src/core/duo.test.ts +142 -0
- package/src/core/duo.ts +156 -0
- package/src/demo.tsx +16 -1
- package/src/frame-assets.ts +22 -0
- package/src/index.ts +5 -1
- package/src/lite.test.ts +30 -0
- package/src/lite.ts +30 -0
- package/vite.config.ts +5 -0
|
@@ -1,17 +1,21 @@
|
|
|
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
|
-
import iphoneFrameImage from '../assets/iphone16pro_black_bg.webp';
|
|
8
|
-
import pixelFrameImage from '../assets/pixel9_black.webp';
|
|
9
|
-
import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
|
|
10
|
-
import pixelTabletFrameImage from '../assets/pixel_tablet_portrait.webp';
|
|
11
|
-
import pixelTabletFrameImageLandscape from '../assets/pixel_tablet_landscape.webp';
|
|
12
|
-
import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
|
|
13
|
-
import appleLogoSvg from '../assets/Apple_logo_white.svg';
|
|
14
|
-
import androidBootImage from '../assets/android_boot.webp';
|
|
15
19
|
import {
|
|
16
20
|
createTouchControlMessage,
|
|
17
21
|
createInjectKeycodeMessage,
|
|
@@ -66,6 +70,12 @@ export interface RemoteControlProps {
|
|
|
66
70
|
// around the video. Defaults to true.
|
|
67
71
|
showFrame?: boolean;
|
|
68
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
|
+
|
|
69
79
|
// When true, drops after a working session auto-reconnect instead of
|
|
70
80
|
// surfacing the manual "Retry" button. Defaults to false.
|
|
71
81
|
autoReconnect?: boolean;
|
|
@@ -82,6 +92,15 @@ export interface RemoteControlProps {
|
|
|
82
92
|
* etc.). It is not called for ordinary network drops.
|
|
83
93
|
*/
|
|
84
94
|
onTerminated?: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* Device frame and boot-logo images. The default export bundles Limrun's
|
|
97
|
+
* frames; `@limrun/ui/lite` omits them so a frameless embed stays small.
|
|
98
|
+
*/
|
|
99
|
+
assets?: RemoteControlAssets;
|
|
100
|
+
/**
|
|
101
|
+
* Coarse connection lifecycle, for status UI outside the component.
|
|
102
|
+
*/
|
|
103
|
+
onConnectionStateChange?: (state: RemoteControlConnectionState) => void;
|
|
85
104
|
|
|
86
105
|
/**
|
|
87
106
|
* Enable the inspect overlay. When set, the component starts polling the
|
|
@@ -305,7 +324,7 @@ export interface ImperativeKeyboardEvent {
|
|
|
305
324
|
export interface RemoteControlHandle {
|
|
306
325
|
openUrl: (url: string) => void;
|
|
307
326
|
sendKeyEvent: (event: ImperativeKeyboardEvent) => void;
|
|
308
|
-
screenshot: () => Promise<ScreenshotData>;
|
|
327
|
+
screenshot: (display?: 'outer' | 'inner') => Promise<ScreenshotData>;
|
|
309
328
|
terminateApp: (bundleId: string) => Promise<void>;
|
|
310
329
|
reconnect: () => void;
|
|
311
330
|
|
|
@@ -388,18 +407,30 @@ export const withAuthenticationToken = (url: string, token: string): string => {
|
|
|
388
407
|
return endpoint.toString();
|
|
389
408
|
};
|
|
390
409
|
|
|
410
|
+
export type RemoteControlConnectionState =
|
|
411
|
+
| 'connecting'
|
|
412
|
+
| 'connected'
|
|
413
|
+
| 'reconnecting'
|
|
414
|
+
| 'failed'
|
|
415
|
+
| 'terminated';
|
|
416
|
+
|
|
417
|
+
export type RemoteControlPlatformAssets = {
|
|
418
|
+
frame?: string;
|
|
419
|
+
frameLandscape?: string;
|
|
420
|
+
tabletFrame?: string;
|
|
421
|
+
tabletFrameLandscape?: string;
|
|
422
|
+
loadingLogo?: string;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
export type RemoteControlAssets = Partial<Record<DevicePlatform, RemoteControlPlatformAssets>>;
|
|
426
|
+
|
|
391
427
|
type DeviceConfig = {
|
|
392
428
|
videoBorderRadiusMultiplier: number;
|
|
393
|
-
loadingLogo: string;
|
|
394
429
|
loadingLogoSize: string;
|
|
395
430
|
videoPosition: {
|
|
396
431
|
portrait: { heightMultiplier?: number; widthMultiplier?: number };
|
|
397
432
|
landscape: { heightMultiplier?: number; widthMultiplier?: number };
|
|
398
433
|
};
|
|
399
|
-
frame: {
|
|
400
|
-
image: string;
|
|
401
|
-
imageLandscape: string;
|
|
402
|
-
};
|
|
403
434
|
};
|
|
404
435
|
|
|
405
436
|
const ANDROID_TABLET_VIDEO_WIDTH = 1920;
|
|
@@ -420,12 +451,7 @@ const isAndroidTabletVideo = (width: number, height: number): boolean =>
|
|
|
420
451
|
// Video position percentages are relative to the frame image dimensions
|
|
421
452
|
const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
|
|
422
453
|
ios: {
|
|
423
|
-
frame: {
|
|
424
|
-
image: iphoneFrameImage,
|
|
425
|
-
imageLandscape: iphoneFrameImageLandscape,
|
|
426
|
-
},
|
|
427
454
|
videoBorderRadiusMultiplier: 0.15,
|
|
428
|
-
loadingLogo: appleLogoSvg,
|
|
429
455
|
loadingLogoSize: '20%',
|
|
430
456
|
// Video position as percentage of frame dimensions
|
|
431
457
|
videoPosition: {
|
|
@@ -434,12 +460,7 @@ const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
|
|
|
434
460
|
},
|
|
435
461
|
},
|
|
436
462
|
android: {
|
|
437
|
-
frame: {
|
|
438
|
-
image: pixelFrameImage,
|
|
439
|
-
imageLandscape: pixelFrameImageLandscape,
|
|
440
|
-
},
|
|
441
463
|
videoBorderRadiusMultiplier: 0.13,
|
|
442
|
-
loadingLogo: androidBootImage,
|
|
443
464
|
loadingLogoSize: '40%',
|
|
444
465
|
// Video position as percentage of frame dimensions
|
|
445
466
|
videoPosition: {
|
|
@@ -488,8 +509,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
488
509
|
sessionId: propSessionId,
|
|
489
510
|
openUrl,
|
|
490
511
|
showFrame = true,
|
|
512
|
+
deviceModel,
|
|
513
|
+
duoModelUrl,
|
|
491
514
|
autoReconnect = false,
|
|
492
515
|
onTerminated,
|
|
516
|
+
assets,
|
|
517
|
+
onConnectionStateChange,
|
|
493
518
|
inspectMode,
|
|
494
519
|
onAxSnapshotChange,
|
|
495
520
|
onInspectSelectionChange,
|
|
@@ -507,6 +532,32 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
507
532
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
508
533
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
509
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
|
+
});
|
|
510
561
|
const [videoLoaded, setVideoLoaded] = useState(false);
|
|
511
562
|
const [retryExhausted, setRetryExhausted] = useState(false);
|
|
512
563
|
// Set once we've concluded the instance is permanently gone (its
|
|
@@ -702,6 +753,16 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
702
753
|
onAxStatusChangeRef.current = onAxStatusChange;
|
|
703
754
|
const onTerminatedRef = useRef(onTerminated);
|
|
704
755
|
onTerminatedRef.current = onTerminated;
|
|
756
|
+
const onConnectionStateChangeRef = useRef(onConnectionStateChange);
|
|
757
|
+
onConnectionStateChangeRef.current = onConnectionStateChange;
|
|
758
|
+
const connectionStateRef = useRef<RemoteControlConnectionState | null>(null);
|
|
759
|
+
// Reports each transition once; callers get status text without tracking
|
|
760
|
+
// the retry machinery below.
|
|
761
|
+
const reportConnectionState = (next: RemoteControlConnectionState) => {
|
|
762
|
+
if (connectionStateRef.current === next) return;
|
|
763
|
+
connectionStateRef.current = next;
|
|
764
|
+
safeInvoke('onConnectionStateChange', onConnectionStateChangeRef.current, next);
|
|
765
|
+
};
|
|
705
766
|
|
|
706
767
|
const inspectActive = inspectMode === true || inspectMode === 'hover-only';
|
|
707
768
|
const inspectModeResolved: InspectMode = inspectMode === 'hover-only' ? 'hover-only' : 'select';
|
|
@@ -715,6 +776,17 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
715
776
|
|
|
716
777
|
const platform = useMemo(() => detectPlatform(url), [url]);
|
|
717
778
|
const config = deviceConfig[platform];
|
|
779
|
+
const platformAssets = assets?.[platform];
|
|
780
|
+
const frameImageSrc =
|
|
781
|
+
platform === 'android' && useAndroidTabletFrame ?
|
|
782
|
+
isLandscape ? platformAssets?.tabletFrameLandscape
|
|
783
|
+
: platformAssets?.tabletFrame
|
|
784
|
+
: isLandscape ? platformAssets?.frameLandscape
|
|
785
|
+
: platformAssets?.frame;
|
|
786
|
+
const loadingLogo = platformAssets?.loadingLogo;
|
|
787
|
+
// Without a frame image (the lite entry) the video lays out frameless
|
|
788
|
+
// whatever showFrame says.
|
|
789
|
+
const frameVisible = !isDuo && showFrame && !!frameImageSrc;
|
|
718
790
|
|
|
719
791
|
const updateStatus = (message: string) => {
|
|
720
792
|
// Use the wrapper for conditional logging
|
|
@@ -1213,6 +1285,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1213
1285
|
|
|
1214
1286
|
// Unified handler for both mouse and touch interactions
|
|
1215
1287
|
const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
|
|
1288
|
+
if (isDuo) return;
|
|
1216
1289
|
event.preventDefault();
|
|
1217
1290
|
event.stopPropagation();
|
|
1218
1291
|
|
|
@@ -1662,6 +1735,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1662
1735
|
const handleKeyboard = (event: React.KeyboardEvent) => {
|
|
1663
1736
|
event.preventDefault();
|
|
1664
1737
|
event.stopPropagation();
|
|
1738
|
+
const inputFocused =
|
|
1739
|
+
document.activeElement === videoRef.current ||
|
|
1740
|
+
(isDuo && document.activeElement === event.currentTarget);
|
|
1665
1741
|
// Use the wrapper for conditional logging
|
|
1666
1742
|
debugLog('Keyboard event:', {
|
|
1667
1743
|
type: event.type,
|
|
@@ -1669,12 +1745,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1669
1745
|
keyCode: event.keyCode,
|
|
1670
1746
|
code: event.code,
|
|
1671
1747
|
target: (event.target as HTMLElement).tagName,
|
|
1672
|
-
focused:
|
|
1748
|
+
focused: inputFocused,
|
|
1673
1749
|
});
|
|
1674
1750
|
|
|
1675
|
-
if (
|
|
1751
|
+
if (!inputFocused) {
|
|
1676
1752
|
// Use the wrapper for conditional warning
|
|
1677
|
-
debugWarn('
|
|
1753
|
+
debugWarn('Simulator input not focused, skipping keyboard event');
|
|
1678
1754
|
return;
|
|
1679
1755
|
}
|
|
1680
1756
|
|
|
@@ -1809,6 +1885,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1809
1885
|
firstFrameShownRef.current = true;
|
|
1810
1886
|
stopRequestFrameLoop();
|
|
1811
1887
|
setVideoLoaded(true);
|
|
1888
|
+
reportConnectionState('connected');
|
|
1812
1889
|
};
|
|
1813
1890
|
|
|
1814
1891
|
// Stop every track on a MediaStream and release the device handle.
|
|
@@ -2327,6 +2404,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2327
2404
|
};
|
|
2328
2405
|
|
|
2329
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;
|
|
2330
2414
|
clearConnectionSuccessTimeout();
|
|
2331
2415
|
clearIceDisconnectedGrace();
|
|
2332
2416
|
stopRequestFrameLoop();
|
|
@@ -2454,6 +2538,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2454
2538
|
clearIceDisconnectedGrace();
|
|
2455
2539
|
teardownConnection();
|
|
2456
2540
|
updateStatus('Instance terminated');
|
|
2541
|
+
reportConnectionState('terminated');
|
|
2457
2542
|
safeInvoke('onTerminated', onTerminatedRef.current);
|
|
2458
2543
|
};
|
|
2459
2544
|
|
|
@@ -2489,6 +2574,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2489
2574
|
if (!autoReconnectRef.current) {
|
|
2490
2575
|
updateStatus(`Connection failed after it was established: ${reason}`);
|
|
2491
2576
|
setRetryExhausted(true);
|
|
2577
|
+
reportConnectionState('failed');
|
|
2492
2578
|
teardownConnection();
|
|
2493
2579
|
return;
|
|
2494
2580
|
}
|
|
@@ -2504,11 +2590,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2504
2590
|
if (nextAttempt >= MAX_CONNECTION_ATTEMPTS) {
|
|
2505
2591
|
updateStatus(`Connection failed after ${MAX_CONNECTION_ATTEMPTS} attempts: ${reason}`);
|
|
2506
2592
|
setRetryExhausted(true);
|
|
2593
|
+
reportConnectionState('failed');
|
|
2507
2594
|
teardownConnection();
|
|
2508
2595
|
return;
|
|
2509
2596
|
}
|
|
2510
2597
|
|
|
2511
2598
|
updateStatus(`Retrying connection (${nextAttempt + 1}/${MAX_CONNECTION_ATTEMPTS})`);
|
|
2599
|
+
reportConnectionState('reconnecting');
|
|
2512
2600
|
teardownConnection();
|
|
2513
2601
|
retryTimeoutRef.current = window.setTimeout(() => {
|
|
2514
2602
|
retryTimeoutRef.current = undefined;
|
|
@@ -2528,6 +2616,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2528
2616
|
connectionAttemptRef.current = attemptNumber;
|
|
2529
2617
|
controlChannelOpenedRef.current = false;
|
|
2530
2618
|
setRetryExhausted(false);
|
|
2619
|
+
// A drop after an established session restarts the attempt budget at 0
|
|
2620
|
+
// while staying 'reconnecting'; only a fresh start reports 'connecting'.
|
|
2621
|
+
if (attemptNumber === 0 && connectionStateRef.current !== 'reconnecting')
|
|
2622
|
+
reportConnectionState('connecting');
|
|
2531
2623
|
clearScheduledRetry();
|
|
2532
2624
|
clearConnectionSuccessTimeout();
|
|
2533
2625
|
stopRequestFrameLoop();
|
|
@@ -2609,25 +2701,46 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2609
2701
|
const rtcConfigPromise = new Promise<{
|
|
2610
2702
|
rtcConfiguration: RTCConfiguration;
|
|
2611
2703
|
cameraSupported: boolean;
|
|
2704
|
+
foldSupported: boolean;
|
|
2612
2705
|
}>((resolve, reject) => {
|
|
2613
|
-
const
|
|
2706
|
+
const settle = (callback: () => void) => {
|
|
2707
|
+
window.clearTimeout(timeoutId);
|
|
2708
|
+
ws.removeEventListener('message', messageHandler);
|
|
2709
|
+
callback();
|
|
2710
|
+
};
|
|
2711
|
+
const timeoutId = window.setTimeout(
|
|
2712
|
+
() => settle(() => reject(new Error('RTCConfiguration timeout'))),
|
|
2713
|
+
30000,
|
|
2714
|
+
);
|
|
2614
2715
|
|
|
2615
2716
|
const messageHandler = (event: MessageEvent) => {
|
|
2717
|
+
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2718
|
+
return;
|
|
2719
|
+
}
|
|
2616
2720
|
try {
|
|
2617
2721
|
const message = JSON.parse(event.data);
|
|
2618
|
-
if (
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2722
|
+
if (
|
|
2723
|
+
message.type === 'signalingError' &&
|
|
2724
|
+
message.requestType === 'requestRtcConfiguration' &&
|
|
2725
|
+
message.sessionId === sessionId
|
|
2726
|
+
) {
|
|
2727
|
+
const reason =
|
|
2728
|
+
typeof message.error === 'string' ?
|
|
2729
|
+
message.error
|
|
2730
|
+
: 'Unable to load streaming configuration';
|
|
2731
|
+
settle(() => reject(new Error(reason)));
|
|
2732
|
+
} else if (message.type === 'rtcConfiguration') {
|
|
2733
|
+
settle(() =>
|
|
2734
|
+
resolve({
|
|
2735
|
+
rtcConfiguration: message.rtcConfiguration,
|
|
2736
|
+
cameraSupported: message.cameraSupported === true,
|
|
2737
|
+
foldSupported: message.foldSupported === true,
|
|
2738
|
+
}),
|
|
2739
|
+
);
|
|
2625
2740
|
}
|
|
2626
2741
|
} catch (e) {
|
|
2627
|
-
window.clearTimeout(timeoutId);
|
|
2628
|
-
ws.removeEventListener('message', messageHandler);
|
|
2629
2742
|
console.error('Error handling RTC configuration:', e);
|
|
2630
|
-
reject(e);
|
|
2743
|
+
settle(() => reject(e));
|
|
2631
2744
|
}
|
|
2632
2745
|
};
|
|
2633
2746
|
|
|
@@ -2640,16 +2753,23 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2640
2753
|
);
|
|
2641
2754
|
});
|
|
2642
2755
|
|
|
2643
|
-
const {
|
|
2644
|
-
|
|
2756
|
+
const {
|
|
2757
|
+
rtcConfiguration: rtcConfig,
|
|
2758
|
+
cameraSupported: serverCameraSupported,
|
|
2759
|
+
foldSupported,
|
|
2760
|
+
} = await rtcConfigPromise;
|
|
2645
2761
|
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
2646
2762
|
return;
|
|
2647
2763
|
}
|
|
2648
2764
|
|
|
2765
|
+
const isDuo = deviceModel === 'iphone-duo' || foldSupported;
|
|
2766
|
+
setDetectedDuo(isDuo);
|
|
2649
2767
|
const peerConnection = new RTCPeerConnection(rtcConfig);
|
|
2650
2768
|
peerConnectionRef.current = peerConnection;
|
|
2651
2769
|
peerConnection.addTransceiver('audio', { direction: 'recvonly' });
|
|
2652
2770
|
const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
|
|
2771
|
+
const duoTransceiver =
|
|
2772
|
+
isDuo ? peerConnection.addTransceiver('video', { direction: 'recvonly' }) : null;
|
|
2653
2773
|
|
|
2654
2774
|
// iOS always supports camera/mic injection; Android servers
|
|
2655
2775
|
// advertise it through `cameraSupported` (Android 15 and newer).
|
|
@@ -2703,6 +2823,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2703
2823
|
return getCodecPriority(a) - getCodecPriority(b);
|
|
2704
2824
|
});
|
|
2705
2825
|
videoTransceiver.setCodecPreferences(sortedCodecs);
|
|
2826
|
+
duoTransceiver?.setCodecPreferences(sortedCodecs);
|
|
2706
2827
|
debugLog('Set codec preferences:', sortedCodecs.map((c) => c.mimeType).join(', '));
|
|
2707
2828
|
}
|
|
2708
2829
|
}
|
|
@@ -2774,6 +2895,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2774
2895
|
controlChannelOpenedRef.current = true;
|
|
2775
2896
|
clearConnectionSuccessTimeout();
|
|
2776
2897
|
updateStatus('Control channel opened');
|
|
2898
|
+
if (isDuo) ws.send(JSON.stringify({ type: 'getFoldState', id: 'duo-capabilities' }));
|
|
2777
2899
|
|
|
2778
2900
|
// Spin up the AX fetcher now that we have a stable WS + control
|
|
2779
2901
|
// channel. The fetcher's send function reuses this WS; it stops
|
|
@@ -2942,7 +3064,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2942
3064
|
updateStatus('Received remote track: ' + event.track.kind);
|
|
2943
3065
|
if (event.track.kind === 'video' && videoRef.current) {
|
|
2944
3066
|
debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
|
|
2945
|
-
|
|
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
|
+
}
|
|
2946
3075
|
}
|
|
2947
3076
|
};
|
|
2948
3077
|
|
|
@@ -2986,6 +3115,36 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
2986
3115
|
}
|
|
2987
3116
|
updateStatus('Received: ' + message.type);
|
|
2988
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
|
+
}
|
|
3136
|
+
case 'signalingError': {
|
|
3137
|
+
if (message.requestType !== 'offer' || message.sessionId !== sessionId) {
|
|
3138
|
+
break;
|
|
3139
|
+
}
|
|
3140
|
+
const reason =
|
|
3141
|
+
typeof message.error === 'string' ?
|
|
3142
|
+
message.error
|
|
3143
|
+
: 'Unable to establish the streaming connection';
|
|
3144
|
+
updateStatus('Error: ' + reason);
|
|
3145
|
+
scheduleRetry(reason, generation);
|
|
3146
|
+
break;
|
|
3147
|
+
}
|
|
2989
3148
|
case 'answer':
|
|
2990
3149
|
if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
|
|
2991
3150
|
updateStatus('No peer connection, skipping answer');
|
|
@@ -3178,6 +3337,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3178
3337
|
ws.send(
|
|
3179
3338
|
JSON.stringify({
|
|
3180
3339
|
type: 'offer',
|
|
3340
|
+
...(isDuo ? { displayStreams: true } : {}),
|
|
3181
3341
|
sdp: offer.sdp,
|
|
3182
3342
|
sessionId: sessionId,
|
|
3183
3343
|
}),
|
|
@@ -3254,7 +3414,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3254
3414
|
// connection effect doesn't need to bounce when the camera
|
|
3255
3415
|
// turns on/off — no SDP-affecting change.
|
|
3256
3416
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3257
|
-
}, [url, token, propSessionId]);
|
|
3417
|
+
}, [url, token, propSessionId, deviceModel]);
|
|
3258
3418
|
|
|
3259
3419
|
// Recompute the inspect-overlay geometry (container-local pixel rect of
|
|
3260
3420
|
// the actually-rendered video content) from the current mapping context.
|
|
@@ -3296,7 +3456,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3296
3456
|
const updateVideoPosition = () => {
|
|
3297
3457
|
// If no frame, just refresh overlay geometry; no inset/letterbox math
|
|
3298
3458
|
// is needed since the video element is its own size.
|
|
3299
|
-
if (!
|
|
3459
|
+
if (!frameVisible || !frame) {
|
|
3300
3460
|
setVideoStyle({});
|
|
3301
3461
|
recomputeOverlayGeometry();
|
|
3302
3462
|
return;
|
|
@@ -3369,7 +3529,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3369
3529
|
video.removeEventListener('resize', bumpOnResize);
|
|
3370
3530
|
if (frame) frame.removeEventListener('load', updateVideoPosition);
|
|
3371
3531
|
};
|
|
3372
|
-
}, [config,
|
|
3532
|
+
}, [config, frameVisible]);
|
|
3373
3533
|
|
|
3374
3534
|
// Start/stop the AX poller and reset inspect state when inspect mode
|
|
3375
3535
|
// toggles. Connection state is independent: the fetcher gets created on
|
|
@@ -3490,7 +3650,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3490
3650
|
sendBinaryControlMessage(message);
|
|
3491
3651
|
}
|
|
3492
3652
|
},
|
|
3493
|
-
screenshot: (): Promise<ScreenshotData> => {
|
|
3653
|
+
screenshot: (display?: 'outer' | 'inner'): Promise<ScreenshotData> => {
|
|
3494
3654
|
return new Promise<ScreenshotData>((resolve, reject) => {
|
|
3495
3655
|
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
3496
3656
|
debugWarn('WebSocket not open, cannot send screenshot command.');
|
|
@@ -3499,7 +3659,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3499
3659
|
|
|
3500
3660
|
const id = `ui-ss-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
3501
3661
|
const request = {
|
|
3502
|
-
type: 'screenshot',
|
|
3662
|
+
type: display ? 'screenshotDisplay' : 'screenshot',
|
|
3663
|
+
...(display ? { display } : {}),
|
|
3503
3664
|
id: id,
|
|
3504
3665
|
};
|
|
3505
3666
|
|
|
@@ -3613,12 +3774,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3613
3774
|
|
|
3614
3775
|
// Show indicators when Alt is held and we have a valid hover point (null when outside)
|
|
3615
3776
|
const showAltIndicators = isAltHeld && hoverPoint !== null;
|
|
3616
|
-
const frameImageSrc =
|
|
3617
|
-
platform === 'android' && useAndroidTabletFrame ?
|
|
3618
|
-
isLandscape ? pixelTabletFrameImageLandscape
|
|
3619
|
-
: pixelTabletFrameImage
|
|
3620
|
-
: isLandscape ? config.frame.imageLandscape
|
|
3621
|
-
: config.frame.image;
|
|
3622
3777
|
|
|
3623
3778
|
return (
|
|
3624
3779
|
<div
|
|
@@ -3654,7 +3809,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3654
3809
|
/>
|
|
3655
3810
|
</>
|
|
3656
3811
|
)}
|
|
3657
|
-
{
|
|
3812
|
+
{frameVisible && (
|
|
3658
3813
|
<img
|
|
3659
3814
|
ref={frameRef}
|
|
3660
3815
|
src={frameImageSrc}
|
|
@@ -3665,12 +3820,17 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3665
3820
|
)}
|
|
3666
3821
|
<video
|
|
3667
3822
|
ref={videoRef}
|
|
3668
|
-
className={clsx(
|
|
3823
|
+
className={clsx(
|
|
3824
|
+
'rc-video',
|
|
3825
|
+
!frameVisible && 'rc-video-frameless',
|
|
3826
|
+
!videoLoaded && 'rc-video-loading',
|
|
3827
|
+
)}
|
|
3669
3828
|
style={{
|
|
3670
3829
|
...videoStyle,
|
|
3671
|
-
...(
|
|
3830
|
+
...(isDuo ? { opacity: 0, width: 1, height: 1, pointerEvents: 'none' as const } : {}),
|
|
3831
|
+
...(loadingLogo ?
|
|
3672
3832
|
{
|
|
3673
|
-
backgroundImage: `url("${
|
|
3833
|
+
backgroundImage: `url("${loadingLogo}")`,
|
|
3674
3834
|
backgroundRepeat: 'no-repeat',
|
|
3675
3835
|
backgroundPosition: 'center',
|
|
3676
3836
|
backgroundSize: config.loadingLogoSize,
|
|
@@ -3680,7 +3840,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3680
3840
|
autoPlay
|
|
3681
3841
|
playsInline
|
|
3682
3842
|
muted
|
|
3683
|
-
tabIndex={0}
|
|
3843
|
+
tabIndex={isDuo ? -1 : 0}
|
|
3684
3844
|
onKeyDown={handleKeyboard}
|
|
3685
3845
|
onKeyUp={handleKeyboard}
|
|
3686
3846
|
onClick={handleVideoClick}
|
|
@@ -3696,7 +3856,47 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
3696
3856
|
}
|
|
3697
3857
|
}}
|
|
3698
3858
|
/>
|
|
3699
|
-
{
|
|
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 && (
|
|
3700
3900
|
<InspectOverlay
|
|
3701
3901
|
snapshot={axSnapshot}
|
|
3702
3902
|
geometry={overlayGeometry}
|