's'
This commit is contained in:
789
web-console/components/live-product/LiveAndroidStreamConsole.tsx
Normal file
789
web-console/components/live-product/LiveAndroidStreamConsole.tsx
Normal file
@@ -0,0 +1,789 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Cable,
|
||||
ExternalLink,
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
MousePointer2,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Usb,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
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 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";
|
||||
|
||||
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 lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | 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 =
|
||||
apiBase && serial
|
||||
? `${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(`${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) return;
|
||||
const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs);
|
||||
return () => clearInterval(id);
|
||||
}, [adbAvailable, liveMs, serial]);
|
||||
|
||||
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]);
|
||||
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:截图轮询 + 输入注入。极致低延迟请用桌面",
|
||||
"USB boards, Redroid, wireless adb: polled screencap + input. For lowest latency use desktop ",
|
||||
)}
|
||||
<a href={SCRCPY_URL} target="_blank" rel="noreferrer" className="text-emerald-300/90 underline-offset-2 hover:underline">
|
||||
scrcpy
|
||||
</a>
|
||||
{t("。", ".")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={SCRCPY_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 self-start 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>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
<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" />
|
||||
{/* 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>
|
||||
{liveMs > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{imgErr ? <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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, ExternalLink, Layers, Loader2, Package, RefreshCw, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Copy, Layers, Loader2, Package, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type DeviceRow = { serial: string; model?: string; state: string };
|
||||
import { LiveAndroidStreamConsole } from "@/components/live-product/LiveAndroidStreamConsole";
|
||||
|
||||
type DeviceRow = { serial: string; model?: string; state: string; transport?: string };
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
|
||||
type AndroidOverview = {
|
||||
@@ -42,33 +44,35 @@ type Props = {
|
||||
t: TFn;
|
||||
notify: (msg: string) => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
apiBase: string;
|
||||
/** Parent busy (e.g. adb actions); combined with local pending for UI lock. */
|
||||
busy: string | null;
|
||||
androidSerial: string;
|
||||
setAndroidSerial: (s: string) => void;
|
||||
devices: DeviceRow[];
|
||||
adbAvailable: boolean;
|
||||
scrcpyUrl: string;
|
||||
apkPath: string;
|
||||
setApkPath: (v: string) => void;
|
||||
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
|
||||
onCopy: (text: string) => void;
|
||||
onRefreshDevices: () => void;
|
||||
};
|
||||
|
||||
export function LiveAndroidWorkstation({
|
||||
t,
|
||||
notify,
|
||||
fetchJson,
|
||||
apiBase,
|
||||
busy,
|
||||
androidSerial,
|
||||
setAndroidSerial,
|
||||
devices,
|
||||
adbAvailable,
|
||||
scrcpyUrl,
|
||||
apkPath,
|
||||
setApkPath,
|
||||
onAndroidAction,
|
||||
onCopy,
|
||||
onRefreshDevices,
|
||||
}: Props) {
|
||||
const [overview, setOverview] = useState<AndroidOverview | null>(null);
|
||||
const [overviewErr, setOverviewErr] = useState<string | null>(null);
|
||||
@@ -83,30 +87,11 @@ export function LiveAndroidWorkstation({
|
||||
const [uiErr, setUiErr] = useState<string | null>(null);
|
||||
const [openUrl, setOpenUrl] = useState("https://");
|
||||
const [adbInputText, setAdbInputText] = useState("");
|
||||
const [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host");
|
||||
const [mirrorReloadKey, setMirrorReloadKey] = useState(0);
|
||||
const [logcatLive, setLogcatLive] = useState(false);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/";
|
||||
const LINK_SCRCPY = "https://github.com/Genymobile/scrcpy";
|
||||
const LINK_WS_SCRCPY = "https://github.com/NetrisTV/ws-scrcpy";
|
||||
const LINK_PANDA_GH = "https://github.com/PandaTestGrid/panda-web-scrcpy";
|
||||
|
||||
const lock = !!(busy || pending);
|
||||
|
||||
const hostMirrorSrc = useMemo(() => {
|
||||
if (!scrcpyUrl) return "";
|
||||
try {
|
||||
const u = new URL(scrcpyUrl);
|
||||
u.searchParams.set("_lh", String(mirrorReloadKey));
|
||||
return u.toString();
|
||||
} catch {
|
||||
const sep = scrcpyUrl.includes("?") ? "&" : "?";
|
||||
return `${scrcpyUrl}${sep}_lh=${mirrorReloadKey}`;
|
||||
}
|
||||
}, [scrcpyUrl, mirrorReloadKey]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
if (!androidSerial || !adbAvailable) {
|
||||
setOverview(null);
|
||||
@@ -281,7 +266,14 @@ export function LiveAndroidWorkstation({
|
||||
: "border-white/10 bg-black/30 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-zinc-200">{d.model || d.serial}</span>
|
||||
{d.transport ? (
|
||||
<span className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-[9px] uppercase text-zinc-500">
|
||||
{d.transport}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[10px] text-zinc-600">{d.serial}</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -296,134 +288,20 @@ export function LiveAndroidWorkstation({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-violet-500/20 bg-gradient-to-b from-violet-500/[0.08] to-black/50">
|
||||
<div className="flex flex-wrap gap-2 border-b border-white/10 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMirrorMode("host")}
|
||||
className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
|
||||
mirrorMode === "host"
|
||||
? "bg-violet-500/25 text-violet-100 ring-1 ring-violet-400/40"
|
||||
: "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{t("宿主投屏 (板子 ws-scrcpy)", "Host ws-scrcpy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMirrorMode("webusb")}
|
||||
className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
|
||||
mirrorMode === "webusb"
|
||||
? "bg-cyan-500/20 text-cyan-100 ring-1 ring-cyan-400/35"
|
||||
: "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
WebUSB · Panda
|
||||
</button>
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 self-center">
|
||||
<a
|
||||
href={LINK_SCRCPY}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-emerald-300"
|
||||
>
|
||||
scrcpy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href={LINK_WS_SCRCPY}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-violet-300"
|
||||
>
|
||||
ws-scrcpy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<a
|
||||
href={LINK_PANDA_GH}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-[10px] text-zinc-500 hover:text-cyan-300"
|
||||
>
|
||||
panda-web-scrcpy <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{mirrorMode === "host" ? (
|
||||
<div>
|
||||
<div className="space-y-2 border-b border-white/5 px-4 py-3">
|
||||
<p className="text-[11px] leading-relaxed text-zinc-400">
|
||||
{t(
|
||||
"板载 ws-scrcpy 同时只允许一个浏览器会话:多标签/总览与安卓页各嵌一个 iframe 会互相抢连接。请只保留一个投屏页,或反复点「重载投屏」。已打补丁的安装会踢掉旧连接以便嵌入。黑屏请先 Wake(会连发唤醒+滑动+Home)。",
|
||||
"Only one ws-scrcpy browser session: multiple embedded iframes fight for the stream—keep one tab or use Reload. Patched installs replace the old session. Black screen: try Wake (wake+swipe+home), then Reload or New tab.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{adbAvailable && androidSerial ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("wake")}
|
||||
className="rounded-lg bg-amber-500/20 px-3 py-1.5 text-[11px] text-amber-100 ring-1 ring-amber-500/30"
|
||||
>
|
||||
Wake
|
||||
</button>
|
||||
) : null}
|
||||
{scrcpyUrl ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMirrorReloadKey((k) => k + 1)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-zinc-200"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{t("重载投屏", "Reload mirror")}
|
||||
</button>
|
||||
<a
|
||||
href={scrcpyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
|
||||
>
|
||||
{t("新窗口打开", "New tab")}
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{scrcpyUrl ? (
|
||||
<iframe
|
||||
key={`ws-scrcpy-${mirrorReloadKey}`}
|
||||
title="ws-scrcpy"
|
||||
src={hostMirrorSrc}
|
||||
className="h-[min(72vh,640px)] w-full bg-black"
|
||||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-sm text-zinc-500">{t("未配置 ws-scrcpy 地址", "ws-scrcpy URL missing")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="border-b border-white/5 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
|
||||
{t(
|
||||
"Panda / WebUSB:手机必须插在您正在打开本页的电脑 USB 上(与板载 ADB 无关)。与官方 scrcpy 协议生态同源,适合本地调试。仓库:",
|
||||
"Panda WebUSB: phone plugs into the PC running this browser. Same scrcpy-protocol family. Repo: ",
|
||||
)}
|
||||
<a className="text-cyan-300 hover:underline" href={LINK_PANDA_GH} target="_blank" rel="noreferrer">
|
||||
PandaTestGrid/panda-web-scrcpy
|
||||
</a>
|
||||
</p>
|
||||
<iframe
|
||||
key={`panda-${mirrorReloadKey}`}
|
||||
title="panda-web-scrcpy"
|
||||
src={PANDA_WEB_SCRCPY}
|
||||
className="h-[min(72vh,640px)] w-full bg-black"
|
||||
allow="fullscreen; usb"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LiveAndroidStreamConsole
|
||||
t={t}
|
||||
apiBase={apiBase}
|
||||
fetchJson={fetchJson}
|
||||
serial={androidSerial}
|
||||
devices={devices}
|
||||
setSerial={setAndroidSerial}
|
||||
adbAvailable={adbAvailable}
|
||||
lock={lock}
|
||||
overview={overview}
|
||||
onAndroidAction={onAndroidAction}
|
||||
notify={notify}
|
||||
onRefreshDevices={onRefreshDevices}
|
||||
/>
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
|
||||
@@ -81,7 +81,6 @@ type HubDashboard = {
|
||||
admin_password?: string;
|
||||
note_zh?: string;
|
||||
};
|
||||
neko_firefox_enabled?: boolean;
|
||||
};
|
||||
services?: Array<{
|
||||
service_id: string;
|
||||
@@ -93,7 +92,10 @@ type HubDashboard = {
|
||||
detail?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
android?: { adb_available: boolean; devices: Array<{ serial: string; model?: string; state: string }> };
|
||||
android?: {
|
||||
adb_available: boolean;
|
||||
devices: Array<{ serial: string; model?: string; state: string; transport?: string }>;
|
||||
};
|
||||
live?: {
|
||||
backend_mode?: string;
|
||||
processes?: Array<{
|
||||
@@ -131,11 +133,6 @@ type HubDashboard = {
|
||||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
};
|
||||
neko_firefox?: {
|
||||
port?: number;
|
||||
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -314,11 +311,6 @@ export default function LiveControlApp() {
|
||||
return o;
|
||||
}, [dash?.stack?.quick_links]);
|
||||
|
||||
const scrcpyUrl = useMemo(
|
||||
() => (ql.ws_scrcpy ? normalizeBrowserReachableHttpUrl(ql.ws_scrcpy) : ""),
|
||||
[ql.ws_scrcpy],
|
||||
);
|
||||
|
||||
const runService = async (service: string, action: "start" | "stop" | "restart") => {
|
||||
setBusy(`svc:${service}`);
|
||||
try {
|
||||
@@ -827,7 +819,7 @@ export default function LiveControlApp() {
|
||||
<h3 className="text-sm font-semibold text-white">Neko {t("登录", "sign-in")}</h3>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{dash.stack.neko_login.note_zh ||
|
||||
t("Chromium :9200 与 Firefox :9201(若启用)共用下列账号策略。", "Chromium :9200 and Firefox :9201 share the same policy.")}
|
||||
t("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
|
||||
</p>
|
||||
<p className="mt-2 text-[11px] text-amber-200/85">
|
||||
{t(
|
||||
@@ -1050,38 +1042,6 @@ export default function LiveControlApp() {
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
{dash.probes.neko_firefox ? (
|
||||
(() => {
|
||||
const p = dash.probes?.neko_firefox;
|
||||
const tcpOk = p?.tcp?.reachable;
|
||||
const http = p?.http;
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
|
||||
<p className="text-xs font-semibold text-white">Neko Firefox</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t(
|
||||
"第二桌面(ENABLE_NEKO_FF=1);口令与 Chromium 相同。",
|
||||
"Second desktop (ENABLE_NEKO_FF=1); same passwords as Chromium.",
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-[11px] text-zinc-500">
|
||||
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t("延迟", "RTT")}{" "}
|
||||
{http?.latency_ms != null
|
||||
? `${http.latency_ms} ms`
|
||||
: p?.tcp?.latency_ms != null
|
||||
? `${p.tcp.latency_ms} ms`
|
||||
: "—"}
|
||||
</p>
|
||||
{!http?.reachable && http?.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1132,14 +1092,6 @@ export default function LiveControlApp() {
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
{s.service_id === "neko_firefox" ? (
|
||||
<p className="mt-2 text-[11px] leading-snug text-fuchsia-200/80">
|
||||
{t(
|
||||
"第二套 Neko 桌面(Firefox);ENABLE_NEKO_FF=1。登录:昵称任意,密码 = NEKO_USER_PASS / NEKO_ADMIN_PASS(与 Chromium 相同,默认 12345678)。",
|
||||
"Second Neko desktop (Firefox). Login: any display name; password = NEKO_USER_PASS / NEKO_ADMIN_PASS (same as Chromium).",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{s.url ? (
|
||||
<a
|
||||
@@ -1245,16 +1197,17 @@ export default function LiveControlApp() {
|
||||
t={t}
|
||||
notify={notify}
|
||||
fetchJson={fetchJson}
|
||||
apiBase={base}
|
||||
busy={busy}
|
||||
androidSerial={androidSerial}
|
||||
setAndroidSerial={setAndroidSerial}
|
||||
devices={dash?.android?.devices || []}
|
||||
adbAvailable={!!dash?.android?.adb_available}
|
||||
scrcpyUrl={scrcpyUrl}
|
||||
apkPath={apkPath}
|
||||
setApkPath={setApkPath}
|
||||
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
|
||||
onCopy={copyText}
|
||||
onRefreshDevices={() => void refreshDashboard()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
defaultPm2NameForChannel,
|
||||
loadYoutubeProChannels,
|
||||
newChannelId,
|
||||
parseYoutubeProChannelList,
|
||||
pushYoutubeProChannelsRemote,
|
||||
saveYoutubeProChannels,
|
||||
type YoutubeProChannel,
|
||||
} from "@/lib/youtubeProChannels";
|
||||
@@ -203,6 +205,7 @@ export function LiveYoutubeUnmannedView({
|
||||
setChannels((prev) => {
|
||||
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
|
||||
saveYoutubeProChannels(next);
|
||||
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -238,6 +241,7 @@ export function LiveYoutubeUnmannedView({
|
||||
setChannels((prev) => {
|
||||
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
|
||||
saveYoutubeProChannels(next);
|
||||
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -260,35 +264,57 @@ export function LiveYoutubeUnmannedView({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const list = loadYoutubeProChannels();
|
||||
const initial =
|
||||
list.length > 0
|
||||
? list
|
||||
: (() => {
|
||||
const id = newChannelId();
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name: "线路 1",
|
||||
streamKey: "",
|
||||
urlLines: "",
|
||||
pm2Name: defaultPm2NameForChannel(id),
|
||||
notes: "",
|
||||
},
|
||||
];
|
||||
})();
|
||||
setChannels(initial);
|
||||
if (!list.length) saveYoutubeProChannels(initial);
|
||||
}, []);
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchJson<{ channels?: unknown }>("/hub/youtube_pro_channels");
|
||||
const fromServer = parseYoutubeProChannelList(data.channels);
|
||||
if (fromServer.length && !cancelled) {
|
||||
setChannels(fromServer);
|
||||
saveYoutubeProChannels(fromServer);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* 使用本机缓存 */
|
||||
}
|
||||
if (cancelled) return;
|
||||
const list = loadYoutubeProChannels();
|
||||
const initial =
|
||||
list.length > 0
|
||||
? list
|
||||
: (() => {
|
||||
const id = newChannelId();
|
||||
return [
|
||||
{
|
||||
id,
|
||||
name: "线路 1",
|
||||
streamKey: "",
|
||||
urlLines: "",
|
||||
pm2Name: defaultPm2NameForChannel(id),
|
||||
notes: "",
|
||||
},
|
||||
];
|
||||
})();
|
||||
setChannels(initial);
|
||||
if (!list.length) saveYoutubeProChannels(initial);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchJson]);
|
||||
|
||||
useEffect(() => {
|
||||
if (channels.length && !activeCh) setActiveCh(channels[0].id);
|
||||
}, [channels, activeCh]);
|
||||
|
||||
const persistChannels = useCallback((next: YoutubeProChannel[]) => {
|
||||
setChannels(next);
|
||||
saveYoutubeProChannels(next);
|
||||
}, []);
|
||||
const persistChannels = useCallback(
|
||||
(next: YoutubeProChannel[]) => {
|
||||
setChannels(next);
|
||||
saveYoutubeProChannels(next);
|
||||
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
|
||||
},
|
||||
[fetchJson],
|
||||
);
|
||||
|
||||
const active = channels.find((c) => c.id === activeCh) || channels[0];
|
||||
|
||||
@@ -373,6 +399,7 @@ export function LiveYoutubeUnmannedView({
|
||||
setChannels((prev) => {
|
||||
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
|
||||
saveYoutubeProChannels(next);
|
||||
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user