s
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 Neko’s 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 Neko’s 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)}
|
||||
/>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user