This commit is contained in:
root
2026-05-17 04:31:59 +00:00
parent 75a0ca4e31
commit 07aa02bca6
51 changed files with 1322 additions and 435 deletions

View File

@@ -702,7 +702,7 @@ export function LiveAndroidStreamConsole({
</div>
<div>
<h3 className="text-base font-semibold tracking-tight text-white">
{t("Live Hub · ADB 云控台", "Live Hub · ADB farm console")}
{t("无人直播系统 · ADB 云控台", "Unmanned Live System · ADB farm console")}
</h3>
<p className="mt-1 max-w-xl text-[11px] leading-relaxed text-zinc-500">
{t(

View File

@@ -24,7 +24,7 @@ import {
Wifi,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
import OverviewCharts from "@/components/OverviewCharts";
@@ -148,6 +148,12 @@ type HubDashboard = {
type SrsProbe = NonNullable<HubDashboard["probes"]>["srs"];
type ProcessEntry = { pm2: string; script: string; label: string };
type ProcessMonitorRow = ProcessEntry & {
process_status?: string;
business_status?: string;
business_note?: string;
is_pushing?: boolean;
};
function formatUptime(sec?: number) {
if (sec == null || !Number.isFinite(sec)) return "—";
@@ -208,6 +214,7 @@ export default function LiveControlApp() {
const [toast, setToast] = useState<string | null>(null);
const [entries, setEntries] = useState<ProcessEntry[]>([]);
const [processRows, setProcessRows] = useState<ProcessMonitorRow[]>([]);
const [currentProc, setCurrentProc] = useState("");
const [urlConfig, setUrlConfig] = useState("");
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
@@ -215,6 +222,8 @@ export default function LiveControlApp() {
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
const [showStreamProbes, setShowStreamProbes] = useState(true);
const dashboardInFlightRef = useRef<Promise<void> | null>(null);
const processMonitorInFlightRef = useRef<Promise<void> | null>(null);
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
@@ -231,22 +240,58 @@ export default function LiveControlApp() {
);
const refreshDashboard = useCallback(async () => {
try {
const d = await fetchJson<HubDashboard>("/hub/dashboard");
setDash(d);
setDashErr(null);
const devs = d.android?.devices || [];
if (devs.length && !androidSerial) {
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
setAndroidSerial((ready ?? devs[0]).serial);
if (dashboardInFlightRef.current) return dashboardInFlightRef.current;
const task = (async () => {
try {
const d = await fetchJson<HubDashboard>("/hub/dashboard");
setDash(d);
setDashErr(null);
const devs = d.android?.devices || [];
if (devs.length && !androidSerial) {
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
setAndroidSerial((ready ?? devs[0]).serial);
}
} catch (e) {
setDashErr((e as Error).message);
} finally {
setLoading(false);
dashboardInFlightRef.current = null;
}
} catch (e) {
setDashErr((e as Error).message);
} finally {
setLoading(false);
}
})();
dashboardInFlightRef.current = task;
return task;
}, [androidSerial, fetchJson]);
const processMonitorPath = useMemo(() => {
const params = new URLSearchParams({ light: "1" });
if (view === "live_youtube") params.set("family", "youtube");
else if (view === "live_tiktok") params.set("family", "tiktok");
return `/process_monitor?${params.toString()}`;
}, [view]);
const refreshProcessMonitor = useCallback(async () => {
if (processMonitorInFlightRef.current) return processMonitorInFlightRef.current;
const task = (async () => {
try {
const data = await fetchJson<{ entries?: ProcessMonitorRow[] }>(processMonitorPath);
const list = data.entries || [];
setProcessRows(list);
setEntries(list.map((row) => ({ pm2: row.pm2, script: row.script, label: row.label })));
setCurrentProc((prev) => {
if (!list.length) return prev;
if (prev && list.some((row) => row.pm2 === prev)) return prev;
return list[0].pm2;
});
} catch {
/* ignore */
} finally {
processMonitorInFlightRef.current = null;
}
})();
processMonitorInFlightRef.current = task;
return task;
}, [fetchJson, processMonitorPath]);
useEffect(() => {
const s = window.localStorage.getItem("live-hub-lang");
if (s === "zh" || s === "en") setLang(s);
@@ -278,23 +323,27 @@ export default function LiveControlApp() {
useEffect(() => {
void refreshDashboard();
if (!autoRefreshEnabled) return;
const id = window.setInterval(() => void refreshDashboard(), 10000);
return () => clearInterval(id);
}, [refreshDashboard, autoRefreshEnabled]);
}, [refreshDashboard]);
useEffect(() => {
void (async () => {
try {
const data = await fetchJson<{ entries?: ProcessEntry[] }>("/process_monitor");
const list = data.entries || [];
setEntries(list);
if (!currentProc && list[0]) setCurrentProc(list[0].pm2);
} catch {
/* ignore */
}
})();
}, [currentProc, fetchJson]);
void refreshProcessMonitor();
}, [refreshProcessMonitor]);
useEffect(() => {
if (!autoRefreshEnabled || view === "live_youtube" || view === "live_tiktok") return;
const id = window.setInterval(() => {
void refreshDashboard();
}, 10000);
return () => clearInterval(id);
}, [autoRefreshEnabled, refreshDashboard, view]);
useEffect(() => {
if (!autoRefreshEnabled) return;
const id = window.setInterval(() => {
void refreshProcessMonitor();
}, view === "live_youtube" || view === "live_tiktok" ? 700 : 4000);
return () => clearInterval(id);
}, [autoRefreshEnabled, refreshProcessMonitor, view]);
useEffect(() => {
if (!currentProc || view !== "live_youtube") return;
@@ -305,7 +354,7 @@ export default function LiveControlApp() {
);
setUrlConfig(data.content || "");
} catch {
setUrlConfig("");
/* 保留当前草稿,避免临时读取失败把编辑器清空 */
}
})();
}, [currentProc, fetchJson, view]);
@@ -368,7 +417,12 @@ export default function LiveControlApp() {
body: JSON.stringify({ process: proc }),
});
notify(String(data.output || data.message || "OK"));
await refreshDashboard();
await refreshProcessMonitor();
if (view === "live_youtube" || view === "live_tiktok") {
void refreshDashboard();
} else {
await refreshDashboard();
}
} catch (e) {
notify((e as Error).message);
} finally {
@@ -376,12 +430,13 @@ export default function LiveControlApp() {
}
};
const saveUrlConfig = async (overrideContent?: string) => {
if (!currentProc) return;
const saveUrlConfig = async (overrideContent?: string, processOverride?: string) => {
const proc = (processOverride ?? currentProc).trim();
if (!proc) return;
const content = overrideContent ?? urlConfig;
setBusy("save:url");
try {
const params = new URLSearchParams({ process: currentProc });
const params = new URLSearchParams({ process: proc });
await fetchJson(`/save_url_config?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -485,10 +540,10 @@ export default function LiveControlApp() {
</div>
<div>
<p className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${portalTheme === "light" ? "text-slate-500" : "text-zinc-500"}`}>
Live Hub
live.local
</p>
<p className={`text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}>
{t("超级业务中心", "Control Center")}
{t("无人直播系统", "Unmanned Live System")}
</p>
</div>
</div>
@@ -606,7 +661,7 @@ export default function LiveControlApp() {
<button
type="button"
disabled={!!busy}
onClick={() => void refreshDashboard()}
onClick={() => void Promise.all([refreshDashboard(), refreshProcessMonitor()])}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-medium transition disabled:opacity-40 ${
portalTheme === "light"
? "border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
@@ -866,8 +921,8 @@ export default function LiveControlApp() {
</div>
<p className="mt-3 text-[10px] text-zinc-600">
{t(
"x86 默认使用 Neko 的 Chrome 镜像ARM 默认使用 Chromium 镜像;本栈未启用 Firefox 版 Neko。",
"x86 defaults to Nekos Chrome image, while ARM defaults to the Chromium image. This stack does not use the Firefox image.",
"x86 默认使用 Neko 的 Chrome 镜像ARM 默认使用 Chromium 镜像;默认保留管理员会话、开发者工具和 Tampermonkey 持久化。本栈未启用 Firefox 版 Neko。",
"x86 defaults to Nekos Chrome image, ARM defaults to Chromium, and admin access, DevTools, plus persistent Tampermonkey stay enabled. This stack does not use Firefox Neko.",
)}
</p>
</div>
@@ -1142,9 +1197,9 @@ export default function LiveControlApp() {
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("鍗曠綉鍙f棤闇€缃戝叧", "Not needed on single NIC")
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("褰撳墠涓嶅彲鎿嶄綔", "Unavailable")
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => void runService(s.service_id, "install")}
@@ -1172,9 +1227,9 @@ export default function LiveControlApp() {
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("鍗曠綉鍙f棤闇€缃戝叧", "Not needed on single NIC")
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("褰撳墠涓嶅彲鎿嶄綔", "Unavailable")
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => void runService(s.service_id, "upgrade")}
@@ -1202,9 +1257,9 @@ export default function LiveControlApp() {
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("鍗曠綉鍙f棤闇€缃戝叧", "Not needed on single NIC")
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("褰撳墠涓嶅彲鎿嶄綔", "Unavailable")
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => void runService(s.service_id, "uninstall")}
@@ -1231,9 +1286,9 @@ export default function LiveControlApp() {
urlConfig={urlConfig}
setUrlConfig={setUrlConfig}
onRunProcess={(a, pm) => void runProcess(a, pm)}
onSaveUrlConfig={(c) => saveUrlConfig(c)}
onSaveUrlConfig={(c, pm) => saveUrlConfig(c, pm)}
fetchJson={fetchJson}
liveProcesses={dash?.live?.processes ?? []}
liveProcesses={processRows.length ? processRows : (dash?.live?.processes ?? [])}
notify={notify}
onNavigate={(target: PortalNavTarget) => setView(target)}
/>

View File

@@ -89,6 +89,8 @@ export function LiveProcessLiveLogCard({
const outRef = useRef<HTMLPreElement>(null);
const errRef = useRef<HTMLPreElement>(null);
const prevOnlineRef = useRef(false);
const pullInFlightRef = useRef(false);
const pullQueuedRef = useRef(false);
const douyinHints = extractDouyinRoomHints(urlListText);
const onlineNow = /^(online|running)$/i.test(processStatus);
@@ -99,6 +101,11 @@ export function LiveProcessLiveLogCard({
const pull = useCallback(async () => {
if (!currentProc) return;
if (pullInFlightRef.current) {
pullQueuedRef.current = true;
return;
}
pullInFlightRef.current = true;
try {
const data = await fetchJson<StatusPayload>(
`/status?process=${encodeURIComponent(currentProc)}`,
@@ -125,6 +132,12 @@ export function LiveProcessLiveLogCard({
setBusinessNote("");
setFfmpegPids([]);
setLogAgeSeconds(null);
} finally {
pullInFlightRef.current = false;
if (pullQueuedRef.current) {
pullQueuedRef.current = false;
window.setTimeout(() => void pull(), 0);
}
}
}, [currentProc, fetchJson]);

View File

@@ -34,6 +34,95 @@ 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 (channel.pm2Name ?? "").trim() || defaultPm2NameForChannel(channel.id);
}
const MODE_KEY = "live-hub-youtube-mode-v1";
/** UI 暂时隐藏「多频道 Pro」切换DOM 与逻辑保留,改 false 即可恢复) */
@@ -41,6 +130,8 @@ const HIDE_YOUTUBE_MULTI_PRO_TOGGLE = false;
type LiveProcRow = {
pm2: string;
script?: string;
label?: string;
process_status?: string;
business_status?: string;
business_note?: string;
@@ -123,7 +214,7 @@ function LiveStreamActionBtn({
const actionForBusy = busyKind ?? kind;
const loading = !!(forThisProc && pb.action === actionForBusy);
const siblingBusy = !!(forThisProc && pb.action !== actionForBusy);
const blockStart = false;
const blockStart = kind === "start" && online;
const blockStop = kind === "stop" && !online;
const blockRestart = kind === "restart" && !online;
const blocked = blockStart || blockStop || blockRestart;
@@ -185,7 +276,7 @@ type Props = {
urlConfig: string;
setUrlConfig: (v: string) => void;
onRunProcess: (a: "start" | "stop" | "restart", processPm2?: string) => void;
onSaveUrlConfig: (content?: string) => void | Promise<void>;
onSaveUrlConfig: (content?: string, processPm2?: string) => void | Promise<void>;
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
liveProcesses: LiveProcRow[];
notify: (msg: string) => void;
@@ -208,7 +299,15 @@ export function LiveYoutubeUnmannedView({
onNavigate,
}: Props) {
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
const processOptions = ytEntries.length ? ytEntries : 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[]>([]);
@@ -240,6 +339,7 @@ export function LiveYoutubeUnmannedView({
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
const c = data.content ?? "";
if (urlListDirtyRef.current) return;
if (mode === "single") setUrlConfig(c);
else {
setProUrlDraft(c);
@@ -247,7 +347,6 @@ export function LiveYoutubeUnmannedView({
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
return next;
});
}
@@ -276,6 +375,10 @@ export function LiveYoutubeUnmannedView({
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
const c = data.content ?? "";
if (urlListDirtyRef.current) {
setUrlFetchNote(t("检测到本地未保存修改,已保留当前草稿", "Kept the local unsaved draft"));
return;
}
if (mode === "single") setUrlConfig(c);
else {
setProUrlDraft(c);
@@ -283,11 +386,6 @@ export function LiveYoutubeUnmannedView({
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
void pushYoutubeProChannelsRemote(fetchJson, next).catch((e) => {
notify(
`${t("", "Channel list not saved on server")}: ${(e as Error).message}`,
);
});
return next;
});
}
@@ -317,10 +415,10 @@ export function LiveYoutubeUnmannedView({
let cancelled = false;
void (async () => {
try {
const fromServer = await fetchYoutubeProChannelsRemote(fetchJson);
if (fromServer.length && !cancelled) {
setChannels(fromServer);
saveYoutubeProChannels(fromServer);
const remote = await fetchYoutubeProChannelsRemote(fetchJson);
if (remote.fetched && !cancelled) {
setChannels(remote.channels);
saveYoutubeProChannels(remote.channels);
return;
}
} catch {
@@ -356,6 +454,32 @@ export function LiveYoutubeUnmannedView({
if (channels.length && !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],
);
useEffect(() => {
if (mode !== "single" || !preferredSingleProc) return;
if (!singleProcessOptions.some((entry) => entry.pm2 === currentProc)) {
setCurrentProc(preferredSingleProc);
return;
}
const currentEntry = singleProcessOptions.find((entry) => entry.pm2 === currentProc);
const currentRow = rowForPm2(currentProc);
const preferredEntry = singleProcessOptions.find((entry) => entry.pm2 === preferredSingleProc);
const preferredRow = rowForPm2(preferredSingleProc);
if (
currentProc !== preferredSingleProc &&
currentEntry &&
preferredEntry &&
singleProcessPriority(preferredEntry, preferredRow) < singleProcessPriority(currentEntry, currentRow)
) {
setCurrentProc(preferredSingleProc);
}
}, [currentProc, mode, preferredSingleProc, rowForPm2, setCurrentProc, singleProcessOptions]);
const persistChannels = useCallback(
(next: YoutubeProChannel[]) => {
setChannels(next);
@@ -370,6 +494,7 @@ export function LiveYoutubeUnmannedView({
);
const active = channels.find((c) => c.id === activeCh) || channels[0];
const activeSavedUrlDraft = useMemo(() => (active?.urlLines ?? "").trim(), [active?.urlLines]);
const setModePersist = useCallback(
(m: "single" | "pro") => {
@@ -378,11 +503,11 @@ export function LiveYoutubeUnmannedView({
}
if (m === "pro" && channels.length) {
const first = channels[0];
const youtubePm2Ok = ytEntries.some((e) => e.pm2 === currentProc);
const youtubePm2Ok = staticYoutubeEntries.some((e) => e.pm2 === currentProc);
const pm2Name =
youtubePm2Ok && currentProc.trim()
? currentProc.trim()
: (first.pm2Name ?? "").trim() || defaultPm2NameForChannel(first.id);
: resolveChannelPm2(first);
const next: YoutubeProChannel[] = [
{
...first,
@@ -411,7 +536,7 @@ export function LiveYoutubeUnmannedView({
ytFields.key,
urlConfig,
currentProc,
ytEntries,
staticYoutubeEntries,
activeCh,
persistChannels,
],
@@ -423,46 +548,49 @@ export function LiveYoutubeUnmannedView({
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)));
}
const u = (c.urlLines ?? "").trim();
setProUrlDraft(u || urlConfig);
setProUrlDraft(u);
setActiveCh(c.id);
setCurrentProc(resolveChannelPm2(c));
},
[active, mode, channels, proUrlDraft, urlConfig, persistChannels],
[active, mode, channels, proUrlDraft, setCurrentProc, persistChannels],
);
useEffect(() => {
if (mode !== "pro" || !activeCh) return;
const ch = channels.find((x) => x.id === activeCh);
if (!ch) return;
const want = (ch.pm2Name ?? "").trim() || defaultPm2NameForChannel(ch.id);
const want = resolveChannelPm2(ch);
setCurrentProc(want);
}, [mode, activeCh, channels, setCurrentProc]);
useEffect(() => {
if (mode !== "pro" || !currentProc || !activeCh) return;
let cancelled = false;
const targetChannelId = activeCh;
void (async () => {
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
if (cancelled) return;
if (cancelled || urlListDirtyRef.current) return;
const c = data.content ?? "";
setProUrlDraft(c);
urlListDirtyRef.current = false;
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
const next = prev.map((x) => (x.id === targetChannelId ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
void pushYoutubeProChannelsRemote(fetchJson, next).catch(() => {});
return next;
});
} catch {
if (!cancelled) setProUrlDraft("");
if (!cancelled && !urlListDirtyRef.current) {
setProUrlDraft(activeSavedUrlDraft);
}
}
})();
return () => {
cancelled = true;
};
}, [mode, currentProc, activeCh, fetchJson]);
}, [activeCh, activeSavedUrlDraft, currentProc, fetchJson, mode]);
const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return;
@@ -492,15 +620,16 @@ export function LiveYoutubeUnmannedView({
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
};
const saveYoutubeIniToServer = async (content: string) => {
if (!currentProc) {
const saveYoutubeIniToServer = async (content: string, processPm2 = currentProc) => {
const targetPm2 = processPm2.trim();
if (!targetPm2) {
setYtIniStatus(t("请选择进程", "Pick a process"));
return;
}
setYtIniLoading(true);
setYtIniStatus(null);
try {
const params = new URLSearchParams({ process: currentProc });
const params = new URLSearchParams({ process: targetPm2 });
await fetchJson(`/save_config?${params}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -560,9 +689,8 @@ export function LiveYoutubeUnmannedView({
const saveProUrlAndServer = async () => {
if (!active) return;
updateActive({ urlLines: proUrlDraft });
setUrlConfig(proUrlDraft);
try {
await Promise.resolve(onSaveUrlConfig(proUrlDraft));
await Promise.resolve(onSaveUrlConfig(proUrlDraft, resolveChannelPm2(active)));
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
@@ -618,7 +746,7 @@ export function LiveYoutubeUnmannedView({
fastAudio: base.fastAudio,
};
const body = buildYoutubeIniFromFields(f);
await saveYoutubeIniToServer(body);
await saveYoutubeIniToServer(body, resolveChannelPm2(active));
updateActive({ streamKey: f.key });
};
@@ -634,7 +762,7 @@ export function LiveYoutubeUnmannedView({
const flushProChannelsBeforeRun = useCallback(
async (targetPm2: string) => {
let next = channels;
const activePm2 = active ? (active.pm2Name ?? "").trim() || defaultPm2NameForChannel(active.id) : "";
const activePm2 = active ? resolveChannelPm2(active) : "";
if (active && targetPm2 === activePm2) {
next = channels.map((row) => (row.id === active.id ? { ...row, urlLines: proUrlDraft } : row));
}
@@ -659,9 +787,9 @@ export function LiveYoutubeUnmannedView({
return;
}
const target = next.find(
(row) => ((row.pm2Name ?? "").trim() || defaultPm2NameForChannel(row.id)) === targetPm2,
(row) => resolveChannelPm2(row) === targetPm2,
);
const activePm2 = active ? (active.pm2Name ?? "").trim() || defaultPm2NameForChannel(active.id) : "";
const activePm2 = active ? resolveChannelPm2(active) : "";
const urlText = targetPm2 === activePm2 ? proUrlDraft : target?.urlLines ?? "";
if (!((target?.streamKey ?? "").trim())) {
notify(t("请先填写当前线路的推流密钥", "Fill the stream key before starting"));
@@ -671,38 +799,17 @@ export function LiveYoutubeUnmannedView({
notify(t("请先填写当前线路的直播地址", "Fill the source URLs before starting"));
return;
}
try {
const svc = await fetchJson<{ status?: string; message?: string; detail?: string }>("/service_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: "neko", action: "start" }),
});
if (svc.status === "error") {
notify(svc.detail || svc.message || t("Neko ", "Neko start failed"));
return;
}
} catch (e) {
notify(`${t("Neko ", "Neko start failed")}: ${(e as Error).message}`);
return;
}
}
setCurrentProc(targetPm2);
onRunProcess(action, targetPm2);
},
[active, channels, fetchJson, flushProChannelsBeforeRun, nonCommentUrlLines, notify, onRunProcess, proUrlDraft, setCurrentProc, t],
[active, channels, flushProChannelsBeforeRun, nonCommentUrlLines, notify, onRunProcess, proUrlDraft, setCurrentProc, t],
);
const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing;
const rowForPm2 = useCallback((pm2: string) => liveProcesses.find((p) => p.pm2 === pm2), [liveProcesses]);
const statusForPm2 = useCallback(
(pm2: string) => rowForPm2(pm2)?.process_status,
[rowForPm2],
);
const singleRow = rowForPm2(currentProc);
const singleOnline = pm2StatusOnline(statusForPm2(currentProc));
const singleStartAction: "start" | "restart" = singleOnline ? "restart" : "start";
const urlToolbar = (
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-zinc-400">
@@ -779,8 +886,8 @@ export function LiveYoutubeUnmannedView({
</h2>
<p className="mt-0.5 text-xs text-zinc-500">
{t(
"单路一条流;多路时每条线路一把密钥、一个进程名,填好密钥和直播地址后保存,再点「开始」。无需 Google 登录。",
"Single lane or multi-lane: one key and one process per lane — fill key & URLs, save, then Start. No Google login.",
"单路一条流;多路时每条线路独立保存自己的密钥、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>
@@ -829,23 +936,22 @@ export function LiveYoutubeUnmannedView({
value={currentProc}
onChange={(e) => setCurrentProc(e.target.value)}
>
{processOptions.map((e) => (
{singleProcessOptions.map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} ({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"
busyKind={singleStartAction}
targetPm2={currentProc}
busy={busy}
online={singleOnline}
label={t("开始直播", "Start")}
className="bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess(singleStartAction)}
baseLock={false}
onRun={() => onRunProcess("start", currentProc)}
notify={notify}
t={t}
/>
@@ -856,8 +962,8 @@ export function LiveYoutubeUnmannedView({
online={singleOnline}
label={t("停止直播", "Stop")}
className="bg-rose-500/15 text-rose-200 ring-rose-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("stop")}
baseLock={false}
onRun={() => onRunProcess("stop", currentProc)}
notify={notify}
t={t}
/>
@@ -868,8 +974,8 @@ export function LiveYoutubeUnmannedView({
online={singleOnline}
label={t("重新开始", "Restart")}
className="bg-amber-500/15 text-amber-200 ring-amber-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("restart")}
baseLock={false}
onRun={() => onRunProcess("restart", currentProc)}
notify={notify}
t={t}
/>
@@ -892,11 +998,9 @@ export function LiveYoutubeUnmannedView({
</p>
<div className="mt-3 max-h-[min(24rem,55vh)] space-y-2 overflow-y-auto pr-1">
{channels.map((c) => {
const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id);
const pm = resolveChannelPm2(c);
const row = rowForPm2(pm);
const st = statusForPm2(pm);
const on = pm2StatusOnline(st);
const startAction: "start" | "restart" = on ? "restart" : "start";
const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0];
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
@@ -954,14 +1058,13 @@ export function LiveYoutubeUnmannedView({
>
<LiveStreamActionBtn
kind="start"
busyKind={startAction}
targetPm2={pm}
busy={busy}
online={rowOnline}
label={t("开始", "Start")}
className="rounded-lg bg-emerald-500/20 text-emerald-100 ring-emerald-500/30"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => void runProProcess(startAction, pm)}
baseLock={false}
onRun={() => void runProProcess("start", pm)}
notify={notify}
t={t}
compact
@@ -973,7 +1076,7 @@ export function LiveYoutubeUnmannedView({
online={rowOnline}
label={t("停止", "Stop")}
className="rounded-lg bg-rose-500/15 text-rose-100 ring-rose-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
baseLock={false}
onRun={() => void runProProcess("stop", pm)}
notify={notify}
t={t}
@@ -986,7 +1089,7 @@ export function LiveYoutubeUnmannedView({
online={rowOnline}
label={t("重新开始", "Restart")}
className="rounded-lg bg-amber-500/15 text-amber-100 ring-amber-500/25"
baseLock={ytIniLoading || urlReloading || urlOptimizing}
baseLock={false}
onRun={() => void runProProcess("restart", pm)}
notify={notify}
t={t}
@@ -1142,6 +1245,7 @@ export function LiveYoutubeUnmannedView({
};
persistChannels([...channels, nc]);
setActiveCh(id);
setCurrentProc(resolveChannelPm2(nc));
setProUrlDraft("");
}}
>
@@ -1211,6 +1315,12 @@ export function LiveYoutubeUnmannedView({
<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)
@@ -1229,18 +1339,6 @@ export function LiveYoutubeUnmannedView({
placeholder="https://live.douyin.com/..."
/>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={lock}
onClick={() => {
urlListDirtyRef.current = true;
setProUrlDraft(urlConfig);
setYtIniStatus(t("已从服务器草稿填充", "Loaded from server draft"));
}}
className="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300"
>
{t("填入全局地址", "Use global URLs")}
</button>
<button
type="button"
disabled={lock}
@@ -1289,6 +1387,7 @@ export function LiveYoutubeUnmannedView({
urlListText={mode === "single" ? urlConfig : proUrlDraft}
keySuffixUi={keySuffixUi}
showKeyRow
pollMs={800}
/>
</div>
);