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

@@ -1,21 +0,0 @@
{
"id": "neko",
"layer": "infra",
"enabled": true,
"manager": "docker",
"ports": { "http": 9200 },
"env": {
"ENABLE_NEKO": "1",
"NEKO_HTTP_PORT": "9200",
"NEKO_USER_PASS": "12345678",
"NEKO_ADMIN_PASS": "12345678",
"NEKO_PROFILE_HOST_DIR": "",
"NEKO_SHM_SIZE": "2gb",
"NEKO_DESKTOP_SCREEN": "1920x1080@30",
"NEKO_WEBRTC_EPR": "52000-52100",
"NEKO_WEBRTC_UDP_RANGE": "52000-52100",
"NEKO_WEBRTC_ICELITE": "1",
"NEKO_WEBRTC_NAT1TO1": "",
"NEKO_PLUGINS_ENABLED": "true"
}
}

View File

@@ -89,12 +89,6 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = (
"Browser ADB control (ws-scrcpy stack; clone URL configurable in system-stack.env)", "Browser ADB control (ws-scrcpy stack; clone URL configurable in system-stack.env)",
), ),
ServiceSpec("chromium", "Chromium 浏览器", "browser", "本机 Chromium + 持久化配置 + Remote DevTools非 Docker"), ServiceSpec("chromium", "Chromium 浏览器", "browser", "本机 Chromium + 持久化配置 + Remote DevTools非 Docker"),
ServiceSpec(
"neko",
"Neko 浏览器 (Docker)",
"browser",
"可选Docker 内嵌 Chromium 桌面,适合远程开 YouTube StudioENABLE_NEKO=1 时安装",
),
ServiceSpec( ServiceSpec(
"android_gateway", "android_gateway",
"Android Gateway", "Android Gateway",
@@ -572,11 +566,10 @@ def recent_pm2_log_lines(max_lines: int = 14) -> list[str]:
async def stack_stream_probes() -> dict[str, Any]: async def stack_stream_probes() -> dict[str, Any]:
"""本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。""" """本机 SRS HTTP 端口探测(供 live.local 验收)。"""
env = load_stack_env() env = load_stack_env()
srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080) srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080)
srs_api_p = int(env.get("SRS_API_PORT", "1985") or 1985) srs_api_p = int(env.get("SRS_API_PORT", "1985") or 1985)
neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
def run_srs() -> dict[str, Any]: def run_srs() -> dict[str, Any]:
@@ -596,16 +589,8 @@ async def stack_stream_probes() -> dict[str, Any]:
"api_ok": api_ok, "api_ok": api_ok,
} }
def run_neko() -> dict[str, Any]: srs_d = await loop.run_in_executor(None, run_srs)
tcp = _probe_tcp_port("127.0.0.1", neko_p) return {"srs": srs_d}
http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5)
return {"port": neko_p, "tcp": tcp, "http": http}
srs_d, neko_d = await asyncio.gather(
loop.run_in_executor(None, run_srs),
loop.run_in_executor(None, run_neko),
)
return {"srs": srs_d, "neko": neko_d}
def stack_summary() -> dict: def stack_summary() -> dict:
@@ -621,7 +606,6 @@ def stack_summary() -> dict:
srs_api = env.get("SRS_API_PORT", "1985") srs_api = env.get("SRS_API_PORT", "1985")
android_panel = env.get("ANDROID_PANEL_PORT", "5000") android_panel = env.get("ANDROID_PANEL_PORT", "5000")
chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222") chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222")
neko_port = env.get("NEKO_HTTP_PORT", "9200")
ql: dict[str, str] = { ql: dict[str, str] = {
"control_plane": f"http://{mdns_host}:{web_port}", "control_plane": f"http://{mdns_host}:{web_port}",
"webtty": f"http://{mdns_host}:{webtty}", "webtty": f"http://{mdns_host}:{webtty}",
@@ -631,7 +615,6 @@ def stack_summary() -> dict:
"srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions", "srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions",
"ws_scrcpy": f"http://{mdns_host}:{android_panel}", "ws_scrcpy": f"http://{mdns_host}:{android_panel}",
"chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}", "chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}",
"neko_browser": f"http://{mdns_host}:{neko_port}",
} }
return { return {
"hostname": hostname, "hostname": hostname,
@@ -644,17 +627,6 @@ def stack_summary() -> dict:
"dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}", "dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}",
"control_url": f"http://{mdns_host}:{web_port}", "control_url": f"http://{mdns_host}:{web_port}",
"stack_env_present": STACK_ENV.is_file(), "stack_env_present": STACK_ENV.is_file(),
"neko_login": {
"user_login": "live",
"user_password": env.get("NEKO_USER_PASS", "12345678"),
"admin_login": "admin",
"admin_password": env.get("NEKO_ADMIN_PASS", "12345678"),
"note_zh": (
"Neko 为 multiuser 模式:「显示昵称」可任意填写(如 live「密码」必须与 NEKO_USER_PASS / NEKO_ADMIN_PASS "
"完全一致(默认 12345678。不要把 live/12345678 填进同一格。密码错误时会一直卡在连接中。"
" WebRTC 需 NEKO_WEBRTC_NAT1TO1 为板子局域网 IP并放行 UDP 端口段。"
),
},
"quick_links": ql, "quick_links": ql,
} }

