@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.
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +655 -558
- package/package.json +2 -2
- package/src/components/remote-control.css +26 -0
- package/src/components/remote-control.tsx +390 -91
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1375
|
+
dataChannel.onclose = () => {
|
|
1376
|
+
if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1178
1379
|
updateStatus('Control channel closed');
|
|
1179
1380
|
};
|
|
1180
1381
|
|
|
1181
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1206
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
1574
|
+
if (peerConnectionRef.current === peerConnection) {
|
|
1575
|
+
const offer = await peerConnection.createOffer({
|
|
1325
1576
|
offerToReceiveVideo: true,
|
|
1326
1577
|
offerToReceiveAudio: false,
|
|
1327
1578
|
});
|
|
1328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
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
|
+
};
|