@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.
@@ -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
- ({ className, url, token, sessionId: propSessionId, openUrl, showFrame = true }: RemoteControlProps, ref) => {
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 { inside: isInside, videoX, videoY, videoWidth, videoHeight } = geometry;
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
- if (isInside) {
345
- // For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
346
- const currentPointerCount = activePointers.current.size;
347
- action =
348
- currentPointerCount === 0
349
- ? AMOTION_EVENT.ACTION_DOWN
350
- : AMOTION_EVENT.ACTION_POINTER_DOWN;
351
- positionToSend = { x: videoX, y: videoY };
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
- if (isInside) {
366
- action = AMOTION_EVENT.ACTION_MOVE;
367
- positionToSend = { x: videoX, y: videoY };
368
- // Update the last known position for this active pointer
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
- debugLog('[rc-touch][mouse->touch] sending', {
400
- pointerId,
401
- eventType,
402
- action,
403
- actionName: motionActionToString(action),
404
- isInside,
405
- positionToSend,
406
- video: { width: videoWidth, height: videoHeight },
407
- altHeld: isAltHeldRef.current,
408
- activePointersAfter: Array.from(activePointers.current.entries()).map(([id, pos]) => ({
409
- id,
410
- x: pos.x,
411
- y: pos.y,
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
- debugLog('[rc-touch][mouse->touch] buffer', {
427
- pointerId,
428
- actionName: motionActionToString(action),
429
- byteLength: message.byteLength,
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(action, ANDROID_KEYS.KEYCODE_ALT_LEFT, 0, ANDROID_KEYS.META_NONE);
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
- // Returns null if outside the video content area or context is missing.
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 isInside =
538
- relativeX >= 0 && relativeX <= ctx.actualWidth &&
539
- relativeY >= 0 && relativeY <= ctx.actualHeight;
540
-
541
- if (!isInside) {
542
- return {
543
- inside: false,
544
- videoX: 0,
545
- videoY: 0,
546
- videoWidth: ctx.videoWidth,
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 isInside =
573
- relativeX >= 0 && relativeX <= ctx.actualWidth &&
574
- relativeY >= 0 && relativeY <= ctx.actualHeight;
575
-
576
- if (!isInside) {
577
- return null;
578
- }
579
-
580
- const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
581
- const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
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 + relativeX;
588
- const containerY = contentTop - ctx.containerRect.top + relativeY;
589
- const mirrorContainerX = contentLeft - ctx.containerRect.left + (ctx.actualWidth - relativeX);
590
- const mirrorContainerY = contentTop - ctx.containerRect.top + (ctx.actualHeight - relativeY);
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 = eventType === 'down' ? AMOTION_EVENT.ACTION_DOWN
660
- : eventType === 'move' ? AMOTION_EVENT.ACTION_MOVE
661
- : AMOTION_EVENT.ACTION_UP;
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 (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open' || !videoRef.current || !ctx) {
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
- if (g0.inside && g1.inside) {
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('down', g0.videoWidth, g0.videoHeight,
764
- g0.videoX, g0.videoY, g1.videoX, g1.videoY,
765
- t0.identifier, t1.identifier);
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
- if (g0.inside && g1.inside) {
770
- twoFingerStateRef.current.finger0 = { x: g0.videoX, y: g0.videoY };
771
- twoFingerStateRef.current.finger1 = { x: g1.videoX, y: g1.videoY };
772
- applyTwoFingerEvent('move', g0.videoWidth, g0.videoHeight,
773
- g0.videoX, g0.videoY, g1.videoX, g1.videoY,
774
- twoFingerStateRef.current.pointerId0,
775
- twoFingerStateRef.current.pointerId1);
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('up', state.videoSize.width, state.videoSize.height,
782
- state.finger0.x, state.finger0.y,
783
- state.finger1.x, state.finger1.y,
784
- state.pointerId0, state.pointerId1);
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 { inside, videoX, videoY, videoWidth, videoHeight } = geometry;
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
- if (inside) {
877
- // Start two-finger gesture
878
- twoFingerStateRef.current = {
879
- finger0: { x: videoX, y: videoY },
880
- finger1: { x: mirrorX, y: mirrorY },
881
- videoSize: { width: videoWidth, height: videoHeight },
882
- source: 'alt-mouse',
883
- pointerId0: ALT_POINTER_ID_PRIMARY,
884
- pointerId1: ALT_POINTER_ID_MIRROR,
885
- };
886
- videoRef.current?.focus();
887
- applyTwoFingerEvent('down', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
888
- ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
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' && inside) {
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('move', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
899
- ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
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 inside positions
941
+ // End gesture at last known positions
909
942
  const { finger0, finger1, videoSize } = state;
910
- applyTwoFingerEvent('up', videoSize.width, videoSize.height,
911
- finger0.x, finger0.y, finger1.x, finger1.y,
912
- ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
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
- updateStatus(`Connection failed after it was established: ${reason}`);
1135
- setRetryExhausted(true);
1136
- teardownConnection();
1137
- return;
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
- updateStatus('ICE state: ' + peerConnection.iceConnectionState);
1406
- if (peerConnection.iceConnectionState === 'failed') {
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 = `${landscape ? frameHeight * config.videoBorderRadiusMultiplier : frameWidth * config.videoBorderRadiusMultiplier}px`;
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
- ? (isLandscape ? pixelTabletFrameImageLandscape : pixelTabletFrameImage)
1869
- : (isLandscape ? config.frame.imageLandscape : config.frame.image);
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
- backgroundImage: `url("${config.loadingLogo}")`,
1929
- backgroundRepeat: 'no-repeat',
1930
- backgroundPosition: 'center',
1931
- backgroundSize: config.loadingLogoSize,
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
+ };