This commit is contained in:
eric
2026-03-29 01:34:23 -05:00
parent 8bce529255
commit 784a4468b8
8 changed files with 308 additions and 218 deletions

View File

@@ -6,6 +6,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
buildYoutubeIniFromFields,
extractDouyinLiveUrls,
managedUrlConfigRelPathForPm2,
managedYoutubeIniRelPathForPm2,
parseYoutubeIniFields,
parseYoutubeStreamKeySuffix,
type YoutubeIniFields,
@@ -191,11 +193,20 @@ export function LiveYoutubeUnmannedView({
);
const c = data.content ?? "";
if (mode === "single") setUrlConfig(c);
else setProUrlDraft(c);
else {
setProUrlDraft(c);
if (activeCh) {
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
return next;
});
}
}
} catch {
/* ignore */
}
}, [currentProc, fetchJson, mode, setUrlConfig]);
}, [activeCh, currentProc, fetchJson, mode, setUrlConfig]);
useEffect(() => {
if (!currentProc || !urlAutoRefresh) return;
@@ -217,7 +228,16 @@ export function LiveYoutubeUnmannedView({
);
const c = data.content ?? "";
if (mode === "single") setUrlConfig(c);
else setProUrlDraft(c);
else {
setProUrlDraft(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) {
setUrlFetchNote((e as Error).message);
@@ -284,11 +304,6 @@ export function LiveYoutubeUnmannedView({
const active = channels.find((c) => c.id === activeCh) || channels[0];
const proLanesWithKey = useMemo(
() => channels.filter((ch) => (ch.streamKey ?? "").trim().length > 0),
[channels],
);
const selectProChannel = useCallback(
(c: YoutubeProChannel) => {
if (active && mode === "pro") {
@@ -306,8 +321,34 @@ export function LiveYoutubeUnmannedView({
const ch = channels.find((x) => x.id === activeCh);
if (!ch) return;
const want = (ch.pm2Name ?? "").trim() || defaultPm2NameForChannel(ch.id);
if (processOptions.some((e) => e.pm2 === want)) setCurrentProc(want);
}, [mode, activeCh, channels, processOptions, setCurrentProc]);
setCurrentProc(want);
}, [mode, activeCh, channels, setCurrentProc]);
useEffect(() => {
if (mode !== "pro" || !currentProc || !activeCh) return;
let cancelled = false;
void (async () => {
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
if (cancelled) return;
const c = data.content ?? "";
setProUrlDraft(c);
urlListDirtyRef.current = false;
setChannels((prev) => {
const next = prev.map((x) => (x.id === activeCh ? { ...x, urlLines: c } : x));
saveYoutubeProChannels(next);
return next;
});
} catch {
if (!cancelled) setProUrlDraft("");
}
})();
return () => {
cancelled = true;
};
}, [mode, currentProc, activeCh, fetchJson]);
const loadYoutubeIni = useCallback(async () => {
if (!currentProc) return;
@@ -375,7 +416,7 @@ export function LiveYoutubeUnmannedView({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "app-config",
path: "youtube.ini",
path: managedYoutubeIniRelPathForPm2(currentProc),
content: source,
action: "youtube_balanced_preset",
}),
@@ -433,7 +474,7 @@ export function LiveYoutubeUnmannedView({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
root: "app-config",
path: "URL_config.ini",
path: managedUrlConfigRelPathForPm2(currentProc),
content: source,
action: "dedupe_urls",
}),
@@ -507,7 +548,7 @@ export function LiveYoutubeUnmannedView({
);
return (
<div className="mx-auto max-w-4xl space-y-6">
<div className="mx-auto max-w-6xl space-y-6">
<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">
@@ -620,9 +661,9 @@ export function LiveYoutubeUnmannedView({
{t("Pro 转播实况", "Pro relay status")}
</h4>
<p className="mt-1 text-[11px] text-zinc-500">
{t("对齐桌面端工作台:每路密钥、源站、进程状态一览;点行可切换编辑。", "Like the desktop workspace: per-lane key, source, PM2 status.")}
{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-52 space-y-2 overflow-y-auto pr-1">
<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 st = statusForPm2(pm);
@@ -630,10 +671,11 @@ export function LiveYoutubeUnmannedView({
const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0];
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
const rowOnline = pm2StatusOnline(statusForPm2(pm));
return (
<div
key={c.id}
className={`flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-left text-xs transition ${
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"
}`}
>
@@ -642,131 +684,92 @@ export function LiveYoutubeUnmannedView({
className="min-w-0 flex-1 text-left"
onClick={() => selectProChannel(c)}
>
<span className="font-medium text-zinc-100">{c.name}</span>
<span className="ml-2 font-mono text-[10px] text-zinc-500">
key {suf || "—"}
</span>
<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 ${
on
? "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30"
: "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25"
}`}
>
{on ? t("运行中", "Live") : st || t("未运行", "Idle")}
</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>
</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>
<span
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ring-1 ${
on
? "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30"
: "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25"
}`}
<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()}
>
{on ? t("运行中", "Live") : st || t("未运行", "Idle")}
</span>
{dUrl ? (
<a
href={dUrl}
target="_blank"
rel="noreferrer"
className="max-w-[200px] truncate text-cyan-300/90 underline-offset-2 hover:underline"
onClick={(e) => e.stopPropagation()}
title={dUrl}
>
{dUrl.replace(/^https?:\/\//, "")}
</a>
) : (
<span className="text-zinc-600"></span>
)}
</div>
);
})}
</div>
</div>
<p className="mt-3 text-xs text-zinc-500">
{t("点选一行即可编辑该线路;下方列表为已保存密钥的线路控制。", "Pick a row above to edit; the list below is lanes with a saved key.")}
</p>
{proLanesWithKey.length === 0 ? (
<p className="mt-4 rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-zinc-400">
{channels.length > 0
? t("请先在下方填写串流密钥并点「保存」。", "Enter a stream key below and tap Save.")
: t("请先新增线路。", "Add a lane first.")}
</p>
) : (
<div className="mt-4 space-y-2">
{proLanesWithKey.map((c) => {
const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id);
const suffix = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
const sel = activeCh === c.id;
const rowOnline = pm2StatusOnline(statusForPm2(pm));
return (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => selectProChannel(c)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectProChannel(c);
}
}}
className={`rounded-xl border p-3 text-left transition ${
sel ? "border-violet-500/45 bg-violet-500/[0.08]" : "border-white/10 bg-black/30 hover:border-white/20"
}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex flex-1 flex-wrap items-center gap-2">
<code className="shrink-0 font-mono text-xs text-cyan-300/90">{suffix || "—"}</code>
<span className="truncate text-sm text-zinc-200">{c.name}</span>
</div>
<div
className="flex shrink-0 flex-wrap gap-2"
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={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("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={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("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={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("restart", pm)}
notify={notify}
t={t}
compact
/>
</div>
<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={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("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={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("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={ytIniLoading || urlReloading || urlOptimizing}
onRun={() => onRunProcess("restart", pm)}
notify={notify}
t={t}
compact
/>
</div>
</div>
);
})}
</div>
)}
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
{t("当前线路", "Current lane")}: {active?.name ?? "—"} · key {keySuffixUi || "—"}
</p>
</div>
</>
)}
</div>
@@ -882,7 +885,19 @@ export function LiveYoutubeUnmannedView({
<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">
<h3 className="text-sm font-semibold text-white">{t("当前线路", "Current lane")}</h3>
<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}
</h3>
{active ? (
<p className="mt-0.5 font-mono text-[11px] text-zinc-500">
PM2: {(active.pm2Name ?? "").trim() || defaultPm2NameForChannel(active.id)} · key {keySuffixUi || "—"}
</p>
) : null}
</div>
<button
type="button"
disabled={lock}
@@ -906,21 +921,6 @@ export function LiveYoutubeUnmannedView({
{t("新增线路", "Add lane")}
</button>
</div>
<label className="mt-3 block text-xs font-medium text-zinc-500">{t("切换线路", "Switch lane")}</label>
<select
className="mt-1 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2.5 text-sm text-white"
value={activeCh}
onChange={(e) => {
const c = channels.find((x) => x.id === e.target.value);
if (c) selectProChannel(c);
}}
>
{channels.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{active ? (
<div className="mt-4 space-y-3 border-t border-white/5 pt-4">
@@ -982,7 +982,12 @@ export function LiveYoutubeUnmannedView({
</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 (this lane)")}</h3>
<h3 className="text-sm font-semibold text-white">{t("直播地址列表", "URL list")}</h3>
<p className="mt-1 font-mono text-[10px] text-zinc-600">
{currentProc
? managedUrlConfigRelPathForPm2(currentProc)
: "URL_config.ini"}
</p>
{urlToolbar}
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
<textarea
@@ -1021,39 +1026,33 @@ export function LiveYoutubeUnmannedView({
</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_config.ini", "URL_config.ini")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{mode === "pro"
? t("Pro 模式在上方「当前线路」里编辑本线路地址。", "Pro: edit this lanes URLs in the lane panel above.")
: t("一行一个地址;可自动刷新或手动重新读取。", "One URL per line; auto-refresh or reload.")}
</p>
{mode === "single" ? (
<>
{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;
setUrlConfig(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>
</>
) : (
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
)}
</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>
{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;
setUrlConfig(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}