"use client"; import { Cable, Copy, Film, Monitor, Radio, Trash2, Upload } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } 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"; const SOURCE_MODE_KEY = "live-hub-tiktok-source-mode-v1"; export type TiktokSourceMode = "ffmpeg" | "replay" | "srs"; type ReplayFileRow = { name: string; size: number; replay_url: string }; 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; /** 录播:写入 URL 配置为单条 replayfile 行并启动进程 */ onReplayGoLive: (replayUrl: string, displayLabel: string) => void | Promise; onCopy: (text: string) => void; fetchJson: (path: string, init?: RequestInit) => Promise; apiPrefix: string; quickLinks: Record; notify: (msg: string) => void; onNavigate?: (target: PortalNavTarget) => void; }; export function LiveTiktokHdmiView({ t, busy, entries, currentProc, setCurrentProc, urlConfig, setUrlConfig, onRunProcess, onSaveUrlConfig, onReplayGoLive, onCopy, fetchJson, apiPrefix, quickLinks, notify, onNavigate, }: Props) { const tkEntries = useMemo(() => entries.filter(isTiktokProcess), [entries]); const processOptions = tkEntries.length ? tkEntries : entries; const [sourceMode, setSourceMode] = useState("ffmpeg"); const [replayFiles, setReplayFiles] = useState([]); const [replayLoading, setReplayLoading] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); const [expanded, setExpanded] = useState("1080p30"); const [selectedPreset, setSelectedPreset] = useState("1080p30"); const [urlReloading, setUrlReloading] = useState(false); const [urlAutoRefresh, setUrlAutoRefresh] = useState(true); const [urlFetchNote, setUrlFetchNote] = useState(null); const urlListDirtyRef = useRef(false); const [hdmiNotes, setHdmiNotes] = useState(""); const [checks, setChecks] = useState>({}); const setSourceModePersist = useCallback( (m: TiktokSourceMode) => { setSourceMode(m); try { window.localStorage.setItem(SOURCE_MODE_KEY, m); } catch { /* ignore */ } }, [], ); useEffect(() => { try { const r = window.localStorage.getItem(SOURCE_MODE_KEY); if (r === "ffmpeg" || r === "replay" || r === "srs") setSourceMode(r); } catch { /* ignore */ } }, []); 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 || sourceMode !== "ffmpeg") return; const id = window.setInterval(() => { if (urlListDirtyRef.current) return; void pullUrlConfigFromServer(); }, 8000); return () => clearInterval(id); }, [currentProc, urlAutoRefresh, pullUrlConfigFromServer, sourceMode]); useEffect(() => { if (sourceMode !== "ffmpeg" || !currentProc) return; void pullUrlConfigFromServer(); }, [sourceMode, currentProc, pullUrlConfigFromServer]); const loadReplayList = useCallback(async () => { if (!currentProc) return; setReplayLoading(true); try { const data = await fetchJson<{ files?: ReplayFileRow[] }>( `/tiktok_replay/list?process=${encodeURIComponent(currentProc)}`, ); setReplayFiles(data.files ?? []); } catch (e) { notify((e as Error).message); setReplayFiles([]); } finally { setReplayLoading(false); } }, [currentProc, fetchJson, notify]); useEffect(() => { if (sourceMode !== "replay" || !currentProc) return; void loadReplayList(); }, [sourceMode, currentProc, loadReplayList]); useEffect(() => { try { const raw = window.localStorage.getItem(CHECK_KEY); if (raw) setChecks(JSON.parse(raw) as Record); } catch { /* ignore */ } try { setHdmiNotes(window.localStorage.getItem(NOTES_KEY) || ""); } catch { /* ignore */ } }, []); const persistChecks = useCallback((next: Record) => { 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 deleteReplayFile = async (name: string) => { if (!currentProc) return; setUploadBusy(true); try { await fetchJson(`/tiktok_replay/delete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ process: currentProc, filename: name }), }); notify(t("已删除文件", "File removed")); await loadReplayList(); } catch (e) { notify((e as Error).message); } finally { setUploadBusy(false); } }; const onPickUpload = async (e: ChangeEvent) => { const f = e.target.files?.[0]; e.target.value = ""; if (!f || !currentProc) return; setUploadBusy(true); try { const fd = new FormData(); fd.append("file", f); const res = await fetch( `${apiPrefix}/tiktok_replay/upload?process=${encodeURIComponent(currentProc)}`, { method: "POST", body: fd }, ); const data = (await res.json()) as { error?: string; message?: string }; if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); notify(data.message || t("上传成功", "Uploaded")); await loadReplayList(); } catch (e) { notify((e as Error).message); } finally { setUploadBusy(false); } }; const lock = busy || urlReloading || uploadBusy; 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 (
{onNavigate ? ( ) : null}