View File

@@ -49,19 +49,24 @@ class BusinessDouyinPatch(BaseModel):
class YoutubeProChannelsBody(BaseModel): class YoutubeProChannelsBody(BaseModel):
channels: list[dict[str, Any]] = Field(default_factory=list) channels: list[dict[str, Any]] = Field(default_factory=list)
@field_validator("channels") @field_validator("channels", mode="after")
@classmethod @classmethod
def _validate_channels(cls, v: list[Any]) -> list[Any]: def _validate_channels(cls, v: list[Any]) -> list[dict[str, Any]]:
if len(v) > 64: if len(v) > 64:
raise ValueError("at most 64 channels") raise ValueError("at most 64 channels")
out: list[dict[str, Any]] = []
for item in v: for item in v:
if not isinstance(item, dict): if not isinstance(item, dict):
raise ValueError("each channel must be an object") raise ValueError("each channel must be an object")
if not str(item.get("id", "")).strip(): cid = str(item.get("id", "")).strip()
if not cid:
raise ValueError("channel.id required") raise ValueError("channel.id required")
if not str(item.get("name", "")).strip(): row = dict(item)
raise ValueError("channel.name required") row["id"] = cid
return v nm = str(row.get("name", "")).strip()
row["name"] = nm if nm else cid
out.append(row)
return out
@router.get("/dashboard") @router.get("/dashboard")

197
src/scrcpy_stream.py Normal file
View File

