Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
2026-03-28 17:34:28 -05:00

785 lines
31 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 { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
buildYoutubeIniFromFields,
parseYoutubeIniFields,
parseYoutubeStreamKeySuffix,
type YoutubeIniFields,
} from "@/lib/youtubeConfigUtils";
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
import {
defaultPm2NameForChannel,
loadYoutubeProChannels,
newChannelId,
saveYoutubeProChannels,
type YoutubeProChannel,
} from "@/lib/youtubeProChannels";
type ProcessEntry = { pm2: string; script: string; label: string };
type TFn = (zh: string, en: string) => string;
function isYoutubeProcess(e: ProcessEntry) {
return /youtube/i.test(e.script) || /youtube/i.test(e.pm2);
}
const MODE_KEY = "live-hub-youtube-mode-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", processPm2?: string) => void;
onSaveUrlConfig: (content?: string) => void | Promise<void>;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
};
export function LiveYoutubeUnmannedView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
onRunProcess,
onSaveUrlConfig,
fetchJson,
}: Props) {
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
const processOptions = ytEntries.length ? ytEntries : entries;
const [mode, setMode] = useState<"single" | "pro">("single");
const [channels, setChannels] = useState<YoutubeProChannel[]>([]);
const [activeCh, setActiveCh] = useState<string>("");
const [youtubeIniText, setYoutubeIniText] = useState("");
const [ytFields, setYtFields] = useState<YoutubeIniFields>({
key: "",
rtmps: true,
bitrate: "",
fastAudio: false,
});
const [ytIniStatus, setYtIniStatus] = useState<string | null>(null);
const [ytIniLoading, setYtIniLoading] = useState(false);
const [proUrlDraft, setProUrlDraft] = useState("");
const [urlAutoRefresh, setUrlAutoRefresh] = useState(true);
const [urlReloading, setUrlReloading] = useState(false);
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
const urlListDirtyRef = useRef(false);
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)}`,
);
const c = data.content ?? "";
if (mode === "single") setUrlConfig(c);
else setProUrlDraft(c);
} catch {
/* ignore */
}
}, [currentProc, fetchJson, mode, setUrlConfig]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
const id = window.setInterval(() => {
if (urlListDirtyRef.current) return;
void pullUrlConfigFromServer();
}, 8000);
return () => clearInterval(id);
}, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]);
const reloadUrlManual = async () => {
if (!currentProc) return;
setUrlReloading(true);
setUrlFetchNote(null);
urlListDirtyRef.current = false;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
const c = data.content ?? "";
if (mode === "single") setUrlConfig(c);
else setProUrlDraft(c);
setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
} catch (e) {
setUrlFetchNote((e as Error).message);
} finally {
setUrlReloading(false);
}
};
useEffect(() => {
try {
const m = window.localStorage.getItem(MODE_KEY);
if (m === "pro" || m === "single") setMode(m);
} catch {
/* ignore */
}
}, []);
const setModePersist = (m: "single" | "pro") => {
setMode(m);
if (m === "pro") {
const ch = channels.find((c) => c.id === activeCh) ?? channels[0];
if (ch) {
const u = (ch.urlLines ?? "").trim();
setProUrlDraft(u || urlConfig);
}
}
try {
window.localStorage.setItem(MODE_KEY, m);
} catch {
/* ignore */
}
};
useEffect(() => {
const list = loadYoutubeProChannels();
const initial =
list.length > 0
? list
: (() => {
const id = newChannelId();
return [
{
id,
name: "线路 1",
streamKey: "",
urlLines: "",
pm2Name: defaultPm2NameForChannel(id),
notes: "",
},
];
})();
setChannels(initial);
if (!list.length) saveYoutubeProChannels(initial);
}, []);
useEffect(() => {
if (channels.length && !activeCh) setActiveCh(channels[0].id);
}, [channels, activeCh]);
const persistChannels = useCallback((next: YoutubeProChannel[]) => {
setChannels(next);
saveYoutubeProChannels(next);
}, []);
const active = channels.find((c) => c.id === activeCh) || channels[0];
const proLanesWithKey = useMemo(
() => channels.filter((ch) => (ch.streamKey ?? "").trim().length > 0),
[channels],
);
const selectProChannel = useCallback(
(c: YoutubeProChannel) => {
if (active && mode === "pro") {
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)));
}
const u = (c.urlLines ?? "").trim();
setProUrlDraft(u || urlConfig);
setActiveCh(c.id);
},
[active, mode, channels, proUrlDraft, urlConfig, persistChannels],
);
useEffect(() => {
if (mode !== "pro" || !activeCh) return;
const ch = channels.find((x) => x.id === activeCh);
if (!ch) return;
const want = (ch.pm2Name ?? "").trim() || defaultPm2NameForChannel(ch.id);
if (processOptions.some((e) => e.pm2 === want)) setCurrentProc(want);
}, [mode, activeCh, channels, processOptions, setCurrentProc]);
const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return;
setYtIniLoading(true);
setYtIniStatus(null);
try {
const data = await fetchJson<{ content?: string }>(
`/get_config?process=${encodeURIComponent(currentProc)}`,
);
const text = data.content ?? "";
setYoutubeIniText(text);
setYtFields(parseYoutubeIniFields(text));
} catch (e) {
setYtIniStatus((e as Error).message);
} finally {
setYtIniLoading(false);
}
}, [currentProc, fetchJson]);
useEffect(() => {
if (!currentProc) return;
void loadYoutubeIni();
}, [currentProc, loadYoutubeIni]);
const updateActive = (patch: Partial<YoutubeProChannel>) => {
if (!active) return;
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
};
const saveYoutubeIniToServer = async (content: string) => {
if (!currentProc) {
setYtIniStatus(t("请选择进程", "Pick a process"));
return;
}
setYtIniLoading(true);
setYtIniStatus(null);
try {
const params = new URLSearchParams({ process: currentProc });
await fetchJson(`/save_config?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
setYoutubeIniText(content);
setYtIniStatus(t("youtube.ini 已保存", "youtube.ini saved"));
} catch (e) {
setYtIniStatus((e as Error).message);
} finally {
setYtIniLoading(false);
}
};
const applyFieldsAndSave = async () => {
const body = buildYoutubeIniFromFields(ytFields);
await saveYoutubeIniToServer(body);
};
const syncFieldsFromText = () => {
setYtFields(parseYoutubeIniFields(youtubeIniText));
setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw"));
};
const keySuffixUi = useMemo(() => {
if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`);
const k = active?.streamKey ?? "";
return parseYoutubeStreamKeySuffix(`key = ${k}`);
}, [mode, ytFields.key, active?.streamKey]);
const saveProUrlAndServer = async () => {
if (!active) return;
updateActive({ urlLines: proUrlDraft });
setUrlConfig(proUrlDraft);
try {
await Promise.resolve(onSaveUrlConfig(proUrlDraft));
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
}
};
const saveSingleUrlAndClearDirty = async () => {
try {
await Promise.resolve(onSaveUrlConfig());
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
}
};
const pushProStreamKeyToServer = async () => {
if (!active) return;
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
const f: YoutubeIniFields = {
key: (active.streamKey ?? "").trim(),
rtmps: base.rtmps,
bitrate: base.bitrate,
fastAudio: base.fastAudio,
};
const body = buildYoutubeIniFromFields(f);
await saveYoutubeIniToServer(body);
updateActive({ streamKey: f.key });
};
const lock = busy || ytIniLoading || urlReloading;
const urlToolbar = (
<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>
<button
type="button"
disabled={lock}
onClick={() => void reloadUrlManual()}
className="rounded-lg border border-white/10 px-3 py-1.5 text-zinc-200 hover:bg-white/5"
>
{t("手动重新读取", "Reload from server")}
</button>
</div>
);
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/80 to-cyan-500/5 p-6 shadow-xl shadow-violet-900/10">
<div className="flex flex-wrap items-start justify-between 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-red-500/30 to-violet-600/30 ring-1 ring-white/10">
<MonitorPlay className="h-5 w-5 text-red-200" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight text-white">
{t("YouTube 无人直播", "YouTube unmanned live")}
</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{t(
"单路一条流;多路时每条线路一把密钥、一个进程名,填好密钥和直播地址后保存,再点「开始」。无需 Google 登录。",
"Single lane or multi-lane: one key and one process per lane — fill key & URLs, save, then Start. No Google login.",
)}
</p>
</div>
</div>
<div className="flex rounded-xl border border-white/10 bg-black/30 p-1">
<button
type="button"
onClick={() => setModePersist("single")}
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
mode === "single" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
}`}
>
{t("单频道", "Single")}
</button>
<button
type="button"
onClick={() => setModePersist("pro")}
className={`rounded-lg px-4 py-2 text-xs font-medium transition ${
mode === "pro" ? "bg-violet-500/25 text-white" : "text-zinc-500 hover:text-zinc-300"
}`}
>
{t("多频道 Pro", "Multi Pro")}
</button>
</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>
{mode === "single" ? (
<>
<label className="mt-4 block text-xs font-semibold uppercase tracking-wider text-zinc-500">
{t("转播任务", "Relay task")}
</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 font-mono text-[11px] text-zinc-600">
key {t("尾码", "suffix")}: {keySuffixUi || "—"}
</p>
</>
) : (
<>
<p className="mt-3 text-xs text-zinc-500">
{t("点选一行即可编辑该线路;此处只列出已保存过密钥的线路。", "Pick a row to edit. Only lanes with a saved key are listed.")}
</p>
{proLanesWithKey.length === 0 ? (
<p className="mt-4 rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-zinc-400">
{channels.length > 0
? t("请先在下方填写串流密钥并点「保存」。", "Enter a stream key below and tap Save.")
: t("请先新增线路。", "Add a lane first.")}
</p>
) : (
<div className="mt-4 space-y-2">
{proLanesWithKey.map((c) => {
const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id);
const suffix = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
return (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => selectProChannel(c)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectProChannel(c);
}
}}
className={`rounded-xl border p-3 text-left transition ${
sel ? "border-violet-500/45 bg-violet-500/[0.08]" : "border-white/10 bg-black/30 hover:border-white/20"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex flex-1 flex-wrap items-center gap-2">
<code className="shrink-0 font-mono text-xs text-cyan-300/90">{suffix || "—"}</code>
<span className="truncate text-sm text-zinc-200">{c.name}</span>
</div>
<div className="flex shrink-0 flex-wrap gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
disabled={lock}
onClick={() => onRunProcess("start", pm)}
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-100 ring-1 ring-emerald-500/30 disabled:opacity-30"
>
{t("开始", "Start")}
</button>
<button
type="button"
disabled={lock}
onClick={() => onRunProcess("stop", pm)}
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-100 ring-1 ring-rose-500/25 disabled:opacity-30"
>
{t("停止", "Stop")}
</button>
<button
type="button"
disabled={lock}
onClick={() => onRunProcess("restart", pm)}
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-100 ring-1 ring-amber-500/25 disabled:opacity-30"
>
{t("重新开始", "Restart")}
</button>
</div>
</div>
</div>
);
})}
</div>
)}
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
{t("当前线路", "Current lane")}: {active?.name ?? "—"} · key {keySuffixUi || "—"}
</p>
</>
)}
</div>
{mode === "single" ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("YouTube 相关设置", "YouTube settings")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("串流参数写进 youtube.ini无需 Google 登录。", "Stream settings go to youtube.ini — no Google login.")}
</p>
<div className="mt-4 space-y-3">
<label className="block text-xs text-zinc-400">{t("串流密钥", "Stream key")}</label>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm font-mono text-zinc-200"
spellCheck={false}
autoComplete="off"
value={ytFields.key}
onChange={(e) => setYtFields((f) => ({ ...f, key: e.target.value }))}
placeholder="xxxx-xxxx-xxxx-xxxx-xxxx"
/>
<div className="grid gap-3 sm:grid-cols-3">
<label className="flex flex-col gap-1 text-xs text-zinc-400">
RTMPS
<select
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 text-sm text-white"
value={ytFields.rtmps ? "1" : "0"}
onChange={(e) => setYtFields((f) => ({ ...f, rtmps: e.target.value === "1" }))}
>
<option value="1">{t("是(默认)", "Yes")}</option>
<option value="0">{t("否 → RTMP", "No")}</option>
</select>
</label>
<label className="flex flex-col gap-1 text-xs text-zinc-400">
{t("比特率 kbps", "Bitrate kbps")}
<input
className="rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
value={ytFields.bitrate}
onChange={(e) => setYtFields((f) => ({ ...f, bitrate: e.target.value }))}
placeholder="4000"
/>
</label>
<label className="flex cursor-pointer items-center gap-2 pt-6 text-xs text-zinc-300">
<input
type="checkbox"
className="rounded border-white/20 bg-black/40"
checked={ytFields.fastAudio}
onChange={(e) => setYtFields((f) => ({ ...f, fastAudio: e.target.checked }))}
/>
fast_audio{t("轻量音频", "lighter audio")}
</label>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void loadYoutubeIni()}
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-200"
>
<RefreshCw className={`h-3.5 w-3.5 ${ytIniLoading ? "animate-spin" : ""}`} />
{t("重新读取", "Reload")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void applyFieldsAndSave()}
className="inline-flex items-center gap-2 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
>
{ytIniLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{t("保存 youtube.ini", "Save youtube.ini")}
</button>
<button
type="button"
disabled={lock}
onClick={syncFieldsFromText}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-400"
>
{t("从原文同步表单", "Parse raw")}
</button>
</div>
<details className="mt-4 rounded-xl border border-white/5 bg-black/20 p-3">
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级youtube.ini 原文", "Raw youtube.ini")}</summary>
<textarea
className="mt-2 h-36 w-full resize-y rounded-lg border border-white/10 bg-black/50 p-3 font-mono text-[11px] text-zinc-400"
value={youtubeIniText}
onChange={(e) => setYoutubeIniText(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={lock}
className="mt-2 rounded-lg bg-cyan-500/15 px-3 py-1.5 text-xs text-cyan-200"
onClick={() => void saveYoutubeIniToServer(youtubeIniText)}
>
{t("保存原文", "Save raw")}
</button>
</details>
{ytIniStatus ? <p className="mt-2 font-mono text-xs text-zinc-500">{ytIniStatus}</p> : null}
</div>
) : (
<div className="space-y-6">
<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("当前线路", "Current lane")}</h3>
<button
type="button"
disabled={lock}
className="inline-flex items-center gap-1 rounded-lg bg-violet-500/20 px-3 py-1.5 text-xs text-violet-100 ring-1 ring-violet-500/30"
onClick={() => {
const id = newChannelId();
const nc: YoutubeProChannel = {
id,
name: `${t("线路", "Ch")} ${channels.length + 1}`,
streamKey: "",
urlLines: "",
pm2Name: defaultPm2NameForChannel(id),
notes: "",
};
persistChannels([...channels, nc]);
setActiveCh(id);
setProUrlDraft("");
}}
>
<Plus className="h-3.5 w-3.5" />
{t("新增线路", "Add lane")}
</button>
</div>
<label className="mt-3 block text-xs font-medium text-zinc-500">{t("切换线路", "Switch lane")}</label>
<select
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2.5 text-sm text-white"
value={activeCh}
onChange={(e) => {
const c = channels.find((x) => x.id === e.target.value);
if (c) selectProChannel(c);
}}
>
{channels.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{active ? (
<div className="mt-4 space-y-3 border-t border-white/5 pt-4">
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm"
value={active.name}
onChange={(e) => updateActive({ name: e.target.value })}
placeholder={t("线路名称", "Lane name")}
/>
<label className="block text-xs text-zinc-500">{t("串流密钥", "Stream key")}</label>
<input
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
spellCheck={false}
value={active.streamKey ?? ""}
onChange={(e) => updateActive({ streamKey: e.target.value })}
placeholder="xxxx-xxxx… / rtmp(s)://…"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void pushProStreamKeyToServer()}
className="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")}
</button>
</div>
<details className="rounded-xl border border-white/5 bg-black/20 p-3">
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级:进程名", "Advanced: process name")}</summary>
<input
className="mt-2 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
onChange={(e) => updateActive({ pm2Name: e.target.value })}
spellCheck={false}
placeholder="youtube2__…"
/>
</details>
<textarea
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
value={active.notes ?? ""}
onChange={(e) => updateActive({ notes: e.target.value })}
placeholder={t("备注(可选)", "Notes (optional)")}
/>
<button
type="button"
disabled={lock || channels.length < 2}
className="inline-flex items-center gap-1 text-xs text-rose-300 hover:text-rose-200 disabled:opacity-30"
onClick={() => {
const next = channels.filter((c) => c.id !== active.id);
persistChannels(next);
setActiveCh(next[0]?.id || "");
}}
>
<Trash2 className="h-3.5 w-3.5" />
{t("删除此线路", "Delete lane")}
</button>
</div>
) : null}
</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("当前线路 · 直播地址列表", "URL list (this lane)")}</h3>
{urlToolbar}
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<textarea
className="mt-4 min-h-[160px] w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={proUrlDraft}
onChange={(e) => {
urlListDirtyRef.current = true;
setProUrlDraft(e.target.value);
}}
spellCheck={false}
placeholder="https://live.douyin.com/..."
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => {
urlListDirtyRef.current = true;
setProUrlDraft(urlConfig);
setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft"));
}}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300"
>
{t("填入全局地址", "Use global URLs")}
</button>
<button
type="button"
disabled={lock}
onClick={() => void saveProUrlAndServer()}
className="rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存地址", "Save URLs")}
</button>
</div>
</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("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{mode === "pro"
? t("Pro 模式在上方「当前线路」里编辑本线路地址。", "Pro: edit this lanes URLs in the lane panel above.")
: t("一行一个地址;可自动刷新或手动重新读取。", "One URL per line; auto-refresh or reload.")}
</p>
{mode === "single" ? (
<>
{urlToolbar}
{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}
/>
<button
type="button"
disabled={lock}
onClick={() => void saveSingleUrlAndClearDirty()}
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存 URL 配置", "Save URL config")}
</button>
</>
) : (
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
)}
</div>
<LiveProcessLiveLogCard
t={t}
currentProc={currentProc}
fetchJson={fetchJson}
urlListText={mode === "single" ? urlConfig : proUrlDraft}
keySuffixUi={keySuffixUi}
showKeyRow
/>
</div>
);
}