1081 lines
42 KiB
TypeScript
1081 lines
42 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Cable,
|
||
ExternalLink,
|
||
LayoutGrid,
|
||
Link2,
|
||
MousePointer2,
|
||
Pause,
|
||
Play,
|
||
Radio,
|
||
RefreshCw,
|
||
Smartphone,
|
||
Sparkles,
|
||
Usb,
|
||
Wifi,
|
||
} from "lucide-react";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { androidScrcpyWsUrl } from "@/lib/androidScrcpyWs";
|
||
import { ScrcpyH264Decoder, webCodecsH264Supported } from "@/lib/scrcpyH264Decoder";
|
||
|
||
type AndroidOverview = {
|
||
model?: string;
|
||
brand?: string;
|
||
android_version?: string;
|
||
sdk?: string;
|
||
battery?: string;
|
||
resolution?: string;
|
||
};
|
||
|
||
export type DeviceRow = {
|
||
serial: string;
|
||
model?: string;
|
||
state: string;
|
||
transport?: string;
|
||
};
|
||
type TFn = (zh: string, en: string) => string;
|
||
|
||
const BOOKMARKS_KEY = "live-hub-adb-bookmarks-v1";
|
||
|
||
function clientPointToCanvasPixel(
|
||
canvas: HTMLCanvasElement,
|
||
clientX: number,
|
||
clientY: number,
|
||
): { x: number; y: number } | null {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const nw = canvas.width;
|
||
const nh = canvas.height;
|
||
if (!nw || !nh) return null;
|
||
const elAR = rect.width / rect.height;
|
||
const imAR = nw / nh;
|
||
let drawW: number;
|
||
let drawH: number;
|
||
let offX: number;
|
||
let offY: number;
|
||
if (imAR > elAR) {
|
||
drawW = rect.width;
|
||
drawH = rect.width / imAR;
|
||
offX = 0;
|
||
offY = (rect.height - drawH) / 2;
|
||
} else {
|
||
drawH = rect.height;
|
||
drawW = rect.height * imAR;
|
||
offX = (rect.width - drawW) / 2;
|
||
offY = 0;
|
||
}
|
||
const lx = clientX - rect.left - offX;
|
||
const ly = clientY - rect.top - offY;
|
||
if (lx < 0 || ly < 0 || lx > drawW || ly > drawH) return null;
|
||
return {
|
||
x: Math.max(0, Math.min(nw - 1, Math.round((lx / drawW) * nw))),
|
||
y: Math.max(0, Math.min(nh - 1, Math.round((ly / drawH) * nh))),
|
||
};
|
||
}
|
||
|
||
function clientPointToImagePixel(
|
||
img: HTMLImageElement,
|
||
clientX: number,
|
||
clientY: number,
|
||
): { x: number; y: number } | null {
|
||
const rect = img.getBoundingClientRect();
|
||
const nw = img.naturalWidth;
|
||
const nh = img.naturalHeight;
|
||
if (!nw || !nh) return null;
|
||
const elAR = rect.width / rect.height;
|
||
const imAR = nw / nh;
|
||
let drawW: number;
|
||
let drawH: number;
|
||
let offX: number;
|
||
let offY: number;
|
||
if (imAR > elAR) {
|
||
drawW = rect.width;
|
||
drawH = rect.width / imAR;
|
||
offX = 0;
|
||
offY = (rect.height - drawH) / 2;
|
||
} else {
|
||
drawH = rect.height;
|
||
drawW = rect.height * imAR;
|
||
offX = (rect.width - drawW) / 2;
|
||
offY = 0;
|
||
}
|
||
const lx = clientX - rect.left - offX;
|
||
const ly = clientY - rect.top - offY;
|
||
if (lx < 0 || ly < 0 || lx > drawW || ly > drawH) return null;
|
||
return {
|
||
x: Math.max(0, Math.min(nw - 1, Math.round((lx / drawW) * nw))),
|
||
y: Math.max(0, Math.min(nh - 1, Math.round((ly / drawH) * nh))),
|
||
};
|
||
}
|
||
|
||
const INTERVALS_MS = [0, 280, 450, 700, 1100] as const;
|
||
|
||
function transportBadge(tr: string | undefined) {
|
||
switch (tr) {
|
||
case "usb":
|
||
return { Icon: Usb, zh: "USB", en: "USB", cls: "bg-amber-500/20 text-amber-100 ring-amber-500/35" };
|
||
case "tcp":
|
||
return { Icon: Wifi, zh: "网络", en: "TCP", cls: "bg-cyan-500/20 text-cyan-100 ring-cyan-500/35" };
|
||
case "emulator":
|
||
return { Icon: Cable, zh: "模拟器", en: "Emu", cls: "bg-fuchsia-500/20 text-fuchsia-100 ring-fuchsia-500/35" };
|
||
default:
|
||
return { Icon: Smartphone, zh: "?", en: "?", cls: "bg-zinc-600/30 text-zinc-400" };
|
||
}
|
||
}
|
||
|
||
type Props = {
|
||
t: TFn;
|
||
apiBase: string;
|
||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||
serial: string;
|
||
devices: DeviceRow[];
|
||
setSerial: (s: string) => void;
|
||
adbAvailable: boolean;
|
||
lock: boolean;
|
||
overview: AndroidOverview | null;
|
||
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
|
||
notify: (msg: string) => void;
|
||
onRefreshDevices: () => void;
|
||
};
|
||
|
||
const SCRCPY_URL = "https://github.com/Genymobile/scrcpy";
|
||
const ESCRCPY_URL = "https://github.com/viarotel-org/escrcpy";
|
||
|
||
/** 与 LiveControlApp 一致:apiBase 为空时用根相对路径(同域 FastAPI 托管静态页)。 */
|
||
function apiUrl(apiBase: string, path: string): string {
|
||
const p = path.startsWith("/") ? path : `/${path}`;
|
||
const b = (apiBase || "").trim().replace(/\/$/, "");
|
||
return b ? `${b}${p}` : p;
|
||
}
|
||
|
||
export function LiveAndroidStreamConsole({
|
||
t,
|
||
apiBase,
|
||
fetchJson,
|
||
serial,
|
||
devices,
|
||
setSerial,
|
||
adbAvailable,
|
||
lock,
|
||
overview,
|
||
onAndroidAction,
|
||
notify,
|
||
onRefreshDevices,
|
||
}: Props) {
|
||
const [frameTs, setFrameTs] = useState(0);
|
||
const [liveMs, setLiveMs] = useState<(typeof INTERVALS_MS)[number]>(450);
|
||
const [imgBusy, setImgBusy] = useState(false);
|
||
const [imgErr, setImgErr] = useState<string | null>(null);
|
||
const [metrics, setMetrics] = useState<{ width: number; height: number } | null>(null);
|
||
const [tapMode, setTapMode] = useState(true);
|
||
const [connectAddr, setConnectAddr] = useState("");
|
||
const [pairHost, setPairHost] = useState("");
|
||
const [pairPort, setPairPort] = useState("37777");
|
||
const [pairCode, setPairCode] = useState("");
|
||
const [connBusy, setConnBusy] = useState(false);
|
||
const [bookmarks, setBookmarks] = useState<string[]>([]);
|
||
const [scrcpyStream, setScrcpyStream] = useState(false);
|
||
/** 用户在本机序列号上点了「关闭真流」后,不再自动拉起,直到切换设备。 */
|
||
const [scrcpyUserStoppedForSerial, setScrcpyUserStoppedForSerial] = useState<string | null>(null);
|
||
const [scrcpyConnected, setScrcpyConnected] = useState(false);
|
||
const [scrcpyErr, setScrcpyErr] = useState<string | null>(null);
|
||
const [scrcpyInfo, setScrcpyInfo] = useState<{
|
||
server_jar?: boolean;
|
||
adb_ok?: boolean;
|
||
server_version?: string;
|
||
hint_zh?: string;
|
||
} | null>(null);
|
||
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null);
|
||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
const wsRef = useRef<WebSocket | null>(null);
|
||
const decoderRef = useRef<ScrcpyH264Decoder | null>(null);
|
||
const mirrorFocusRef = useRef<HTMLDivElement | null>(null);
|
||
const shotFailRef = useRef(0);
|
||
|
||
const activeTransport = devices.find((d) => d.serial === serial)?.transport;
|
||
const useFastShot = liveMs > 0;
|
||
|
||
const shotUrl = serial
|
||
? `${apiUrl(apiBase, "/android/screenshot")}?serial=${encodeURIComponent(serial)}&ts=${frameTs}${useFastShot ? "&fast=1" : ""}`
|
||
: "";
|
||
|
||
const bumpFrame = useCallback(() => {
|
||
setFrameTs((n) => n + 1);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const raw = window.localStorage.getItem(BOOKMARKS_KEY);
|
||
if (raw) {
|
||
const v = JSON.parse(raw) as unknown;
|
||
if (Array.isArray(v)) setBookmarks(v.map(String).filter(Boolean).slice(0, 24));
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}, []);
|
||
|
||
const saveBookmark = (addr: string) => {
|
||
const a = addr.trim();
|
||
if (!a) return;
|
||
const next = [a, ...bookmarks.filter((x) => x !== a)].slice(0, 16);
|
||
setBookmarks(next);
|
||
try {
|
||
window.localStorage.setItem(BOOKMARKS_KEY, JSON.stringify(next));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
notify(t("已保存快捷地址", "Saved quick address"));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!serial || !adbAvailable) {
|
||
setMetrics(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`${apiUrl(apiBase, "/android/display_metrics")}?serial=${encodeURIComponent(serial)}`,
|
||
);
|
||
const data = (await res.json()) as { width?: number; height?: number };
|
||
if (!res.ok || cancelled) return;
|
||
if (typeof data.width === "number" && typeof data.height === "number") {
|
||
setMetrics({ width: data.width, height: data.height });
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [apiBase, adbAvailable, serial]);
|
||
|
||
useEffect(() => {
|
||
if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream) return;
|
||
const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs);
|
||
return () => clearInterval(id);
|
||
}, [adbAvailable, liveMs, serial, scrcpyStream]);
|
||
|
||
useEffect(() => {
|
||
if (!adbAvailable) {
|
||
setScrcpyInfo(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const res = await fetch(apiUrl(apiBase, "/android/scrcpy/info"));
|
||
const data = (await res.json()) as {
|
||
server_jar?: boolean;
|
||
adb_ok?: boolean;
|
||
server_version?: string;
|
||
hint_zh?: string;
|
||
};
|
||
if (!cancelled && res.ok) setScrcpyInfo(data);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [apiBase, adbAvailable]);
|
||
|
||
useEffect(() => {
|
||
if (!scrcpyStream) setScrcpyErr(null);
|
||
}, [scrcpyStream]);
|
||
|
||
useEffect(() => {
|
||
setScrcpyUserStoppedForSerial(null);
|
||
}, [serial]);
|
||
|
||
useEffect(() => {
|
||
const row = devices.find((d) => d.serial === serial);
|
||
const userStopped = Boolean(serial && scrcpyUserStoppedForSerial === serial);
|
||
const canAuto =
|
||
adbAvailable &&
|
||
Boolean(serial) &&
|
||
row?.state === "device" &&
|
||
Boolean(scrcpyInfo?.server_jar) &&
|
||
webCodecsH264Supported() &&
|
||
!userStopped;
|
||
|
||
if (canAuto) {
|
||
setScrcpyStream(true);
|
||
setLiveMs(0);
|
||
return;
|
||
}
|
||
|
||
if (userStopped || !serial || !adbAvailable) {
|
||
setScrcpyStream(false);
|
||
return;
|
||
}
|
||
if (scrcpyInfo && !scrcpyInfo.server_jar) {
|
||
setScrcpyStream(false);
|
||
return;
|
||
}
|
||
if (!webCodecsH264Supported()) {
|
||
setScrcpyStream(false);
|
||
return;
|
||
}
|
||
if (row && row.state !== "device") {
|
||
setScrcpyStream(false);
|
||
return;
|
||
}
|
||
}, [serial, devices, adbAvailable, scrcpyInfo, scrcpyUserStoppedForSerial]);
|
||
|
||
useEffect(() => {
|
||
if (!scrcpyStream || !serial || !adbAvailable) {
|
||
wsRef.current?.close();
|
||
wsRef.current = null;
|
||
decoderRef.current?.reset();
|
||
decoderRef.current = null;
|
||
setScrcpyConnected(false);
|
||
return;
|
||
}
|
||
if (!webCodecsH264Supported()) {
|
||
setScrcpyErr(t("当前浏览器不支持 WebCodecs H.264", "WebCodecs H.264 not supported in this browser"));
|
||
setScrcpyStream(false);
|
||
return;
|
||
}
|
||
const canvas = canvasRef.current;
|
||
if (!canvas) return;
|
||
|
||
const dec = new ScrcpyH264Decoder(canvas, (msg) => setScrcpyErr(msg));
|
||
decoderRef.current = dec;
|
||
|
||
const wsUrl = androidScrcpyWsUrl(apiBase, serial);
|
||
const ws = new WebSocket(wsUrl);
|
||
wsRef.current = ws;
|
||
ws.binaryType = "arraybuffer";
|
||
|
||
ws.onopen = () => {
|
||
setScrcpyConnected(true);
|
||
setScrcpyErr(null);
|
||
};
|
||
ws.onmessage = (ev) => {
|
||
if (typeof ev.data === "string") {
|
||
try {
|
||
const j = JSON.parse(ev.data) as { error?: string };
|
||
if (j.error) setScrcpyErr(j.error);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return;
|
||
}
|
||
decoderRef.current?.push(ev.data as ArrayBuffer);
|
||
};
|
||
ws.onerror = () => {
|
||
setScrcpyErr(t("WebSocket 异常", "WebSocket error"));
|
||
};
|
||
ws.onclose = () => {
|
||
setScrcpyConnected(false);
|
||
decoderRef.current?.reset();
|
||
decoderRef.current = null;
|
||
};
|
||
|
||
return () => {
|
||
ws.close();
|
||
wsRef.current = null;
|
||
dec.reset();
|
||
if (decoderRef.current === dec) decoderRef.current = null;
|
||
};
|
||
}, [scrcpyStream, serial, adbAvailable, apiBase, t]);
|
||
|
||
useEffect(() => {
|
||
if (serial && adbAvailable) {
|
||
shotFailRef.current = 0;
|
||
setFrameTs((n) => n + 1);
|
||
}
|
||
}, [serial, adbAvailable]);
|
||
|
||
const runConnect = async () => {
|
||
const addr = connectAddr.trim();
|
||
if (!addr) {
|
||
notify(t("填写 host:port", "Enter host:port"));
|
||
return;
|
||
}
|
||
setConnBusy(true);
|
||
try {
|
||
const r = await fetchJson<{ ok?: boolean; message?: string }>("/android/connect", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ address: addr }),
|
||
});
|
||
notify(r.message || (r.ok ? "OK" : t("连接结果见日志", "See message")));
|
||
onRefreshDevices();
|
||
bumpFrame();
|
||
} catch (e) {
|
||
notify((e as Error).message);
|
||
} finally {
|
||
setConnBusy(false);
|
||
}
|
||
};
|
||
|
||
const runDisconnect = async (addr: string) => {
|
||
setConnBusy(true);
|
||
try {
|
||
const r = await fetchJson<{ ok?: boolean; message?: string }>("/android/disconnect", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ address: addr }),
|
||
});
|
||
notify(r.message || "OK");
|
||
onRefreshDevices();
|
||
} catch (e) {
|
||
notify((e as Error).message);
|
||
} finally {
|
||
setConnBusy(false);
|
||
}
|
||
};
|
||
|
||
const runPair = async () => {
|
||
const h = pairHost.trim();
|
||
const p = parseInt(pairPort, 10) || 37777;
|
||
const c = pairCode.trim();
|
||
if (!h || !c) {
|
||
notify(t("填写配对 IP 与码", "Host + code required"));
|
||
return;
|
||
}
|
||
setConnBusy(true);
|
||
try {
|
||
const r = await fetchJson<{ ok?: boolean; message?: string }>("/android/pair", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ host: h, port: p, code: c }),
|
||
});
|
||
notify(r.message || (r.ok ? t("配对成功后再点连接", "Paired — now Connect") : "pair"));
|
||
} catch (e) {
|
||
notify((e as Error).message);
|
||
} finally {
|
||
setConnBusy(false);
|
||
}
|
||
};
|
||
|
||
const onImageClick = async (e: React.MouseEvent<HTMLImageElement>) => {
|
||
if (!tapMode || lock || !adbAvailable || !serial) return;
|
||
const img = imgRef.current;
|
||
if (!img) return;
|
||
const p = clientPointToImagePixel(img, e.clientX, e.clientY);
|
||
if (!p) return;
|
||
|
||
const now = Date.now();
|
||
const prev = lastTapRef.current;
|
||
if (prev && now - prev.t < 320 && Math.abs(prev.x - p.x) < 28 && Math.abs(prev.y - p.y) < 28) {
|
||
lastTapRef.current = null;
|
||
await onAndroidAction("double_tap", { x: p.x, y: p.y });
|
||
notify(t("双击", "Double tap"));
|
||
} else {
|
||
lastTapRef.current = { t: now, x: p.x, y: p.y };
|
||
await onAndroidAction("tap", { x: p.x, y: p.y });
|
||
}
|
||
bumpFrame();
|
||
};
|
||
|
||
const onImageContextMenu = async (e: React.MouseEvent<HTMLImageElement>) => {
|
||
e.preventDefault();
|
||
if (lock || !adbAvailable || !serial) return;
|
||
const img = imgRef.current;
|
||
if (!img) return;
|
||
const p = clientPointToImagePixel(img, e.clientX, e.clientY);
|
||
if (!p) return;
|
||
await onAndroidAction("long_press", { x: p.x, y: p.y, duration: 720 });
|
||
notify(t("长按", "Long press"));
|
||
bumpFrame();
|
||
};
|
||
|
||
const onMirrorKeyDown = (e: React.KeyboardEvent) => {
|
||
if (!serial || lock) return;
|
||
const tag = (e.target as HTMLElement).tagName;
|
||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||
const k = e.key.toLowerCase();
|
||
const map: Record<string, [string, Record<string, unknown>?]> = {
|
||
h: ["key", { keycode: "3" }],
|
||
b: ["key", { keycode: "4" }],
|
||
r: ["key", { keycode: "187" }],
|
||
w: ["wake", undefined],
|
||
m: ["key", { keycode: "82" }],
|
||
};
|
||
const hit = map[k];
|
||
if (!hit) return;
|
||
e.preventDefault();
|
||
void (async () => {
|
||
await onAndroidAction(hit[0], hit[1]);
|
||
if (!scrcpyStream) bumpFrame();
|
||
})();
|
||
};
|
||
|
||
const onCanvasClick = async (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
if (!tapMode || lock || !adbAvailable || !serial) return;
|
||
const c = canvasRef.current;
|
||
if (!c) return;
|
||
const p = clientPointToCanvasPixel(c, e.clientX, e.clientY);
|
||
if (!p) return;
|
||
const now = Date.now();
|
||
const prev = lastTapRef.current;
|
||
if (prev && now - prev.t < 320 && Math.abs(prev.x - p.x) < 28 && Math.abs(prev.y - p.y) < 28) {
|
||
lastTapRef.current = null;
|
||
await onAndroidAction("double_tap", { x: p.x, y: p.y });
|
||
notify(t("双击", "Double tap"));
|
||
} else {
|
||
lastTapRef.current = { t: now, x: p.x, y: p.y };
|
||
await onAndroidAction("tap", { x: p.x, y: p.y });
|
||
}
|
||
if (!scrcpyStream) bumpFrame();
|
||
};
|
||
|
||
const onCanvasContextMenu = async (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||
e.preventDefault();
|
||
if (lock || !adbAvailable || !serial) return;
|
||
const c = canvasRef.current;
|
||
if (!c) return;
|
||
const p = clientPointToCanvasPixel(c, e.clientX, e.clientY);
|
||
if (!p) return;
|
||
await onAndroidAction("long_press", { x: p.x, y: p.y, duration: 720 });
|
||
notify(t("长按", "Long press"));
|
||
if (!scrcpyStream) bumpFrame();
|
||
};
|
||
|
||
const onImgError = () => {
|
||
setImgBusy(false);
|
||
shotFailRef.current += 1;
|
||
if (shotFailRef.current <= 3) {
|
||
setImgErr(null);
|
||
window.setTimeout(() => bumpFrame(), 380 + shotFailRef.current * 200);
|
||
} else {
|
||
setImgErr(t("截图连续失败:检查 USB 授权 / 无线是否断开 / Redroid 端口", "Screenshot failed repeatedly"));
|
||
}
|
||
};
|
||
|
||
const onImgLoad = () => {
|
||
setImgBusy(false);
|
||
shotFailRef.current = 0;
|
||
setImgErr(null);
|
||
};
|
||
|
||
if (!adbAvailable) {
|
||
return (
|
||
<div className="rounded-2xl border border-amber-500/25 bg-amber-500/[0.06] p-6 text-center text-sm text-amber-200/90">
|
||
{t("宿主机未安装 adb,无法使用 Live 控制台。", "adb is required for the Live console.")}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const tb = transportBadge(activeTransport);
|
||
const HintIcon = tb.Icon;
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="rounded-2xl border border-sky-500/20 bg-gradient-to-br from-sky-500/[0.08] via-zinc-950/80 to-zinc-900/90 p-4 ring-1 ring-white/[0.05]">
|
||
<div className="flex flex-wrap items-center gap-2 border-b border-white/[0.06] pb-3">
|
||
<Link2 className="h-4 w-4 text-sky-400" />
|
||
<h4 className="text-sm font-semibold text-white">{t("链路管理", "Link manager")}</h4>
|
||
<span className="text-[10px] text-zinc-500">
|
||
{t("USB / Redroid :5555 / 无线调试(先配对再连接)", "USB · Redroid :5555 · wireless (pair then connect)")}
|
||
</span>
|
||
</div>
|
||
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||
<div className="space-y-2 rounded-xl border border-white/[0.06] bg-black/35 p-3">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">adb connect</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<input
|
||
className="min-w-[12rem] flex-1 rounded-lg border border-white/10 bg-black/50 px-3 py-2 font-mono text-xs text-zinc-200"
|
||
placeholder="192.168.1.10:5555"
|
||
value={connectAddr}
|
||
onChange={(e) => setConnectAddr(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && void runConnect()}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={connBusy || lock}
|
||
onClick={() => void runConnect()}
|
||
className="rounded-lg bg-sky-500/25 px-3 py-2 text-xs font-semibold text-sky-100 ring-1 ring-sky-400/40"
|
||
>
|
||
{connBusy ? "…" : "Connect"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={connBusy || lock || !connectAddr.trim()}
|
||
onClick={() => saveBookmark(connectAddr)}
|
||
className="rounded-lg border border-white/10 px-3 py-2 text-xs text-zinc-400 hover:text-zinc-200"
|
||
>
|
||
{t("存书签", "Pin")}
|
||
</button>
|
||
</div>
|
||
{bookmarks.length ? (
|
||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||
{bookmarks.map((b) => (
|
||
<button
|
||
key={b}
|
||
type="button"
|
||
className="rounded-md bg-white/[0.06] px-2 py-0.5 font-mono text-[10px] text-zinc-400 hover:bg-white/10"
|
||
onClick={() => setConnectAddr(b)}
|
||
>
|
||
{b}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<div className="flex flex-wrap gap-2 pt-2">
|
||
<button
|
||
type="button"
|
||
disabled={connBusy || lock || !serial}
|
||
onClick={() => void runDisconnect(serial)}
|
||
className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-2.5 py-1.5 text-[10px] text-rose-200"
|
||
>
|
||
{t("断开当前 TCP", "Disconnect current")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={connBusy || lock}
|
||
onClick={() => void runDisconnect("")}
|
||
className="rounded-lg border border-white/10 px-2.5 py-1.5 text-[10px] text-zinc-500 hover:text-zinc-300"
|
||
>
|
||
{t("断开全部 TCP", "Disconnect all TCP")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={connBusy || lock}
|
||
onClick={() => {
|
||
onRefreshDevices();
|
||
bumpFrame();
|
||
notify(t("已刷新设备列表", "Devices refreshed"));
|
||
}}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-2.5 py-1.5 text-[10px] text-zinc-400"
|
||
>
|
||
<RefreshCw className="h-3 w-3" />
|
||
ADB list
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2 rounded-xl border border-white/[0.06] bg-black/35 p-3">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">adb pair(无线调试)</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
<input
|
||
className="w-32 rounded-lg border border-white/10 bg-black/50 px-2 py-1.5 font-mono text-xs"
|
||
placeholder="IP"
|
||
value={pairHost}
|
||
onChange={(e) => setPairHost(e.target.value)}
|
||
/>
|
||
<input
|
||
className="w-20 rounded-lg border border-white/10 bg-black/50 px-2 py-1.5 font-mono text-xs"
|
||
placeholder="port"
|
||
value={pairPort}
|
||
onChange={(e) => setPairPort(e.target.value)}
|
||
/>
|
||
<input
|
||
className="min-w-[6rem] flex-1 rounded-lg border border-white/10 bg-black/50 px-2 py-1.5 font-mono text-xs tracking-widest"
|
||
placeholder="123456"
|
||
value={pairCode}
|
||
onChange={(e) => setPairCode(e.target.value)}
|
||
/>
|
||
<button
|
||
type="button"
|
||
disabled={connBusy || lock}
|
||
onClick={() => void runPair()}
|
||
className="rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs text-violet-100 ring-1 ring-violet-400/35"
|
||
>
|
||
Pair
|
||
</button>
|
||
</div>
|
||
<p className="text-[10px] leading-relaxed text-zinc-600">
|
||
{t(
|
||
"手机上「无线调试」会显示配对端口与 6 位码;配对成功后用上方 connect 连接 adb 显示的 IP:5555。",
|
||
"Use the pairing port + 6-digit code from Wireless debugging; then connect to the listed adb IP:5555.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative overflow-hidden rounded-3xl border border-emerald-500/25 bg-gradient-to-br from-emerald-500/[0.12] via-zinc-950/90 to-violet-500/[0.08] p-1 shadow-2xl shadow-emerald-900/20 ring-1 ring-white/[0.06]">
|
||
<div className="pointer-events-none absolute -right-20 -top-20 h-56 w-56 rounded-full bg-emerald-500/10 blur-3xl" aria-hidden />
|
||
<div className="relative rounded-[1.35rem] border border-white/[0.06] bg-zinc-950/80 p-4 backdrop-blur-sm">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div className="flex items-start gap-3">
|
||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-400/30 to-violet-500/25 ring-1 ring-white/10">
|
||
<Sparkles className="h-6 w-6 text-emerald-200" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-semibold tracking-tight text-white">
|
||
{t("Live Hub · ADB 云控台", "Live Hub · ADB farm console")}
|
||
</h3>
|
||
<p className="mt-1 max-w-xl text-[11px] leading-relaxed text-zinc-500">
|
||
{t(
|
||
"统一适配 USB / Redroid / 无线 adb:已授权设备默认走官方 scrcpy-server 的 WebSocket H.264 真流(与桌面 ",
|
||
"USB / Redroid / wireless adb: authorized devices default to official scrcpy-server WebSocket H.264 (same stack as ",
|
||
)}
|
||
<a href={ESCRCPY_URL} target="_blank" rel="noreferrer" className="text-emerald-300/90 underline-offset-2 hover:underline">
|
||
escrcpy
|
||
</a>
|
||
{t(" / ", " / ")}
|
||
<a href={SCRCPY_URL} target="_blank" rel="noreferrer" className="text-emerald-300/90 underline-offset-2 hover:underline">
|
||
scrcpy
|
||
</a>
|
||
{t(");无 jar 或关闭真流时用截图。", "); PNG screenshot when jar missing or stream off.")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 flex-col gap-1.5 self-start sm:flex-row">
|
||
<a
|
||
href={ESCRCPY_URL}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1.5 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-medium text-zinc-300 hover:bg-white/[0.07]"
|
||
>
|
||
escrcpy
|
||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||
</a>
|
||
<a
|
||
href={SCRCPY_URL}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1.5 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-[11px] font-medium text-zinc-300 hover:bg-white/[0.07]"
|
||
>
|
||
scrcpy
|
||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{devices.length > 0 ? (
|
||
<div className="mt-5">
|
||
<p className="mb-2 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||
<LayoutGrid className="h-3.5 w-3.5" />
|
||
{t("设备矩阵", "Device matrix")}
|
||
</p>
|
||
<div className="flex gap-2 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||
{devices.map((d) => {
|
||
const on = d.state === "device";
|
||
const active = serial === d.serial;
|
||
const badge = transportBadge(d.transport);
|
||
const BIcon = badge.Icon;
|
||
return (
|
||
<button
|
||
key={d.serial}
|
||
type="button"
|
||
onClick={() => setSerial(d.serial)}
|
||
className={`flex min-w-[9rem] flex-col rounded-xl border px-3 py-2 text-left transition ${
|
||
active
|
||
? "border-emerald-500/50 bg-emerald-500/15 ring-1 ring-emerald-400/30"
|
||
: "border-white/10 bg-black/30 hover:border-white/20"
|
||
}`}
|
||
>
|
||
<span className="flex items-center gap-1.5 text-xs font-medium text-zinc-200">
|
||
<Smartphone className="h-3.5 w-3.5 text-emerald-400/80" />
|
||
<span className="truncate">{d.model || d.serial.slice(0, 10)}</span>
|
||
</span>
|
||
<span className="mt-0.5 truncate font-mono text-[9px] text-zinc-600">{d.serial}</span>
|
||
<div className="mt-1 flex flex-wrap gap-1">
|
||
<span
|
||
className={`inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-[9px] font-semibold ring-1 ${badge.cls}`}
|
||
>
|
||
<BIcon className="h-2.5 w-2.5" />
|
||
{t(badge.zh, badge.en)}
|
||
</span>
|
||
<span
|
||
className={`rounded-md px-1.5 py-0.5 text-[9px] font-semibold ${
|
||
on ? "bg-emerald-500/20 text-emerald-200" : "bg-zinc-600/30 text-zinc-500"
|
||
}`}
|
||
>
|
||
{d.state}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="mt-6 grid gap-6 xl:grid-cols-12">
|
||
<div className="xl:col-span-8">
|
||
<div className="flex flex-wrap items-center gap-2 border-b border-white/[0.06] pb-3">
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||
{t("刷新间隔", "Interval")}
|
||
</span>
|
||
{INTERVALS_MS.map((ms) => (
|
||
<button
|
||
key={ms}
|
||
type="button"
|
||
onClick={() => setLiveMs(ms)}
|
||
className={`rounded-lg px-2.5 py-1 text-[10px] font-semibold transition ${
|
||
liveMs === ms
|
||
? "bg-violet-500/25 text-violet-100 ring-1 ring-violet-400/35"
|
||
: "bg-black/30 text-zinc-500 hover:text-zinc-300"
|
||
}`}
|
||
>
|
||
{ms === 0 ? t("手动", "Manual") : `${ms}ms`}
|
||
</button>
|
||
))}
|
||
<span
|
||
className={`ml-1 inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[9px] font-medium ring-1 ${tb.cls}`}
|
||
>
|
||
<HintIcon className="h-3 w-3" />
|
||
{activeTransport === "tcp"
|
||
? t("建议 ≥450ms", "Suggest ≥450ms")
|
||
: t("可试 280ms", "Try 280ms")}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void bumpFrame()}
|
||
className="ml-auto inline-flex items-center gap-1 rounded-lg border border-white/10 bg-white/[0.05] px-2.5 py-1 text-[10px] text-zinc-300 hover:bg-white/[0.08]"
|
||
>
|
||
<RefreshCw className={`h-3 w-3 ${imgBusy ? "animate-spin" : ""}`} />
|
||
{t("下一帧", "Next frame")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={lock || !serial}
|
||
onClick={() => void onAndroidAction("wake")}
|
||
className="rounded-lg bg-amber-500/20 px-2.5 py-1 text-[10px] font-semibold text-amber-100 ring-1 ring-amber-500/30"
|
||
>
|
||
Wake
|
||
</button>
|
||
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-zinc-400">
|
||
<input
|
||
type="checkbox"
|
||
className="rounded border-white/20 bg-black/50"
|
||
checked={tapMode}
|
||
onChange={(e) => setTapMode(e.target.checked)}
|
||
/>
|
||
<MousePointer2 className="h-3 w-3" />
|
||
{t("点按穿透", "Tap-through")}
|
||
</label>
|
||
<button
|
||
type="button"
|
||
disabled={lock || !serial || !scrcpyInfo?.server_jar}
|
||
title={
|
||
scrcpyInfo && !scrcpyInfo.server_jar && scrcpyInfo.hint_zh
|
||
? scrcpyInfo.hint_zh
|
||
: t("官方 scrcpy-server + WebSocket H.264", "Official scrcpy-server + WebSocket H.264")
|
||
}
|
||
onClick={() => {
|
||
setScrcpyStream((v) => {
|
||
const next = !v;
|
||
if (next) {
|
||
setScrcpyUserStoppedForSerial(null);
|
||
notify(t("已开启实时画面(H.264 WebSocket)", "Live H.264 WebSocket stream on"));
|
||
} else {
|
||
if (serial) setScrcpyUserStoppedForSerial(serial);
|
||
notify(t("已关闭实时画面(将使用截图)", "Live stream off — screenshot mode"));
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
className={`inline-flex items-center gap-1 rounded-lg border px-2.5 py-1 text-[10px] font-semibold transition ${
|
||
scrcpyStream
|
||
? "border-cyan-400/50 bg-cyan-500/20 text-cyan-100 ring-1 ring-cyan-400/35"
|
||
: "border-white/10 bg-black/30 text-zinc-400 hover:text-zinc-200"
|
||
}`}
|
||
>
|
||
<Radio className="h-3 w-3" />
|
||
{scrcpyStream ? t("关闭真流", "Stop WS") : t("实时画面", "Live H.264")}
|
||
</button>
|
||
</div>
|
||
{scrcpyInfo && !scrcpyInfo.server_jar ? (
|
||
<p className="mt-2 text-[10px] text-amber-200/80">
|
||
{t(
|
||
"未检测到 scrcpy-server:服务启动时会自动从官方 Release 拉取;若仍失败请检查宿主机出网或重启 web 进程。",
|
||
"scrcpy-server missing: it is fetched on hub startup from official releases; check outbound HTTPS or restart web.",
|
||
)}
|
||
{scrcpyInfo.server_version ? ` (${t("期望版本", "version")} ${scrcpyInfo.server_version})` : null}
|
||
</p>
|
||
) : null}
|
||
<p className="mt-2 text-[10px] text-zinc-600">
|
||
{t(
|
||
"快捷键(镜像区聚焦时):H Home · B Back · R 多任务 · W 唤醒 · M 菜单",
|
||
"Hotkeys (mirror focused): H Home · B Back · R Recents · W Wake · M Menu",
|
||
)}
|
||
</p>
|
||
|
||
<div className="relative mx-auto mt-4 max-h-[min(78vh,720px)] min-h-[280px]">
|
||
{!serial ? (
|
||
<p className="py-16 text-center text-sm text-zinc-600">{t("请选择或连接设备", "Pick or connect a device")}</p>
|
||
) : (
|
||
<div
|
||
ref={mirrorFocusRef}
|
||
tabIndex={0}
|
||
onKeyDown={onMirrorKeyDown}
|
||
className="relative flex justify-center outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||
>
|
||
<div
|
||
className="relative rounded-[2rem] border-[10px] border-zinc-800 bg-black shadow-inner shadow-black/80 ring-1 ring-white/10"
|
||
style={{ maxHeight: "min(72vh, 680px)" }}
|
||
>
|
||
<div className="absolute left-1/2 top-0 z-10 h-5 w-24 -translate-x-1/2 rounded-b-xl bg-black/80" />
|
||
{scrcpyStream ? (
|
||
<canvas
|
||
ref={canvasRef}
|
||
className={`max-h-[min(72vh,680px)] w-auto max-w-full rounded-[1.35rem] object-contain ${
|
||
tapMode ? "cursor-crosshair" : "cursor-default"
|
||
}`}
|
||
draggable={false}
|
||
onClick={onCanvasClick}
|
||
onContextMenu={onCanvasContextMenu}
|
||
/>
|
||
) : (
|
||
/* eslint-disable-next-line @next/next/no-img-element */
|
||
<img
|
||
ref={imgRef}
|
||
src={shotUrl || undefined}
|
||
alt="device"
|
||
className={`max-h-[min(72vh,680px)] w-auto max-w-full rounded-[1.35rem] object-contain ${
|
||
tapMode ? "cursor-crosshair" : "cursor-default"
|
||
}`}
|
||
draggable={false}
|
||
onLoadStart={() => {
|
||
setImgBusy(true);
|
||
}}
|
||
onLoad={onImgLoad}
|
||
onError={onImgError}
|
||
onClick={onImageClick}
|
||
onContextMenu={onImageContextMenu}
|
||
/>
|
||
)}
|
||
</div>
|
||
{scrcpyConnected ? (
|
||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-cyan-500/90 px-2 py-0.5 text-[9px] font-bold text-cyan-950 shadow-lg">
|
||
<Radio className="h-3 w-3" />
|
||
SCRCPY·WS
|
||
</div>
|
||
) : liveMs > 0 && !scrcpyStream ? (
|
||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-emerald-500/90 px-2 py-0.5 text-[9px] font-bold text-emerald-950 shadow-lg">
|
||
<Play className="h-3 w-3" />
|
||
LIVE
|
||
</div>
|
||
) : !scrcpyStream ? (
|
||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-zinc-700/90 px-2 py-0.5 text-[9px] font-bold text-zinc-200">
|
||
<Pause className="h-3 w-3" />
|
||
{t("单帧", "Still")}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
{scrcpyErr ? <p className="mt-3 text-center text-xs text-rose-300">{scrcpyErr}</p> : null}
|
||
{imgErr && !scrcpyStream ? <p className="mt-3 text-center text-xs text-rose-300">{imgErr}</p> : null}
|
||
<p className="mt-3 text-center text-[10px] text-zinc-600">
|
||
{t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4 xl:col-span-4">
|
||
<div className="rounded-2xl border border-white/[0.07] bg-black/40 p-4">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||
{t("遥测", "Telemetry")}
|
||
</p>
|
||
{overview ? (
|
||
<dl className="mt-3 space-y-2 font-mono text-[11px] text-zinc-400">
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">device</dt>
|
||
<dd className="text-right text-zinc-200">
|
||
{overview.brand} {overview.model}
|
||
</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">Android</dt>
|
||
<dd className="text-zinc-300">
|
||
{overview.android_version} (API {overview.sdk})
|
||
</dd>
|
||
</div>
|
||
<div className="flex justify-between gap-2">
|
||
<dt className="text-zinc-600">wm size</dt>
|
||
<dd className="text-cyan-300/90">
|
||
{metrics ? `${metrics.width}×${metrics.height}` : overview.resolution || "—"}
|
||
</dd>
|
||
</div>
|
||
<div className="whitespace-pre-wrap text-[10px] leading-snug text-zinc-500">{overview.battery}</div>
|
||
</dl>
|
||
) : (
|
||
<p className="mt-2 text-xs text-zinc-600">{t("在上方点「刷新设备信息」", "Use Refresh device info above")}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-violet-500/20 bg-gradient-to-b from-violet-500/[0.08] to-black/40 p-4">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-violet-300/80">
|
||
{t("导航坞", "Nav dock")}
|
||
</p>
|
||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||
{(
|
||
[
|
||
["3", "Home"],
|
||
["4", "Back"],
|
||
["187", "Recents"],
|
||
] as const
|
||
).map(([code, label]) => (
|
||
<button
|
||
key={code}
|
||
type="button"
|
||
disabled={lock || !serial}
|
||
onClick={() => {
|
||
void onAndroidAction("key", { keycode: code });
|
||
bumpFrame();
|
||
}}
|
||
className="rounded-xl border border-white/10 bg-white/[0.06] py-3 text-xs font-semibold text-zinc-200 hover:bg-white/[0.1]"
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={lock || !serial}
|
||
onClick={() => {
|
||
void onAndroidAction("key", { keycode: "24" });
|
||
bumpFrame();
|
||
}}
|
||
className="rounded-xl border border-white/10 bg-black/30 py-2 text-[11px] text-zinc-300"
|
||
>
|
||
Vol+
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={lock || !serial}
|
||
onClick={() => {
|
||
void onAndroidAction("key", { keycode: "25" });
|
||
bumpFrame();
|
||
}}
|
||
className="rounded-xl border border-white/10 bg-black/30 py-2 text-[11px] text-zinc-300"
|
||
>
|
||
Vol−
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-cyan-500/15 bg-cyan-500/[0.05] p-4">
|
||
<p className="text-[10px] font-semibold uppercase tracking-wider text-cyan-300/80">
|
||
{t("手势", "Gestures")}
|
||
</p>
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
{(["up", "down", "left", "right"] as const).map((dir) => (
|
||
<button
|
||
key={dir}
|
||
type="button"
|
||
disabled={lock || !serial}
|
||
onClick={() => {
|
||
void onAndroidAction("swipe", { direction: dir });
|
||
bumpFrame();
|
||
}}
|
||
className="rounded-lg border border-white/10 bg-black/30 py-2 font-mono text-[10px] uppercase text-zinc-300"
|
||
>
|
||
{dir}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|