@@ -0,0 +1,197 @@
"""在宿主上拉起官方 scrcpy-serverraw_stream经 ADB forward 读 H.264,再推到 WebSocket。
与桌面端 [escrcpy](https://github.com/viarotel-org/escrcpy) / [scrcpy](https://github.com/Genymobile/scrcpy)
使用同一套 scrcpy-server版本号须与 jar 内 BuildConfig 完全一致(见官方 doc/develop.md
"""
from __future__ import annotations
import asyncio
import json
import os
import random
import re
import shutil
import socket
from pathlib import Path
from typing import Any
from src.android_control import _adb_prefix, _adb_text, _run_cmd, adb_available
BASE_DIR = Path(__file__).resolve().parent.parent
REMOTE_JAR = "/data/local/tmp/scrcpy-web-hub.jar"
_VENDOR_JAR = BASE_DIR / "vendor" / "scrcpy-server.jar"
_VERSION_FILE = BASE_DIR / "vendor" / "scrcpy-server.version"
def default_server_version() -> str:
v = (os.environ.get("SCRCPY_SERVER_VERSION") or "").strip()
if v:
return v
try:
if _VERSION_FILE.is_file():
line = _VERSION_FILE.read_text(encoding="utf-8").splitlines()[0].strip()
if line:
return line
except OSError:
pass
return "3.3.3"
def resolve_scrcpy_server_jar() -> Path | None:
env = (os.environ.get("SCRCPY_SERVER_JAR") or "").strip()
if env:
p = Path(env).expanduser()
if p.is_file():
return p.resolve()
if _VENDOR_JAR.is_file():
return _VENDOR_JAR.resolve()
exe = shutil.which("scrcpy")
if exe:
parent = Path(exe).resolve().parent
for name in ("scrcpy-server", "scrcpy-server.jar"):
cand = parent / name
if cand.is_file():
return cand.resolve()
return None
def scrcpy_stream_info() -> dict[str, Any]:
jar = resolve_scrcpy_server_jar()
ver = default_server_version()
return {
"adb_ok": adb_available(),
"server_jar": jar is not None,
"server_path": str(jar) if jar else None,
"server_version": ver,
"remote_jar": REMOTE_JAR,
"hint_zh": "将官方 scrcpy 发行包中的 scrcpy-server或 scrcpy-server.jar放到项目 vendor/scrcpy-server.jar"
"或设置环境变量 SCRCPY_SERVER_JAR版本字符串须与该 jar 完全一致(默认可配 vendor/scrcpy-server.version 或 SCRCPY_SERVER_VERSION",
"hint_en": "Place scrcpy-server from the official scrcpy release in vendor/scrcpy-server.jar, "
"or set SCRCPY_SERVER_JAR; the version string must match the jar (vendor/scrcpy-server.version or SCRCPY_SERVER_VERSION).",
}
_SERIAL_SAFE = re.compile(r"^[A-Za-z0-9._:\-]+$")
def _validate_serial(serial: str) -> str:
s = (serial or "").strip()
if not s or len(s) > 256 or not _SERIAL_SAFE.match(s):
raise ValueError("Invalid device serial")
return s
def _pick_local_port() -> int:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind(("127.0.0.1", 0))
_host, port = s.getsockname()
return int(port)
finally:
s.close()
async def _adb_forward_add(serial: str, local_port: int, socket_suffix: str) -> None:
spec = f"tcp:{local_port}"
abstract = f"localabstract:scrcpy_{socket_suffix}"
code, _out, err = await _adb_text(serial, "forward", spec, abstract, timeout=25.0)
if code != 0:
raise RuntimeError(err or f"adb forward {spec} {abstract} failed")
async def _adb_forward_remove(local_port: int) -> None:
spec = f"tcp:{local_port}"
await _adb_text(None, "forward", "--remove", spec, timeout=15.0)
async def _push_server(serial: str, jar: Path) -> None:
code, _out, err = await _run_cmd(
_adb_prefix(serial) + ["push", str(jar), REMOTE_JAR],
timeout=120.0,
)
if code != 0:
raise RuntimeError(err or "adb push scrcpy-server failed")
async def _start_server_process(serial: str, version: str, scid_hex: str, max_size: int) -> asyncio.subprocess.Process:
"""在设备上后台启动 app_processtunnel_forward 模式下等待宿主机连入 forward 端口。"""
# 与 DesktopConnection.getSocketName 一致scrcpy_%08x
args_line = (
f"{version} tunnel_forward=true audio=false control=false cleanup=false raw_stream=true "
f"max_size={max_size} scid={scid_hex}"
)
# 使用 sh -c 与后台 &,避免阻塞 adb shell
inner = (
f"CLASSPATH={REMOTE_JAR} /system/bin/app_process / com.genymobile.scrcpy.Server {args_line} "
">/dev/null 2>&1 &"
)
proc = await asyncio.create_subprocess_exec(
*_adb_prefix(serial),
"shell",
"sh",
"-c",
inner,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
rc = int(proc.returncode or 0)
if rc != 0:
msg = (stderr or b"").decode("utf-8", "replace").strip()
raise RuntimeError(msg or f"app_process start failed (code {rc})")
return proc
async def open_scrcpy_h264_stream(
serial: str,
*,
max_size: int = 1920,
connect_attempts: int = 12,
connect_delay: float = 0.22,
) -> tuple[int, str, asyncio.StreamReader, asyncio.StreamWriter]:
"""建立 forward + 启动 server并返回 (local_port, scid_hex, reader, writer)。"""
_validate_serial(serial)
if not adb_available():
raise RuntimeError("adb is not installed")
jar = resolve_scrcpy_server_jar()
if not jar:
raise RuntimeError(
"scrcpy-server not found; set SCRCPY_SERVER_JAR or add vendor/scrcpy-server.jar",
)
version = default_server_version()
scid = random.randint(0x1000_0000, 0x7FFF_FFFF)
scid_hex = f"{scid:08x}"
port = _pick_local_port()
await _push_server(serial, jar)
await _adb_forward_add(serial, port, scid_hex)
await _start_server_process(serial, version, scid_hex, max(0, min(int(max_size), 8192)))
last_err: str | None = None
for attempt in range(connect_attempts):
await asyncio.sleep(connect_delay)
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection("127.0.0.1", port),
timeout=4.0,
)
return port, scid_hex, reader, writer
except (OSError, asyncio.TimeoutError) as exc:
last_err = str(exc)
continue
await _adb_forward_remove(port)
raise RuntimeError(last_err or "Could not connect to scrcpy forward port")
async def cleanup_scrcpy_session(local_port: int, writer: asyncio.StreamWriter | None) -> None:
if writer is not None and not writer.is_closing():
writer.close()
try:
await writer.wait_closed()
except OSError:
pass
await _adb_forward_remove(local_port)

View File

@@ -44,6 +44,7 @@ FILES = [
"src/http_clients/async_http.py", "src/http_clients/async_http.py",
"src/http_clients/sync_http.py", "src/http_clients/sync_http.py",
"src/android_control.py", "src/android_control.py",
"src/scrcpy_stream.py",
"src/async_thread_loop.py", "src/async_thread_loop.py",
"src/youtube_ini_sync.py", "src/youtube_ini_sync.py",
"src/control_plane.py", "src/control_plane.py",
@@ -92,6 +93,8 @@ FILES = [
"web-console/components/live-product/LiveProcessLiveLogCard.tsx", "web-console/components/live-product/LiveProcessLiveLogCard.tsx",
"web-console/components/live-product/LiveTiktokHdmiView.tsx", "web-console/components/live-product/LiveTiktokHdmiView.tsx",
"web-console/lib/panelUrls.ts", "web-console/lib/panelUrls.ts",
"web-console/lib/androidScrcpyWs.ts",
"web-console/lib/scrcpyH264Decoder.ts",
"web-console/lib/youtubeProChannels.ts", "web-console/lib/youtubeProChannels.ts",
"web-console/lib/youtubeConfigUtils.ts", "web-console/lib/youtubeConfigUtils.ts",
"web-console/components/console/LiveConsoleWorkspace.tsx", "web-console/components/console/LiveConsoleWorkspace.tsx",

1
vendor/scrcpy-server.version vendored Normal file
View File

@@ -0,0 +1 @@
3.3.3

View File

@@ -870,8 +870,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
</h1> </h1>
<p className="mt-3 max-w-3xl text-sm text-slate-400"> <p className="mt-3 max-w-3xl text-sm text-slate-400">
{tr( {tr(
"ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI 采集Neko / Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP,采集卡可走视频进 Linux。各模块独立,可分别启停。", "ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMIChromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立可分别启停。",
"ShellCrash for egress; FFmpeg for stream or HDMI; Neko/Chromium for browser; USB ADB to AOSP plus optional capture card. Modules are isolated.", "ShellCrash for egress; FFmpeg for stream or HDMI; Chromium for browser; USB ADB to AOSP. Modules are isolated.",
)} )}
</p> </p>
</div> </div>
@@ -971,8 +971,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
</li> </li>
<li> <li>
{tr( {tr(
"「直播业务」里启停 PM2 脚本抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy、Neko 等是否在跑。", "「直播业务」里启停 PM2 脚本抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy 等是否在跑。",
"Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, Neko, etc.", "Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, etc.",
)} )}
</li> </li>
<li> <li>
@@ -995,8 +995,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
<h2 className="section-title">{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}</h2> <h2 className="section-title">{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}</h2>
<p className="section-lead"> <p className="section-lead">
{tr( {tr(
"ws-scrcpy浏览器里控安卓Chromium本机+油猴+RemoteDebugNeko可选 Docker 远程桌面开 YouTube。", "ws-scrcpy浏览器里控安卓Chromium本机+油猴+RemoteDebug。",
"ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.", "ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222.",
)} )}
</p> </p>
<div className="mt-4 flex max-h-[28rem] flex-col gap-2 overflow-y-auto pr-1 log-scroll"> <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")} 🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")}
</a> </a>
) : null} ) : 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 ? ( {qlNorm.webtty ? (
<a className="link-tile" href={qlNorm.webtty} target="_blank" rel="noreferrer"> <a className="link-tile" href={qlNorm.webtty} target="_blank" rel="noreferrer">
WebTTY WebTTY
@@ -1501,8 +1496,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
</div> </div>
<p className="mt-2 text-xs leading-relaxed text-slate-500"> <p className="mt-2 text-xs leading-relaxed text-slate-500">
{tr( {tr(
"内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开Neko 在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。", "内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开。",
"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).", "Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab.",
)} )}
</p> </p>
<iframe className="mt-4 h-[30rem] w-full rounded-2xl border border-white/10 bg-black" src={rawAndroidPanelUrl} title="ws-scrcpy" /> <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, MousePointer2,
Pause, Pause,
Play, Play,
Radio,
RefreshCw, RefreshCw,
Smartphone, Smartphone,
Sparkles, Sparkles,
@@ -16,6 +17,9 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { androidScrcpyWsUrl } from "@/lib/androidScrcpyWs";
import { ScrcpyH264Decoder, webCodecsH264Supported } from "@/lib/scrcpyH264Decoder";
type AndroidOverview = { type AndroidOverview = {
model?: string; model?: string;
brand?: string; brand?: string;
@@ -35,6 +39,41 @@ type TFn = (zh: string, en: string) => string;
const BOOKMARKS_KEY = "live-hub-adb-bookmarks-v1"; 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( function clientPointToImagePixel(
img: HTMLImageElement, img: HTMLImageElement,
clientX: number, clientX: number,
@@ -101,6 +140,7 @@ type Props = {
}; };
const SCRCPY_URL = "https://github.com/Genymobile/scrcpy"; const SCRCPY_URL = "https://github.com/Genymobile/scrcpy";
const ESCRCPY_URL = "https://github.com/viarotel-org/escrcpy";
export function LiveAndroidStreamConsole({ export function LiveAndroidStreamConsole({
t, t,
@@ -128,8 +168,20 @@ export function LiveAndroidStreamConsole({
const [pairCode, setPairCode] = useState(""); const [pairCode, setPairCode] = useState("");
const [connBusy, setConnBusy] = useState(false); const [connBusy, setConnBusy] = useState(false);
const [bookmarks, setBookmarks] = useState<string[]>([]); 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 lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null);
const imgRef = useRef<HTMLImageElement | 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 mirrorFocusRef = useRef<HTMLDivElement | null>(null);
const shotFailRef = useRef(0); const shotFailRef = useRef(0);
@@ -194,10 +246,97 @@ export function LiveAndroidStreamConsole({
}, [apiBase, adbAvailable, serial]); }, [apiBase, adbAvailable, serial]);
useEffect(() => { useEffect(() => {
if (!adbAvailable || !serial || liveMs <= 0) return; if (!adbAvailable || !serial || liveMs <= 0 || scrcpyStream) return;
const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs); const id = window.setInterval(() => setFrameTs((n) => n + 1), liveMs);
return () => clearInterval(id); 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(() => { useEffect(() => {
if (serial && adbAvailable) { if (serial && adbAvailable) {
@@ -318,10 +457,41 @@ export function LiveAndroidStreamConsole({
e.preventDefault(); e.preventDefault();
void (async () => { void (async () => {
await onAndroidAction(hit[0], hit[1]); 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 = () => { const onImgError = () => {
setImgBusy(false); setImgBusy(false);
shotFailRef.current += 1; shotFailRef.current += 1;
@@ -488,25 +658,40 @@ export function LiveAndroidStreamConsole({
</h3> </h3>
<p className="mt-1 max-w-xl text-[11px] leading-relaxed text-zinc-500"> <p className="mt-1 max-w-xl text-[11px] leading-relaxed text-zinc-500">
{t( {t(
"统一适配 USB 板子、Redroid 容器与无线 adb截图轮询 + 输入注入。极致低延迟请用桌面", "统一适配 USB / Redroid / 无线 adb可选官方 scrcpy-server 经 WebSocket 的 H.264 真流(与桌面 ",
"USB boards, Redroid, wireless adb: polled screencap + input. For lowest latency use desktop ", "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"> <a href={SCRCPY_URL} target="_blank" rel="noreferrer" className="text-emerald-300/90 underline-offset-2 hover:underline">
scrcpy scrcpy
</a> </a>
{t("。", ".")} {t(");截图轮询作备用。", "); polled PNG as fallback.")}
</p> </p>
</div> </div>
</div> </div>
<a <div className="flex shrink-0 flex-col gap-1.5 self-start sm:flex-row">
href={SCRCPY_URL} <a
target="_blank" href={ESCRCPY_URL}
rel="noreferrer" target="_blank"
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]" 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" /> escrcpy
</a> <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> </div>
{devices.length > 0 ? ( {devices.length > 0 ? (
@@ -614,7 +799,41 @@ export function LiveAndroidStreamConsole({
<MousePointer2 className="h-3 w-3" /> <MousePointer2 className="h-3 w-3" />
{t("点按穿透", "Tap-through")} {t("点按穿透", "Tap-through")}
</label> </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> </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"> <p className="mt-2 text-[10px] text-zinc-600">
{t( {t(
"快捷键镜像区聚焦时H Home · B Back · R 多任务 · W 唤醒 · M 菜单", "快捷键镜像区聚焦时H Home · B Back · R 多任务 · W 唤醒 · M 菜单",
@@ -637,38 +856,56 @@ export function LiveAndroidStreamConsole({
style={{ maxHeight: "min(72vh, 680px)" }} 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" /> <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 */} {scrcpyStream ? (
<img <canvas
ref={imgRef} ref={canvasRef}
src={shotUrl || undefined} className={`max-h-[min(72vh,680px)] w-auto max-w-full rounded-[1.35rem] object-contain ${
alt="device" tapMode ? "cursor-crosshair" : "cursor-default"
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}
draggable={false} onContextMenu={onCanvasContextMenu}
onLoadStart={() => { />
setImgBusy(true); ) : (
}} /* eslint-disable-next-line @next/next/no-img-element */
onLoad={onImgLoad} <img
onError={onImgError} ref={imgRef}
onClick={onImageClick} src={shotUrl || undefined}
onContextMenu={onImageContextMenu} 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> </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"> <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" /> <Play className="h-3 w-3" />
LIVE LIVE
</div> </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"> <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" /> <Pause className="h-3 w-3" />
{t("单帧", "Still")} {t("单帧", "Still")}
</div> </div>
)} ) : null}
</div> </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"> <p className="mt-3 text-center text-[10px] text-zinc-600">
{t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")} {t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")}
</p> </p>

View File

@@ -74,13 +74,6 @@ type HubDashboard = {
platform?: string; platform?: string;
ips?: string[]; ips?: string[];
quick_links?: Record<string, 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<{ services?: Array<{
service_id: string; service_id: string;
@@ -128,11 +121,6 @@ type HubDashboard = {
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string }; 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 }; 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> </div>
) : null} ) : 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 ? ( {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"> <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"> <h3 className="flex items-center gap-2 text-sm font-semibold text-white">
@@ -1010,38 +962,6 @@ export default function LiveControlApp() {
); );
})() })()
) : null} ) : 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> </div>
) : null} ) : null}
</div> </div>
@@ -1229,7 +1149,7 @@ export default function LiveControlApp() {
<p className={`mt-2 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}> <p className={`mt-2 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
{t( {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> </p>
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-center"> <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>
<label className="flex cursor-pointer items-center justify-between gap-4 border-t border-white/[0.06] pt-4"> <label className="flex cursor-pointer items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
<div> <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> <p className="text-[11px] text-zinc-500">{t("关闭可精简首页", "Hide stream probe cards")}</p>
</div> </div>
<input <input

View File

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

View File

@@ -241,7 +241,11 @@ export function LiveYoutubeUnmannedView({
setChannels((prev) => { setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x)); const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(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}`,
);
});
return next; return next;
}); });
} }
@@ -311,9 +315,13 @@ export function LiveYoutubeUnmannedView({
(next: YoutubeProChannel[]) => { (next: YoutubeProChannel[]) => {
setChannels(next); setChannels(next);
saveYoutubeProChannels(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]; const active = channels.find((c) => c.id === activeCh) || channels[0];
@@ -633,10 +641,10 @@ export function LiveYoutubeUnmannedView({
target: "live_youtube", target: "live_youtube",
}, },
{ {
label: "Neko", label: "Android",
sub: t("Chromium 工作室", "Chromium"), sub: t("ADB 设备", "ADB"),
gradient: "from-emerald-400 to-teal-500", 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 { function migrateRow(x: Record<string, unknown>): YoutubeProChannel | null {
if (!x || typeof x !== "object") return null; if (!x || typeof x !== "object") return null;
const id = String(x.id || "").trim(); const id = String(x.id || "").trim();
const name = String(x.name || "").trim(); if (!id) return null;
if (!id || !name) return null; const name = String(x.name || "").trim() || id;
return { return {
id, id,
name, name,

78
web.py
View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import re import re
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -9,7 +10,7 @@ from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from fastapi import FastAPI, Query, Request from fastapi import FastAPI, Query, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -28,6 +29,7 @@ from src.android_control import (
android_ui_nodes, android_ui_nodes,
list_android_devices, list_android_devices,
) )
from src.scrcpy_stream import cleanup_scrcpy_session, open_scrcpy_h264_stream, scrcpy_stream_info
from src.control_plane import ( from src.control_plane import (
delete_root_file, delete_root_file,
list_root_files, list_root_files,
@@ -480,6 +482,80 @@ async def get_android_display_metrics(serial: str = Query(..., description="Andr
return JSONResponse({"error": str(exc)}, status_code=400) return JSONResponse({"error": str(exc)}, status_code=400)
@app.get("/android/scrcpy/info")
async def get_android_scrcpy_info():
"""是否具备 scrcpy-server 与版本提示(真 · WebSocket 流依赖此 jar"""
return scrcpy_stream_info()
@app.websocket("/android/scrcpy/ws")
async def android_scrcpy_ws(websocket: WebSocket):
"""浏览器 WebSocket推送设备端 scrcpy-server raw_stream H.264 字节流(与官方 scrcpy 同源编码)。"""
await websocket.accept()
serial = (websocket.query_params.get("serial") or "").strip()
ms_raw = (websocket.query_params.get("max_size") or "1920").strip()
try:
max_size = max(0, min(int(ms_raw), 8192))
except ValueError:
max_size = 1920
writer: asyncio.StreamWriter | None = None
port: int | None = None
try:
port, _scid, reader, writer = await open_scrcpy_h264_stream(serial, max_size=max_size)
except ValueError as exc:
await websocket.send_text(json.dumps({"error": str(exc), "phase": "serial"}))
await websocket.close(code=4400)
return
except Exception as exc:
await websocket.send_text(json.dumps({"error": str(exc), "phase": "setup"}))
await websocket.close(code=4500)
return
async def pump() -> None:
try:
while True:
chunk = await reader.read(64 * 1024)
if not chunk:
break
await websocket.send_bytes(chunk)
except Exception:
pass
async def wait_client_disconnect() -> None:
try:
while True:
msg = await websocket.receive()
if msg.get("type") == "websocket.disconnect":
return
except Exception:
return
pump_task = asyncio.create_task(pump())
disc_task = asyncio.create_task(wait_client_disconnect())
try:
_done, pending = await asyncio.wait(
{pump_task, disc_task},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
await asyncio.gather(*pending, return_exceptions=True)
finally:
if not pump_task.done():
pump_task.cancel()
await asyncio.gather(pump_task, return_exceptions=True)
if not disc_task.done():
disc_task.cancel()
await asyncio.gather(disc_task, return_exceptions=True)
if port is not None:
await cleanup_scrcpy_session(port, writer)
try:
await websocket.close()
except Exception:
pass
@app.post("/android/action") @app.post("/android/action")
async def post_android_action(request: Request): async def post_android_action(request: Request):
try: try: