's'
This commit is contained in:
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 Studio;ENABLE_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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
197
src/scrcpy_stream.py
Normal file
@@ -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)
|
||||||
@@ -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
1
vendor/scrcpy-server.version
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.3.3
|
||||||
@@ -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 处理推流或 HDMI;Chromium 负责浏览器侧;开发板通过 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:本机+油猴+RemoteDebug;Neko:可选 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" />
|
||||||
|
|||||||
@@ -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,26 +658,41 @@ 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>
|
||||||
|
<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
|
<a
|
||||||
href={SCRCPY_URL}
|
href={SCRCPY_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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]"
|
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
|
scrcpy
|
||||||
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
<ExternalLink className="h-3.5 w-3.5 opacity-70" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{devices.length > 0 ? (
|
{devices.length > 0 ? (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
@@ -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,7 +856,18 @@ 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 ? (
|
||||||
|
<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
|
<img
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
src={shotUrl || undefined}
|
src={shotUrl || undefined}
|
||||||
@@ -654,21 +884,28 @@ export function LiveAndroidStreamConsole({
|
|||||||
onClick={onImageClick}
|
onClick={onImageClick}
|
||||||
onContextMenu={onImageContextMenu}
|
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>
|
||||||
|
|||||||
@@ -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 桌面 Chrome;Neko 使用 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
28
web-console/lib/androidScrcpyWs.ts
Normal file
28
web-console/lib/androidScrcpyWs.ts
Normal file
@@ -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()}`;
|
||||||
|
}
|
||||||
169
web-console/lib/scrcpyH264Decoder.ts
Normal file
169
web-console/lib/scrcpyH264Decoder.ts
Normal file
@@ -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<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";
|
||||||
|
}
|
||||||
@@ -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
78
web.py
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user