Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveTiktokHdmiView.tsx
2026-03-29 02:57:08 -05:00

421 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { Cable, Copy, Monitor, Radio } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip";
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
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 输出时序与显示端一致;下游画布建议 1280×720。",
hintEn: "Match SoC HDMI timing to the display; downstream canvas e.g. 1280×720.",
},
{
id: "1080p30",
labelZh: "1080p @ 30Hz",
labelEn: "1080p @ 30Hz",
spec: "1920x1080, 30Hz",
hintZh: "带宽紧张时可优先 1080p30检查线材与接口规格。",
hintEn: "Prefer 1080p30 when bandwidth is tight; check cable and port specs.",
},
{
id: "1080p60",
labelZh: "1080p @ 60Hz",
labelEn: "1080p @ 60Hz",
spec: "1920x1080, 60Hz, 高刷转播",
hintZh: "两端均需稳定支持 1080p60长时间运行注意散热。",
hintEn: "Both ends need stable 1080p60; mind thermals for long runs.",
},
];
const CHECK_KEY = "live-hub-tiktok-hdmi-check-v1";
const NOTES_KEY = "live-hub-tiktok-hdmi-notes-v1";
type Props = {
t: TFn;
busy: boolean;
entries: ProcessEntry[];
currentProc: string;
setCurrentProc: (v: string) => void;
urlConfig: string;
setUrlConfig: (v: string) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void | Promise<void>;
onCopy: (text: string) => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
onNavigate?: (target: PortalNavTarget) => void;
};
export function LiveTiktokHdmiView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
onRunProcess,
onSaveUrlConfig,
onCopy,
fetchJson,
onNavigate,
}: 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 [urlAutoRefresh, setUrlAutoRefresh] = useState(true);
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
const urlListDirtyRef = useRef(false);
const [hdmiNotes, setHdmiNotes] = useState("");
const [checks, setChecks] = useState<Record<string, boolean>>({});
useEffect(() => {
urlListDirtyRef.current = false;
}, [currentProc]);
const pullUrlConfigFromServer = useCallback(async () => {
if (!currentProc) return;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
setUrlConfig(data.content ?? "");
} catch {
/* ignore */
}
}, [currentProc, fetchJson, setUrlConfig]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
const id = window.setInterval(() => {
if (urlListDirtyRef.current) return;
void pullUrlConfigFromServer();
}, 8000);
return () => clearInterval(id);
}, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]);
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);
setUrlFetchNote(null);
urlListDirtyRef.current = false;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
setUrlConfig(data.content ?? "");
setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
} catch (e) {
setUrlFetchNote((e as Error).message);
} finally {
setUrlReloading(false);
}
};
const saveUrlAndClearDirty = async () => {
try {
await Promise.resolve(onSaveUrlConfig());
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
}
};
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: "thermal", zh: "长时间推流注意散热,过热会掉帧黑屏", en: "Thermal headroom for long runs" },
];
return (
<div className="mx-auto max-w-4xl space-y-6">
{onNavigate ? (
<LivePipelineStrip
t={t}
title={t("TikTok HDMI 硬件链路", "TikTok HDMI hardware chain")}
subtitle={t(
"板端编码经 HDMI 到显示/下游设备;与「网络」页顶卡相同的箭头风格。",
"Encode on SoC → HDMI → display/downstream; same arrow style as Network header.",
)}
onNavigate={onNavigate}
steps={[
{
label: "FFmpeg",
sub: t("采集 / 推流", "Capture"),
gradient: "from-cyan-400 to-blue-500",
target: "live_tiktok",
},
{
label: "HDMI",
sub: t("板载输出", "SoC out"),
gradient: "from-violet-500 to-fuchsia-500",
},
{
label: "Android",
sub: t("AOSP / 手机", "AOSP"),
gradient: "from-amber-400 to-orange-500",
target: "android",
},
]}
/>
) : null}
<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>
</div>
<div className="rounded-2xl border border-cyan-500/20 bg-cyan-500/[0.06] p-4 ring-1 ring-white/[0.04]">
<p className="text-xs leading-relaxed text-cyan-100/90">
{t(
"HDMI 互斥:后端在「开始 / 重新开始」TikTok 或 obs*.shSRS 拉流上屏)时,会自动停止其它正在占用 HDMI 链路的同类进程;与 YouTube 推流互不干扰。",
"HDMI mutex: starting TikTok or obs*.sh stops other TikTok/OBS HDMI sinks; YouTube RTMP is unaffected.",
)}
</p>
</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("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)}
>
{processOptions.map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} ({e.pm2})
</option>
))}
</select>
<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("HDMI / 走线备忘", "HDMI / wiring 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={t("接口、线长、显示器型号…", "Ports, cable length, display model…")}
/>
</div>
</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("直播地址列表 · 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"
onClick={() => onCopy(urlConfig)}
>
<Copy className="h-3.5 w-3.5" />
{t("复制全文", "Copy all")}
</button>
</div>
<p className="mt-1 text-xs text-zinc-500">
{t(
"一行一个地址;可勾选自动从服务器刷新,或点「重新读取」手动同步。",
"One URL per line; enable auto-refresh or reload from server.",
)}
</p>
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-zinc-400">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={urlAutoRefresh}
onChange={(e) => setUrlAutoRefresh(e.target.checked)}
/>
{t("每 8 秒自动从服务器刷新", "Auto-refresh from server every 8s")}
</label>
</div>
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<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) => {
urlListDirtyRef.current = true;
setUrlConfig(e.target.value);
}}
spellCheck={false}
/>
<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={() => void saveUrlAndClearDirty()}
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>
</div>
<LiveProcessLiveLogCard
t={t}
currentProc={currentProc}
fetchJson={fetchJson}
urlListText={urlConfig}
keySuffixUi=""
showKeyRow={false}
/>
</div>
);
}