@limrun/ui 0.5.2 → 0.6.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());
@@ -1045,6 +1056,67 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1045
1056
  }
1046
1057
  };
1047
1058
 
1059
+ const clearScheduledRetry = () => {
1060
+ if (retryTimeoutRef.current) {
1061
+ window.clearTimeout(retryTimeoutRef.current);
1062
+ retryTimeoutRef.current = undefined;
1063
+ }
1064
+ };
1065
+
1066
+ const clearConnectionSuccessTimeout = () => {
1067
+ if (connectionSuccessTimeoutRef.current) {
1068
+ window.clearTimeout(connectionSuccessTimeoutRef.current);
1069
+ connectionSuccessTimeoutRef.current = undefined;
1070
+ }
1071
+ };
1072
+
1073
+ const stopRequestFrameLoop = () => {
1074
+ if (requestFrameIntervalRef.current) {
1075
+ window.clearInterval(requestFrameIntervalRef.current);
1076
+ requestFrameIntervalRef.current = undefined;
1077
+ }
1078
+ };
1079
+
1080
+ const markFirstFrameShown = () => {
1081
+ if (firstFrameShownRef.current) {
1082
+ return;
1083
+ }
1084
+ firstFrameShownRef.current = true;
1085
+ stopRequestFrameLoop();
1086
+ setVideoLoaded(true);
1087
+ };
1088
+
1089
+ const teardownConnection = () => {
1090
+ clearConnectionSuccessTimeout();
1091
+ stopRequestFrameLoop();
1092
+ if (wsRef.current) {
1093
+ wsRef.current.onopen = null;
1094
+ wsRef.current.onmessage = null;
1095
+ wsRef.current.onerror = null;
1096
+ wsRef.current.onclose = null;
1097
+ wsRef.current.close();
1098
+ wsRef.current = null;
1099
+ }
1100
+ if (peerConnectionRef.current) {
1101
+ peerConnectionRef.current.onconnectionstatechange = null;
1102
+ peerConnectionRef.current.oniceconnectionstatechange = null;
1103
+ peerConnectionRef.current.ontrack = null;
1104
+ peerConnectionRef.current.onicecandidate = null;
1105
+ peerConnectionRef.current.close();
1106
+ peerConnectionRef.current = null;
1107
+ }
1108
+ if (videoRef.current) {
1109
+ videoRef.current.srcObject = null;
1110
+ }
1111
+ if (dataChannelRef.current) {
1112
+ dataChannelRef.current.onopen = null;
1113
+ dataChannelRef.current.onclose = null;
1114
+ dataChannelRef.current.onerror = null;
1115
+ dataChannelRef.current.close();
1116
+ dataChannelRef.current = null;
1117
+ }
1118
+ };
1119
+
1048
1120
  const handleVisibilityChange = () => {
1049
1121
  if (document.hidden) {
1050
1122
  stopKeepAlive();
@@ -1053,46 +1125,143 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1053
1125
  }
1054
1126
  };
1055
1127
 
