This commit is contained in:
eric
2026-03-29 02:57:08 -05:00
parent ef87c43b33
commit e3ac026710
15 changed files with 799 additions and 222 deletions

View File

@@ -870,8 +870,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
</h1>
<p className="mt-3 max-w-3xl text-sm text-slate-400">
{tr(
"ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI 采集Neko / Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP,采集卡可走视频进 Linux。各模块独立,可分别启停。",
"ShellCrash for egress; FFmpeg for stream or HDMI; Neko/Chromium for browser; USB ADB to AOSP plus optional capture card. Modules are isolated.",
"ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMIChromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立可分别启停。",
"ShellCrash for egress; FFmpeg for stream or HDMI; Chromium for browser; USB ADB to AOSP. Modules are isolated.",
)}
</p>
</div>
@@ -971,8 +971,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
</li>
<li>
{tr(
"「直播业务」里启停 PM2 脚本抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy、Neko 等是否在跑。",
"Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, Neko, etc.",
"「直播业务」里启停 PM2 脚本抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy 等是否在跑。",
"Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, etc.",
)}
</li>
<li>
@@ -995,8 +995,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
<h2 className="section-title">{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}</h2>
<p className="section-lead">
{tr(
"ws-scrcpy浏览器里控安卓Chromium本机+油猴+RemoteDebugNeko可选 Docker 远程桌面开 YouTube。",
"ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.",
"ws-scrcpy浏览器里控安卓Chromium本机+油猴+RemoteDebug。",
"ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222.",
)}
</p>
<div className="mt-4 flex max-h-[28rem] flex-col gap-2 overflow-y-auto pr-1 log-scroll">
@@ -1028,11 +1028,6 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")}
</a>
) : null}
{qlNorm.neko_browser ? (
<a className="link-tile link-tile-warn" href={qlNorm.neko_browser} target="_blank" rel="noreferrer">
🐱 Neko / {tr("Docker 浏览器桌面", "Docker browser desktop")}
</a>
) : null}
{qlNorm.webtty ? (
<a className="link-tile" href={qlNorm.webtty} target="_blank" rel="noreferrer">
WebTTY
@@ -1501,8 +1496,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
</div>
<p className="mt-2 text-xs leading-relaxed text-slate-500">
{tr(
"内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开Neko 在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。",
"Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko needs ENABLE_NEKO and Docker (often :9200).",
"内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开。",
"Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab.",
)}
</p>
<iframe className="mt-4 h-[30rem] w-full rounded-2xl border border-white/10 bg-black" src={rawAndroidPanelUrl} title="ws-scrcpy" />

View File

@@ -8,6 +8,7 @@ import {
MousePointer2,
Pause,
Play,
Radio,
RefreshCw,
Smartphone,
Sparkles,
@@ -16,6 +17,9 @@ import {
} 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;
@@ -35,6 +39,41 @@ 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,
@@ -101,6 +140,7 @@ type Props = {
};
const SCRCPY_URL = "https://github.com/Genymobile/scrcpy";
const ESCRCPY_URL = "https://github.com/viarotel-org/escrcpy";
export function LiveAndroidStreamConsole({
t,
@@ -128,8 +168,20 @@ export function LiveAndroidStreamConsole({
const [pairCode, setPairCode] = useState("");
const [connBusy, setConnBusy] = useState(false);
const [bookmarks, setBookmarks] = useState<string[]>([]);
const [scrcpyStream, setScrcpyStream] = useState(false);
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);
@@ -194,10 +246,97 @@ export function LiveAndroidStreamConsole({
}, [apiBase, adbAvailable, serial]);
useEffect(() => {
if (!adbAvailable || !serial || liveMs <= 0) return;
if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream) return;
const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs);
return () => clearInterval(id);
}, [adbAvailable, liveMs, serial]);
}, [adbAvailable, liveMs, serial, scrcpyStream]);
useEffect(() => {
if (!adbAvailable) {
setScrcpyInfo(null);
return;
}
let cancelled = false;
void (async () => {
try {
const res = await fetch(`${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(() => {
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) {
@@ -318,10 +457,41 @@ export function LiveAndroidStreamConsole({
e.preventDefault();
void (async () => {
await onAndroidAction(hit[0], hit[1]);
bumpFrame();
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;
@@ -488,25 +658,40 @@ export function LiveAndroidStreamConsole({
</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 ",
"统一适配 USB / Redroid / 无线 adb可选官方 scrcpy-server 经 WebSocket 的 H.264 真流(与桌面 ",
"USB / Redroid / wireless adb: optional real H.264 from official scrcpy-server over WebSocket (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("。", ".")}
{t(");截图轮询作备用。", "); polled PNG as fallback.")}
</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 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 ? (
@@ -614,7 +799,41 @@ export function LiveAndroidStreamConsole({
<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;
notify(
next
? t("已请求 Scrcpy 真流WebSocket", "Scrcpy WebSocket stream starting")
: t("已关闭 Scrcpy 真流", "Scrcpy stream stopped"),
);
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("Scrcpy 真流", "Scrcpy WS")}
</button>
</div>
{scrcpyInfo && !scrcpyInfo.server_jar ? (
<p className="mt-2 text-[10px] text-amber-200/80">
{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}
</p>
) : null}
<p className="mt-2 text-[10px] text-zinc-600">
{t(
"快捷键镜像区聚焦时H Home · B Back · R 多任务 · W 唤醒 · M 菜单",
@@ -637,38 +856,56 @@ export function LiveAndroidStreamConsole({
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}
/>
{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>
{liveMs > 0 ? (
{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>
)}
{imgErr ? <p className="mt-3 text-center text-xs text-rose-300">{imgErr}</p> : null}
{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>

View File

@@ -74,13 +74,6 @@ type HubDashboard = {
platform?: string;
ips?: string[];
quick_links?: Record<string, string>;
neko_login?: {
user_login?: string;
user_password?: string;
admin_login?: string;
admin_password?: string;
note_zh?: string;
};
};
services?: Array<{
service_id: string;
@@ -128,11 +121,6 @@ type HubDashboard = {
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
http_ui?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
};
neko?: {
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 };
};
};
};
@@ -814,42 +802,6 @@ export default function LiveControlApp() {
</div>
) : null}
{dash?.stack?.neko_login ? (
<div className="rounded-2xl border border-fuchsia-500/25 bg-gradient-to-r from-fuchsia-500/[0.08] to-violet-500/[0.06] p-5">
<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("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
</p>
<p className="mt-2 text-[11px] text-amber-200/85">
{t(
"重要:两栏分开填——昵称随意,密码必须是环境变量里的口令(默认 12345678。用错密码会像一直加载勿把「用户名/密码」写在一行。",
"Use two fields: display name is free-form; password must match NEKO_* (default 12345678). Wrong password looks like infinite loading.",
)}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("用户", "User")}</p>
<p className="mt-1 text-fuchsia-200">{dash.stack.neko_login.user_login ?? "live"}</p>
<p className="mt-2 text-[10px] uppercase text-zinc-500">{t("口令", "Password")}</p>
<p className="mt-0.5 text-fuchsia-100">{dash.stack.neko_login.user_password ?? "—"}</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("管理员", "Admin")}</p>
<p className="mt-1 text-violet-200">{dash.stack.neko_login.admin_login ?? "admin"}</p>
<p className="mt-2 text-[10px] uppercase text-zinc-500">{t("口令", "Password")}</p>
<p className="mt-0.5 text-violet-100">{dash.stack.neko_login.admin_password ?? "—"}</p>
</div>
</div>
<p className="mt-3 text-[10px] text-zinc-600">
{t(
"Google 无官方 ARM 桌面 ChromeNeko 使用 Chromium / Firefox 多架构镜像(非 Chrome 品牌)。",
"No official ARM desktop Chrome; Neko uses Chromium/Firefox multi-arch images.",
)}
</p>
</div>
) : null}
{dash?.network?.interfaces?.length ? (
<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">
@@ -1010,38 +962,6 @@ export default function LiveControlApp() {
);
})()
) : null}
{dash.probes.neko ? (
(() => {
const p = dash.probes?.neko;
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 Chromium</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"Docker 内 Chromium非 Chrome 品牌arm64/x86 同一镜像。",
"Chromium in Docker (not Chrome branding); multi-arch.",
)}
</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>
@@ -1229,7 +1149,7 @@ export default function LiveControlApp() {
<p className={`mt-2 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
{t(
"透明代理与规则分流直接在本页完成,无需跳转;流媒体与其它系统服务请在「服务」页管理。",
"Proxy rules and YAML editing are embedded here. Use Services for SRS/Neko/etc.",
"Proxy rules and YAML editing are embedded here. Use Services for SRS, Chromium, etc.",
)}
</p>
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
@@ -1294,7 +1214,7 @@ export default function LiveControlApp() {
</label>
<label className="flex cursor-pointer items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
<div>
<p className="text-sm font-medium text-white">{t("总览显示 SRS / Neko 探针", "Show SRS/Neko probes")}</p>
<p className="text-sm font-medium text-white">{t("总览显示 SRS 探针", "Show SRS stream probe")}</p>
<p className="text-[11px] text-zinc-500">{t("关闭可精简首页", "Hide stream probe cards")}</p>
</div>
<input

View File

@@ -23,24 +23,24 @@ const PRESETS: HdmiPreset[] = [
labelZh: "720p @ 60Hz",
labelEn: "720p @ 60Hz",
spec: "1280x720, 59.94/60Hz, RGB/YUV 有限",
hintZh: "板端 HDMI 输出与采集卡 EDID 对齐OBS 画布 1280×720。",
hintEn: "Match SoC HDMI timing to capture card EDID; OBS 1280×720.",
hintZh: "板端 HDMI 输出时序与显示端一致;下游画布建议 1280×720。",
hintEn: "Match SoC HDMI timing to the display; downstream canvas e.g. 1280×720.",
},
{
id: "1080p30",
labelZh: "1080p @ 30Hz",
labelEn: "1080p @ 30Hz",
spec: "1920x1080, 30Hz, 适合 USB 采集带宽紧张场景",
hintZh: "USB3 采集常更稳;检查线缆长度与屏蔽。",
hintEn: "Often more stable on USB3 capture; check cable quality.",
spec: "1920x1080, 30Hz",
hintZh: "带宽紧张时可优先 1080p30检查线材与接口规格。",
hintEn: "Prefer 1080p30 when bandwidth is tight; check cable and port specs.",
},
{
id: "1080p60",
labelZh: "1080p @ 60Hz",
labelEn: "1080p @ 60Hz",
spec: "1920x1080, 60Hz, 高刷转播",
hintZh: "需确认采集卡与 SoC 同时支持 1080p60过热会掉帧。",
hintEn: "Requires 1080p60 on both ends; thermal throttling may drop frames.",
hintZh: "两端均需稳定支持 1080p60长时间运行注意散热。",
hintEn: "Both ends need stable 1080p60; mind thermals for long runs.",
},
];
@@ -176,8 +176,6 @@ export function LiveTiktokHdmiView({
const checklist: { id: string; zh: string; en: string }[] = [
{ id: "cable", zh: "HDMI 线材插紧、方向正确(如需转接头确认规格)", en: "HDMI cable seated; adapter specs OK" },
{ id: "edid", zh: "采集卡与板端分辨率/刷新率一致(见上方预设)", en: "Capture + SoC timing match preset" },
{ id: "usb", zh: "USB 采集盒接 USB3 口,避免过长无屏蔽线", en: "USB3 capture; avoid bad cables" },
{ id: "thermal", zh: "长时间推流注意散热,过热会掉帧黑屏", en: "Thermal headroom for long runs" },
];
@@ -188,8 +186,8 @@ export function LiveTiktokHdmiView({
t={t}
title={t("TikTok HDMI 硬件链路", "TikTok HDMI hardware chain")}
subtitle={t(
"板端编码到采集卡再到安卓;与「网络」页顶卡相同的箭头风格。",
"Encode → HDMI → capture → Android; same arrow style as Network header.",
"板端编码经 HDMI 到显示/下游设备;与「网络」页顶卡相同的箭头风格。",
"Encode on SoC → HDMI → display/downstream; same arrow style as Network header.",
)}
onNavigate={onNavigate}
steps={[
@@ -199,22 +197,11 @@ export function LiveTiktokHdmiView({
gradient: "from-cyan-400 to-blue-500",
target: "live_tiktok",
},
{
label: "File Browser",
sub: t("本地文件", "Local files"),
gradient: "from-amber-400 to-orange-500",
target: "files",
},
{
label: "HDMI",
sub: t("板载输出", "SoC out"),
gradient: "from-violet-500 to-fuchsia-500",
},
{
label: t("采集卡", "Capture card"),
sub: "USB / PCIe",
gradient: "from-emerald-400 to-teal-500",
},
{
label: "Android",
sub: t("AOSP / 手机", "AOSP"),
@@ -350,13 +337,13 @@ export function LiveTiktokHdmiView({
</li>
))}
</ul>
<label className="mt-4 block text-[11px] text-zinc-500">{t("采集卡 / EDID / 走线备忘", "Capture / EDID notes")}</label>
<label className="mt-4 block text-[11px] text-zinc-500">{t("HDMI / 走线备忘", "HDMI / wiring notes")}</label>
<textarea
className="mt-1 min-h-[72px] w-full resize-y rounded-lg border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
value={hdmiNotes}
onChange={(e) => persistNotes(e.target.value)}
spellCheck={false}
placeholder="Magewell / OBS 视频采集设备 …"
placeholder={t("接口、线长、显示器型号…", "Ports, cable length, display model…")}
/>
</div>
</div>

View File

@@ -241,7 +241,11 @@ export function LiveYoutubeUnmannedView({
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
void pushYoutubeProChannelsRemote(fetchJson, next).catch((e) => {
notify(
`${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`,
);
});
return next;
});
}
@@ -311,9 +315,13 @@ export function LiveYoutubeUnmannedView({
(next: YoutubeProChannel[]) => {
setChannels(next);
saveYoutubeProChannels(next);
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
void pushYoutubeProChannelsRemote(fetchJson, next).catch((e) => {
notify(
`${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`,
);
});
},
[fetchJson],
[fetchJson, notify, t],
);
const active = channels.find((c) => c.id === activeCh) || channels[0];
@@ -633,10 +641,10 @@ export function LiveYoutubeUnmannedView({
target: "live_youtube",
},
{
label: "Neko",
sub: t("Chromium 工作室", "Chromium"),
label: "Android",
sub: t("ADB 设备", "ADB"),
gradient: "from-emerald-400 to-teal-500",
target: "services",
target: "android",
},
]}
/>

View File

@@ -0,0 +1,28 @@
/**
* 将 HTTP API 根地址转为 WebSocket 根(用于 /android/scrcpy/ws
* next dev 下若未设置 NEXT_PUBLIC_API_URL浏览器会连到 :3000WebSocket 升级通常无法被中间件代理;
* 真流调试请设 NEXT_PUBLIC_API_URL 指向 uvicorn例如 http://127.0.0.1:8001
*/
export function httpApiBaseToWsBase(apiBase: string): string {
if (typeof window === "undefined") return "ws://127.0.0.1:8001";
const base = (apiBase || "").trim();
if (base) {
try {
const u = new URL(base, window.location.href);
const proto = u.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${u.host}`;
} catch {
/* ignore */
}
}
const { protocol, host } = window.location;
const proto = protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${host}`;
}
export function androidScrcpyWsUrl(apiBase: string, serial: string, maxSize = 1920): string {
const root = httpApiBaseToWsBase(apiBase).replace(/\/$/, "");
const q = new URLSearchParams({ serial, max_size: String(maxSize) });
return `${root}/android/scrcpy/ws?${q.toString()}`;
}

View File

@@ -0,0 +1,169 @@
/**
* 解析 scrcpy raw_streamAVCC4 字节大端长度 + NAL并用 WebCodecs 画到 Canvas。
* 与官方 scrcpy / escrcpy 设备端编码一致;浏览器需 Chromium 系且支持 VideoDecoderH.264)。
*/
function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
const o = new Uint8Array(a.length + b.length);
o.set(a, 0);
o.set(b, a.length);
return o;
}
function readU32be(b: Uint8Array, o: number): number {
return (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];
}
function buildAvcC(sps: Uint8Array, pps: Uint8Array): Uint8Array {
const spsBody = sps.subarray(1);
const ppsBody = pps.subarray(1);
const out = new Uint8Array(11 + spsBody.length + 3 + ppsBody.length);
let p = 0;
out[p++] = 1;
out[p++] = sps[1];
out[p++] = sps[2];
out[p++] = sps[3];
out[p++] = 0xfc | 3;
out[p++] = 0xe0 | 1;
out[p++] = (spsBody.length >> 8) & 0xff;
out[p++] = spsBody.length & 0xff;
out.set(spsBody, p);
p += spsBody.length;
out[p++] = 1;
out[p++] = (ppsBody.length >> 8) & 0xff;
out[p++] = ppsBody.length & 0xff;
out.set(ppsBody, p);
return out;
}
function codecStringFromSps(sps: Uint8Array): string {
const h = (n: number) => n.toString(16).padStart(2, "0").toUpperCase();
return `avc1.${h(sps[1])}${h(sps[2])}${h(sps[3])}`;
}
export class ScrcpyH264Decoder {
private buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
private decoder: VideoDecoder | null = null;
private configured = false;
private configuring = false;
private ts = 0;
private sps: Uint8Array | null = null;
private pps: Uint8Array | null = null;
constructor(
private readonly canvas: HTMLCanvasElement,
private readonly onDecodeError: (msg: string) => void,
) {}
reset(): void {
this.buf = new Uint8Array(0);
this.sps = null;
this.pps = null;
this.configured = false;
this.configuring = false;
this.ts = 0;
if (this.decoder) {
try {
this.decoder.close();
} catch {
/* ignore */
}
this.decoder = null;
}
}
push(chunk: ArrayBuffer): void {
const add = new Uint8Array(chunk.byteLength);
add.set(new Uint8Array(chunk));
this.buf = concatBuffers(this.buf, add);
this.drainAvcc();
}
private drainAvcc(): void {
const b = this.buf;
let o = 0;
while (o + 4 <= b.length) {
const len = readU32be(b, o);
if (len <= 0 || len > 6 * 1024 * 1024) {
this.buf = new Uint8Array(b.subarray(o + 1));
o = 0;
continue;
}
if (o + 4 + len > b.length) break;
const nal = b.subarray(o + 4, o + 4 + len);
o += 4 + len;
this.handleNal(nal);
}
if (o > 0) this.buf = new Uint8Array(b.subarray(o));
}
private handleNal(nal: Uint8Array): void {
if (nal.length < 2) return;
const t = nal[0] & 0x1f;
if (t === 7) this.sps = new Uint8Array(nal);
else if (t === 8) this.pps = new Uint8Array(nal);
if (this.sps && this.pps && !this.configured && !this.configuring) {
void this.configure();
}
if (!this.configured || !this.decoder) return;
if (t !== 1 && t !== 5 && t !== 7 && t !== 8) return;
if (t === 7 || t === 8) return;
const key = t === 5;
const data = new Uint8Array(4 + nal.length);
new DataView(data.buffer).setUint32(0, nal.length, false);
data.set(nal, 4);
try {
const chunk = new EncodedVideoChunk({
type: key ? "key" : "delta",
timestamp: this.ts,
data,
});
this.ts += 33_333;
this.decoder.decode(chunk);
} catch (e) {
this.onDecodeError((e as Error).message || String(e));
}
}
private async configure(): Promise<void> {
if (!this.sps || !this.pps || this.configured || this.configuring) return;
this.configuring = true;
try {
const avcc = buildAvcC(this.sps, this.pps);
const codec = codecStringFromSps(this.sps);
const dec = new VideoDecoder({
output: (frame) => {
const ctx = this.canvas.getContext("2d");
if (!ctx) {
frame.close();
return;
}
if (this.canvas.width !== frame.displayWidth || this.canvas.height !== frame.displayHeight) {
this.canvas.width = frame.displayWidth;
this.canvas.height = frame.displayHeight;
}
ctx.drawImage(frame, 0, 0);
frame.close();
},
error: (e) => this.onDecodeError(String((e as DOMException).message || e)),
});
await dec.configure({
codec,
description: avcc,
optimizeForLatency: true,
} as VideoDecoderConfig);
this.decoder = dec;
this.configured = true;
} catch (e) {
this.onDecodeError((e as Error).message || String(e));
} finally {
this.configuring = false;
}
}
}
export function webCodecsH264Supported(): boolean {
return typeof VideoDecoder !== "undefined" && typeof VideoFrame !== "undefined";
}

View File

@@ -24,8 +24,8 @@ export function parseYoutubeProChannelList(raw: unknown): YoutubeProChannel[] {
function migrateRow(x: Record<string, unknown>): YoutubeProChannel | null {
if (!x || typeof x !== "object") return null;
const id = String(x.id || "").trim();
const name = String(x.name || "").trim();
if (!id || !name) return null;
if (!id) return null;
const name = String(x.name || "").trim() || id;
return {
id,
name,