{t("TikTok 无人直播(单路)", "TikTok unmanned (single lane)")}

{t( "布局对齐 YouTube 单频道:直播开关 + 源站 + URL_config;无 stream key,输出走 HDMI 硬件链路。", "Same layout as YouTube single: controls + URLs; no stream key — HDMI hardware path.", )}

{t( "HDMI 互斥:后端在「开始 / 重新开始」TikTok 或 obs*.sh(SRS 拉流上屏)时,会自动停止其它正在占用 HDMI 链路的同类进程;与 YouTube 推流互不干扰。", "HDMI mutex: starting TikTok or obs*.sh stops other TikTok/OBS HDMI sinks; YouTube RTMP is unaffected.", )}

{t("直播开关", "Live control")}

{sourceMode === "srs" ? (

{t( "此模式不显示 URL_config:请在 OBS 中填写下方 RTMP 推流地址;板端 SRS 拉流上屏仍可能占用 HDMI,与 TikTok 进程互斥规则不变。", "URL_config is hidden: set the RTMP URL in OBS. SRS pull-to-HDMI may still mutex with TikTok per server rules.", )}

) : null}
{( [ ["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]) => ( ))}

{t("当前 HDMI 预设(备忘)", "HDMI preset (memo)")}:{" "} {presetLabel ? t(presetLabel.labelZh, presetLabel.labelEn) : selectedPreset}

{sourceMode === "replay" ? (

{t("录播素材", "VOD library")}

{t( "文件保存在服务器 config/tiktok_replay/对应进程目录;点「开始直播」会写入 replayfile 行并启动 PM2 进程。停播请用上方「停止直播」。", "Files under config/tiktok_replay/. Go live writes replayfile + starts PM2. Use Stop above to stop.", )}

{replayLoading ? (

{t("加载中…", "Loading…")}

) : replayFiles.length === 0 ? (

{t("暂无文件,请先上传。", "No files yet.")}

) : (
    {replayFiles.map((row) => (
  • {row.name} {(row.size / (1024 * 1024)).toFixed(1)} MB
  • ))}
)}
) : null} {sourceMode === "srs" ? (

{t("SRS / OBS 推流", "SRS / OBS ingest")}

{t( "OBS:设置 → 推流 → 服务选「自定义」,服务器填 RTMP 地址;串流密钥可与 SRS 应用配置一致(默认 live/livestream)。", "OBS → Stream → Custom: RTMP URL as server; stream key per SRS app (default live/livestream).", )}

{t("RTMP 推流地址(OBS 服务器)", "RTMP publish URL")}
{quickLinks.srs_rtmp_publish || "—"} {quickLinks.srs_rtmp_publish ? ( ) : null}
{t("HTTP-FLV 预览", "HTTP-FLV preview")}
{quickLinks.srs_play_flv || quickLinks.srs_http || "—"} {quickLinks.srs_play_flv || quickLinks.srs_http ? ( ) : null}
{t("SRS 控制台", "SRS web UI")}
{quickLinks.srs_http || "—"}
) : null}

{t("HDMI 硬件与时序", "HDMI timing")}

{t("点选一路作为现场备忘;与板端 ffmpeg/OBS 采集参数对齐。", "Pick a preset as field memo; match capture settings.")}

{PRESETS.map((p) => { const open = expanded === p.id; const sel = selectedPreset === p.id; return ( ); })}

{t("上线前检查清单(本地保存)", "Pre-flight checklist (local)")}

    {checklist.map((c) => (
  • persistChecks({ ...checks, [c.id]: e.target.checked })} /> {t(c.zh, c.en)}
  • ))}