1056
- const start = async () => {
1128
+ const scheduleRetry = (reason: string, generation: number) => {
1129
+ if (generation !== connectionGenerationRef.current) {
1130
+ return;
1131
+ }
1132
+
1133
+ if (controlChannelOpenedRef.current) {
1134
+ updateStatus(`Connection failed after it was established: ${reason}`);
1135
+ setRetryExhausted(true);
1136
+ teardownConnection();
1137
+ return;
1138
+ }
1139
+
1140
+ clearScheduledRetry();
1141
+
1142
+ const nextAttempt = connectionAttemptRef.current + 1;
1143
+ if (nextAttempt >= MAX_CONNECTION_ATTEMPTS) {
1144
+ updateStatus(`Connection failed after ${MAX_CONNECTION_ATTEMPTS} attempts: ${reason}`);
1145
+ setRetryExhausted(true);
1146
+ teardownConnection();
1147
+ return;
1148
+ }
1149
+
1150
+ updateStatus(`Retrying connection (${nextAttempt + 1}/${MAX_CONNECTION_ATTEMPTS})`);
1151
+ teardownConnection();
1152
+ retryTimeoutRef.current = window.setTimeout(() => {
1153
+ retryTimeoutRef.current = undefined;
1154
+ if (generation !== connectionGenerationRef.current) {
1155
+ return;
1156
+ }
1157
+ void startAttempt(nextAttempt);
1158
+ }, CONNECTION_RETRY_DELAY_MS);
1159
+ };
1160
+
1161
+ const startAttempt = async (attemptNumber = 0) => {
1162
+ const generation = connectionGenerationRef.current + 1;
1163
+ connectionGenerationRef.current = generation;
1164
+ connectionAttemptRef.current = attemptNumber;
1165
+ controlChannelOpenedRef.current = false;
1166
+ setRetryExhausted(false);
1167
+ clearScheduledRetry();
1168
+ clearConnectionSuccessTimeout();
1169
+ stopRequestFrameLoop();
1170
+ firstFrameShownRef.current = false;
1171
+ setVideoLoaded(false);
1172
+ teardownConnection();
1173
+
1174
+ const isCurrentAttempt = () =>
1175
+ generation === connectionGenerationRef.current;
1176
+
1177
+ connectionSuccessTimeoutRef.current = window.setTimeout(() => {
1178
+ connectionSuccessTimeoutRef.current = undefined;
1179
+ if (!isCurrentAttempt() || controlChannelOpenedRef.current) {
1180
+ return;
1181
+ }
1182
+ scheduleRetry('Connection did not succeed within 15 seconds', generation);
1183
+ }, CONNECTION_SUCCESS_TIMEOUT_MS);
1184
+
1057
1185
  try {
1058
- wsRef.current = new WebSocket(`${url}?token=${token}`);
1186
+ const ws = new WebSocket(`${url}?token=${token}`);
1187
+ wsRef.current = ws;
1188
+
1189
+ // Wait for WebSocket to connect
1190
+ await new Promise<void>((resolve, reject) => {
1191
+ let settled = false;
1192
+ const timeoutId = window.setTimeout(() => reject(new Error('WebSocket connection timeout')), 30000);
1193
+ const settle = (callback: () => void) => {
1194
+ if (settled) {
1195
+ return;
1196
+ }
1197
+ settled = true;
1198
+ window.clearTimeout(timeoutId);
1199
+ callback();
1200
+ };
1201
+
1202
+ ws.onopen = () => {
1203
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1204
+ return;
1205
+ }
1206
+ settle(resolve);
1207
+ };
1208
+
1209
+ ws.onerror = (error) => {
1210
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1211
+ return;
1212
+ }
1213
+ updateStatus('WebSocket error: ' + error);
1214
+ settle(() => reject(new Error('WebSocket connection failed')));
1215
+ };
1059
1216
 
1060
- wsRef.current.onerror = (error) => {
1217
+ ws.onclose = () => {
1218
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1219
+ return;
1220
+ }
1221
+ updateStatus('WebSocket closed');
1222
+ settle(() => reject(new Error('WebSocket closed before connection was established')));
1223
+ };
1224
+ });
1225
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1226
+ return;
1227
+ }
1228
+
1229
+ ws.onerror = (error) => {
1230
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1231
+ return;
1232
+ }
1061
1233
  updateStatus('WebSocket error: ' + error);
1062
1234
  };
1063
1235
 
1064
- wsRef.current.onclose = () => {
1236
+ ws.onclose = () => {
1237
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1238
+ return;
1239
+ }
1065
1240
  updateStatus('WebSocket closed');
1066
1241
  };
1067
1242
 
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
1243
  // Request RTCConfiguration
