Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveAndroidStreamConsole.tsx
2026-03-29 03:51:50 -05:00

1081 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}