@limrun/ui 0.5.1 → 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.
@@ -7,6 +7,8 @@ import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
7
7
  import iphoneFrameImage from '../assets/iphone16pro_black_bg.webp';
8
8
  import pixelFrameImage from '../assets/pixel9_black.webp';
9
9
  import pixelFrameImageLandscape from '../assets/pixel9_black_landscape.webp';
10
+ import pixelTabletFrameImage from '../assets/pixel_tablet_portrait.webp';
11
+ import pixelTabletFrameImageLandscape from '../assets/pixel_tablet_landscape.webp';
10
12
  import iphoneFrameImageLandscape from '../assets/iphone16pro_black_landscape_bg.webp';
11
13
  import appleLogoSvg from '../assets/Apple_logo_white.svg';
12
14
  import androidBootImage from '../assets/android_boot.webp';
@@ -115,6 +117,16 @@ type DeviceConfig = {
115
117
  }
116
118
  }
117
119
 
120
+ const ANDROID_TABLET_VIDEO_WIDTH = 1920;
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;
125
+
126
+ const isAndroidTabletVideo = (width: number, height: number): boolean =>
127
+ (width === ANDROID_TABLET_VIDEO_WIDTH && height === ANDROID_TABLET_VIDEO_HEIGHT) ||
128
+ (width === ANDROID_TABLET_VIDEO_HEIGHT && height === ANDROID_TABLET_VIDEO_WIDTH);
129
+
118
130
  // Device-specific configuration for frame sizing and video positioning
119
131
  // Video position percentages are relative to the frame image dimensions
120
132
  const deviceConfig: Record<DevicePlatform, DeviceConfig> = {
@@ -184,12 +196,21 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
184
196
  const videoRef = useRef<HTMLVideoElement>(null);
185
197
  const frameRef = useRef<HTMLImageElement>(null);
186
198
  const [videoLoaded, setVideoLoaded] = useState(false);
199
+ const [retryExhausted, setRetryExhausted] = useState(false);
187
200
  const [isLandscape, setIsLandscape] = useState(false);
201
+ const [useAndroidTabletFrame, setUseAndroidTabletFrame] = useState(false);
188
202
  const [videoStyle, setVideoStyle] = useState<React.CSSProperties>({});
189
203
  const wsRef = useRef<WebSocket | null>(null);
190
204
  const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
191
205
  const dataChannelRef = useRef<RTCDataChannel | null>(null);
192
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);
193
214
  const pendingScreenshotResolversRef = useRef<
194
215
  Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
195
216
  >(new Map());
@@ -1035,6 +1056,67 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1035
1056
  }
1036
1057
  };
1037
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
+
1038
1120
  const handleVisibilityChange = () => {
1039
1121
  if (document.hidden) {
1040
1122
  stopKeepAlive();
@@ -1043,46 +1125,143 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1043
1125
  }
1044
1126
  };
1045
1127
 
1046
- 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
+
1047
1185
  try {
1048
- 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
+ };
1216
+
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
+ }
1049
1228
 
1050
- wsRef.current.onerror = (error) => {
1229
+ ws.onerror = (error) => {
1230
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1231
+ return;
1232
+ }
1051
1233
  updateStatus('WebSocket error: ' + error);
1052
1234
  };
1053
1235
 
1054
- wsRef.current.onclose = () => {
1236
+ ws.onclose = () => {
1237
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1238
+ return;
1239
+ }
1055
1240
  updateStatus('WebSocket closed');
1056
1241
  };
1057
1242
 
1058
- // Wait for WebSocket to connect
1059
- await new Promise((resolve, reject) => {
1060
- if (wsRef.current) {
1061
- wsRef.current.onopen = resolve;
1062
- setTimeout(() => reject(new Error('WebSocket connection timeout')), 30000);
1063
- }
1064
- });
1065
-
1066
1243
  // Request RTCConfiguration
