serve-emul 0.0.4

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +201 -0
  3. package/README.md +196 -0
  4. package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
  5. package/dist/ui/assets/index-CyUIa9dV.js +42 -0
  6. package/dist/ui/index.html +13 -0
  7. package/package.json +67 -0
  8. package/scripts/fetch-scrcpy.ts +28 -0
  9. package/scripts/release.ts +136 -0
  10. package/src/accessibility.ts +88 -0
  11. package/src/adb.ts +209 -0
  12. package/src/app-info.ts +114 -0
  13. package/src/app-management.ts +150 -0
  14. package/src/cli.ts +149 -0
  15. package/src/emulator.ts +229 -0
  16. package/src/input.ts +258 -0
  17. package/src/location.ts +135 -0
  18. package/src/route-playback.ts +359 -0
  19. package/src/scrcpy.ts +466 -0
  20. package/src/server.ts +1260 -0
  21. package/src/session-recorder.ts +149 -0
  22. package/src/ui/app.tsx +111 -0
  23. package/src/ui/components/accessibility-panel.tsx +113 -0
  24. package/src/ui/components/app-management-panel.tsx +256 -0
  25. package/src/ui/components/control-bar.tsx +24 -0
  26. package/src/ui/components/device-panel.tsx +532 -0
  27. package/src/ui/components/device-stream.tsx +142 -0
  28. package/src/ui/components/location-panel.tsx +584 -0
  29. package/src/ui/components/logcat-panel.tsx +100 -0
  30. package/src/ui/components/session-panel.tsx +127 -0
  31. package/src/ui/components/status-bar.tsx +19 -0
  32. package/src/ui/index.html +12 -0
  33. package/src/ui/lib/h264.ts +35 -0
  34. package/src/ui/lib/use-stream.ts +368 -0
  35. package/src/ui/main.tsx +7 -0
  36. package/src/ui/styles.css +708 -0
  37. package/src/ui/tsconfig.json +17 -0
  38. package/src/update-check.ts +93 -0
  39. package/vendor/scrcpy-server-v2.7 +0 -0
  40. package/vendor/scrcpy-server-v3.1 +0 -0
  41. package/vendor/scrcpy-server-v3.3.4 +0 -0
  42. package/vendor/scrcpy-server-v4.0 +0 -0
