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