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

1069 lines
42 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 { Ban, Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
buildYoutubeIniFromFields,
extractDouyinLiveUrls,
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 LiveProcRow = { pm2: string; process_status?: string };
function pm2StatusOnline(st?: string) {
return /^(online|running)$/i.test((st || "").trim());
}
function parseProcBusy(busy: string | null): { action: string; pm2: string } | null {
if (!busy?.startsWith("proc:")) return null;
const rest = busy.slice(5);
const idx = rest.indexOf(":");
if (idx <= 0) return null;
const action = rest.slice(0, idx);
const pm2 = rest.slice(idx + 1);
if (!pm2 || !["start", "stop", "restart"].includes(action)) return null;
return { action, pm2 };
}
function LiveStreamActionBtn({
kind,
targetPm2,
busy,
online,
label,
className,
baseLock,
onRun,
notify,
t,
compact,
}: {
kind: "start" | "stop" | "restart";
targetPm2: string;
busy: string | null;
online: boolean;
label: string;
className: string;
baseLock: boolean;
onRun: () => void;
notify: (s: string) => void;
t: TFn;
compact?: boolean;
}) {
const pb = parseProcBusy(busy);
const forThisProc = pb && pb.pm2 === targetPm2;
const loading = !!(forThisProc && pb.action === kind);
const siblingBusy = !!(forThisProc && pb.action !== kind);
const blockStart = kind === "start" && online;
const blockStop = kind === "stop" && !online;
const blockRestart = kind === "restart" && !online;
const blocked = blockStart || blockStop || blockRestart;
const hardLock = baseLock || loading || siblingBusy;
const onClick = () => {
if (loading) return;
if (siblingBusy) {
notify(t("请等待当前操作完成", "Wait for the current action to finish"));
return;
}
if (blockStart) {
notify(t("已在直播中,无需重复开始", "Already live"));
return;
}
if (blockStop) {
notify(t("当前未在直播,无法停止", "Not live — cannot stop"));
return;
}
if (blockRestart) {
notify(t("进程未运行时请使用「开始直播」", "Use Start when the process is not running"));
return;
}
if (baseLock) return;
onRun();
};
const sizeCls = compact
? "min-h-[2rem] px-3 py-1.5 text-xs font-medium"
: "min-h-[2.75rem] px-4 py-3 text-sm font-medium";
return (
<button
type="button"
onClick={onClick}
className={`relative overflow-hidden rounded-xl ring-1 transition ${sizeCls} ${className} ${
blocked || hardLock ? "cursor-not-allowed" : ""
} ${blocked && !loading ? "opacity-45" : ""} ${siblingBusy ? "opacity-35" : ""} ${loading ? "ring-violet-400/40" : ""}`}
>
{loading ? (
<span className="absolute inset-0 flex items-center justify-center bg-black/25 backdrop-blur-[2px]">
<Loader2 className={`${compact ? "h-4 w-4" : "h-6 w-6"} animate-spin text-white`} />
</span>
) : null}
<span className={`inline-flex items-center justify-center gap-1.5 ${loading ? "invisible" : ""}`}>
{blocked ? <Ban className={`${compact ? "h-3 w-3" : "h-4 w-4"} shrink-0 opacity-90`} aria-hidden /> : null}
{label}
</span>
</button>
);
}
type Props = {
t: TFn;
busy: string | null;
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>;
liveProcesses: LiveProcRow[];
notify: (msg: string) => void;
};
export function LiveYoutubeUnmannedView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
onRunProcess,
onSaveUrlConfig,
fetchJson,
liveProcesses,
notify,
}: 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 [urlOptimizing, setUrlOptimizing] = 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 applyYoutubeBalancedPreset = async () => {
setYtIniLoading(true);
setYtIniStatus(null);
try {
const source = youtubeIniText || buildYoutubeIniFromFields(ytFields);
const data = await fetchJson<{ message?: string; content?: string }>("/config_optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "app-config",
path: "youtube.ini",
content: source,
action: "youtube_balanced_preset",
}),
});
const next = data.content ?? source;
setYoutubeIniText(next);
setYtFields(parseYoutubeIniFields(next));
setYtIniStatus(data.message || t("已应用 YouTube 预设", "Applied YouTube preset"));
} catch (e) {
setYtIniStatus((e as Error).message);
} finally {
setYtIniLoading(false);
}
};
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 dedupeUrlDraft = async () => {
const source = mode === "single" ? urlConfig : proUrlDraft;
setUrlOptimizing(true);
setUrlFetchNote(null);
try {
const data = await fetchJson<{ message?: string; content?: string }>("/config_optimize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "app-config",
path: "URL_config.ini",
content: source,
action: "dedupe_urls",
}),
});
const next = data.content ?? source;
if (mode === "single") setUrlConfig(next);
else {
setProUrlDraft(next);
if (active) updateActive({ urlLines: next });
}
urlListDirtyRef.current = true;
setUrlFetchNote(data.message || t("已去重当前 URL 列表", "Deduplicated current URL list"));
} catch (e) {
setUrlFetchNote((e as Error).message);
} finally {
setUrlOptimizing(false);
}
};
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 || urlOptimizing;
const statusForPm2 = useCallback(
(pm2: string) => liveProcesses.find((p) => p.pm2 === pm2)?.process_status,
[liveProcesses],
);
const singleOnline = pm2StatusOnline(statusForPm2(currentProc));
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>
<button
type="button"
disabled={lock}
onClick={() => void dedupeUrlDraft()}
className="rounded-lg border border-cyan-500/20 bg-cyan-500/10 px-3 py-1.5 text-cyan-100 hover:bg-cyan-500/15"
>
{urlOptimizing ? t("去重中...", "Deduplicating...") : t("智能去重", "Deduplicate")}
</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">
<LiveStreamActionBtn
kind="start"
targetPm2={currentProc}
busy={busy}
online={singleOnline}
label={t("开始直播", "Start")}
className="bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("start")}
notify={notify}
t={t}
/>
<LiveStreamActionBtn
kind="stop"
targetPm2={currentProc}
busy={busy}
online={singleOnline}
label={t("停止直播", "Stop")}
className="bg-rose-500/15 text-rose-200 ring-rose-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("stop")}
notify={notify}
t={t}
/>
<LiveStreamActionBtn
kind="restart"
targetPm2={currentProc}
busy={busy}
online={singleOnline}
label={t("重新开始", "Restart")}
className="bg-amber-500/15 text-amber-200 ring-amber-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("restart")}
notify={notify}
t={t}
/>
</div>
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
key {t("尾码", "suffix")}: {keySuffixUi || "—"}
</p>
</>
) : (
<>
<div className="mt-4 rounded-xl border border-cyan-500/25 bg-gradient-to-br from-cyan-500/[0.1] via-zinc-950/50 to-violet-500/[0.06] p-4 ring-1 ring-white/[0.04]">
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-200/90">
{t("Pro 转播实况", "Pro relay status")}
</h4>
<p className="mt-1 text-[11px] text-zinc-500">
{t("对齐桌面端工作台:每路密钥、源站、进程状态一览;点行可切换编辑。", "Like the desktop workspace: per-lane key, source, PM2 status.")}
</p>
<div className="mt-3 max-h-52 space-y-2 overflow-y-auto pr-1">
{channels.map((c) => {
const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id);
const st = statusForPm2(pm);
const on = pm2StatusOnline(st);
const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0];
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
return (
<div
key={c.id}
className={`flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-left text-xs transition ${
sel ? "border-violet-500/45 bg-violet-500/[0.1]" : "border-white/10 bg-black/30 hover:border-white/18"
}`}
>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => selectProChannel(c)}
>
<span className="font-medium text-zinc-100">{c.name}</span>
<span className="ml-2 font-mono text-[10px] text-zinc-500">
key {suf || "—"}
</span>
</button>
<span
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ring-1 ${
on
? "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30"
: "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25"
}`}
>
{on ? t("运行中", "Live") : st || t("未运行", "Idle")}
</span>
{dUrl ? (
<a
href={dUrl}
target="_blank"
rel="noreferrer"
className="max-w-[200px] truncate text-cyan-300/90 underline-offset-2 hover:underline"
onClick={(e) => e.stopPropagation()}
title={dUrl}
>
{dUrl.replace(/^https?:\/\//, "")}
</a>
) : (
<span className="text-zinc-600"></span>
)}
</div>
);
})}
</div>
</div>
<p className="mt-3 text-xs text-zinc-500">
{t("点选一行即可编辑该线路;下方列表为已保存密钥的线路控制。", "Pick a row above to edit; the list below is lanes with a saved key.")}
</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;
const rowOnline = pm2StatusOnline(statusForPm2(pm));
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()}
onKeyDown={(e) => e.stopPropagation()}
>
<LiveStreamActionBtn
kind="start"
targetPm2={pm}
busy={busy}
online={rowOnline}
label={t("开始", "Start")}
className="rounded-lg bg-emerald-500/20 text-emerald-100 ring-emerald-500/30"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("start", pm)}
notify={notify}
t={t}
compact
/>
<LiveStreamActionBtn
kind="stop"
targetPm2={pm}
busy={busy}
online={rowOnline}
label={t("停止", "Stop")}
className="rounded-lg bg-rose-500/15 text-rose-100 ring-rose-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("stop", pm)}
notify={notify}
t={t}
compact
/>
<LiveStreamActionBtn
kind="restart"
targetPm2={pm}
busy={busy}
online={rowOnline}
label={t("重新开始", "Restart")}
className="rounded-lg bg-amber-500/15 text-amber-100 ring-amber-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("restart", pm)}
notify={notify}
t={t}
compact
/>
</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={() => void applyYoutubeBalancedPreset()}
className="rounded-xl border border-cyan-500/20 bg-cyan-500/10 px-4 py-2 text-sm text-cyan-100"
>
{t("平衡预设", "Balanced preset")}
</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>
);
}