Files
2026-03-25 12:29:03 -05:00

1049 lines
37 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert } from 'react-native'
import { useBind } from '@/context/BindContext'
import { apiFetch, apiJson } from '@/lib/api'
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<string, string>, caps: GpuCaps | null): Record<string, string> {
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 [entries, setEntries] = useState<Entry[]>([])
const [currentProcess, setCurrentProcess] = useState('')
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
line: '—',
variant: 'loading',
})
const [logText, setLogText] = useState('')
const [bootError, setBootError] = useState<string | null>(null)
const [transientLoadError, setTransientLoadError] = useState<string | null>(null)
const [emptyHint, setEmptyHint] = useState<string | null>(null)
const [ytIniModel, setYtIniModel] = useState<YoutubeIniModel>({
keys: [''],
activeIndex: 0,
options: {},
header: '',
})
const [gpuCaps, setGpuCaps] = useState<GpuCaps | null>(null)
const [urlText, setUrlText] = useState('')
const [youtubeStatus, setYoutubeStatus] = useState('就绪')
const [urlStatus, setUrlStatus] = useState('就绪')
const [loadingAction, setLoadingAction] = useState<string | null>(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<string | null>(null)
const [saveProKeyLoading, setSaveProKeyLoading] = useState(false)
const [saveProKeyLoadingId, setSaveProKeyLoadingId] = useState<string | null>(null)
const [proChannelEditors, setProChannelEditors] = useState<Record<string, ProChannelEditor>>({})
const [committedChannelKeys, setCommittedChannelKeys] = useState<Record<string, string[]>>({})
const [proRelayStatus, setProRelayStatus] = useState<ProRelayStatusPayload | null>(null)
const [newChannelDraft, setNewChannelDraft] = useState<{ keyText: string } | null>(null)
const [addChannelBusy, setAddChannelBusy] = useState(false)
const [deleteChannelBusyId, setDeleteChannelBusyId] = useState<string | null>(null)
const lastLiveStatusRef = useRef<{ process: string; status: string } | null>(null)
const loadSucceededRef = useRef(false)
const lastBindKeyRef = useRef<string>('')
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')
.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')
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',
)
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')
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 }),
})
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 }),
},
)
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 || '',
}),
},
)
} 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)}`,
)
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 }),
},
)
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)}`,
)
const data = (await res.json()) as Partial<YoutubeIniModel> & { 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),
},
)
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<boolean> => {
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,
}),
})
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 }),
},
)
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)}`,
)
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) setLoadingAction(action)
try {
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`)
if (!res.ok) throw new Error('无法连接到本机服务')
const data = (await res.json()) as Record<string, unknown>
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}`)
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],
)
const runProChannelStart = useCallback(
async (channelId: string) => {
if (!channelId) 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)}`)
if (!res.ok) throw new Error('无法连接到本机服务')
const data = (await res.json()) as Record<string, unknown>
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}`)
setTimeout(() => void runPm2Action('status', pm), 1500)
} catch (err) {
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
} finally {
setLoadingAction(null)
}
},
[getEntry, runPm2Action, saveProChannelKeysFor],
)
const runProChannelRestart = useCallback(
async (channelId: string) => {
if (!channelId) 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)}`)
if (!res.ok) throw new Error('无法连接到本机服务')
const data = (await res.json()) as Record<string, unknown>
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}`)
setTimeout(() => void runPm2Action('status', pm), 1500)
} catch (err) {
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
} finally {
setLoadingAction(null)
}
},
[getEntry, runPm2Action, saveProChannelKeysFor],
)
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,
}),
})
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 }),
})
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',
)
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')
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<string, ProChannelEditor> = {}
const committed: Record<string, string[]> = {}
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)}`,
)
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<string, ProChannelEditor> = {}
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)}`,
)
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,
}
}