@limrun/ui 0.1.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.
@@ -0,0 +1,868 @@
1
+ import React, { useEffect, useRef, useState, useMemo, forwardRef, useImperativeHandle } from 'react';
2
+ import { clsx } from 'clsx';
3
+ import './remote-control.css';
4
+
5
+ import { ANDROID_KEYS, AMOTION_EVENT, codeMap } from '../core/constants';
6
+ import {
7
+ createTouchControlMessage,
8
+ createInjectKeycodeMessage,
9
+ createSetClipboardMessage,
10
+ } from '../core/webrtc-messages';
11
+
12
+ declare global {
13
+ interface Window {
14
+ debugRemoteControl?: boolean;
15
+ }
16
+ }
17
+
18
+ interface RemoteControlProps {
19
+ // url is the URL of the instance to connect to.
20
+ url: string;
21
+
22
+ // token is used to authenticate the connection to the instance.
23
+ token: string;
24
+
25
+ // className is the class name to apply to the component
26
+ // on top of the default styles.
27
+ className?: string;
28
+
29
+ // sessionId is a unique identifier for the WebRTC session
30
+ // with the source to prevent conflicts between other
31
+ // users connected to the same source.
32
+ // If empty, the component will generate a random one.
33
+ sessionId?: string;
34
+
35
+ // openUrl is the URL to open in the instance when the
36
+ // component is ready.
37
+ //
38
+ // If not provided, the component will not open any URL.
39
+ openUrl?: string;
40
+ }
41
+
42
+ interface ScreenshotData {
43
+ dataUri: string;
44
+ }
45
+
46
+ export interface ImperativeKeyboardEvent {
47
+ type: 'keydown' | 'keyup';
48
+ code: string; // e.g., "KeyA", "Enter", "ShiftLeft"
49
+ shiftKey?: boolean;
50
+ altKey?: boolean;
51
+ ctrlKey?: boolean;
52
+ metaKey?: boolean;
53
+ }
54
+
55
+ export interface RemoteControlHandle {
56
+ openUrl: (url: string) => void;
57
+ sendKeyEvent: (event: ImperativeKeyboardEvent) => void;
58
+ screenshot: () => Promise<ScreenshotData>;
59
+ }
60
+
61
+ const debugLog = (...args: any[]) => {
62
+ if (window.debugRemoteControl) {
63
+ console.log(...args);
64
+ }
65
+ };
66
+
67
+ const debugWarn = (...args: any[]) => {
68
+ if (window.debugRemoteControl) {
69
+ console.warn(...args);
70
+ }
71
+ };
72
+
73
+ function getAndroidKeycodeAndMeta(event: React.KeyboardEvent): { keycode: number; metaState: number } | null {
74
+ const code = event.code;
75
+ const keycode = codeMap[code];
76
+
77
+ if (!keycode) {
78
+ // Use the wrapper for conditional warning
79
+ debugWarn(`Unknown event.code: ${code}, key: ${event.key}`);
80
+ return null;
81
+ }
82
+
83
+ let metaState = ANDROID_KEYS.META_NONE;
84
+ const isLetter = code >= 'KeyA' && code <= 'KeyZ';
85
+ const isCapsLock = event.getModifierState('CapsLock');
86
+ const isShiftPressed = event.shiftKey;
87
+
88
+ // Determine effective shift state
89
+ let effectiveShift = isShiftPressed;
90
+ if (isLetter) {
91
+ effectiveShift = isShiftPressed !== isCapsLock; // Logical XOR for booleans
92
+ }
93
+
94
+ // Apply meta states
95
+ if (effectiveShift) metaState |= ANDROID_KEYS.META_SHIFT_ON;
96
+ if (event.ctrlKey) metaState |= ANDROID_KEYS.META_CTRL_ON;
97
+ if (event.altKey) metaState |= ANDROID_KEYS.META_ALT_ON;
98
+ if (event.metaKey) metaState |= ANDROID_KEYS.META_META_ON; // Command on Mac, Windows key on Win
99
+
100
+ return { keycode, metaState };
101
+ }
102
+
103
+ export const RemoteControl = forwardRef<RemoteControlHandle, RemoteControlProps>(
104
+ ({ className, url, token, sessionId: propSessionId, openUrl }: RemoteControlProps, ref) => {
105
+ const videoRef = useRef<HTMLVideoElement>(null);
106
+ const wsRef = useRef<WebSocket | null>(null);
107
+ const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
108
+ const dataChannelRef = useRef<RTCDataChannel | null>(null);
109
+ const [isConnected, setIsConnected] = useState<boolean>(false);
110
+ const keepAliveIntervalRef = useRef<number | undefined>(undefined);
111
+ const pendingScreenshotResolversRef = useRef<
112
+ Map<string, (value: ScreenshotData | PromiseLike<ScreenshotData>) => void>
113
+ >(new Map());
114
+ const pendingScreenshotRejectersRef = useRef<Map<string, (reason?: any) => void>>(new Map());
115
+
116
+ // Map to track active pointers (mouse or touch) and their last known position inside the video
117
+ // Key: pointerId (-1 for mouse, touch.identifier for touch), Value: { x: number, y: number }
118
+ const activePointers = useRef<Map<number, { x: number; y: number }>>(new Map());
119
+
120
+ const sessionId = useMemo(
121
+ () =>
122
+ propSessionId ||
123
+ Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15),
124
+ [propSessionId],
125
+ );
126
+
127
+ const updateStatus = (message: string) => {
128
+ // Use the wrapper for conditional logging
129
+ debugLog(message);
130
+ };
131
+
132
+ const sendBinaryControlMessage = (data: ArrayBuffer) => {
133
+ if (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open') {
134
+ return;
135
+ }
136
+ dataChannelRef.current.send(data);
137
+ };
138
+
139
+ // Unified handler for both mouse and touch interactions
140
+ const handleInteraction = (event: React.MouseEvent | React.TouchEvent) => {
141
+ event.preventDefault();
142
+ event.stopPropagation();
143
+
144
+ if (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open' || !videoRef.current) {
145
+ return;
146
+ }
147
+
148
+ const video = videoRef.current;
149
+ const rect = video.getBoundingClientRect();
150
+ const videoWidth = video.videoWidth;
151
+ const videoHeight = video.videoHeight;
152
+
153
+ if (!videoWidth || !videoHeight) return; // Video dimensions not ready
154
+
155
+ // Helper to process a single pointer event (either mouse or a single touch point)
156
+ const processPointer = (
157
+ pointerId: number,
158
+ clientX: number,
159
+ clientY: number,
160
+ eventType: 'down' | 'move' | 'up' | 'cancel',
161
+ ) => {
162
+ // --- Start: Coordinate Calculation ---
163
+ const displayWidth = rect.width;
164
+ const displayHeight = rect.height;
165
+ const videoAspectRatio = videoWidth / videoHeight;
166
+ const containerAspectRatio = displayWidth / displayHeight;
167
+ let actualWidth = displayWidth;
168
+ let actualHeight = displayHeight;
169
+ if (videoAspectRatio > containerAspectRatio) {
170
+ actualHeight = displayWidth / videoAspectRatio;
171
+ } else {
172
+ actualWidth = displayHeight * videoAspectRatio;
173
+ }
174
+ const offsetX = (displayWidth - actualWidth) / 2;
175
+ const offsetY = (displayHeight - actualHeight) / 2;
176
+ const relativeX = clientX - rect.left - offsetX;
177
+ const relativeY = clientY - rect.top - offsetY;
178
+ const isInside =
179
+ relativeX >= 0 && relativeX <= actualWidth && relativeY >= 0 && relativeY <= actualHeight;
180
+
181
+ let videoX = 0;
182
+ let videoY = 0;
183
+ if (isInside) {
184
+ videoX = Math.max(0, Math.min(videoWidth, (relativeX / actualWidth) * videoWidth));
185
+ videoY = Math.max(0, Math.min(videoHeight, (relativeY / actualHeight) * videoHeight));
186
+ }
187
+ // --- End: Coordinate Calculation ---
188
+
189
+ let action: number | null = null;
190
+ let positionToSend: { x: number; y: number } | null = null;
191
+ let pressure = 1.0; // Default pressure
192
+ const buttons = AMOTION_EVENT.BUTTON_PRIMARY; // Assume primary button
193
+
194
+ switch (eventType) {
195
+ case 'down':
196
+ if (isInside) {
197
+ action = AMOTION_EVENT.ACTION_DOWN;
198
+ positionToSend = { x: videoX, y: videoY };
199
+ activePointers.current.set(pointerId, positionToSend);
200
+ if (pointerId === -1) {
201
+ // Focus on mouse down
202
+ videoRef.current?.focus();
203
+ }
204
+ } else {
205
+ // If the initial down event is outside, ignore it for this pointer
206
+ activePointers.current.delete(pointerId);
207
+ }
208
+ break;
209
+
210
+ case 'move':
211
+ if (activePointers.current.has(pointerId)) {
212
+ if (isInside) {
213
+ action = AMOTION_EVENT.ACTION_MOVE;
214
+ positionToSend = { x: videoX, y: videoY };
215
+ // Update the last known position for this active pointer
216
+ activePointers.current.set(pointerId, positionToSend);
217
+ } else {
218
+ // Moved outside while active - do nothing, UP/CANCEL will use last known pos
219
+ }
220
+ }
221
+ break;
222
+
223
+ case 'up':
224
+ case 'cancel': // Treat cancel like up, but use ACTION_CANCEL
225
+ if (activePointers.current.has(pointerId)) {
226
+ action = eventType === 'cancel' ? AMOTION_EVENT.ACTION_CANCEL : AMOTION_EVENT.ACTION_UP;
227
+ // IMPORTANT: Send the UP/CANCEL at the *last known position* inside the video
228
+ positionToSend = activePointers.current.get(pointerId)!;
229
+ activePointers.current.delete(pointerId); // Remove pointer as it's no longer active
230
+ }
231
+ break;
232
+ }
233
+
234
+ // Send message if action and position determined
235
+ if (action !== null && positionToSend !== null) {
236
+ const message = createTouchControlMessage(
237
+ action,
238
+ pointerId,
239
+ videoWidth,
240
+ videoHeight,
241
+ positionToSend.x,
242
+ positionToSend.y,
243
+ pressure,
244
+ buttons,
245
+ buttons,
246
+ );
247
+ if (message) {
248
+ sendBinaryControlMessage(message);
249
+ }
250
+ } else if (eventType === 'up' || eventType === 'cancel') {
251
+ // Clean up map just in case if 'down' was outside and 'up'/'cancel' is triggered
252
+ activePointers.current.delete(pointerId);
253
+ }
254
+ };
255
+
256
+ // --- Event Type Handling ---
257
+
258
+ if ('touches' in event) {
259
+ // Touch Events
260
+ const touches = event.changedTouches; // Use changedTouches for start/end/cancel
261
+ let eventType: 'down' | 'move' | 'up' | 'cancel';
262
+
263
+ switch (event.type) {
264
+ case 'touchstart':
265
+ eventType = 'down';
266
+ break;
267
+ case 'touchmove':
268
+ eventType = 'move';
269
+ break;
270
+ case 'touchend':
271
+ eventType = 'up';
272
+ break;
273
+ case 'touchcancel':
274
+ eventType = 'cancel';
275
+ break;
276
+ default:
277
+ return; // Should not happen
278
+ }
279
+
280
+ for (let i = 0; i < touches.length; i++) {
281
+ const touch = touches[i];
282
+ processPointer(touch.identifier, touch.clientX, touch.clientY, eventType);
283
+ }
284
+ } else {
285
+ // Mouse Events
286
+ const pointerId = -1; // Use -1 for mouse pointer
287
+ let eventType: 'down' | 'move' | 'up' | 'cancel' | null = null;
288
+
289
+ switch (event.type) {
290
+ case 'mousedown':
291
+ if (event.button === 0) eventType = 'down'; // Only primary button
292
+ break;
293
+ case 'mousemove':
294
+ // Only process move if primary button is down (check map)
295
+ if (activePointers.current.has(pointerId)) {
296
+ eventType = 'move';
297
+ }
298
+ break;
299
+ case 'mouseup':
300
+ if (event.button === 0) eventType = 'up'; // Only primary button
301
+ break;
302
+ case 'mouseleave':
303
+ // Treat leave like up only if button was down
304
+ if (activePointers.current.has(pointerId)) {
305
+ eventType = 'up';
306
+ }
307
+ break;
308
+ }
309
+
310
+ if (eventType) {
311
+ processPointer(pointerId, event.clientX, event.clientY, eventType);
312
+ }
313
+ }
314
+ };
315
+
316
+ const handleKeyboard = (event: React.KeyboardEvent) => {
317
+ event.preventDefault();
318
+ event.stopPropagation();
319
+ // Use the wrapper for conditional logging
320
+ debugLog('Keyboard event:', {
321
+ type: event.type,
322
+ key: event.key,
323
+ keyCode: event.keyCode,
324
+ code: event.code,
325
+ target: (event.target as HTMLElement).tagName,
326
+ focused: document.activeElement === videoRef.current,
327
+ });
328
+
329
+ if (document.activeElement !== videoRef.current) {
330
+ // Use the wrapper for conditional warning
331
+ debugWarn('Video element not focused, skipping keyboard event');
332
+ return;
333
+ }
334
+
335
+ if (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open') {
336
+ // Use the wrapper for conditional warning
337
+ debugWarn('Data channel not ready for keyboard event:', dataChannelRef.current?.readyState);
338
+ return;
339
+ }
340
+
341
+ // Handle special shortcuts first (Paste, Menu)
342
+ if (event.type === 'keydown') {
343
+ // Paste (Cmd+V / Ctrl+V)
344
+ if (event.key.toLowerCase() === 'v' && (event.metaKey || event.ctrlKey)) {
345
+ debugLog('Paste shortcut detected');
346
+ navigator.clipboard
347
+ .readText()
348
+ .then((text) => {
349
+ if (text) {
350
+ debugLog(
351
+ 'Pasting text via SET_CLIPBOARD:',
352
+ text.substring(0, 20) + (text.length > 20 ? '...' : ''),
353
+ );
354
+ const message = createSetClipboardMessage(text, true); // paste=true
355
+ sendBinaryControlMessage(message);
356
+ }
357
+ })
358
+ .catch((err) => {
359
+ console.error('Failed to read clipboard contents: ', err);
360
+ });
361
+ return; // Don't process 'v' keycode further
362
+ }
363
+
364
+ // Menu (Cmd+M / Ctrl+M) - Send down and up immediately
365
+ if (event.key.toLowerCase() === 'm' && (event.metaKey || event.ctrlKey)) {
366
+ debugLog('Menu shortcut detected');
367
+ const messageDown = createInjectKeycodeMessage(
368
+ ANDROID_KEYS.ACTION_DOWN,
369
+ ANDROID_KEYS.MENU,
370
+ 0,
371
+ ANDROID_KEYS.META_NONE, // Modifiers are handled by the shortcut check, not passed down
372
+ );
373
+ sendBinaryControlMessage(messageDown);
374
+ const messageUp = createInjectKeycodeMessage(
375
+ ANDROID_KEYS.ACTION_UP,
376
+ ANDROID_KEYS.MENU,
377
+ 0,
378
+ ANDROID_KEYS.META_NONE,
379
+ );
380
+ sendBinaryControlMessage(messageUp);
381
+ return; // Don't process 'm' keycode further
382
+ }
383
+ }
384
+
385
+ // Handle general key presses (including Arrows, Enter, Backspace, Delete, Letters, Numbers, Symbols)
386
+ const keyInfo = getAndroidKeycodeAndMeta(event);
387
+
388
+ if (keyInfo) {
389
+ const { keycode, metaState } = keyInfo;
390
+ const action = event.type === 'keydown' ? ANDROID_KEYS.ACTION_DOWN : ANDROID_KEYS.ACTION_UP;
391
+
392
+ debugLog(`Sending Keycode: key=${event.key}, code=${keycode}, action=${action}, meta=${metaState}`);
393
+
394
+ const message = createInjectKeycodeMessage(
395
+ action,
396
+ keycode,
397
+ 0, // repeat count, typically 0 for single presses
398
+ metaState,
399
+ );
400
+ sendBinaryControlMessage(message);
401
+ } else {
402
+ debugLog(`Ignoring unhandled key event: type=${event.type}, key=${event.key}`);
403
+ }
404
+ };
405
+
406
+ const sendKeepAlive = () => {
407
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
408
+ wsRef.current.send(
409
+ JSON.stringify({
410
+ type: 'keepAlive',
411
+ sessionId: sessionId,
412
+ }),
413
+ );
414
+ }
415
+ };
416
+
417
+ const startKeepAlive = () => {
418
+ if (keepAliveIntervalRef.current) {
419
+ window.clearInterval(keepAliveIntervalRef.current);
420
+ }
421
+ keepAliveIntervalRef.current = window.setInterval(sendKeepAlive, 10000);
422
+ };
423
+
424
+ const stopKeepAlive = () => {
425
+ if (keepAliveIntervalRef.current) {
426
+ window.clearInterval(keepAliveIntervalRef.current);
427
+ keepAliveIntervalRef.current = undefined;
428
+ }
429
+ };
430
+
431
+ const handleVisibilityChange = () => {
432
+ if (document.hidden) {
433
+ stopKeepAlive();
434
+ } else {
435
+ startKeepAlive();
436
+ }
437
+ };
438
+
439
+ const start = async () => {
440
+ try {
441
+ wsRef.current = new WebSocket(`${url}?token=${token}`);
442
+
443
+ wsRef.current.onerror = (error) => {
444
+ updateStatus('WebSocket error: ' + error);
445
+ };
446
+
447
+ wsRef.current.onclose = () => {
448
+ updateStatus('WebSocket closed');
449
+ };
450
+
451
+ // Wait for WebSocket to connect
452
+ await new Promise((resolve, reject) => {
453
+ if (wsRef.current) {
454
+ wsRef.current.onopen = resolve;
455
+ setTimeout(() => reject(new Error('WebSocket connection timeout')), 5000);
456
+ }
457
+ });
458
+
459
+ // Request RTCConfiguration
460
+ const rtcConfigPromise = new Promise<RTCConfiguration>((resolve, reject) => {
461
+ const timeoutId = setTimeout(() => reject(new Error('RTCConfiguration timeout')), 5000);
462
+
463
+ const messageHandler = (event: MessageEvent) => {
464
+ try {
465
+ const message = JSON.parse(event.data);
466
+ if (message.type === 'rtcConfiguration') {
467
+ clearTimeout(timeoutId);
468
+ wsRef.current?.removeEventListener('message', messageHandler);
469
+ resolve(message.rtcConfiguration);
470
+ }
471
+ } catch (e) {
472
+ console.error('Error handling RTC configuration:', e);
473
+ reject(e);
474
+ }
475
+ };
476
+
477
+ wsRef.current?.addEventListener('message', messageHandler);
478
+ wsRef.current?.send(
479
+ JSON.stringify({
480
+ type: 'requestRtcConfiguration',
481
+ sessionId: sessionId,
482
+ }),
483
+ );
484
+ });
485
+
486
+ const rtcConfig = await rtcConfigPromise;
487
+ peerConnectionRef.current = new RTCPeerConnection(rtcConfig);
488
+ peerConnectionRef.current.addTransceiver('audio', { direction: 'recvonly' });
489
+ peerConnectionRef.current.addTransceiver('video', { direction: 'recvonly' });
490
+ dataChannelRef.current = peerConnectionRef.current.createDataChannel('control', {
491
+ ordered: true,
492
+ negotiated: true,
493
+ id: 1,
494
+ });
495
+
496
+ dataChannelRef.current.onopen = () => {
497
+ updateStatus('Control channel opened');
498
+ // Request first frame once we're ready to receive video
499
+ if (wsRef.current) {
500
+ wsRef.current.send(JSON.stringify({ type: 'requestFrame', sessionId: sessionId }));
501
+
502
+ // Send openUrl message if the prop is provided
503
+ if (openUrl) {
504
+ try {
505
+ const decodedUrl = decodeURIComponent(openUrl);
506
+ updateStatus('Opening URL');
507
+ wsRef.current.send(
508
+ JSON.stringify({
509
+ type: 'openUrl',
510
+ url: decodedUrl,
511
+ sessionId: sessionId,
512
+ }),
513
+ );
514
+ } catch (error) {
515
+ console.error({ error }, 'Error decoding URL, falling back to the original URL');
516
+ wsRef.current.send(
517
+ JSON.stringify({
518
+ type: 'openUrl',
519
+ url: openUrl,
520
+ sessionId: sessionId,
521
+ }),
522
+ );
523
+ }
524
+ }
525
+ }
526
+ };
527
+
528
+ dataChannelRef.current.onclose = () => {
529
+ updateStatus('Control channel closed');
530
+ };
531
+
532
+ dataChannelRef.current.onerror = (error) => {
533
+ console.error('Control channel error:', error);
534
+ updateStatus('Control channel error: ' + error);
535
+ };
536
+
537
+ // Set up connection state monitoring
538
+ peerConnectionRef.current.onconnectionstatechange = () => {
539
+ updateStatus('Connection state: ' + peerConnectionRef.current?.connectionState);
540
+ setIsConnected(peerConnectionRef.current?.connectionState === 'connected');
541
+ };
542
+
543
+ peerConnectionRef.current.oniceconnectionstatechange = () => {
544
+ updateStatus('ICE state: ' + peerConnectionRef.current?.iceConnectionState);
545
+ };
546
+
547
+ // Set up video handling
548
+ peerConnectionRef.current.ontrack = (event) => {
549
+ updateStatus('Received remote track: ' + event.track.kind);
550
+ if (event.track.kind === 'video' && videoRef.current) {
551
+ debugLog(`[${new Date().toISOString()}] Video track received:`, event.track);
552
+ videoRef.current.srcObject = event.streams[0];
553
+ }
554
+ };
555
+
556
+ // Handle ICE candidates
557
+ peerConnectionRef.current.onicecandidate = (event) => {
558
+ if (event.candidate && wsRef.current) {
559
+ const message = {
560
+ type: 'candidate',
561
+ candidate: event.candidate.candidate,
562
+ sdpMid: event.candidate.sdpMid,
563
+ sdpMLineIndex: event.candidate.sdpMLineIndex,
564
+ sessionId: sessionId,
565
+ };
566
+ wsRef.current.send(JSON.stringify(message));
567
+ updateStatus('Sent ICE candidate');
568
+ } else {
569
+ updateStatus('ICE candidate gathering completed');
570
+ }
571
+ };
572
+
573
+ // Handle incoming messages
574
+ wsRef.current.onmessage = async (event) => {
575
+ let message;
576
+ try {
577
+ message = JSON.parse(event.data);
578
+ } catch (e) {
579
+ debugWarn('Error parsing message:', e);
580
+ return;
581
+ }
582
+ updateStatus('Received: ' + message.type);
583
+ switch (message.type) {
584
+ case 'answer':
585
+ if (!peerConnectionRef.current) {
586
+ updateStatus('No peer connection, skipping answer');
587
+ break;
588
+ }
589
+ await peerConnectionRef.current.setRemoteDescription(
590
+ new RTCSessionDescription({
591
+ type: 'answer',
592
+ sdp: message.sdp,
593
+ }),
594
+ );
595
+ updateStatus('Set remote description');
596
+ break;
597
+ case 'candidate':
598
+ if (!peerConnectionRef.current) {
599
+ updateStatus('No peer connection, skipping candidate');
600
+ break;
601
+ }
602
+ await peerConnectionRef.current.addIceCandidate(
603
+ new RTCIceCandidate({
604
+ candidate: message.candidate,
605
+ sdpMid: message.sdpMid,
606
+ sdpMLineIndex: message.sdpMLineIndex,
607
+ }),
608
+ );
609
+ updateStatus('Added ICE candidate');
610
+ break;
611
+ case 'screenshot':
612
+ if (typeof message.id !== 'string' || typeof message.dataUri !== 'string') {
613
+ debugWarn('Received invalid screenshot success message:', message);
614
+ break;
615
+ }
616
+ const resolver = pendingScreenshotResolversRef.current.get(message.id);
617
+ if (!resolver) {
618
+ debugWarn(`Received screenshot data for unknown or handled id: ${message.id}`);
619
+ break;
620
+ }
621
+ debugLog(`Received screenshot data for id ${message.id}`);
622
+ resolver({ dataUri: message.dataUri });
623
+ pendingScreenshotResolversRef.current.delete(message.id);
624
+ pendingScreenshotRejectersRef.current.delete(message.id);
625
+ break;
626
+ case 'screenshotError':
627
+ if (typeof message.id !== 'string' || typeof message.message !== 'string') {
628
+ debugWarn('Received invalid screenshot error message:', message);
629
+ break;
630
+ }
631
+ const rejecter = pendingScreenshotRejectersRef.current.get(message.id);
632
+ if (!rejecter) {
633
+ debugWarn(`Received screenshot error for unknown or handled id: ${message.id}`);
634
+ break;
635
+ }
636
+ debugWarn(`Received screenshot error for id ${message.id}: ${message.message}`);
637
+ rejecter(new Error(message.message));
638
+ pendingScreenshotResolversRef.current.delete(message.id);
639
+ pendingScreenshotRejectersRef.current.delete(message.id);
640
+ break;
641
+ default:
642
+ debugWarn(`Received unhandled message type: ${message.type}`, message);
643
+ break;
644
+ }
645
+ };
646
+
647
+ // Create and send offer
648
+ if (peerConnectionRef.current) {
649
+ const offer = await peerConnectionRef.current.createOffer({
650
+ offerToReceiveVideo: true,
651
+ offerToReceiveAudio: false,
652
+ });
653
+ await peerConnectionRef.current.setLocalDescription(offer);
654
+
655
+ if (wsRef.current) {
656
+ wsRef.current.send(
657
+ JSON.stringify({
658
+ type: 'offer',
659
+ sdp: offer.sdp,
660
+ sessionId: sessionId,
661
+ }),
662
+ );
663
+ }
664
+ updateStatus('Sent offer');
665
+ }
666
+ } catch (e) {
667
+ updateStatus('Error: ' + e);
668
+ }
669
+ };
670
+
671
+ const stop = () => {
672
+ if (wsRef.current) {
673
+ wsRef.current.close();
674
+ wsRef.current = null;
675
+ }
676
+ if (peerConnectionRef.current) {
677
+ peerConnectionRef.current.close();
678
+ peerConnectionRef.current = null;
679
+ }
680
+ if (videoRef.current) {
681
+ videoRef.current.srcObject = null;
682
+ }
683
+ if (dataChannelRef.current) {
684
+ dataChannelRef.current.close();
685
+ dataChannelRef.current = null;
686
+ }
687
+ setIsConnected(false);
688
+ updateStatus('Stopped');
689
+ };
690
+
691
+ useEffect(() => {
692
+ // Start connection when component mounts
693
+ start();
694
+
695
+ // Only start keepAlive if page is visible
696
+ if (!document.hidden) {
697
+ startKeepAlive();
698
+ }
699
+
700
+ // Add visibility change listener
701
+ document.addEventListener('visibilitychange', handleVisibilityChange);
702
+
703
+ // Clean up
704
+ return () => {
705
+ stopKeepAlive();
706
+ stop();
707
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
708
+ };
709
+ }, [url, token, propSessionId]);
710
+
711
+ const handleVideoClick = () => {
712
+ if (videoRef.current) {
713
+ videoRef.current.focus();
714
+ }
715
+ };
716
+
717
+ // Expose sendOpenUrlCommand via ref
718
+ useImperativeHandle(ref, () => ({
719
+ openUrl: (newUrl: string) => {
720
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
721
+ debugWarn('WebSocket not open, cannot send open_url command via ref.');
722
+ return;
723
+ }
724
+ try {
725
+ const decodedUrl = decodeURIComponent(newUrl);
726
+ updateStatus('Opening URL');
727
+ wsRef.current.send(
728
+ JSON.stringify({
729
+ type: 'openUrl',
730
+ url: decodedUrl,
731
+ sessionId: sessionId,
732
+ }),
733
+ );
734
+ } catch (error) {
735
+ debugWarn('Error decoding or sending URL via ref:', { error, url: newUrl });
736
+ wsRef.current.send(
737
+ JSON.stringify({
738
+ type: 'openUrl',
739
+ url: newUrl,
740
+ sessionId: sessionId,
741
+ }),
742
+ );
743
+ }
744
+ },
745
+
746
+ sendKeyEvent: (event: ImperativeKeyboardEvent) => {
747
+ if (!dataChannelRef.current || dataChannelRef.current.readyState !== 'open') {
748
+ debugWarn('Data channel not ready for imperative key command:', dataChannelRef.current?.readyState);
749
+ return;
750
+ }
751
+
752
+ const keycode = codeMap[event.code];
753
+ if (!keycode) {
754
+ debugWarn(`Unknown event.code for imperative command: ${event.code}`);
755
+ return;
756
+ }
757
+
758
+ let metaState = ANDROID_KEYS.META_NONE;
759
+ if (event.shiftKey) metaState |= ANDROID_KEYS.META_SHIFT_ON;
760
+ if (event.altKey) metaState |= ANDROID_KEYS.META_ALT_ON;
761
+ if (event.ctrlKey) metaState |= ANDROID_KEYS.META_CTRL_ON;
762
+ if (event.metaKey) metaState |= ANDROID_KEYS.META_META_ON;
763
+
764
+ const action = event.type === 'keydown' ? ANDROID_KEYS.ACTION_DOWN : ANDROID_KEYS.ACTION_UP;
765
+
766
+ debugLog(
767
+ `Sending Imperative Key Command: code=${event.code}, keycode=${keycode}, action=${action}, meta=${metaState}`,
768
+ );
769
+
770
+ const message = createInjectKeycodeMessage(
771
+ action,
772
+ keycode,
773
+ 0, // repeat count, typically 0 for single presses
774
+ metaState,
775
+ );
776
+ if (message) {
777
+ sendBinaryControlMessage(message);
778
+ }
779
+ },
780
+ screenshot: (): Promise<ScreenshotData> => {
781
+ return new Promise<ScreenshotData>((resolve, reject) => {
782
+ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
783
+ debugWarn('WebSocket not open, cannot send screenshot command.');
784
+ return reject(new Error('WebSocket is not connected or connection is not open.'));
785
+ }
786
+
787
+ const id = `ui-ss-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
788
+ const request = {
789
+ type: 'screenshot', // Matches the type expected by instance API
790
+ id: id,
791
+ };
792
+
793
+ pendingScreenshotResolversRef.current.set(id, resolve);
794
+ pendingScreenshotRejectersRef.current.set(id, reject);
795
+
796
+ debugLog('Sending screenshot request:', request);
797
+ try {
798
+ wsRef.current.send(JSON.stringify(request));
799
+ } catch (err) {
800
+ debugWarn('Failed to send screenshot request immediately:', err);
801
+ pendingScreenshotResolversRef.current.delete(id);
802
+ pendingScreenshotRejectersRef.current.delete(id);
803
+ reject(err);
804
+ return; // Important to return here if send failed synchronously
805
+ }
806
+
807
+ setTimeout(() => {
808
+ if (pendingScreenshotResolversRef.current.has(id)) {
809
+ debugWarn(`Screenshot request timed out for id ${id}`);
810
+ pendingScreenshotRejectersRef.current.get(id)?.(new Error('Screenshot request timed out'));
811
+ pendingScreenshotResolversRef.current.delete(id);
812
+ pendingScreenshotRejectersRef.current.delete(id);
813
+ }
814
+ }, 30000); // 30-second timeout
815
+ });
816
+ },
817
+ }));
818
+
819
+ return (
820
+ <div
821
+ className={clsx(
822
+ 'rc-container', // Use custom CSS class instead of Tailwind
823
+ className,
824
+ )}
825
+ style={{ touchAction: 'none' }} // Keep touchAction none for the container
826
+ // Attach unified handler to all interaction events on the container
827
+ // This helps capture mouseleave correctly even if the video element itself isn't hovered
828
+ onMouseDown={handleInteraction}
829
+ onMouseMove={handleInteraction}
830
+ onMouseUp={handleInteraction}
831
+ onMouseLeave={handleInteraction} // Handle mouse leaving the container
832
+ onTouchStart={handleInteraction}
833
+ onTouchMove={handleInteraction}
834
+ onTouchEnd={handleInteraction}
835
+ onTouchCancel={handleInteraction}
836
+ >
837
+ <video
838
+ ref={videoRef}
839
+ className="rc-video" // Use custom CSS class
840
+ autoPlay
841
+ playsInline
842
+ muted
843
+ tabIndex={0} // Make it focusable
844
+ style={{ outline: 'none', pointerEvents: 'none' }}
845
+ onKeyDown={handleKeyboard}
846
+ onKeyUp={handleKeyboard}
847
+ onClick={handleVideoClick}
848
+ onFocus={() => {
849
+ if (videoRef.current) {
850
+ videoRef.current.style.outline = 'none';
851
+ }
852
+ }}
853
+ onBlur={() => {
854
+ if (videoRef.current) {
855
+ videoRef.current.style.outline = 'none';
856
+ }
857
+ }}
858
+ />
859
+ {!isConnected && (
860
+ <div className="rc-placeholder-wrapper">
861
+ <div className="rc-spinner"></div>
862
+ <p className="rc-placeholder-content">Connecting...</p>
863
+ </div>
864
+ )}
865
+ </div>
866
+ );
867
+ },
868
+ );