702 lines
28 KiB
TypeScript
702 lines
28 KiB
TypeScript
"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";
|
||
|
||
/** UI 仅展示 SRS 选项(ffmpeg/replay 的 option 保留 hidden,改 false 即可恢复) */
|
||
const TIKTOK_SOURCE_UI_SRS_ONLY = true;
|
||
|
||
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<void>;
|
||
/** 录播:写入 URL 配置为单条 replayfile 行并启动进程 */
|
||
onReplayGoLive: (replayUrl: string, displayLabel: string) => void | Promise<void>;
|
||
onCopy: (text: string) => void;
|
||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||
apiPrefix: string;
|
||
quickLinks: Record<string, string>;
|
||
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<TiktokSourceMode>(
|
||
TIKTOK_SOURCE_UI_SRS_ONLY ? "srs" : "ffmpeg",
|
||
);
|
||
const [replayFiles, setReplayFiles] = useState<ReplayFileRow[]>([]);
|
||
const [replayLoading, setReplayLoading] = useState(false);
|
||
const [uploadBusy, setUploadBusy] = useState(false);
|
||
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>>({});
|
||
|
||
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") {
|
||
if (TIKTOK_SOURCE_UI_SRS_ONLY && (r === "ffmpeg" || r === "replay")) setSourceMode("srs");
|
||
else 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<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 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<HTMLInputElement>) => {
|
||
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 (
|
||
<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*.sh(SRS 拉流上屏)时,会自动停止其它正在占用 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>
|
||
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||
{t("直播源", "Video source")}
|
||
</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={sourceMode}
|
||
onChange={(e) => setSourceModePersist(e.target.value as TiktokSourceMode)}
|
||
>
|
||
<option value="ffmpeg" hidden={TIKTOK_SOURCE_UI_SRS_ONLY}>
|
||
FFmpeg(抖音 / TikTok 等直播间 URL)
|
||
</option>
|
||
<option value="replay" hidden={TIKTOK_SOURCE_UI_SRS_ONLY}>
|
||
{t("录播素材(本地上传)", "VOD upload")}
|
||
</option>
|
||
<option value="srs">{t("SRS(OBS 推流)", "SRS (OBS)")}</option>
|
||
</select>
|
||
{sourceMode === "srs" ? (
|
||
<p className="mt-2 text-[11px] leading-relaxed text-amber-200/85">
|
||
{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.",
|
||
)}
|
||
</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 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>
|
||
|
||
{sourceMode === "replay" ? (
|
||
<div className="rounded-2xl border border-violet-500/25 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
|
||
<Film className="h-4 w-4 text-violet-300" />
|
||
{t("录播素材", "VOD library")}
|
||
</h3>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-violet-500/15 px-3 py-2 text-xs font-medium text-violet-100 ring-1 ring-violet-500/30">
|
||
<Upload className="h-3.5 w-3.5" />
|
||
<span>{t("上传视频", "Upload")}</span>
|
||
<input type="file" accept=".mp4,.mkv,.ts,.flv,.mov,.m4v,.webm" className="hidden" onChange={onPickUpload} />
|
||
</label>
|
||
<button
|
||
type="button"
|
||
disabled={lock || !currentProc}
|
||
onClick={() => void loadReplayList()}
|
||
className="rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-xs text-zinc-300"
|
||
>
|
||
{t("刷新列表", "Refresh")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p className="mt-2 text-xs text-zinc-500">
|
||
{t(
|
||
"文件保存在服务器 config/tiktok_replay/对应进程目录;点「开始直播」会写入 replayfile 行并启动 PM2 进程。停播请用上方「停止直播」。",
|
||
"Files under config/tiktok_replay/<process>. Go live writes replayfile + starts PM2. Use Stop above to stop.",
|
||
)}
|
||
</p>
|
||
{replayLoading ? (
|
||
<p className="mt-4 text-xs text-zinc-500">{t("加载中…", "Loading…")}</p>
|
||
) : replayFiles.length === 0 ? (
|
||
<p className="mt-4 text-xs text-zinc-500">{t("暂无文件,请先上传。", "No files yet.")}</p>
|
||
) : (
|
||
<ul className="mt-4 space-y-2">
|
||
{replayFiles.map((row) => (
|
||
<li
|
||
key={row.name}
|
||
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-300"
|
||
>
|
||
<span className="min-w-0 flex-1 truncate font-mono">{row.name}</span>
|
||
<span className="shrink-0 text-zinc-500">{(row.size / (1024 * 1024)).toFixed(1)} MB</span>
|
||
<div className="flex shrink-0 flex-wrap gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() =>
|
||
void (async () => {
|
||
try {
|
||
await Promise.resolve(onReplayGoLive(row.replay_url, row.name));
|
||
} catch {
|
||
/* parent toasts */
|
||
}
|
||
})()
|
||
}
|
||
className="rounded-lg bg-emerald-500/20 px-2 py-1 text-[11px] text-emerald-100 ring-1 ring-emerald-500/30"
|
||
>
|
||
{t("开始直播", "Go live")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => onRunProcess("stop")}
|
||
className="rounded-lg bg-rose-500/15 px-2 py-1 text-[11px] text-rose-100 ring-1 ring-rose-500/25"
|
||
>
|
||
{t("停播", "Stop")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={lock}
|
||
onClick={() => void deleteReplayFile(row.name)}
|
||
className="inline-flex items-center gap-1 rounded-lg bg-zinc-700/40 px-2 py-1 text-[11px] text-zinc-200"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
{t("删除", "Del")}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{sourceMode === "srs" ? (
|
||
<div className="rounded-2xl border border-emerald-500/25 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||
<h3 className="text-sm font-semibold text-white">{t("SRS / OBS 推流", "SRS / OBS ingest")}</h3>
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
{t(
|
||
"OBS:设置 → 推流 → 服务选「自定义」,服务器填 RTMP 地址;串流密钥可与 SRS 应用配置一致(默认 live/livestream)。",
|
||
"OBS → Stream → Custom: RTMP URL as server; stream key per SRS app (default live/livestream).",
|
||
)}
|
||
</p>
|
||
<dl className="mt-4 space-y-3 text-xs">
|
||
<div>
|
||
<dt className="text-zinc-500">{t("RTMP 推流地址(OBS 服务器)", "RTMP publish URL")}</dt>
|
||
<dd className="mt-1 flex flex-wrap items-center gap-2 font-mono text-emerald-200/90">
|
||
<span className="break-all">{quickLinks.srs_rtmp_publish || "—"}</span>
|
||
{quickLinks.srs_rtmp_publish ? (
|
||
<button
|
||
type="button"
|
||
className="shrink-0 text-cyan-300 hover:text-cyan-200"
|
||
onClick={() => onCopy(quickLinks.srs_rtmp_publish)}
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : null}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-zinc-500">{t("HTTP-FLV 预览", "HTTP-FLV preview")}</dt>
|
||
<dd className="mt-1 flex flex-wrap items-center gap-2 font-mono text-zinc-400">
|
||
<span className="break-all">{quickLinks.srs_play_flv || quickLinks.srs_http || "—"}</span>
|
||
{quickLinks.srs_play_flv || quickLinks.srs_http ? (
|
||
<button
|
||
type="button"
|
||
className="shrink-0 text-cyan-300 hover:text-cyan-200"
|
||
onClick={() => onCopy(quickLinks.srs_play_flv || quickLinks.srs_http)}
|
||
>
|
||
<Copy className="h-3.5 w-3.5" />
|
||
</button>
|
||
) : null}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-zinc-500">{t("SRS 控制台", "SRS web UI")}</dt>
|
||
<dd className="mt-1 font-mono text-zinc-400">
|
||
<a
|
||
href={quickLinks.srs_http || "#"}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-cyan-300 hover:underline"
|
||
>
|
||
{quickLinks.srs_http || "—"}
|
||
</a>
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
</div>
|
||
) : null}
|
||
|
||
<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>
|
||
|
||
{sourceMode === "ffmpeg" ? (
|
||
<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>
|
||
) : null}
|
||
|
||
<LiveProcessLiveLogCard
|
||
t={t}
|
||
currentProc={currentProc}
|
||
fetchJson={fetchJson}
|
||
urlListText={sourceMode === "ffmpeg" ? urlConfig : ""}
|
||
keySuffixUi=""
|
||
showKeyRow={false}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|