Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveTiktokHdmiView.tsx
2026-03-28 15:21:30 -05:00

347 lines
14 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, useState } from "react";
import { extractDouyinLiveUrls } from "@/lib/youtubeConfigUtils";
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.",
},
];
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;
douyinUrl: string;
setDouyinUrl: (v: string) => void;
onRunProcess: (a: "start" | "stop" | "restart") => void;
onSaveUrlConfig: (content?: string) => void;
onSaveBusinessMeta: () => void;
onCopy: (text: string) => void;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
};
export function LiveTiktokHdmiView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
douyinUrl,
setDouyinUrl,
onRunProcess,
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/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-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("采集卡 / 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("抖音源站(业务元数据)", "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}
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 font-medium 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">
<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("一行一个地址;与 YouTube 单频道页相同维护方式。", "One URL per line; same as YouTube single.")}</p>
<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}
/>
<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>
);
}