This commit is contained in:
eric
2026-03-28 15:08:59 -05:00
parent 92ecc14e43
commit d74f3046a3
14 changed files with 1889 additions and 245 deletions

View File

@@ -59,6 +59,22 @@ compose_run() {
fi
}
compose_in_service_dir() {
local compose_file="$1"
shift
local d base
d="$(cd "$(dirname "$compose_file")" && pwd)"
base="$(basename "$compose_file")"
(
cd "$d" || exit 1
if docker compose version >/dev/null 2>&1; then
docker compose -f "$base" "$@"
else
docker-compose -f "$base" "$@"
fi
)
}
as_live() {
runuser -u live -- bash -lc "cd '$ROOT_DIR' && $*"
}
@@ -435,10 +451,7 @@ install_neko_browser() {
export NEKO_WEBRTC_NAT1TO1
fi
export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}"
(
cd "$neko_dir" || exit 1
compose_run "$neko_dir/docker-compose.yml" up -d
)
compose_in_service_dir "$neko_dir/docker-compose.yml" up -d
}
configure_adb_udev() {
@@ -642,7 +655,7 @@ prepare_homepage() {
fi
log "Preparing Homepage"
render_homepage_services
compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" up -d
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d
}
prepare_filebrowser() {
@@ -660,7 +673,7 @@ prepare_filebrowser() {
rm -f "$fb_db"
touch "$fb_db"
chmod -R 0777 "$ROOT_DIR/services/filebrowser/database" "$ROOT_DIR/services/filebrowser/config"
compose_run "$ROOT_DIR/services/filebrowser/docker-compose.yml" down >/dev/null 2>&1 || true
compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" down >/dev/null 2>&1 || true
docker run --rm \
-v "$fb_db_dir:/database" \
@@ -736,7 +749,7 @@ PY
rm -f "$fb_cfg_dir/users.backup.json"
rm -f "$fb_tmp_json"
compose_run "$ROOT_DIR/services/filebrowser/docker-compose.yml" up -d
compose_in_service_dir "$ROOT_DIR/services/filebrowser/docker-compose.yml" up -d
}
prepare_srs() {
@@ -745,7 +758,7 @@ prepare_srs() {
fi
log "Starting SRS"
mkdir -p "$ROOT_DIR/services/srs/data"
compose_run "$ROOT_DIR/services/srs/docker-compose.yml" up -d
compose_in_service_dir "$ROOT_DIR/services/srs/docker-compose.yml" up -d
}
run_hardware_probe() {
@@ -775,9 +788,9 @@ install_live_edge_proxy() {
if [ -f "$ROOT_DIR/config/system-stack.env" ] && grep -q '^HOMEPAGE_PORT=80' "$ROOT_DIR/config/system-stack.env"; then
sed -i 's/^HOMEPAGE_PORT=80$/HOMEPAGE_PORT=3080/' "$ROOT_DIR/config/system-stack.env" || true
fi
compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true
render_homepage_services
compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true
compose_in_service_dir "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true
fi
fi

View File

@@ -88,6 +88,24 @@ compose_run() {
fi
}
# docker-compose v1 将 ./卷 解析为当前工作目录,必须在 compose 文件所在目录执行(与 Neko 一致)
compose_in_service_dir() {
local compose_file="$1"
shift
local d base
d="$(cd "$(dirname "$compose_file")" && pwd)"
base="$(basename "$compose_file")"
(
cd "$d" || exit 1
cmd="$(compose_bin)" || exit 1
if [ "$cmd" = "docker compose" ]; then
docker compose -f "$base" "$@"
else
docker-compose -f "$base" "$@"
fi
)
}
docker_container_status() {
local name="$1"
if ! have docker; then
@@ -227,12 +245,12 @@ service_srs_status() {
}
service_srs_start() {
compose_run "$SRS_COMPOSE" up -d
compose_in_service_dir "$SRS_COMPOSE" up -d
kv message "SRS started"
}
service_srs_stop() {
compose_run "$SRS_COMPOSE" down
compose_in_service_dir "$SRS_COMPOSE" down
kv message "SRS stopped"
}
@@ -310,12 +328,12 @@ service_filebrowser_status() {
}
service_filebrowser_start() {
compose_run "$FILEBROWSER_COMPOSE" up -d
compose_in_service_dir "$FILEBROWSER_COMPOSE" up -d
kv message "File Browser started"
}
service_filebrowser_stop() {
compose_run "$FILEBROWSER_COMPOSE" down
compose_in_service_dir "$FILEBROWSER_COMPOSE" down
kv message "File Browser stopped"
}
@@ -339,12 +357,12 @@ service_homepage_status() {
}
service_homepage_start() {
compose_run "$HOMEPAGE_COMPOSE" up -d
compose_in_service_dir "$HOMEPAGE_COMPOSE" up -d
kv message "Homepage started"
}
service_homepage_stop() {
compose_run "$HOMEPAGE_COMPOSE" down
compose_in_service_dir "$HOMEPAGE_COMPOSE" down
kv message "Homepage stopped"
}
@@ -451,7 +469,12 @@ service_chromium_status() {
}
service_chromium_start() {
nohup "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 &
if [ -z "${DISPLAY:-}" ] && command -v xvfb-run >/dev/null 2>&1; then
nohup xvfb-run -a "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 &
else
[ -z "${DISPLAY:-}" ] && export DISPLAY=:0
nohup "$BROWSER_LAUNCHER" >/tmp/live-browser.log 2>&1 &
fi
disown || true
kv message "Chromium launched"
}
@@ -503,8 +526,6 @@ service_neko_start() {
kv message "Neko compose missing"
return 0
fi
local neko_dir
neko_dir="$(cd "$(dirname "$NEKO_COMPOSE")" && pwd)"
local prof="${NEKO_PROFILE_HOST_DIR:-$ROOT_DIR/services/neko/data/chromium-profile}"
mkdir -p "$prof"
chmod 777 "$prof" 2>/dev/null || true
@@ -520,18 +541,7 @@ service_neko_start() {
export NEKO_WEBRTC_NAT1TO1="$(hostname -I 2>/dev/null | awk '{print $1}')"
fi
export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}"
cmd="$(compose_bin)" || {
kv message "docker compose unavailable"
return 0
}
(
cd "$neko_dir" || exit 1
if [ "$cmd" = "docker compose" ]; then
docker compose -f docker-compose.yml up -d
else
docker-compose -f docker-compose.yml up -d
fi
)
compose_in_service_dir "$NEKO_COMPOSE" up -d
kv message "Neko stack started"
}
@@ -540,20 +550,7 @@ service_neko_stop() {
kv message "Neko compose missing"
return 0
fi
local neko_dir
neko_dir="$(cd "$(dirname "$NEKO_COMPOSE")" && pwd)"
cmd="$(compose_bin)" || {
kv message "docker compose unavailable"
return 0
}
(
cd "$neko_dir" || exit 1
if [ "$cmd" = "docker compose" ]; then
docker compose -f docker-compose.yml down
else
docker-compose -f docker-compose.yml down
fi
)
compose_in_service_dir "$NEKO_COMPOSE" down
kv message "Neko stack stopped"
}

View File

@@ -1,6 +1,6 @@
# 变量由 system-stack.env 或启动前 export勿使用 ${VAR:-default},旧版 docker-compose 会报错)
# WebRTCNEKO_WEBRTC_NAT1TO1 填板子局域网 IP
version: "3.8"
version: "3.3"
services:
neko:
image: ghcr.io/m1k1o/neko/chromium:latest

View File

@@ -299,6 +299,14 @@ def _is_png(data: bytes) -> bool:
return bool(data and len(data) > 8 and data.startswith(b"\x89PNG\r\n\x1a\n"))
async def android_logcat_recent(serial: str, lines: int = 200) -> str:
"""Dump recent logcat lines (logcat -d -t N), similar to device-console tooling."""
if not serial:
raise ValueError("Missing Android device serial")
n = max(1, min(int(lines), 3000))
return await _shell(serial, f"logcat -d -t {n}", timeout=60.0)
async def android_screenshot(serial: str) -> bytes:
if not serial:
raise ValueError("Missing Android device serial")

View File

@@ -8,9 +8,11 @@ import shutil
import socket
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from typing import Any, Iterable
from urllib.error import URLError
from urllib.request import urlopen
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -454,6 +456,65 @@ def load_stack_env() -> dict[str, str]:
return values
def _probe_tcp_port(host: str, port: int, timeout: float = 2.0) -> dict[str, Any]:
t0 = time.monotonic()
try:
with socket.create_connection((host, int(port)), timeout=timeout):
ms = (time.monotonic() - t0) * 1000
return {"ok": True, "reachable": True, "latency_ms": round(ms, 2), "error": ""}
except OSError as exc:
ms = (time.monotonic() - t0) * 1000
return {"ok": False, "reachable": False, "latency_ms": round(ms, 2), "error": str(exc)}
def _probe_http_get(url: str, timeout: float = 2.5) -> dict[str, Any]:
t0 = time.monotonic()
try:
with urlopen(url, timeout=timeout) as resp:
code = int(resp.getcode() or 0)
ms = (time.monotonic() - t0) * 1000
return {
"ok": True,
"http_code": code,
"latency_ms": round(ms, 2),
"reachable": True,
"error": "",
}
except (URLError, OSError, ValueError) as exc:
ms = (time.monotonic() - t0) * 1000
return {
"ok": False,
"http_code": 0,
"latency_ms": round(ms, 2),
"reachable": False,
"error": str(exc),
}
async def stack_stream_probes() -> dict[str, Any]:
"""本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。"""
env = load_stack_env()
srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080)
neko_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200)
loop = asyncio.get_event_loop()
def run_srs() -> dict[str, Any]:
tcp = _probe_tcp_port("127.0.0.1", srs_p)
http = _probe_http_get(f"http://127.0.0.1:{srs_p}/", timeout=2.5)
return {"port": srs_p, "tcp": tcp, "http": http}
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}
def stack_summary() -> dict:
env = load_stack_env()
hostname = env.get("HOSTNAME_ALIAS") or env.get("HOSTNAME") or socket.gethostname()

View File

@@ -8,7 +8,7 @@ from fastapi import APIRouter
from pydantic import BaseModel
from src.android_control import adb_available, list_android_devices
from src.control_plane import query_services, stack_summary, system_snapshot
from src.control_plane import query_services, stack_stream_probes, stack_summary, system_snapshot
from src.live_config import business_config, load_hub_config_snapshot, services_config_list
from src.web_process_backend import WebProcessBackend
@@ -38,6 +38,7 @@ async def hub_dashboard() -> dict[str, Any]:
else:
devices = []
stack = stack_summary()
probes = await stack_stream_probes()
live_status: dict[str, Any] = {
"processes": [],
@@ -68,6 +69,7 @@ async def hub_dashboard() -> dict[str, Any]:
return {
"snapshot": snap,
"stack": stack,
"probes": probes,
"services": services,
"android": {
"adb_available": adb_available(),

View File

@@ -53,7 +53,12 @@ FILES = [
"web-console/middleware.ts",
"web-console/components/OverviewCharts.tsx",
"web-console/components/live-product/LiveControlApp.tsx",
"web-console/components/live-product/LiveAndroidWorkstation.tsx",
"web-console/components/live-product/LiveYoutubeUnmannedView.tsx",
"web-console/components/live-product/LiveTiktokHdmiView.tsx",
"web-console/lib/panelUrls.ts",
"web-console/lib/youtubeProChannels.ts",
"web-console/lib/youtubeConfigUtils.ts",
"web-console/components/console/LiveConsoleWorkspace.tsx",
]
@@ -224,12 +229,18 @@ def main() -> int:
_safe_print("远程 py_compile 失败,见日志")
return 3
_log_append(
log_path,
"\n[提示] web_console_build 在远端执行 npm + next buildARM 上常需 520 分钟;"
"未完成前日志不会出现 exit=,属正常现象。\n",
)
_safe_print("远端正在构建 web-console请耐心等待ARM 可能较久)…")
build_cmd = (
f"cd {REMOTE_BASE}/web-console && "
"(npm ci 2>/dev/null || npm install) && "
"(command -v pnpm >/dev/null 2>&1 && pnpm run build || npm run build)"
)
code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=900)
code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=2400)
if code_b != 0:
_safe_print("[警告] web-console build 非 0可能未装 Node/pnpm完整输出见日志")

View File

@@ -0,0 +1,597 @@
"use client";
import { ArrowUpRight, Layers, Loader2, Package, Terminal } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
type DeviceRow = { serial: string; model?: string; state: string };
type TFn = (zh: string, en: string) => string;
type AndroidOverview = {
serial?: string;
model?: string;
brand?: string;
android_version?: string;
sdk?: string;
battery?: string;
focus?: string;
resolution?: string;
rotation?: string;
screen_state?: string;
};
type PkgRow = { package: string; label?: string };
type UiNode = {
label: string;
text?: string;
content_desc?: string;
resource_id?: string;
center_x: number;
center_y: number;
clickable?: boolean;
enabled?: boolean;
};
type Props = {
t: TFn;
notify: (msg: string) => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
/** Parent busy (e.g. adb actions); combined with local pending for UI lock. */
busy: string | null;
androidSerial: string;
setAndroidSerial: (s: string) => void;
devices: DeviceRow[];
adbAvailable: boolean;
scrcpyUrl: string;
androidShotUrl: string | null;
androidShotErr: string | null;
apkPath: string;
setApkPath: (v: string) => void;
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
};
export function LiveAndroidWorkstation({
t,
notify,
fetchJson,
busy,
androidSerial,
setAndroidSerial,
devices,
adbAvailable,
scrcpyUrl,
androidShotUrl,
androidShotErr,
apkPath,
setApkPath,
onAndroidAction,
}: Props) {
const [overview, setOverview] = useState<AndroidOverview | null>(null);
const [overviewErr, setOverviewErr] = useState<string | null>(null);
const [pkgQuery, setPkgQuery] = useState("");
const [pkgList, setPkgList] = useState<PkgRow[]>([]);
const [shellCmd, setShellCmd] = useState("getprop ro.product.model");
const [shellOut, setShellOut] = useState("");
const [logcatLines, setLogcatLines] = useState("300");
const [logcatText, setLogcatText] = useState("");
const [logcatErr, setLogcatErr] = useState<string | null>(null);
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
const [uiErr, setUiErr] = useState<string | null>(null);
const [openUrl, setOpenUrl] = useState("https://");
const [pending, setPending] = useState<string | null>(null);
const lock = !!(busy || pending);
const loadOverview = useCallback(async () => {
if (!androidSerial || !adbAvailable) {
setOverview(null);
setOverviewErr(null);
return;
}
setPending("overview");
setOverviewErr(null);
try {
const o = await fetchJson<AndroidOverview>(
`/android/overview?serial=${encodeURIComponent(androidSerial)}`,
);
setOverview(o);
} catch (e) {
setOverview(null);
setOverviewErr((e as Error).message);
} finally {
setPending(null);
}
}, [adbAvailable, androidSerial, fetchJson]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const searchPackages = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("packages");
try {
const q = encodeURIComponent(pkgQuery.trim());
const data = await fetchJson<{ packages: PkgRow[] }>(
`/android/packages?serial=${encodeURIComponent(androidSerial)}&query=${q}&limit=80`,
);
setPkgList(data.packages || []);
} catch (e) {
notify((e as Error).message);
setPkgList([]);
} finally {
setPending(null);
}
};
const runShell = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const cmd = shellCmd.trim();
if (!cmd) return;
setPending("shell");
try {
const data = await fetchJson<{ output?: string; message?: string }>("/android/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shell", serial: androidSerial, payload: { command: cmd } }),
});
setShellOut(data.output ?? data.message ?? "");
} catch (e) {
setShellOut((e as Error).message);
} finally {
setPending(null);
}
};
const refreshLogcat = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
const n = Math.min(3000, Math.max(1, parseInt(logcatLines, 10) || 200));
setPending("logcat");
setLogcatErr(null);
try {
const data = await fetchJson<{ text?: string }>(
`/android/logcat?serial=${encodeURIComponent(androidSerial)}&lines=${n}`,
);
setLogcatText(data.text ?? "");
} catch (e) {
setLogcatText("");
setLogcatErr((e as Error).message);
} finally {
setPending(null);
}
};
const loadUiNodes = async () => {
if (!androidSerial) {
notify(t("请选择设备", "Pick a device"));
return;
}
setPending("ui");
setUiErr(null);
try {
const data = await fetchJson<{ nodes: UiNode[] }>(
`/android/ui?serial=${encodeURIComponent(androidSerial)}&limit=100`,
);
setUiNodes(data.nodes || []);
} catch (e) {
setUiNodes([]);
setUiErr((e as Error).message);
} finally {
setPending(null);
}
};
const tapNode = async (x: number, y: number) => {
await onAndroidAction("tap", { x, y });
};
const sendKey = async (keycode: string) => {
await onAndroidAction("key", { keycode });
};
const openUrlGo = async () => {
const u = openUrl.trim();
if (!u || u === "https://") {
notify(t("填写 URL", "Enter URL"));
return;
}
await onAndroidAction("open_url", { url: u });
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-white">{t("设备与工作台", "Device workstation")}</h3>
{!adbAvailable ? (
<span className="text-xs text-amber-200/90">{t("宿主机未检测到 adb", "adb not found on host")}</span>
) : (
<button
type="button"
disabled={lock || !androidSerial}
onClick={() => void loadOverview()}
className="rounded-lg border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs text-zinc-300 hover:bg-white/[0.08]"
>
{t("刷新设备信息", "Refresh device info")}
</button>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{devices.map((d) => (
<button
key={d.serial}
type="button"
onClick={() => setAndroidSerial(d.serial)}
className={`rounded-xl border px-4 py-2 text-left text-xs ${
androidSerial === d.serial
? "border-violet-500/50 bg-violet-500/10 text-white"
: "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
<div className="text-[10px] text-zinc-600">{d.serial}</div>
</button>
))}
</div>
</div>
{adbAvailable && androidSerial ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-violet-300/90">
{t("设备信息", "Device info")}
</h4>
{overviewErr ? <p className="mt-2 text-xs text-rose-300">{overviewErr}</p> : null}
{overview ? (
<dl className="mt-3 space-y-1.5 font-mono text-[11px] text-zinc-400">
<div>
<dt className="inline text-zinc-600">model</dt>{" "}
<dd className="inline text-zinc-300">
{overview.brand} {overview.model}
</dd>
</div>
<div>
<dt className="inline text-zinc-600">Android</dt>{" "}
<dd className="inline text-zinc-300">
{overview.android_version} (sdk {overview.sdk})
</dd>
</div>
<div>
<dt className="inline text-zinc-600">display</dt>{" "}
<dd className="inline text-zinc-300">{overview.resolution}</dd>
</div>
<div>
<dt className="inline text-zinc-600">rotation</dt>{" "}
<dd className="inline text-zinc-300">{overview.rotation}</dd>
</div>
<div className="whitespace-pre-wrap text-[10px] leading-snug text-zinc-500">
{overview.battery}
</div>
<div className="line-clamp-3 text-[10px] text-zinc-500">{overview.focus}</div>
</dl>
) : !overviewErr ? (
<p className="mt-2 text-xs text-zinc-500">{t("加载中…", "Loading…")}</p>
) : null}
</div>
<div className="rounded-2xl border border-cyan-500/15 bg-zinc-950/50 p-5">
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-300/90">
{t("快捷控制", "Quick controls")}
</h4>
<p className="mt-2 text-[11px] text-zinc-500">
{t("按键与常用应用;深层调试用下方 Shell / Logcat。", "Keys & apps; use Shell / Logcat for deep debug.")}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void sendKey("3")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Home
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("4")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Back
</button>
<button
type="button"
disabled={lock}
onClick={() => void sendKey("187")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Recents
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("wake")}
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
>
Wake
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("launch_package", { package: "com.zhiliaoapp.musically" })}
className="rounded-lg bg-gradient-to-r from-pink-500/25 to-violet-500/25 px-3 py-1.5 text-xs ring-1 ring-white/10"
>
TikTok
</button>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<input
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
value={openUrl}
onChange={(e) => setOpenUrl(e.target.value)}
placeholder="https://"
/>
<button
type="button"
disabled={lock}
onClick={() => void openUrlGo()}
className="rounded-lg bg-violet-500/20 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/30"
>
{t("打开链接", "Open URL")}
</button>
</div>
</div>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Package className="h-4 w-4 text-emerald-400" />
{t("包名检索与启动", "Packages")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("过滤 pm list packages行内可启动或强停。", "Filter pm list packages; launch or force-stop.")}
</p>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<input
className="min-w-0 flex-1 rounded-xl border border-white/10 bg-black/40 px-4 py-2 text-sm"
value={pkgQuery}
onChange={(e) => setPkgQuery(e.target.value)}
placeholder="tiktok / musically / …"
/>
<button
type="button"
disabled={lock}
onClick={() => void searchPackages()}
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm text-emerald-100 ring-1 ring-emerald-500/30"
>
{t("搜索", "Search")}
</button>
</div>
<ul className="mt-4 max-h-48 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 font-mono text-[11px]">
{pkgList.map((p) => (
<li key={p.package} className="flex flex-wrap items-center justify-between gap-2 py-1 text-zinc-400">
<span className="min-w-0 truncate text-zinc-300">{p.package}</span>
<span className="flex shrink-0 gap-1">
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("launch_package", { package: p.package })}
className="rounded bg-white/10 px-2 py-0.5 text-[10px] text-zinc-200"
>
{t("启动", "Launch")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void onAndroidAction("force_stop", { package: p.package })}
className="rounded bg-rose-500/15 px-2 py-0.5 text-[10px] text-rose-200"
>
{t("强停", "Stop")}
</button>
</span>
</li>
))}
{!pkgList.length ? (
<li className="py-2 text-center text-zinc-600">{t("暂无结果,先搜索", "No results")}</li>
) : null}
</ul>
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Terminal className="h-4 w-4 text-amber-400" />
{t("交互式 Shell只读安全子集由你输入决定", "ADB shell")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("等同 adb shell勿执行不可逆命令。", "Same as adb shell; avoid destructive commands.")}
</p>
<textarea
className="mt-3 min-h-[72px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-3 font-mono text-xs text-zinc-200"
value={shellCmd}
onChange={(e) => setShellCmd(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={lock}
onClick={() => void runShell()}
className="mt-2 rounded-xl bg-amber-500/20 px-4 py-2 text-sm text-amber-100 ring-1 ring-amber-500/30"
>
{pending === "shell" ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Run
</span>
) : (
"Run"
)}
</button>
{shellOut ? (
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400">
{shellOut}
</pre>
) : null}
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("Logcat最近行", "Logcat (recent)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("执行 logcat -d -t N适合快速排障。", "Runs logcat -d -t N for quick triage.")}
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className="w-24 rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
value={logcatLines}
onChange={(e) => setLogcatLines(e.target.value)}
/>
<button
type="button"
disabled={lock}
onClick={() => void refreshLogcat()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("拉取日志", "Pull logs")}
</button>
</div>
{logcatErr ? <p className="mt-2 text-xs text-rose-300">{logcatErr}</p> : null}
{logcatText ? (
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/50 p-3 font-mono text-[10px] leading-tight text-zinc-500">
{logcatText}
</pre>
) : null}
</div>
) : null}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Layers className="h-4 w-4 text-fuchsia-400" />
{t("界面节点uiautomator dump", "UI nodes")}
</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("列出可交互节点,点击坐标模拟触控。", "Clickable nodes from UI dump; tap by coordinates.")}
</p>
<button
type="button"
disabled={lock}
onClick={() => void loadUiNodes()}
className="mt-3 rounded-xl border border-fuchsia-500/30 bg-fuchsia-500/10 px-4 py-2 text-sm text-fuchsia-100"
>
{t("刷新节点树", "Refresh nodes")}
</button>
{uiErr ? <p className="mt-2 text-xs text-rose-300">{uiErr}</p> : null}
<ul className="mt-3 max-h-64 space-y-1 overflow-y-auto rounded-lg border border-white/5 bg-black/30 p-2 text-[11px]">
{uiNodes.map((n, i) => (
<li
key={`${n.center_x}-${n.center_y}-${i}`}
className="flex flex-wrap items-center justify-between gap-2 border-b border-white/[0.04] py-1.5 text-zinc-400 last:border-0"
>
<div className="min-w-0 flex-1">
<span className="text-zinc-200">{n.label}</span>
{n.resource_id ? (
<span className="ml-2 font-mono text-[10px] text-zinc-600">{n.resource_id}</span>
) : null}
<div className="font-mono text-[10px] text-zinc-600">
({n.center_x}, {n.center_y}){n.clickable ? " · click" : ""}
</div>
</div>
<button
type="button"
disabled={lock}
onClick={() => void tapNode(n.center_x, n.center_y)}
className="shrink-0 rounded bg-white/10 px-2 py-1 text-[10px] text-zinc-200"
>
Tap
</button>
</li>
))}
{!uiNodes.length && !uiErr ? (
<li className="py-4 text-center text-zinc-600">{t("尚未加载", "Not loaded")}</li>
) : null}
</ul>
</div>
) : null}
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK设备上的路径", "Install APK (path on device)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
<button
type="button"
disabled={lock || !adbAvailable || !androidSerial}
onClick={() => void onAndroidAction("install_apk", { path: apkPath })}
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
>
pm install
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("实时画面ADB 截图)", "Live screen (ADB)")}</h3>
{scrcpyUrl ? (
<a
href={scrcpyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-violet-300 hover:text-violet-200"
>
ws-scrcpy <ArrowUpRight className="h-3 w-3" />
</a>
) : null}
</div>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新;全帧低延迟请用 ws-scrcpy 新窗口或本页内嵌。",
"~2.5s ADB refresh; use ws-scrcpy for low-latency stream.",
)}
</p>
{!adbAvailable ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && adbAvailable ? <p className="mt-3 text-sm text-rose-300">{androidShotErr}</p> : null}
{androidShotUrl ? (
<img
alt="Android live"
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
src={androidShotUrl}
/>
) : null}
{adbAvailable && !androidShotUrl && !androidShotErr ? (
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
) : null}
</div>
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
<span className="text-xs font-medium text-zinc-400">ws-scrcpy</span>
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
{t("新窗口打开", "Open tab")}
</a>
</div>
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
</div>
) : null}
</div>
);
}

View File

@@ -5,12 +5,13 @@ import {
Activity,
ArrowUpRight,
ChevronRight,
CirclePlay,
Cpu,
HardDrive,
Loader2,
Menu,
MonitorPlay,
Network,
Radio,
RefreshCw,
Server,
Settings,
@@ -22,10 +23,20 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react";
import OverviewCharts from "@/components/OverviewCharts";
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
type Lang = "zh" | "en";
type ViewKey = "dashboard" | "services" | "live" | "android" | "network" | "settings";
type ViewKey =
| "dashboard"
| "services"
| "live_youtube"
| "live_tiktok"
| "android"
| "network"
| "settings";
const apiBase = () =>
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || "";
@@ -66,6 +77,18 @@ type HubDashboard = {
script?: string;
}>;
};
probes?: {
srs?: {
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 };
};
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 };
};
};
};
type ProcessEntry = { pm2: string; script: string; label: string };
@@ -225,7 +248,7 @@ export default function LiveControlApp() {
}, [currentProc, fetchJson]);
useEffect(() => {
if (!currentProc || view !== "live") return;
if (!currentProc || (view !== "live_youtube" && view !== "live_tiktok")) return;
void (async () => {
try {
const data = await fetchJson<{ content?: string }>(
@@ -317,15 +340,16 @@ export default function LiveControlApp() {
}
};
const saveUrlConfig = async () => {
const saveUrlConfig = async (overrideContent?: string) => {
if (!currentProc) return;
const content = overrideContent ?? urlConfig;
setBusy("save:url");
try {
const params = new URLSearchParams({ process: currentProc });
await fetchJson(`/save_url_config?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: urlConfig }),
body: JSON.stringify({ content }),
});
notify(t("URL 配置已保存", "URL config saved"));
} catch (e) {
@@ -374,12 +398,20 @@ export default function LiveControlApp() {
const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [
{ id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" },
{ id: "services", icon: Server, labelZh: "服务", labelEn: "Services" },
{ id: "live", icon: Radio, labelZh: "无人直播", labelEn: "Live ops" },
{ id: "live_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" },
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
{ id: "network", icon: Network, labelZh: "网络", labelEn: "Network" },
{ id: "settings", icon: Settings, labelZh: "设置", labelEn: "Settings" },
];
const copyText = (text: string) => {
void navigator.clipboard.writeText(text).then(
() => notify(t("已复制", "Copied")),
() => notify(t("复制失败", "Copy failed")),
);
};
const onlineServices =
dash?.services?.filter((s) => s.running && s.available).length ?? 0;
const totalServices = dash?.services?.length ?? 0;
@@ -557,6 +589,35 @@ export default function LiveControlApp() {
))}
</div>
{dash?.probes ? (
<div className="grid gap-3 sm:grid-cols-2">
{(["srs", "neko"] as const).map((key) => {
const p = dash.probes?.[key];
const tcpOk = p?.tcp?.reachable;
const http = p?.http;
const title = key === "srs" ? "SRS" : "Neko Chromium";
return (
<div
key={key}
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">{title}</p>
<p className="mt-1 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>
);
})}
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-3">
<motion.div
initial={{ opacity: 0 }}
@@ -597,13 +658,22 @@ export default function LiveControlApp() {
<li className="text-zinc-600">{t("暂无数据", "No data")}</li>
) : null}
</ul>
<button
type="button"
onClick={() => setView("live")}
className="mt-4 w-full rounded-xl border border-violet-500/30 bg-violet-500/10 py-2 text-xs font-semibold text-violet-200"
>
{t("去控制台", "Go to live ops")}
</button>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setView("live_youtube")}
className="rounded-xl border border-violet-500/30 bg-violet-500/10 py-2 text-[11px] font-semibold text-violet-200"
>
YouTube
</button>
<button
type="button"
onClick={() => setView("live_tiktok")}
className="rounded-xl border border-cyan-500/30 bg-cyan-500/10 py-2 text-[11px] font-semibold text-cyan-200"
>
TikTok
</button>
</div>
</div>
</div>
</div>
@@ -680,192 +750,59 @@ export default function LiveControlApp() {
</div>
) : null}
{view === "live" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("选择进程", "Process")}</label>
<select
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
value={currentProc}
onChange={(e) => setCurrentProc(e.target.value)}
>
{entries.map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} ({e.pm2})
</option>
))}
</select>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={!!busy}
onClick={() => void runProcess("start")}
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-200 ring-1 ring-emerald-500/30"
>
{t("启动", "Start")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void runProcess("restart")}
className="rounded-xl bg-amber-500/15 px-4 py-2 text-sm font-medium text-amber-200 ring-1 ring-amber-500/25"
>
{t("重启", "Restart")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void runProcess("stop")}
className="rounded-xl bg-rose-500/15 px-4 py-2 text-sm font-medium text-rose-200 ring-1 ring-rose-500/25"
>
{t("停止", "Stop")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("抖音 / TikTok 拉流地址", "Douyin / TikTok URL")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("写入业务元数据config center推流细节仍以 URL_config.ini / PM2 为准。", "Stored in config center; streaming still uses URL_config.ini.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={douyinUrl}
onChange={(e) => setDouyinUrl(e.target.value)}
placeholder="https://live.douyin.com/..."
/>
<button
type="button"
disabled={!!busy}
onClick={() => void saveBusinessMeta()}
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
>
{t("保存到配置中心", "Save to config hub")}
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">URL_config.ini</h3>
<textarea
className="mt-4 h-48 w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={urlConfig}
onChange={(e) => setUrlConfig(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={!!busy}
onClick={() => void saveUrlConfig()}
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存 URL 配置", "Save URL config")}
</button>
</div>
<p className="text-center text-xs text-zinc-600">
{t(
"FFmpeg 自动重启:请在 PM2 ecosystem 或 systemd 中配置 restart 策略business.json 中 ffmpeg.auto_restart 供运维工具读取。",
"For FFmpeg auto-restart, configure PM2 or systemd; business.json flags are for automation hooks.",
)}
</p>
</div>
{view === "live_youtube" ? (
<LiveYoutubeUnmannedView
t={t}
busy={!!busy}
entries={entries}
currentProc={currentProc}
setCurrentProc={setCurrentProc}
urlConfig={urlConfig}
setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl}
setDouyinUrl={setDouyinUrl}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}
fetchJson={fetchJson}
/>
) : null}
{view === "live_tiktok" ? (
<LiveTiktokHdmiView
t={t}
busy={!!busy}
entries={entries}
currentProc={currentProc}
setCurrentProc={setCurrentProc}
urlConfig={urlConfig}
setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl}
setDouyinUrl={setDouyinUrl}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}
onCopy={copyText}
/>
) : null}
{view === "android" ? (
<div className="mx-auto max-w-5xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("设备", "Devices")}</h3>
<div className="mt-4 flex flex-wrap gap-2">
{(dash?.android?.devices || []).map((d) => (
<button
key={d.serial}
type="button"
onClick={() => setAndroidSerial(d.serial)}
className={`rounded-xl border px-4 py-2 text-left text-xs ${
androidSerial === d.serial
? "border-violet-500/50 bg-violet-500/10 text-white"
: "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
<div className="text-[10px] text-zinc-600">{d.serial}</div>
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!!busy}
onClick={() =>
void androidAction("launch_package", { package: "com.zhiliaoapp.musically" })
}
className="rounded-xl bg-gradient-to-r from-pink-500/30 to-violet-500/30 px-4 py-2 text-sm font-medium ring-1 ring-white/10"
>
TikTok
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void androidAction("wake")}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm"
>
Wake
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK设备上的路径", "Install APK (path on device)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
<button
type="button"
disabled={!!busy}
onClick={() => void androidAction("install_apk", { path: apkPath })}
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
>
pm install
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("实时画面ADB 截图)", "Live screen (ADB)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新一次;无需 ws-scrcpy。点击下方「新窗口」可尝试流式画面。",
"~2.5s refresh via ADB; no ws-scrcpy required. Open ws-scrcpy in a new tab for streaming.",
)}
</p>
{!dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-rose-300">{androidShotErr}</p>
) : null}
{androidShotUrl ? (
<img
alt="Android live"
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
src={androidShotUrl}
/>
) : null}
{dash?.android?.adb_available && !androidShotUrl && !androidShotErr ? (
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
) : null}
</div>
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
<span className="text-xs font-medium text-zinc-400">ws-scrcpy</span>
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
{t("新窗口打开", "Open tab")}
</a>
</div>
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
</div>
) : null}
</div>
<LiveAndroidWorkstation
t={t}
notify={notify}
fetchJson={fetchJson}
busy={busy}
androidSerial={androidSerial}
setAndroidSerial={setAndroidSerial}
devices={dash?.android?.devices || []}
adbAvailable={!!dash?.android?.adb_available}
scrcpyUrl={scrcpyUrl}
androidShotUrl={androidShotUrl}
androidShotErr={androidShotErr}
apkPath={apkPath}
setApkPath={setApkPath}
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
/>
) : null}
{view === "network" ? (

View File

@@ -0,0 +1,203 @@
"use client";
import { Cable, Copy, Monitor } from "lucide-react";
import { useMemo, useState } from "react";
type ProcessEntry = { pm2: string; script: string; label: string };
type TFn = (zh: string, en: string) => string;
function isTiktokProcess(e: ProcessEntry) {
return /tiktok/i.test(e.script) || /tiktok/i.test(e.pm2);
}
type HdmiPreset = { id: string; labelZh: string; labelEn: string; spec: string; hintZh: string; hintEn: string };
const PRESETS: HdmiPreset[] = [
{
id: "720p60",
labelZh: "720p @ 60Hz",
labelEn: "720p @ 60Hz",
spec: "1280x720, 59.94/60Hz, RGB/YUV 有限",
hintZh: "板端 HDMI 输出与采集卡 EDID 对齐OBS 画布 1280×720。",
hintEn: "Match SoC HDMI timing to capture card EDID; OBS 1280×720.",
},
{
id: "1080p30",
labelZh: "1080p @ 30Hz",
labelEn: "1080p @ 30Hz",
spec: "1920x1080, 30Hz, 适合 USB 采集带宽紧张场景",
hintZh: "USB3 采集常更稳;检查线缆长度与屏蔽。",
hintEn: "Often more stable on USB3 capture; check cable quality.",
},
{
id: "1080p60",
labelZh: "1080p @ 60Hz",
labelEn: "1080p @ 60Hz",
spec: "1920x1080, 60Hz, 高刷转播",
hintZh: "需确认采集卡与 SoC 同时支持 1080p60过热会掉帧。",
hintEn: "Requires 1080p60 on both ends; thermal throttling may drop frames.",
},
];
type Props = {
t: TFn;
busy: boolean;
entries: ProcessEntry[];
currentProc: string;
setCurrentProc: (v: string) => void;
urlConfig: string;
setUrlConfig: (v: string) => void;
douyinUrl: string;
setDouyinUrl: (v: string) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void;
onSaveBusinessMeta: () => void;
onCopy: (text: string) => void;
};
export function LiveTiktokHdmiView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
douyinUrl,
setDouyinUrl,
onRunProcess,
onSaveUrlConfig,
onSaveBusinessMeta,
onCopy,
}: Props) {
const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]);
const [expanded, setExpanded] = useState<string | null>("1080p30");
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 via-transparent to-violet-500/5 p-6">
<div className="flex items-center gap-2">
<Cable className="h-5 w-5 text-cyan-300" />
<h2 className="text-base font-semibold text-white">{t("TikTok 无人直播 · HDMI", "TikTok unmanned · HDMI")}</h2>
</div>
<p className="mt-2 text-xs text-zinc-500">
{t(
"TikTok 无多频道概念:单路转播到 HDMI。下列为常见时序与排障要点便于你在板子与采集侧做兼容测试。",
"Single-path HDMI relay; presets for timing compatibility tests.",
)}
</p>
</div>
<div className="grid gap-3 md:grid-cols-3">
{PRESETS.map((p) => {
const open = expanded === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setExpanded(open ? null : p.id)}
className={`rounded-2xl border p-4 text-left transition ${
open ? "border-cyan-500/40 bg-cyan-500/10" : "border-white/[0.08] bg-zinc-950/50 hover:border-white/15"
}`}
>
<div className="flex items-center gap-2 text-sm font-semibold text-white">
<Monitor className="h-4 w-4 text-cyan-400" />
{t(p.labelZh, p.labelEn)}
</div>
<p className="mt-2 font-mono text-[11px] text-zinc-400">{p.spec}</p>
{open ? <p className="mt-2 text-xs text-zinc-500">{t(p.hintZh, p.hintEn)}</p> : null}
</button>
);
})}
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("TikTok 进程", "TikTok process")}</label>
<select
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
value={currentProc}
onChange={(e) => setCurrentProc(e.target.value)}
>
{(tkEntries.length ? tkEntries : entries).map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} ({e.pm2})
</option>
))}
</select>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={!!busy}
onClick={() => onRunProcess("start")}
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-200 ring-1 ring-emerald-500/30"
>
{t("启动", "Start")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => onRunProcess("restart")}
className="rounded-xl bg-amber-500/15 px-4 py-2 text-sm font-medium text-amber-200 ring-1 ring-amber-500/25"
>
{t("重启", "Restart")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => onRunProcess("stop")}
className="rounded-xl bg-rose-500/15 px-4 py-2 text-sm font-medium text-rose-200 ring-1 ring-rose-500/25"
>
{t("停止", "Stop")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("拉流地址(业务元数据)", "Source URL (hub)")}</h3>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={douyinUrl}
onChange={(e) => setDouyinUrl(e.target.value)}
placeholder="https://live.douyin.com/..."
/>
<button
type="button"
disabled={!!busy}
onClick={() => onSaveBusinessMeta()}
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
>
{t("保存", "Save")}
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">URL_config.ini</h3>
<button
type="button"
className="inline-flex items-center gap-1 text-xs text-cyan-300 hover:text-cyan-200"
onClick={() => onCopy(urlConfig)}
>
<Copy className="h-3.5 w-3.5" />
{t("复制全文", "Copy all")}
</button>
</div>
<textarea
className="mt-4 h-40 w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={urlConfig}
onChange={(e) => setUrlConfig(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={!!busy}
onClick={() => onSaveUrlConfig()}
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存 URL 配置", "Save URL config")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,626 @@
"use client";
import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
buildYoutubeIniFromFields,
extractDouyinLiveUrls,
parseYoutubeIniFields,
parseYoutubeStreamKeySuffix,
type YoutubeIniFields,
} from "@/lib/youtubeConfigUtils";
import {
defaultPm2NameForChannel,
loadYoutubeProChannels,
newChannelId,
saveYoutubeProChannels,
type YoutubeProChannel,
} from "@/lib/youtubeProChannels";
type ProcessEntry = { pm2: string; script: string; label: string };
type TFn = (zh: string, en: string) => string;
function isYoutubeProcess(e: ProcessEntry) {
return /youtube/i.test(e.script) || /youtube/i.test(e.pm2);
}
const MODE_KEY = "live-hub-youtube-mode-v1";
type Props = {
t: TFn;
busy: boolean;
entries: ProcessEntry[];
currentProc: string;
setCurrentProc: (v: string) => void;
urlConfig: string;
setUrlConfig: (v: string) => void;
douyinUrl: string;
setDouyinUrl: (v: string) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void;
onSaveBusinessMeta: () => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
};
export function LiveYoutubeUnmannedView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
douyinUrl,
setDouyinUrl,
onRunProcess,
onSaveUrlConfig,
onSaveBusinessMeta,
fetchJson,
}: Props) {
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
const processOptions = ytEntries.length ? ytEntries : entries;
const [mode, setMode] = useState<"single" | "pro">("single");
const [channels, setChannels] = useState<YoutubeProChannel[]>([]);
const [activeCh, setActiveCh] = useState<string>("");
const [youtubeIniText, setYoutubeIniText] = useState("");
const [ytFields, setYtFields] = useState<YoutubeIniFields>({
key: "",
rtmps: true,
bitrate: "",
fastAudio: false,
});
const [ytIniStatus, setYtIniStatus] = useState<string | null>(null);
const [ytIniLoading, setYtIniLoading] = useState(false);
const [proUrlDraft, setProUrlDraft] = useState("");
useEffect(() => {
try {
const m = window.localStorage.getItem(MODE_KEY);
if (m === "pro" || m === "single") setMode(m);
} catch {
/* ignore */
}
}, []);
const setModePersist = (m: "single" | "pro") => {
setMode(m);
if (m === "pro") {
const ch = channels.find((c) => c.id === activeCh) ?? channels[0];
if (ch) {
const u = (ch.urlLines ?? "").trim();
setProUrlDraft(u || urlConfig);
}
}
try {
window.localStorage.setItem(MODE_KEY, m);
} catch {
/* ignore */
}
};
useEffect(() => {
const list = loadYoutubeProChannels();
const initial =
list.length > 0
? list
: (() => {
const id = newChannelId();
return [
{
id,
name: "线路 1",
streamKey: "",
urlLines: "",
pm2Name: defaultPm2NameForChannel(id),
notes: "",
},
];
})();
setChannels(initial);
if (!list.length) saveYoutubeProChannels(initial);
}, []);
useEffect(() => {
if (channels.length && !activeCh) setActiveCh(channels[0].id);
}, [channels, activeCh]);
const persistChannels = useCallback((next: YoutubeProChannel[]) => {
setChannels(next);
saveYoutubeProChannels(next);
}, []);
const active = channels.find((c) => c.id === activeCh) || channels[0];
const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return;
setYtIniLoading(true);
setYtIniStatus(null);
try {
const data = await fetchJson<{ content?: string }>(
`/get_config?process=${encodeURIComponent(currentProc)}`,
);
const text = data.content ?? "";
setYoutubeIniText(text);
setYtFields(parseYoutubeIniFields(text));
} catch (e) {
setYtIniStatus((e as Error).message);
} finally {
setYtIniLoading(false);
}
}, [currentProc, fetchJson]);
useEffect(() => {
if (!currentProc) return;
void loadYoutubeIni();
}, [currentProc, loadYoutubeIni]);
const updateActive = (patch: Partial<YoutubeProChannel>) => {
if (!active) return;
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
};
const saveYoutubeIniToServer = async (content: string) => {
if (!currentProc) {
setYtIniStatus(t("请选择进程", "Pick a process"));
return;
}
setYtIniLoading(true);
setYtIniStatus(null);
try {
await fetchJson("/save_config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
setYoutubeIniText(content);
setYtIniStatus(t("youtube.ini 已保存", "youtube.ini saved"));
} catch (e) {
setYtIniStatus((e as Error).message);
} finally {
setYtIniLoading(false);
}
};
const applyFieldsAndSave = async () => {
const body = buildYoutubeIniFromFields(ytFields);
await saveYoutubeIniToServer(body);
};
const syncFieldsFromText = () => {
setYtFields(parseYoutubeIniFields(youtubeIniText));
setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw"));
};
const keySuffixUi = useMemo(() => {
if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`);
const k = active?.streamKey ?? "";
return parseYoutubeStreamKeySuffix(`key = ${k}`);
}, [mode, ytFields.key, active?.streamKey]);
const urlTextForPreview = mode === "pro" ? proUrlDraft : urlConfig;
const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlTextForPreview), [urlTextForPreview]);
const saveProUrlAndServer = async () => {
if (!active) return;
updateActive({ urlLines: proUrlDraft });
setUrlConfig(proUrlDraft);
onSaveUrlConfig(proUrlDraft);
};
const pushProStreamKeyToServer = async () => {
if (!active) return;
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
const f: YoutubeIniFields = {
key: (active.streamKey ?? "").trim(),
rtmps: base.rtmps,
bitrate: base.bitrate,
fastAudio: base.fastAudio,
};
const body = buildYoutubeIniFromFields(f);
await saveYoutubeIniToServer(body);
updateActive({ streamKey: f.key });
};
const lock = busy || ytIniLoading;
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/80 to-cyan-500/5 p-6 shadow-xl shadow-violet-900/10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-red-500/30 to-violet-600/30 ring-1 ring-white/10">
<MonitorPlay className="h-5 w-5 text-red-200" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight text-white">
{t("YouTube 无人直播", "YouTube unmanned live")}
</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{t(
"对齐桌面客户端「直播」页:单路配置与多路 Pro 编排,无账号登录。",
"Desktop-style live control: single or Pro lanes, no account login.",
)}
</p>
</div>
</div>
<div className="flex rounded-xl border border-white/10 bg-black/30 p-1">
<button
type="button"
onClick={() => setModePersist("single")}
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
mode === "single" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
}`}
>
{t("单频道", "Single")}
</button>
<button
type="button"
onClick={() => setModePersist("pro")}
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
mode === "pro" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
}`}
>
{t("多频道 Pro", "Multi Pro")}
</button>
</div>
</div>
</div>
<div className="rounded-2xl border border-amber-500/20 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
<div className="flex items-center gap-2 text-amber-200/90">
<Radio className="h-4 w-4" />
<h3 className="text-sm font-semibold text-white">{t("直播开关", "Live control")}</h3>
</div>
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
{t("进程PM2", "PM2 process")}
</label>
<select
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
value={currentProc}
onChange={(e) => setCurrentProc(e.target.value)}
>
{processOptions.map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} ({e.pm2})
</option>
))}
</select>
{mode === "pro" && active ? (
<p className="mt-2 font-mono text-[11px] text-cyan-300/80">
Pro {t("建议独立进程名", "suggested")}: {active.pm2Name || defaultPm2NameForChannel(active.id)}
</p>
) : null}
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
{(
[
["start", t("开始直播", "Start"), "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"],
["stop", t("停止直播", "Stop"), "bg-rose-500/15 text-rose-200 ring-rose-500/25"],
["restart", t("重新开始", "Restart"), "bg-amber-500/15 text-amber-200 ring-amber-500/25"],
] as const
).map(([a, label, cls]) => (
<button
key={a}
type="button"
disabled={lock}
onClick={() => onRunProcess(a)}
className={`rounded-xl px-4 py-3 text-sm font-medium ring-1 ${cls}`}
>
{label}
</button>
))}
</div>
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
key {t("尾码(参考)", "suffix")}: {keySuffixUi || "—"}
</p>
</div>
{mode === "single" ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("YouTube 相关设置", "YouTube settings")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("编辑 config/youtube.ini与桌面端一致字段。不使用 Google 登录。", "Edits config/youtube.ini. No Google OAuth.")}
</p>
<div className="mt-4 space-y-3">
<label className="block text-xs text-zinc-400">{t("串流密钥", "Stream key")}</label>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm font-mono text-zinc-200"
spellCheck={false}
autoComplete="off"
value={ytFields.key}
onChange={(e) => setYtFields((f) => ({ ...f, key: e.target.value }))}
placeholder="xxxx-xxxx-xxxx-xxxx-xxxx"
/>
<div className="grid gap-3 sm:grid-cols-3">
<label className="flex flex-col gap-1 text-xs text-zinc-400">
RTMPS
<select
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-white"
value={ytFields.rtmps ? "1" : "0"}
onChange={(e) => setYtFields((f) => ({ ...f, rtmps: e.target.value === "1" }))}
>
<option value="1">{t("是(默认)", "Yes")}</option>
<option value="0">{t("否 → RTMP", "No")}</option>
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-zinc-400">
{t("比特率 kbps", "Bitrate kbps")}
<input
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
value={ytFields.bitrate}
onChange={(e) => setYtFields((f) => ({ ...f, bitrate: e.target.value }))}
placeholder="4000"
/>
</label>
<label className="flex cursor-pointer items-center gap-2 pt-6 text-xs text-zinc-300">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={ytFields.fastAudio}
onChange={(e) => setYtFields((f) => ({ ...f, fastAudio: e.target.checked }))}
/>
fast_audio{t("轻量音频", "lighter audio")}
</label>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void loadYoutubeIni()}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-200"
>
<RefreshCw className={`h-3.5 w-3.5 ${ytIniLoading ? "animate-spin" : ""}`} />
{t("重新读取", "Reload")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void applyFieldsAndSave()}
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
>
{ytIniLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t("保存 youtube.ini", "Save youtube.ini")}
</button>
<button
type="button"
disabled={lock}
onClick={syncFieldsFromText}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-400"
>
{t("从原文同步表单", "Parse raw")}
</button>
</div>
<details className="mt-4 rounded-xl border border-white/5 bg-black/20 p-3">
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级youtube.ini 原文", "Raw youtube.ini")}</summary>
<textarea
className="mt-2 h-36 w-full resize-y rounded-lg border border-white/10 bg-black/50 p-3 font-mono text-[11px] text-zinc-400"
value={youtubeIniText}
onChange={(e) => setYoutubeIniText(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={lock}
className="mt-2 rounded-lg bg-cyan-500/15 px-3 py-1.5 text-xs text-cyan-200"
onClick={() => void saveYoutubeIniToServer(youtubeIniText)}
>
{t("保存原文", "Save raw")}
</button>
</details>
{ytIniStatus ? <p className="mt-2 font-mono text-xs text-zinc-500">{ytIniStatus}</p> : null}
</div>
) : (
<div className="space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("Pro 线路列表", "Pro channels")}</h3>
<button
type="button"
disabled={lock}
className="inline-flex items-center gap-1 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs text-violet-100 ring-1 ring-violet-500/30"
onClick={() => {
const id = newChannelId();
const nc: YoutubeProChannel = {
id,
name: `${t("线路", "Ch")} ${channels.length + 1}`,
streamKey: "",
urlLines: "",
pm2Name: defaultPm2NameForChannel(id),
notes: "",
};
persistChannels([...channels, nc]);
setActiveCh(id);
setProUrlDraft("");
}}
>
<Plus className="h-3.5 w-3.5" />
{t("新增线路", "Add")}
</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{channels.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
if (active && mode === "pro") {
persistChannels(
channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)),
);
}
const u = (c.urlLines ?? "").trim();
setProUrlDraft(u || urlConfig);
setActiveCh(c.id);
}}
className={`rounded-xl border px-3 py-2 text-left text-xs ${
activeCh === c.id ? "border-violet-500/50 bg-violet-500/10 text-white" : "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="font-medium text-zinc-200">{c.name}</div>
<div className="font-mono text-[10px] text-zinc-600">{c.pm2Name || defaultPm2NameForChannel(c.id)}</div>
</button>
))}
</div>
{active ? (
<div className="mt-4 space-y-3 border-t border-white/5 pt-4">
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm"
value={active.name}
onChange={(e) => updateActive({ name: e.target.value })}
placeholder={t("线路显示名", "Channel name")}
/>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
onChange={(e) => updateActive({ pm2Name: e.target.value })}
placeholder="youtube2__…"
/>
<label className="block text-xs text-zinc-500">{t("串流密钥(本线路)", "Stream key for this lane")}</label>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
spellCheck={false}
value={active.streamKey ?? ""}
onChange={(e) => updateActive({ streamKey: e.target.value })}
placeholder="key / rtmp(s) URL"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void pushProStreamKeyToServer()}
className="rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
>
{t("将本线路密钥写入服务器 youtube.ini", "Push key → server youtube.ini")}
</button>
</div>
<p className="text-[11px] text-amber-200/70">
{t(
"服务器仅一份 youtube.ini推送会覆盖多路并行请在板上为每路配置独立 PM2 与独立 config 目录,或轮流推送。",
"Server has one youtube.ini; overwrite. For true parallel lanes use separate PM2 + config dirs on the host.",
)}
</p>
<textarea
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
value={active.notes ?? ""}
onChange={(e) => updateActive({ notes: e.target.value })}
placeholder={t("备注", "Notes")}
/>
<button
type="button"
disabled={lock || channels.length < 2}
className="inline-flex items-center gap-1 text-xs text-rose-300 hover:text-rose-200 disabled:opacity-30"
onClick={() => {
const next = channels.filter((c) => c.id !== active.id);
persistChannels(next);
setActiveCh(next[0]?.id || "");
}}
>
<Trash2 className="h-3.5 w-3.5" />
{t("删除线路", "Remove")}
</button>
</div>
) : null}
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("当前线路 · 直播地址列表", "URL list (this lane)")}</h3>
<textarea
className="mt-4 min-h-[160px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={proUrlDraft}
onChange={(e) => setProUrlDraft(e.target.value)}
spellCheck={false}
placeholder="https://live.douyin.com/..."
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => {
setProUrlDraft(urlConfig);
setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft"));
}}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300"
>
{t("与全局 URL 同步", "Sync from global")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void saveProUrlAndServer()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存到服务器并记入本线路", "Save to server + lane")}
</button>
</div>
</div>
</div>
)}
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("抖音源站(业务元数据)", "Douyin source (metadata)")}</h3>
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON供运维脚本读取。", "business.json for automation.")}</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={douyinUrl}
onChange={(e) => setDouyinUrl(e.target.value)}
placeholder="https://live.douyin.com/..."
/>
<button
type="button"
disabled={lock}
onClick={() => void onSaveBusinessMeta()}
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm text-violet-100 ring-1 ring-violet-500/35"
>
{t("保存到配置中心", "Save to hub")}
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{mode === "pro"
? t("单频道模式在此编辑全局文件Pro 线路请用上方「当前线路」块保存。", "Pro lanes use the lane editor above.")
: t("一行一个直播间地址。", "One URL per line.")}
</p>
{mode === "single" ? (
<>
<textarea
className="mt-4 min-h-[200px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={urlConfig}
onChange={(e) => setUrlConfig(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={lock}
onClick={() => void onSaveUrlConfig()}
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存 URL 配置", "Save URL config")}
</button>
</>
) : (
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
)}
{douyinUrls.length > 0 ? (
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
{douyinUrls.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
/** 对齐 d2yexeapp electron `youtubeConfigUtils.ts` 的浏览器侧解析(无 OAuth */
export function parseYoutubeStreamKeySuffix(iniOrKeyLine: string): string {
for (const line of iniOrKeyLine.split(/\r?\n/)) {
const t = line.trim();
if (t.startsWith("#") || !t) continue;
const m = t.match(/^(?:key|stream_key)\s*=\s*(.+)$/i);
if (m) {
const v = m[1].trim().split(/[#;]/)[0].trim();
if (v && !v.toLowerCase().startsWith("rtmp")) return v.length > 8 ? v.slice(-8) : v;
if (v.includes("live2/")) {
const parts = v.split("/");
const last = parts[parts.length - 1] || "";
return last.length > 8 ? last.slice(-8) : last;
}
}
}
const one = iniOrKeyLine.trim();
if (one && !one.includes("\n") && !one.toLowerCase().startsWith("rtmp"))
return one.length > 8 ? one.slice(-8) : one;
return "";
}
export function extractDouyinLiveUrls(urlIni: string): string[] {
const out: string[] = [];
for (const line of urlIni.split(/\r?\n/)) {
const t = line.trim();
if (!t || t.startsWith("#")) continue;
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue;
const full = t.match(/(https?:\/\/live\.douyin\.com\/[a-zA-Z0-9_-]+)/i);
if (full) {
out.push(full[1].replace(/^http:\/\//i, "https://"));
continue;
}
const rel = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i);
if (rel) {
out.push(`https://live.douyin.com/${rel[1]}`);
continue;
}
const n = t.match(/(\d{8,})/);
if (n) out.push(`https://live.douyin.com/${n[1]}`);
}
return Array.from(new Set(out)).slice(0, 16);
}
export type YoutubeIniFields = {
key: string;
rtmps: boolean;
bitrate: string;
fastAudio: boolean;
};
export function parseYoutubeIniFields(content: string): YoutubeIniFields {
let key = "";
let rtmps = true;
let bitrate = "";
let fastAudio = false;
const lines = content.split(/\r?\n/);
let inYoutube = false;
for (const line of lines) {
const t = line.trim();
if (/^\[youtube\]/i.test(t)) {
inYoutube = true;
continue;
}
if (/^\[/.test(t)) {
inYoutube = false;
continue;
}
if (!inYoutube) continue;
if (!t || t.startsWith("#") || t.startsWith(";")) continue;
const eq = t.indexOf("=");
if (eq < 0) continue;
const k = t.slice(0, eq).trim().toLowerCase();
const v = t
.slice(eq + 1)
.trim()
.split(/[#;]/)[0]
.trim();
if (k === "key" || k === "stream_key") key = v;
else if (k === "rtmps") rtmps = !/^(0|false|否)/i.test(v);
else if (k === "bitrate") bitrate = v;
else if (k === "fast_audio") fastAudio = /^(1|true|yes|是|开|on)/i.test(v);
}
return { key, rtmps, bitrate, fastAudio };
}
export function buildYoutubeIniFromFields(f: YoutubeIniFields): string {
const bitrateLine = f.bitrate.trim()
? `bitrate = ${f.bitrate.trim()}`
: "# bitrate = 4000";
return `[youtube]
# YouTube 推流 key工作室 → 直播 → 串流密钥)
key = ${f.key.trim()}
# 使用 RTMPS填 否 则 RTMP
rtmps = ${f.rtmps ? "是" : "否"}
# 视频比特率 kbps可选
${bitrateLine}
# 轻量音频链,减轻 CPU1 / 是
fast_audio = ${f.fastAudio ? "1" : "0"}
`;
}

View File

@@ -0,0 +1,71 @@
export type YoutubeProChannel = {
id: string;
name: string;
/** 备忘尾码(旧版) */
streamKeySuffix?: string;
/** 完整串流密钥或 rtmp(s) URL */
streamKey?: string;
/** 本线路 URL_config 草稿(多路本地编排) */
urlLines?: string;
/** 建议 PM2 名,如 youtube2__xxx */
pm2Name?: string;
notes?: string;
};
const STORAGE_KEY = "live-hub-youtube-pro-v2";
function migrateRow(x: Record<string, unknown>): YoutubeProChannel | null {
if (!x || typeof x !== "object") return null;
const id = String(x.id || "").trim();
const name = String(x.name || "").trim();
if (!id || !name) return null;
return {
id,
name,
streamKeySuffix: typeof x.streamKeySuffix === "string" ? x.streamKeySuffix : undefined,
streamKey: typeof x.streamKey === "string" ? x.streamKey : undefined,
urlLines: typeof x.urlLines === "string" ? x.urlLines : undefined,
pm2Name: typeof x.pm2Name === "string" ? x.pm2Name : undefined,
notes: typeof x.notes === "string" ? x.notes : undefined,
};
}
function safeParse(raw: string | null): YoutubeProChannel[] {
if (!raw) return [];
try {
const v = JSON.parse(raw) as unknown;
if (!Array.isArray(v)) return [];
return v.map((x) => migrateRow(x as Record<string, unknown>)).filter((c): c is YoutubeProChannel => c !== null);
} catch {
return [];
}
}
export function loadYoutubeProChannels(): YoutubeProChannel[] {
if (typeof window === "undefined") return [];
let raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
raw = window.localStorage.getItem("live-hub-youtube-pro-v1");
if (raw) {
const legacy = safeParse(raw);
if (legacy.length) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(legacy));
return legacy;
}
}
}
return safeParse(raw);
}
export function saveYoutubeProChannels(channels: YoutubeProChannel[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(channels));
}
export function newChannelId(): string {
return `ch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
}
export function defaultPm2NameForChannel(channelId: string): string {
return `youtube2__${channelId}`;
}

13
web.py
View File

@@ -17,6 +17,7 @@ from src.android_control import (
adb_available,
android_action,
android_device_overview,
android_logcat_recent,
android_packages,
android_screenshot,
android_ui_nodes,
@@ -285,6 +286,18 @@ async def get_android_overview(serial: str = Query(..., description="Android dev
return JSONResponse({"error": str(exc)}, status_code=400)
@app.get("/android/logcat")
async def get_android_logcat(
serial: str = Query(..., description="Android device serial"),
lines: int = Query(200, ge=1, le=3000, description="Number of recent log lines"),
):
try:
text = await android_logcat_recent(serial, lines=lines)
return {"text": text}
except (RuntimeError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
@app.get("/android/screenshot")
async def get_android_screenshot(serial: str = Query(..., description="Android device serial")):
try: