's'
This commit is contained in:
@@ -267,16 +267,18 @@ def main() -> int:
|
||||
f"echo '{PASS}' | sudo -S systemctl restart live-console.service",
|
||||
timeout=120,
|
||||
)
|
||||
_safe_print("等待服务监听…")
|
||||
time.sleep(6)
|
||||
_safe_print("等待控制面监听(最多约 45s)…")
|
||||
time.sleep(4)
|
||||
run_logged(
|
||||
client,
|
||||
log_path,
|
||||
"curl_health_after_restart",
|
||||
"curl -sS -m 15 http://127.0.0.1:8001/health 2>&1 || "
|
||||
"curl -sS -m 15 http://127.0.0.1:8101/health 2>&1 || "
|
||||
"echo 'curl_health_failed'",
|
||||
timeout=25,
|
||||
"for i in $(seq 1 22); do "
|
||||
"if curl -sf -m 4 http://127.0.0.1:8001/health >/dev/null 2>&1; then echo \"health_ok_8001\"; exit 0; fi; "
|
||||
"if curl -sf -m 4 http://127.0.0.1:8101/health >/dev/null 2>&1; then echo \"health_ok_8101\"; exit 0; fi; "
|
||||
"sleep 2; "
|
||||
"done; echo curl_health_failed; exit 0",
|
||||
timeout=120,
|
||||
)
|
||||
run_logged(
|
||||
client,
|
||||
@@ -300,18 +302,18 @@ def main() -> int:
|
||||
timeout=180,
|
||||
)
|
||||
# Caddy 刚 enable --now 时 bind :80 可能晚于立即 curl(曾出现 http_code=000)
|
||||
time.sleep(8)
|
||||
time.sleep(12)
|
||||
run_logged(
|
||||
client,
|
||||
log_path,
|
||||
"curl_port80_after_edge",
|
||||
"for i in 1 2 3 4 5; do "
|
||||
"code=$(curl -sS -m 8 -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo 000); "
|
||||
"for i in 1 2 3 4 5 6 7 8; do "
|
||||
"code=$(curl -sS -m 10 -o /dev/null -w '%{http_code}' http://127.0.0.1/ 2>/dev/null || echo 000); "
|
||||
"echo \"try$i http_code=$code\"; "
|
||||
"[ \"$code\" = 200 ] && exit 0; "
|
||||
"sleep 3; "
|
||||
"sleep 4; "
|
||||
"done; exit 1",
|
||||
timeout=60,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
run_logged(
|
||||
@@ -331,6 +333,14 @@ def main() -> int:
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
run_logged(
|
||||
client,
|
||||
log_path,
|
||||
"docker_srs_neko_ps",
|
||||
"docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -E 'live-srs|live-neko' || echo '(no live-srs / live-neko in docker ps)'",
|
||||
timeout=25,
|
||||
)
|
||||
|
||||
return 0
|
||||
finally:
|
||||
if sftp:
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUpRight, Layers, Loader2, Package, Terminal } from "lucide-react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Layers,
|
||||
Loader2,
|
||||
Package,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type DeviceRow = { serial: string; model?: string; state: string };
|
||||
@@ -31,6 +40,13 @@ type UiNode = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const SHELL_SNIPPETS: { label: string; cmd: string }[] = [
|
||||
{ label: "getprop", cmd: "getprop ro.build.version.release" },
|
||||
{ label: "top pkg", cmd: "dumpsys activity activities | grep -E 'mResumedActivity' | tail -n 1" },
|
||||
{ label: "sdcard", cmd: "ls -la /sdcard | head -n 24" },
|
||||
{ label: "net", cmd: "dumpsys connectivity | head -n 40" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
notify: (msg: string) => void;
|
||||
@@ -47,6 +63,7 @@ type Props = {
|
||||
apkPath: string;
|
||||
setApkPath: (v: string) => void;
|
||||
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
|
||||
onCopy: (text: string) => void;
|
||||
};
|
||||
|
||||
export function LiveAndroidWorkstation({
|
||||
@@ -64,6 +81,7 @@ export function LiveAndroidWorkstation({
|
||||
apkPath,
|
||||
setApkPath,
|
||||
onAndroidAction,
|
||||
onCopy,
|
||||
}: Props) {
|
||||
const [overview, setOverview] = useState<AndroidOverview | null>(null);
|
||||
const [overviewErr, setOverviewErr] = useState<string | null>(null);
|
||||
@@ -77,6 +95,8 @@ export function LiveAndroidWorkstation({
|
||||
const [uiNodes, setUiNodes] = useState<UiNode[]>([]);
|
||||
const [uiErr, setUiErr] = useState<string | null>(null);
|
||||
const [openUrl, setOpenUrl] = useState("https://");
|
||||
const [adbInputText, setAdbInputText] = useState("");
|
||||
const [embedScrcpy, setEmbedScrcpy] = useState(false);
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const lock = !!(busy || pending);
|
||||
@@ -206,6 +226,15 @@ export function LiveAndroidWorkstation({
|
||||
await onAndroidAction("open_url", { url: u });
|
||||
};
|
||||
|
||||
const sendAdbText = async () => {
|
||||
const raw = adbInputText.trim();
|
||||
if (!raw) {
|
||||
notify(t("填写要输入的文字", "Enter text"));
|
||||
return;
|
||||
}
|
||||
await onAndroidAction("text", { text: raw });
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -241,6 +270,14 @@ export function LiveAndroidWorkstation({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{adbAvailable && devices.length === 0 ? (
|
||||
<p className="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-xs text-amber-200/90">
|
||||
{t(
|
||||
"未检测到已授权设备:请 USB 连接并开启「USB 调试」,手机上允许本计算机调试。",
|
||||
"No authorized device: enable USB debugging and authorize this computer.",
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
@@ -330,6 +367,71 @@ export function LiveAndroidWorkstation({
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendKey("24")}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
|
||||
>
|
||||
Vol+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendKey("25")}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
|
||||
>
|
||||
Vol−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendKey("66")}
|
||||
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs"
|
||||
>
|
||||
Enter
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-[10px] text-zinc-600">{t("滑动手势(中心区域)", "Swipe (center)")}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{(["up", "down", "left", "right"] as const).map((dir) => (
|
||||
<button
|
||||
key={dir}
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("swipe", { direction: dir })}
|
||||
className="rounded-lg border border-white/10 bg-black/30 px-2.5 py-1 font-mono text-[10px] uppercase text-zinc-300"
|
||||
>
|
||||
{dir}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-white/5 bg-black/20 p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{t("输入文字(adb input text)", "Type text (adb)")}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-zinc-600">
|
||||
{t("仅适合英文与数字;中文需剪贴板或无障碍方案。", "ASCII-focused; CJK needs clipboard.")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-xs"
|
||||
value={adbInputText}
|
||||
onChange={(e) => setAdbInputText(e.target.value)}
|
||||
placeholder="hello / 123"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void sendAdbText();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void sendAdbText()}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-2 text-xs text-emerald-100 ring-1 ring-emerald-500/30"
|
||||
>
|
||||
{t("发送", "Send")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
@@ -366,6 +468,9 @@ export function LiveAndroidWorkstation({
|
||||
value={pkgQuery}
|
||||
onChange={(e) => setPkgQuery(e.target.value)}
|
||||
placeholder="tiktok / musically / …"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void searchPackages();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -408,70 +513,112 @@ export function LiveAndroidWorkstation({
|
||||
) : 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)}
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<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("Ctrl+Enter 执行;勿执行不可逆命令。", "Ctrl+Enter to run.")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{SHELL_SNIPPETS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => setShellCmd(s.cmd)}
|
||||
className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-1 text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-3 min-h-[100px] 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}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
void runShell();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<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 className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void runShell()}
|
||||
className="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 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!shellOut}
|
||||
onClick={() => onCopy(shellOut)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("复制输出", "Copy")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{shellOut ? (
|
||||
<pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-white/5 bg-black/40 p-3 font-mono text-[11px] text-zinc-400 xl:max-h-[min(24rem,50vh)]">
|
||||
{shellOut}
|
||||
</pre>
|
||||
) : 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("Logcat(最近行)", "Logcat (recent)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("logcat -d -t N;大日志可能略慢。", "logcat -d -t N.")}
|
||||
</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>
|
||||
{logcatText ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(logcatText)}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-white/10 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("复制", "Copy")}
|
||||
</button>
|
||||
) : null}
|
||||
</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 xl:max-h-[min(24rem,50vh)]">
|
||||
{logcatText}
|
||||
</pre>
|
||||
) : null}
|
||||
</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}
|
||||
|
||||
@@ -583,13 +730,29 @@ export function LiveAndroidWorkstation({
|
||||
|
||||
{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>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbedScrcpy((v) => !v)}
|
||||
className="flex items-center gap-2 text-xs font-medium text-zinc-300 hover:text-white"
|
||||
>
|
||||
ws-scrcpy
|
||||
{embedScrcpy ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
<span className="font-normal text-zinc-600">
|
||||
{embedScrcpy ? t("收起内嵌", "Collapse") : t("展开内嵌(省资源)", "Expand embed")}
|
||||
</span>
|
||||
</button>
|
||||
<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" />
|
||||
{embedScrcpy ? (
|
||||
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
|
||||
) : (
|
||||
<p className="px-4 py-3 text-center text-[11px] text-zinc-600">
|
||||
{t("默认不加载 iframe,避免占用带宽与解码;需要时点「展开内嵌」。", "Iframe off by default to save resources.")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -783,6 +783,7 @@ export default function LiveControlApp() {
|
||||
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
|
||||
onSaveBusinessMeta={() => void saveBusinessMeta()}
|
||||
onCopy={copyText}
|
||||
fetchJson={fetchJson}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -802,6 +803,7 @@ export default function LiveControlApp() {
|
||||
apkPath={apkPath}
|
||||
setApkPath={setApkPath}
|
||||
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
|
||||
onCopy={copyText}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Cable, Copy, Monitor } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Cable, Copy, Monitor, Radio } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { extractDouyinLiveUrls } from "@/lib/youtubeConfigUtils";
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
|
||||
@@ -40,6 +42,9 @@ const PRESETS: HdmiPreset[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const CHECK_KEY = "live-hub-tiktok-hdmi-check-v1";
|
||||
const NOTES_KEY = "live-hub-tiktok-hdmi-notes-v1";
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
busy: boolean;
|
||||
@@ -54,6 +59,7 @@ type Props = {
|
||||
onSaveUrlConfig: (content?: string) => void;
|
||||
onSaveBusinessMeta: () => void;
|
||||
onCopy: (text: string) => void;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
};
|
||||
|
||||
export function LiveTiktokHdmiView({
|
||||
@@ -70,91 +76,207 @@ export function LiveTiktokHdmiView({
|
||||
onSaveUrlConfig,
|
||||
onSaveBusinessMeta,
|
||||
onCopy,
|
||||
fetchJson,
|
||||
}: Props) {
|
||||
const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]);
|
||||
const processOptions = tkEntries.length ? tkEntries : entries;
|
||||
const [expanded, setExpanded] = useState<string | null>("1080p30");
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>("1080p30");
|
||||
const [urlReloading, setUrlReloading] = useState(false);
|
||||
const [hdmiNotes, setHdmiNotes] = useState("");
|
||||
const [checks, setChecks] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CHECK_KEY);
|
||||
if (raw) setChecks(JSON.parse(raw) as Record<string, boolean>);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
setHdmiNotes(window.localStorage.getItem(NOTES_KEY) || "");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistChecks = useCallback((next: Record<string, boolean>) => {
|
||||
setChecks(next);
|
||||
try {
|
||||
window.localStorage.setItem(CHECK_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistNotes = (v: string) => {
|
||||
setHdmiNotes(v);
|
||||
try {
|
||||
window.localStorage.setItem(NOTES_KEY, v);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const reloadUrlConfig = async () => {
|
||||
if (!currentProc) return;
|
||||
setUrlReloading(true);
|
||||
try {
|
||||
const data = await fetchJson<{ content?: string }>(
|
||||
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
|
||||
);
|
||||
setUrlConfig(data.content || "");
|
||||
} catch {
|
||||
/* parent may show via busy */
|
||||
} finally {
|
||||
setUrlReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlConfig), [urlConfig]);
|
||||
const lock = busy || urlReloading;
|
||||
const presetLabel = PRESETS.find((p) => p.id === selectedPreset);
|
||||
|
||||
const checklist: { id: string; zh: string; en: string }[] = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
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 className="rounded-2xl border border-cyan-500/25 bg-gradient-to-br from-cyan-500/[0.14] via-zinc-950/80 to-fuchsia-500/10 p-6 shadow-xl shadow-cyan-900/10">
|
||||
<div className="flex flex-wrap items-start 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-cyan-500/35 to-fuchsia-600/25 ring-1 ring-white/10">
|
||||
<Cable className="h-5 w-5 text-cyan-200" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight text-white">
|
||||
{t("TikTok 无人直播(单路)", "TikTok unmanned (single lane)")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
{t(
|
||||
"布局对齐 YouTube 单频道:直播开关 + 源站 + URL_config;无 stream key,输出走 HDMI 硬件链路。",
|
||||
"Same layout as YouTube single: controls + URLs; no stream key — HDMI hardware path.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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("TikTok 进程(PM2)", "TikTok 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)}
|
||||
>
|
||||
{(tkEntries.length ? tkEntries : entries).map((e) => (
|
||||
{processOptions.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 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 text-[11px] text-zinc-500">
|
||||
{t("当前 HDMI 预设(备忘)", "HDMI preset (memo)")}:{" "}
|
||||
<span className="font-mono text-cyan-300/90">
|
||||
{presetLabel ? t(presetLabel.labelZh, presetLabel.labelEn) : selectedPreset}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Monitor className="h-4 w-4 text-cyan-400" />
|
||||
{t("HDMI 硬件与时序", "HDMI timing")}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("点选一路作为现场备忘;与板端 ffmpeg/OBS 采集参数对齐。", "Pick a preset as field memo; match capture settings.")}
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
{PRESETS.map((p) => {
|
||||
const open = expanded === p.id;
|
||||
const sel = selectedPreset === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpanded(open ? null : p.id);
|
||||
setSelectedPreset(p.id);
|
||||
}}
|
||||
className={`rounded-2xl border p-4 text-left transition ${
|
||||
sel ? "border-cyan-500/50 ring-1 ring-cyan-500/20" : ""
|
||||
} ${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="mt-6 rounded-xl border border-white/5 bg-black/25 p-4">
|
||||
<p className="text-xs font-semibold text-zinc-400">{t("上线前检查清单(本地保存)", "Pre-flight checklist (local)")}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{checklist.map((c) => (
|
||||
<li key={c.id} className="flex items-start gap-2 text-xs text-zinc-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 rounded border-white/20 bg-black/40"
|
||||
checked={!!checks[c.id]}
|
||||
onChange={(e) => persistChecks({ ...checks, [c.id]: e.target.checked })}
|
||||
/>
|
||||
<span>{t(c.zh, c.en)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label className="mt-4 block text-[11px] text-zinc-500">{t("采集卡 / EDID / 走线备忘", "Capture / EDID notes")}</label>
|
||||
<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"
|
||||
value={hdmiNotes}
|
||||
onChange={(e) => persistNotes(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="Magewell / OBS 视频采集设备 …"
|
||||
/>
|
||||
</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>
|
||||
<h3 className="text-sm font-semibold text-white">{t("抖音源站(业务元数据)", "Douyin source (hub metadata)")}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("写入 business JSON。", "Writes business JSON.")}</p>
|
||||
<input
|
||||
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
|
||||
value={douyinUrl}
|
||||
@@ -163,17 +285,17 @@ export function LiveTiktokHdmiView({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => onSaveBusinessMeta()}
|
||||
disabled={lock}
|
||||
onClick={() => void 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")}
|
||||
{t("保存到配置中心", "Save to hub")}
|
||||
</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>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-cyan-300 hover:text-cyan-200"
|
||||
@@ -183,20 +305,41 @@ export function LiveTiktokHdmiView({
|
||||
{t("复制全文", "Copy all")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">{t("一行一个地址;与 YouTube 单频道页相同维护方式。", "One URL per line; same as YouTube single.")}</p>
|
||||
<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"
|
||||
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={!!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 className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void reloadUrlConfig()}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-300"
|
||||
>
|
||||
{t("重新读取", "Reload from server")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onSaveUrlConfig()}
|
||||
className="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>
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user