diff --git a/live-platform/config/defaults/services/neko.json b/live-platform/config/defaults/services/neko.json
deleted file mode 100644
index 5a9b153..0000000
--- a/live-platform/config/defaults/services/neko.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/src/control_plane.py b/src/control_plane.py
index 131ea83..93d1892 100644
--- a/src/control_plane.py
+++ b/src/control_plane.py
@@ -89,12 +89,6 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = (
"Browser ADB control (ws-scrcpy stack; clone URL configurable in system-stack.env)",
),
ServiceSpec("chromium", "Chromium 浏览器", "browser", "本机 Chromium + 持久化配置 + Remote DevTools(非 Docker)"),
- ServiceSpec(
- "neko",
- "Neko 浏览器 (Docker)",
- "browser",
- "可选:Docker 内嵌 Chromium 桌面,适合远程开 YouTube Studio;ENABLE_NEKO=1 时安装",
- ),
ServiceSpec(
"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]:
- """本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。"""
+ """本机 SRS HTTP 端口探测(供 live.local 验收)。"""
env = load_stack_env()
srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080)
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()
def run_srs() -> dict[str, Any]:
@@ -596,16 +589,8 @@ async def stack_stream_probes() -> dict[str, Any]:
"api_ok": api_ok,
}
- def run_neko() -> dict[str, Any]:
- tcp = _probe_tcp_port("127.0.0.1", neko_p)
- 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}
+ srs_d = await loop.run_in_executor(None, run_srs)
+ return {"srs": srs_d}
def stack_summary() -> dict:
@@ -621,7 +606,6 @@ def stack_summary() -> dict:
srs_api = env.get("SRS_API_PORT", "1985")
android_panel = env.get("ANDROID_PANEL_PORT", "5000")
chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222")
- neko_port = env.get("NEKO_HTTP_PORT", "9200")
ql: dict[str, str] = {
"control_plane": f"http://{mdns_host}:{web_port}",
"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",
"ws_scrcpy": f"http://{mdns_host}:{android_panel}",
"chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}",
- "neko_browser": f"http://{mdns_host}:{neko_port}",
}
return {
"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}",
"control_url": f"http://{mdns_host}:{web_port}",
"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,
}
diff --git a/src/hub_routes.py b/src/hub_routes.py
index 021e1e5..b6e6035 100644
--- a/src/hub_routes.py
+++ b/src/hub_routes.py
@@ -49,19 +49,24 @@ class BusinessDouyinPatch(BaseModel):
class YoutubeProChannelsBody(BaseModel):
channels: list[dict[str, Any]] = Field(default_factory=list)
- @field_validator("channels")
+ @field_validator("channels", mode="after")
@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:
raise ValueError("at most 64 channels")
+ out: list[dict[str, Any]] = []
for item in v:
if not isinstance(item, dict):
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")
- if not str(item.get("name", "")).strip():
- raise ValueError("channel.name required")
- return v
+ row = dict(item)
+ row["id"] = cid
+ nm = str(row.get("name", "")).strip()
+ row["name"] = nm if nm else cid
+ out.append(row)
+ return out
@router.get("/dashboard")
diff --git a/src/scrcpy_stream.py b/src/scrcpy_stream.py
new file mode 100644
index 0000000..42d7da5
--- /dev/null
+++ b/src/scrcpy_stream.py
@@ -0,0 +1,197 @@
+"""在宿主上拉起官方 scrcpy-server(raw_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_process;tunnel_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)
diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py
index 1654494..3f5dc79 100644
--- a/tools/deploy_live_paramiko.py
+++ b/tools/deploy_live_paramiko.py
@@ -44,6 +44,7 @@ FILES = [
"src/http_clients/async_http.py",
"src/http_clients/sync_http.py",
"src/android_control.py",
+ "src/scrcpy_stream.py",
"src/async_thread_loop.py",
"src/youtube_ini_sync.py",
"src/control_plane.py",
@@ -92,6 +93,8 @@ FILES = [
"web-console/components/live-product/LiveProcessLiveLogCard.tsx",
"web-console/components/live-product/LiveTiktokHdmiView.tsx",
"web-console/lib/panelUrls.ts",
+ "web-console/lib/androidScrcpyWs.ts",
+ "web-console/lib/scrcpyH264Decoder.ts",
"web-console/lib/youtubeProChannels.ts",
"web-console/lib/youtubeConfigUtils.ts",
"web-console/components/console/LiveConsoleWorkspace.tsx",
diff --git a/vendor/scrcpy-server.version b/vendor/scrcpy-server.version
new file mode 100644
index 0000000..619b537
--- /dev/null
+++ b/vendor/scrcpy-server.version
@@ -0,0 +1 @@
+3.3.3
diff --git a/web-console/components/console/LiveConsoleWorkspace.tsx b/web-console/components/console/LiveConsoleWorkspace.tsx
index 4031a42..b7cbc7b 100644
--- a/web-console/components/console/LiveConsoleWorkspace.tsx
+++ b/web-console/components/console/LiveConsoleWorkspace.tsx
@@ -870,8 +870,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
{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 处理推流或 HDMI;Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。",
+ "ShellCrash for egress; FFmpeg for stream or HDMI; Chromium for browser; USB ADB to AOSP. Modules are isolated.",
)}
@@ -971,8 +971,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
{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.",
)}
@@ -995,8 +995,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:
{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}
{tr(
- "ws-scrcpy:浏览器里控安卓;Chromium:本机+油猴+RemoteDebug;Neko:可选 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.",
)}
{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.",
)}
diff --git a/web-console/components/live-product/LiveAndroidStreamConsole.tsx b/web-console/components/live-product/LiveAndroidStreamConsole.tsx
index cb3d609..0faf894 100644
--- a/web-console/components/live-product/LiveAndroidStreamConsole.tsx
+++ b/web-console/components/live-product/LiveAndroidStreamConsole.tsx
@@ -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([]);
+ const [scrcpyStream, setScrcpyStream] = useState(false);
+ const [scrcpyConnected, setScrcpyConnected] = useState(false);
+ const [scrcpyErr, setScrcpyErr] = useState(null);
+ const [scrcpyInfo, setScrcpyInfo] = useState<{
+ server_jar?: boolean;
+ adb_ok?: boolean;
+ server_version?: string;
+ hint_zh?: string;
+ } | null>(null);
const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null);
const imgRef = useRef(null);
+ const canvasRef = useRef(null);
+ const wsRef = useRef(null);
+ const decoderRef = useRef(null);
const mirrorFocusRef = useRef(null);
const shotFailRef = useRef(0);
@@ -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) => {
+ if (!tapMode || lock || !adbAvailable || !serial) return;
+ const c = canvasRef.current;
+ if (!c) return;
+ const p = clientPointToCanvasPixel(c, e.clientX, e.clientY);
+ if (!p) return;
+ const now = Date.now();
+ const prev = lastTapRef.current;
+ if (prev && now - prev.t < 320 && Math.abs(prev.x - p.x) < 28 && Math.abs(prev.y - p.y) < 28) {
+ lastTapRef.current = null;
+ await onAndroidAction("double_tap", { x: p.x, y: p.y });
+ notify(t("双击", "Double tap"));
+ } else {
+ lastTapRef.current = { t: now, x: p.x, y: p.y };
+ await onAndroidAction("tap", { x: p.x, y: p.y });
+ }
+ if (!scrcpyStream) bumpFrame();
+ };
+
+ const onCanvasContextMenu = async (e: React.MouseEvent) => {
+ e.preventDefault();
+ if (lock || !adbAvailable || !serial) return;
+ const c = canvasRef.current;
+ if (!c) return;
+ const p = clientPointToCanvasPixel(c, e.clientX, e.clientY);
+ if (!p) return;
+ await onAndroidAction("long_press", { x: p.x, y: p.y, duration: 720 });
+ notify(t("长按", "Long press"));
+ if (!scrcpyStream) bumpFrame();
+ };
+
const onImgError = () => {
setImgBusy(false);
shotFailRef.current += 1;
@@ -488,25 +658,40 @@ export function LiveAndroidStreamConsole({
{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 ",
)}
+
+ escrcpy
+
+ {t(" / ", " / ")}
scrcpy
- {t("。", ".")}
+ {t(");截图轮询作备用。", "); polled PNG as fallback.")}
-
- scrcpy
-
-
+
{devices.length > 0 ? (
@@ -614,7 +799,41 @@ export function LiveAndroidStreamConsole({
{t("点按穿透", "Tap-through")}
+ {
+ 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"
+ }`}
+ >
+
+ {scrcpyStream ? t("关闭真流", "Stop WS") : t("Scrcpy 真流", "Scrcpy WS")}
+
+ {scrcpyInfo && !scrcpyInfo.server_jar ? (
+
+ {t("未检测到 scrcpy-server:请放置 vendor/scrcpy-server.jar 或设置 SCRCPY_SERVER_JAR。", "No scrcpy-server jar: add vendor/scrcpy-server.jar or SCRCPY_SERVER_JAR.")}
+ {scrcpyInfo.server_version ? ` (${t("期望版本", "version")} ${scrcpyInfo.server_version})` : null}
+
+ ) : null}
{t(
"快捷键(镜像区聚焦时):H Home · B Back · R 多任务 · W 唤醒 · M 菜单",
@@ -637,38 +856,56 @@ export function LiveAndroidStreamConsole({
style={{ maxHeight: "min(72vh, 680px)" }}
>
- {/* eslint-disable-next-line @next/next/no-img-element */}
- {
- setImgBusy(true);
- }}
- onLoad={onImgLoad}
- onError={onImgError}
- onClick={onImageClick}
- onContextMenu={onImageContextMenu}
- />
+ {scrcpyStream ? (
+
+ ) : (
+ /* eslint-disable-next-line @next/next/no-img-element */
+ {
+ setImgBusy(true);
+ }}
+ onLoad={onImgLoad}
+ onError={onImgError}
+ onClick={onImageClick}
+ onContextMenu={onImageContextMenu}
+ />
+ )}
- {liveMs > 0 ? (
+ {scrcpyConnected ? (
+
+
+ SCRCPY·WS
+
+ ) : liveMs > 0 && !scrcpyStream ? (
- ) : (
+ ) : !scrcpyStream ? (
- )}
+ ) : null}
)}
- {imgErr ? {imgErr}
: null}
+ {scrcpyErr ? {scrcpyErr}
: null}
+ {imgErr && !scrcpyStream ? {imgErr}
: null}
{t("左键单击 / 双击 · 右键长按 · 坐标对齐 PNG 像素", "Left tap/double · right long-press · PNG pixel coords")}
diff --git a/web-console/components/live-product/LiveControlApp.tsx b/web-console/components/live-product/LiveControlApp.tsx
index b6e2e7e..c538e09 100644
--- a/web-console/components/live-product/LiveControlApp.tsx
+++ b/web-console/components/live-product/LiveControlApp.tsx
@@ -74,13 +74,6 @@ type HubDashboard = {
platform?: string;
ips?: string[];
quick_links?: Record;
- 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() {
) : null}
- {dash?.stack?.neko_login ? (
-
-
Neko {t("登录", "sign-in")}
-
- {dash.stack.neko_login.note_zh ||
- t("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
-
-
- {t(
- "重要:两栏分开填——昵称随意,密码必须是环境变量里的口令(默认 12345678)。用错密码会像一直加载;勿把「用户名/密码」写在一行。",
- "Use two fields: display name is free-form; password must match NEKO_* (default 12345678). Wrong password looks like infinite loading.",
- )}
-
-
-
-
{t("用户", "User")}
-
{dash.stack.neko_login.user_login ?? "live"}
-
{t("口令", "Password")}
-
{dash.stack.neko_login.user_password ?? "—"}
-
-
-
{t("管理员", "Admin")}
-
{dash.stack.neko_login.admin_login ?? "admin"}
-
{t("口令", "Password")}
-
{dash.stack.neko_login.admin_password ?? "—"}
-
-
-
- {t(
- "Google 无官方 ARM 桌面 Chrome;Neko 使用 Chromium / Firefox 多架构镜像(非 Chrome 品牌)。",
- "No official ARM desktop Chrome; Neko uses Chromium/Firefox multi-arch images.",
- )}
-
-
- ) : null}
-
{dash?.network?.interfaces?.length ? (
@@ -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 (
-
-
Neko Chromium
-
- {t(
- "Docker 内 Chromium(非 Chrome 品牌);arm64/x86 同一镜像。",
- "Chromium in Docker (not Chrome branding); multi-arch.",
- )}
-
-
- :{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
-
-
- {t("延迟", "RTT")}{" "}
- {http?.latency_ms != null
- ? `${http.latency_ms} ms`
- : p?.tcp?.latency_ms != null
- ? `${p.tcp.latency_ms} ms`
- : "—"}
-
- {!http?.reachable && http?.error ? (
-
{http.error}
- ) : null}
-
- );
- })()
- ) : null}
) : null}
@@ -1229,7 +1149,7 @@ export default function LiveControlApp() {
{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.",
)}
@@ -1294,7 +1214,7 @@ export default function LiveControlApp() {
-
{t("总览显示 SRS / Neko 探针", "Show SRS/Neko probes")}
+
{t("总览显示 SRS 探针", "Show SRS stream probe")}
{t("关闭可精简首页", "Hide stream probe cards")}
))}
- {t("采集卡 / EDID / 走线备忘", "Capture / EDID notes")}
+ {t("HDMI / 走线备忘", "HDMI / wiring notes")}
diff --git a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
index cd3422c..d38e409 100644
--- a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
+++ b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
@@ -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",
},
]}
/>
diff --git a/web-console/lib/androidScrcpyWs.ts b/web-console/lib/androidScrcpyWs.ts
new file mode 100644
index 0000000..0752bc1
--- /dev/null
+++ b/web-console/lib/androidScrcpyWs.ts
@@ -0,0 +1,28 @@
+/**
+ * 将 HTTP API 根地址转为 WebSocket 根(用于 /android/scrcpy/ws)。
+ * next dev 下若未设置 NEXT_PUBLIC_API_URL,浏览器会连到 :3000,WebSocket 升级通常无法被中间件代理;
+ * 真流调试请设 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()}`;
+}
diff --git a/web-console/lib/scrcpyH264Decoder.ts b/web-console/lib/scrcpyH264Decoder.ts
new file mode 100644
index 0000000..c514975
--- /dev/null
+++ b/web-console/lib/scrcpyH264Decoder.ts
@@ -0,0 +1,169 @@
+/**
+ * 解析 scrcpy raw_stream(AVCC:4 字节大端长度 + NAL)并用 WebCodecs 画到 Canvas。
+ * 与官方 scrcpy / escrcpy 设备端编码一致;浏览器需 Chromium 系且支持 VideoDecoder(H.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 = 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 {
+ 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";
+}
diff --git a/web-console/lib/youtubeProChannels.ts b/web-console/lib/youtubeProChannels.ts
index ad6af27..7c2703a 100644
--- a/web-console/lib/youtubeProChannels.ts
+++ b/web-console/lib/youtubeProChannels.ts
@@ -24,8 +24,8 @@ export function parseYoutubeProChannelList(raw: unknown): YoutubeProChannel[] {
function migrateRow(x: Record): 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,
diff --git a/web.py b/web.py
index 85a8ba5..e370667 100644
--- a/web.py
+++ b/web.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import json
import re
import sys
from contextlib import asynccontextmanager
@@ -9,7 +10,7 @@ from typing import Optional
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.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
@@ -28,6 +29,7 @@ from src.android_control import (
android_ui_nodes,
list_android_devices,
)
+from src.scrcpy_stream import cleanup_scrcpy_session, open_scrcpy_h264_stream, scrcpy_stream_info
from src.control_plane import (
delete_root_file,
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)
+@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")
async def post_android_action(request: Request):
try: