"use client"; import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildYoutubeIniFromFields, parseYoutubeIniFields, parseYoutubeStreamKeySuffix, type YoutubeIniFields, } from "@/lib/youtubeConfigUtils"; import { LiveProcessLiveLogCard } from "@/components/live-product/LiveProcessLiveLogCard"; import { defaultPm2NameForChannel, loadYoutubeProChannels, newChannelId, saveYoutubeProChannels, type YoutubeProChannel, } from "@/lib/youtubeProChannels"; type ProcessEntry = { pm2: string; script: string; label: string }; type TFn = (zh: string, en: string) => string; function isYoutubeProcess(e: ProcessEntry) { return /youtube/i.test(e.script) || /youtube/i.test(e.pm2); } const MODE_KEY = "live-hub-youtube-mode-v1"; type Props = { t: TFn; busy: boolean; entries: ProcessEntry[]; currentProc: string; setCurrentProc: (v: string) => void; urlConfig: string; setUrlConfig: (v: string) => void; onRunProcess: (a: "start" | "stop" | "restart", processPm2?: string) => void; onSaveUrlConfig: (content?: string) => void | Promise; fetchJson: (path: string, init?: RequestInit) => Promise; }; export function LiveYoutubeUnmannedView({ t, busy, entries, currentProc, setCurrentProc, urlConfig, setUrlConfig, onRunProcess, onSaveUrlConfig, fetchJson, }: Props) { const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]); const processOptions = ytEntries.length ? ytEntries : entries; const [mode, setMode] = useState<"single" | "pro">("single"); const [channels, setChannels] = useState([]); const [activeCh, setActiveCh] = useState(""); const [youtubeIniText, setYoutubeIniText] = useState(""); const [ytFields, setYtFields] = useState({ key: "", rtmps: true, bitrate: "", fastAudio: false, }); const [ytIniStatus, setYtIniStatus] = useState(null); const [ytIniLoading, setYtIniLoading] = useState(false); const [proUrlDraft, setProUrlDraft] = useState(""); const [urlAutoRefresh, setUrlAutoRefresh] = useState(true); const [urlReloading, setUrlReloading] = useState(false); const [urlFetchNote, setUrlFetchNote] = useState(null); const urlListDirtyRef = useRef(false); useEffect(() => { urlListDirtyRef.current = false; }, [currentProc]); const pullUrlConfigFromServer = useCallback(async () => { if (!currentProc) return; try { const data = await fetchJson<{ content?: string }>( `/get_url_config?process=${encodeURIComponent(currentProc)}`, ); const c = data.content ?? ""; if (mode === "single") setUrlConfig(c); else setProUrlDraft(c); } catch { /* ignore */ } }, [currentProc, fetchJson, mode, setUrlConfig]); useEffect(() => { if (!currentProc || !urlAutoRefresh) return; const id = window.setInterval(() => { if (urlListDirtyRef.current) return; void pullUrlConfigFromServer(); }, 8000); return () => clearInterval(id); }, [currentProc, urlAutoRefresh, pullUrlConfigFromServer]); const reloadUrlManual = async () => { if (!currentProc) return; setUrlReloading(true); setUrlFetchNote(null); urlListDirtyRef.current = false; try { const data = await fetchJson<{ content?: string }>( `/get_url_config?process=${encodeURIComponent(currentProc)}`, ); const c = data.content ?? ""; if (mode === "single") setUrlConfig(c); else setProUrlDraft(c); setUrlFetchNote(t("已从服务器读取 URL_config", "Reloaded URL_config from server")); } catch (e) { setUrlFetchNote((e as Error).message); } finally { setUrlReloading(false); } }; useEffect(() => { try { const m = window.localStorage.getItem(MODE_KEY); if (m === "pro" || m === "single") setMode(m); } catch { /* ignore */ } }, []); const setModePersist = (m: "single" | "pro") => { setMode(m); if (m === "pro") { const ch = channels.find((c) => c.id === activeCh) ?? channels[0]; if (ch) { const u = (ch.urlLines ?? "").trim(); setProUrlDraft(u || urlConfig); } } try { window.localStorage.setItem(MODE_KEY, m); } catch { /* ignore */ } }; useEffect(() => { const list = loadYoutubeProChannels(); const initial = list.length > 0 ? list : (() => { const id = newChannelId(); return [ { id, name: "线路 1", streamKey: "", urlLines: "", pm2Name: defaultPm2NameForChannel(id), notes: "", }, ]; })(); setChannels(initial); if (!list.length) saveYoutubeProChannels(initial); }, []); useEffect(() => { if (channels.length && !activeCh) setActiveCh(channels[0].id); }, [channels, activeCh]); const persistChannels = useCallback((next: YoutubeProChannel[]) => { setChannels(next); saveYoutubeProChannels(next); }, []); 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") { persistChannels(channels.map((x) => (x.id === active.id ? { ...x, urlLines: proUrlDraft } : x))); } const u = (c.urlLines ?? "").trim(); setProUrlDraft(u || urlConfig); setActiveCh(c.id); }, [active, mode, channels, proUrlDraft, urlConfig, 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); if (processOptions.some((e) => e.pm2 === want)) setCurrentProc(want); }, [mode, activeCh, channels, processOptions, setCurrentProc]); const loadYoutubeIni = useCallback(async () => { if (!currentProc) return; setYtIniLoading(true); setYtIniStatus(null); try { const data = await fetchJson<{ content?: string }>( `/get_config?process=${encodeURIComponent(currentProc)}`, ); const text = data.content ?? ""; setYoutubeIniText(text); setYtFields(parseYoutubeIniFields(text)); } catch (e) { setYtIniStatus((e as Error).message); } finally { setYtIniLoading(false); } }, [currentProc, fetchJson]); useEffect(() => { if (!currentProc) return; void loadYoutubeIni(); }, [currentProc, loadYoutubeIni]); const updateActive = (patch: Partial) => { if (!active) return; persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c))); }; const saveYoutubeIniToServer = async (content: string) => { if (!currentProc) { setYtIniStatus(t("请选择进程", "Pick a process")); return; } setYtIniLoading(true); setYtIniStatus(null); try { const params = new URLSearchParams({ process: currentProc }); await fetchJson(`/save_config?${params}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); setYoutubeIniText(content); setYtIniStatus(t("youtube.ini 已保存", "youtube.ini saved")); } catch (e) { setYtIniStatus((e as Error).message); } finally { setYtIniLoading(false); } }; const applyFieldsAndSave = async () => { const body = buildYoutubeIniFromFields(ytFields); await saveYoutubeIniToServer(body); }; const syncFieldsFromText = () => { setYtFields(parseYoutubeIniFields(youtubeIniText)); setYtIniStatus(t("已从下方原文同步到表单", "Synced from raw")); }; const keySuffixUi = useMemo(() => { if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`); const k = active?.streamKey ?? ""; return parseYoutubeStreamKeySuffix(`key = ${k}`); }, [mode, ytFields.key, active?.streamKey]); const saveProUrlAndServer = async () => { if (!active) return; updateActive({ urlLines: proUrlDraft }); setUrlConfig(proUrlDraft); try { await Promise.resolve(onSaveUrlConfig(proUrlDraft)); urlListDirtyRef.current = false; } catch { /* parent 已 toast */ } }; const saveSingleUrlAndClearDirty = async () => { try { await Promise.resolve(onSaveUrlConfig()); urlListDirtyRef.current = false; } catch { /* parent 已 toast */ } }; const pushProStreamKeyToServer = async () => { if (!active) return; const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields)); const f: YoutubeIniFields = { key: (active.streamKey ?? "").trim(), rtmps: base.rtmps, bitrate: base.bitrate, fastAudio: base.fastAudio, }; const body = buildYoutubeIniFromFields(f); await saveYoutubeIniToServer(body); updateActive({ streamKey: f.key }); }; const lock = busy || ytIniLoading || urlReloading; const urlToolbar = (
); return (

{t("YouTube 无人直播", "YouTube unmanned live")}

{t( "单路一条流;多路时每条线路一把密钥、一个进程名,填好密钥和直播地址后保存,再点「开始」。无需 Google 登录。", "Single lane or multi-lane: one key and one process per lane — fill key & URLs, save, then Start. No Google login.", )}

{t("直播开关", "Live control")}

{mode === "single" ? ( <>
{( [ ["start", t("开始直播", "Start"), "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"], ["stop", t("停止直播", "Stop"), "bg-rose-500/15 text-rose-200 ring-rose-500/25"], ["restart", t("重新开始", "Restart"), "bg-amber-500/15 text-amber-200 ring-amber-500/25"], ] as const ).map(([a, label, cls]) => ( ))}

key {t("尾码", "suffix")}: {keySuffixUi || "—"}

) : ( <>

{t("点选一行即可编辑该线路;此处只列出已保存过密钥的线路。", "Pick a row to edit. Only lanes with a saved key are listed.")}

{proLanesWithKey.length === 0 ? (

{channels.length > 0 ? t("请先在下方填写串流密钥并点「保存」。", "Enter a stream key below and tap Save.") : t("请先新增线路。", "Add a lane first.")}

) : (
{proLanesWithKey.map((c) => { const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id); const suffix = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`); const sel = activeCh === c.id; return (
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" }`} >
{suffix || "—"} {c.name}
e.stopPropagation()}>
); })}
)}

{t("当前线路", "Current lane")}: {active?.name ?? "—"} · key {keySuffixUi || "—"}

)}
{mode === "single" ? (

{t("YouTube 相关设置", "YouTube settings")}

{t("串流参数写进 youtube.ini,无需 Google 登录。", "Stream settings go to youtube.ini — no Google login.")}

setYtFields((f) => ({ ...f, key: e.target.value }))} placeholder="xxxx-xxxx-xxxx-xxxx-xxxx" />
{t("高级:youtube.ini 原文", "Raw youtube.ini")}