1803 lines
69 KiB
TypeScript
1803 lines
69 KiB
TypeScript
"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>
|
||
);
|
||
}
|