diff --git a/config/runtime.env.example b/config/runtime.env.example index f4bb26f..7690f81 100644 --- a/config/runtime.env.example +++ b/config/runtime.env.example @@ -5,6 +5,7 @@ HOST=0.0.0.0 # Web 控制台(FastAPI + 静态页)端口;与同机 SRS / Clash / Neko 等冲突时请改成未占用端口,例如 8101、9200 +# 若 Nginx/Caddy 反代出现 502,请核对 proxy_pass 端口与这里一致(Docker Compose 默认常为 8101,PM2 ecosystem 默认 8001) PORT=8001 # 开发模式时 Next 端口(一般无需改) diff --git a/scripts/launch.py b/scripts/launch.py index adc2067..4e74683 100644 --- a/scripts/launch.py +++ b/scripts/launch.py @@ -93,6 +93,16 @@ def ensure_console_built(*, skip: bool) -> bool: if _npm(["install"], cwd=CONSOLE) != 0: sys.exit(1) if _npm(["run", "build"], cwd=CONSOLE) != 0: + if OUT_INDEX.is_file(): + print( + "[launch] npm run build 失败,但检测到已有 web-console/out/index.html," + "将继续启动 API(静态页可能为旧构建;请修复构建后重启)。", + ) + return True + print( + "[launch] npm run build 失败且缺少 web-console/out/index.html;" + "无法提供控制台。请在 web-console 目录执行 npm run build 或检查 Node/内存。", + ) sys.exit(1) print("[launch] 前端构建完成。") return True diff --git a/src/hub_routes.py b/src/hub_routes.py index b6e6035..65ad7b0 100644 --- a/src/hub_routes.py +++ b/src/hub_routes.py @@ -78,7 +78,10 @@ async def hub_dashboard() -> dict[str, Any]: else: devices = [] stack = stack_summary() - probes = await stack_stream_probes() + try: + probes = await stack_stream_probes() + except Exception: + probes = {"srs": {}, "neko": {}, "error": "probe_failed"} live_status: dict[str, Any] = { "processes": [], diff --git a/src/scrcpy_stream.py b/src/scrcpy_stream.py index 42d7da5..9e4269f 100644 --- a/src/scrcpy_stream.py +++ b/src/scrcpy_stream.py @@ -147,8 +147,8 @@ async def open_scrcpy_h264_stream( serial: str, *, max_size: int = 1920, - connect_attempts: int = 12, - connect_delay: float = 0.22, + connect_attempts: int = 22, + connect_delay: float = 0.28, ) -> tuple[int, str, asyncio.StreamReader, asyncio.StreamWriter]: """建立 forward + 启动 server,并返回 (local_port, scid_hex, reader, writer)。""" _validate_serial(serial) diff --git a/web-console/components/live-product/LiveAndroidStreamConsole.tsx b/web-console/components/live-product/LiveAndroidStreamConsole.tsx index 052de3c..cfcaf31 100644 --- a/web-console/components/live-product/LiveAndroidStreamConsole.tsx +++ b/web-console/components/live-product/LiveAndroidStreamConsole.tsx @@ -176,6 +176,8 @@ export function LiveAndroidStreamConsole({ const [connBusy, setConnBusy] = useState(false); const [bookmarks, setBookmarks] = useState([]); const [scrcpyStream, setScrcpyStream] = useState(false); + /** 用户在本机序列号上点了「关闭真流」后,不再自动拉起,直到切换设备。 */ + const [scrcpyUserStoppedForSerial, setScrcpyUserStoppedForSerial] = useState(null); const [scrcpyConnected, setScrcpyConnected] = useState(false); const [scrcpyErr, setScrcpyErr] = useState(null); const [scrcpyInfo, setScrcpyInfo] = useState<{ @@ -288,6 +290,45 @@ export function LiveAndroidStreamConsole({ if (!scrcpyStream) setScrcpyErr(null); }, [scrcpyStream]); + useEffect(() => { + setScrcpyUserStoppedForSerial(null); + }, [serial]); + + useEffect(() => { + const row = devices.find((d) => d.serial === serial); + const userStopped = Boolean(serial && scrcpyUserStoppedForSerial === serial); + const canAuto = + adbAvailable && + Boolean(serial) && + row?.state === "device" && + Boolean(scrcpyInfo?.server_jar) && + webCodecsH264Supported() && + !userStopped; + + if (canAuto) { + setScrcpyStream(true); + setLiveMs(0); + return; + } + + if (userStopped || !serial || !adbAvailable) { + setScrcpyStream(false); + return; + } + if (scrcpyInfo && !scrcpyInfo.server_jar) { + setScrcpyStream(false); + return; + } + if (!webCodecsH264Supported()) { + setScrcpyStream(false); + return; + } + if (row && row.state !== "device") { + setScrcpyStream(false); + return; + } + }, [serial, devices, adbAvailable, scrcpyInfo, scrcpyUserStoppedForSerial]); + useEffect(() => { if (!scrcpyStream || !serial || !adbAvailable) { wsRef.current?.close(); @@ -666,8 +707,8 @@ export function LiveAndroidStreamConsole({

