@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.
- package/README.md +9 -0
- package/dist/components/remote-control.d.ts +31 -0
- package/dist/core/constants.d.ts +232 -0
- package/dist/core/webrtc-messages.d.ts +3 -0
- package/dist/index.cjs +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +735 -0
- package/package.json +45 -0
- package/src/components/remote-control.css +60 -0
- package/src/components/remote-control.tsx +868 -0
- package/src/core/constants.ts +342 -0
- package/src/core/webrtc-messages.ts +100 -0
- package/src/index.ts +1 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +26 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +28 -0
|
@@ -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
|
+
);
|