s
This commit is contained in:
@@ -21,7 +21,9 @@ import {
|
||||
loadYoutubeProChannels,
|
||||
newChannelId,
|
||||
pushYoutubeProChannelsRemote,
|
||||
resolveYoutubeProChannelPm2,
|
||||
saveYoutubeProChannels,
|
||||
sanitizeYoutubePm2Name,
|
||||
type YoutubeProChannel,
|
||||
} from "@/lib/youtubeProChannels";
|
||||
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
|
||||
@@ -120,14 +122,32 @@ function preferredSingleProcessPm2FromRuntime(entries: ProcessEntry[], liveRows:
|
||||
}
|
||||
|
||||
function resolveChannelPm2(channel: YoutubeProChannel): string {
|
||||
return (channel.pm2Name ?? "").trim() || defaultPm2NameForChannel(channel.id);
|
||||
return resolveYoutubeProChannelPm2(channel);
|
||||
}
|
||||
|
||||
const MODE_KEY = "live-hub-youtube-mode-v1";
|
||||
const PRO_PM2_RESERVED = new Set(["tiktok", "obs", "web"]);
|
||||
|
||||
/** UI 暂时隐藏「多频道 Pro」切换(DOM 与逻辑保留,改 false 即可恢复) */
|
||||
const HIDE_YOUTUBE_MULTI_PRO_TOGGLE = false;
|
||||
|
||||
type QuickLinkMap = Record<string, string>;
|
||||
type ProLaneDraft = {
|
||||
name: string;
|
||||
streamKey: string;
|
||||
pm2Name: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
function laneDraftFromChannel(channel: YoutubeProChannel): ProLaneDraft {
|
||||
return {
|
||||
name: channel.name,
|
||||
streamKey: channel.streamKey ?? "",
|
||||
pm2Name: channel.pm2Name ?? defaultPm2NameForChannel(channel.id),
|
||||
notes: channel.notes ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
type LiveProcRow = {
|
||||
pm2: string;
|
||||
script?: string;
|
||||
@@ -171,6 +191,17 @@ function businessStatusBadgeClass(row: LiveProcRow | undefined): string {
|
||||
return "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25";
|
||||
}
|
||||
|
||||
function nonCommentUrlLinesOf(text: string): string[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#"));
|
||||
}
|
||||
|
||||
function shouldPreserveLocalUrlDraft(localText: string, remoteText: string): boolean {
|
||||
return nonCommentUrlLinesOf(localText).length > 0 && nonCommentUrlLinesOf(remoteText).length === 0;
|
||||
}
|
||||
|
||||
function parseProcBusy(busy: string | null): { action: string; pm2: string } | null {
|
||||
if (!busy?.startsWith("proc:")) return null;
|
||||
const rest = busy.slice(5);
|
||||
@@ -281,6 +312,7 @@ type Props = {
|
||||
liveProcesses: LiveProcRow[];
|
||||
notify: (msg: string) => void;
|
||||
onNavigate?: (target: PortalNavTarget) => void;
|
||||
quickLinks?: QuickLinkMap;
|
||||
};
|
||||
|
||||
export function LiveYoutubeUnmannedView({
|
||||
@@ -297,6 +329,7 @@ export function LiveYoutubeUnmannedView({
|
||||
liveProcesses,
|
||||
notify,
|
||||
onNavigate,
|
||||
quickLinks,
|
||||
}: Props) {
|
||||
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
|
||||
const staticYoutubeEntries = useMemo(() => ytEntries.filter((entry) => isStaticYoutubeControlEntry(entry)), [ytEntries]);
|
||||
@@ -326,6 +359,9 @@ export function LiveYoutubeUnmannedView({
|
||||
const [urlReloading, setUrlReloading] = useState(false);
|
||||
const [urlOptimizing, setUrlOptimizing] = useState(false);
|
||||
const [urlFetchNote, setUrlFetchNote] = useState<string | null>(null);
|
||||
const [laneDrafts, setLaneDrafts] = useState<Record<string, ProLaneDraft>>({});
|
||||
const [laneSaving, setLaneSaving] = useState(false);
|
||||
const [laneStatus, setLaneStatus] = useState<string | null>(null);
|
||||
const urlListDirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -342,6 +378,11 @@ export function LiveYoutubeUnmannedView({
|
||||
if (urlListDirtyRef.current) return;
|
||||
if (mode === "single") setUrlConfig(c);
|
||||
else {
|
||||
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
|
||||
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
|
||||
setProUrlDraft(localDraft);
|
||||
return;
|
||||
}
|
||||
setProUrlDraft(c);
|
||||
if (activeCh) {
|
||||
setChannels((prev) => {
|
||||
@@ -354,7 +395,7 @@ export function LiveYoutubeUnmannedView({
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [activeCh, currentProc, fetchJson, mode, setUrlConfig]);
|
||||
}, [activeCh, channels, currentProc, fetchJson, mode, setUrlConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProc || !urlAutoRefresh) return;
|
||||
@@ -381,6 +422,12 @@ export function LiveYoutubeUnmannedView({
|
||||
}
|
||||
if (mode === "single") setUrlConfig(c);
|
||||
else {
|
||||
const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? "";
|
||||
if (shouldPreserveLocalUrlDraft(localDraft, c)) {
|
||||
setProUrlDraft(localDraft);
|
||||
setUrlFetchNote(t("服务器暂无已保存 URL,已保留本地草稿", "Server has no saved URLs, kept the local draft"));
|
||||
return;
|
||||
}
|
||||
setProUrlDraft(c);
|
||||
if (activeCh) {
|
||||
setChannels((prev) => {
|
||||
@@ -480,21 +527,134 @@ export function LiveYoutubeUnmannedView({
|
||||
}
|
||||
}, [currentProc, mode, preferredSingleProc, rowForPm2, setCurrentProc, singleProcessOptions]);
|
||||
|
||||
const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => {
|
||||
setChannels(next);
|
||||
saveYoutubeProChannels(next);
|
||||
}, []);
|
||||
|
||||
const syncChannelsRemote = useCallback(
|
||||
async (next: YoutubeProChannel[]) => {
|
||||
await pushYoutubeProChannelsRemote(fetchJson, next);
|
||||
},
|
||||
[fetchJson],
|
||||
);
|
||||
|
||||
const persistChannels = useCallback(
|
||||
(next: YoutubeProChannel[]) => {
|
||||
setChannels(next);
|
||||
saveYoutubeProChannels(next);
|
||||
void pushYoutubeProChannelsRemote(fetchJson, next).catch((e) => {
|
||||
applyChannelsLocal(next);
|
||||
void syncChannelsRemote(next).catch((e) => {
|
||||
notify(
|
||||
`${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
[fetchJson, notify, t],
|
||||
[applyChannelsLocal, notify, syncChannelsRemote, t],
|
||||
);
|
||||
|
||||
const active = channels.find((c) => c.id === activeCh) || channels[0];
|
||||
const activeSavedUrlDraft = useMemo(() => (active?.urlLines ?? "").trim(), [active?.urlLines]);
|
||||
const activeLaneDraft = useMemo(
|
||||
() => (active ? laneDrafts[active.id] ?? laneDraftFromChannel(active) : null),
|
||||
[active, laneDrafts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
setLaneDrafts((prev) => {
|
||||
if (prev[active.id]) return prev;
|
||||
return { ...prev, [active.id]: laneDraftFromChannel(active) };
|
||||
});
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
setLaneStatus(null);
|
||||
}, [activeCh]);
|
||||
|
||||
const setLaneDraftField = useCallback(
|
||||
(key: keyof ProLaneDraft, value: string) => {
|
||||
if (!active) return;
|
||||
setLaneDrafts((prev) => ({
|
||||
...prev,
|
||||
[active.id]: {
|
||||
...(prev[active.id] ?? laneDraftFromChannel(active)),
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
setLaneStatus(null);
|
||||
},
|
||||
[active],
|
||||
);
|
||||
|
||||
const activeSavedPm2 = active ? resolveChannelPm2(active) : "";
|
||||
const activeDraftPm2 = active && activeLaneDraft
|
||||
? resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name })
|
||||
: "";
|
||||
const activePm2PreviewChanged = !!active && !!activeLaneDraft && activeDraftPm2 !== activeSavedPm2;
|
||||
|
||||
const activeLaneValidation = useMemo(() => {
|
||||
if (!active || !activeLaneDraft) {
|
||||
return {
|
||||
ok: false,
|
||||
message: t("请选择线路", "Pick a lane"),
|
||||
normalizedName: "",
|
||||
normalizedPm2: "",
|
||||
normalizedStreamKey: "",
|
||||
normalizedNotes: "",
|
||||
};
|
||||
}
|
||||
const normalizedName = activeLaneDraft.name.trim() || `${t("线路", "Ch")} ${channels.findIndex((row) => row.id === active.id) + 1}`;
|
||||
const normalizedPm2 = resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name });
|
||||
if (!normalizedPm2) {
|
||||
return {
|
||||
ok: false,
|
||||
message: t("进程名不能为空", "PM2 name is required"),
|
||||
normalizedName,
|
||||
normalizedPm2,
|
||||
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
|
||||
normalizedNotes: activeLaneDraft.notes.trim(),
|
||||
};
|
||||
}
|
||||
if (PRO_PM2_RESERVED.has(normalizedPm2)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: t("该进程名保留给系统模块,请换一个", "This PM2 name is reserved"),
|
||||
normalizedName,
|
||||
normalizedPm2,
|
||||
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
|
||||
normalizedNotes: activeLaneDraft.notes.trim(),
|
||||
};
|
||||
}
|
||||
const duplicate = channels.find((row) => row.id !== active.id && resolveChannelPm2(row) === normalizedPm2);
|
||||
if (duplicate) {
|
||||
return {
|
||||
ok: false,
|
||||
message: t("该进程名已被其它线路占用", "Another lane already uses this PM2 name"),
|
||||
normalizedName,
|
||||
normalizedPm2,
|
||||
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
|
||||
normalizedNotes: activeLaneDraft.notes.trim(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: "",
|
||||
normalizedName,
|
||||
normalizedPm2,
|
||||
normalizedStreamKey: activeLaneDraft.streamKey.trim(),
|
||||
normalizedNotes: activeLaneDraft.notes.trim(),
|
||||
};
|
||||
}, [active, activeLaneDraft, channels, t]);
|
||||
|
||||
const activeLaneDirty = useMemo(() => {
|
||||
if (!active || !activeLaneDraft) return false;
|
||||
const savedDraft = laneDraftFromChannel(active);
|
||||
return (
|
||||
activeLaneDraft.name !== savedDraft.name ||
|
||||
activeLaneDraft.streamKey !== savedDraft.streamKey ||
|
||||
sanitizeYoutubePm2Name(activeLaneDraft.pm2Name) !== sanitizeYoutubePm2Name(savedDraft.pm2Name) ||
|
||||
activeLaneDraft.notes !== savedDraft.notes
|
||||
);
|
||||
}, [active, activeLaneDraft]);
|
||||
|
||||
const setModePersist = useCallback(
|
||||
(m: "single" | "pro") => {
|
||||
@@ -574,6 +734,11 @@ export function LiveYoutubeUnmannedView({
|
||||
);
|
||||
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) => {
|
||||
@@ -590,7 +755,7 @@ export function LiveYoutubeUnmannedView({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeCh, activeSavedUrlDraft, currentProc, fetchJson, mode]);
|
||||
}, [activeCh, activeSavedUrlDraft, channels, currentProc, fetchJson, mode]);
|
||||
|
||||
const loadYoutubeIni = useCallback(async () => {
|
||||
if (!currentProc) return;
|
||||
@@ -615,10 +780,80 @@ export function LiveYoutubeUnmannedView({
|
||||
void loadYoutubeIni();
|
||||
}, [currentProc, loadYoutubeIni]);
|
||||
|
||||
const updateActive = (patch: Partial<YoutubeProChannel>) => {
|
||||
const persistActiveLaneDraft = useCallback(
|
||||
async () => {
|
||||
if (!active || !activeLaneDraft) {
|
||||
throw new Error(t("请选择线路", "Pick a lane"));
|
||||
}
|
||||
if (!activeLaneValidation.ok) {
|
||||
throw new Error(activeLaneValidation.message);
|
||||
}
|
||||
const next = channels.map((row) =>
|
||||
row.id === active.id
|
||||
? {
|
||||
...row,
|
||||
name: activeLaneValidation.normalizedName,
|
||||
streamKey: activeLaneValidation.normalizedStreamKey,
|
||||
pm2Name: activeLaneValidation.normalizedPm2,
|
||||
notes: activeLaneValidation.normalizedNotes,
|
||||
urlLines: proUrlDraft,
|
||||
}
|
||||
: row,
|
||||
);
|
||||
applyChannelsLocal(next);
|
||||
await syncChannelsRemote(next);
|
||||
setLaneDrafts((prev) => ({
|
||||
...prev,
|
||||
[active.id]: {
|
||||
name: activeLaneValidation.normalizedName,
|
||||
streamKey: activeLaneValidation.normalizedStreamKey,
|
||||
pm2Name: activeLaneValidation.normalizedPm2,
|
||||
notes: activeLaneValidation.normalizedNotes,
|
||||
},
|
||||
}));
|
||||
return {
|
||||
next,
|
||||
savedLane:
|
||||
next.find((row) => row.id === active.id) ??
|
||||
({
|
||||
...active,
|
||||
name: activeLaneValidation.normalizedName,
|
||||
streamKey: activeLaneValidation.normalizedStreamKey,
|
||||
pm2Name: activeLaneValidation.normalizedPm2,
|
||||
notes: activeLaneValidation.normalizedNotes,
|
||||
urlLines: proUrlDraft,
|
||||
} satisfies YoutubeProChannel),
|
||||
pm2: activeLaneValidation.normalizedPm2,
|
||||
};
|
||||
},
|
||||
[
|
||||
active,
|
||||
activeLaneDraft,
|
||||
activeLaneValidation,
|
||||
applyChannelsLocal,
|
||||
channels,
|
||||
proUrlDraft,
|
||||
syncChannelsRemote,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const saveProLaneSettings = useCallback(async () => {
|
||||
if (!active) return;
|
||||
persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c)));
|
||||
};
|
||||
setLaneSaving(true);
|
||||
setLaneStatus(null);
|
||||
try {
|
||||
const saved = await persistActiveLaneDraft();
|
||||
setCurrentProc(saved.pm2);
|
||||
setLaneStatus(t("线路设置已保存", "Lane settings saved"));
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
setLaneStatus(message);
|
||||
notify(message);
|
||||
} finally {
|
||||
setLaneSaving(false);
|
||||
}
|
||||
}, [active, notify, persistActiveLaneDraft, setCurrentProc, t]);
|
||||
|
||||
const saveYoutubeIniToServer = async (content: string, processPm2 = currentProc) => {
|
||||
const targetPm2 = processPm2.trim();
|
||||
@@ -682,18 +917,24 @@ export function LiveYoutubeUnmannedView({
|
||||
|
||||
const keySuffixUi = useMemo(() => {
|
||||
if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`);
|
||||
const k = active?.streamKey ?? "";
|
||||
const k = activeLaneDraft?.streamKey ?? active?.streamKey ?? "";
|
||||
return parseYoutubeStreamKeySuffix(`key = ${k}`);
|
||||
}, [mode, ytFields.key, active?.streamKey]);
|
||||
}, [mode, ytFields.key, active?.streamKey, activeLaneDraft?.streamKey]);
|
||||
|
||||
const saveProUrlAndServer = async () => {
|
||||
if (!active) return;
|
||||
updateActive({ urlLines: proUrlDraft });
|
||||
try {
|
||||
await Promise.resolve(onSaveUrlConfig(proUrlDraft, resolveChannelPm2(active)));
|
||||
setLaneSaving(true);
|
||||
setLaneStatus(null);
|
||||
const saved = await persistActiveLaneDraft();
|
||||
setCurrentProc(saved.pm2);
|
||||
await Promise.resolve(onSaveUrlConfig(proUrlDraft, saved.pm2));
|
||||
urlListDirtyRef.current = false;
|
||||
setLaneStatus(t("线路与地址已保存", "Lane settings and URLs saved"));
|
||||
} catch {
|
||||
/* parent 已 toast */
|
||||
} finally {
|
||||
setLaneSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -725,7 +966,6 @@ export function LiveYoutubeUnmannedView({
|
||||
if (mode === "single") setUrlConfig(next);
|
||||
else {
|
||||
setProUrlDraft(next);
|
||||
if (active) updateActive({ urlLines: next });
|
||||
}
|
||||
urlListDirtyRef.current = true;
|
||||
setUrlFetchNote(data.message || t("已去重当前 URL 列表", "Deduplicated current URL list"));
|
||||
@@ -737,76 +977,102 @@ export function LiveYoutubeUnmannedView({
|
||||
};
|
||||
|
||||
const pushProStreamKeyToServer = async () => {
|
||||
if (!active) return;
|
||||
if (!active || !activeLaneDraft) return;
|
||||
const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields));
|
||||
const f: YoutubeIniFields = {
|
||||
key: (active.streamKey ?? "").trim(),
|
||||
key: activeLaneDraft.streamKey.trim(),
|
||||
rtmps: base.rtmps,
|
||||
bitrate: base.bitrate,
|
||||
fastAudio: base.fastAudio,
|
||||
};
|
||||
const body = buildYoutubeIniFromFields(f);
|
||||
await saveYoutubeIniToServer(body, resolveChannelPm2(active));
|
||||
updateActive({ streamKey: f.key });
|
||||
setLaneSaving(true);
|
||||
setLaneStatus(null);
|
||||
try {
|
||||
const saved = await persistActiveLaneDraft();
|
||||
setCurrentProc(saved.pm2);
|
||||
await saveYoutubeIniToServer(body, saved.pm2);
|
||||
setLaneStatus(t("线路设置与 youtube.ini 已保存", "Lane settings and youtube.ini saved"));
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
setLaneStatus(message);
|
||||
notify(message);
|
||||
} finally {
|
||||
setLaneSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const nonCommentUrlLines = useCallback(
|
||||
(text: string) =>
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#")),
|
||||
[],
|
||||
const currentUrlDraft = mode === "single" ? urlConfig : proUrlDraft;
|
||||
const currentUrlCount = useMemo(() => nonCommentUrlLinesOf(currentUrlDraft).length, [currentUrlDraft]);
|
||||
const currentDouyinCount = useMemo(() => extractDouyinLiveUrls(currentUrlDraft).length, [currentUrlDraft]);
|
||||
const laneQuickLinks = useMemo(
|
||||
() =>
|
||||
[
|
||||
quickLinks?.neko_browser,
|
||||
quickLinks?.chromium_remote_debug,
|
||||
quickLinks?.srs_api,
|
||||
].filter((value): value is string => !!value),
|
||||
[quickLinks],
|
||||
);
|
||||
|
||||
const flushProChannelsBeforeRun = useCallback(
|
||||
async (targetPm2: string) => {
|
||||
let next = channels;
|
||||
const next = channels;
|
||||
const activePm2 = active ? resolveChannelPm2(active) : "";
|
||||
if (active && targetPm2 === activePm2) {
|
||||
next = channels.map((row) => (row.id === active.id ? { ...row, urlLines: proUrlDraft } : row));
|
||||
const saved = await persistActiveLaneDraft();
|
||||
return saved;
|
||||
}
|
||||
setChannels(next);
|
||||
saveYoutubeProChannels(next);
|
||||
await pushYoutubeProChannelsRemote(fetchJson, next);
|
||||
return next;
|
||||
applyChannelsLocal(next);
|
||||
await syncChannelsRemote(next);
|
||||
return {
|
||||
next,
|
||||
savedLane: active ?? null,
|
||||
pm2: targetPm2,
|
||||
};
|
||||
},
|
||||
[active, channels, fetchJson, proUrlDraft],
|
||||
[active, applyChannelsLocal, channels, persistActiveLaneDraft, syncChannelsRemote],
|
||||
);
|
||||
|
||||
const runProProcess = useCallback(
|
||||
async (action: "start" | "stop" | "restart", targetPm2: string) => {
|
||||
const targetMatchesActiveLane = !!active && resolveChannelPm2(active) === targetPm2;
|
||||
if (action !== "stop") {
|
||||
let next = channels;
|
||||
let effectivePm2 = targetPm2;
|
||||
try {
|
||||
next = await flushProChannelsBeforeRun(targetPm2);
|
||||
const saved = await flushProChannelsBeforeRun(targetPm2);
|
||||
next = saved.next;
|
||||
effectivePm2 = saved.pm2;
|
||||
} catch (e) {
|
||||
notify(
|
||||
`${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`,
|
||||
`${t("当前线路保存失败", "Failed to save the active lane")}: ${(e as Error).message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const target = next.find(
|
||||
(row) => resolveChannelPm2(row) === targetPm2,
|
||||
(row) => resolveChannelPm2(row) === effectivePm2,
|
||||
);
|
||||
const activePm2 = active ? resolveChannelPm2(active) : "";
|
||||
const urlText = targetPm2 === activePm2 ? proUrlDraft : target?.urlLines ?? "";
|
||||
const urlText = target?.urlLines ?? "";
|
||||
if (!((target?.streamKey ?? "").trim())) {
|
||||
notify(t("请先填写当前线路的推流密钥", "Fill the stream key before starting"));
|
||||
return;
|
||||
}
|
||||
if (!nonCommentUrlLines(urlText).length) {
|
||||
if (!nonCommentUrlLinesOf(urlText).length) {
|
||||
notify(t("请先填写当前线路的直播地址", "Fill the source URLs before starting"));
|
||||
return;
|
||||
}
|
||||
targetPm2 = effectivePm2;
|
||||
}
|
||||
if (targetMatchesActiveLane) {
|
||||
setCurrentProc(targetPm2);
|
||||
}
|
||||
setCurrentProc(targetPm2);
|
||||
onRunProcess(action, targetPm2);
|
||||
},
|
||||
[active, channels, flushProChannelsBeforeRun, nonCommentUrlLines, notify, onRunProcess, proUrlDraft, setCurrentProc, t],
|
||||
[active, channels, flushProChannelsBeforeRun, notify, onRunProcess, setCurrentProc, t],
|
||||
);
|
||||
|
||||
const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing;
|
||||
const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing || laneSaving;
|
||||
|
||||
const singleRow = rowForPm2(currentProc);
|
||||
const singleOnline = pm2StatusOnline(statusForPm2(currentProc));
|
||||
@@ -869,7 +1135,7 @@ export function LiveYoutubeUnmannedView({
|
||||
label: "Neko",
|
||||
sub: t("Chromium 工作室", "Chromium"),
|
||||
gradient: "from-emerald-400 to-teal-500",
|
||||
openUrl: normalizeBrowserReachableHttpUrl("http://live.local:9200/"),
|
||||
openUrl: normalizeBrowserReachableHttpUrl(quickLinks?.neko_browser || "http://live.local:9200/"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -920,6 +1186,41 @@ export function LiveYoutubeUnmannedView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{laneQuickLinks.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickLinks?.neko_browser ? (
|
||||
<a
|
||||
href={normalizeBrowserReachableHttpUrl(quickLinks.neko_browser)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100 ring-1 ring-emerald-500/20"
|
||||
>
|
||||
{t("打开 Neko 工作室", "Open Neko studio")}
|
||||
</a>
|
||||
) : null}
|
||||
{quickLinks?.chromium_remote_debug ? (
|
||||
<a
|
||||
href={normalizeBrowserReachableHttpUrl(quickLinks.chromium_remote_debug)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-cyan-500/20 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-100 ring-1 ring-cyan-500/20"
|
||||
>
|
||||
{t("打开 Chromium 调试", "Open Chromium debug")}
|
||||
</a>
|
||||
) : null}
|
||||
{quickLinks?.srs_api ? (
|
||||
<a
|
||||
href={normalizeBrowserReachableHttpUrl(quickLinks.srs_api)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-violet-500/20 bg-violet-500/10 px-3 py-2 text-xs text-violet-100 ring-1 ring-violet-500/20"
|
||||
>
|
||||
{t("打开 SRS API", "Open SRS API")}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-2xl border border-amber-500/20 bg-zinc-950/60 p-6 ring-1 ring-white/[0.04]">
|
||||
<div className="flex items-center gap-2 text-amber-200/90">
|
||||
<Radio className="h-4 w-4" />
|
||||
@@ -1001,7 +1302,9 @@ export function LiveYoutubeUnmannedView({
|
||||
const pm = resolveChannelPm2(c);
|
||||
const row = rowForPm2(pm);
|
||||
const st = statusForPm2(pm);
|
||||
const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0];
|
||||
const laneUrls = nonCommentUrlLinesOf((c.urlLines ?? "").trim());
|
||||
const douyinUrls = extractDouyinLiveUrls((c.urlLines ?? "").trim());
|
||||
const dUrl = douyinUrls[0];
|
||||
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
|
||||
const sel = activeCh === c.id;
|
||||
const rowOnline = pm2StatusOnline(st);
|
||||
@@ -1035,6 +1338,14 @@ export function LiveYoutubeUnmannedView({
|
||||
<span className="truncate" title={pm}>
|
||||
{pm}
|
||||
</span>
|
||||
<span className="text-zinc-600">·</span>
|
||||
<span>
|
||||
URL {laneUrls.length}
|
||||
</span>
|
||||
<span className="text-zinc-600">·</span>
|
||||
<span>
|
||||
DY {douyinUrls.length}
|
||||
</span>
|
||||
</div>
|
||||
{dUrl ? (
|
||||
<a
|
||||
@@ -1222,10 +1533,18 @@ export function LiveYoutubeUnmannedView({
|
||||
{active ? (
|
||||
<span className="ml-2 font-normal text-zinc-400">· {active.name}</span>
|
||||
) : null}
|
||||
{activeLaneDirty ? (
|
||||
<span className="ml-2 rounded-md bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-100 ring-1 ring-amber-500/25">
|
||||
{t("有未保存修改", "Unsaved")}
|
||||
</span>
|
||||
) : null}
|
||||
</h3>
|
||||
{active ? (
|
||||
<p className="mt-0.5 font-mono text-[11px] text-zinc-500">
|
||||
PM2: {(active.pm2Name ?? "").trim() || defaultPm2NameForChannel(active.id)} · key {keySuffixUi || "—"}
|
||||
PM2: {activeSavedPm2}
|
||||
{activePm2PreviewChanged ? ` → ${activeDraftPm2}` : ""}
|
||||
{" · "}
|
||||
key {keySuffixUi || "—"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1258,50 +1577,73 @@ export function LiveYoutubeUnmannedView({
|
||||
<div className="mt-4 space-y-3 border-t border-white/5 pt-4">
|
||||
<input
|
||||
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm"
|
||||
value={active.name}
|
||||
onChange={(e) => updateActive({ name: e.target.value })}
|
||||
value={activeLaneDraft?.name ?? ""}
|
||||
onChange={(e) => setLaneDraftField("name", e.target.value)}
|
||||
placeholder={t("线路名称", "Lane name")}
|
||||
/>
|
||||
<label className="block text-xs text-zinc-500">{t("串流密钥", "Stream key")}</label>
|
||||
<input
|
||||
className="w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm"
|
||||
spellCheck={false}
|
||||
value={active.streamKey ?? ""}
|
||||
onChange={(e) => updateActive({ streamKey: e.target.value })}
|
||||
value={activeLaneDraft?.streamKey ?? ""}
|
||||
onChange={(e) => setLaneDraftField("streamKey", e.target.value)}
|
||||
placeholder="xxxx-xxxx… / rtmp(s)://…"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void saveProLaneSettings()}
|
||||
className="rounded-xl border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-zinc-100"
|
||||
>
|
||||
{laneSaving ? t("保存中...", "Saving...") : t("保存线路设置", "Save lane")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void pushProStreamKeyToServer()}
|
||||
className="rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
|
||||
>
|
||||
{t("保存", "Save")}
|
||||
</button>
|
||||
>
|
||||
{t("写入 youtube.ini", "Write youtube.ini")}
|
||||
</button>
|
||||
</div>
|
||||
{!activeLaneValidation.ok ? (
|
||||
<p className="text-xs text-rose-300">{activeLaneValidation.message}</p>
|
||||
) : null}
|
||||
<details className="rounded-xl border border-white/5 bg-black/20 p-3">
|
||||
<summary className="cursor-pointer text-xs text-zinc-500">{t("高级:进程名", "Advanced: process name")}</summary>
|
||||
<input
|
||||
className="mt-2 w-full rounded-lg border border-white/10 bg-black/40 px-3 py-2 font-mono text-xs"
|
||||
value={active.pm2Name ?? defaultPm2NameForChannel(active.id)}
|
||||
onChange={(e) => updateActive({ pm2Name: e.target.value })}
|
||||
value={activeLaneDraft?.pm2Name ?? defaultPm2NameForChannel(active.id)}
|
||||
onChange={(e) => setLaneDraftField("pm2Name", e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="youtube2__…"
|
||||
/>
|
||||
<p className="mt-2 font-mono text-[11px] text-zinc-500">
|
||||
{t("实际生效", "Effective")}: {activeDraftPm2 || "—"}
|
||||
</p>
|
||||
{activeLaneValidation.message && !activeLaneValidation.ok ? (
|
||||
<p className="mt-2 text-[11px] text-rose-300">{activeLaneValidation.message}</p>
|
||||
) : null}
|
||||
</details>
|
||||
<textarea
|
||||
className="h-16 w-full resize-y rounded-xl border border-white/10 bg-black/40 p-3 text-xs text-zinc-300"
|
||||
value={active.notes ?? ""}
|
||||
onChange={(e) => updateActive({ notes: e.target.value })}
|
||||
value={activeLaneDraft?.notes ?? ""}
|
||||
onChange={(e) => setLaneDraftField("notes", e.target.value)}
|
||||
placeholder={t("备注(可选)", "Notes (optional)")}
|
||||
/>
|
||||
{laneStatus ? <p className="text-xs text-zinc-400">{laneStatus}</p> : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || channels.length < 2}
|
||||
className="inline-flex items-center gap-1 text-xs text-rose-300 hover:text-rose-200 disabled:opacity-30"
|
||||
onClick={() => {
|
||||
const next = channels.filter((c) => c.id !== active.id);
|
||||
setLaneDrafts((prev) => {
|
||||
const clone = { ...prev };
|
||||
delete clone[active.id];
|
||||
return clone;
|
||||
});
|
||||
persistChannels(next);
|
||||
setActiveCh(next[0]?.id || "");
|
||||
}}
|
||||
@@ -1326,6 +1668,14 @@ export function LiveYoutubeUnmannedView({
|
||||
? managedUrlConfigRelPathForPm2(currentProc)
|
||||
: "URL_config.ini"}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px]">
|
||||
<span className="rounded-full border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-cyan-100">
|
||||
{t("有效地址", "Valid URLs")}: {currentUrlCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-violet-500/20 bg-violet-500/10 px-2.5 py-1 text-violet-100">
|
||||
Douyin: {currentDouyinCount}
|
||||
</span>
|
||||
</div>
|
||||
{urlToolbar}
|
||||
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
|
||||
<textarea
|
||||
@@ -1358,6 +1708,14 @@ export function LiveYoutubeUnmannedView({
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t("一行一个地址;可自动刷新或手动重新读取。", "One URL per line; auto-refresh or reload.")}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px]">
|
||||
<span className="rounded-full border border-cyan-500/20 bg-cyan-500/10 px-2.5 py-1 text-cyan-100">
|
||||
{t("有效地址", "Valid URLs")}: {currentUrlCount}
|
||||
</span>
|
||||
<span className="rounded-full border border-violet-500/20 bg-violet-500/10 px-2.5 py-1 text-violet-100">
|
||||
Douyin: {currentDouyinCount}
|
||||
</span>
|
||||
</div>
|
||||
{urlToolbar}
|
||||
{urlFetchNote ? <p className="mt-2 text-xs text-zinc-500">{urlFetchNote}</p> : null}
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user