@limrun/ui 0.5.2 → 0.7.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.
@@ -119,6 +119,9 @@ type DeviceConfig = {
119
119
 
120
120
  const ANDROID_TABLET_VIDEO_WIDTH = 1920;
121
121
  const ANDROID_TABLET_VIDEO_HEIGHT = 1200;
122
+ const MAX_CONNECTION_ATTEMPTS = 3;
123
+ const CONNECTION_RETRY_DELAY_MS = 1000;
124
+ const CONNECTION_SUCCESS_TIMEOUT_MS = 15000;
122
125
 
123
126
  const isAndroidTabletVideo = (width: number, height: number): boolean =>
124
127
  (width === ANDROID_TABLET_VIDEO_WIDTH && height === ANDROID_TABLET_VIDEO_HEIGHT) ||
@@ -193,6 +196,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
193
196
  const videoRef = useRef<HTMLVideoElement>(null);
194
197
  const frameRef = useRef<HTMLImageElement>(null);
195
198
  const [videoLoaded, setVideoLoaded] = useState(false);
199
+ const [retryExhausted, setRetryExhausted] = useState(false);
196
200
  const [isLandscape, setIsLandscape] = useState(false);
197
201
  const [useAndroidTabletFrame, setUseAndroidTabletFrame] = useState(false);
198
202
  const [videoStyle, setVideoStyle] = useState<React.CSSProperties>({});
@@ -200,6 +204,13 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
200
204
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
201
205
  const dataChannelRef = useRef<RTCDataChannel | null>(null);
202
206
  const keepAliveIntervalRef = useRef<number | undefined>(undefined);
207
+ const retryTimeoutRef = useRef<number | undefined>(undefined);
208
+ const connectionSuccessTimeoutRef = useRef<number | undefined>(undefined);
209
+ const requestFrameIntervalRef = useRef<number | undefined>(undefined);
210
+ const connectionGenerationRef = useRef(0);
211
+ const connectionAttemptRef = useRef(0);
212
+ const controlChannelOpenedRef = useRef(false);
213
+ const firstFrameShownRef = useRef(false);
203
214
  const pendingScreenshotResolversRef = useRef<
204
215
  Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
205
216
  >(new Map());
@@ -308,7 +319,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
308
319
 
309
320
  // Minimal geometry for single-finger touch events (no mirror/container coords needed).
310
321
  type PointerGeometry = {
311
- inside: boolean;
312
322
  videoX: number;
313
323
  videoY: number;
314
324
  videoWidth: number;
@@ -321,7 +331,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
321
331
  geometry: PointerGeometry | null,
322
332
  ) => {
323
333
  if (!geometry) return;
324
- const { inside: isInside, videoX, videoY, videoWidth, videoHeight } = geometry;
334
+ const { videoX, videoY, videoWidth, videoHeight } = geometry;
325
335
 
326
336
  let action: number | null = null;
327
337
  let positionToSend: { x: number; y: number } | null = null;
@@ -330,35 +340,26 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
330
340
 
331
341
  switch (eventType) {
332
342
  case 'down':
333
- if (isInside) {
334
- // For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
335
- const currentPointerCount = activePointers.current.size;
336
- action =
337
- currentPointerCount === 0
338
- ? AMOTION_EVENT.ACTION_DOWN
339
- : AMOTION_EVENT.ACTION_POINTER_DOWN;
340
- positionToSend = { x: videoX, y: videoY };
341
- activePointers.current.set(pointerId, positionToSend);
342
- if (pointerId === -1) {
343
- // Focus on mouse down
344
- videoRef.current?.focus();
345
- }
346
- } else {
347
- // If the initial down event is outside, ignore it for this pointer
348
- activePointers.current.delete(pointerId);
343
+ // For multi-touch: use ACTION_DOWN for first pointer, ACTION_POINTER_DOWN for additional pointers
344
+ const currentPointerCount = activePointers.current.size;
345
+ action =
346
+ currentPointerCount === 0
347
+ ? AMOTION_EVENT.ACTION_DOWN
348
+ : AMOTION_EVENT.ACTION_POINTER_DOWN;
349
+ positionToSend = { x: videoX, y: videoY };
350
+ activePointers.current.set(pointerId, positionToSend);
351
+ if (pointerId === -1) {
352
+ // Focus on mouse down
353
+ videoRef.current?.focus();
349
354
  }
350
355
  break;
351
356
 
352
357
  case 'move':
353
358
  if (activePointers.current.has(pointerId)) {
354
- if (isInside) {
355
- action = AMOTION_EVENT.ACTION_MOVE;
356
- positionToSend = { x: videoX, y: videoY };
357
- // Update the last known position for this active pointer
358
- activePointers.current.set(pointerId, positionToSend);
359
- } else {
360
- // Moved outside while active - do nothing, UP/CANCEL will use last known pos
361
- }
359
+ action = AMOTION_EVENT.ACTION_MOVE;
360
+ positionToSend = { x: videoX, y: videoY };
361
+ // Update the last known position for this active pointer
362
+ activePointers.current.set(pointerId, positionToSend);
362
363
  }
363
364
  break;
364
365
 
@@ -390,7 +391,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
390
391
  eventType,
391
392
  action,
392
393
  actionName: motionActionToString(action),
393
- isInside,
394
394
  positionToSend,
395
395
  video: { width: videoWidth, height: videoHeight },
396
396
  altHeld: isAltHeldRef.current,
@@ -420,7 +420,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
420
420
  sendBinaryControlMessage(message);
421
421
  }
422
422
  } else if (eventType === 'up' || eventType === 'cancel') {
423
- // Clean up map just in case if 'down' was outside and 'up'/'cancel' is triggered
424
423
  activePointers.current.delete(pointerId);
425
424
  }
426
425
  };
@@ -513,8 +512,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
513
512
  };
