This commit is contained in:
root
2026-05-17 07:16:36 +00:00
parent 71ea4da318
commit a10ffe332d
17 changed files with 178 additions and 99 deletions

View File

@@ -224,6 +224,8 @@ export default function LiveControlApp() {
const [showStreamProbes, setShowStreamProbes] = useState(true);
const dashboardInFlightRef = useRef<Promise<void> | null>(null);
const processMonitorInFlightRef = useRef<Promise<void> | null>(null);
const currentProcRef = useRef(currentProc);
const urlConfigRequestSeqRef = useRef(0);
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
@@ -345,13 +347,20 @@ export default function LiveControlApp() {
return () => clearInterval(id);
}, [autoRefreshEnabled, refreshProcessMonitor, view]);
useEffect(() => {
currentProcRef.current = currentProc;
}, [currentProc]);
useEffect(() => {
if (!currentProc || view !== "live_youtube") return;
void (async () => {
const targetProc = currentProcRef.current.trim();
const requestId = ++urlConfigRequestSeqRef.current;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
`/get_url_config?process=${encodeURIComponent(targetProc)}`,
);
if (requestId !== urlConfigRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
setUrlConfig(data.content || "");
} catch {
/* 保留当前草稿,避免临时读取失败把编辑器清空 */

View File

@@ -51,6 +51,7 @@ type Props = {
keySuffixUi: string;
showKeyRow?: boolean;
pollMs?: number;
refreshToken?: number;
};
function businessLine(t: TFn, raw: string | undefined, note: string | undefined): string {
@@ -75,6 +76,7 @@ export function LiveProcessLiveLogCard({
keySuffixUi,
showKeyRow = true,
pollMs = 1000,
refreshToken = 0,
}: Props) {
const [processStatus, setProcessStatus] = useState<string>("");
const [recentLog, setRecentLog] = useState("");
@@ -91,6 +93,8 @@ export function LiveProcessLiveLogCard({
const prevOnlineRef = useRef(false);
const pullInFlightRef = useRef(false);
const pullQueuedRef = useRef(false);
const currentProcRef = useRef(currentProc);
const requestSeqRef = useRef(0);
const douyinHints = extractDouyinRoomHints(urlListText);
const onlineNow = /^(online|running)$/i.test(processStatus);
@@ -100,16 +104,19 @@ export function LiveProcessLiveLogCard({
const primaryUrl = primaryRoomId ? `https://live.douyin.com/${primaryRoomId}` : fallbackUrl || null;
const pull = useCallback(async () => {
if (!currentProc) return;
const targetProc = currentProcRef.current.trim();
if (!targetProc) return;
if (pullInFlightRef.current) {
pullQueuedRef.current = true;
return;
}
pullInFlightRef.current = true;
const requestId = ++requestSeqRef.current;
try {
const data = await fetchJson<StatusPayload>(
`/status?process=${encodeURIComponent(currentProc)}`,
`/status?process=${encodeURIComponent(targetProc)}`,
);
if (requestId !== requestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
const ps = data.process_status || "unknown";
setProcessStatus(ps);
setRecentLog(data.recent_log || "");
@@ -127,6 +134,7 @@ export function LiveProcessLiveLogCard({
}
prevOnlineRef.current = online;
} catch {
if (requestId !== requestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
setProcessStatus("error");
setBusinessStatus("error");
setBusinessNote("");
@@ -139,11 +147,40 @@ export function LiveProcessLiveLogCard({
window.setTimeout(() => void pull(), 0);
}
}
}, [currentProc, fetchJson]);
}, [fetchJson]);
useEffect(() => {
currentProcRef.current = currentProc;
requestSeqRef.current += 1;
pullQueuedRef.current = false;
if (!currentProc) {
setProcessStatus("");
setRecentLog("");
setRecentError("");
setBusinessStatus("");
setBusinessNote("");
setFfmpegPids([]);
setLogAgeSeconds(null);
setLiveSince(null);
prevOnlineRef.current = false;
return;
}
setProcessStatus("");
setRecentLog("");
setRecentError("");
setBusinessStatus("");
setBusinessNote("");
setFfmpegPids([]);
setLogAgeSeconds(null);
setLiveSince(null);
prevOnlineRef.current = false;
void pull();
}, [pull]);
}, [currentProc, pull]);
useEffect(() => {
if (!currentProc) return;
void pull();
}, [currentProc, pull, refreshToken]);
useEffect(() => {
if (paused || !currentProc) return;
@@ -190,6 +227,9 @@ export function LiveProcessLiveLogCard({
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">{t("当前直播日志", "Live process log")}</h3>
<span className="rounded-full bg-white/10 px-2 py-0.5 font-mono text-[10px] text-zinc-300 ring-1 ring-white/10">
{currentProc || "—"}
</span>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-200/90 ring-1 ring-emerald-500/25">
{paused ? t("已暂停刷新", "Paused") : t("每秒刷新", "~1s refresh")}
</span>

View File

@@ -120,9 +120,18 @@ export function LiveTiktokHdmiView({
const [urlAutoRefresh, setUrlAutoRefresh] = useState(true);
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
const urlListDirtyRef = useRef(false);
const urlDraftRef = useRef(urlConfig);
const [hdmiNotes, setHdmiNotes] = useState("");
const [checks, setChecks] = useState<Record<string, boolean>>({});
const applyUrlDraft = useCallback(
(next: string) => {
urlDraftRef.current = next;
setUrlConfig(next);
},
[setUrlConfig],
);
const setSourceModePersist = useCallback(
(m: TiktokSourceMode) => {
setSourceMode(m);
@@ -151,17 +160,21 @@ export function LiveTiktokHdmiView({
urlListDirtyRef.current = false;
}, [currentProc]);
useEffect(() => {
urlDraftRef.current = urlConfig;
}, [urlConfig]);
const pullUrlConfigFromServer = useCallback(async () => {
if (!currentProc) return;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
setUrlConfig(data.content ?? "");
applyUrlDraft(data.content ?? "");
} catch {
/* ignore */
}
}, [currentProc, fetchJson, setUrlConfig]);
}, [applyUrlDraft, currentProc, fetchJson]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh || sourceMode !== "ffmpeg") return;
@@ -239,7 +252,7 @@ export function LiveTiktokHdmiView({
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
setUrlConfig(data.content ?? "");
applyUrlDraft(data.content ?? "");
setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server"));
} catch (e) {
setUrlFetchNote((e as Error).message);
@@ -250,7 +263,7 @@ export function LiveTiktokHdmiView({
const saveUrlAndClearDirty = async () => {
try {
await Promise.resolve(onSaveUrlConfig());
await Promise.resolve(onSaveUrlConfig(urlDraftRef.current));
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
@@ -745,7 +758,7 @@ export function LiveTiktokHdmiView({
value={urlConfig}
onChange={(e) => {
urlListDirtyRef.current = true;
setUrlConfig(e.target.value);
applyUrlDraft(e.target.value);
}}
spellCheck={false}
/>

View File

@@ -362,28 +362,62 @@ export function LiveYoutubeUnmannedView({
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 () => {
if (!currentProc) return;
const targetProc = currentProcRef.current.trim();
if (!targetProc) return;
const requestId = ++urlRequestSeqRef.current;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
`/get_url_config?process=${encodeURIComponent(targetProc)}`,
);
if (requestId !== urlRequestSeqRef.current || targetProc !== currentProcRef.current.trim()) return;
const c = data.content ?? "";
if (urlListDirtyRef.current) return;
if (mode === "single") setUrlConfig(c);
if (mode === "single") applySingleUrlDraft(c);
else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
setProUrlDraft(localDraft);
applyProUrlDraft(localDraft);
return;
}
setProUrlDraft(c);
applyProUrlDraft(c);
if (activeCh) {
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
@@ -395,7 +429,7 @@ export function LiveYoutubeUnmannedView({
} catch {
/* ignore */
}
}, [activeCh, channels, currentProc, fetchJson, mode, setUrlConfig]);
}, [activeCh, applyProUrlDraft, applySingleUrlDraft, channels, fetchJson, mode]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
@@ -407,28 +441,31 @@ export function LiveYoutubeUnmannedView({
}, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]);
const reloadUrlManual = async () => {
if (!currentProc) return;
const targetProc = currentProcRef.current.trim();
if (!targetProc) return;
setUrlReloading(true);
setUrlFetchNote(null);
urlListDirtyRef.current = false;
const requestId = ++urlRequestSeqRef.current;
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
`/get_url_config?process=${encodeURIComponent(targetProc)}`,
);
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") setUrlConfig(c);
if (mode === "single") applySingleUrlDraft(c);
else {
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
setProUrlDraft(localDraft);
applyProUrlDraft(localDraft);
setUrlFetchNote(t("服务器暂无已保存 URL已保留本地草稿", "Server has no saved URLs, kept the local draft"));
return;
}
setProUrlDraft(c);
applyProUrlDraft(c);
if (activeCh) {
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
@@ -439,9 +476,12 @@ export function LiveYoutubeUnmannedView({
}
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 {
setUrlReloading(false);
if (requestId === urlRequestSeqRef.current && targetProc === currentProcRef.current.trim()) {
setUrlReloading(false);
}
}
};
@@ -552,11 +592,6 @@ export function LiveYoutubeUnmannedView({
);
const active = channels.find((c) => c.id === activeCh) || channels[0];
const activeSavedUrlDraft = useMemo(() => (active?.urlLines ?? "").trim(), [active?.urlLines]);
const activeUrlDirty = useMemo(
() => (active ? proUrlDraft !== (active.urlLines ?? "").trim() : false),
[active, proUrlDraft],
);
const activeLaneDraft = useMemo(
() => (active ? laneDrafts[active.id] ?? laneDraftFromChannel(active) : null),
[active, laneDrafts],
@@ -663,12 +698,14 @@ export function LiveYoutubeUnmannedView({
const setModePersist = useCallback(
(m: "single" | "pro") => {
if (m === "single" && mode === "pro" && active) {
if (activeUrlDirty) {
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)));
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()
@@ -678,14 +715,14 @@ export function LiveYoutubeUnmannedView({
{
...first,
streamKey: ytFields.key.trim() || first.streamKey || "",
urlLines: urlConfig.trim() || first.urlLines || "",
urlLines: latestSingleUrlDraft || first.urlLines || "",
pm2Name,
},
...channels.slice(1),
];
persistChannels(next);
const ch = next.find((c) => c.id === activeCh) ?? next[0];
setProUrlDraft((ch?.urlLines ?? "").trim() || urlConfig.trim());
applyProUrlDraft((ch?.urlLines ?? "").trim() || latestSingleUrlDraft);
}
setMode(m);
try {
@@ -697,11 +734,9 @@ export function LiveYoutubeUnmannedView({
[
mode,
active,
activeUrlDirty,
channels,
proUrlDraft,
applyProUrlDraft,
ytFields.key,
urlConfig,
currentProc,
staticYoutubeEntries,
activeCh,
@@ -711,15 +746,16 @@ export function LiveYoutubeUnmannedView({
const selectProChannel = useCallback(
(c: YoutubeProChannel) => {
if (active && mode === "pro" && activeUrlDirty) {
persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x)));
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();
setProUrlDraft(u);
applyProUrlDraft(u);
setActiveCh(c.id);
setCurrentProc(resolveChannelPm2(c));
},
[active, activeUrlDirty, mode, channels, proUrlDraft, setCurrentProc, persistChannels],
[active, applyProUrlDraft, mode, channels, setCurrentProc, persistChannels],
);
useEffect(() => {
@@ -730,62 +766,36 @@ export function LiveYoutubeUnmannedView({
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 || urlListDirtyRef.current) return;
const c = data.content ?? "";
const localDraft = channels.find((row) => row.id === targetChannelId)?.urlLines ?? "";
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
setProUrlDraft(localDraft);
return;
}
setProUrlDraft(c);
urlListDirtyRef.current = false;
setChannels((prev) => {
const next = prev.map((x) => (x.id === targetChannelId ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
return next;
});
} catch {
if (!cancelled && !urlListDirtyRef.current) {
setProUrlDraft(activeSavedUrlDraft);
}
}
})();
return () => {
cancelled = true;
};
}, [activeCh, activeSavedUrlDraft, channels, currentProc, fetchJson, mode]);
const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return;
const targetProc = currentProcRef.current.trim();
if (!targetProc) return;
const requestId = ++ytIniRequestSeqRef.current;
setYtIniLoading(true);
setYtIniStatus(null);
try {
const data = await fetchJson<{ content?: string }>(
`/get_config?process=${encodeURIComponent(currentProc)}`,
`/get_config?process=${encodeURIComponent(targetProc)}`,
);
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 {
setYtIniLoading(false);
if (requestId === ytIniRequestSeqRef.current && targetProc === currentProcRef.current.trim()) {
setYtIniLoading(false);
}
}
}, [currentProc, fetchJson]);
}, [fetchJson]);
useEffect(() => {
if (!currentProc) return;
void pullUrlConfigFromServer();
void loadYoutubeIni();
}, [currentProc, loadYoutubeIni]);
setActiveViewRefreshToken((prev) => prev + 1);
}, [activeCh, currentProc, loadYoutubeIni, mode, pullUrlConfigFromServer]);
const persistActiveLaneDraft = useCallback(
async () => {
@@ -795,6 +805,7 @@ export function LiveYoutubeUnmannedView({
if (!activeLaneValidation.ok) {
throw new Error(activeLaneValidation.message);
}
const latestProUrlDraft = proUrlDraftRef.current;
const next = channels.map((row) =>
row.id === active.id
? {
@@ -803,7 +814,7 @@ export function LiveYoutubeUnmannedView({
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
urlLines: proUrlDraft,
urlLines: latestProUrlDraft,
}
: row,
);
@@ -828,7 +839,7 @@ export function LiveYoutubeUnmannedView({
streamKey: activeLaneValidation.normalizedStreamKey,
pm2Name: activeLaneValidation.normalizedPm2,
notes: activeLaneValidation.normalizedNotes,
urlLines: proUrlDraft,
urlLines: latestProUrlDraft,
} satisfies YoutubeProChannel),
pm2: activeLaneValidation.normalizedPm2,
};
@@ -839,7 +850,6 @@ export function LiveYoutubeUnmannedView({
activeLaneValidation,
applyChannelsLocal,
channels,
proUrlDraft,
syncChannelsRemote,
t,
],
@@ -933,9 +943,10 @@ export function LiveYoutubeUnmannedView({
try {
setLaneSaving(true);
setLaneStatus(null);
const latestProUrlDraft = proUrlDraftRef.current;
const saved = await persistActiveLaneDraft();
setCurrentProc(saved.pm2);
await Promise.resolve(onSaveUrlConfig(proUrlDraft, saved.pm2));
await Promise.resolve(onSaveUrlConfig(latestProUrlDraft, saved.pm2));
urlListDirtyRef.current = false;
setLaneStatus(t("线路与地址已保存", "Lane settings and URLs saved"));
} catch (e) {
@@ -948,8 +959,13 @@ export function LiveYoutubeUnmannedView({
};
const saveSingleUrlAndClearDirty = async () => {
const targetPm2 = currentProc.trim();
if (!targetPm2) {
notify(t("请选择进程", "Pick a process"));
return;
}
try {
await Promise.resolve(onSaveUrlConfig());
await Promise.resolve(onSaveUrlConfig(singleUrlDraftRef.current, targetPm2));
urlListDirtyRef.current = false;
} catch {
/* parent 已 toast */
@@ -957,7 +973,7 @@ export function LiveYoutubeUnmannedView({
};
const dedupeUrlDraft = async () => {
const source = mode === "single" ? urlConfig : proUrlDraft;
const source = mode === "single" ? singleUrlDraftRef.current : proUrlDraftRef.current;
setUrlOptimizing(true);
setUrlFetchNote(null);
try {
@@ -972,9 +988,9 @@ export function LiveYoutubeUnmannedView({
}),
});
const next = data.content ?? source;
if (mode === "single") setUrlConfig(next);
if (mode === "single") applySingleUrlDraft(next);
else {
setProUrlDraft(next);
applyProUrlDraft(next);
}
urlListDirtyRef.current = true;
setUrlFetchNote(data.message || t("已去重当前 URL 列表", "Deduplicated current URL list"));
@@ -1574,7 +1590,7 @@ export function LiveYoutubeUnmannedView({
persistChannels([...channels, nc]);
setActiveCh(id);
setCurrentProc(resolveChannelPm2(nc));
setProUrlDraft("");
applyProUrlDraft("");
}}
>
<Plus className="h-3.5 w-3.5" />
@@ -1692,7 +1708,7 @@ export function LiveYoutubeUnmannedView({
value={proUrlDraft}
onChange={(e) => {
urlListDirtyRef.current = true;
setProUrlDraft(e.target.value);
applyProUrlDraft(e.target.value);
}}
spellCheck={false}
placeholder="https://live.douyin.com/..."
@@ -1732,7 +1748,7 @@ export function LiveYoutubeUnmannedView({
value={urlConfig}
onChange={(e) => {
urlListDirtyRef.current = true;
setUrlConfig(e.target.value);
applySingleUrlDraft(e.target.value);
}}
spellCheck={false}
/>
@@ -1755,6 +1771,7 @@ export function LiveYoutubeUnmannedView({
keySuffixUi={keySuffixUi}
showKeyRow
pollMs={800}
refreshToken={activeViewRefreshToken}
/>
</div>
);