@@ -0,0 +1,532 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+
3
+ type GridDeviceKind = "physical" | "emulator" | "avd";
4
+
5
+ type GridDevice = {
6
+ id: string;
7
+ kind: GridDeviceKind;
8
+ serial: string | null;
9
+ avd: string | null;
10
+ name: string;
11
+ state: string;
12
+ current: boolean;
13
+ canSelect: boolean;
14
+ canStart: boolean;
15
+ canStop: boolean;
16
+ };
17
+
18
+ type DeviceGridResponse = {
19
+ ok?: boolean;
20
+ currentSerial?: string;
21
+ sessionStatus?: "streaming" | "stopped" | "error";
22
+ devices?: GridDevice[];
23
+ error?: string;
24
+ };
25
+
26
+ type Orientation = "auto" | "portrait" | "landscape";
27
+ type OrientationResponse = {
28
+ ok?: boolean;
29
+ orientation?: { orientation?: Orientation | "unknown"; raw?: string };
30
+ error?: string;
31
+ };
32
+
33
+ type NightMode = "auto" | "dark" | "light";
34
+ type NightModeResponse = {
35
+ ok?: boolean;
36
+ nightMode?: { mode?: NightMode | "unknown"; raw?: string };
37
+ error?: string;
38
+ };
39
+
40
+ type FontScaleResponse = {
41
+ ok?: boolean;
42
+ fontScale?: { scale?: number; raw?: string };
43
+ error?: string;
44
+ };
45
+
46
+ type NetworkResponse = {
47
+ ok?: boolean;
48
+ network?: {
49
+ enabled?: boolean | null;
50
+ wifi?: "enabled" | "disabled" | "unknown";
51
+ mobileData?: "enabled" | "disabled" | "unknown";
52
+ };
53
+ error?: string;
54
+ };
55
+
56
+ type BusyAction = "select" | "start" | "stop";
57
+ const FONT_SCALE_PRESETS = [0.85, 1, 1.15, 1.3, 1.5] as const;
58
+
59
+ export function DevicePanel() {
60
+ const [devices, setDevices] = useState<GridDevice[]>([]);
61
+ const [status, setStatus] = useState("Loading...");
62
+ const [sessionStatus, setSessionStatus] = useState<DeviceGridResponse["sessionStatus"]>("streaming");
63
+ const [query, setQuery] = useState("");
64
+ const [busy, setBusy] = useState<Record<string, BusyAction | undefined>>({});
65
+
66
+ const refresh = useCallback(async () => {
67
+ try {
68
+ const res = await fetch("/api/device-grid", { cache: "no-store" });
69
+ const json = await res.json() as DeviceGridResponse;
70
+ if (!json.ok || !json.devices) {
71
+ setDevices([]);
72
+ setStatus(json.error || "Unavailable");
73
+ return;
74
+ }
75
+ setDevices(json.devices);
76
+ setSessionStatus(json.sessionStatus ?? "streaming");
77
+ const running = json.devices.filter((device) => device.serial && device.state === "device").length;
78
+ setStatus(`${running}/${json.devices.length} ready`);
79
+ } catch (err) {
80
+ setDevices([]);
81
+ setStatus(err instanceof Error ? err.message : String(err));
82
+ }
83
+ }, []);
84
+
85
+ const runDeviceAction = useCallback(
86
+ async (device: GridDevice, action: BusyAction) => {
87
+ setBusy((current) => ({ ...current, [device.id]: action }));
88
+ setStatus(action === "select" ? "Switching..." : action === "start" ? "Starting..." : "Stopping...");
89
+ try {
90
+ const endpoint =
91
+ action === "select"
92
+ ? "/api/devices/select"
93
+ : action === "start"
94
+ ? "/api/avds/start"
95
+ : "/api/avds/stop";
96
+ const body =
97
+ action === "select"
98
+ ? { serial: device.serial }
99
+ : action === "start"
100
+ ? { avd: device.avd ?? device.name }
101
+ : { serial: device.serial, avd: device.avd ?? undefined };
102
+ const res = await fetch(endpoint, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify(body),
106
+ });
107
+ const json = await res.json() as { ok?: boolean; error?: string };
108
+ if (!json.ok) throw new Error(json.error || "Action failed");
109
+ await refresh();
110
+ } catch (err) {
111
+ setStatus(err instanceof Error ? err.message : String(err));
112
+ } finally {
113
+ setBusy((current) => {
114
+ const next = { ...current };
115
+ delete next[device.id];
116
+ return next;
117
+ });
118
+ }
119
+ },
120
+ [refresh],
121
+ );
122
+
123
+ const filtered = useMemo(() => {
124
+ const needle = query.trim().replace(/^\/+/, "").toLowerCase();
125
+ if (!needle) return devices;
126
+ return devices.filter((device) =>
127
+ [device.name, device.serial ?? "", device.avd ?? "", device.kind, device.state]
128
+ .join(" ")
129
+ .toLowerCase()
130
+ .includes(needle),
131
+ );
132
+ }, [devices, query]);
133
+
134
+ useEffect(() => {
135
+ void refresh();
136
+ const timer = setInterval(() => {
137
+ void refresh();
138
+ }, 3000);
139
+ return () => clearInterval(timer);
140
+ }, [refresh]);
141
+
142
+ return (
143
+ <section className="device-panel">
144
+ <div className="panel-heading">
145
+ <h2>Devices</h2>
146
+ <div className="location-status">{status}</div>
147
+ </div>
148
+
149
+ <div className="device-search">
150
+ <input
151
+ value={query}
152
+ onChange={(event) => setQuery(event.target.value)}
153
+ placeholder="Search devices and AVDs"
154
+ />
155
+ {query ? <button onClick={() => setQuery("")}>Clear</button> : null}
156
+ </div>
157
+
158
+ <div className="device-list android-grid-list">
159
+ {filtered.length === 0 ? (
160
+ <div className="device-empty">{query ? "No matching Android targets." : "No Android targets found."}</div>
161
+ ) : (
162
+ filtered.map((device) => (
163
+ <DeviceRow
164
+ key={device.id}
165
+ device={device}
166
+ sessionStatus={sessionStatus}
167
+ busy={busy[device.id]}
168
+ onSelect={() => void runDeviceAction(device, "select")}
169
+ onStart={() => void runDeviceAction(device, "start")}
170
+ onStop={() => void runDeviceAction(device, "stop")}
171
+ />
172
+ ))
173
+ )}
174
+ </div>
175
+
176
+ <button onClick={() => void refresh()}>Refresh Devices</button>
177
+ </section>
178
+ );
179
+ }
180
+
181
+ export function OrientationPanel() {
182
+ const [orientation, setOrientation] = useState<Orientation | "unknown">("unknown");
183
+ const [orientationStatus, setOrientationStatus] = useState("Loading...");
184
+
185
+ const refreshOrientation = useCallback(async () => {
186
+ try {
187
+ const res = await fetch("/api/orientation", { cache: "no-store" });
188
+ const json = await res.json() as OrientationResponse;
189
+ if (!json.ok || !json.orientation) {
190
+ setOrientation("unknown");
191
+ setOrientationStatus(json.error || "Unavailable");
192
+ return;
193
+ }
194
+ const next = json.orientation.orientation ?? "unknown";
195
+ setOrientation(next);
196
+ setOrientationStatus(next === "unknown" ? json.orientation.raw || "Unknown" : next);
197
+ } catch (err) {
198
+ setOrientation("unknown");
199
+ setOrientationStatus(err instanceof Error ? err.message : String(err));
200
+ }
201
+ }, []);
202
+
203
+ const setDeviceOrientation = useCallback(async (next: Orientation) => {
204
+ setOrientationStatus("Applying...");
205
+ try {
206
+ const res = await fetch("/api/orientation", {
207
+ method: "POST",
208
+ headers: { "Content-Type": "application/json" },
209
+ body: JSON.stringify({ orientation: next }),
210
+ });
211
+ const json = await res.json() as OrientationResponse;
212
+ if (!json.ok || !json.orientation) {
213
+ setOrientationStatus(json.error || "Failed");
214
+ return;
215
+ }
216
+ const applied = json.orientation.orientation ?? "unknown";
217
+ setOrientation(applied);
218
+ setOrientationStatus(applied === "unknown" ? json.orientation.raw || "Unknown" : applied);
219
+ } catch (err) {
220
+ setOrientationStatus(err instanceof Error ? err.message : String(err));
221
+ }
222
+ }, []);
223
+
224
+ useEffect(() => {
225
+ void refreshOrientation();
226
+ const timer = setInterval(() => void refreshOrientation(), 3000);
227
+ return () => clearInterval(timer);
228
+ }, [refreshOrientation]);
229
+
230
+ return (
231
+ <section className="tool-panel orientation-panel">
232
+ <div className="panel-heading">
233
+ <h2>Orientation</h2>
234
+ <div className="location-status">{orientationStatus}</div>
235
+ </div>
236
+ <div className="segmented-row">
237
+ <button
238
+ className={orientation === "portrait" ? "selected" : ""}
239
+ onClick={() => void setDeviceOrientation("portrait")}
240
+ >
241
+ Portrait
242
+ </button>
243
+ <button
244
+ className={orientation === "landscape" ? "selected" : ""}
245
+ onClick={() => void setDeviceOrientation("landscape")}
246
+ >
247
+ Landscape
248
+ </button>
249
+ <button
250
+ className={orientation === "auto" ? "selected" : ""}
251
+ onClick={() => void setDeviceOrientation("auto")}
252
+ >
253
+ Auto
254
+ </button>
255
+ </div>
256
+ </section>
257
+ );
258
+ }
259
+
260
+ export function NightModePanel() {
261
+ const [nightMode, setNightMode] = useState<NightMode | "unknown">("unknown");
262
+ const [nightModeStatus, setNightModeStatus] = useState("Loading...");
263
+
264
+ const refreshNightMode = useCallback(async () => {
265
+ try {
266
+ const res = await fetch("/api/night-mode", { cache: "no-store" });
267
+ const json = await res.json() as NightModeResponse;
268
+ if (!json.ok || !json.nightMode) {
269
+ setNightMode("unknown");
270
+ setNightModeStatus(json.error || "Unavailable");
271
+ return;
272
+ }
273
+ const next = json.nightMode.mode ?? "unknown";
274
+ setNightMode(next);
275
+ setNightModeStatus(next === "unknown" ? json.nightMode.raw || "Unknown" : next);
276
+ } catch (err) {
277
+ setNightMode("unknown");
278
+ setNightModeStatus(err instanceof Error ? err.message : String(err));
279
+ }
280
+ }, []);
281
+
282
+ const setDeviceNightMode = useCallback(async (next: NightMode) => {
283
+ setNightModeStatus("Applying...");
284
+ try {
285
+ const res = await fetch("/api/night-mode", {
286
+ method: "POST",
287
+ headers: { "Content-Type": "application/json" },
288
+ body: JSON.stringify({ mode: next }),
289
+ });
290
+ const json = await res.json() as NightModeResponse;
291
+ if (!json.ok || !json.nightMode) {
292
+ setNightModeStatus(json.error || "Failed");
293
+ return;
294
+ }
295
+ const applied = json.nightMode.mode ?? "unknown";
296
+ setNightMode(applied);
297
+ setNightModeStatus(applied === "unknown" ? json.nightMode.raw || "Unknown" : applied);
298
+ } catch (err) {
299
+ setNightModeStatus(err instanceof Error ? err.message : String(err));
300
+ }
301
+ }, []);
302
+
303
+ useEffect(() => {
304
+ void refreshNightMode();
305
+ const timer = setInterval(() => void refreshNightMode(), 3000);
306
+ return () => clearInterval(timer);
307
+ }, [refreshNightMode]);
308
+
309
+ return (
310
+ <section className="tool-panel night-mode-panel">
311
+ <div className="panel-heading">
312
+ <h2>Theme</h2>
313
+ <div className="location-status">{nightModeStatus}</div>
314
+ </div>
315
+ <div className="segmented-row">
316
+ <button
317
+ className={nightMode === "dark" ? "selected" : ""}
318
+ onClick={() => void setDeviceNightMode("dark")}
319
+ >
320
+ Dark
321
+ </button>
322
+ <button
323
+ className={nightMode === "light" ? "selected" : ""}
324
+ onClick={() => void setDeviceNightMode("light")}
325
+ >
326
+ Light
327
+ </button>
328
+ <button
329
+ className={nightMode === "auto" ? "selected" : ""}
330
+ onClick={() => void setDeviceNightMode("auto")}
331
+ >
332
+ Auto
333
+ </button>
334
+ </div>
335
+ </section>
336
+ );
337
+ }
338
+
339
+ export function FontScalePanel() {
340
+ const [fontScale, setFontScale] = useState<number | null>(null);
341
+ const [fontScaleStatus, setFontScaleStatus] = useState("Loading...");
342
+
343
+ const refreshFontScale = useCallback(async () => {
344
+ try {
345
+ const res = await fetch("/api/font-scale", { cache: "no-store" });
346
+ const json = await res.json() as FontScaleResponse;
347
+ if (!json.ok || !json.fontScale || typeof json.fontScale.scale !== "number") {
348
+ setFontScale(null);
349
+ setFontScaleStatus(json.error || "Unavailable");
350
+ return;
351
+ }
352
+ setFontScale(json.fontScale.scale);
353
+ setFontScaleStatus(`${Math.round(json.fontScale.scale * 100)}%`);
354
+ } catch (err) {
355
+ setFontScale(null);
356
+ setFontScaleStatus(err instanceof Error ? err.message : String(err));
357
+ }
358
+ }, []);
359
+
360
+ const setDeviceFontScale = useCallback(async (next: number) => {
361
+ setFontScaleStatus("Applying...");
362
+ try {
363
+ const res = await fetch("/api/font-scale", {
364
+ method: "POST",
365
+ headers: { "Content-Type": "application/json" },
366
+ body: JSON.stringify({ scale: next }),
367
+ });
368
+ const json = await res.json() as FontScaleResponse;
369
+ if (!json.ok || !json.fontScale || typeof json.fontScale.scale !== "number") {
370
+ setFontScaleStatus(json.error || "Failed");
371
+ return;
372
+ }
373
+ setFontScale(json.fontScale.scale);
374
+ setFontScaleStatus(`${Math.round(json.fontScale.scale * 100)}%`);
375
+ } catch (err) {
376
+ setFontScaleStatus(err instanceof Error ? err.message : String(err));
377
+ }
378
+ }, []);
379
+
380
+ useEffect(() => {
381
+ void refreshFontScale();
382
+ const timer = setInterval(() => void refreshFontScale(), 3000);
383
+ return () => clearInterval(timer);
384
+ }, [refreshFontScale]);
385
+
386
+ return (
387
+ <section className="tool-panel font-scale-panel">
388
+ <div className="panel-heading">
389
+ <h2>Font Size</h2>
390
+ <div className="location-status">{fontScaleStatus}</div>
391
+ </div>
392
+ <div className="font-scale-row">
393
+ {FONT_SCALE_PRESETS.map((scale) => (
394
+ <button
395
+ key={scale}
396
+ className={fontScale !== null && Math.abs(fontScale - scale) < 0.01 ? "selected" : ""}
397
+ onClick={() => void setDeviceFontScale(scale)}
398
+ >
399
+ {Math.round(scale * 100)}%
400
+ </button>
401
+ ))}
402
+ </div>
403
+ </section>
404
+ );
405
+ }
406
+
407
+ function networkLabel(network: NonNullable<NetworkResponse["network"]>): string {
408
+ const state = network.enabled === true ? "on" : network.enabled === false ? "off" : "unknown";
409
+ const wifi = network.wifi && network.wifi !== "unknown" ? `wifi ${network.wifi}` : "wifi ?";
410
+ const mobileData =
411
+ network.mobileData && network.mobileData !== "unknown" ? `data ${network.mobileData}` : "data ?";
412
+ return `${state} (${wifi}, ${mobileData})`;
413
+ }
414
+
415
+ export function NetworkPanel() {
416
+ const [enabled, setEnabled] = useState<boolean | null>(null);
417
+ const [networkStatus, setNetworkStatus] = useState("Loading...");
418
+
419
+ const refreshNetwork = useCallback(async () => {
420
+ try {
421
+ const res = await fetch("/api/network", { cache: "no-store" });
422
+ const json = await res.json() as NetworkResponse;
423
+ if (!json.ok || !json.network) {
424
+ setEnabled(null);
425
+ setNetworkStatus(json.error || "Unavailable");
426
+ return;
427
+ }
428
+ setEnabled(json.network.enabled ?? null);
429
+ setNetworkStatus(networkLabel(json.network));
430
+ } catch (err) {
431
+ setEnabled(null);
432
+ setNetworkStatus(err instanceof Error ? err.message : String(err));
433
+ }
434
+ }, []);
435
+
436
+ const setDeviceNetwork = useCallback(async (next: boolean) => {
437
+ setNetworkStatus("Applying...");
438
+ try {
439
+ const res = await fetch("/api/network", {
440
+ method: "POST",
441
+ headers: { "Content-Type": "application/json" },
442
+ body: JSON.stringify({ enabled: next }),
443
+ });
444
+ const json = await res.json() as NetworkResponse;
445
+ if (!json.ok || !json.network) {
446
+ setNetworkStatus(json.error || "Failed");
447
+ return;
448
+ }
449
+ setEnabled(json.network.enabled ?? null);
450
+ setNetworkStatus(networkLabel(json.network));
451
+ } catch (err) {
452
+ setNetworkStatus(err instanceof Error ? err.message : String(err));
453
+ }
454
+ }, []);
455
+
456
+ useEffect(() => {
457
+ void refreshNetwork();
458
+ const timer = setInterval(() => void refreshNetwork(), 5000);
459
+ return () => clearInterval(timer);
460
+ }, [refreshNetwork]);
461
+
462
+ return (
463
+ <section className="tool-panel network-panel">
464
+ <div className="panel-heading">
465
+ <h2>Network</h2>
466
+ <div className="location-status">{networkStatus}</div>
467
+ </div>
468
+ <div className="segmented-row network-row">
469
+ <button
470
+ className={enabled === true ? "selected" : ""}
471
+ onClick={() => void setDeviceNetwork(true)}
472
+ >
473
+ On
474
+ </button>
475
+ <button
476
+ className={enabled === false ? "selected" : ""}
477
+ onClick={() => void setDeviceNetwork(false)}
478
+ >
479
+ Off
480
+ </button>
481
+ </div>
482
+ </section>
483
+ );
484
+ }
485
+
486
+ function DeviceRow({
487
+ device,
488
+ sessionStatus,
489
+ busy,
490
+ onSelect,
491
+ onStart,
492
+ onStop,
493
+ }: {
494
+ device: GridDevice;
495
+ sessionStatus: DeviceGridResponse["sessionStatus"];
496
+ busy: BusyAction | undefined;
497
+ onSelect: () => void;
498
+ onStart: () => void;
499
+ onStop: () => void;
500
+ }) {
501
+ const isLiveCurrent = device.current && sessionStatus === "streaming";
502
+ const status = device.current ? (sessionStatus ?? "streaming") : device.state;
503
+ const title = device.kind === "avd" ? "AVD" : device.kind === "emulator" ? "EMU" : "USB";
504
+
505
+ return (
506
+ <div className={device.current ? "device-row grid-device-row current" : "device-row grid-device-row"}>
507
+ <button
508
+ type="button"
509
+ className="device-row-main"
510
+ disabled={!device.canSelect || Boolean(busy) || isLiveCurrent}
511
+ onClick={onSelect}
512
+ >
513
+ <span className="device-kind" title={device.kind}>{title}</span>
514
+ <span className="device-name">{device.name}</span>
515
+ <span className="device-subtitle">{device.serial ?? device.avd ?? "not running"}</span>
516
+ </button>
517
+ <div className="device-row-actions">
518
+ <code>{busy ?? status}</code>
519
+ {device.canStart ? (
520
+ <button disabled={Boolean(busy)} onClick={onStart}>
521
+ Start
522
+ </button>
523
+ ) : null}
524
+ {device.canStop ? (
525
+ <button disabled={Boolean(busy)} onClick={onStop}>
526
+ Stop
527
+ </button>
528
+ ) : null}
529
+ </div>
530
+ </div>
531
+ );
532
+ }
@@ -0,0 +1,142 @@
1
+ import { useRef } from "react";
2
+ import type { PointerEvent, RefObject } from "react";
3
+ import type { Sender } from "../lib/use-stream";
4
+ import type { AccessibilityNode } from "./accessibility-panel";
5
+
6
+ type Props = {
7
+ canvasRef: RefObject<HTMLCanvasElement>;
8
+ send: Sender;
9
+ accessibilityNodes?: AccessibilityNode[];
10
+ accessibilityEnabled?: boolean;
11
+ highlightedAccessibilityId?: string | null;
12
+ deviceSize?: { width: number; height: number } | null;
13
+ };
14
+
15
+ type Point = { x: number; y: number };
16
+
17
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
18
+
19
+ export function DeviceStream({
20
+ canvasRef,
21
+ send,
22
+ accessibilityNodes = [],
23
+ accessibilityEnabled = false,
24
+ highlightedAccessibilityId = null,
25
+ deviceSize = null,
26
+ }: Props) {
27
+ const activeRef = useRef<{ id: number; x: number; y: number } | null>(null);
28
+ const pendingMoveRef = useRef<Point | null>(null);
29
+ const moveRafRef = useRef(0);
30
+
31
+ const pointFromClient = (clientX: number, clientY: number): Point | null => {
32
+ const canvas = canvasRef.current;
33
+ if (!canvas) return null;
34
+ const r = canvas.getBoundingClientRect();
35
+ return {
36
+ x: clamp01((clientX - r.left) / r.width),
37
+ y: clamp01((clientY - r.top) / r.height),
38
+ };
39
+ };
40
+
41
+ const norm = (e: PointerEvent<HTMLCanvasElement>): Point | null =>
42
+ pointFromClient(e.clientX, e.clientY);
43
+
44
+ const sendTouch = (action: "down" | "move" | "up", p: Point, pointerId: number) => {
45
+ send({ type: "touch", action, x: p.x, y: p.y, pointerId }, false);
46
+ };
47
+
48
+ const flushMove = () => {
49
+ moveRafRef.current = 0;
50
+ const active = activeRef.current;
51
+ const next = pendingMoveRef.current;
52
+ if (!active || !next) return;
53
+ pendingMoveRef.current = null;
54
+ active.x = next.x;
55
+ active.y = next.y;
56
+ sendTouch("move", next, active.id);
57
+ };
58
+
59
+ const queueMove = (p: Point) => {
60
+ pendingMoveRef.current = p;
61
+ if (!moveRafRef.current) {
62
+ moveRafRef.current = requestAnimationFrame(flushMove);
63
+ }
64
+ };
65
+
66
+ const onPointerDown = (e: PointerEvent<HTMLCanvasElement>) => {
67
+ if (e.pointerType === "mouse" && e.button !== 0) return;
68
+ if (activeRef.current) return;
69
+ e.preventDefault();
70
+ canvasRef.current?.setPointerCapture(e.pointerId);
71
+ const p = norm(e);
72
+ if (!p) return;
73
+ activeRef.current = { id: e.pointerId, ...p };
74
+ pendingMoveRef.current = null;
75
+ sendTouch("down", p, e.pointerId);
76
+ };
77
+
78
+ const onPointerMove = (e: PointerEvent<HTMLCanvasElement>) => {
79
+ const active = activeRef.current;
80
+ if (!active || e.pointerId !== active.id) return;
81
+ e.preventDefault();
82
+ const native = e.nativeEvent;
83
+ const coalesced =
84
+ typeof native.getCoalescedEvents === "function" ? native.getCoalescedEvents() : null;
85
+ if (coalesced && coalesced.length > 0) {
86
+ const last = coalesced[coalesced.length - 1];
87
+ const p = pointFromClient(last.clientX, last.clientY);
88
+ if (p) queueMove(p);
89
+ } else {
90
+ const p = norm(e);
91
+ if (p) queueMove(p);
92
+ }
93
+ };
94
+
95
+ const stopPointer = (e: PointerEvent<HTMLCanvasElement>) => {
96
+ const active = activeRef.current;
97
+ if (!active || e.pointerId !== active.id) return;
98
+ e.preventDefault();
99
+ if (moveRafRef.current) {
100
+ cancelAnimationFrame(moveRafRef.current);
101
+ flushMove();
102
+ }
103
+ const up = norm(e);
104
+ if (up) sendTouch("up", up, active.id);
105
+ try {
106
+ canvasRef.current?.releasePointerCapture(active.id);
107
+ } catch {}
108
+ activeRef.current = null;
109
+ pendingMoveRef.current = null;
110
+ };
111
+
112
+ return (
113
+ <div className="stream-surface">
114
+ <canvas
115
+ ref={canvasRef}
116
+ onPointerDown={onPointerDown}
117
+ onPointerMove={onPointerMove}
118
+ onPointerUp={stopPointer}
119
+ onPointerCancel={stopPointer}
120
+ onContextMenu={(e) => e.preventDefault()}
121
+ />
122
+ {accessibilityEnabled && deviceSize && (
123
+ <div className="ax-overlay" aria-hidden="true">
124
+ {accessibilityNodes.map((node) => {
125
+ const left = (node.bounds.left / deviceSize.width) * 100;
126
+ const top = (node.bounds.top / deviceSize.height) * 100;
127
+ const width = ((node.bounds.right - node.bounds.left) / deviceSize.width) * 100;
128
+ const height = ((node.bounds.bottom - node.bounds.top) / deviceSize.height) * 100;
129
+ const active = node.id === highlightedAccessibilityId;
130
+ return (
131
+ <div
132
+ key={node.id}
133
+ className={active ? "ax-box active" : "ax-box"}
134
+ style={{ left: `${left}%`, top: `${top}%`, width: `${width}%`, height: `${height}%` }}
135
+ />
136
+ );
137
+ })}
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }