@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.
- package/dist/index.cjs +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +636 -537
- package/package.json +2 -2
- package/src/assets/pixel_tablet_landscape.webp +0 -0
- package/src/assets/pixel_tablet_portrait.webp +0 -0
- package/src/components/remote-control.css +26 -0
- package/src/components/remote-control.tsx +408 -92
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1229
|
+
ws.onerror = (error) => {
|
|
1230
|
+
if (!isCurrentAttempt() || wsRef.current !== ws) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1051
1233
|
updateStatus('WebSocket error: ' + error);
|
|
1052
1234
|
};
|
|
1053
1235
|
|
|
1054
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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
|
-
|
|
1375
|
+
dataChannel.onclose = () => {
|
|
1376
|
+
if (!isCurrentAttempt() || dataChannelRef.current !== dataChannel) {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1168
1379
|
updateStatus('Control channel closed');
|
|
1169
1380
|
};
|
|
1170
1381
|
|
|
1171
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1196
|
-
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
1574
|
+
if (peerConnectionRef.current === peerConnection) {
|
|
1575
|
+
const offer = await peerConnection.createOffer({
|
|
1315
1576
|
offerToReceiveVideo: true,
|
|
1316
1577
|
offerToReceiveAudio: false,
|
|
1317
1578
|
});
|
|
1318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
+
};
|