1077
1244
  const rtcConfigPromise = new Promise<RTCConfiguration>((resolve, reject) => {
1078
- const timeoutId = setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
1245
+ const timeoutId = window.setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
1079
1246
 
1080
1247
  const messageHandler = (event: MessageEvent) => {
1081
1248
  try {
1082
1249
  const message = JSON.parse(event.data);
1083
1250
  if (message.type === 'rtcConfiguration') {
1084
- clearTimeout(timeoutId);
1085
- wsRef.current?.removeEventListener('message', messageHandler);
1251
+ window.clearTimeout(timeoutId);
1252
+ ws.removeEventListener('message', messageHandler);
1086
1253
  resolve(message.rtcConfiguration);
1087
1254
  }
1088
1255
  } catch (e) {
1256
+ window.clearTimeout(timeoutId);
1257
+ ws.removeEventListener('message', messageHandler);
1089
1258
  console.error('Error handling RTC configuration:', e);
1090
1259
  reject(e);
1091
1260
  }
1092
1261
  };
1093
1262
 
1094
- wsRef.current?.addEventListener('message', messageHandler);
1095
- wsRef.current?.send(
1263
+ ws.addEventListener('message', messageHandler);
1264
+ ws.send(
1096
1265
  JSON.stringify({
1097
1266
  type: 'requestRtcConfiguration',
1098
1267
  sessionId: sessionId,
@@ -1101,9 +1270,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1101
1270
  });
1102
1271
 
1103
1272
  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' });
1273
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1274
+ return;
1275
+ }
1276
+
1277
+ const peerConnection = new RTCPeerConnection(rtcConfig);
1278
+ peerConnectionRef.current = peerConnection;
1279
+ peerConnection.addTransceiver('audio', { direction: 'recvonly' });
1280
+ const videoTransceiver = peerConnection.addTransceiver('video', { direction: 'recvonly' });
1107
1281
 
1108
1282
  // As hardware encoder, we use H265 for iOS and VP9 for Android.
1109
1283
  // We make sure these two are the first ones in the list.
@@ -1130,70 +1304,115 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1130
1304
  }
1131
1305
  }
1132
1306
 
1133
- dataChannelRef.current = peerConnectionRef.current.createDataChannel('control', {
1307
+ const dataChannel = peerConnection.createDataChannel('control', {
1134
1308
  ordered: true,
1135
1309
  negotiated: true,
1136
1310
  id: 1,
1137
1311
  });
1312
+ dataChannelRef.current = dataChannel;
1138
1313
 
1139
- dataChannelRef.current.onopen = () => {
1314
+ dataChannel.onopen = () => {
1315
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel || wsRef.current !== ws) {
1316
+ return;
1317
+ }
1318
+ controlChannelOpenedRef.current = true;
1319
+ clearConnectionSuccessTimeout();
1140
1320
  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
1321
+ const sendRequestFrame = () => {
1322
+ if (
1323
+ !isCurrentAttempt() ||
1324
+ firstFrameShownRef.current ||
1325
+ dataChannelRef.current !== dataChannel ||
1326
+ wsRef.current !== ws ||
1327
+ ws.readyState !== WebSocket.OPEN
1328
+ ) {
1329
+ return;
1149
1330
  }
1331
+ ws.send(JSON.stringify({ type: 'requestFrame', sessionId: sessionId }));
1332
+ };
1150
1333
 
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
- }
1334
+ sendRequestFrame();
1335
+ stopRequestFrameLoop();
1336
+ requestFrameIntervalRef.current = window.setInterval(() => {
1337
+ if (
1338
+ !isCurrentAttempt() ||
1339
+ firstFrameShownRef.current ||
1340
+ dataChannelRef.current !== dataChannel ||
1341
+ wsRef.current !== ws ||
1342
+ ws.readyState !== WebSocket.OPEN
1343
+ ) {
1344
+ stopRequestFrameLoop();
1345
+ return;
1346
+ }
1347
+ sendRequestFrame();
1348
+ }, 250);
1349
+
1350
+ // Send openUrl message if the prop is provided
1351
+ if (openUrl) {
1352
+ try {
1353
+ const decodedUrl = decodeURIComponent(openUrl);
1354
+ updateStatus('Opening URL');
1355
+ ws.send(
1356
+ JSON.stringify({
1357
+ type: 'openUrl',
1358
+ url: decodedUrl,
1359
+ sessionId: sessionId,
1360
+ }),
1361
+ );
1362
+ } catch (error) {
1363
+ console.error({ error }, 'Error decoding URL, falling back to the original URL');
1364
+ ws.send(
1365
+ JSON.stringify({
1366
+ type: 'openUrl',
1367
+ url: openUrl,
1368
+ sessionId: sessionId,
1369
+ }),
1370
+ );
1173
1371
  }
1174
1372
  }
1175
1373
  };
1176
1374
 
1177
- dataChannelRef.current.onclose = () => {
1375
+ dataChannel.onclose = () => {
1376
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
1377
+ return;
1378
+ }
1178
1379
  updateStatus('Control channel closed');
1179
1380
  };
1180
1381
 
1181
- dataChannelRef.current.onerror = (error) => {
1382
+ dataChannel.onerror = (error) => {
1383
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
1384
+ return;
1385
+ }
1182
1386
  console.error('Control channel error:', error);
1183
1387
  updateStatus('Control channel error: ' + error);
1184
1388
  };
1185
1389
 
1186
1390
  // Set up connection state monitoring
1187
- peerConnectionRef.current.onconnectionstatechange = () => {
1188
- updateStatus('Connection state: ' + peerConnectionRef.current?.connectionState);
1391
+ peerConnection.onconnectionstatechange = () => {
1392
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1393
+ return;
1394
+ }
1395
+ updateStatus('Connection state: ' + peerConnection.connectionState);
1396
+ if (peerConnection.connectionState === 'failed') {
1397
+ scheduleRetry('WebRTC connection entered failed state', generation);
1398
+ }
1189
1399
  };
1190
1400
 
1191
- peerConnectionRef.current.oniceconnectionstatechange = () => {
1192
- updateStatus('ICE state: ' + peerConnectionRef.current?.iceConnectionState);
1401
+ peerConnection.oniceconnectionstatechange = () => {
1402
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1403
+ return;
1404
+ }
1405
+ updateStatus('ICE state: ' + peerConnection.iceConnectionState);
1406
+ if (peerConnection.iceConnectionState === 'failed') {
1407
+ scheduleRetry('ICE connection entered failed state', generation);
1408
+ }
1193
1409
  };
1194
1410
 
1195
1411
  // Set up video handling
1196
- peerConnectionRef.current.ontrack = (event) => {
1412
+ peerConnection.ontrack = (event) => {
1413
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1414
+ return;
1415
+ }
1197
1416
  updateStatus('Received remote track: ' + event.track.kind);
1198
1417
  if (event.track.kind === 'video' && videoRef.current) {
1199
1418
  debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
@@ -1202,8 +1421,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1202
1421
  };
1203
1422
 
1204
1423
  // Handle ICE candidates
1205
- peerConnectionRef.current.onicecandidate = (event) => {
1206
- if (event.candidate && wsRef.current) {
1424
+ peerConnection.onicecandidate = (event) => {
1425
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection || wsRef.current !== ws) {
1426
+ return;
1427
+ }
1428
+ if (event.candidate && ws.readyState === WebSocket.OPEN) {
1207
1429
  const message = {
1208
1430
  type: 'candidate',
1209
1431
  candidate: event.candidate.candidate,
@@ -1211,7 +1433,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1211
1433
  sdpMLineIndex: event.candidate.sdpMLineIndex,
1212
1434
  sessionId: sessionId,
1213
1435
  };
1214
- wsRef.current.send(JSON.stringify(message));
1436
+ ws.send(JSON.stringify(message));
1215
1437
  updateStatus('Sent ICE candidate');
1216
1438
  } else {
1217
1439
  updateStatus('ICE candidate gathering completed');
@@ -1219,7 +1441,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1219
1441
  };
1220
1442
 
1221
1443
  // Handle incoming messages
1222
- wsRef.current.onmessage = async (event) => {
1444
+ ws.onmessage = async (event) => {
1445
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1446
+ return;
1447
+ }
1223
1448
  let message;
1224
1449
  try {
1225
1450
  message = JSON.parse(event.data);
@@ -1230,47 +1455,73 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1230
1455
  updateStatus('Received: ' + message.type);
1231
1456
  switch (message.type) {
1232
1457
  case 'answer':
1233
- if (!peerConnectionRef.current) {
1458
+ if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
1234
1459
  updateStatus('No peer connection, skipping answer');
1235
1460
  break;
1236
1461
  }
1237
- await peerConnectionRef.current.setRemoteDescription(
1462
+ await peerConnection.setRemoteDescription(
1238
1463
  new RTCSessionDescription({
1239
1464
  type: 'answer',
1240
1465
  sdp: message.sdp,
1241
1466
  }),
1242
1467
  );
1468
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1469
+ return;
1470
+ }
1243
1471
  updateStatus('Set remote description');
1244
1472
  break;
1245
1473
  case 'candidate':
1246
- if (!peerConnectionRef.current) {
1474
+ if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
1247
1475
  updateStatus('No peer connection, skipping candidate');
1248
1476
  break;
1249
1477
  }
1250
- await peerConnectionRef.current.addIceCandidate(
1478
+ await peerConnection.addIceCandidate(
1251
1479
  new RTCIceCandidate({
1252
1480
  candidate: message.candidate,
1253
1481
  sdpMid: message.sdpMid,
1254
1482
  sdpMLineIndex: message.sdpMLineIndex,
1255
1483
  }),
1256
1484
  );
1485
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1486
+ return;
1487
+ }
1257
1488
  updateStatus('Added ICE candidate');
1258
1489
  break;
1259
1490
  case 'screenshot':
1260
- if (typeof message.id !== 'string' || typeof message.dataUri !== 'string') {
1491
+ case 'screenshotResult': {
1492
+ if (typeof message.id !== 'string') {
1261
1493
  debugWarn('Received invalid screenshot success message:', message);
1262
1494
  break;
1263
1495
  }
1496
+ const screenshotError = getScreenshotError(message);
1497
+ if (screenshotError) {
1498
+ const rejecter = pendingScreenshotRejectersRef.current.get(message.id);
1499
+ if (!rejecter) {
1500
+ debugWarn(`Received screenshot error for unknown or handled id: ${message.id}`);
1501
+ break;
1502
+ }
1503
+ debugWarn(`Received screenshot error for id ${message.id}: ${screenshotError}`);
1504
+ rejecter(new Error(screenshotError));
1505
+ pendingScreenshotResolversRef.current.delete(message.id);
1506
+ pendingScreenshotRejectersRef.current.delete(message.id);
1507
+ break;
1508
+ }
1509
+ const screenshotData = toScreenshotData(message);
1510
+ if (!screenshotData) {
1511
+ debugWarn('Received screenshot message without image data:', message);
1512
+ break;
1513
+ }
1264
1514
  const resolver = pendingScreenshotResolversRef.current.get(message.id);
1265
1515
  if (!resolver) {
1266
1516
  debugWarn(`Received screenshot data for unknown or handled id: ${message.id}`);
1267
1517
  break;
1268
1518
  }
1269
1519
  debugLog(`Received screenshot data for id ${message.id}`);
1270
- resolver({ dataUri: message.dataUri });
1520
+ resolver(screenshotData);
1271
1521
  pendingScreenshotResolversRef.current.delete(message.id);
1272
1522
  pendingScreenshotRejectersRef.current.delete(message.id);
1273
1523
  break;
1524
+ }
1274
1525
  case 'screenshotError':
1275
1526
  if (typeof message.id !== 'string' || typeof message.message !== 'string') {
1276
1527
  debugWarn('Received invalid screenshot error message:', message);
@@ -1320,15 +1571,21 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1320
1571
  };
1321
1572
 
1322
1573
  // Create and send offer
1323
- if (peerConnectionRef.current) {
1324
- const offer = await peerConnectionRef.current.createOffer({
1574
+ if (peerConnectionRef.current === peerConnection) {
1575
+ const offer = await peerConnection.createOffer({
1325
1576
  offerToReceiveVideo: true,
1326
1577
  offerToReceiveAudio: false,
1327
1578
  });
1328
- await peerConnectionRef.current.setLocalDescription(offer);
1579
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1580
+ return;
1581
+ }
1582
+ await peerConnection.setLocalDescription(offer);
1583
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1584
+ return;
1585
+ }
1329
1586
 
1330
- if (wsRef.current) {
1331
- wsRef.current.send(
1587
+ if (isCurrentAttempt() && wsRef.current === ws && ws.readyState === WebSocket.OPEN) {
1588
+ ws.send(
1332
1589
  JSON.stringify({
1333
1590
  type: 'offer',
1334
1591
  sdp: offer.sdp,
@@ -1339,29 +1596,33 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1339
1596
  updateStatus('Sent offer');
1340
1597
  }
1341
1598
  } catch (e) {
1342
- updateStatus('Error: ' + e);
1599
+ if (!isCurrentAttempt()) {
1600
+ return;
1601
+ }
1602
+ const reason = e instanceof Error ? e.message : String(e);
1603
+ updateStatus('Error: ' + reason);
1604
+ scheduleRetry(reason, generation);
1343
1605
  }
1344
1606
  };
1345
1607
 
1608
+ const start = () => {
1609
+ void startAttempt(0);
1610
+ };
1611
+
1346
1612
  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
- }
1613
+ connectionGenerationRef.current += 1;
1614
+ connectionAttemptRef.current = 0;
1615
+ controlChannelOpenedRef.current = false;
1616
+ clearScheduledRetry();
1617
+ teardownConnection();
1362
1618
  updateStatus('Stopped');
1363
1619
  };
1364
1620
 
1621
+ const handleManualRetry = (event: React.MouseEvent<HTMLButtonElement>) => {
1622
+ event.stopPropagation();
1623
+ start();
1624
+ };
1625
+
1365
1626
  useEffect(() => {
1366
1627
  // Reset video loaded state when connection params change
1367
1628
  setVideoLoaded(false);
@@ -1678,7 +1939,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1678
1939
  onKeyDown={handleKeyboard}
1679
1940
  onKeyUp={handleKeyboard}
1680
1941
  onClick={handleVideoClick}
1681
- onLoadedMetadata={() => setVideoLoaded(true)}
1942
+ onLoadedData={markFirstFrameShown}
1682
1943
  onFocus={() => {
1683
1944
  if (videoRef.current) {
1684
1945
  videoRef.current.style.outline = 'none';
@@ -1690,7 +1951,45 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1690
1951
  }
1691
1952
  }}
1692
1953
  />
1954
+ {retryExhausted && (
1955
+ <button
1956
+ type="button"
1957
+ className="rc-retry-button"
1958
+ onClick={handleManualRetry}
1959
+ >
1960
+ Retry
1961
+ </button>
1962
+ )}
1693
1963
  </div>
1694
1964
  );
1695
1965
  },
1696
1966
  );
1967
+
1968
+ const getScreenshotError = (message: any): string | null => {
1969
+ if (typeof message.message === 'string') {
1970
+ return message.message;
1971
+ }
1972
+
1973
+ if (typeof message.error === 'string') {
1974
+ return message.error;
1975
+ }
1976
+
1977
+ return null;
1978
+ };
1979
+
1980
+ const toScreenshotData = (message: any): ScreenshotData | null => {
1981
+ if (typeof message.dataUri === 'string') {
1982
+ return { dataUri: message.dataUri };
1983
+ }
1984
+
1985
+ if (typeof message.base64 === 'string') {
1986
+ if (message.base64.startsWith('data:')) {
1987
+ return { dataUri: message.base64 };
1988
+ }
1989
+
1990
+ const mimeType = message.base64.startsWith('/9j/') ? 'image/jpeg' : 'image/png';
1991
+ return { dataUri: `data:${mimeType};base64,${message.base64}` };
1992
+ }
1993
+
1994
+ return null;
1995
+ };