import { useCallback, useMemo, useState } from 'react' import { View, Text, ScrollView, TextInput, Pressable, StyleSheet, ActivityIndicator, Alert, RefreshControl, Linking, Switch, } from 'react-native' import { Picker } from '@react-native-picker/picker' import { SafeAreaView } from 'react-native-safe-area-context' import { useBind } from '@/context/BindContext' import { useRecorder } from '@/hooks/useRecorder' import { theme } from '@/constants/theme' import { douyinRoomAndHref, parseYoutubeStreamKeySuffix, proChannelPm2Process, } from '@/lib/youtubeConfigUtils' function statusTintBackground(variant: string): string { switch (variant) { case 'online': return 'rgba(61, 214, 140, 0.1)' case 'stopped': case 'error': return 'rgba(245, 101, 101, 0.1)' case 'warn': return 'rgba(246, 173, 85, 0.12)' case 'offline': return 'rgba(139, 146, 158, 0.11)' case 'loading': return 'rgba(91, 157, 255, 0.12)' case 'unknown': case 'empty': return 'rgba(91, 157, 255, 0.09)' default: return 'rgba(139, 146, 158, 0.1)' } } function ctrlStyle(action: string): object { switch (action) { case 'start': return styles.ctrl_start case 'stop': return styles.ctrl_stop case 'restart': return styles.ctrl_restart case 'status': return styles.ctrl_status default: return {} } } const variantColor: Record = { online: theme.success, stopped: theme.danger, error: theme.danger, warn: theme.warn, offline: theme.danger, loading: theme.muted, unknown: theme.accent, empty: theme.accent, } function ProLiveSwitchGrid({ rows, committedChannelKeys, proChannelEditors, highlightedChannelId, loadingAction, onSelectChannel, onStart, onStop, onRestart, }: { rows: { channelId: string; channelName: string; douyinUrl: string; live: boolean }[] committedChannelKeys: Record proChannelEditors: Record highlightedChannelId: string loadingAction: string | null onSelectChannel: (channelId: string) => void onStart: (channelId: string) => void onStop: (channelId: string) => void onRestart: (channelId: string) => void }) { const pmBusy = loadingAction !== null return ( {rows.map((row) => { const saved = committedChannelKeys[row.channelId]?.[0] ?? '' const ed = proChannelEditors[row.channelId] const activeKey = (saved || ed?.keys?.[0] || '').trim() const keySuffix = parseYoutubeStreamKeySuffix(`key = ${activeKey}`) const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '') const stopDisabled = pmBusy || !row.live const rowSelected = row.channelId === highlightedChannelId return ( onSelectChannel(row.channelId)}> {keySuffix || '—'} {row.live ? '直播中' : '未开播'} {room} · {row.channelName} {href ? ( void Linking.openURL(href)}> 在浏览器打开抖音页 ) : null} onStart(row.channelId)} > 开始 onStop(row.channelId)} > 停止 onRestart(row.channelId)} > 重新开始 ) })} ) } function LiveSwitchGrid({ loadingAction, onAction, disabledAll, }: { loadingAction: string | null onAction: (a: 'start' | 'stop' | 'restart' | 'status') => void disabledAll?: boolean }) { return ( {( [ ['start', '开始直播'], ['stop', '停止直播'], ['restart', '重新开始'], ['status', '刷新状态'], ] as const ).map(([action, label]) => ( { if (disabledAll) { Alert.alert('未绑定', '请先在「远程」页完成配对后再操作。') return } if (loadingAction !== null && action !== 'status') return void onAction(action) }} > {label} ))} ) } export default function RecordScreen() { const { bind } = useBind() const r = useRecorder() const unbound = !bind const [refreshing, setRefreshing] = useState(false) const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant]) const statusBg = useMemo(() => statusTintBackground(r.statusMeta.variant), [r.statusMeta.variant]) const onRefresh = useCallback(async () => { if (!bind) return setRefreshing(true) await r.refreshProcessList() setRefreshing(false) }, [bind, r.refreshProcessList]) if (r.bootError && bind) { return ( 无法连接:{r.bootError} 常见于网络瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。 void r.refreshProcessList()}> 重试加载任务列表 ) } return ( void onRefresh()} tintColor={theme.accent} /> ) : undefined } > {bind && r.transientLoadError ? ( 任务列表刷新失败:{r.transientLoadError} {'\n'} 下拉刷新重试;直播状态仍会每 2 秒轮询。 ) : null} {bind && ( 以下仅远程控制 PC 上的转播与 PM2 进程;推流与 FFmpeg 在电脑端执行,手机不跑拉流/转码。 )} {r.statusMeta.line} 多频道 PRO { if (unbound) return void r.togglePro(v) }} trackColor={{ false: 'rgba(255,255,255,0.12)', true: 'rgba(91, 157, 255, 0.45)', }} thumbColor={r.multiChannelPro ? '#7eb6ff' : '#5c6370'} ios_backgroundColor="rgba(255,255,255,0.1)" /> {bind ? ( r.setCurrentProcess(String(v))} style={styles.picker} dropdownIconColor={theme.text} > {!r.entries.length ? ( ) : ( r.entries.map((e) => ( )) )} ) : null} {bind && r.emptyHint ? ( 还没有可用的直播任务 {r.emptyHint} ) : unbound ? ( <> 直播开关 {}} /> YouTube 相关设置 配对后从 PC 读取 youtube.ini,与桌面端一致。 直播地址列表 要转播的直播间地址,一行一个。 OBS 推流 OBS 选自定义推流,服务器: srt://live.local:10080/live/obs 当前直播日志 每秒刷新 · 配对后显示进程输出与异常 {r.logText || '—'} ) : ( <> 直播开关 {r.isYoutube && r.multiChannelPro && r.proLiveRows.length > 0 ? ( r.setProChannelId(cid)} onStart={(cid) => void r.runProChannelStart(cid)} onStop={(cid) => void r.runPm2Action('stop', proChannelPm2Process(cid))} onRestart={(cid) => void r.runProChannelRestart(cid)} /> ) : r.isYoutube && r.multiChannelPro && r.proLiveRows.length === 0 ? ( {r.proChannelsList.length > 0 ? '暂无已保存的线路密钥。请在「当前频道」中保存串流密钥后,对应行将显示于此。' : '暂无频道。'} ) : ( void r.runPm2Action(a)} /> )} {r.isYoutube && r.multiChannelPro && ( 当前频道 {r.proChannelsError ? ( {r.proChannelsError} ) : ( <> r.setProChannelId(String(v))} style={styles.picker} dropdownIconColor={theme.text} > {r.proChannelsList.map((c) => ( ))} r.setNewChannelDraft({ keyText: '' })} > 新增频道 void r.deleteProChannel(r.proChannelId)} > {r.deleteChannelBusyId === r.proChannelId ? '…' : '删除当前频道'} {r.newChannelDraft ? ( 新频道串流密钥 r.setNewChannelDraft({ keyText: v })} placeholder="粘贴密钥" placeholderTextColor={theme.muted} autoCapitalize="none" /> r.setNewChannelDraft(null)} > 取消 void r.submitNewChannelDraft()} > {r.addChannelBusy ? '…' : '提交'} ) : null} )} )} {r.isYoutube && r.multiChannelPro && !r.proChannelsError && r.proChannelsList.length > 0 && ( <> YouTube 密钥(当前线路) void r.saveProChannelKey()} > {r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId ? ( ) : ( 保存密钥 )} {r.youtubeStatus} 直播地址列表(当前线路) void r.reloadProUrlFromServer()}> 重新读取 void r.saveProUrlConfig()} disabled={r.saveUrlLoading} > {r.saveUrlLoading ? '…' : '保存地址'} {r.urlStatus} )} {r.isYoutube && !r.multiChannelPro && ( YouTube 相关设置 串流密钥 r.setYtIniModel((m) => ({ ...m, keys: [v], activeIndex: 0 })) } placeholder="粘贴或编辑串流密钥" placeholderTextColor={theme.muted} autoCapitalize="none" /> 输出分辨率 r.setYtIniModel((m) => ({ ...m, options: { ...m.options, output_resolution: String(v) }, })) } style={styles.picker} dropdownIconColor={theme.text} > 是否使用 GPU(NVENC) r.setYtIniModel((m) => ({ ...m, options: { ...m.options, use_gpu: String(v) }, })) } style={styles.picker} dropdownIconColor={theme.text} > {r.gpuCaps?.nvenc_available ? ( <> ) : ( )} 音频降噪 r.setYtIniModel((m) => ({ ...m, options: { ...m.options, audio_denoise: String(v) }, })) } style={styles.picker} dropdownIconColor={theme.text} > void r.loadYoutubeIniModel()}> 重新读取 void r.saveYoutubeIniModel()} > {r.saveYoutubeLoading ? ( ) : ( 保存 )} {r.youtubeStatus} {!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && ( Google 授权与拉取密钥请在 PC 端操作。 )} )} {((r.isYoutube && !r.multiChannelPro) || r.isTiktok) && ( 直播地址列表 要转播的直播间地址写在这里,一行一个。 void r.loadConfig('/get_url_config', r.setUrlText, r.setUrlStatus)} > 重新读取 { r.setSaveUrlLoading(true) void r.saveConfig('/save_url_config', r.urlText, r.setUrlStatus, () => r.setSaveUrlLoading(false), ) }} > 保存 {r.urlStatus} )} {!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && r.isObs && ( OBS 推流 在 OBS 里选「自定义」推流,服务器填下面这一行: srt://live.local:10080/live/obs )} 当前直播日志 每 2 秒刷新 · 与上方状态同源 {r.logText || '—'} )} ) } const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: theme.bg }, fatalWrap: { flex: 1, padding: 20, justifyContent: 'center' }, fatalHint: { fontSize: 13, color: theme.muted, lineHeight: 20, marginTop: 12, marginBottom: 20 }, retryBtn: { alignSelf: 'flex-start', backgroundColor: '#3d7eff', paddingVertical: 12, paddingHorizontal: 18, borderRadius: 10, }, retryBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 }, warnBanner: { padding: 12, borderRadius: 10, backgroundColor: 'rgba(246,173,85,0.12)', borderWidth: 1, borderColor: 'rgba(246,173,85,0.35)', marginBottom: 14, }, warnBannerText: { fontSize: 13, color: theme.warn, lineHeight: 20 }, scroll: { padding: 16, paddingBottom: 32 }, banner: { padding: 12, borderRadius: 10, backgroundColor: 'rgba(91,157,255,0.1)', borderWidth: 1, borderColor: 'rgba(91,157,255,0.25)', marginBottom: 14, }, bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 }, toolbarCard: { marginBottom: 12, borderRadius: 14, borderWidth: 1, borderColor: 'rgba(255,255,255,0.07)', backgroundColor: theme.bgElevated, paddingVertical: 12, paddingHorizontal: 12, }, toolbarRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 10, }, toolbarLeft: { flex: 1, minWidth: 0, marginRight: 6 }, statusChip: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10, paddingHorizontal: 12, paddingLeft: 11, borderRadius: 12, borderLeftWidth: 3, borderTopWidth: 1, borderRightWidth: 1, borderBottomWidth: 1, borderTopColor: 'rgba(255,255,255,0.06)', borderRightColor: 'rgba(255,255,255,0.06)', borderBottomColor: 'rgba(255,255,255,0.06)', }, statusDotOuter: { width: 14, height: 14, borderRadius: 7, borderWidth: 2, alignItems: 'center', justifyContent: 'center', }, statusDotInner: { width: 6, height: 6, borderRadius: 3 }, statusLineText: { flex: 1, fontSize: 15, fontWeight: '600', color: theme.text, letterSpacing: 0.2, }, proCluster: { flexDirection: 'row', alignItems: 'center', flexShrink: 0, gap: 10, paddingLeft: 10, paddingVertical: 4, paddingRight: 4, borderRadius: 12, backgroundColor: 'rgba(91, 157, 255, 0.06)', borderWidth: 1, borderColor: 'rgba(91, 157, 255, 0.18)', }, proClusterDisabled: { opacity: 0.45 }, proLabelRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, proLabelZh: { fontSize: 13, fontWeight: '600', color: theme.text }, proBadge: { paddingHorizontal: 7, paddingVertical: 3, borderRadius: 6, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', }, proBadgeOn: { backgroundColor: 'rgba(91, 157, 255, 0.22)', borderColor: 'rgba(91, 157, 255, 0.45)', }, proBadgeText: { fontSize: 10, fontWeight: '800', color: theme.accent, letterSpacing: 0.6, }, proSwitchWrap: { transform: [{ scale: 0.92 }] }, unpairedText: { fontSize: 15, fontWeight: '600', color: theme.muted }, proGrid: { gap: 10 }, proSwitchRow: { padding: 12, borderRadius: 10, borderWidth: 1, borderColor: theme.border, backgroundColor: theme.bgCard, gap: 8, }, proSwitchRowOn: { borderColor: 'rgba(91,157,255,0.55)', backgroundColor: 'rgba(91,157,255,0.08)' }, proKeySuffix: { fontFamily: 'monospace', fontSize: 13, color: theme.accent, fontWeight: '600' }, proTag: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999 }, proTagOn: { backgroundColor: 'rgba(46,160,67,0.2)' }, proTagOff: { backgroundColor: 'rgba(110,118,129,0.2)' }, proTagText: { fontSize: 12, fontWeight: '600', color: theme.text }, proMid: { fontSize: 13, color: theme.text, lineHeight: 20 }, proLink: { fontSize: 12, color: theme.accent, marginTop: 6, fontWeight: '600' }, proActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 }, proBtn: { paddingVertical: 10, paddingHorizontal: 12, borderRadius: 8, minWidth: 72, alignItems: 'center' }, proBtnStart: { backgroundColor: '#0e7a0d' }, proBtnStop: { backgroundColor: '#d1342c' }, proBtnRestart: { backgroundColor: '#e68600' }, proBtnText: { color: '#fff', fontWeight: '700', fontSize: 12 }, newChBox: { marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: theme.border }, pickerWrap: { borderWidth: 1, borderColor: theme.border, borderRadius: 10, overflow: 'hidden', backgroundColor: theme.bgCard, }, pickerDisabled: { opacity: 0.65 }, picker: { color: theme.text }, pickerRowBelow: { marginTop: 0 }, dot: { width: 8, height: 8, borderRadius: 4 }, emptyCard: { padding: 20, borderRadius: 12, backgroundColor: theme.bgCard, borderWidth: 1, borderColor: theme.border, alignItems: 'center', }, emptyTitle: { fontSize: 15, fontWeight: '600', color: theme.muted, marginBottom: 10 }, card: { padding: 14, borderRadius: 12, backgroundColor: theme.bgCard, borderWidth: 1, borderColor: theme.border, marginBottom: 12, }, cardAccent: { borderColor: 'rgba(91,157,255,0.35)', backgroundColor: 'rgba(91,157,255,0.06)', }, cardTitle: { fontSize: 15, fontWeight: '600', color: theme.text, marginBottom: 10 }, fieldLbl: { fontSize: 12, fontWeight: '600', color: theme.muted, marginBottom: 6, marginTop: 4 }, inputSingle: { borderWidth: 1, borderColor: theme.border, borderRadius: 10, padding: 10, color: theme.text, fontSize: 14, marginBottom: 10, backgroundColor: theme.bg, }, area: { minHeight: 100, borderWidth: 1, borderColor: theme.border, borderRadius: 10, padding: 10, color: theme.text, fontFamily: 'monospace', fontSize: 12, marginBottom: 10, backgroundColor: theme.bg, }, inputDisabled: { opacity: 0.85 }, row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap' }, btn: { backgroundColor: '#3d7eff', paddingVertical: 10, paddingHorizontal: 18, borderRadius: 10, minWidth: 100, alignItems: 'center', }, btnDisabled: { opacity: 0.55 }, btnText: { color: '#fff', fontWeight: '700' }, btnSec: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border }, btnSecText: { color: theme.text, fontWeight: '600' }, hint: { color: theme.muted, fontSize: 14, lineHeight: 22, textAlign: 'center' }, hintSmall: { color: theme.muted, fontSize: 12, marginTop: 8, marginBottom: 8 }, err: { color: theme.danger, fontSize: 14 }, mono: { fontFamily: 'monospace', color: theme.accent, fontSize: 13, marginTop: 6 }, grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, ctrl: { flexGrow: 1, minWidth: '42%', paddingVertical: 14, borderRadius: 10, alignItems: 'center' }, ctrlDisabled: { opacity: 0.5 }, ctrl_start: { backgroundColor: '#0e7a0d' }, ctrl_stop: { backgroundColor: '#d1342c' }, ctrl_restart: { backgroundColor: '#e68600' }, ctrl_status: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border }, ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 }, logBox: { maxHeight: 280, backgroundColor: '#1e2128', borderRadius: 10, padding: 10 }, log: { fontFamily: 'monospace', fontSize: 11, color: '#d0d4dc', lineHeight: 18 }, })