Files
eric b77a8cadb2
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
'init'
2026-03-24 14:46:41 -05:00

417 lines
14 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useBind } from '@/context/BindContext'
import { apiFetch, apiJson } from '@/lib/api'
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
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'
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' }
}
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 [emptyHint, setEmptyHint] = useState<string | null>(null)
const [youtubeText, setYoutubeText] = useState('')
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 [proYoutubeKey, setProYoutubeKey] = useState('')
const [saveProKeyLoading, setSaveProKeyLoading] = useState(false)
const lastLiveStatusRef = useRef<{ process: string; status: string } | null>(null)
useEffect(() => {
void getMultiChannelPro().then(setMultiChannelProState)
}, [])
const togglePro = useCallback(async (v: boolean) => {
setMultiChannelProState(v)
await setMultiChannelPro(v)
}, [])
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 res = await apiFetch(bind, '/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])
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 saveProChannelKey = useCallback(async () => {
if (!bind || !proChannelId) return
setSaveProKeyLoading(true)
try {
const res = await apiFetch(bind, '/relay_pro/channel_key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId: proChannelId, youtubeKey: proYoutubeKey }),
})
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 {
setSaveProKeyLoading(false)
}
}, [bind, proChannelId, proYoutubeKey])
const saveProUrlConfig = useCallback(async () => {
if (!bind || !proChannelId) return
setSaveUrlLoading(true)
try {
const res = await apiFetch(
bind,
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: 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)
}
}, [bind, proChannelId, urlText])
const runPm2Action = useCallback(
async (action: string) => {
if (!bind) return
const isStatus = action === 'status'
if (!isStatus) setLoadingAction(action)
try {
if (action === 'start' && isYoutube && multiChannelPro) {
if (!proChannelId) throw new Error('请先选择一条转播线路')
const sync = await apiFetch(
bind,
`/relay_pro/sync_active?channel_id=${encodeURIComponent(proChannelId)}`,
{ method: 'POST' },
)
const sj = (await sync.json().catch(() => ({}))) as { error?: string }
if (!sync.ok) throw new Error(sj.error || '同步失败')
}
const res = await apiFetch(
bind,
`/${action}?process=${encodeURIComponent(currentProcess)}`,
)
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 === currentProcess ? prev.status : undefined
if (!prev || prev.process !== currentProcess || prev.status !== ps) {
lastLiveStatusRef.current = { process: currentProcess, 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(currentProcess)
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}`)
setTimeout(() => void runPm2Action('status'), 1500)
}
} catch (err) {
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
setStatusMeta({ line: '状态未知', variant: 'unknown' })
} finally {
if (!isStatus) setLoadingAction(null)
}
},
[bind, currentProcess, getEntry, isYoutube, multiChannelPro, proChannelId],
)
useEffect(() => {
if (!bind) {
setBootError(null)
setEmptyHint('请先在「配对」页扫码绑定 PC。')
setEntries([])
return
}
let cancelled = false
void (async () => {
try {
const filtered = await loadProcessMonitor()
if (cancelled) return
setBootError(null)
if (!filtered.length) {
setEmptyHint(
UI_DROPDOWN_YOUTUBE_ONLY
? '没有发现 YouTube 录制任务。'
: '没有发现可用的录制任务。',
)
setStatusMeta({ line: '暂无可选任务', variant: 'empty' })
return
}
setEmptyHint(null)
setCurrentProcess(filtered[0].pm2)
} catch (e) {
setBootError(e instanceof Error ? e.message : String(e))
setStatusMeta({ line: '未连接', variant: 'offline' })
}
})()
return () => {
cancelled = true
}
}, [bind, loadProcessMonitor])
useEffect(() => {
if (!entries.length || !currentProcess || !bind) return
void runPm2Action('status')
}, [entries.length, currentProcess, bind]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!entries.length || !currentProcess || !bind) return
const id = setInterval(() => void runPm2Action('status'), 2000)
return () => clearInterval(id)
}, [entries.length, currentProcess, bind]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!ent || !currentProcess || !bind) return
if (isYoutube && multiChannelPro) return
if (isYoutube) void loadConfig('/get_config', setYoutubeText, setYoutubeStatus)
if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus)
}, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro, bind])
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 || !proChannelId) return
let cancelled = false
void (async () => {
try {
const dr = await apiFetch(
bind,
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(proChannelId)}`,
)
if (!dr.ok) {
if (!cancelled) setYoutubeStatus('读取线路失败')
return
}
const cd = (await dr.json()) as { youtubeKey?: string }
if (cancelled) return
setProYoutubeKey(cd.youtubeKey || '')
const ur = await apiFetch(
bind,
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
)
if (ur.ok) {
const u = (await ur.json()) as { content?: string }
if (!cancelled) {
setUrlText(u.content || '')
setUrlStatus('已读取当前线路地址')
}
}
} catch (e) {
if (!cancelled) setUrlStatus(e instanceof Error ? e.message : String(e))
}
})()
return () => {
cancelled = true
}
}, [bind, isYoutube, multiChannelPro, proChannelId])
return {
bootError,
emptyHint,
entries,
currentProcess,
setCurrentProcess,
statusMeta,
logText,
youtubeText,
setYoutubeText,
urlText,
setUrlText,
youtubeStatus,
urlStatus,
loadingAction,
saveYoutubeLoading,
saveUrlLoading,
multiChannelPro,
togglePro,
proChannelId,
setProChannelId,
proChannelsList,
proChannelsError,
proYoutubeKey,
setProYoutubeKey,
saveProKeyLoading,
saveProUrlConfig,
saveProChannelKey,
runPm2Action,
loadConfig,
saveConfig,
setSaveYoutubeLoading,
setSaveUrlLoading,
ent,
isYoutube,
isTiktok,
isObs,
UI_SHOW_YOUTUBE_GOOGLE_OAUTH,
setYoutubeStatus,
setUrlStatus,
}
}