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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +201 -0
- package/README.md +196 -0
- package/dist/ui/assets/index-Cm5-Tjhs.css +1 -0
- package/dist/ui/assets/index-CyUIa9dV.js +42 -0
- package/dist/ui/index.html +13 -0
- package/package.json +67 -0
- package/scripts/fetch-scrcpy.ts +28 -0
- package/scripts/release.ts +136 -0
- package/src/accessibility.ts +88 -0
- package/src/adb.ts +209 -0
- package/src/app-info.ts +114 -0
- package/src/app-management.ts +150 -0
- package/src/cli.ts +149 -0
- package/src/emulator.ts +229 -0
- package/src/input.ts +258 -0
- package/src/location.ts +135 -0
- package/src/route-playback.ts +359 -0
- package/src/scrcpy.ts +466 -0
- package/src/server.ts +1260 -0
- package/src/session-recorder.ts +149 -0
- package/src/ui/app.tsx +111 -0
- package/src/ui/components/accessibility-panel.tsx +113 -0
- package/src/ui/components/app-management-panel.tsx +256 -0
- package/src/ui/components/control-bar.tsx +24 -0
- package/src/ui/components/device-panel.tsx +532 -0
- package/src/ui/components/device-stream.tsx +142 -0
- package/src/ui/components/location-panel.tsx +584 -0
- package/src/ui/components/logcat-panel.tsx +100 -0
- package/src/ui/components/session-panel.tsx +127 -0
- package/src/ui/components/status-bar.tsx +19 -0
- package/src/ui/index.html +12 -0
- package/src/ui/lib/h264.ts +35 -0
- package/src/ui/lib/use-stream.ts +368 -0
- package/src/ui/main.tsx +7 -0
- package/src/ui/styles.css +708 -0
- package/src/ui/tsconfig.json +17 -0
- package/src/update-check.ts +93 -0
- package/vendor/scrcpy-server-v2.7 +0 -0
- package/vendor/scrcpy-server-v3.1 +0 -0
- package/vendor/scrcpy-server-v3.3.4 +0 -0
- package/vendor/scrcpy-server-v4.0 +0 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { PointerEvent } from "react";
|
|
3
|
+
|
|
4
|
+
type Point = { x: number; y: number };
|
|
5
|
+
type LocationPoint = { latitude: number; longitude: number; altitude?: number };
|
|
6
|
+
type Tile = { key: string; x: number; y: number; left: number; top: number; wrappedX: number };
|
|
7
|
+
type RouteSnapshot = {
|
|
8
|
+
status: "idle" | "running" | "paused" | "completed" | "error";
|
|
9
|
+
waypointCount: number;
|
|
10
|
+
totalMeters: number;
|
|
11
|
+
progressMeters: number;
|
|
12
|
+
speedKph: number;
|
|
13
|
+
multiplier: number;
|
|
14
|
+
loop: boolean;
|
|
15
|
+
lastError: string | null;
|
|
16
|
+
currentLocation: (LocationPoint & { appliedAt: string }) | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TILE_SIZE = 256;
|
|
20
|
+
const MIN_ZOOM = 2;
|
|
21
|
+
const MAX_ZOOM = 18;
|
|
22
|
+
const DEFAULT_LOCATION: LocationPoint = { latitude: 37.5665, longitude: 126.978 };
|
|
23
|
+
const DEFAULT_SIZE = { width: 320, height: 220 };
|
|
24
|
+
|
|
25
|
+
const PRESETS: (LocationPoint & { label: string })[] = [
|
|
26
|
+
{ label: "Seoul", latitude: 37.5665, longitude: 126.978 },
|
|
27
|
+
{ label: "London", latitude: 51.5072, longitude: -0.1276 },
|
|
28
|
+
{ label: "SF", latitude: 37.7749, longitude: -122.4194 },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function clamp(n: number, min: number, max: number): number {
|
|
32
|
+
return Math.min(max, Math.max(min, n));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function wrapLongitude(longitude: number): number {
|
|
36
|
+
return ((((longitude + 180) % 360) + 360) % 360) - 180;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function worldSize(zoom: number): number {
|
|
40
|
+
return TILE_SIZE * 2 ** zoom;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function project(location: LocationPoint, zoom: number): Point {
|
|
44
|
+
const sin = Math.sin((clamp(location.latitude, -85.05112878, 85.05112878) * Math.PI) / 180);
|
|
45
|
+
const size = worldSize(zoom);
|
|
46
|
+
return {
|
|
47
|
+
x: ((wrapLongitude(location.longitude) + 180) / 360) * size,
|
|
48
|
+
y: (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * size,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unproject(point: Point, zoom: number): LocationPoint {
|
|
53
|
+
const size = worldSize(zoom);
|
|
54
|
+
const lng = (point.x / size) * 360 - 180;
|
|
55
|
+
const n = Math.PI - (2 * Math.PI * point.y) / size;
|
|
56
|
+
const lat = (180 / Math.PI) * Math.atan(Math.sinh(n));
|
|
57
|
+
return {
|
|
58
|
+
latitude: clamp(lat, -85.05112878, 85.05112878),
|
|
59
|
+
longitude: wrapLongitude(lng),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatCoord(n: number): string {
|
|
64
|
+
return n.toFixed(6);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizedTileX(x: number, zoom: number): number {
|
|
68
|
+
const count = 2 ** zoom;
|
|
69
|
+
return ((x % count) + count) % count;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tileUrl(tile: Tile, zoom: number): string {
|
|
73
|
+
return `https://tile.openstreetmap.org/${zoom}/${tile.wrappedX}/${tile.y}.png`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function waypointFromRecord(value: unknown): LocationPoint | null {
|
|
77
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
78
|
+
const record = value as Record<string, unknown>;
|
|
79
|
+
const latitude = Number(record.latitude ?? record.lat);
|
|
80
|
+
const longitude = Number(record.longitude ?? record.lng ?? record.lon);
|
|
81
|
+
const altitudeRaw = record.altitude ?? record.alt ?? record.ele;
|
|
82
|
+
const altitude = altitudeRaw === undefined || altitudeRaw === null ? undefined : Number(altitudeRaw);
|
|
83
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
|
84
|
+
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return null;
|
|
85
|
+
if (altitude !== undefined && !Number.isFinite(altitude)) return null;
|
|
86
|
+
return {
|
|
87
|
+
latitude,
|
|
88
|
+
longitude,
|
|
89
|
+
...(altitude === undefined ? {} : { altitude }),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function waypointsFromCoordinates(coordinates: unknown): LocationPoint[] {
|
|
94
|
+
if (!Array.isArray(coordinates)) return [];
|
|
95
|
+
if (coordinates.length >= 2 && typeof coordinates[0] === "number" && typeof coordinates[1] === "number") {
|
|
96
|
+
const longitude = coordinates[0];
|
|
97
|
+
const latitude = coordinates[1];
|
|
98
|
+
const altitude = typeof coordinates[2] === "number" ? coordinates[2] : undefined;
|
|
99
|
+
return latitude >= -90 && latitude <= 90 && longitude >= -180 && longitude <= 180
|
|
100
|
+
? [{ latitude, longitude, ...(altitude === undefined ? {} : { altitude }) }]
|
|
101
|
+
: [];
|
|
102
|
+
}
|
|
103
|
+
return coordinates.flatMap(waypointsFromCoordinates);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function waypointsFromGeoJson(value: unknown): LocationPoint[] {
|
|
107
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
|
|
108
|
+
const record = value as Record<string, unknown>;
|
|
109
|
+
if (record.type === "FeatureCollection" && Array.isArray(record.features)) {
|
|
110
|
+
return record.features.flatMap(waypointsFromGeoJson);
|
|
111
|
+
}
|
|
112
|
+
if (record.type === "Feature") return waypointsFromGeoJson(record.geometry);
|
|
113
|
+
if (record.coordinates) return waypointsFromCoordinates(record.coordinates);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseJsonWaypoints(text: string): LocationPoint[] {
|
|
118
|
+
const parsed = JSON.parse(text) as unknown;
|
|
119
|
+
if (Array.isArray(parsed)) {
|
|
120
|
+
const fromArray = parsed.map(waypointFromRecord).filter((p): p is LocationPoint => Boolean(p));
|
|
121
|
+
if (fromArray.length > 0) return fromArray;
|
|
122
|
+
}
|
|
123
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
124
|
+
const waypoints = (parsed as Record<string, unknown>).waypoints;
|
|
125
|
+
if (Array.isArray(waypoints)) {
|
|
126
|
+
const fromWaypoints = waypoints.map(waypointFromRecord).filter((p): p is LocationPoint => Boolean(p));
|
|
127
|
+
if (fromWaypoints.length > 0) return fromWaypoints;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const fromGeoJson = waypointsFromGeoJson(parsed);
|
|
131
|
+
if (fromGeoJson.length > 0) return fromGeoJson;
|
|
132
|
+
throw new Error("JSON must be a waypoint array or GeoJSON route");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseXmlWaypoints(text: string, kind: "gpx" | "kml"): LocationPoint[] {
|
|
136
|
+
const doc = new DOMParser().parseFromString(text, "application/xml");
|
|
137
|
+
if (doc.querySelector("parsererror")) throw new Error(`${kind.toUpperCase()} parse failed`);
|
|
138
|
+
if (kind === "gpx") {
|
|
139
|
+
const nodes = Array.from(doc.querySelectorAll("trkpt, rtept, wpt"));
|
|
140
|
+
return nodes.flatMap((node) => {
|
|
141
|
+
const latitude = Number(node.getAttribute("lat"));
|
|
142
|
+
const longitude = Number(node.getAttribute("lon"));
|
|
143
|
+
const ele = node.querySelector("ele")?.textContent;
|
|
144
|
+
const altitude = ele ? Number(ele) : undefined;
|
|
145
|
+
return waypointFromRecord({ latitude, longitude, altitude }) ?? [];
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return Array.from(doc.querySelectorAll("coordinates")).flatMap((node) =>
|
|
149
|
+
(node.textContent ?? "")
|
|
150
|
+
.trim()
|
|
151
|
+
.split(/\s+/)
|
|
152
|
+
.flatMap((tuple) => {
|
|
153
|
+
const [longitude, latitude, altitude] = tuple.split(",").map(Number);
|
|
154
|
+
return waypointFromRecord({ latitude, longitude, altitude }) ?? [];
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseRouteText(text: string, fileName: string): LocationPoint[] {
|
|
160
|
+
const lower = fileName.toLowerCase();
|
|
161
|
+
const trimmed = text.trim();
|
|
162
|
+
if (lower.endsWith(".gpx") || (trimmed.startsWith("<") && trimmed.includes("<gpx"))) {
|
|
163
|
+
return parseXmlWaypoints(text, "gpx");
|
|
164
|
+
}
|
|
165
|
+
if (lower.endsWith(".kml") || (trimmed.startsWith("<") && trimmed.includes("<kml"))) {
|
|
166
|
+
return parseXmlWaypoints(text, "kml");
|
|
167
|
+
}
|
|
168
|
+
return parseJsonWaypoints(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatDistance(meters: number): string {
|
|
172
|
+
return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${Math.round(meters)} m`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function LocationPanel() {
|
|
176
|
+
const mapRef = useRef<HTMLDivElement>(null);
|
|
177
|
+
const fileRef = useRef<HTMLInputElement>(null);
|
|
178
|
+
const dragRef = useRef<{ start: Point; center: Point; moved: boolean } | null>(null);
|
|
179
|
+
const [size, setSize] = useState(DEFAULT_SIZE);
|
|
180
|
+
const [zoom, setZoom] = useState(12);
|
|
181
|
+
const [center, setCenter] = useState<LocationPoint>(DEFAULT_LOCATION);
|
|
182
|
+
const [draft, setDraft] = useState<LocationPoint>(DEFAULT_LOCATION);
|
|
183
|
+
const [latText, setLatText] = useState(formatCoord(DEFAULT_LOCATION.latitude));
|
|
184
|
+
const [lngText, setLngText] = useState(formatCoord(DEFAULT_LOCATION.longitude));
|
|
185
|
+
const [status, setStatus] = useState("Ready");
|
|
186
|
+
const [routePoints, setRoutePoints] = useState<LocationPoint[]>([]);
|
|
187
|
+
const [routeStatus, setRouteStatus] = useState<RouteSnapshot | null>(null);
|
|
188
|
+
const [speedKph, setSpeedKph] = useState("30");
|
|
189
|
+
const [multiplier, setMultiplier] = useState("1");
|
|
190
|
+
const [loop, setLoop] = useState(false);
|
|
191
|
+
|
|
192
|
+
const syncDraft = useCallback((next: LocationPoint, recenter = false) => {
|
|
193
|
+
const normalized = {
|
|
194
|
+
latitude: clamp(next.latitude, -85.05112878, 85.05112878),
|
|
195
|
+
longitude: wrapLongitude(next.longitude),
|
|
196
|
+
};
|
|
197
|
+
setDraft(normalized);
|
|
198
|
+
setLatText(formatCoord(normalized.latitude));
|
|
199
|
+
setLngText(formatCoord(normalized.longitude));
|
|
200
|
+
if (recenter) setCenter(normalized);
|
|
201
|
+
}, []);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const node = mapRef.current;
|
|
205
|
+
if (!node) return;
|
|
206
|
+
const updateSize = () => {
|
|
207
|
+
const rect = node.getBoundingClientRect();
|
|
208
|
+
setSize({
|
|
209
|
+
width: Math.max(1, Math.round(rect.width)),
|
|
210
|
+
height: Math.max(1, Math.round(rect.height)),
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
updateSize();
|
|
214
|
+
const observer = new ResizeObserver(updateSize);
|
|
215
|
+
observer.observe(node);
|
|
216
|
+
return () => observer.disconnect();
|
|
217
|
+
}, []);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
fetch("/api/location")
|
|
221
|
+
.then((r) => r.json())
|
|
222
|
+
.then((data: { location?: LocationPoint | null }) => {
|
|
223
|
+
if (data.location) syncDraft(data.location, true);
|
|
224
|
+
})
|
|
225
|
+
.catch(() => {});
|
|
226
|
+
}, [syncDraft]);
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
let cancelled = false;
|
|
230
|
+
const syncRoute = () => {
|
|
231
|
+
fetch("/api/route")
|
|
232
|
+
.then((r) => r.json() as Promise<RouteSnapshot>)
|
|
233
|
+
.then((route) => {
|
|
234
|
+
if (cancelled) return;
|
|
235
|
+
setRouteStatus(route);
|
|
236
|
+
if (route.currentLocation) syncDraft(route.currentLocation, route.status === "running");
|
|
237
|
+
if (route.lastError) setStatus(route.lastError);
|
|
238
|
+
})
|
|
239
|
+
.catch(() => {});
|
|
240
|
+
};
|
|
241
|
+
syncRoute();
|
|
242
|
+
const timer = setInterval(syncRoute, 1000);
|
|
243
|
+
return () => {
|
|
244
|
+
cancelled = true;
|
|
245
|
+
clearInterval(timer);
|
|
246
|
+
};
|
|
247
|
+
}, [syncDraft]);
|
|
248
|
+
|
|
249
|
+
const centerPixel = useMemo(() => project(center, zoom), [center, zoom]);
|
|
250
|
+
const draftPixel = useMemo(() => project(draft, zoom), [draft, zoom]);
|
|
251
|
+
|
|
252
|
+
const tiles = useMemo<Tile[]>(() => {
|
|
253
|
+
const maxTile = 2 ** zoom - 1;
|
|
254
|
+
const leftWorld = centerPixel.x - size.width / 2;
|
|
255
|
+
const topWorld = centerPixel.y - size.height / 2;
|
|
256
|
+
const startX = Math.floor(leftWorld / TILE_SIZE);
|
|
257
|
+
const endX = Math.floor((leftWorld + size.width) / TILE_SIZE);
|
|
258
|
+
const startY = clamp(Math.floor(topWorld / TILE_SIZE), 0, maxTile);
|
|
259
|
+
const endY = clamp(Math.floor((topWorld + size.height) / TILE_SIZE), 0, maxTile);
|
|
260
|
+
const out: Tile[] = [];
|
|
261
|
+
for (let y = startY; y <= endY; y++) {
|
|
262
|
+
for (let x = startX; x <= endX; x++) {
|
|
263
|
+
out.push({
|
|
264
|
+
key: `${zoom}-${x}-${y}`,
|
|
265
|
+
x,
|
|
266
|
+
y,
|
|
267
|
+
wrappedX: normalizedTileX(x, zoom),
|
|
268
|
+
left: x * TILE_SIZE - leftWorld,
|
|
269
|
+
top: y * TILE_SIZE - topWorld,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}, [centerPixel, size.height, size.width, zoom]);
|
|
275
|
+
|
|
276
|
+
const locationFromClient = (clientX: number, clientY: number): LocationPoint | null => {
|
|
277
|
+
const node = mapRef.current;
|
|
278
|
+
if (!node) return null;
|
|
279
|
+
const rect = node.getBoundingClientRect();
|
|
280
|
+
return unproject(
|
|
281
|
+
{
|
|
282
|
+
x: centerPixel.x + clientX - rect.left - rect.width / 2,
|
|
283
|
+
y: centerPixel.y + clientY - rect.top - rect.height / 2,
|
|
284
|
+
},
|
|
285
|
+
zoom,
|
|
286
|
+
);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const markerLeft = draftPixel.x - centerPixel.x + size.width / 2;
|
|
290
|
+
const markerTop = draftPixel.y - centerPixel.y + size.height / 2;
|
|
291
|
+
const routePolyline = useMemo(
|
|
292
|
+
() =>
|
|
293
|
+
routePoints
|
|
294
|
+
.map((point) => {
|
|
295
|
+
const p = project(point, zoom);
|
|
296
|
+
return `${p.x - centerPixel.x + size.width / 2},${p.y - centerPixel.y + size.height / 2}`;
|
|
297
|
+
})
|
|
298
|
+
.join(" "),
|
|
299
|
+
[centerPixel.x, centerPixel.y, routePoints, size.height, size.width, zoom],
|
|
300
|
+
);
|
|
301
|
+
const progress =
|
|
302
|
+
routeStatus && routeStatus.totalMeters > 0
|
|
303
|
+
? Math.min(100, Math.round((routeStatus.progressMeters / routeStatus.totalMeters) * 100))
|
|
304
|
+
: 0;
|
|
305
|
+
|
|
306
|
+
const applyLocation = async (location = draft) => {
|
|
307
|
+
setStatus("Setting...");
|
|
308
|
+
try {
|
|
309
|
+
const res = await fetch("/api/location", {
|
|
310
|
+
method: "POST",
|
|
311
|
+
headers: { "Content-Type": "application/json" },
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
latitude: location.latitude,
|
|
314
|
+
longitude: location.longitude,
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
const data = (await res.json()) as { ok?: boolean; error?: string };
|
|
318
|
+
if (!res.ok || !data.ok) throw new Error(data.error || "location update failed");
|
|
319
|
+
setStatus(`Applied ${formatCoord(location.latitude)}, ${formatCoord(location.longitude)}`);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const applyText = () => {
|
|
326
|
+
const latitude = Number(latText);
|
|
327
|
+
const longitude = Number(lngText);
|
|
328
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
329
|
+
setStatus("Coordinates must be numbers");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
|
333
|
+
setStatus("Coordinates are out of range");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const next = { latitude, longitude };
|
|
337
|
+
syncDraft(next, true);
|
|
338
|
+
void applyLocation(next);
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const readRouteFile = async (file: File) => {
|
|
342
|
+
setStatus("Loading route...");
|
|
343
|
+
try {
|
|
344
|
+
const points = parseRouteText(await file.text(), file.name);
|
|
345
|
+
if (points.length < 1) throw new Error("route file has no waypoints");
|
|
346
|
+
setRoutePoints(points.slice(0, 10_000));
|
|
347
|
+
syncDraft(points[0], true);
|
|
348
|
+
setStatus(`Loaded ${points.length} waypoints`);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
351
|
+
} finally {
|
|
352
|
+
if (fileRef.current) fileRef.current.value = "";
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const startRoute = async () => {
|
|
357
|
+
if (routePoints.length < 1) {
|
|
358
|
+
setStatus("Load a route first");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const speed = Number(speedKph);
|
|
362
|
+
const rate = Number(multiplier);
|
|
363
|
+
if (!Number.isFinite(speed) || speed <= 0) {
|
|
364
|
+
setStatus("Speed must be positive");
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (!Number.isFinite(rate) || rate <= 0) {
|
|
368
|
+
setStatus("Rate must be positive");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
setStatus("Starting route...");
|
|
372
|
+
try {
|
|
373
|
+
const res = await fetch("/api/route", {
|
|
374
|
+
method: "POST",
|
|
375
|
+
headers: { "Content-Type": "application/json" },
|
|
376
|
+
body: JSON.stringify({
|
|
377
|
+
waypoints: routePoints,
|
|
378
|
+
speedKph: speed,
|
|
379
|
+
multiplier: rate,
|
|
380
|
+
intervalMs: 1000,
|
|
381
|
+
loop,
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
const data = (await res.json()) as { ok?: boolean; error?: string; route?: RouteSnapshot };
|
|
385
|
+
if (!res.ok || !data.ok || !data.route) throw new Error(data.error || "route start failed");
|
|
386
|
+
setRouteStatus(data.route);
|
|
387
|
+
setStatus("Route running");
|
|
388
|
+
} catch (err) {
|
|
389
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const controlRoute = async (action: "pause" | "resume" | "stop") => {
|
|
394
|
+
try {
|
|
395
|
+
const res = await fetch("/api/route/control", {
|
|
396
|
+
method: "POST",
|
|
397
|
+
headers: { "Content-Type": "application/json" },
|
|
398
|
+
body: JSON.stringify({ action }),
|
|
399
|
+
});
|
|
400
|
+
const data = (await res.json()) as { ok?: boolean; error?: string; route?: RouteSnapshot };
|
|
401
|
+
if (!res.ok || !data.ok || !data.route) throw new Error(data.error || "route control failed");
|
|
402
|
+
setRouteStatus(data.route);
|
|
403
|
+
setStatus(action === "stop" ? "Route stopped" : `Route ${data.route.status}`);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
mapRef.current?.setPointerCapture(e.pointerId);
|
|
412
|
+
dragRef.current = {
|
|
413
|
+
start: { x: e.clientX, y: e.clientY },
|
|
414
|
+
center: centerPixel,
|
|
415
|
+
moved: false,
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
420
|
+
const drag = dragRef.current;
|
|
421
|
+
if (!drag) return;
|
|
422
|
+
const dx = e.clientX - drag.start.x;
|
|
423
|
+
const dy = e.clientY - drag.start.y;
|
|
424
|
+
if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true;
|
|
425
|
+
if (!drag.moved) return;
|
|
426
|
+
setCenter(unproject({ x: drag.center.x - dx, y: drag.center.y - dy }, zoom));
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const onPointerUp = (e: PointerEvent<HTMLDivElement>) => {
|
|
430
|
+
const drag = dragRef.current;
|
|
431
|
+
dragRef.current = null;
|
|
432
|
+
try {
|
|
433
|
+
mapRef.current?.releasePointerCapture(e.pointerId);
|
|
434
|
+
} catch {}
|
|
435
|
+
if (drag?.moved) return;
|
|
436
|
+
const next = locationFromClient(e.clientX, e.clientY);
|
|
437
|
+
if (next) syncDraft(next);
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
return (
|
|
441
|
+
<aside className="location-panel">
|
|
442
|
+
<div className="panel-heading">
|
|
443
|
+
<h2>Location</h2>
|
|
444
|
+
<div className="location-status">{status}</div>
|
|
445
|
+
</div>
|
|
446
|
+
<div
|
|
447
|
+
className="map"
|
|
448
|
+
ref={mapRef}
|
|
449
|
+
onPointerDown={onPointerDown}
|
|
450
|
+
onPointerMove={onPointerMove}
|
|
451
|
+
onPointerUp={onPointerUp}
|
|
452
|
+
onPointerCancel={() => {
|
|
453
|
+
dragRef.current = null;
|
|
454
|
+
}}
|
|
455
|
+
>
|
|
456
|
+
{tiles.map((tile) => (
|
|
457
|
+
<img
|
|
458
|
+
alt=""
|
|
459
|
+
className="map-tile"
|
|
460
|
+
draggable={false}
|
|
461
|
+
key={tile.key}
|
|
462
|
+
src={tileUrl(tile, zoom)}
|
|
463
|
+
style={{
|
|
464
|
+
left: tile.left,
|
|
465
|
+
top: tile.top,
|
|
466
|
+
}}
|
|
467
|
+
/>
|
|
468
|
+
))}
|
|
469
|
+
{routePolyline && (
|
|
470
|
+
<svg className="route-overlay" viewBox={`0 0 ${size.width} ${size.height}`}>
|
|
471
|
+
<polyline points={routePolyline} />
|
|
472
|
+
</svg>
|
|
473
|
+
)}
|
|
474
|
+
<div
|
|
475
|
+
className="map-marker"
|
|
476
|
+
style={{
|
|
477
|
+
transform: `translate(${markerLeft}px, ${markerTop}px)`,
|
|
478
|
+
}}
|
|
479
|
+
/>
|
|
480
|
+
<div className="map-attribution">© OpenStreetMap</div>
|
|
481
|
+
</div>
|
|
482
|
+
<div className="map-controls">
|
|
483
|
+
<button onClick={() => setZoom((z) => clamp(z + 1, MIN_ZOOM, MAX_ZOOM))}>+</button>
|
|
484
|
+
<button onClick={() => setZoom((z) => clamp(z - 1, MIN_ZOOM, MAX_ZOOM))}>-</button>
|
|
485
|
+
<button onClick={() => setCenter(draft)}>Center</button>
|
|
486
|
+
</div>
|
|
487
|
+
<div className="coordinate-grid">
|
|
488
|
+
<label>
|
|
489
|
+
Lat
|
|
490
|
+
<input
|
|
491
|
+
inputMode="decimal"
|
|
492
|
+
onChange={(e) => setLatText(e.currentTarget.value)}
|
|
493
|
+
value={latText}
|
|
494
|
+
/>
|
|
495
|
+
</label>
|
|
496
|
+
<label>
|
|
497
|
+
Lng
|
|
498
|
+
<input
|
|
499
|
+
inputMode="decimal"
|
|
500
|
+
onChange={(e) => setLngText(e.currentTarget.value)}
|
|
501
|
+
value={lngText}
|
|
502
|
+
/>
|
|
503
|
+
</label>
|
|
504
|
+
</div>
|
|
505
|
+
<div className="preset-row">
|
|
506
|
+
{PRESETS.map((preset) => (
|
|
507
|
+
<button
|
|
508
|
+
key={preset.label}
|
|
509
|
+
onClick={() => {
|
|
510
|
+
syncDraft(preset, true);
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
{preset.label}
|
|
514
|
+
</button>
|
|
515
|
+
))}
|
|
516
|
+
</div>
|
|
517
|
+
<button className="primary-action" onClick={applyText}>
|
|
518
|
+
Set Location
|
|
519
|
+
</button>
|
|
520
|
+
<section className="route-panel">
|
|
521
|
+
<div className="panel-heading">
|
|
522
|
+
<h2>Route</h2>
|
|
523
|
+
<div className="location-status">{routeStatus?.status ?? "idle"} {progress}%</div>
|
|
524
|
+
</div>
|
|
525
|
+
<input
|
|
526
|
+
ref={fileRef}
|
|
527
|
+
type="file"
|
|
528
|
+
accept=".gpx,.geojson,.json,.kml,application/json,application/geo+json"
|
|
529
|
+
onChange={(e) => {
|
|
530
|
+
const file = e.currentTarget.files?.[0];
|
|
531
|
+
if (file) void readRouteFile(file);
|
|
532
|
+
}}
|
|
533
|
+
/>
|
|
534
|
+
<div className="route-meta">
|
|
535
|
+
{routePoints.length} pts
|
|
536
|
+
{routeStatus ? ` • ${formatDistance(routeStatus.progressMeters)} / ${formatDistance(routeStatus.totalMeters)}` : ""}
|
|
537
|
+
</div>
|
|
538
|
+
<div className="coordinate-grid">
|
|
539
|
+
<label>
|
|
540
|
+
km/h
|
|
541
|
+
<input
|
|
542
|
+
inputMode="decimal"
|
|
543
|
+
onChange={(e) => setSpeedKph(e.currentTarget.value)}
|
|
544
|
+
value={speedKph}
|
|
545
|
+
/>
|
|
546
|
+
</label>
|
|
547
|
+
<label>
|
|
548
|
+
Rate
|
|
549
|
+
<input
|
|
550
|
+
inputMode="decimal"
|
|
551
|
+
onChange={(e) => setMultiplier(e.currentTarget.value)}
|
|
552
|
+
value={multiplier}
|
|
553
|
+
/>
|
|
554
|
+
</label>
|
|
555
|
+
</div>
|
|
556
|
+
<label className="toggle-row">
|
|
557
|
+
<input
|
|
558
|
+
checked={loop}
|
|
559
|
+
onChange={(e) => setLoop(e.currentTarget.checked)}
|
|
560
|
+
type="checkbox"
|
|
561
|
+
/>
|
|
562
|
+
Loop
|
|
563
|
+
</label>
|
|
564
|
+
<div className="route-actions">
|
|
565
|
+
<button onClick={startRoute}>Play</button>
|
|
566
|
+
<button
|
|
567
|
+
onClick={() => {
|
|
568
|
+
void controlRoute(routeStatus?.status === "paused" ? "resume" : "pause");
|
|
569
|
+
}}
|
|
570
|
+
>
|
|
571
|
+
{routeStatus?.status === "paused" ? "Resume" : "Pause"}
|
|
572
|
+
</button>
|
|
573
|
+
<button
|
|
574
|
+
onClick={() => {
|
|
575
|
+
void controlRoute("stop");
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
Stop
|
|
579
|
+
</button>
|
|
580
|
+
</div>
|
|
581
|
+
</section>
|
|
582
|
+
</aside>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type LogLine = {
|
|
4
|
+
id: number;
|
|
5
|
+
line: string;
|
|
6
|
+
at: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const MAX_LINES = 500;
|
|
10
|
+
|
|
11
|
+
export function LogcatPanel() {
|
|
12
|
+
const nextIdRef = useRef(1);
|
|
13
|
+
const pausedRef = useRef(false);
|
|
14
|
+
const eventSourceRef = useRef<EventSource | null>(null);
|
|
15
|
+
const [lines, setLines] = useState<LogLine[]>([]);
|
|
16
|
+
const [packageName, setPackageName] = useState("");
|
|
17
|
+
const [search, setSearch] = useState("");
|
|
18
|
+
const [paused, setPaused] = useState(false);
|
|
19
|
+
const [enabled, setEnabled] = useState(false);
|
|
20
|
+
const [status, setStatus] = useState("Off");
|
|
21
|
+
|
|
22
|
+
const disconnect = () => {
|
|
23
|
+
eventSourceRef.current?.close();
|
|
24
|
+
eventSourceRef.current = null;
|
|
25
|
+
setEnabled(false);
|
|
26
|
+
setStatus("Off");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const connect = () => {
|
|
30
|
+
eventSourceRef.current?.close();
|
|
31
|
+
const params = new URLSearchParams();
|
|
32
|
+
if (packageName.trim()) params.set("package", packageName.trim());
|
|
33
|
+
if (search.trim()) params.set("search", search.trim());
|
|
34
|
+
const source = new EventSource(`/api/logcat?${params}`);
|
|
35
|
+
eventSourceRef.current = source;
|
|
36
|
+
setEnabled(true);
|
|
37
|
+
setStatus("Connecting");
|
|
38
|
+
source.addEventListener("ready", () => setStatus("Streaming"));
|
|
39
|
+
source.addEventListener("log", (event) => {
|
|
40
|
+
if (pausedRef.current) return;
|
|
41
|
+
try {
|
|
42
|
+
const data = JSON.parse((event as MessageEvent).data) as { line: string; at: string };
|
|
43
|
+
setLines((current) =>
|
|
44
|
+
[...current, { id: nextIdRef.current++, line: data.line, at: data.at }].slice(-MAX_LINES),
|
|
45
|
+
);
|
|
46
|
+
} catch {}
|
|
47
|
+
});
|
|
48
|
+
source.addEventListener("error", () => setStatus("Error"));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
return disconnect;
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
pausedRef.current = paused;
|
|
57
|
+
}, [paused]);
|
|
58
|
+
|
|
59
|
+
const copyLogs = async () => {
|
|
60
|
+
await navigator.clipboard.writeText(lines.map((line) => line.line).join("\n"));
|
|
61
|
+
setStatus("Copied");
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<section className="tool-panel logcat-panel">
|
|
66
|
+
<div className="panel-heading">
|
|
67
|
+
<h2>Logcat</h2>
|
|
68
|
+
<div className="location-status">{status} • {lines.length}</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="coordinate-grid">
|
|
71
|
+
<label>
|
|
72
|
+
Package
|
|
73
|
+
<input
|
|
74
|
+
onChange={(e) => setPackageName(e.currentTarget.value)}
|
|
75
|
+
placeholder="com.example.app"
|
|
76
|
+
value={packageName}
|
|
77
|
+
/>
|
|
78
|
+
</label>
|
|
79
|
+
<label>
|
|
80
|
+
Search
|
|
81
|
+
<input
|
|
82
|
+
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
83
|
+
placeholder="error"
|
|
84
|
+
value={search}
|
|
85
|
+
/>
|
|
86
|
+
</label>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="panel-actions">
|
|
89
|
+
<button onClick={connect}>{enabled ? "Apply" : "Start"}</button>
|
|
90
|
+
<button onClick={disconnect} disabled={!enabled}>Stop</button>
|
|
91
|
+
<button onClick={() => setPaused((v) => !v)}>{paused ? "Resume" : "Pause"}</button>
|
|
92
|
+
<button onClick={() => setLines([])}>Clear</button>
|
|
93
|
+
<button onClick={() => void copyLogs()}>Copy</button>
|
|
94
|
+
</div>
|
|
95
|
+
<pre className="logcat-output">
|
|
96
|
+
{lines.length ? lines.map((entry) => entry.line).join("\n") : enabled ? "Waiting for logcat..." : "Logcat is off."}
|
|
97
|
+
</pre>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|