"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: (path: string, init?: RequestInit) => Promise; serial: string; devices: DeviceRow[]; setSerial: (s: string) => void; adbAvailable: boolean; lock: boolean; overview: AndroidOverview | null; onAndroidAction: (action: string, payload?: Record) => Promise; 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(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([]); const [scrcpyStream, setScrcpyStream] = useState(false); /** 用户在本机序列号上点了「关闭真流」后,不再自动拉起,直到切换设备。 */ const [scrcpyUserStoppedForSerial, setScrcpyUserStoppedForSerial] = useState(null); const [scrcpyConnected, setScrcpyConnected] = useState(false); const [scrcpyErr, setScrcpyErr] = useState(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(null); const canvasRef = useRef(null); const wsRef = useRef(null); const decoderRef = useRef(null); const mirrorFocusRef = useRef(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) => { 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) => { 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?]> = { 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) => { 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) => { 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 (
{t("宿主机未安装 adb,无法使用 Live 控制台。", "adb is required for the Live console.")}
); } const tb = transportBadge(activeTransport); const HintIcon = tb.Icon; return (

{t("链路管理", "Link manager")}

{t("USB / Redroid :5555 / 无线调试(先配对再连接)", "USB · Redroid :5555 · wireless (pair then connect)")}

adb connect

setConnectAddr(e.target.value)} onKeyDown={(e) => e.key === "Enter" && void runConnect()} />
{bookmarks.length ? (
{bookmarks.map((b) => ( ))}
) : null}

adb pair(无线调试)

setPairHost(e.target.value)} /> setPairPort(e.target.value)} /> setPairCode(e.target.value)} />

{t( "手机上「无线调试」会显示配对端口与 6 位码;配对成功后用上方 connect 连接 adb 显示的 IP:5555。", "Use the pairing port + 6-digit code from Wireless debugging; then connect to the listed adb IP:5555.", )}

{t("Live Hub · ADB 云控台", "Live Hub · ADB farm console")}

{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 ", )} escrcpy {t(" / ", " / ")} scrcpy {t(");无 jar 或关闭真流时用截图。", "); PNG screenshot when jar missing or stream off.")}

{devices.length > 0 ? (

{t("设备矩阵", "Device matrix")}

{devices.map((d) => { const on = d.state === "device"; const active = serial === d.serial; const badge = transportBadge(d.transport); const BIcon = badge.Icon; return ( ); })}
) : null}
{t("刷新间隔", "Interval")} {INTERVALS_MS.map((ms) => ( ))} {activeTransport === "tcp" ? t("建议 ≥450ms", "Suggest ≥450ms") : t("可试 280ms", "Try 280ms")}
{scrcpyInfo && !scrcpyInfo.server_jar ? (

{t("未检测到 scrcpy-server:请放置 vendor/scrcpy-server.jar 或设置 SCRCPY_SERVER_JAR。", "No scrcpy-server jar: add vendor/scrcpy-server.jar or SCRCPY_SERVER_JAR.")} {scrcpyInfo.server_version ? ` (${t("期望版本", "version")} ${scrcpyInfo.server_version})` : null}

) : null}

{t( "快捷键(镜像区聚焦时):H Home · B Back · R 多任务 · W 唤醒 · M 菜单", "Hotkeys (mirror focused): H Home · B Back · R Recents · W Wake · M Menu", )}

{!serial ? (

{t("请选择或连接设备", "Pick or connect a device")}

) : (
{scrcpyStream ? ( ) : ( /* eslint-disable-next-line @next/next/no-img-element */ device { setImgBusy(true); }} onLoad={onImgLoad} onError={onImgError} onClick={onImageClick} onContextMenu={onImageContextMenu} /> )}
{scrcpyConnected ? (
SCRCPY·WS
) : liveMs > 0 && !scrcpyStream ? (
LIVE
) : !scrcpyStream ? (
{t("单帧", "Still")}
) : null}
)} {scrcpyErr ?

{scrcpyErr}

: null} {imgErr && !scrcpyStream ?

{imgErr}

: null}

{t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")}

{t("遥测", "Telemetry")}

{overview ? (
device
{overview.brand} {overview.model}
Android
{overview.android_version} (API {overview.sdk})
wm size
{metrics ? `${metrics.width}×${metrics.height}` : overview.resolution || "—"}
{overview.battery}
) : (

{t("在上方点「刷新设备信息」", "Use Refresh device info above")}

)}

{t("导航坞", "Nav dock")}

{( [ ["3", "Home"], ["4", "Back"], ["187", "Recents"], ] as const ).map(([code, label]) => ( ))}

{t("手势", "Gestures")}

{(["up", "down", "left", "right"] as const).map((dir) => ( ))}
); }