Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
417 lines
14 KiB
TypeScript
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,
|
|
}
|
|
}
|