This commit is contained in:
eric
2026-05-19 06:47:07 +00:00
parent e1d7251545
commit 78e1308063
63 changed files with 2527 additions and 186 deletions

View File

@@ -39,6 +39,8 @@ export type DeviceRow = {
type TFn = (zh: string, en: string) => string;
const BOOKMARKS_KEY = "live-hub-adb-bookmarks-v1";
const SCREENSHOT_FALLBACK_INTERVAL_MS = 450;
const SCRCPY_FIRST_FRAME_TIMEOUT_MS = 5200;
function clientPointToCanvasPixel(
canvas: HTMLCanvasElement,
@@ -195,6 +197,9 @@ export function LiveAndroidStreamConsole({
const decoderRef = useRef<ScrcpyH264Decoder | null>(null);
const mirrorFocusRef = useRef<HTMLDivElement | null>(null);
const shotFailRef = useRef(0);
const scrcpyExpectedCloseRef = useRef(false);
const scrcpyFirstFrameTimerRef = useRef<number | null>(null);
const scrcpyGotFrameRef = useRef(false);
const activeTransport = devices.find((d) => d.serial === serial)?.transport;
const useFastShot = liveMs > 0;
@@ -207,6 +212,13 @@ export function LiveAndroidStreamConsole({
setFrameTs((n) => n + 1);
}, []);
const clearScrcpyFirstFrameTimer = useCallback(() => {
if (scrcpyFirstFrameTimerRef.current != null) {
window.clearTimeout(scrcpyFirstFrameTimerRef.current);
scrcpyFirstFrameTimerRef.current = null;
}
}, []);
useEffect(() => {
const onVis = () => setPageVisible(document.visibilityState === "visible");
onVis();
@@ -304,6 +316,7 @@ export function LiveAndroidStreamConsole({
useEffect(() => {
setScrcpyUserStoppedForSerial(null);
scrcpyExpectedCloseRef.current = false;
}, [serial]);
useEffect(() => {
@@ -343,6 +356,9 @@ export function LiveAndroidStreamConsole({
useEffect(() => {
if (!scrcpyStream || !serial || !adbAvailable) {
clearScrcpyFirstFrameTimer();
scrcpyExpectedCloseRef.current = true;
scrcpyGotFrameRef.current = false;
wsRef.current?.close();
wsRef.current = null;
decoderRef.current?.reset();
@@ -358,9 +374,29 @@ export function LiveAndroidStreamConsole({
const canvas = canvasRef.current;
if (!canvas) return;
const dec = new ScrcpyH264Decoder(canvas, (msg) => setScrcpyErr(msg));
const fallbackToScreenshot = (message: string) => {
clearScrcpyFirstFrameTimer();
scrcpyExpectedCloseRef.current = true;
wsRef.current?.close();
setScrcpyErr(message);
setScrcpyStream(false);
setLiveMs(SCREENSHOT_FALLBACK_INTERVAL_MS);
setScrcpyUserStoppedForSerial(serial);
window.setTimeout(() => bumpFrame(), 120);
};
const dec = new ScrcpyH264Decoder(
canvas,
(msg) => fallbackToScreenshot(`${t("实时画面解码失败,已切回截图模式", "Live stream decode failed, using screenshot mode")}: ${msg}`),
() => {
scrcpyGotFrameRef.current = true;
clearScrcpyFirstFrameTimer();
},
);
decoderRef.current = dec;
scrcpyExpectedCloseRef.current = false;
scrcpyGotFrameRef.current = false;
const wsUrl = androidScrcpyWsUrl(apiBase, serial);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -369,6 +405,11 @@ export function LiveAndroidStreamConsole({
ws.onopen = () => {
setScrcpyConnected(true);
setScrcpyErr(null);
clearScrcpyFirstFrameTimer();
scrcpyFirstFrameTimerRef.current = window.setTimeout(() => {
if (scrcpyGotFrameRef.current) return;
fallbackToScreenshot(t("实时画面无首帧,已切回截图模式", "No first live frame, using screenshot mode"));
}, SCRCPY_FIRST_FRAME_TIMEOUT_MS);
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
@@ -389,15 +430,22 @@ export function LiveAndroidStreamConsole({
setScrcpyConnected(false);
decoderRef.current?.reset();
decoderRef.current = null;
clearScrcpyFirstFrameTimer();
if (scrcpyStream && !scrcpyExpectedCloseRef.current) {
fallbackToScreenshot(t("实时画面连接失败,已切回截图模式", "Live stream failed, using screenshot mode"));
}
};
return () => {
clearScrcpyFirstFrameTimer();
scrcpyExpectedCloseRef.current = true;
scrcpyGotFrameRef.current = false;
ws.close();
wsRef.current = null;
dec.reset();
if (decoderRef.current === dec) decoderRef.current = null;
};
}, [scrcpyStream, serial, adbAvailable, apiBase, t]);
}, [scrcpyStream, serial, adbAvailable, apiBase, bumpFrame, clearScrcpyFirstFrameTimer, t]);
useEffect(() => {
if (serial && adbAvailable) {

View File

@@ -118,9 +118,17 @@ type HubDashboard = {
tx_bytes?: number;
rx_packets?: number;
tx_packets?: number;
rx_bps?: number;
tx_bps?: number;
sample_interval_seconds?: number;
speed_mbps?: number | null;
operstate?: string;
}>;
total_rx_bytes?: number;
total_tx_bytes?: number;
total_rx_bps?: number;
total_tx_bps?: number;
sample_interval_seconds?: number;
};
live_events?: {
pm2_tail?: string[];
@@ -221,6 +229,11 @@ function formatBytes(value?: number) {
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
}
function formatRate(value?: number) {
const text = formatBytes(value);
return text === "—" ? text : `${text}/s`;
}
function statusPill(status: string) {
if (status === "online" || status === "running")
return "bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30";
@@ -1181,11 +1194,26 @@ export default function LiveControlApp() {
<div className="rounded-2xl border border-cyan-500/15 bg-gradient-to-br from-cyan-500/[0.07] to-transparent p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Wifi className="h-4 w-4 text-cyan-300" />
{t("网络流量(累计)", "Network I/O (cumulative)")}
{t("网络带宽 / 流量", "Network bandwidth / traffic")}
</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{t("自开机以来各接口收发字节;非实时速率。", "Per-interface RX/TX since boot (not a live bitrate).")}
{t(
"实时速率由总览自动刷新间隔计算;累计值来自系统网卡计数器。",
"Live rates are calculated from dashboard refresh deltas; totals come from kernel counters.",
)}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3 font-mono text-[11px]">
<p className="text-[10px] uppercase tracking-[0.08em] text-cyan-300/70">{t("实时下行", "Live down")}</p>
<p className="mt-1 text-lg font-semibold text-cyan-100">{formatRate(dash.network.total_rx_bps)}</p>
<p className="mt-1 text-zinc-500">{t("累计", "Total")}: {formatBytes(dash.network.total_rx_bytes)}</p>
</div>
<div className="rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3 font-mono text-[11px]">
<p className="text-[10px] uppercase tracking-[0.08em] text-cyan-300/70">{t("实时上行", "Live up")}</p>
<p className="mt-1 text-lg font-semibold text-cyan-100">{formatRate(dash.network.total_tx_bps)}</p>
<p className="mt-1 text-zinc-500">{t("累计", "Total")}: {formatBytes(dash.network.total_tx_bytes)}</p>
</div>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(dash.network.interfaces || []).slice(0, 6).map((iface) => (
<div
@@ -1198,11 +1226,13 @@ export default function LiveControlApp() {
{iface.speed_mbps ? `${iface.speed_mbps} Mbps` : t("未知速率", "unknown speed")}
</p>
<p className="mt-2 text-zinc-500">
{formatBytes(iface.rx_bytes)} <span className="text-zinc-600">·</span>{" "}
{formatRate(iface.rx_bps)} <span className="text-zinc-600">·</span>{" "}
{formatBytes(iface.rx_bytes)} <span className="text-zinc-600">·</span>{" "}
{iface.rx_packets?.toLocaleString() ?? "—"} pkt
</p>
<p className="mt-0.5 text-zinc-500">
{formatBytes(iface.tx_bytes)} <span className="text-zinc-600">·</span>{" "}
{formatRate(iface.tx_bps)} <span className="text-zinc-600">·</span>{" "}
{formatBytes(iface.tx_bytes)} <span className="text-zinc-600">·</span>{" "}
{iface.tx_packets?.toLocaleString() ?? "—"} pkt
</p>
</div>