Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveTiktokHdmiView.tsx
2026-03-29 04:28:16 -05:00

702 lines
28 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, 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*.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>
<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("SRSOBS 推流)", "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>
);
}