1049 lines
37 KiB
TypeScript
1049 lines
37 KiB
TypeScript
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,
|
||
}
|
||
}
|