1067
1244
  const rtcConfigPromise = new Promise<RTCConfiguration>((resolve, reject) => {
1068
- const timeoutId = setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
1245
+ const timeoutId = window.setTimeout(() => reject(new Error('RTCConfiguration timeout')), 30000);
1069
1246
 
1070
1247
  const messageHandler = (event: MessageEvent) => {
1071
1248
  try {
1072
1249
  const message = JSON.parse(event.data);
1073
1250
  if (message.type === 'rtcConfiguration') {
1074
- clearTimeout(timeoutId);
1075
- wsRef.current?.removeEventListener('message', messageHandler);
1251
+ window.clearTimeout(timeoutId);
1252
+ ws.removeEventListener('message', messageHandler);
1076
1253
  resolve(message.rtcConfiguration);
1077
1254
  }
1078
1255
  } catch (e) {
1256
+ window.clearTimeout(timeoutId);
1257
+ ws.removeEventListener('message', messageHandler);
1079
1258
  console.error('Error handling RTC configuration:', e);
1080
1259
  reject(e);
1081
1260
  }
1082
1261
  };
1083
1262
 
1084
- wsRef.current?.addEventListener('message', messageHandler);
1085
- wsRef.current?.send(
1263
+ ws.addEventListener('message', messageHandler);
1264
+ ws.send(
1086
1265
  JSON.stringify({
1087
1266
  type: 'requestRtcConfiguration',
1088
1267
  sessionId: sessionId,
@@ -1091,9 +1270,14 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1091
1270
  });
1092
1271
 
1093
1272
  const rtcConfig = await rtcConfigPromise;
1094
- peerConnectionRef.current = new RTCPeerConnection(rtcConfig);
1095
- peerConnectionRef.current.addTransceiver('audio', { direction: 'recvonly' });
1096
- 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' });
1097
1281
 
1098
1282
  // As hardware encoder, we use H265 for iOS and VP9 for Android.
1099
1283
  // We make sure these two are the first ones in the list.
@@ -1120,70 +1304,115 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1120
1304
  }
1121
1305
  }
1122
1306
 
1123
- dataChannelRef.current = peerConnectionRef.current.createDataChannel('control', {
1307
+ const dataChannel = peerConnection.createDataChannel('control', {
1124
1308
  ordered: true,
1125
1309
  negotiated: true,
1126
1310
  id: 1,
1127
1311
  });
1312
+ dataChannelRef.current = dataChannel;
1128
1313
 
1129
- dataChannelRef.current.onopen = () => {
1314
+ dataChannel.onopen = () => {
1315
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel || wsRef.current !== ws) {
1316
+ return;
1317
+ }
1318
+ controlChannelOpenedRef.current = true;
1319
+ clearConnectionSuccessTimeout();
1130
1320
  updateStatus('Control channel opened');
1131
- // Request first frame once we're ready to receive video
1132
- if (wsRef.current) {
1133
- for (let i = 0; i < 12; i++) {
1134
- setTimeout(() => {
1135
- if (wsRef.current) {
1136
- wsRef.current.send(JSON.stringify({ type: 'requestFrame', sessionId: sessionId }));
1137
- }
1138
- }, 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;
1139
1330
  }
1331
+ ws.send(JSON.stringify({ type: 'requestFrame', sessionId: sessionId }));
1332
+ };
1140
1333
 
1141
- // Send openUrl message if the prop is provided
1142
- if (openUrl) {
1143
- try {
1144
- const decodedUrl = decodeURIComponent(openUrl);
1145
- updateStatus('Opening URL');
1146
- wsRef.current.send(
1147
- JSON.stringify({
1148
- type: 'openUrl',
1149
- url: decodedUrl,
1150
- sessionId: sessionId,
1151
- }),
1152
- );
1153
- } catch (error) {
1154
- console.error({ error }, 'Error decoding URL, falling back to the original URL');
1155
- wsRef.current.send(
1156
- JSON.stringify({
1157
- type: 'openUrl',
1158
- url: openUrl,
1159
- sessionId: sessionId,
1160
- }),
1161
- );
1162
- }
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
+ );
1163
1371
  }
1164
1372
  }
1165
1373
  };
1166
1374
 
1167
- dataChannelRef.current.onclose = () => {
1375
+ dataChannel.onclose = () => {
1376
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
1377
+ return;
1378
+ }
1168
1379
  updateStatus('Control channel closed');
1169
1380
  };
1170
1381
 
1171
- dataChannelRef.current.onerror = (error) => {
1382
+ dataChannel.onerror = (error) => {
1383
+ if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
1384
+ return;
1385
+ }
1172
1386
  console.error('Control channel error:', error);
1173
1387
  updateStatus('Control channel error: ' + error);
1174
1388
  };
1175
1389
 
1176
1390
  // Set up connection state monitoring
1177
- peerConnectionRef.current.onconnectionstatechange = () => {
1178
- 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
+ }
1179
1399
  };
1180
1400
 
1181
- peerConnectionRef.current.oniceconnectionstatechange = () => {
1182
- 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
+ }
1183
1409
  };
1184
1410
 
1185
1411
  // Set up video handling
1186
- peerConnectionRef.current.ontrack = (event) => {
1412
+ peerConnection.ontrack = (event) => {
1413
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1414
+ return;
1415
+ }
1187
1416
  updateStatus('Received remote track: ' + event.track.kind);
1188
1417
  if (event.track.kind === 'video' && videoRef.current) {
1189
1418
  debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
@@ -1192,8 +1421,11 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1192
1421
  };
1193
1422
 
1194
1423
  // Handle ICE candidates
1195
- peerConnectionRef.current.onicecandidate = (event) => {
1196
- 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) {
1197
1429
  const message = {
1198
1430
  type: 'candidate',
1199
1431
  candidate: event.candidate.candidate,
@@ -1201,7 +1433,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1201
1433
  sdpMLineIndex: event.candidate.sdpMLineIndex,
1202
1434
  sessionId: sessionId,
1203
1435
  };
1204
- wsRef.current.send(JSON.stringify(message));
1436
+ ws.send(JSON.stringify(message));
1205
1437
  updateStatus('Sent ICE candidate');
1206
1438
  } else {
1207
1439
  updateStatus('ICE candidate gathering completed');
@@ -1209,7 +1441,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1209
1441
  };
1210
1442
 
1211
1443
  // Handle incoming messages
1212
- wsRef.current.onmessage = async (event) => {
1444
+ ws.onmessage = async (event) => {
1445
+ if (!isCurrentAttempt() || wsRef.current !== ws) {
1446
+ return;
1447
+ }
1213
1448
  let message;
1214
1449
  try {
1215
1450
  message = JSON.parse(event.data);
@@ -1220,47 +1455,73 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1220
1455
  updateStatus('Received: ' + message.type);
1221
1456
  switch (message.type) {
1222
1457
  case 'answer':
1223
- if (!peerConnectionRef.current) {
1458
+ if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
1224
1459
  updateStatus('No peer connection, skipping answer');
1225
1460
  break;
1226
1461
  }
1227
- await peerConnectionRef.current.setRemoteDescription(
1462
+ await peerConnection.setRemoteDescription(
1228
1463
  new RTCSessionDescription({
1229
1464
  type: 'answer',
1230
1465
  sdp: message.sdp,
1231
1466
  }),
1232
1467
  );
1468
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1469
+ return;
1470
+ }
1233
1471
  updateStatus('Set remote description');
1234
1472
  break;
1235
1473
  case 'candidate':
1236
- if (!peerConnectionRef.current) {
1474
+ if (!peerConnectionRef.current || peerConnectionRef.current !== peerConnection) {
1237
1475
  updateStatus('No peer connection, skipping candidate');
1238
1476
  break;
1239
1477
  }
1240
- await peerConnectionRef.current.addIceCandidate(
1478
+ await peerConnection.addIceCandidate(
1241
1479
  new RTCIceCandidate({
1242
1480
  candidate: message.candidate,
1243
1481
  sdpMid: message.sdpMid,
1244
1482
  sdpMLineIndex: message.sdpMLineIndex,
1245
1483
  }),
1246
1484
  );
1485
+ if (!isCurrentAttempt() || peerConnectionRef.current !== peerConnection) {
1486
+ return;
1487
+ }
1247
1488
  updateStatus('Added ICE candidate');
1248
1489
  break;
1249
1490
  case 'screenshot':
1250
- if (typeof message.id !== 'string' || typeof message.dataUri !== 'string') {
1491
+ case 'screenshotResult': {
1492
+ if (typeof message.id !== 'string') {
1251
1493
  debugWarn('Received invalid screenshot success message:', message);
1252
1494
  break;
1253
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
+ }
1254
1514
  const resolver = pendingScreenshotResolversRef.current.get(message.id);
1255
1515
  if (!resolver) {
1256
1516
  debugWarn(`Received screenshot data for unknown or handled id: ${message.id}`);
1257
1517
  break;
1258
1518
  }
1259
1519
  debugLog(`Received screenshot data for id ${message.id}`);
1260
- resolver({ dataUri: message.dataUri });
1520
+ resolver(screenshotData);
1261
1521
  pendingScreenshotResolversRef.current.delete(message.id);
1262
1522
  pendingScreenshotRejectersRef.current.delete(message.id);
1263
1523
  break;
1524
+ }
1264
1525
  case 'screenshotError':
1265
1526
  if (typeof message.id !== 'string' || typeof message.message !== 'string') {
1266
1527
  debugWarn('Received invalid screenshot error message:', message);
@@ -1310,15 +1571,21 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1310
1571
  };
1311
1572
 
1312
1573
  // Create and send offer
1313
- if (peerConnectionRef.current) {
1314
- const offer = await peerConnectionRef.current.createOffer({
1574
+ if (peerConnectionRef.current === peerConnection) {
1575
+ const offer = await peerConnection.createOffer({
1315
1576
  offerToReceiveVideo: true,
1316
1577
  offerToReceiveAudio: false,
1317
1578
  });
1318
- 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
+ }
1319
1586
 
1320
- if (wsRef.current) {
1321
- wsRef.current.send(
1587
+ if (isCurrentAttempt() && wsRef.current === ws && ws.readyState === WebSocket.OPEN) {
1588
+ ws.send(
1322
1589
  JSON.stringify({
1323
1590
  type: 'offer',
1324
1591
  sdp: offer.sdp,
@@ -1329,29 +1596,33 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1329
1596
  updateStatus('Sent offer');
1330
1597
  }
1331
1598
  } catch (e) {
1332
- 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);
1333
1605
  }
1334
1606
  };
1335
1607
 
1608
+ const start = () => {
1609
+ void startAttempt(0);
1610
+ };
1611
+
1336
1612
  const stop = () => {
1337
- if (wsRef.current) {
1338
- wsRef.current.close();
1339
- wsRef.current = null;
1340
- }
1341
- if (peerConnectionRef.current) {
1342
- peerConnectionRef.current.close();
1343
- peerConnectionRef.current = null;
1344
- }
1345
- if (videoRef.current) {
1346
- videoRef.current.srcObject = null;
1347
- }
1348
- if (dataChannelRef.current) {
1349
- dataChannelRef.current.close();
1350
- dataChannelRef.current = null;
1351
- }
1613
+ connectionGenerationRef.current += 1;
1614
+ connectionAttemptRef.current = 0;
1615
+ controlChannelOpenedRef.current = false;
1616
+ clearScheduledRetry();
1617
+ teardownConnection();
1352
1618
  updateStatus('Stopped');
1353
1619
  };
1354
1620
 
1621
+ const handleManualRetry = (event: React.MouseEvent<HTMLButtonElement>) => {
1622
+ event.stopPropagation();
1623
+ start();
1624
+ };
1625
+
1355
1626
  useEffect(() => {
1356
1627
  // Reset video loaded state when connection params change
1357
1628
  setVideoLoaded(false);
@@ -1397,6 +1668,9 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1397
1668
  // Determine landscape based on video's intrinsic dimensions
1398
1669
  const landscape = video.videoWidth > video.videoHeight;
1399
1670
  setIsLandscape(landscape);
1671
+ setUseAndroidTabletFrame(
1672
+ platform === 'android' && isAndroidTabletVideo(video.videoWidth, video.videoHeight),
1673
+ );
1400
1674
 
1401
1675
  const pos = landscape ? config.videoPosition.landscape : config.videoPosition.portrait;
1402
1676
  let newStyle: React.CSSProperties = {};
@@ -1589,6 +1863,10 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1589
1863
 
1590
1864
  // Show indicators when Alt is held and we have a valid hover point (null when outside)
1591
1865
  const showAltIndicators = isAltHeld && hoverPoint !== null;
1866
+ const frameImageSrc =
1867
+ platform === 'android' && useAndroidTabletFrame
1868
+ ? (isLandscape ? pixelTabletFrameImageLandscape : pixelTabletFrameImage)
1869
+ : (isLandscape ? config.frame.imageLandscape : config.frame.image);
1592
1870
 
1593
1871
  return (
1594
1872
  <div
@@ -1630,7 +1908,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1630
1908
  {showFrame && (
1631
1909
  <img
1632
1910
  ref={frameRef}
1633
- src={isLandscape ? config.frame.imageLandscape : config.frame.image}
1911
+ src={frameImageSrc}
1634
1912
  alt=""
1635
1913
  className={platform === 'ios' ? clsx('rc-phone-frame', 'rc-phone-frame-ios') : 'rc-phone-frame'}
1636
1914
  draggable={false}
@@ -1661,7 +1939,7 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1661
1939
  onKeyDown={handleKeyboard}
1662
1940
  onKeyUp={handleKeyboard}
1663
1941
  onClick={handleVideoClick}
1664
- onLoadedMetadata={() => setVideoLoaded(true)}
1942
+ onLoadedData={markFirstFrameShown}
1665
1943
  onFocus={() => {
1666
1944
  if (videoRef.current) {
1667
1945
  videoRef.current.style.outline = 'none';
@@ -1673,7 +1951,45 @@ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>
1673
1951
  }
1674
1952
  }}
1675
1953
  />
1954
+ {retryExhausted && (
1955
+ <button
1956
+ type="button"
1957
+ className="rc-retry-button"
1958
+ onClick={handleManualRetry}
1959
+ >
1960
+ Retry
1961
+ </button>
1962
+ )}
1676
1963
  </div>
1677
1964
  );
1678
1965
  },
1679
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
+ };