@limrun/ui 0.6.0 → 0.8.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/dist/components/remote-control.d.ts +2 -0
- package/dist/demo.d.ts +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +613 -605
- package/index.html +199 -0
- package/package.json +43 -43
- package/src/components/remote-control.css +1 -1
- package/src/components/remote-control.tsx +269 -195
- package/src/demo.tsx +185 -0
- package/tsconfig.json +25 -25
- package/tsconfig.node.json +25 -25
|
@@ -51,6 +51,10 @@ interface RemoteControlProps {
|
|
|
51
51
|
// showFrame controls whether to display the device frame
|
|
52
52
|
// around the video. Defaults to true.
|
|
53
53
|
showFrame?: boolean;
|
|
54
|
+
|
|
55
|
+
// When true, drops after a working session auto-reconnect instead of
|
|
56
|
+
// surfacing the manual "Retry" button. Defaults to false.
|
|
57
|
+
autoReconnect?: boolean;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
interface ScreenshotData {
|
|
@@ -71,6 +75,7 @@ export interface RemoteControlHandle {
|
|
|
71
75
|
sendKeyEvent: (event: ImperativeKeyboardEvent) => void;
|
|
72
76
|
screenshot: () => Promise<ScreenshotData>;
|
|
73
77
|
terminateApp: (bundleId: string) => Promise<void>;
|
|
78
|
+
reconnect: () => void;
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
const debugLog = (...args: any[]) => {
|
|
@@ -108,20 +113,21 @@ type DeviceConfig = {
|
|
|
108
113
|
loadingLogo: string;
|
|
109
114
|
loadingLogoSize: string;
|
|
110
115
|
videoPosition: {
|
|
111
|
-
portrait: { heightMultiplier?: number; widthMultiplier?: number
|
|
112
|
-
landscape: { heightMultiplier?: number; widthMultiplier?: number
|
|
116
|
+
portrait: { heightMultiplier?: number; widthMultiplier?: number };
|
|
117
|
+
landscape: { heightMultiplier?: number; widthMultiplier?: number };
|
|
113
118
|
};
|
|
114
119
|
frame: {
|
|
115
120
|
image: string;
|
|
116
121
|
imageLandscape: string;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
122
|
+
};
|
|
123
|
+
};
|
|
119
124
|
|
|
120
125
|
const ANDROID_TABLET_VIDEO_WIDTH = 1920;
|
|
121
126
|
const ANDROID_TABLET_VIDEO_HEIGHT = 1200;
|
|
122
127
|
const MAX_CONNECTION_ATTEMPTS = 3;
|
|
123
128
|
const CONNECTION_RETRY_DELAY_MS = 1000;
|
|
124
129
|
const CONNECTION_SUCCESS_TIMEOUT_MS = 15000;
|
|
130
|
+
const ICE_DISCONNECTED_GRACE_MS = 3000;
|
|
125
131
|
|
|
126
132
|
const isAndroidTabletVideo = (width: number, height: number): boolean =>
|
|
127
133
|
(width === ANDROID_TABLET_VIDEO_WIDTH && height === ANDROID_TABLET_VIDEO_HEIGHT) ||
|
|
@@ -191,7 +197,18 @@ function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number
|
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>(
|
|
194
|
-
(
|
|
200
|
+
(
|
|
201
|
+
{
|
|
202
|
+
className,
|
|
203
|
+
url,
|
|
204
|
+
token,
|
|
205
|
+
sessionId: propSessionId,
|
|
206
|
+
openUrl,
|
|
207
|
+
showFrame = true,
|
|
208
|
+
autoReconnect = false,
|
|
209
|
+
}: RemoteControlProps,
|
|
210
|
+
ref,
|
|
211
|
+
) => {
|
|
195
212
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
196
213
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
197
214
|
const frameRef = useRef<HTMLImageElement>(null);
|
|
@@ -207,9 +224,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
207
224
|
const retryTimeoutRef = useRef<number | undefined>(undefined);
|
|
208
225
|
const connectionSuccessTimeoutRef = useRef<number | undefined>(undefined);
|
|
209
226
|
const requestFrameIntervalRef = useRef<number | undefined>(undefined);
|
|
227
|
+
const iceDisconnectedGraceRef = useRef<number | undefined>(undefined);
|
|
210
228
|
const connectionGenerationRef = useRef(0);
|
|
211
229
|
const connectionAttemptRef = useRef(0);
|
|
212
230
|
const controlChannelOpenedRef = useRef(false);
|
|
231
|
+
// Mirrored to a ref so stale closures in event handlers see the latest value.
|
|
232
|
+
const autoReconnectRef = useRef(autoReconnect);
|
|
233
|
+
autoReconnectRef.current = autoReconnect;
|
|
213
234
|
const firstFrameShownRef = useRef(false);
|
|
214
235
|
const pendingScreenshotResolversRef = useRef<
|
|
215
236
|
Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
|
|
@@ -319,7 +340,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
319
340
|
|
|
320
341
|
// Minimal geometry for single-finger touch events (no mirror/container coords needed).
|
|
321
342
|
type PointerGeometry = {
|
|
322
|
-
inside: boolean;
|
|
323
343
|
videoX: number;
|
|
324
344
|
videoY: number;
|
|
325
345
|
videoWidth: number;
|
|
@@ -332,7 +352,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
332
352
|
geometry: PointerGeometry | null,
|
|
333
353
|
) => {
|
|
334
354
|
if (!geometry) return;
|
|
335
|
-
const {
|
|
355
|
+
const { videoX, videoY, videoWidth, videoHeight } = geometry;
|
|
336
356
|
|
|
337
357
|
let action: number | null = null;
|
|
338
358
|
let positionToSend: { x: number; y: number } | null = null;
|
|
@@ -341,35 +361,23 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
341
361
|
|
|
342
362
|
switch (eventType) {
|
|
343
363
|
case 'down':
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
activePointers.current.set(pointerId, positionToSend);
|
|
353
|
-
if (pointerId === -1) {
|
|
354
|
-
// Focus on mouse down
|
|
355
|
-
videoRef.current?.focus();
|
|
356
|
-
}
|
|
357
|
-
} else {
|
|
358
|
-
// If the initial down event is outside, ignore it for this pointer
|
|
359
|
-
activePointers.current.delete(pointerId);
|
|
364
|
+
// For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
|
|
365
|
+
const currentPointerCount = activePointers.current.size;
|
|
366
|
+
action = currentPointerCount === 0 ? AMOTION_EVENT.ACTION_DOWN : AMOTION_EVENT.ACTION_POINTER_DOWN;
|
|
367
|
+
positionToSend = { x: videoX, y: videoY };
|
|
368
|
+
activePointers.current.set(pointerId, positionToSend);
|
|
369
|
+
if (pointerId === -1) {
|
|
370
|
+
// Focus on mouse down
|
|
371
|
+
videoRef.current?.focus();
|
|
360
372
|
}
|
|
361
373
|
break;
|
|
362
374
|
|
|
363
375
|
case 'move':
|
|
364
376
|
if (activePointers.current.has(pointerId)) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
activePointers.current.set(pointerId, positionToSend);
|
|
370
|
-
} else {
|
|
371
|
-
// Moved outside while active - do nothing, UP/CANCEL will use last known pos
|
|
372
|
-
}
|
|
377
|
+
action = AMOTION_EVENT.ACTION_MOVE;
|
|
378
|
+
positionToSend = { x: videoX, y: videoY };
|
|
379
|
+
// Update the last known position for this active pointer
|
|
380
|
+
activePointers.current.set(pointerId, positionToSend);
|
|
373
381
|
}
|
|
374
382
|
break;
|
|
375
383
|
|
|
@@ -386,9 +394,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
386
394
|
// For multi-touch: use ACTION_UP for last pointer, ACTION_POINTER_UP for non-last pointers
|
|
387
395
|
const remainingPointerCount = activePointers.current.size;
|
|
388
396
|
action =
|
|
389
|
-
remainingPointerCount === 0
|
|
390
|
-
? AMOTION_EVENT.ACTION_UP
|
|
391
|
-
: AMOTION_EVENT.ACTION_POINTER_UP;
|
|
397
|
+
remainingPointerCount === 0 ? AMOTION_EVENT.ACTION_UP : AMOTION_EVENT.ACTION_POINTER_UP;
|
|
392
398
|
}
|
|
393
399
|
}
|
|
394
400
|
break;
|
|
@@ -396,21 +402,20 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
396
402
|
|
|
397
403
|
// Send message if action and position determined
|
|
398
404
|
if (action !== null && positionToSend !== null) {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
});
|
|
405
|
+
debugLog('[rc-touch][mouse->touch] sending', {
|
|
406
|
+
pointerId,
|
|
407
|
+
eventType,
|
|
408
|
+
action,
|
|
409
|
+
actionName: motionActionToString(action),
|
|
410
|
+
positionToSend,
|
|
411
|
+
video: { width: videoWidth, height: videoHeight },
|
|
412
|
+
altHeld: isAltHeldRef.current,
|
|
413
|
+
activePointersAfter: Array.from(activePointers.current.entries()).map(([id, pos]) => ({
|
|
414
|
+
id,
|
|
415
|
+
x: pos.x,
|
|
416
|
+
y: pos.y,
|
|
417
|
+
})),
|
|
418
|
+
});
|
|
414
419
|
const message = createTouchControlMessage(
|
|
415
420
|
action,
|
|
416
421
|
pointerId,
|
|
@@ -423,15 +428,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
423
428
|
buttons,
|
|
424
429
|
);
|
|
425
430
|
if (message) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
+
debugLog('[rc-touch][mouse->touch] buffer', {
|
|
432
|
+
pointerId,
|
|
433
|
+
actionName: motionActionToString(action),
|
|
434
|
+
byteLength: message.byteLength,
|
|
435
|
+
});
|
|
431
436
|
sendBinaryControlMessage(message);
|
|
432
437
|
}
|
|
433
438
|
} else if (eventType === 'up' || eventType === 'cancel') {
|
|
434
|
-
// Clean up map just in case if 'down' was outside and 'up'/'cancel' is triggered
|
|
435
439
|
activePointers.current.delete(pointerId);
|
|
436
440
|
}
|
|
437
441
|
};
|
|
@@ -460,7 +464,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
460
464
|
// This is iOS-specific; Android doesn't use this modifier injection.
|
|
461
465
|
if (platform === 'ios' && dataChannelRef.current && dataChannelRef.current.readyState === 'open') {
|
|
462
466
|
const action = nextHeld ? ANDROID_KEYS.ACTION_DOWN : ANDROID_KEYS.ACTION_UP;
|
|
463
|
-
const message = createInjectKeycodeMessage(
|
|
467
|
+
const message = createInjectKeycodeMessage(
|
|
468
|
+
action,
|
|
469
|
+
ANDROID_KEYS.KEYCODE_ALT_LEFT,
|
|
470
|
+
0,
|
|
471
|
+
ANDROID_KEYS.META_NONE,
|
|
472
|
+
);
|
|
464
473
|
debugLog('[rc-touch][alt] sending Indigo modifier keycode', {
|
|
465
474
|
action,
|
|
466
475
|
keycode: ANDROID_KEYS.KEYCODE_ALT_LEFT,
|
|
@@ -524,8 +533,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
524
533
|
};
|
|
525
534
|
};
|
|
526
535
|
|
|
527
|
-
// Map a client point to video coordinates using a pre-computed context
|
|
528
|
-
//
|
|
536
|
+
// Map a client point to video coordinates using a pre-computed context,
|
|
537
|
+
// clamping points outside the rendered video to the nearest point on the video.
|
|
529
538
|
const mapClientPointToVideo = (
|
|
530
539
|
ctx: VideoMappingContext,
|
|
531
540
|
clientX: number,
|
|
@@ -534,25 +543,18 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
534
543
|
const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
|
|
535
544
|
const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
|
|
536
545
|
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
videoHeight: ctx.videoHeight,
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
|
|
552
|
-
const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
|
|
546
|
+
const clampedRelativeX = Math.max(0, Math.min(ctx.actualWidth, relativeX));
|
|
547
|
+
const clampedRelativeY = Math.max(0, Math.min(ctx.actualHeight, relativeY));
|
|
548
|
+
const videoX = Math.max(
|
|
549
|
+
0,
|
|
550
|
+
Math.min(ctx.videoWidth, (clampedRelativeX / ctx.actualWidth) * ctx.videoWidth),
|
|
551
|
+
);
|
|
552
|
+
const videoY = Math.max(
|
|
553
|
+
0,
|
|
554
|
+
Math.min(ctx.videoHeight, (clampedRelativeY / ctx.actualHeight) * ctx.videoHeight),
|
|
555
|
+
);
|
|
553
556
|
|
|
554
557
|
return {
|
|
555
|
-
inside: true,
|
|
556
558
|
videoX,
|
|
557
559
|
videoY,
|
|
558
560
|
videoWidth: ctx.videoWidth,
|
|
@@ -560,7 +562,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
560
562
|
};
|
|
561
563
|
};
|
|
562
564
|
|
|
563
|
-
// Compute full hover point with mirror/container coordinates (for Alt indicator rendering)
|
|
565
|
+
// Compute full hover point with mirror/container coordinates (for Alt indicator rendering),
|
|
566
|
+
// clamping points outside the rendered video to the nearest point on the video.
|
|
564
567
|
const computeFullHoverPoint = (
|
|
565
568
|
ctx: VideoMappingContext,
|
|
566
569
|
clientX: number,
|
|
@@ -569,25 +572,25 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
569
572
|
const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
|
|
570
573
|
const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
|
|
571
574
|
|
|
572
|
-
const
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
575
|
+
const clampedRelativeX = Math.max(0, Math.min(ctx.actualWidth, relativeX));
|
|
576
|
+
const clampedRelativeY = Math.max(0, Math.min(ctx.actualHeight, relativeY));
|
|
577
|
+
const videoX = Math.max(
|
|
578
|
+
0,
|
|
579
|
+
Math.min(ctx.videoWidth, (clampedRelativeX / ctx.actualWidth) * ctx.videoWidth),
|
|
580
|
+
);
|
|
581
|
+
const videoY = Math.max(
|
|
582
|
+
0,
|
|
583
|
+
Math.min(ctx.videoHeight, (clampedRelativeY / ctx.actualHeight) * ctx.videoHeight),
|
|
584
|
+
);
|
|
582
585
|
const mirrorVideoX = ctx.videoWidth - videoX;
|
|
583
586
|
const mirrorVideoY = ctx.videoHeight - videoY;
|
|
584
587
|
|
|
585
588
|
const contentLeft = ctx.videoRect.left + ctx.offsetX;
|
|
586
589
|
const contentTop = ctx.videoRect.top + ctx.offsetY;
|
|
587
|
-
const containerX = contentLeft - ctx.containerRect.left +
|
|
588
|
-
const containerY = contentTop - ctx.containerRect.top +
|
|
589
|
-
const mirrorContainerX = contentLeft - ctx.containerRect.left + (ctx.actualWidth -
|
|
590
|
-
const mirrorContainerY = contentTop - ctx.containerRect.top + (ctx.actualHeight -
|
|
590
|
+
const containerX = contentLeft - ctx.containerRect.left + clampedRelativeX;
|
|
591
|
+
const containerY = contentTop - ctx.containerRect.top + clampedRelativeY;
|
|
592
|
+
const mirrorContainerX = contentLeft - ctx.containerRect.left + (ctx.actualWidth - clampedRelativeX);
|
|
593
|
+
const mirrorContainerY = contentTop - ctx.containerRect.top + (ctx.actualHeight - clampedRelativeY);
|
|
591
594
|
|
|
592
595
|
return {
|
|
593
596
|
containerX,
|
|
@@ -613,15 +616,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
613
616
|
x1: number,
|
|
614
617
|
y1: number,
|
|
615
618
|
) => {
|
|
616
|
-
const msg = createTwoFingerTouchControlMessage(
|
|
617
|
-
action,
|
|
618
|
-
videoWidth,
|
|
619
|
-
videoHeight,
|
|
620
|
-
x0,
|
|
621
|
-
y0,
|
|
622
|
-
x1,
|
|
623
|
-
y1,
|
|
624
|
-
);
|
|
619
|
+
const msg = createTwoFingerTouchControlMessage(action, videoWidth, videoHeight, x0, y0, x1, y1);
|
|
625
620
|
debugLog('[rc-touch2] sendTwoFingerMessage (iOS)', {
|
|
626
621
|
actionName: motionActionToString(action),
|
|
627
622
|
video: { width: videoWidth, height: videoHeight },
|
|
@@ -656,9 +651,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
656
651
|
|
|
657
652
|
if (platform === 'ios') {
|
|
658
653
|
// iOS: use special two-finger message (type=18)
|
|
659
|
-
const action =
|
|
660
|
-
|
|
661
|
-
|
|
654
|
+
const action =
|
|
655
|
+
eventType === 'down' ? AMOTION_EVENT.ACTION_DOWN
|
|
656
|
+
: eventType === 'move' ? AMOTION_EVENT.ACTION_MOVE
|
|
657
|
+
: AMOTION_EVENT.ACTION_UP;
|
|
662
658
|
sendTwoFingerMessage(action, videoWidth, videoHeight, x0, y0, x1, y1);
|
|
663
659
|
} else {
|
|
664
660
|
// Android: send two separate single-touch messages with proper action codes
|
|
@@ -711,7 +707,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
711
707
|
// to ensure consistent behavior across focus transitions.
|
|
712
708
|
}
|
|
713
709
|
|
|
714
|
-
if (
|
|
710
|
+
if (
|
|
711
|
+
!dataChannelRef.current ||
|
|
712
|
+
dataChannelRef.current.readyState !== 'open' ||
|
|
713
|
+
!videoRef.current ||
|
|
714
|
+
!ctx
|
|
715
|
+
) {
|
|
715
716
|
return;
|
|
716
717
|
}
|
|
717
718
|
|
|
@@ -751,37 +752,55 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
751
752
|
|
|
752
753
|
if (!twoFingerStateRef.current) {
|
|
753
754
|
// Starting a new two-finger gesture
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
755
|
+
twoFingerStateRef.current = {
|
|
756
|
+
finger0: { x: g0.videoX, y: g0.videoY },
|
|
757
|
+
finger1: { x: g1.videoX, y: g1.videoY },
|
|
758
|
+
videoSize: { width: g0.videoWidth, height: g0.videoHeight },
|
|
759
|
+
source: 'real-touch',
|
|
760
|
+
pointerId0: t0.identifier,
|
|
761
|
+
pointerId1: t1.identifier,
|
|
762
|
+
};
|
|
763
|
+
applyTwoFingerEvent(
|
|
764
|
+
'down',
|
|
765
|
+
g0.videoWidth,
|
|
766
|
+
g0.videoHeight,
|
|
767
|
+
g0.videoX,
|
|
768
|
+
g0.videoY,
|
|
769
|
+
g1.videoX,
|
|
770
|
+
g1.videoY,
|
|
771
|
+
t0.identifier,
|
|
772
|
+
t1.identifier,
|
|
773
|
+
);
|
|
767
774
|
} else if (twoFingerStateRef.current.source === 'real-touch') {
|
|
768
775
|
// Continuing two-finger gesture (move)
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
776
|
+
twoFingerStateRef.current.finger0 = { x: g0.videoX, y: g0.videoY };
|
|
777
|
+
twoFingerStateRef.current.finger1 = { x: g1.videoX, y: g1.videoY };
|
|
778
|
+
applyTwoFingerEvent(
|
|
779
|
+
'move',
|
|
780
|
+
g0.videoWidth,
|
|
781
|
+
g0.videoHeight,
|
|
782
|
+
g0.videoX,
|
|
783
|
+
g0.videoY,
|
|
784
|
+
g1.videoX,
|
|
785
|
+
g1.videoY,
|
|
786
|
+
twoFingerStateRef.current.pointerId0,
|
|
787
|
+
twoFingerStateRef.current.pointerId1,
|
|
788
|
+
);
|
|
777
789
|
}
|
|
778
790
|
} else if (allTouches.length < 2 && twoFingerStateRef.current?.source === 'real-touch') {
|
|
779
791
|
// Finger lifted - end two-finger gesture using last known state
|
|
780
792
|
const state = twoFingerStateRef.current;
|
|
781
|
-
applyTwoFingerEvent(
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
793
|
+
applyTwoFingerEvent(
|
|
794
|
+
'up',
|
|
795
|
+
state.videoSize.width,
|
|
796
|
+
state.videoSize.height,
|
|
797
|
+
state.finger0.x,
|
|
798
|
+
state.finger0.y,
|
|
799
|
+
state.finger1.x,
|
|
800
|
+
state.finger1.y,
|
|
801
|
+
state.pointerId0,
|
|
802
|
+
state.pointerId1,
|
|
803
|
+
);
|
|
785
804
|
twoFingerStateRef.current = null;
|
|
786
805
|
// Don't process remaining finger - gesture ended
|
|
787
806
|
return;
|
|
@@ -841,7 +860,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
841
860
|
altHeldRef: isAltHeldRef.current,
|
|
842
861
|
inTwoFingerMode,
|
|
843
862
|
geometry: {
|
|
844
|
-
inside: geometry.inside,
|
|
845
863
|
videoX: geometry.videoX,
|
|
846
864
|
videoY: geometry.videoY,
|
|
847
865
|
videoWidth: geometry.videoWidth,
|
|
@@ -868,48 +886,71 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
868
886
|
eventType: 'down' | 'move' | 'up' | 'cancel',
|
|
869
887
|
geometry: PointerGeometry,
|
|
870
888
|
) => {
|
|
871
|
-
const {
|
|
889
|
+
const { videoX, videoY, videoWidth, videoHeight } = geometry;
|
|
872
890
|
const mirrorX = videoWidth - videoX;
|
|
873
891
|
const mirrorY = videoHeight - videoY;
|
|
874
892
|
|
|
875
893
|
if (eventType === 'down') {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
894
|
+
// Start two-finger gesture
|
|
895
|
+
twoFingerStateRef.current = {
|
|
896
|
+
finger0: { x: videoX, y: videoY },
|
|
897
|
+
finger1: { x: mirrorX, y: mirrorY },
|
|
898
|
+
videoSize: { width: videoWidth, height: videoHeight },
|
|
899
|
+
source: 'alt-mouse',
|
|
900
|
+
pointerId0: ALT_POINTER_ID_PRIMARY,
|
|
901
|
+
pointerId1: ALT_POINTER_ID_MIRROR,
|
|
902
|
+
};
|
|
903
|
+
videoRef.current?.focus();
|
|
904
|
+
applyTwoFingerEvent(
|
|
905
|
+
'down',
|
|
906
|
+
videoWidth,
|
|
907
|
+
videoHeight,
|
|
908
|
+
videoX,
|
|
909
|
+
videoY,
|
|
910
|
+
mirrorX,
|
|
911
|
+
mirrorY,
|
|
912
|
+
ALT_POINTER_ID_PRIMARY,
|
|
913
|
+
ALT_POINTER_ID_MIRROR,
|
|
914
|
+
);
|
|
890
915
|
return;
|
|
891
916
|
}
|
|
892
917
|
|
|
893
918
|
if (eventType === 'move') {
|
|
894
|
-
if (twoFingerStateRef.current?.source === 'alt-mouse'
|
|
919
|
+
if (twoFingerStateRef.current?.source === 'alt-mouse') {
|
|
895
920
|
// Update positions
|
|
896
921
|
twoFingerStateRef.current.finger0 = { x: videoX, y: videoY };
|
|
897
922
|
twoFingerStateRef.current.finger1 = { x: mirrorX, y: mirrorY };
|
|
898
|
-
applyTwoFingerEvent(
|
|
899
|
-
|
|
923
|
+
applyTwoFingerEvent(
|
|
924
|
+
'move',
|
|
925
|
+
videoWidth,
|
|
926
|
+
videoHeight,
|
|
927
|
+
videoX,
|
|
928
|
+
videoY,
|
|
929
|
+
mirrorX,
|
|
930
|
+
mirrorY,
|
|
931
|
+
ALT_POINTER_ID_PRIMARY,
|
|
932
|
+
ALT_POINTER_ID_MIRROR,
|
|
933
|
+
);
|
|
900
934
|
}
|
|
901
|
-
// If outside, we just don't send a move - UP will use last known position
|
|
902
935
|
return;
|
|
903
936
|
}
|
|
904
937
|
|
|
905
938
|
if (eventType === 'up' || eventType === 'cancel') {
|
|
906
939
|
const state = twoFingerStateRef.current;
|
|
907
940
|
if (state?.source === 'alt-mouse') {
|
|
908
|
-
// End gesture at last known
|
|
941
|
+
// End gesture at last known positions
|
|
909
942
|
const { finger0, finger1, videoSize } = state;
|
|
910
|
-
applyTwoFingerEvent(
|
|
911
|
-
|
|
912
|
-
|
|
943
|
+
applyTwoFingerEvent(
|
|
944
|
+
'up',
|
|
945
|
+
videoSize.width,
|
|
946
|
+
videoSize.height,
|
|
947
|
+
finger0.x,
|
|
948
|
+
finger0.y,
|
|
949
|
+
finger1.x,
|
|
950
|
+
finger1.y,
|
|
951
|
+
ALT_POINTER_ID_PRIMARY,
|
|
952
|
+
ALT_POINTER_ID_MIRROR,
|
|
953
|
+
);
|
|
913
954
|
twoFingerStateRef.current = null;
|
|
914
955
|
}
|
|
915
956
|
return;
|
|
@@ -1077,6 +1118,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1077
1118
|
}
|
|
1078
1119
|
};
|
|
1079
1120
|
|
|
1121
|
+
const clearIceDisconnectedGrace = () => {
|
|
1122
|
+
if (iceDisconnectedGraceRef.current !== undefined) {
|
|
1123
|
+
window.clearTimeout(iceDisconnectedGraceRef.current);
|
|
1124
|
+
iceDisconnectedGraceRef.current = undefined;
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1080
1128
|
const markFirstFrameShown = () => {
|
|
1081
1129
|
if (firstFrameShownRef.current) {
|
|
1082
1130
|
return;
|
|
@@ -1088,6 +1136,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1088
1136
|
|
|
1089
1137
|
const teardownConnection = () => {
|
|
1090
1138
|
clearConnectionSuccessTimeout();
|
|
1139
|
+
clearIceDisconnectedGrace();
|
|
1091
1140
|
stopRequestFrameLoop();
|
|
1092
1141
|
if (wsRef.current) {
|
|
1093
1142
|
wsRef.current.onopen = null;
|
|
@@ -1131,10 +1180,16 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1131
1180
|
}
|
|
1132
1181
|
|
|
1133
1182
|
if (controlChannelOpenedRef.current) {
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1183
|
+
if (!autoReconnectRef.current) {
|
|
1184
|
+
updateStatus(`Connection failed after it was established: ${reason}`);
|
|
1185
|
+
setRetryExhausted(true);
|
|
1186
|
+
teardownConnection();
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
// Reset so the upcoming retry gets a fresh MAX_CONNECTION_ATTEMPTS budget.
|
|
1190
|
+
updateStatus(`Reconnecting after established session dropped: ${reason}`);
|
|
1191
|
+
controlChannelOpenedRef.current = false;
|
|
1192
|
+
connectionAttemptRef.current = -1;
|
|
1138
1193
|
}
|
|
1139
1194
|
|
|
1140
1195
|
clearScheduledRetry();
|
|
@@ -1171,8 +1226,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1171
1226
|
setVideoLoaded(false);
|
|
1172
1227
|
teardownConnection();
|
|
1173
1228
|
|
|
1174
|
-
const isCurrentAttempt = () =>
|
|
1175
|
-
generation === connectionGenerationRef.current;
|
|
1229
|
+
const isCurrentAttempt = () => generation === connectionGenerationRef.current;
|
|
1176
1230
|
|
|
1177
1231
|
connectionSuccessTimeoutRef.current = window.setTimeout(() => {
|
|
1178
1232
|
connectionSuccessTimeoutRef.current = undefined;
|
|
@@ -1300,7 +1354,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1300
1354
|
return getCodecPriority(a) - getCodecPriority(b);
|
|
1301
1355
|
});
|
|
1302
1356
|
videoTransceiver.setCodecPreferences(sortedCodecs);
|
|
1303
|
-
debugLog('Set codec preferences:', sortedCodecs.map(c => c.mimeType).join(', '));
|
|
1357
|
+
debugLog('Set codec preferences:', sortedCodecs.map((c) => c.mimeType).join(', '));
|
|
1304
1358
|
}
|
|
1305
1359
|
}
|
|
1306
1360
|
|
|
@@ -1402,9 +1456,32 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1402
1456
|
if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
|
|
1403
1457
|
return;
|
|
1404
1458
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1459
|
+
const iceState = peerConnection.iceConnectionState;
|
|
1460
|
+
updateStatus('ICE state: ' + iceState);
|
|
1461
|
+
if (iceState === 'connected' || iceState === 'completed') {
|
|
1462
|
+
clearIceDisconnectedGrace();
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
if (iceState === 'failed') {
|
|
1466
|
+
clearIceDisconnectedGrace();
|
|
1407
1467
|
scheduleRetry('ICE connection entered failed state', generation);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (
|
|
1471
|
+
iceState === 'disconnected' &&
|
|
1472
|
+
autoReconnectRef.current &&
|
|
1473
|
+
iceDisconnectedGraceRef.current === undefined
|
|
1474
|
+
) {
|
|
1475
|
+
// Cap the browser's natural disconnected→failed escalation to recover faster.
|
|
1476
|
+
iceDisconnectedGraceRef.current = window.setTimeout(() => {
|
|
1477
|
+
iceDisconnectedGraceRef.current = undefined;
|
|
1478
|
+
if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
if (peerConnection.iceConnectionState === 'disconnected') {
|
|
1482
|
+
scheduleRetry('ICE stayed disconnected past grace period', generation);
|
|
1483
|
+
}
|
|
1484
|
+
}, ICE_DISCONNECTED_GRACE_MS);
|
|
1408
1485
|
}
|
|
1409
1486
|
};
|
|
1410
1487
|
|
|
@@ -1614,6 +1691,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1614
1691
|
connectionAttemptRef.current = 0;
|
|
1615
1692
|
controlChannelOpenedRef.current = false;
|
|
1616
1693
|
clearScheduledRetry();
|
|
1694
|
+
clearIceDisconnectedGrace();
|
|
1617
1695
|
teardownConnection();
|
|
1618
1696
|
updateStatus('Stopped');
|
|
1619
1697
|
};
|
|
@@ -1650,9 +1728,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1650
1728
|
useEffect(() => {
|
|
1651
1729
|
const video = videoRef.current;
|
|
1652
1730
|
const frame = frameRef.current;
|
|
1653
|
-
|
|
1731
|
+
|
|
1654
1732
|
if (!video) return;
|
|
1655
|
-
|
|
1733
|
+
|
|
1656
1734
|
// If no frame, no positioning needed
|
|
1657
1735
|
if (!showFrame || !frame) {
|
|
1658
1736
|
setVideoStyle({});
|
|
@@ -1662,16 +1740,16 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1662
1740
|
const updateVideoPosition = () => {
|
|
1663
1741
|
const frameWidth = frame.clientWidth;
|
|
1664
1742
|
const frameHeight = frame.clientHeight;
|
|
1665
|
-
|
|
1743
|
+
|
|
1666
1744
|
if (frameWidth === 0 || frameHeight === 0) return;
|
|
1667
|
-
|
|
1745
|
+
|
|
1668
1746
|
// Determine landscape based on video's intrinsic dimensions
|
|
1669
1747
|
const landscape = video.videoWidth > video.videoHeight;
|
|
1670
1748
|
setIsLandscape(landscape);
|
|
1671
1749
|
setUseAndroidTabletFrame(
|
|
1672
1750
|
platform === 'android' && isAndroidTabletVideo(video.videoWidth, video.videoHeight),
|
|
1673
1751
|
);
|
|
1674
|
-
|
|
1752
|
+
|
|
1675
1753
|
const pos = landscape ? config.videoPosition.landscape : config.videoPosition.portrait;
|
|
1676
1754
|
let newStyle: React.CSSProperties = {};
|
|
1677
1755
|
if (pos.heightMultiplier) {
|
|
@@ -1683,7 +1761,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1683
1761
|
// Let the other dimension follow the video stream's intrinsic aspect ratio.
|
|
1684
1762
|
newStyle.height = 'auto';
|
|
1685
1763
|
}
|
|
1686
|
-
newStyle.borderRadius = `${
|
|
1764
|
+
newStyle.borderRadius = `${
|
|
1765
|
+
landscape ?
|
|
1766
|
+
frameHeight * config.videoBorderRadiusMultiplier
|
|
1767
|
+
: frameWidth * config.videoBorderRadiusMultiplier
|
|
1768
|
+
}px`;
|
|
1687
1769
|
setVideoStyle(newStyle);
|
|
1688
1770
|
};
|
|
1689
1771
|
|
|
@@ -1693,10 +1775,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1693
1775
|
|
|
1694
1776
|
resizeObserver.observe(frame);
|
|
1695
1777
|
resizeObserver.observe(video);
|
|
1696
|
-
|
|
1778
|
+
|
|
1697
1779
|
// Also update when the frame image loads
|
|
1698
1780
|
frame.addEventListener('load', updateVideoPosition);
|
|
1699
|
-
|
|
1781
|
+
|
|
1700
1782
|
// Update when video metadata loads (to get correct intrinsic dimensions)
|
|
1701
1783
|
video.addEventListener('loadedmetadata', updateVideoPosition);
|
|
1702
1784
|
|
|
@@ -1859,22 +1941,22 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1859
1941
|
}, 30000);
|
|
1860
1942
|
});
|
|
1861
1943
|
},
|
|
1944
|
+
reconnect: () => start(),
|
|
1862
1945
|
}));
|
|
1863
1946
|
|
|
1864
1947
|
// Show indicators when Alt is held and we have a valid hover point (null when outside)
|
|
1865
1948
|
const showAltIndicators = isAltHeld && hoverPoint !== null;
|
|
1866
1949
|
const frameImageSrc =
|
|
1867
|
-
platform === 'android' && useAndroidTabletFrame
|
|
1868
|
-
|
|
1869
|
-
:
|
|
1950
|
+
platform === 'android' && useAndroidTabletFrame ?
|
|
1951
|
+
isLandscape ? pixelTabletFrameImageLandscape
|
|
1952
|
+
: pixelTabletFrameImage
|
|
1953
|
+
: isLandscape ? config.frame.imageLandscape
|
|
1954
|
+
: config.frame.image;
|
|
1870
1955
|
|
|
1871
1956
|
return (
|
|
1872
1957
|
<div
|
|
1873
1958
|
ref={containerRef}
|
|
1874
|
-
className={clsx(
|
|
1875
|
-
'rc-container',
|
|
1876
|
-
className,
|
|
1877
|
-
)}
|
|
1959
|
+
className={clsx('rc-container', className)}
|
|
1878
1960
|
style={{ touchAction: 'none' }} // Keep touchAction none for the container
|
|
1879
1961
|
// Attach unified handler to all interaction events on the container
|
|
1880
1962
|
// This helps capture mouseleave correctly even if the video element itself isn't hovered
|
|
@@ -1916,21 +1998,17 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1916
1998
|
)}
|
|
1917
1999
|
<video
|
|
1918
2000
|
ref={videoRef}
|
|
1919
|
-
className={clsx(
|
|
1920
|
-
'rc-video',
|
|
1921
|
-
!showFrame && 'rc-video-frameless',
|
|
1922
|
-
!videoLoaded && 'rc-video-loading',
|
|
1923
|
-
)}
|
|
2001
|
+
className={clsx('rc-video', !showFrame && 'rc-video-frameless', !videoLoaded && 'rc-video-loading')}
|
|
1924
2002
|
style={{
|
|
1925
2003
|
...videoStyle,
|
|
1926
|
-
...(config.loadingLogo
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2004
|
+
...(config.loadingLogo ?
|
|
2005
|
+
{
|
|
2006
|
+
backgroundImage: `url("${config.loadingLogo}")`,
|
|
2007
|
+
backgroundRepeat: 'no-repeat',
|
|
2008
|
+
backgroundPosition: 'center',
|
|
2009
|
+
backgroundSize: config.loadingLogoSize,
|
|
2010
|
+
}
|
|
2011
|
+
: {}),
|
|
1934
2012
|
}}
|
|
1935
2013
|
autoPlay
|
|
1936
2014
|
playsInline
|
|
@@ -1952,11 +2030,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
|
|
|
1952
2030
|
}}
|
|
1953
2031
|
/>
|
|
1954
2032
|
{retryExhausted && (
|
|
1955
|
-
<button
|
|
1956
|
-
type="button"
|
|
1957
|
-
className="rc-retry-button"
|
|
1958
|
-
onClick={handleManualRetry}
|
|
1959
|
-
>
|
|
2033
|
+
<button type="button" className="rc-retry-button" onClick={handleManualRetry}>
|
|
1960
2034
|
Retry
|
|
1961
2035
|
</button>
|
|
1962
2036
|
)}
|
|
@@ -1992,4 +2066,4 @@ const toScreenshotData = (message: any): ScreenshotData | null => {
|
|
|
1992
2066
|
}
|
|
1993
2067
|
|
|
1994
2068
|
return null;
|
|
1995
|
-
};
|
|
2069
|
+
};
|