Files
root ffdc887b8a s
2026-05-17 12:09:14 +00:00

1803 lines
69 KiB
TypeScript
Raw Permalink 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,
managedUrlConfigRelPathForPm2,
managedYoutubeIniRelPathForPm2,
parseYoutubeIniFields,
parseYoutubeStreamKeySuffix,
type YoutubeIniFields,
} from "@/lib/youtubeConfigUtils";
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
import { LivePipelineStrip } from "@/components/live-product/LivePipelineStrip";
import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard";
import {
defaultPm2NameForChannel,
fetchYoutubeProChannelsRemote,
loadYoutubeProChannels,
newChannelId,
pushYoutubeProChannelsRemote,
resolveYoutubeProChannelPm2,
saveYoutubeProChannels,
sanitizeYoutubePm2Name,
type YoutubeProChannel,
} from "@/lib/youtubeProChannels";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
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);
}
function scriptStem(script: string): string {
const base = script.split("/").pop() || script;
return base.replace(/\.[^.]+$/, "");
}
function isStaticYoutubeControlEntry(entry: ProcessEntry): boolean {
return isYoutubeProcess(entry) && entry.pm2.trim() === scriptStem(entry.script).trim();
}
function singleModeProcessLabel(entry: ProcessEntry, t: TFn): string {
const stem = scriptStem(entry.script).toLowerCase();
if (stem.startsWith("douyin_youtube")) return t("抖音→YouTube", "Douyin → YouTube");
if (stem.startsWith("youtube")) return t("YouTube", "YouTube");
return entry.label;
}
function preferredSingleProcessPm2(entries: ProcessEntry[]): string {
const exact = entries.find((entry) => entry.pm2.trim().toLowerCase() === "youtube");
if (exact) return exact.pm2;
const byScript = entries.find((entry) => scriptStem(entry.script).trim().toLowerCase() === "youtube");
if (byScript) return byScript.pm2;
return entries[0]?.pm2 ?? "";
}
function isYoutubeRow(row: LiveProcRow | undefined): boolean {
if (!row) return false;
return /youtube/i.test(row.pm2) || /youtube/i.test(row.script ?? "");
}
function singleProcessPriority(entry: ProcessEntry, row: LiveProcRow | undefined): number {
const business = (row?.business_status || "").trim().toLowerCase();
if (row?.is_pushing || business === "streaming") return 0;
if (pm2StatusOnline(row?.process_status)) return 1;
if (entry.pm2.trim().toLowerCase() === "youtube") return 2;
if (scriptStem(entry.script).trim().toLowerCase() === "youtube") return 3;
return 10;
}
function buildSingleProcessOptions(entries: ProcessEntry[], liveRows: LiveProcRow[]): ProcessEntry[] {
const youtubeEntries = entries.filter(isYoutubeProcess);
const staticYoutubeEntries = youtubeEntries.filter((entry) => isStaticYoutubeControlEntry(entry));
const base = staticYoutubeEntries.length
? [...staticYoutubeEntries]
: youtubeEntries.length
? [...youtubeEntries]
: [...entries];
const seen = new Set(base.map((entry) => entry.pm2.trim()));
for (const row of liveRows.filter(isYoutubeRow)) {
const pm2 = row.pm2.trim();
if (!pm2 || seen.has(pm2)) continue;
const business = (row.business_status || "").trim().toLowerCase();
if (!row.is_pushing && business !== "streaming" && !pm2StatusOnline(row.process_status)) continue;
const matched = youtubeEntries.find((entry) => entry.pm2.trim() === pm2);
base.push(
matched ?? {
pm2,
script: row.script ?? youtubeEntries[0]?.script ?? "youtube.py",
label: row.label ?? pm2,
},
);
seen.add(pm2);
}
return base;
}
function preferredSingleProcessPm2FromRuntime(entries: ProcessEntry[], liveRows: LiveProcRow[]): string {
if (!entries.length) return "";
const rowMap = new Map(
liveRows
.filter(isYoutubeRow)
.map((row) => [row.pm2.trim(), row] as const),
);
let best = entries[0];
let bestScore = singleProcessPriority(best, rowMap.get(best.pm2.trim()));
for (const entry of entries.slice(1)) {
const score = singleProcessPriority(entry, rowMap.get(entry.pm2.trim()));
if (score < bestScore) {
best = entry;
bestScore = score;
}
}
return best.pm2;
}
function resolveChannelPm2(channel: YoutubeProChannel): string {
return resolveYoutubeProChannelPm2(channel);
}
const MODE_KEY = "live-hub-youtube-mode-v1";
const PRO_PM2_RESERVED = new Set(["tiktok", "obs", "web"]);
/** UI 暂时隐藏「多频道 Pro」切换DOM 与逻辑保留,改 false 即可恢复) */
const HIDE_YOUTUBE_MULTI_PRO_TOGGLE = false;
type QuickLinkMap = Record<string, string>;
type ProLaneDraft = {
name: string;
streamKey: string;
pm2Name: string;
notes: string;
};
function laneDraftFromChannel(channel: YoutubeProChannel): ProLaneDraft {
return {
name: channel.name,
streamKey: channel.streamKey ?? "",
pm2Name: channel.pm2Name ?? defaultPm2NameForChannel(channel.id),
notes: channel.notes ?? "",
};
}
type LiveProcRow = {
pm2: string;
script?: string;
label?: string;
process_status?: string;
business_status?: string;
business_note?: string;
is_pushing?: boolean;
};
function pm2StatusOnline(st?: string) {
return /^(online|running)$/i.test((st || "").trim());
}
function businessStatusLabel(row: LiveProcRow | undefined, t: TFn): string {
const business = (row?.business_status || "").trim().toLowerCase();
if (business === "ready") return t("已配置", "Ready");
if (business === "streaming") return t("推流中", "Pushing");
if (business === "waiting_source") return t("等待上游", "Waiting source");
if (business === "source_live") return t("源已开播,转推未起", "Source live, relay down");
if (business === "monitoring") return t("监控中", "Monitoring");
if (business === "misconfigured") return t("密钥异常", "Key invalid");
if (business === "stale") return t("心跳过期", "Stale");
if (business === "error") return t("异常", "Error");
if (pm2StatusOnline(row?.process_status)) return t("运行中", "Running");
return (row?.process_status || "").trim() || t("未运行", "Idle");
}
function businessStatusBadgeClass(row: LiveProcRow | undefined): string {
const business = (row?.business_status || "").trim().toLowerCase();
if (business === "ready") return "bg-sky-500/15 text-sky-100 ring-sky-500/25";
if (business === "streaming") return "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30";
if (business === "waiting_source" || business === "monitoring") {
return "bg-amber-500/15 text-amber-100 ring-amber-500/25";
}
if (business === "source_live") return "bg-sky-500/15 text-sky-100 ring-sky-500/25";
if (business === "misconfigured" || business === "error" || business === "stale") {
return "bg-rose-500/15 text-rose-100 ring-rose-500/25";
}
if (pm2StatusOnline(row?.process_status)) return "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30";
return "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25";
}
function nonCommentUrlLinesOf(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#"));
}
function shouldPreserveLocalUrlDraft(localText: string, remoteText: string): boolean {
return nonCommentUrlLinesOf(localText).length > 0 && nonCommentUrlLinesOf(remoteText).length === 0;
}
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,
busyKind,
targetPm2,
busy,
online,
label,
className,
baseLock,
onRun,
notify,
t,
compact,
}: {
kind: "start" | "stop" | "restart";
busyKind?: "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 actionForBusy = busyKind ?? kind;
const loading = !!(forThisProc && pb.action === actionForBusy);
const siblingBusy = !!(forThisProc && pb.action !== actionForBusy);
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, processPm2?: string) => void | Promise<void>;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
liveProcesses: LiveProcRow[];
notify: (msg: string) => void;
onNavigate?: (target: PortalNavTarget) => void;
quickLinks?: QuickLinkMap;
};
export function LiveYoutubeUnmannedView({
t,
busy,
entries,
currentProc,
setCurrentProc,
urlConfig,
setUrlConfig,
onRunProcess,
onSaveUrlConfig,
fetchJson,
liveProcesses,
notify,
onNavigate,
quickLinks,
}: Props) {
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
const staticYoutubeEntries = useMemo(() => ytEntries.filter((entry) => isStaticYoutubeControlEntry(entry)), [ytEntries]);
const singleProcessOptions = useMemo(
() => buildSingleProcessOptions(ytEntries.length ? ytEntries : entries, liveProcesses),
[entries, liveProcesses, ytEntries],
);
const preferredSingleProc = useMemo(
() => preferredSingleProcessPm2FromRuntime(singleProcessOptions, liveProcesses) || preferredSingleProcessPm2(singleProcessOptions),
[liveProcesses, singleProcessOptions],
);
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 [laneDrafts, setLaneDrafts] = useState<Record<string, ProLaneDraft>>({});
const [laneSaving, setLaneSaving] = useState(false);
const [laneStatus, setLaneStatus] = useState<string | null>(null);
const [activeViewRefreshToken, setActiveViewRefreshToken] = useState(0);
const urlListDirtyRef = useRef(false);
const singleUrlDraftRef = useRef(urlConfig);
const proUrlDraftRef = useRef(proUrlDraft);
const currentProcRef = useRef(currentProc);
const urlRequestSeqRef = useRef(0);
const ytIniRequestSeqRef = useRef(0);
const applySingleUrlDraft = useCallback(
(next: string) => {
singleUrlDraftRef.current = next;
setUrlConfig(next);
},
[setUrlConfig],
);
const applyProUrlDraft = useCallback((next: string) => {
proUrlDraftRef.current = next;
setProUrlDraft(next);
}, []);
useEffect(() => {
urlListDirtyRef.current = false;
}, [currentProc]);
useEffect(() => {
currentProcRef.current = currentProc;
}, [currentProc]);
useEffect(() => {
singleUrlDraftRef.current = urlConfig;
}, [urlConfig]);
useEffect(() => {
proUrlDraftRef.current = proUrlDraft;
}, [proUrlDraft]);
const pullUrlConfigFromServer = useCallback(async () => {
const targetProc = currentProcRef.current.trim();
if (!targetProc) return;
const requestId = ++urlRequestSeqRef.current;
try {
const params = new URLSearchParams({
process: targetProc,
_ts: String(Date.now()),
});
const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`);
if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
const c = data.content ?? "";
if (urlListDirtyRef.current) return;
if (mode === "single") applySingleUrlDraft(c);
else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
applyProUrlDraft(localDraft);
return;
}
applyProUrlDraft(c);
if (activeCh) {
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
return next;
});
}
}
} catch {
/* ignore */
}
}, [activeCh, applyProUrlDraft, applySingleUrlDraft, channels, fetchJson, mode]);
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 () => {
const targetProc = currentProcRef.current.trim();
if (!targetProc) return;
setUrlReloading(true);
setUrlFetchNote(null);
urlListDirtyRef.current = false;
const requestId = ++urlRequestSeqRef.current;
try {
const params = new URLSearchParams({
process: targetProc,
_ts: String(Date.now()),
});
const data = await fetchJson<{ content?: string }>(`/get_url_config?${params}`);
if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
const c = data.content ?? "";
if (urlListDirtyRef.current) {
setUrlFetchNote(t("检测到本地未保存修改,已保留当前草稿", "Kept the local unsaved draft"));
return;
}
if (mode === "single") applySingleUrlDraft(c);
else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
applyProUrlDraft(localDraft);
setUrlFetchNote(t("服务器暂无已保存 URL已保留本地草稿", "Server has no saved URLs, kept the local draft"));
return;
}
applyProUrlDraft(c);
if (activeCh) {
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
return next;
});
}
}
setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
} catch (e) {
if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
setUrlFetchNote((e as Error).message);
} finally {
if (requestId === urlRequestSeqRef.current && targetProc === currentProcRef.current.trim()) {
setUrlReloading(false);
}
}
};
useEffect(() => {
try {
if (HIDE_YOUTUBE_MULTI_PRO_TOGGLE) {
setMode("single");
return;
}
const m = window.localStorage.getItem(MODE_KEY);
if (m === "pro" || m === "single") setMode(m);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const remote = await fetchYoutubeProChannelsRemote(fetchJson);
if (remote.fetched && remote.channels.length && !cancelled) {
setChannels(remote.channels);
saveYoutubeProChannels(remote.channels);
return;
}
} catch {
/* 使用本机缓存 */
}
if (cancelled) return;
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);
})();
return () => {
cancelled = true;
};
}, [fetchJson]);
useEffect(() => {
if (!channels.length) {
if (activeCh) setActiveCh("");
return;
}
if (!activeCh || !channels.some((row) => row.id === activeCh)) {
setActiveCh(channels[0].id);
}
}, [channels, activeCh]);
const rowForPm2 = useCallback((pm2: string) => liveProcesses.find((p) => p.pm2 === pm2), [liveProcesses]);
const statusForPm2 = useCallback(
(pm2: string) => rowForPm2(pm2)?.process_status,
[rowForPm2],
);
const currentProcRow = rowForPm2(currentProc);
const currentProcLooksYoutube = useMemo(
() => !!currentProc && (/youtube/i.test(currentProc) || /youtube/i.test(currentProcRow?.script ?? "") || /youtube/i.test(currentProcRow?.label ?? "")),
[currentProc, currentProcRow],
);
useEffect(() => {
if (mode !== "single" || !preferredSingleProc) return;
if (!currentProc || !currentProcLooksYoutube) {
setCurrentProc(preferredSingleProc);
}
}, [currentProc, currentProcLooksYoutube, mode, preferredSingleProc, setCurrentProc]);
const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => {
setChannels(next);
saveYoutubeProChannels(next);
}, []);
const syncChannelsRemote = useCallback(
async (next: YoutubeProChannel[]) => {
await pushYoutubeProChannelsRemote(fetchJson, next);
},
[fetchJson],
);
const persistChannels = useCallback(
(next: YoutubeProChannel[]) => {
applyChannelsLocal(next);
void syncChannelsRemote(next).catch((e) => {
notify(
`${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`,
);
});
},
[applyChannelsLocal, notify, syncChannelsRemote, t],
);
const active = channels.find((c) => c.id === activeCh) || channels[0];
const activeLaneDraft = useMemo(
() => (active ? laneDrafts[active.id] ?? laneDraftFromChannel(active) : null),
[active, laneDrafts],
);
useEffect(() => {
if (!active) return;
setLaneDrafts((prev) => {
if (prev[active.id]) return prev;
return { ...prev, [active.id]: laneDraftFromChannel(active) };
});
}, [active]);
useEffect(() => {
setLaneStatus(null);
}, [activeCh]);
const setLaneDraftField = useCallback(
(key: keyof ProLaneDraft, value: string) => {
if (!active) return;
setLaneDrafts((prev) => ({
...prev,
[active.id]: {
...(prev[active.id] ?? laneDraftFromChannel(active)),
[key]: value,
},
}));
setLaneStatus(null);
},
[active],
);
const activeSavedPm2 = active ? resolveChannelPm2(active) : "";
const activeDraftPm2 = active && activeLaneDraft
? resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name })
: "";
const activePm2PreviewChanged = !!active && !!activeLaneDraft && activeDraftPm2 !== activeSavedPm2;
const activeLaneValidation = useMemo(() => {
if (!active || !activeLaneDraft) {
return {
ok: false,
message: t("请选择线路", "Pick a lane"),
normalizedName: "",
normalizedPm2: "",
normalizedStreamKey: "",
normalizedNotes: "",
};
}
const normalizedName = activeLaneDraft.name.trim() || `${t("线路", "Ch")} ${channels.findIndex((row) => row.id === active.id) + 1}`;
const normalizedPm2 = resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name });
if (!normalizedPm2) {
return {
ok: false,
message: t("进程名不能为空", "PM2 name is required"),
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}
if (PRO_PM2_RESERVED.has(normalizedPm2)) {
return {
ok: false,
message: t("该进程名保留给系统模块,请换一个", "This PM2 name is reserved"),
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}
const duplicate = channels.find((row) => row.id !== active.id && resolveChannelPm2(row) === normalizedPm2);
if (duplicate) {
return {
ok: false,
message: t("该进程名已被其它线路占用", "Another lane already uses this PM2 name"),
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}
return {
ok: true,
message: "",
normalizedName,
normalizedPm2,
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
normalizedNotes: activeLaneDraft.notes.trim(),
};
}, [active, activeLaneDraft, channels, t]);
const activeLaneDirty = useMemo(() => {
if (!active || !activeLaneDraft) return false;
const savedDraft = laneDraftFromChannel(active);
return (
activeLaneDraft.name !== savedDraft.name ||
activeLaneDraft.streamKey !== savedDraft.streamKey ||
sanitizeYoutubePm2Name(activeLaneDraft.pm2Name) !== sanitizeYoutubePm2Name(savedDraft.pm2Name) ||
activeLaneDraft.notes !== savedDraft.notes
);
}, [active, activeLaneDraft]);
const setModePersist = useCallback(
(m: "single" | "pro") => {
if (m === "single" && mode === "pro" && active) {
const latestProUrlDraft = proUrlDraftRef.current;
if (latestProUrlDraft !== (active.urlLines ?? "").trim()) {
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: latestProUrlDraft } : x)));
}
}
if (m === "pro" && channels.length) {
const first = channels[0];
const latestSingleUrlDraft = singleUrlDraftRef.current.trim();
const youtubePm2Ok = staticYoutubeEntries.some((e) => e.pm2 === currentProc);
const pm2Name =
youtubePm2Ok && currentProc.trim()
? currentProc.trim()
: resolveChannelPm2(first);
const next: YoutubeProChannel[] = [
{
...first,
streamKey: ytFields.key.trim() || first.streamKey || "",
urlLines: latestSingleUrlDraft || first.urlLines || "",
pm2Name,
},
...channels.slice(1),
];
persistChannels(next);
const ch = next.find((c) => c.id === activeCh) ?? next[0];
applyProUrlDraft((ch?.urlLines ?? "").trim() || latestSingleUrlDraft);
}
setMode(m);
try {
window.localStorage.setItem(MODE_KEY, m);
} catch {
/* ignore */
}
},
[
mode,
active,
channels,
applyProUrlDraft,
ytFields.key,
currentProc,
staticYoutubeEntries,
activeCh,
persistChannels,
],
);
const selectProChannel = useCallback(
(c: YoutubeProChannel) => {
const latestProUrlDraft = proUrlDraftRef.current;
if (active && mode === "pro" && latestProUrlDraft !== (active.urlLines ?? "").trim()) {
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: latestProUrlDraft } : x)));
}
const u = (c.urlLines ?? "").trim();
applyProUrlDraft(u);
setActiveCh(c.id);
setCurrentProc(resolveChannelPm2(c));
},
[active, applyProUrlDraft, mode, channels, setCurrentProc, persistChannels],
);
useEffect(() => {
if (mode !== "pro" || !activeCh) return;
const ch = channels.find((x) => x.id === activeCh);
if (!ch) return;
const want = resolveChannelPm2(ch);
setCurrentProc(want);
}, [mode, activeCh, channels, setCurrentProc]);
const loadYoutubeIni = useCallback(async () => {
const targetProc = currentProcRef.current.trim();
if (!targetProc) return;
const requestId = ++ytIniRequestSeqRef.current;
setYtIniLoading(true);
setYtIniStatus(null);
try {
const params = new URLSearchParams({
process: targetProc,
_ts: String(Date.now()),
});
const data = await fetchJson<{ content?: string }>(`/get_config?${params}`);
if (requestId !== ytIniRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
const text = data.content ?? "";
setYoutubeIniText(text);
setYtFields(parseYoutubeIniFields(text));
} catch (e) {
if (requestId !== ytIniRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
setYtIniStatus((e as Error).message);
} finally {
if (requestId === ytIniRequestSeqRef.current && targetProc === currentProcRef.current.trim()) {
setYtIniLoading(false);
}
}
}, [fetchJson]);
useEffect(() => {
if (!currentProc) return;
void pullUrlConfigFromServer();
void loadYoutubeIni();
setActiveViewRefreshToken((prev) => prev + 1);
}, [activeCh, currentProc, loadYoutubeIni, mode, pullUrlConfigFromServer]);
const persistActiveLaneDraft = useCallback(
async () => {
if (!active || !activeLaneDraft) {
throw new Error(t("请选择线路", "Pick a lane"));
}
if (!activeLaneValidation.ok) {
throw new Error(activeLaneValidation.message);
}
const latestProUrlDraft = proUrlDraftRef.current;
const next = channels.map((row) =>
row.id === active.id
? {
...row,
name: activeLaneValidation.normalizedName,
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
urlLines: latestProUrlDraft,
}
: row,
);
applyChannelsLocal(next);
await syncChannelsRemote(next);
setLaneDrafts((prev) => ({
...prev,
[active.id]: {
name: activeLaneValidation.normalizedName,
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
},
}));
return {
next,
savedLane:
next.find((row) => row.id === active.id) ??
({
...active,
name: activeLaneValidation.normalizedName,
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
urlLines: latestProUrlDraft,
} satisfies YoutubeProChannel),
pm2: activeLaneValidation.normalizedPm2,
};
},
[
active,
activeLaneDraft,
activeLaneValidation,
applyChannelsLocal,
channels,
syncChannelsRemote,
t,
],
);
const saveProLaneSettings = useCallback(async () => {
if (!active) return;
setLaneSaving(true);
setLaneStatus(null);
try {
const saved = await persistActiveLaneDraft();
setCurrentProc(saved.pm2);
setLaneStatus(t("线路设置已保存", "Lane settings saved"));
} catch (e) {
const message = (e as Error).message;
setLaneStatus(message);
notify(message);
} finally {
setLaneSaving(false);
}
}, [active, notify, persistActiveLaneDraft, setCurrentProc, t]);
const saveYoutubeIniToServer = async (content: string, processPm2 = currentProcRef.current) => {
const targetPm2 = processPm2.trim();
if (!targetPm2) {
setYtIniStatus(t("请选择进程", "Pick a process"));
return;
}
setYtIniLoading(true);
setYtIniStatus(null);
try {
const params = new URLSearchParams({ process: targetPm2 });
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: managedYoutubeIniRelPathForPm2(currentProcRef.current),
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 = activeLaneDraft?.streamKey ?? active?.streamKey ?? "";
return parseYoutubeStreamKeySuffix(`key = ${k}`);
}, [mode, ytFields.key, active?.streamKey, activeLaneDraft?.streamKey]);
const saveProUrlAndServer = async () => {
if (!active) return;
try {
setLaneSaving(true);
setLaneStatus(null);
urlRequestSeqRef.current += 1;
const latestProUrlDraft = proUrlDraftRef.current;
const saved = await persistActiveLaneDraft();
setCurrentProc(saved.pm2);
await Promise.resolve(onSaveUrlConfig(latestProUrlDraft, saved.pm2));
urlListDirtyRef.current = false;
setLaneStatus(t("线路与地址已保存", "Lane settings and URLs saved"));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
setLaneStatus(message);
notify(message);
} finally {
setLaneSaving(false);
}
};
const saveSingleUrlAndClearDirty = async () => {
const targetPm2 = currentProcRef.current.trim();
if (!targetPm2) {
notify(t("请选择进程", "Pick a process"));
return;
}
try {
urlRequestSeqRef.current += 1;
await Promise.resolve(onSaveUrlConfig(singleUrlDraftRef.current, targetPm2));
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
}
};
const dedupeUrlDraft = async () => {
const source = mode === "single" ? singleUrlDraftRef.current : proUrlDraftRef.current;
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: managedUrlConfigRelPathForPm2(currentProcRef.current),
content: source,
action: "dedupe_urls",
}),
});
const next = data.content ?? source;
if (mode === "single") applySingleUrlDraft(next);
else {
applyProUrlDraft(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 || !activeLaneDraft) return;
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
const f: YoutubeIniFields = {
key: activeLaneDraft.streamKey.trim(),
rtmps: base.rtmps,
bitrate: base.bitrate,
fastAudio: base.fastAudio,
};
const body = buildYoutubeIniFromFields(f);
setLaneSaving(true);
setLaneStatus(null);
try {
const saved = await persistActiveLaneDraft();
setCurrentProc(saved.pm2);
await saveYoutubeIniToServer(body, saved.pm2);
setLaneStatus(t("线路设置与 youtube.ini 已保存", "Lane settings and youtube.ini saved"));
} catch (e) {
const message = (e as Error).message;
setLaneStatus(message);
notify(message);
} finally {
setLaneSaving(false);
}
};
const currentUrlDraft = mode === "single" ? urlConfig : proUrlDraft;
const currentUrlCount = useMemo(() => nonCommentUrlLinesOf(currentUrlDraft).length, [currentUrlDraft]);
const currentDouyinCount = useMemo(() => extractDouyinLiveUrls(currentUrlDraft).length, [currentUrlDraft]);
const laneQuickLinks = useMemo(
() =>
[
quickLinks?.neko_browser,
quickLinks?.chromium_remote_debug,
quickLinks?.srs_api,
].filter((value): value is string => !!value),
[quickLinks],
);
const flushProChannelsBeforeRun = useCallback(
async (targetPm2: string) => {
const next = channels;
const activePm2 = active ? resolveChannelPm2(active) : "";
if (active && targetPm2 === activePm2) {
const saved = await persistActiveLaneDraft();
return saved;
}
applyChannelsLocal(next);
await syncChannelsRemote(next);
return {
next,
savedLane: active ?? null,
pm2: targetPm2,
};
},
[active, applyChannelsLocal, channels, persistActiveLaneDraft, syncChannelsRemote],
);
const runProProcess = useCallback(
async (action: "start" | "stop" | "restart", targetPm2: string) => {
const targetMatchesActiveLane = !!active && resolveChannelPm2(active) === targetPm2;
if (action !== "stop") {
let next = channels;
let effectivePm2 = targetPm2;
try {
const saved = await flushProChannelsBeforeRun(targetPm2);
next = saved.next;
effectivePm2 = saved.pm2;
} catch (e) {
notify(
`${t("当前线路保存失败", "Failed to save the active lane")}: ${(e as Error).message}`,
);
return;
}
const target = next.find(
(row) => resolveChannelPm2(row) === effectivePm2,
);
const urlText = target?.urlLines ?? "";
if (!((target?.streamKey ?? "").trim())) {
notify(t("请先填写当前线路的推流密钥", "Fill the stream key before starting"));
return;
}
if (!nonCommentUrlLinesOf(urlText).length) {
notify(t("请先填写当前线路的直播地址", "Fill the source URLs before starting"));
return;
}
targetPm2 = effectivePm2;
}
if (targetMatchesActiveLane) {
setCurrentProc(targetPm2);
}
onRunProcess(action, targetPm2);
},
[active, channels, flushProChannelsBeforeRun, notify, onRunProcess, setCurrentProc, t],
);
const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing || laneSaving;
const singleRow = currentProcRow;
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-6xl space-y-6">
{onNavigate ? (
<LivePipelineStrip
t={t}
title={t("YouTube 转播链路", "YouTube relay chain")}
subtitle={t(
"与「网络」页相同风格的链路示意:代理 → 编码推流 → 浏览器侧操作工作室。",
"Same visual language as Network: proxy → encode → browser studio.",
)}
onNavigate={onNavigate}
steps={[
{
label: "ShellCrash",
sub: t("透明代理", "Proxy"),
gradient: "from-violet-500 to-fuchsia-500",
target: "network",
},
{
label: "FFmpeg",
sub: t("推流 / 转码", "Stream"),
gradient: "from-cyan-400 to-blue-500",
target: "live_youtube",
},
{
label: "Neko",
sub: t("Chromium 工作室", "Chromium"),
gradient: "from-emerald-400 to-teal-500",
openUrl: normalizeBrowserReachableHttpUrl(quickLinks?.neko_browser || "http://live.local:9200/"),
},
]}
/>
) : null}
<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(
"单路一条流多路时每条线路独立保存自己的密钥、URL 列表和 PM2 任务。填好后保存再点「开始」。Neko 浏览器仅作辅助工作室,不再阻塞推流。",
"Single lane or multi-lane: every lane keeps its own key, URL list, and PM2 task. Save, then Start. Neko is optional browser tooling and no longer blocks streaming.",
)}
</p>
</div>
</div>
<div
className={`flex rounded-xl border border-white/10 bg-black/30 p-1 ${
HIDE_YOUTUBE_MULTI_PRO_TOGGLE ? "hidden" : ""
}`}
aria-hidden={HIDE_YOUTUBE_MULTI_PRO_TOGGLE}
>
<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>
{laneQuickLinks.length ? (
<div className="flex flex-wrap gap-2">
{quickLinks?.neko_browser ? (
<a
href={normalizeBrowserReachableHttpUrl(quickLinks.neko_browser)}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100 ring-1 ring-emerald-500/20"
>
{t("打开 Neko 工作室", "Open Neko studio")}
</a>
) : null}
{quickLinks?.chromium_remote_debug ? (
<a
href={normalizeBrowserReachableHttpUrl(quickLinks.chromium_remote_debug)}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-cyan-500/20 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-100 ring-1 ring-cyan-500/20"
>
{t("打开 Chromium 调试", "Open Chromium debug")}
</a>
) : null}
{quickLinks?.srs_api ? (
<a
href={normalizeBrowserReachableHttpUrl(quickLinks.srs_api)}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-violet-500/20 bg-violet-500/10 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/20"
>
{t("打开 SRS API", "Open SRS API")}
</a>
) : null}
</div>
) : null}
<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) => {
const next = e.target.value;
currentProcRef.current = next;
setCurrentProc(next);
}}
>
{currentProc && !singleProcessOptions.some((entry) => entry.pm2 === currentProc) ? (
<option value={currentProc}>
{currentProc}
{currentProcRow ? ` (${businessStatusLabel(currentProcRow, t)})` : ""}
</option>
) : null}
{singleProcessOptions.map((e) => (
<option key={e.pm2} value={e.pm2}>
{singleModeProcessLabel(e, t)} ({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={false}
onRun={() => onRunProcess("start", currentProc)}
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={false}
onRun={() => onRunProcess("stop", currentProc)}
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={false}
onRun={() => onRunProcess("restart", currentProc)}
notify={notify}
t={t}
/>
</div>
<p className="mt-3 text-center text-xs text-zinc-400">
{t("当前状态", "Current state")}: {businessStatusLabel(singleRow, t)}
</p>
<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("对齐桌面端:每路独立 URL / youtube 配置文件;点左侧信息切换编辑,右侧控制进程。", "Per-lane config files like desktop; click row info to edit, use right-side controls for PM2.")}
</p>
<div className="mt-3 max-h-[min(24rem,55vh)] space-y-2 overflow-y-auto pr-1">
{channels.map((c) => {
const pm = resolveChannelPm2(c);
const row = rowForPm2(pm);
const st = statusForPm2(pm);
const laneUrls = nonCommentUrlLinesOf((c.urlLines ?? "").trim());
const douyinUrls = extractDouyinLiveUrls((c.urlLines ?? "").trim());
const dUrl = douyinUrls[0];
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
const rowOnline = pm2StatusOnline(st);
return (
<div
key={c.id}
className={`flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2.5 text-left text-xs transition sm:flex-nowrap ${
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={() => {
currentProcRef.current = resolveChannelPm2(c);
selectProChannel(c);
}}
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-zinc-100">{c.name}</span>
<span
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ring-1 ${
businessStatusBadgeClass(row)
}`}
>
{businessStatusLabel(row, t)}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[10px] text-zinc-500">
<span>
key {suf || "—"}
</span>
<span className="text-zinc-600">·</span>
<span className="truncate" title={pm}>
{pm}
</span>
<span className="text-zinc-600">·</span>
<span>
URL {laneUrls.length}
</span>
<span className="text-zinc-600">·</span>
<span>
DY {douyinUrls.length}
</span>
</div>
{dUrl ? (
<a
href={dUrl}
target="_blank"
rel="noreferrer"
className="mt-1 block max-w-full truncate text-cyan-300/90 underline-offset-2 hover:underline"
onClick={(e) => e.stopPropagation()}
title={dUrl}
>
{dUrl.replace(/^https?:\/\//, "")}
</a>
) : (
<span className="mt-1 block text-zinc-600"></span>
)}
</button>
<div
className="flex w-full shrink-0 flex-wrap items-center justify-end gap-1.5 sm:w-auto"
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={false}
onRun={() => void runProProcess("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={false}
onRun={() => void runProProcess("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={false}
onRun={() => void runProProcess("restart", pm)}
notify={notify}
t={t}
compact
/>
</div>
</div>
);
})}
</div>
</div>
</>
)}
</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">
<div>
<h3 className="text-sm font-semibold text-white">
{t("当前线路", "Current lane")}
{active ? (
<span className="ml-2 font-normal text-zinc-400">· {active.name}</span>
) : null}
{activeLaneDirty ? (
<span className="ml-2 rounded-md bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-100 ring-1 ring-amber-500/25">
{t("有未保存修改", "Unsaved")}
</span>
) : null}
</h3>
{active ? (
<p className="mt-0.5 font-mono text-[11px] text-zinc-500">
PM2: {activeSavedPm2}
{activePm2PreviewChanged ? `${activeDraftPm2}` : ""}
{" · "}
key {keySuffixUi || "—"}
</p>
) : null}
</div>
<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: "",
};
setLaneDrafts((prev) => ({
...prev,
[id]: laneDraftFromChannel(nc),
}));
persistChannels([...channels, nc]);
setActiveCh(id);
currentProcRef.current = resolveChannelPm2(nc);
setCurrentProc(currentProcRef.current);
applyProUrlDraft("");
}}
>
<Plus className="h-3.5 w-3.5" />
{t("新增线路", "Add lane")}
</button>
</div>
{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={activeLaneDraft?.name ?? ""}
onChange={(e) => setLaneDraftField("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={activeLaneDraft?.streamKey ?? ""}
onChange={(e) => setLaneDraftField("streamKey", e.target.value)}
placeholder="xxxx-xxxx… / rtmp(s)://…"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => void saveProLaneSettings()}
className="rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-100"
>
{laneSaving ? t("保存中...", "Saving...") : t("保存线路设置", "Save lane")}
</button>
<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("写入 youtube.ini", "Write youtube.ini")}
</button>
</div>
{!activeLaneValidation.ok ? (
<p className="text-xs text-rose-300">{activeLaneValidation.message}</p>
) : null}
<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={activeLaneDraft?.pm2Name ?? defaultPm2NameForChannel(active.id)}
onChange={(e) => setLaneDraftField("pm2Name", e.target.value)}
spellCheck={false}
placeholder="youtube2__…"
/>
<p className="mt-2 font-mono text-[11px] text-zinc-500">
{t("实际生效", "Effective")}: {activeDraftPm2 || "—"}
</p>
{activeLaneValidation.message && !activeLaneValidation.ok ? (
<p className="mt-2 text-[11px] text-rose-300">{activeLaneValidation.message}</p>
) : null}
</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={activeLaneDraft?.notes ?? ""}
onChange={(e) => setLaneDraftField("notes", e.target.value)}
placeholder={t("备注(可选)", "Notes (optional)")}
/>
{laneStatus ? <p className="text-xs text-zinc-400">{laneStatus}</p> : null}
<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);
setLaneDrafts((prev) => {
const clone = { ...prev };
delete clone[active.id];
return clone;
});
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")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t(
"每条线路独立写入自己的 URL_config.<pm2>.ini不再复用或回填所谓“全局地址”。",
"Each lane writes to its own URL_config.<pm2>.ini with no shared global URL draft.",
)}
</p>
<p className="mt-1 font-mono text-[10px] text-zinc-600">
{currentProc
? managedUrlConfigRelPathForPm2(currentProc)
: "URL_config.ini"}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-cyan-100">
{t("有效地址", "Valid URLs")}: {currentUrlCount}
</span>
<span className="rounded-full border border-violet-500/20 bg-violet-500/10 px-2.5 py-1 text-violet-100">
Douyin: {currentDouyinCount}
</span>
</div>
{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;
applyProUrlDraft(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={() => 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>
)}
{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("直播地址列表 · URL_config.ini", "URL_config.ini")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("一行一个地址;可自动刷新或手动重新读取。", "One URL per line; auto-refresh or reload.")}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-[11px]">
<span className="rounded-full border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-cyan-100">
{t("有效地址", "Valid URLs")}: {currentUrlCount}
</span>
<span className="rounded-full border border-violet-500/20 bg-violet-500/10 px-2.5 py-1 text-violet-100">
Douyin: {currentDouyinCount}
</span>
</div>
{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;
applySingleUrlDraft(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>
</div>
) : null}
<LiveProcessLiveLogCard
t={t}
currentProc={currentProc}
fetchJson={fetchJson}
urlListText={mode === "single" ? urlConfig : proUrlDraft}
keySuffixUi={keySuffixUi}
showKeyRow
pollMs={800}
refreshToken={activeViewRefreshToken}
/>
</div>
);
}