import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Alert } from 'react-native' import { useAuth } from '@/context/AuthContext' import { useBind } from '@/context/BindContext' import { apiFetch, apiJson } from '@/lib/api' import { reportLiveControlEvent } from '@/lib/liveTelemetry' import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes' import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils' import { proChannelPm2Process } from '@/lib/youtubeConfigUtils' type Entry = { script: string; label: string; pm2: string } const UI_DROPDOWN_YOUTUBE_ONLY = true const UI_SHOW_YOUTUBE_GOOGLE_OAUTH = false type StatusVariant = | 'loading' | 'online' | 'stopped' | 'error' | 'warn' | 'offline' | 'unknown' | 'empty' type ProChannelEditor = { keys: string[]; activeIndex: number; urlText: string } type ProRelayStatusPayload = { channelStatuses?: { channelId: string; channelName?: string; live: boolean }[] } function mergeYoutubeOptionsForUi(opts: Record, caps: GpuCaps | null): Record { const o = { ...opts } const nv = caps?.nvenc_available ?? false const capsKnown = caps !== null const res = (o.output_resolution || '').toLowerCase() o.output_resolution = res.includes('1080') ? '1080p' : '720p' const ug = (o.use_gpu || '').trim() if (!ug) { o.use_gpu = capsKnown ? (nv ? '是' : '无') : '是' } else if (['是', '否', '无'].includes(ug)) { if (capsKnown && ug === '是' && !nv) o.use_gpu = '无' } else { o.use_gpu = capsKnown ? (nv ? '否' : '无') : '否' } const ad = (o.audio_denoise || '').trim().toLowerCase() if (!ad) o.audio_denoise = '否' else if (['是', '开', '1', 'true', 'on', 'yes'].includes(ad)) o.audio_denoise = '是' else o.audio_denoise = '否' return o } function statusPresentation(processStatus: string): { line: string; variant: StatusVariant } { if (processStatus === 'online') return { line: '正在直播', variant: 'online' } if (processStatus === 'stopped') return { line: '已停止', variant: 'stopped' } if (processStatus === 'errored') return { line: '出现异常', variant: 'error' } if (processStatus === 'launching' || processStatus === 'starting') return { line: '正在启动…', variant: 'warn' } if (processStatus === 'waiting restart') return { line: '等待重启', variant: 'warn' } if (processStatus === 'not_found') return { line: '尚未开始', variant: 'offline' } if (processStatus === 'invalid') return { line: '无法识别', variant: 'error' } return { line: '状态未知', variant: 'unknown' } } function pickProcessForEntries(filtered: Entry[], multi: boolean): string { if (!filtered.length) return '' if (multi) { const relay = filtered.find((e) => e.pm2.startsWith('youtube2__')) if (relay) return relay.pm2 } return filtered[0].pm2 } export function useRecorder() { const { bind } = useBind() const { pbToken, ensureCanControlLive, session } = useAuth() const pbTokRef = useRef(pbToken) pbTokRef.current = pbToken const [entries, setEntries] = useState([]) const [currentProcess, setCurrentProcess] = useState('') const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({ line: '—', variant: 'loading', }) const [logText, setLogText] = useState('') const [bootError, setBootError] = useState(null) const [transientLoadError, setTransientLoadError] = useState(null) const [emptyHint, setEmptyHint] = useState(null) const [ytIniModel, setYtIniModel] = useState({ keys: [''], activeIndex: 0, options: {}, header: '', }) const [gpuCaps, setGpuCaps] = useState(null) const [urlText, setUrlText] = useState('') const [youtubeStatus, setYoutubeStatus] = useState('就绪') const [urlStatus, setUrlStatus] = useState('就绪') const [loadingAction, setLoadingAction] = useState(null) const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false) const [saveUrlLoading, setSaveUrlLoading] = useState(false) const [multiChannelPro, setMultiChannelProState] = useState(false) const [proChannelId, setProChannelId] = useState('') const [proChannelsList, setProChannelsList] = useState< { id: string; name: string; keyPreview: string; douyinUrl?: string }[] >([]) const [proChannelsError, setProChannelsError] = useState(null) const [saveProKeyLoading, setSaveProKeyLoading] = useState(false) const [saveProKeyLoadingId, setSaveProKeyLoadingId] = useState(null) const [proChannelEditors, setProChannelEditors] = useState>({}) const [committedChannelKeys, setCommittedChannelKeys] = useState>({}) const [proRelayStatus, setProRelayStatus] = useState(null) const [newChannelDraft, setNewChannelDraft] = useState<{ keyText: string } | null>(null) const [addChannelBusy, setAddChannelBusy] = useState(false) const [deleteChannelBusyId, setDeleteChannelBusyId] = useState(null) const lastLiveStatusRef = useRef<{ process: string; status: string } | null>(null) const loadSucceededRef = useRef(false) const lastBindKeyRef = useRef('') const bindRef = useRef(bind) bindRef.current = bind const proChannelIdRef = useRef(proChannelId) proChannelIdRef.current = proChannelId const bindKey = useMemo( () => (bind ? `${String(bind.baseUrl ?? '').trim()}\0${String(bind.token ?? '').trim()}` : ''), [bind?.baseUrl, bind?.token], ) useEffect(() => { void getMultiChannelPro().then(setMultiChannelProState) }, []) useEffect(() => { if (!bindKey) return let cancelled = false void apiFetch(bindRef.current!, '/host/gpu_status', { pbToken: pbTokRef.current }) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) .then((j: GpuCaps) => { if (!cancelled) setGpuCaps({ nvidia_detected: !!j.nvidia_detected, nvenc_available: !!j.nvenc_available, }) }) .catch(() => { if (!cancelled) setGpuCaps({ nvidia_detected: false, nvenc_available: false }) }) return () => { cancelled = true } }, [bindKey]) const getEntry = useCallback( (name: string) => entries.find((e) => e.pm2 === name) ?? null, [entries], ) const ent = useMemo(() => getEntry(currentProcess), [getEntry, currentProcess]) const script = ent?.script ?? '' const isYoutube = ent ? isYoutubeScript(script) : false const isTiktok = ent ? isTiktokScript(script) : false const isObs = ent ? isObsScript(script) : false const loadProcessMonitor = useCallback(async () => { const b = bindRef.current if (!b) throw new Error('尚未绑定 PC') const res = await apiFetch(b, '/process_monitor', { pbToken: pbTokRef.current }) if (!res.ok) throw new Error(String(res.status)) const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] } const raw: Entry[] = (data.entries || []).map((e) => ({ script: e.script, label: e.label || e.script, pm2: e.pm2 || pm2NameFromScript(e.script), })) const filtered = UI_DROPDOWN_YOUTUBE_ONLY ? raw.filter((e) => isYoutubeScript(e.script)) : raw setEntries(filtered) return filtered }, [bind?.baseUrl, bind?.token]) const reloadProChannelsList = useCallback(async () => { const b = bindRef.current if (!b || !isYoutube || !multiChannelPro) return [] try { const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>( b, '/relay_pro/channels', { pbToken: pbTokRef.current }, ) if (!d.ok) { setProChannelsError(d.error || '无法加载 Pro 频道') setProChannelsList([]) return [] } setProChannelsError(null) const list = d.channels || [] setProChannelsList(list) return list } catch (e) { setProChannelsError(e instanceof Error ? e.message : String(e)) setProChannelsList([]) return [] } }, [isYoutube, multiChannelPro]) const togglePro = useCallback( async (v: boolean) => { if (!v) { setMultiChannelProState(false) await setMultiChannelPro(false) setProChannelEditors({}) setCommittedChannelKeys({}) setNewChannelDraft(null) return } const b = bindRef.current if (!b) { setYoutubeStatus('未绑定 PC') return } setYoutubeStatus('正在将当前配置同步到多频道…') try { const res = await apiFetch(b, '/relay_pro/channels', { pbToken: pbTokRef.current }) const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string } if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表') const firstId = d.channels[0].id const allKeys = ytIniModel.keys?.length ? ytIniModel.keys.map((k) => String(k)) : [''] const ai = Math.max(0, Math.min(ytIniModel.activeIndex, Math.max(0, allKeys.length - 1))) const keys = [(allKeys[ai] ?? allKeys[0] ?? '').trim() || ''] const keyRes = await apiFetch(b, '/relay_pro/channel_keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }), pbToken: pbTokRef.current, }) const kj = (await keyRes.json()) as { error?: string } if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败') const urlRes = await apiFetch( b, `/relay_pro/url_config?channel_id=${encodeURIComponent(firstId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: urlText }), pbToken: pbTokRef.current, }, ) const uj = (await urlRes.json()) as { error?: string } if (!urlRes.ok) throw new Error(uj.error || '同步地址列表失败') try { await apiFetch( b, `/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys, activeIndex: 0, options: ytIniModel.options, header: ytIniModel.header || '', }), pbToken: pbTokRef.current, }, ) } catch { /* 选项同步失败不阻断 */ } setMultiChannelProState(true) await setMultiChannelPro(true) setProChannelId(firstId) setYoutubeStatus('已切换到多频道(已沿用当前单路配置)') await reloadProChannelsList() } catch (e) { setYoutubeStatus(`切换失败:${e instanceof Error ? e.message : String(e)}`) } }, [ytIniModel, urlText, currentProcess, reloadProChannelsList], ) const loadConfig = useCallback( async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => { if (!bind || !currentProcess) return try { const res = await apiFetch( bind, `${endpoint}?process=${encodeURIComponent(currentProcess)}`, { pbToken: pbTokRef.current }, ) if (!res.ok) throw new Error(`连接失败 ${res.status}`) const data = (await res.json()) as { content?: string } setContent(data.content || '') setSt('已读到最新内容') } catch (err) { setSt(`读取失败:${err instanceof Error ? err.message : String(err)}`) } }, [bind, currentProcess], ) const saveConfig = useCallback( async ( endpoint: string, content: string, setSt: (s: string) => void, endLoading: () => void, ) => { if (!bind) return try { const res = await apiFetch( bind, `${endpoint}?process=${encodeURIComponent(currentProcess)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }), pbToken: pbTokRef.current, }, ) if (!res.ok) throw new Error(`保存失败 ${res.status}`) const data = (await res.json()) as { message?: string } setSt(data.message || '已保存') } catch (err) { setSt(`未能保存:${err instanceof Error ? err.message : String(err)}`) } finally { endLoading() } }, [bind, currentProcess], ) const loadYoutubeIniModel = useCallback(async () => { if (!bind || !currentProcess) return try { const res = await apiFetch( bind, `/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`, { pbToken: pbTokRef.current }, ) const data = (await res.json()) as Partial & { error?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) let keys = Array.isArray(data.keys) && data.keys.length ? data.keys.map(String) : [''] let ai = typeof data.activeIndex === 'number' ? Math.max(0, Math.min(data.activeIndex, keys.length - 1)) : 0 if (!multiChannelPro) { const single = (keys[ai] ?? '').trim() keys = [single] ai = 0 } let options = data.options && typeof data.options === 'object' ? { ...data.options } : {} options = mergeYoutubeOptionsForUi(options, gpuCaps) setYtIniModel({ keys, activeIndex: ai, options, header: typeof data.header === 'string' ? data.header : '', }) setYoutubeStatus('已读取') } catch (err) { setYoutubeStatus(`读取失败:${err instanceof Error ? err.message : String(err)}`) } }, [bind, currentProcess, multiChannelPro, gpuCaps]) const saveYoutubeIniModel = useCallback(async () => { if (!bind || !currentProcess) return setSaveYoutubeLoading(true) try { const modelToSave = !multiChannelPro ? { ...ytIniModel, keys: [(ytIniModel.keys[0] ?? '').trim()], activeIndex: 0, } : ytIniModel const res = await apiFetch( bind, `/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(modelToSave), pbToken: pbTokRef.current, }, ) const data = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) setYoutubeStatus(data.message || '已保存') } catch (err) { setYoutubeStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) } finally { setSaveYoutubeLoading(false) } }, [bind, currentProcess, ytIniModel, multiChannelPro]) const saveProChannelKeysFor = useCallback( async (channelId: string): Promise => { const b = bindRef.current if (!b || !channelId) return false const ed = proChannelEditors[channelId] if (!ed) return false const keysOne = [(ed.keys[0] ?? '').trim()] setSaveProKeyLoadingId(channelId) try { const res = await apiFetch(b, '/relay_pro/channel_keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelId, keys: keysOne, activeIndex: 0, }), pbToken: pbTokRef.current, }) const data = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) setProChannelEditors((prev) => { const cur = prev[channelId] if (!cur) return prev return { ...prev, [channelId]: { ...cur, keys: keysOne, activeIndex: 0 } } }) const cleaned = keysOne.map((s) => String(s).trim()).filter(Boolean) setCommittedChannelKeys((prev) => ({ ...prev, [channelId]: cleaned })) setYoutubeStatus(data.message || '密钥已保存') return true } catch (err) { setYoutubeStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) return false } finally { setSaveProKeyLoadingId(null) } }, [proChannelEditors], ) const saveProChannelKey = useCallback(async () => { if (!proChannelId) return setSaveProKeyLoading(true) try { await saveProChannelKeysFor(proChannelId) } finally { setSaveProKeyLoading(false) } }, [proChannelId, saveProChannelKeysFor]) const saveProUrlConfig = useCallback(async () => { const b = bindRef.current if (!b || !proChannelId) return const ed = proChannelEditors[proChannelId] if (!ed) return setSaveUrlLoading(true) try { const res = await apiFetch( b, `/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: ed.urlText }), pbToken: pbTokRef.current, }, ) const data = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) setUrlStatus(data.message || '已保存') } catch (err) { setUrlStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) } finally { setSaveUrlLoading(false) } }, [proChannelId, proChannelEditors]) const reloadProUrlFromServer = useCallback(async () => { const b = bindRef.current if (!b || !proChannelId) return try { const ur = await apiFetch( b, `/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`, { pbToken: pbTokRef.current }, ) if (!ur.ok) { setUrlStatus('读取失败') return } const u = (await ur.json()) as { content?: string } const content = u.content || '' setProChannelEditors((prev) => { const cur = prev[proChannelId] || { keys: [''], activeIndex: 0, urlText: '' } return { ...prev, [proChannelId]: { ...cur, urlText: content } } }) setUrlStatus('已重新读取') } catch (e) { setUrlStatus(e instanceof Error ? e.message : String(e)) } }, [proChannelId]) const setProChannelKeyText = useCallback((v: string) => { setProChannelEditors((prev) => { const cur = prev[proChannelId] || { keys: [''], activeIndex: 0, urlText: '' } return { ...prev, [proChannelId]: { ...cur, keys: [v], activeIndex: 0 } } }) }, [proChannelId]) const setProChannelUrlText = useCallback((v: string) => { setProChannelEditors((prev) => { const cur = prev[proChannelId] || { keys: [''], activeIndex: 0, urlText: '' } return { ...prev, [proChannelId]: { ...cur, urlText: v } } }) }, [proChannelId]) const procForPm2 = useCallback( (processOverride?: string) => { if (processOverride) return processOverride if (isYoutube && multiChannelPro && proChannelId) return proChannelPm2Process(proChannelId) return currentProcess }, [isYoutube, multiChannelPro, proChannelId, currentProcess], ) const runPm2Action = useCallback( async (action: string, processOverride?: string) => { if (!bindRef.current) { setLogText('未绑定 PC:请在「远程」页扫码或粘贴 JSON 完成配对后再操作。') setStatusMeta({ line: '未连接', variant: 'offline' }) return } const proc = procForPm2(processOverride) const isStatus = action === 'status' if (!isStatus) { if (action === 'start' || action === 'restart') { const allowed = await ensureCanControlLive() if (!allowed) return } setLoadingAction(action) } try { const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`, { pbToken: pbTokRef.current, }) if (!res.ok) throw new Error('无法连接到本机服务') const data = (await res.json()) as Record if (action === 'status') { const ps = data.process_status as string const prev = lastLiveStatusRef.current const prevPs = prev?.process === proc ? prev.status : undefined if (!prev || prev.process !== proc || prev.status !== ps) { lastLiveStatusRef.current = { process: proc, status: ps } } const { line, variant } = statusPresentation(ps) setStatusMeta({ line, variant }) let textOut = `── 状态 · ${new Date().toLocaleTimeString()} ──\n${data.raw_status}\n\n` const rl = data.recent_log as string const re = data.recent_error as string textOut += `【输出】\n${rl || '(暂无)'}\n\n【异常】\n${re || '(暂无)'}` setLogText(textOut) } else { const e = getEntry(proc) const who = e ? e.label : '当前任务' const out = typeof data.output === 'string' ? data.output : [data.pm2_output, data.message, data.note] .filter((x): x is string => typeof x === 'string') .join('\n') || '已完成' const actionLabel = action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`) if (action === 'start' || action === 'restart' || action === 'stop') { void reportLiveControlEvent( bindRef.current, pbTokRef.current, session?.record?.id, proc, action, ) } const statusProc = processOverride ?? proc setTimeout(() => void runPm2Action('status', statusProc), 1500) } } catch (err) { setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`) setStatusMeta({ line: '状态未知', variant: 'unknown' }) } finally { if (!isStatus) setLoadingAction(null) } }, [bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id], ) const runProChannelStart = useCallback( async (channelId: string) => { if (!channelId) return const allowed = await ensureCanControlLive() if (!allowed) return const pm = proChannelPm2Process(channelId) setLoadingAction('start') try { const saved = await saveProChannelKeysFor(channelId) if (!saved) return const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`, { pbToken: pbTokRef.current, }) if (!res.ok) throw new Error('无法连接到本机服务') const data = (await res.json()) as Record const e = getEntry(pm) const who = e ? e.label : '当前任务' const out = typeof data.output === 'string' ? data.output : [data.pm2_output, data.message, data.note] .filter((x): x is string => typeof x === 'string') .join('\n') || '已完成' setLogText(`已对「${who}」执行:开始直播\n\n${out}`) void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start') setTimeout(() => void runPm2Action('status', pm), 1500) } catch (err) { setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`) } finally { setLoadingAction(null) } }, [getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id], ) const runProChannelRestart = useCallback( async (channelId: string) => { if (!channelId) return const allowed = await ensureCanControlLive() if (!allowed) return const pm = proChannelPm2Process(channelId) setLoadingAction('restart') try { const saved = await saveProChannelKeysFor(channelId) if (!saved) return const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`, { pbToken: pbTokRef.current, }) if (!res.ok) throw new Error('无法连接到本机服务') const data = (await res.json()) as Record const e = getEntry(pm) const who = e ? e.label : '当前任务' const out = typeof data.output === 'string' ? data.output : [data.pm2_output, data.message, data.note] .filter((x): x is string => typeof x === 'string') .join('\n') || '已完成' setLogText(`已对「${who}」执行:重新开始\n\n${out}`) void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart') setTimeout(() => void runPm2Action('status', pm), 1500) } catch (err) { setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`) } finally { setLoadingAction(null) } }, [getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id], ) const submitNewChannelDraft = useCallback(async () => { const b = bindRef.current if (!b || !newChannelDraft) return setAddChannelBusy(true) try { const key = newChannelDraft.keyText.trim() const res = await apiFetch(b, '/relay_pro/channel_add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `频道 ${proChannelsList.length + 1}`, youtubeKey: key, }), pbToken: pbTokRef.current, }) const d = (await res.json()) as { error?: string; message?: string; channelId?: string } if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`) setYoutubeStatus(d.message || '已新增频道') setNewChannelDraft(null) const list = await reloadProChannelsList() const nid = (d.channelId || '').trim() if (nid) setProChannelId(nid) else if (list.length) setProChannelId(list[0].id) } catch (e) { setYoutubeStatus(`新增失败:${e instanceof Error ? e.message : String(e)}`) } finally { setAddChannelBusy(false) } }, [newChannelDraft, proChannelsList.length, reloadProChannelsList]) const deleteProChannel = useCallback( async (channelId: string) => { if (proChannelsList.length <= 1) { setYoutubeStatus('至少保留一条频道') return } const b = bindRef.current if (!b) return Alert.alert('删除频道', '确定删除该频道?将移除配置与独立地址文件,且不可恢复。', [ { text: '取消', style: 'cancel' }, { text: '删除', style: 'destructive', onPress: () => { void (async () => { setNewChannelDraft(null) setDeleteChannelBusyId(channelId) try { const res = await apiFetch(b, '/relay_pro/channel_delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelId }), pbToken: pbTokRef.current, }) const d = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`) setYoutubeStatus(d.message || '已删除频道') setProChannelEditors((prev) => { const n = { ...prev } delete n[channelId] return n }) setCommittedChannelKeys((prev) => { const n = { ...prev } delete n[channelId] return n }) const nextList = await reloadProChannelsList() if (proChannelId === channelId && nextList.length > 0) { setProChannelId(nextList[0].id) } } catch (e) { setYoutubeStatus(`删除失败:${e instanceof Error ? e.message : String(e)}`) } finally { setDeleteChannelBusyId(null) } })() }, }, ]) }, [proChannelsList.length, proChannelId, reloadProChannelsList], ) const proLiveRows = useMemo(() => { const liveMap = new Map( (proRelayStatus?.channelStatuses ?? []).map((s) => [s.channelId, s.live]), ) const rows: { channelId: string; channelName: string; douyinUrl: string; live: boolean }[] = [] for (const c of proChannelsList) { const saved = committedChannelKeys[c.id] if (!saved?.length) continue rows.push({ channelId: c.id, channelName: c.name, douyinUrl: c.douyinUrl ?? '', live: liveMap.get(c.id) ?? false, }) } return rows }, [proChannelsList, proRelayStatus, committedChannelKeys]) const channelIdsSig = useMemo(() => proChannelsList.map((c) => c.id).join('\0'), [proChannelsList]) useEffect(() => { if (!bindKey) { loadSucceededRef.current = false lastBindKeyRef.current = '' setBootError(null) setTransientLoadError(null) setEmptyHint(null) setEntries([]) setCurrentProcess('') setStatusMeta({ line: '未连接', variant: 'offline' }) setLogText('') return } if (lastBindKeyRef.current !== bindKey) { lastBindKeyRef.current = bindKey loadSucceededRef.current = false } let cancelled = false void (async () => { try { const filtered = await loadProcessMonitor() if (cancelled) return loadSucceededRef.current = true setBootError(null) setTransientLoadError(null) if (!filtered.length) { setEmptyHint( UI_DROPDOWN_YOUTUBE_ONLY ? '没有发现 YouTube 录制任务。' : '没有发现可用的录制任务。', ) setStatusMeta({ line: '暂无可选任务', variant: 'empty' }) return } setEmptyHint(null) const multi = await getMultiChannelPro() if (cancelled) return setCurrentProcess(pickProcessForEntries(filtered, multi)) } catch (e) { if (cancelled) return const msg = e instanceof Error ? e.message : String(e) if (loadSucceededRef.current) { setTransientLoadError(msg) setBootError(null) } else { setBootError(msg) } setStatusMeta({ line: '未连接', variant: 'offline' }) } })() return () => { cancelled = true } }, [bindKey, loadProcessMonitor]) const refreshProcessList = useCallback(async () => { if (!bindRef.current) return setTransientLoadError(null) setBootError(null) try { const filtered = await loadProcessMonitor() loadSucceededRef.current = true setBootError(null) if (!filtered.length) { setEmptyHint( UI_DROPDOWN_YOUTUBE_ONLY ? '没有发现 YouTube 录制任务。' : '没有发现可用的录制任务。', ) setStatusMeta({ line: '暂无可选任务', variant: 'empty' }) return } setEmptyHint(null) const multi = await getMultiChannelPro() setCurrentProcess((prev) => { if (filtered.some((e) => e.pm2 === prev)) { if (multi) { const relay = filtered.find((e) => e.pm2.startsWith('youtube2__')) if (relay && prev !== relay.pm2) return relay.pm2 } return prev } return pickProcessForEntries(filtered, multi) }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) if (loadSucceededRef.current) setTransientLoadError(msg) else setBootError(msg) } }, [loadProcessMonitor]) useEffect(() => { if (!entries.length || !currentProcess || !bindKey) return if (isYoutube && multiChannelPro && !proChannelId) return void runPm2Action('status') }, [entries.length, currentProcess, bindKey, isYoutube, multiChannelPro, proChannelId]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!entries.length || !currentProcess || !bindKey) return if (isYoutube && multiChannelPro && !proChannelId) return const pm = procForPm2() const id = setInterval(() => void runPm2Action('status'), 2000) return () => clearInterval(id) }, [entries.length, currentProcess, bindKey, isYoutube, multiChannelPro, proChannelId, procForPm2]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (!ent || !currentProcess || !bind) return if (isYoutube && multiChannelPro) return if (isYoutube) void loadYoutubeIniModel() if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus) }, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro, bind, loadYoutubeIniModel]) useEffect(() => { if (!bind || !isYoutube || !multiChannelPro) return let cancelled = false void (async () => { try { const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>( bind, '/relay_pro/channels', { pbToken: pbTokRef.current }, ) if (cancelled) return if (!d.ok) { setProChannelsError(d.error || '无法加载 Pro 频道') setProChannelsList([]) return } setProChannelsError(null) setProChannelsList(d.channels || []) setProChannelId((prev) => { if (prev && (d.channels || []).some((c) => c.id === prev)) return prev return d.channels?.[0]?.id ?? '' }) } catch (e) { if (!cancelled) { setProChannelsError(e instanceof Error ? e.message : String(e)) setProChannelsList([]) } } })() return () => { cancelled = true } }, [bind, isYoutube, multiChannelPro]) useEffect(() => { if (!bind || !isYoutube || !multiChannelPro) return let cancelled = false const poll = async () => { try { const r = await apiFetch(bind, '/relay_pro/live_status', { pbToken: pbTokRef.current }) if (!r.ok || cancelled) return const j = (await r.json()) as ProRelayStatusPayload if (!cancelled) setProRelayStatus(j) } catch { /* ignore */ } } void poll() const id = setInterval(poll, 2000) return () => { cancelled = true clearInterval(id) } }, [bind, isYoutube, multiChannelPro]) useEffect(() => { if (!isYoutube || !multiChannelPro || !proChannelsList.length) { if (!multiChannelPro) setProChannelEditors({}) return } let cancelled = false void (async () => { const next: Record = {} const committed: Record = {} const b = bindRef.current if (!b) return for (const c of proChannelsList) { try { const dr = await apiFetch( b, `/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`, { pbToken: pbTokRef.current }, ) if (!dr.ok) continue const cd = (await dr.json()) as { youtubeKey?: string youtubeKeys?: string[] } const ksRaw = Array.isArray(cd.youtubeKeys) && cd.youtubeKeys.length ? cd.youtubeKeys.map(String) : [cd.youtubeKey || ''] const k0 = (ksRaw[0] ?? '').trim() next[c.id] = { keys: [k0 || ''], activeIndex: 0, urlText: '' } committed[c.id] = k0 ? [k0] : [] } catch { /* skip */ } } if (!cancelled) { setProChannelEditors((prev) => { const merged: Record = {} for (const id of Object.keys(next)) { merged[id] = { ...next[id], urlText: prev[id]?.urlText ?? '' } } return merged }) setCommittedChannelKeys(committed) } })() return () => { cancelled = true } }, [isYoutube, multiChannelPro, channelIdsSig, proChannelsList]) useEffect(() => { if (!bind || !isYoutube || !multiChannelPro || !proChannelId) return const cid = proChannelId let cancelled = false void (async () => { try { const ur = await apiFetch( bind, `/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`, { pbToken: pbTokRef.current }, ) if (!ur.ok || cancelled) return const j = (await ur.json()) as { content?: string } const content = String(j.content ?? '') if (cid !== proChannelIdRef.current) return setProChannelEditors((prev) => { const cur = prev[cid] || { keys: [''], activeIndex: 0, urlText: '' } return { ...prev, [cid]: { ...cur, urlText: content } } }) setUrlStatus('已读取当前线路地址') } catch { if (!cancelled) setUrlStatus('读取失败') } })() return () => { cancelled = true } }, [bind, isYoutube, multiChannelPro, proChannelId]) useEffect(() => { if (!multiChannelPro) { setCommittedChannelKeys({}) setProRelayStatus(null) } }, [multiChannelPro]) useEffect(() => { if (!isYoutube || !multiChannelPro) return if (proChannelId) return if (proLiveRows.length === 0) return setProChannelId(proLiveRows[0].channelId) }, [isYoutube, multiChannelPro, proChannelId, proLiveRows]) return { bootError, transientLoadError, refreshProcessList, emptyHint, entries, currentProcess, setCurrentProcess, statusMeta, logText, ytIniModel, setYtIniModel, loadYoutubeIniModel, saveYoutubeIniModel, gpuCaps, urlText, setUrlText, youtubeStatus, urlStatus, loadingAction, saveYoutubeLoading, saveUrlLoading, multiChannelPro, togglePro, proChannelId, setProChannelId, proChannelsList, proChannelsError, proChannelEditors, setProChannelKeyText, setProChannelUrlText, saveProKeyLoading, saveProKeyLoadingId, saveProUrlConfig, reloadProUrlFromServer, saveProChannelKey, runPm2Action, runProChannelStart, runProChannelRestart, loadConfig, saveConfig, setSaveYoutubeLoading, setSaveUrlLoading, ent, isYoutube, isTiktok, isObs, UI_SHOW_YOUTUBE_GOOGLE_OAUTH, setYoutubeStatus, setUrlStatus, proLiveRows, proRelayStatus, committedChannelKeys, newChannelDraft, setNewChannelDraft, submitNewChannelDraft, addChannelBusy, deleteProChannel, deleteChannelBusyId, reloadProChannelsList, } }