{t( - "统一适配 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 ", + "统一适配 USB / Redroid / 无线 adb:已授权设备默认走官方 scrcpy-server 的 WebSocket H.264 真流(与桌面 ", + "USB / Redroid / wireless adb: authorized devices default to official scrcpy-server WebSocket H.264 (same stack as ", )} escrcpy @@ -676,7 +717,7 @@ export function LiveAndroidStreamConsole({ scrcpy - {t(");截图轮询作备用。", "); polled PNG as fallback.")} + {t(");无 jar 或关闭真流时用截图。", "); PNG screenshot when jar missing or stream off.")}

@@ -818,11 +859,13 @@ export function LiveAndroidStreamConsole({ onClick={() => { setScrcpyStream((v) => { const next = !v; - notify( - next - ? t("已请求 Scrcpy 真流(WebSocket)", "Scrcpy WebSocket stream starting") - : t("已关闭 Scrcpy 真流", "Scrcpy stream stopped"), - ); + if (next) { + setScrcpyUserStoppedForSerial(null); + notify(t("已开启实时画面(H.264 WebSocket)", "Live H.264 WebSocket stream on")); + } else { + if (serial) setScrcpyUserStoppedForSerial(serial); + notify(t("已关闭实时画面(将使用截图)", "Live stream off — screenshot mode")); + } return next; }); }} @@ -833,7 +876,7 @@ export function LiveAndroidStreamConsole({ }`} > - {scrcpyStream ? t("关闭真流", "Stop WS") : t("Scrcpy 真流", "Scrcpy WS")} + {scrcpyStream ? t("关闭真流", "Stop WS") : t("实时画面", "Live H.264")} {scrcpyInfo && !scrcpyInfo.server_jar ? ( diff --git a/web-console/lib/androidScrcpyWs.ts b/web-console/lib/androidScrcpyWs.ts index 0752bc1..6f3c8bd 100644 --- a/web-console/lib/androidScrcpyWs.ts +++ b/web-console/lib/androidScrcpyWs.ts @@ -1,11 +1,12 @@ /** * 将 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)。 + * next dev:HTTP 可走 middleware rewrite,但 WebSocket 升级通常不会转发,故在常见 dev 端口直连 uvicorn(与 middleware API_PROXY_TARGET 默认一致)。 */ +const DEFAULT_DEV_API_PORT = "8001"; + export function httpApiBaseToWsBase(apiBase: string): string { - if (typeof window === "undefined") return "ws://127.0.0.1:8001"; + if (typeof window === "undefined") return `ws://127.0.0.1:${DEFAULT_DEV_API_PORT}`; const base = (apiBase || "").trim(); if (base) { try { @@ -16,6 +17,24 @@ export function httpApiBaseToWsBase(apiBase: string): string { /* ignore */ } } + const pub = + typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL + ? String(process.env.NEXT_PUBLIC_API_URL).trim() + : ""; + if (pub) { + try { + const u = new URL(pub, window.location.href); + const proto = u.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${u.host}`; + } catch { + /* ignore */ + } + } + const port = window.location.port; + if (port === "3000" || port === "3001") { + const h = window.location.hostname || "127.0.0.1"; + return `ws://${h}:${DEFAULT_DEV_API_PORT}`; + } const { protocol, host } = window.location; const proto = protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${host}`; diff --git a/web-console/middleware.ts b/web-console/middleware.ts index 4fc480b..f58fa86 100644 --- a/web-console/middleware.ts +++ b/web-console/middleware.ts @@ -29,6 +29,7 @@ const API_ROOTS = new Set([ "android", "hub", "youtube_pro_channels", + "tiktok_replay", ]); export function middleware(request: NextRequest) { @@ -68,5 +69,6 @@ export const config = { "/android/:path*", "/hub/:path*", "/youtube_pro_channels", + "/tiktok_replay/:path*", ], }; diff --git a/web.py b/web.py index 19e37e9..7fb683b 100644 --- a/web.py +++ b/web.py @@ -336,7 +336,11 @@ def script_for_pm2(pm2: str) -> Optional[str]: @asynccontextmanager async def lifespan(app: FastAPI): - await process_backend.refresh_mode() + try: + await process_backend.refresh_mode() + except Exception: + # PM2/Shell 异常时不阻塞整站启动(否则反代持续 502) + pass yield @@ -364,7 +368,7 @@ async def alias_youtube_pro_channels_post(body: YoutubeProChannelsBody): app.add_middleware( CORSMiddleware, allow_origins=["*"], - allow_methods=["GET", "POST"], + allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], )