514
513
  };
515
514
 
516
- // Map a client point to video coordinates using a pre-computed context.
517
- // Returns null if outside the video content area or context is missing.
515
+ // Map a client point to video coordinates using a pre-computed context,
516
+ // clamping points outside the rendered video to the nearest point on the video.
518
517
  const mapClientPointToVideo = (
519
518
  ctx: VideoMappingContext,
520
519
  clientX: number,
@@ -523,25 +522,12 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
523
522
  const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
524
523
  const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
525
524
 
526
- const isInside =
527
- relativeX >= 0 && relativeX <= ctx.actualWidth &&
528
- relativeY >= 0 && relativeY <= ctx.actualHeight;
529
-
530
- if (!isInside) {
531
- return {
532
- inside: false,
533
- videoX: 0,
534
- videoY: 0,
535
- videoWidth: ctx.videoWidth,
536
- videoHeight: ctx.videoHeight,
537
- };
538
- }
539
-
540
- const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
541
- const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
525
+ const clampedRelativeX = Math.max(0, Math.min(ctx.actualWidth, relativeX));
526
+ const clampedRelativeY = Math.max(0, Math.min(ctx.actualHeight, relativeY));
527
+ const videoX = Math.max(0, Math.min(ctx.videoWidth, (clampedRelativeX / ctx.actualWidth) * ctx.videoWidth));
528
+ const videoY = Math.max(0, Math.min(ctx.videoHeight, (clampedRelativeY / ctx.actualHeight) * ctx.videoHeight));
542
529
 
543
530
  return {
544
- inside: true,
545
531
  videoX,
546
532
  videoY,
547
533
  videoWidth: ctx.videoWidth,
@@ -549,7 +535,8 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
549
535
  };
550
536
  };
551
537
 
552
- // Compute full hover point with mirror/container coordinates (for Alt indicator rendering).
538
+ // Compute full hover point with mirror/container coordinates (for Alt indicator rendering),
539
+ // clamping points outside the rendered video to the nearest point on the video.
553
540
  const computeFullHoverPoint = (
554
541
  ctx: VideoMappingContext,
555
542
  clientX: number,
@@ -558,25 +545,19 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
558
545
  const relativeX = clientX - ctx.videoRect.left - ctx.offsetX;
559
546
  const relativeY = clientY - ctx.videoRect.top - ctx.offsetY;
560
547
 
561
- const isInside =
562
- relativeX >= 0 && relativeX <= ctx.actualWidth &&
563
- relativeY >= 0 && relativeY <= ctx.actualHeight;
564
-
565
- if (!isInside) {
566
- return null;
567
- }
568
-
569
- const videoX = Math.max(0, Math.min(ctx.videoWidth, (relativeX / ctx.actualWidth) * ctx.videoWidth));
570
- const videoY = Math.max(0, Math.min(ctx.videoHeight, (relativeY / ctx.actualHeight) * ctx.videoHeight));
548
+ const clampedRelativeX = Math.max(0, Math.min(ctx.actualWidth, relativeX));
549
+ const clampedRelativeY = Math.max(0, Math.min(ctx.actualHeight, relativeY));
550
+ const videoX = Math.max(0, Math.min(ctx.videoWidth, (clampedRelativeX / ctx.actualWidth) * ctx.videoWidth));
551
+ const videoY = Math.max(0, Math.min(ctx.videoHeight, (clampedRelativeY / ctx.actualHeight) * ctx.videoHeight));
571
552
  const mirrorVideoX = ctx.videoWidth - videoX;
572
553
  const mirrorVideoY = ctx.videoHeight - videoY;
573
554
 
574
555
  const contentLeft = ctx.videoRect.left + ctx.offsetX;
575
556
  const contentTop = ctx.videoRect.top + ctx.offsetY;
576
- const containerX = contentLeft - ctx.containerRect.left + relativeX;
577
- const containerY = contentTop - ctx.containerRect.top + relativeY;
578
- const mirrorContainerX = contentLeft - ctx.containerRect.left + (ctx.actualWidth - relativeX);
579
- const mirrorContainerY = contentTop - ctx.containerRect.top + (ctx.actualHeight - relativeY);
557
+ const containerX = contentLeft - ctx.containerRect.left + clampedRelativeX;
558
+ const containerY = contentTop - ctx.containerRect.top + clampedRelativeY;
559
+ const mirrorContainerX = contentLeft - ctx.containerRect.left + (ctx.actualWidth - clampedRelativeX);
560
+ const mirrorContainerY = contentTop - ctx.containerRect.top + (ctx.actualHeight - clampedRelativeY);
580
561
 
581
562
  return {
582
563
  containerX,
@@ -740,29 +721,25 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
740
721
 
741
722
  if (!twoFingerStateRef.current) {
742
723
  // Starting a new two-finger gesture
743
- if (g0.inside && g1.inside) {
744
- twoFingerStateRef.current = {
745
- finger0: { x: g0.videoX, y: g0.videoY },
746
- finger1: { x: g1.videoX, y: g1.videoY },
747
- videoSize: { width: g0.videoWidth, height: g0.videoHeight },
748
- source: 'real-touch',
749
- pointerId0: t0.identifier,
750
- pointerId1: t1.identifier,
751
- };
752
- applyTwoFingerEvent('down', g0.videoWidth, g0.videoHeight,
753
- g0.videoX, g0.videoY, g1.videoX, g1.videoY,
754
- t0.identifier, t1.identifier);
755
- }
724
+ twoFingerStateRef.current = {
725
+ finger0: { x: g0.videoX, y: g0.videoY },
726
+ finger1: { x: g1.videoX, y: g1.videoY },
727
+ videoSize: { width: g0.videoWidth, height: g0.videoHeight },
728
+ source: 'real-touch',
729
+ pointerId0: t0.identifier,
730
+ pointerId1: t1.identifier,
731
+ };
732
+ applyTwoFingerEvent('down', g0.videoWidth, g0.videoHeight,
733
+ g0.videoX, g0.videoY, g1.videoX, g1.videoY,
734
+ t0.identifier, t1.identifier);
756
735
  } else if (twoFingerStateRef.current.source === 'real-touch') {
757
736
  // Continuing two-finger gesture (move)
758
- if (g0.inside && g1.inside) {
759
- twoFingerStateRef.current.finger0 = { x: g0.videoX, y: g0.videoY };
760
- twoFingerStateRef.current.finger1 = { x: g1.videoX, y: g1.videoY };
761
- applyTwoFingerEvent('move', g0.videoWidth, g0.videoHeight,
762
- g0.videoX, g0.videoY, g1.videoX, g1.videoY,
763
- twoFingerStateRef.current.pointerId0,
764
- twoFingerStateRef.current.pointerId1);
765
- }
737
+ twoFingerStateRef.current.finger0 = { x: g0.videoX, y: g0.videoY };
738
+ twoFingerStateRef.current.finger1 = { x: g1.videoX, y: g1.videoY };
739
+ applyTwoFingerEvent('move', g0.videoWidth, g0.videoHeight,
740
+ g0.videoX, g0.videoY, g1.videoX, g1.videoY,
741
+ twoFingerStateRef.current.pointerId0,
742
+ twoFingerStateRef.current.pointerId1);
766
743
  }
767
744
  } else if (allTouches.length < 2 && twoFingerStateRef.current?.source === 'real-touch') {
768
745
  // Finger lifted - end two-finger gesture using last known state
@@ -830,7 +807,6 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
830
807
  altHeldRef: isAltHeldRef.current,
831
808
  inTwoFingerMode,
832
809
  geometry: {
833
- inside: geometry.inside,
834
810
  videoX: geometry.videoX,
835
811
  videoY: geometry.videoY,
836
812
  videoWidth: geometry.videoWidth,
@@ -857,44 +833,41 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
857
833
  eventType: 'down' | 'move' | 'up' | 'cancel',
858
834
  geometry: PointerGeometry,
859
835
  ) => {
860
- const { inside, videoX, videoY, videoWidth, videoHeight } = geometry;
836
+ const { videoX, videoY, videoWidth, videoHeight } = geometry;
861
837
  const mirrorX = videoWidth - videoX;
862
838
  const mirrorY = videoHeight - videoY;
863
839
 
864
840
  if (eventType === 'down') {
865
- if (inside) {
866
- // Start two-finger gesture
867
- twoFingerStateRef.current = {
868
- finger0: { x: videoX, y: videoY },
869
- finger1: { x: mirrorX, y: mirrorY },
870
- videoSize: { width: videoWidth, height: videoHeight },
871
- source: 'alt-mouse',
872
- pointerId0: ALT_POINTER_ID_PRIMARY,
873
- pointerId1: ALT_POINTER_ID_MIRROR,
874
- };
875
- videoRef.current?.focus();
876
- applyTwoFingerEvent('down', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
877
- ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
878
- }
841
+ // Start two-finger gesture
842
+ twoFingerStateRef.current = {
843
+ finger0: { x: videoX, y: videoY },
844
+ finger1: { x: mirrorX, y: mirrorY },
845
+ videoSize: { width: videoWidth, height: videoHeight },
846
+ source: 'alt-mouse',
847
+ pointerId0: ALT_POINTER_ID_PRIMARY,
848
+ pointerId1: ALT_POINTER_ID_MIRROR,
849
+ };
850
+ videoRef.current?.focus();
851
+ applyTwoFingerEvent('down', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
852
+ ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
879
853
  return;
880
854
  }
881
855
 
882
856
  if (eventType === 'move') {
883
- if (twoFingerStateRef.current?.source === 'alt-mouse' && inside) {
857
+ if (twoFingerStateRef.current?.source === 'alt-mouse') {
884
858
  // Update positions
885
859
  twoFingerStateRef.current.finger0 = { x: videoX, y: videoY };
886
860
  twoFingerStateRef.current.finger1 = { x: mirrorX, y: mirrorY };
887
861
  applyTwoFingerEvent('move', videoWidth, videoHeight, videoX, videoY, mirrorX, mirrorY,
888
862
  ALT_POINTER_ID_PRIMARY, ALT_POINTER_ID_MIRROR);
889
863
  }
890
- // If outside, we just don't send a move - UP will use last known position
891
864
  return;
892
865
  }
893
866
 
894
867
  if (eventType === 'up' || eventType === 'cancel') {
895
868
  const state = twoFingerStateRef.current;
896
869
  if (state?.source === 'alt-mouse') {
897
- // End gesture at last known inside positions
870
+ // End gesture at last known positions
898
871
  const { finger0, finger1, videoSize } = state;
899
872
  applyTwoFingerEvent('up', videoSize.width, videoSize.height,
900
873
  finger0.x, finger0.y, finger1.x, finger1.y,
@@ -1045,6 +1018,67 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1045
1018
  }
1046
1019
  };
1047
1020
 
1021
+ const clearScheduledRetry = () => {
1022
+ if (retryTimeoutRef.current) {
1023
+ window.clearTimeout(retryTimeoutRef.current);
1024
+ retryTimeoutRef.current = undefined;
1025
+ }
1026
+ };
1027
+
1028
+ const clearConnectionSuccessTimeout = () => {
1029
+ if (connectionSuccessTimeoutRef.current) {
1030
+ window.clearTimeout(connectionSuccessTimeoutRef.current);
1031
+ connectionSuccessTimeoutRef.current = undefined;
1032
+ }
1033
+ };
1034
+
1035
+ const stopRequestFrameLoop = () => {
1036
+ if (requestFrameIntervalRef.current) {
1037
+ window.clearInterval(requestFrameIntervalRef.current);
1038
+ requestFrameIntervalRef.current = undefined;
1039
+ }
1040
+ };
1041
+
1042
+ const markFirstFrameShown = () => {
1043
+ if (firstFrameShownRef.current) {
1044
+ return;
1045
+ }
1046
+ firstFrameShownRef.current = true;
1047
+ stopRequestFrameLoop();
1048
+ setVideoLoaded(true);
1049
+ };
1050
+
1051
+ const teardownConnection = () => {
1052
+ clearConnectionSuccessTimeout();
1053
+ stopRequestFrameLoop();
1054
+ if (wsRef.current) {
1055
+ wsRef.current.onopen = null;
1056
+ wsRef.current.onmessage = null;
1057
+ wsRef.current.onerror = null;
1058
+ wsRef.current.onclose = null;
1059
+ wsRef.current.close();
1060
+ wsRef.current = null;
1061
+ }
1062
+ if (peerConnectionRef.current) {
1063
+ peerConnectionRef.current.onconnectionstatechange = null;
1064
+ peerConnectionRef.current.oniceconnectionstatechange = null;
1065
+ peerConnectionRef.current.ontrack = null;
1066
+ peerConnectionRef.current.onicecandidate = null;
1067
+ peerConnectionRef.current.close();
1068
+ peerConnectionRef.current = null;
1069
+ }
1070
+ if (videoRef.current) {
1071
+ videoRef.current.srcObject = null;
1072
+ }
1073
+ if (dataChannelRef.current) {
1074
+ dataChannelRef.current.onopen = null;
1075
+ dataChannelRef.current.onclose = null;
1076
+ dataChannelRef.current.onerror = null;
1077
+ dataChannelRef.current.close();
1078
+ dataChannelRef.current = null;
1079
+ }
1080
+ };
1081
+
1048
1082
  const handleVisibilityChange = () => {
1049
1083
  if (document.hidden) {
1050
1084
  stopKeepAlive();
@@ -1053,46 +1087,143 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1053
1087
  }
1054
1088
  };
1055
1089
 
1056
- const start = async () => {
1090
+ const scheduleRetry = (reason: string, generation: number) => {
1091
+ if (generation !== connectionGenerationRef.current) {
1092
+ return;
1093
+ }
1094
+
1095
+ if (controlChannelOpenedRef.current) {
1096
+ updateStatus(`Connection failed after it was established: ${reason}`);
1097
+ setRetryExhausted(true);
1098
+ teardownConnection();
1099
+ return;
1100
+ }
1101
+
1102
+ clearScheduledRetry();
1103
+
1104
+ const nextAttempt = connectionAttemptRef.current + 1;
1105
+ if (nextAttempt >= MAX_CONNECTION_ATTEMPTS) {
1106
+ updateStatus(`Connection failed after ${MAX_CONNECTION_ATTEMPTS} attempts: ${reason}`);
1107
+ setRetryExhausted(true);
1108
+ teardownConnection();
1109
+ return;
1110
+ }
1111
+
1112
+ updateStatus(`Retrying connection (${nextAttempt + 1}/${MAX_CONNECTION_ATTEMPTS})`);
1113
+ teardownConnection();
1114
+ retryTimeoutRef.current = window.setTimeout(() => {
1115
+ retryTimeoutRef.current = undefined;
1116
+ if (generation !== connectionGenerationRef.current) {
1117
+ return;
1118
+ }
1119
+ void startAttempt(nextAttempt);
1120
+ }, CONNECTION_RETRY_DELAY_MS);
1121
+ };
1122
+
1123
+ const startAttempt = async (attemptNumber = 0) => {
1124
+ const generation = connectionGenerationRef.current + 1;
1125
+ connectionGenerationRef.current = generation;
1126
+ connectionAttemptRef.current = attemptNumber;
1127
+ controlChannelOpenedRef.current = false;
1128
+ setRetryExhausted(false);
1129
+ clearScheduledRetry();
1130
+ clearConnectionSuccessTimeout();
1131
+ stopRequestFrameLoop();
1132
+ firstFrameShownRef.current = false;
1133
+ setVideoLoaded(false);
1134
+ teardownConnection();
1135
+
1136
+ const isCurrentAttempt = () =>
1137
+ generation === connectionGenerationRef.current;
1138
+
1139
+ connectionSuccessTimeoutRef.current = window.setTimeout(() => {
1140
+ connectionSuccessTimeoutRef.current = undefined;
1141
+ if (!isCurrentAttempt() || controlChannelOpenedRef.current) {
1142
+ return;
1143
+ }
1144
+ scheduleRetry('Connection did not succeed within 15 seconds', generation);
1145
+ }, CONNECTION_SUCCESS_TIMEOUT_MS);
1146
+
1057
1147
  try {
1058
- wsRef.current = new WebSocket(`${url}?token=${token}`);
1148
+ const ws = new WebSocket(`${url}?token=${token}`);
1149
+ wsRef.current = ws;
1150
+
1151
+ // Wait for WebSocket to connect
1152
+ await new Promise<void>((resolve, reject) => {
1153
+ let settled = false;
1154
+ const timeoutId = window.setTimeout(() => reject(new Error('WebSocket connection timeout')), 30000);
1155
+ const settle = (callback: () => void) => {
1156
+ if (settled) {
1157
+ return;
1158
+ }
1159
+ settled = true;
1160
+ window.clearTimeout(timeoutId);
1161
+ callback();
1162
+ };
1163
+
1164
+ ws.onopen = () => {
1165
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1166
+ return;
1167
+ }
1168
+ settle(resolve);
1169
+ };
1059
1170
 
1060
- wsRef.current.onerror = (error) => {
1171
+ ws.onerror = (error) => {
1172
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1173
+ return;
1174
+ }
1175
+ updateStatus('WebSocket error: ' + error);
1176
+ settle(() => reject(new Error('WebSocket connection failed')));
1177
+ };
1178
+
1179
+ ws.onclose = () => {
1180
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1181
+ return;
1182
+ }
1183
+ updateStatus('WebSocket closed');
1184
+ settle(() => reject(new Error('WebSocket closed before connection was established')));
1185
+ };
1186
+ });
1187
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1188
+ return;
1189
+ }
1190
+
1191
+ ws.onerror = (error) => {
1192
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1193
+ return;
1194
+ }
1061
1195
  updateStatus('WebSocket error: ' + error);
1062
1196
  };
1063
1197
 
1064
- wsRef.current.onclose = () => {
1198
+ ws.onclose = () => {
1199
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1200
+ return;
1201
+ }
1065
1202
  updateStatus('WebSocket closed');
1066
1203
  };
1067
1204
 
1068
- // Wait for WebSocket to connect
1069
- await new Promise((resolve, reject) => {
1070
- if (wsRef.current) {
1071
- wsRef.current.onopen = resolve;
1072
- setTimeout(() => reject(new Error('WebSocket connection timeout')), 30000);
1073
- }
1074
- });
1075
-
1076
1205
  // Request RTCConfiguration
1077
1206
  const rtcConfigPromise = new Promise<RTCConfiguration>((resolve, reject) => {
1078
- const timeoutId = setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
1207
+ const timeoutId = window.setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
1079
1208
 
1080
1209
  const messageHandler = (event: MessageEvent) => {
1081
1210
  try {
1082
1211
  const message = JSON.parse(event.data);
1083
1212
  if (message.type === 'rtcConfiguration') {
1084
- clearTimeout(timeoutId);
1085
- wsRef.current?.removeEventListener('message', messageHandler);
1213
+ window.clearTimeout(timeoutId);
1214
+ ws.removeEventListener('message', messageHandler);
1086
1215
  resolve(message.rtcConfiguration);
1087
1216
  }
1088
1217
  } catch (e) {
1218
+ window.clearTimeout(timeoutId);
1219
+ ws.removeEventListener('message', messageHandler);
1089
1220
  console.error('Error handling RTC configuration:', e);
1090
1221
  reject(e);
1091
1222
  }
1092
1223
  };
1093
1224
 
1094
- wsRef.current?.addEventListener('message', messageHandler);
1095
- wsRef.current?.send(
1225
+ ws.addEventListener('message', messageHandler);
1226
+ ws.send(
1096
1227
  JSON.stringify({
1097
1228
  type: 'requestRtcConfiguration',
1098
1229
  sessionId: sessionId,
@@ -1101,9 +1232,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1101
1232
  });
1102
1233
 
1103
1234
  const rtcConfig = await rtcConfigPromise;
1104
- peerConnectionRef.current = new RTCPeerConnection(rtcConfig);
1105
- peerConnectionRef.current.addTransceiver('audio', { direction: 'recvonly' });
1106
- const videoTransceiver = peerConnectionRef.current.addTransceiver('video', { direction: 'recvonly' });
1235
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1236
+ return;
1237
+ }
1238
+
1239
+ const peerConnection = new RTCPeerConnection(rtcConfig);
1240
+ peerConnectionRef.current = peerConnection;
1241
+ peerConnection.addTransceiver('audio', { direction: 'recvonly' });
1242
+ const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
1107
1243
 
1108
1244
  // As hardware encoder, we use H265 for iOS and VP9 for Android.
1109
1245
  // We make sure these two are the first ones in the list.
@@ -1130,70 +1266,115 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1130
1266
  }
1131
1267
  }
1132
1268
 
1133
- dataChannelRef.current = peerConnectionRef.current.createDataChannel('control', {
1269
+ const dataChannel = peerConnection.createDataChannel('control', {
1134
1270
  ordered: true,
1135
1271
  negotiated: true,
1136
1272
  id: 1,
1137
1273
  });
1274
+ dataChannelRef.current = dataChannel;
1138
1275
 
1139
- dataChannelRef.current.onopen = () => {
1276
+ dataChannel.onopen = () => {
1277
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel || wsRef.current !== ws) {
1278
+ return;
1279
+ }
1280
+ controlChannelOpenedRef.current = true;
1281
+ clearConnectionSuccessTimeout();
1140
1282
  updateStatus('Control channel opened');
1141
- // Request first frame once we're ready to receive video
1142
- if (wsRef.current) {
1143
- for (let i = 0; i < 12; i++) {
1144
- setTimeout(() => {
1145
- if (wsRef.current) {
1146
- wsRef.current.send(JSON.stringify({ type: 'requestFrame', sessionId: sessionId }));
1147
- }
1148
- }, i * 125); // 125ms = quarter second
1283
+ const sendRequestFrame = () => {
1284
+ if (
1285
+ !isCurrentAttempt() ||
1286
+ firstFrameShownRef.current ||
1287
+ dataChannelRef.current !== dataChannel ||
1288
+ wsRef.current !== ws ||
1289
+ ws.readyState !== WebSocket.OPEN
1290
+ ) {
1291
+ return;
1149
1292
  }
1293
+ ws.send(JSON.stringify({ type: 'requestFrame', sessionId: sessionId }));
1294
+ };
1150
1295
 
1151
- // Send openUrl message if the prop is provided
1152
- if (openUrl) {
1153
- try {
1154
- const decodedUrl = decodeURIComponent(openUrl);
1155
- updateStatus('Opening URL');
1156
- wsRef.current.send(
1157
- JSON.stringify({
1158
- type: 'openUrl',
1159
- url: decodedUrl,
1160
- sessionId: sessionId,
1161
- }),
1162
- );
1163
- } catch (error) {
1164
- console.error({ error }, 'Error decoding URL, falling back to the original URL');
1165
- wsRef.current.send(
1166
- JSON.stringify({
1167
- type: 'openUrl',
1168
- url: openUrl,
1169
- sessionId: sessionId,
1170
- }),
1171
- );
1172
- }
1296
+ sendRequestFrame();
1297
+ stopRequestFrameLoop();
1298
+ requestFrameIntervalRef.current = window.setInterval(() => {
1299
+ if (
1300
+ !isCurrentAttempt() ||
1301
+ firstFrameShownRef.current ||
1302
+ dataChannelRef.current !== dataChannel ||
1303
+ wsRef.current !== ws ||
1304
+ ws.readyState !== WebSocket.OPEN
1305
+ ) {
1306
+ stopRequestFrameLoop();
1307
+ return;
1308
+ }
1309
+ sendRequestFrame();
1310
+ }, 250);
1311
+
1312
+ // Send openUrl message if the prop is provided
1313
+ if (openUrl) {
1314
+ try {
1315
+ const decodedUrl = decodeURIComponent(openUrl);
1316
+ updateStatus('Opening URL');
1317
+ ws.send(
1318
+ JSON.stringify({
1319
+ type: 'openUrl',
1320
+ url: decodedUrl,
1321
+ sessionId: sessionId,
1322
+ }),
1323
+ );
1324
+ } catch (error) {
1325
+ console.error({ error }, 'Error decoding URL, falling back to the original URL');
1326
+ ws.send(
1327
+ JSON.stringify({
1328
+ type: 'openUrl',
1329
+ url: openUrl,
1330
+ sessionId: sessionId,
1331
+ }),
1332
+ );
1173
1333
  }
1174
1334
  }
1175
1335
  };
1176
1336
 
1177
- dataChannelRef.current.onclose = () => {
1337
+ dataChannel.onclose = () => {
1338
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
1339
+ return;
1340
+ }
1178
1341
  updateStatus('Control channel closed');
1179
1342
  };
1180
1343
 
1181
- dataChannelRef.current.onerror = (error) => {
1344
+ dataChannel.onerror = (error) => {
1345
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
1346
+ return;
1347
+ }
1182
1348
  console.error('Control channel error:', error);
1183
1349
  updateStatus('Control channel error: ' + error);
1184
1350
  };
1185
1351
 
1186
1352
  // Set up connection state monitoring
1187
- peerConnectionRef.current.onconnectionstatechange = () => {
1188
- updateStatus('Connection state: ' + peerConnectionRef.current?.connectionState);
1353
+ peerConnection.onconnectionstatechange = () => {
1354
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1355
+ return;
1356
+ }
1357
+ updateStatus('Connection state: ' + peerConnection.connectionState);
1358
+ if (peerConnection.connectionState === 'failed') {
1359
+ scheduleRetry('WebRTC connection entered failed state', generation);
1360
+ }
1189
1361
  };
1190
1362
 
1191
- peerConnectionRef.current.oniceconnectionstatechange = () => {
1192
- updateStatus('ICE state: ' + peerConnectionRef.current?.iceConnectionState);
1363
+ peerConnection.oniceconnectionstatechange = () => {
1364
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1365
+ return;
1366
+ }
1367
+ updateStatus('ICE state: ' + peerConnection.iceConnectionState);
1368
+ if (peerConnection.iceConnectionState === 'failed') {
1369
+ scheduleRetry('ICE connection entered failed state', generation);
1370
+ }
1193
1371
  };
1194
1372
 
1195
1373
  // Set up video handling
1196
- peerConnectionRef.current.ontrack = (event) => {
1374
+ peerConnection.ontrack = (event) => {
1375
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1376
+ return;
1377
+ }
1197
1378
  updateStatus('Received remote track: ' + event.track.kind);
1198
1379
  if (event.track.kind === 'video' && videoRef.current) {
1199
1380
  debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
@@ -1202,8 +1383,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1202
1383
  };
1203
1384
 
1204
1385
  // Handle ICE candidates
1205
- peerConnectionRef.current.onicecandidate = (event) => {
1206
- if (event.candidate && wsRef.current) {
1386
+ peerConnection.onicecandidate = (event) => {
1387
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection || wsRef.current !== ws) {
1388
+ return;
1389
+ }
1390
+ if (event.candidate && ws.readyState === WebSocket.OPEN) {
1207
1391
  const message = {
1208
1392
  type: 'candidate',
1209
1393
  candidate: event.candidate.candidate,
@@ -1211,7 +1395,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1211
1395
  sdpMLineIndex: event.candidate.sdpMLineIndex,
1212
1396
  sessionId: sessionId,
1213
1397
  };
1214
- wsRef.current.send(JSON.stringify(message));
1398
+ ws.send(JSON.stringify(message));
1215
1399
  updateStatus('Sent ICE candidate');
1216
1400
  } else {
1217
1401
  updateStatus('ICE candidate gathering completed');
@@ -1219,7 +1403,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1219
1403
  };
1220
1404
 
1221
1405
  // Handle incoming messages
1222
- wsRef.current.onmessage = async (event) => {
1406
+ ws.onmessage = async (event) => {
1407
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1408
+ return;
1409
+ }
1223
1410
  let message;
1224
1411
  try {
1225
1412
  message = JSON.parse(event.data);
@@ -1230,47 +1417,73 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1230
1417
  updateStatus('Received: ' + message.type);
1231
1418
  switch (message.type) {
1232
1419
  case 'answer':
1233
- if (!peerConnectionRef.current) {
1420
+ if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
1234
1421
  updateStatus('No peer connection, skipping answer');
1235
1422
  break;
1236
1423
  }
1237
- await peerConnectionRef.current.setRemoteDescription(
1424
+ await peerConnection.setRemoteDescription(
1238
1425
  new RTCSessionDescription({
1239
1426
  type: 'answer',
1240
1427
  sdp: message.sdp,
1241
1428
  }),
1242
1429
  );
1430
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1431
+ return;
1432
+ }
1243
1433
  updateStatus('Set remote description');
1244
1434
  break;
1245
1435
  case 'candidate':
1246
- if (!peerConnectionRef.current) {
1436
+ if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
1247
1437
  updateStatus('No peer connection, skipping candidate');
1248
1438
  break;
1249
1439
  }
1250
- await peerConnectionRef.current.addIceCandidate(
1440
+ await peerConnection.addIceCandidate(
1251
1441
  new RTCIceCandidate({
1252
1442
  candidate: message.candidate,
1253
1443
  sdpMid: message.sdpMid,
1254
1444
  sdpMLineIndex: message.sdpMLineIndex,
1255
1445
  }),
1256
1446
  );
1447
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1448
+ return;
1449
+ }
1257
1450
  updateStatus('Added ICE candidate');
1258
1451
  break;
1259
1452
  case 'screenshot':
1260
- if (typeof message.id !== 'string' || typeof message.dataUri !== 'string') {
1453
+ case 'screenshotResult': {
1454
+ if (typeof message.id !== 'string') {
1261
1455
  debugWarn('Received invalid screenshot success message:', message);
1262
1456
  break;
1263
1457
  }
1458
+ const screenshotError = getScreenshotError(message);
1459
+ if (screenshotError) {
1460
+ const rejecter = pendingScreenshotRejectersRef.current.get(message.id);
1461
+ if (!rejecter) {
1462
+ debugWarn(`Received screenshot error for unknown or handled id: ${message.id}`);
1463
+ break;
1464
+ }
1465
+ debugWarn(`Received screenshot error for id ${message.id}: ${screenshotError}`);
1466
+ rejecter(new Error(screenshotError));
1467
+ pendingScreenshotResolversRef.current.delete(message.id);
1468
+ pendingScreenshotRejectersRef.current.delete(message.id);
1469
+ break;
1470
+ }
1471
+ const screenshotData = toScreenshotData(message);
1472
+ if (!screenshotData) {
1473
+ debugWarn('Received screenshot message without image data:', message);
1474
+ break;
1475
+ }
1264
1476
  const resolver = pendingScreenshotResolversRef.current.get(message.id);
1265
1477
  if (!resolver) {
1266
1478
  debugWarn(`Received screenshot data for unknown or handled id: ${message.id}`);
1267
1479
  break;
1268
1480
  }
1269
1481
  debugLog(`Received screenshot data for id ${message.id}`);
1270
- resolver({ dataUri: message.dataUri });
1482
+ resolver(screenshotData);
1271
1483
  pendingScreenshotResolversRef.current.delete(message.id);
1272
1484
  pendingScreenshotRejectersRef.current.delete(message.id);
1273
1485
  break;
1486
+ }
1274
1487
  case 'screenshotError':
1275
1488
  if (typeof message.id !== 'string' || typeof message.message !== 'string') {
1276
1489
  debugWarn('Received invalid screenshot error message:', message);
@@ -1320,15 +1533,21 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1320
1533
  };
1321
1534
 
1322
1535
  // Create and send offer
1323
- if (peerConnectionRef.current) {
1324
- const offer = await peerConnectionRef.current.createOffer({
1536
+ if (peerConnectionRef.current === peerConnection) {
1537
+ const offer = await peerConnection.createOffer({
1325
1538
  offerToReceiveVideo: true,
1326
1539
  offerToReceiveAudio: false,
1327
1540
  });
1328
- await peerConnectionRef.current.setLocalDescription(offer);
1541
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1542
+ return;
1543
+ }
1544
+ await peerConnection.setLocalDescription(offer);
1545
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1546
+ return;
1547
+ }
1329
1548
 
1330
- if (wsRef.current) {
1331
- wsRef.current.send(
1549
+ if (isCurrentAttempt() && wsRef.current === ws && ws.readyState === WebSocket.OPEN) {
1550
+ ws.send(
1332
1551
  JSON.stringify({
1333
1552
  type: 'offer',
1334
1553
  sdp: offer.sdp,
@@ -1339,29 +1558,33 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1339
1558
  updateStatus('Sent offer');
1340
1559
  }
1341
1560
  } catch (e) {
1342
- updateStatus('Error: ' + e);
1561
+ if (!isCurrentAttempt()) {
1562
+ return;
1563
+ }
1564
+ const reason = e instanceof Error ? e.message : String(e);
1565
+ updateStatus('Error: ' + reason);
1566
+ scheduleRetry(reason, generation);
1343
1567
  }
1344
1568
  };
1345
1569
 
1570
+ const start = () => {
1571
+ void startAttempt(0);
1572
+ };
1573
+
1346
1574
  const stop = () => {
1347
- if (wsRef.current) {
1348
- wsRef.current.close();
1349
- wsRef.current = null;
1350
- }
1351
- if (peerConnectionRef.current) {
1352
- peerConnectionRef.current.close();
1353
- peerConnectionRef.current = null;
1354
- }
1355
- if (videoRef.current) {
1356
- videoRef.current.srcObject = null;
1357
- }
1358
- if (dataChannelRef.current) {
1359
- dataChannelRef.current.close();
1360
- dataChannelRef.current = null;
1361
- }
1575
+ connectionGenerationRef.current += 1;
1576
+ connectionAttemptRef.current = 0;
1577
+ controlChannelOpenedRef.current = false;
1578
+ clearScheduledRetry();
1579
+ teardownConnection();
1362
1580
  updateStatus('Stopped');
1363
1581
  };
1364
1582
 
1583
+ const handleManualRetry = (event: React.MouseEvent<HTMLButtonElement>) => {
1584
+ event.stopPropagation();
1585
+ start();
1586
+ };
1587
+
1365
1588
  useEffect(() => {
1366
1589
  // Reset video loaded state when connection params change
1367
1590
  setVideoLoaded(false);
@@ -1678,7 +1901,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1678
1901
  onKeyDown={handleKeyboard}
1679
1902
  onKeyUp={handleKeyboard}
1680
1903
  onClick={handleVideoClick}
1681
- onLoadedMetadata={() => setVideoLoaded(true)}
1904
+ onLoadedData={markFirstFrameShown}
1682
1905
  onFocus={() => {
1683
1906
  if (videoRef.current) {
1684
1907
  videoRef.current.style.outline = 'none';
@@ -1690,7 +1913,45 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1690
1913
  }
1691
1914
  }}
1692
1915
  />
1916
+ {retryExhausted && (
1917
+ <button
1918
+ type="button"
1919
+ className="rc-retry-button"
1920
+ onClick={handleManualRetry}
1921
+ >
1922
+ Retry
1923
+ </button>
1924
+ )}
1693
1925
  </div>
1694
1926
  );
1695
1927
  },
1696
1928
  );
1929
+
1930
+ const getScreenshotError = (message: any): string | null => {
1931
+ if (typeof message.message === 'string') {
1932
+ return message.message;
1933
+ }
1934
+
1935
+ if (typeof message.error === 'string') {
1936
+ return message.error;
1937
+ }
1938
+
1939
+ return null;
1940
+ };
1941
+
1942
+ const toScreenshotData = (message: any): ScreenshotData | null => {
1943
+ if (typeof message.dataUri === 'string') {
1944
+ return { dataUri: message.dataUri };
1945
+ }
1946
+
1947
+ if (typeof message.base64 === 'string') {
1948
+ if (message.base64.startsWith('data:')) {
1949
+ return { dataUri: message.base64 };
1950
+ }
1951
+
1952
+ const mimeType = message.base64.startsWith('/9j/') ? 'image/jpeg' : 'image/png';
1953
+ return { dataUri: `data:${mimeType};base64,${message.base64}` };
1954
+ }
1955
+
1956
+ return null;
1957
+ };