915 lines
34 KiB
TypeScript
915 lines
34 KiB
TypeScript
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<string, string> = {
|
||
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<string, string[]>
|
||
proChannelEditors: Record<string, { keys: string[]; urlText: string }>
|
||
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 (
|
||
<View style={styles.proGrid}>
|
||
{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 (
|
||
<View
|
||
key={row.channelId}
|
||
style={[styles.proSwitchRow, rowSelected && styles.proSwitchRowOn]}
|
||
>
|
||
<Pressable onPress={() => onSelectChannel(row.channelId)}>
|
||
<Text style={styles.proKeySuffix} numberOfLines={1}>
|
||
{keySuffix || '—'}
|
||
</Text>
|
||
<View style={[styles.proTag, row.live ? styles.proTagOn : styles.proTagOff]}>
|
||
<Text style={styles.proTagText}>{row.live ? '直播中' : '未开播'}</Text>
|
||
</View>
|
||
<Text style={styles.proMid} numberOfLines={2}>
|
||
{room} · {row.channelName}
|
||
</Text>
|
||
</Pressable>
|
||
{href ? (
|
||
<Pressable onPress={() => void Linking.openURL(href)}>
|
||
<Text style={styles.proLink}>在浏览器打开抖音页</Text>
|
||
</Pressable>
|
||
) : null}
|
||
<View style={styles.proActions}>
|
||
<Pressable
|
||
style={[styles.proBtn, styles.proBtnStart, (pmBusy || row.live) && styles.ctrlDisabled]}
|
||
disabled={pmBusy || row.live}
|
||
onPress={() => onStart(row.channelId)}
|
||
>
|
||
<Text style={styles.proBtnText}>开始</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.proBtn, styles.proBtnStop, stopDisabled && styles.ctrlDisabled]}
|
||
disabled={stopDisabled}
|
||
onPress={() => onStop(row.channelId)}
|
||
>
|
||
<Text style={styles.proBtnText}>停止</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.proBtn, styles.proBtnRestart, pmBusy && styles.ctrlDisabled]}
|
||
disabled={pmBusy}
|
||
onPress={() => onRestart(row.channelId)}
|
||
>
|
||
<Text style={styles.proBtnText}>重新开始</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
)
|
||
})}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
function LiveSwitchGrid({
|
||
loadingAction,
|
||
onAction,
|
||
disabledAll,
|
||
}: {
|
||
loadingAction: string | null
|
||
onAction: (a: 'start' | 'stop' | 'restart' | 'status') => void
|
||
disabledAll?: boolean
|
||
}) {
|
||
return (
|
||
<View style={styles.grid}>
|
||
{(
|
||
[
|
||
['start', '开始直播'],
|
||
['stop', '停止直播'],
|
||
['restart', '重新开始'],
|
||
['status', '刷新状态'],
|
||
] as const
|
||
).map(([action, label]) => (
|
||
<Pressable
|
||
key={action}
|
||
style={[
|
||
styles.ctrl,
|
||
ctrlStyle(action),
|
||
loadingAction === action && styles.btnDisabled,
|
||
disabledAll && styles.ctrlDisabled,
|
||
]}
|
||
disabled={!disabledAll && loadingAction !== null && action !== 'status'}
|
||
onPress={() => {
|
||
if (disabledAll) {
|
||
Alert.alert('未绑定', '请先在「远程」页完成配对后再操作。')
|
||
return
|
||
}
|
||
if (loadingAction !== null && action !== 'status') return
|
||
void onAction(action)
|
||
}}
|
||
>
|
||
<Text style={[styles.ctrlText, action === 'status' && { color: theme.text }]}>{label}</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||
<View style={styles.fatalWrap}>
|
||
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
||
<Text style={styles.fatalHint}>
|
||
常见于网络瞬时断连;若「远程」里测试 API 正常,多半是任务列表请求失败,请重试。
|
||
</Text>
|
||
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
|
||
<Text style={styles.retryBtnText}>重试加载任务列表</Text>
|
||
</Pressable>
|
||
</View>
|
||
</SafeAreaView>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||
<ScrollView
|
||
contentContainerStyle={styles.scroll}
|
||
refreshControl={
|
||
bind ? (
|
||
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
|
||
) : undefined
|
||
}
|
||
>
|
||
{bind && r.transientLoadError ? (
|
||
<View style={styles.warnBanner}>
|
||
<Text style={styles.warnBannerText}>
|
||
任务列表刷新失败:{r.transientLoadError}
|
||
{'\n'}
|
||
下拉刷新重试;直播状态仍会每 2 秒轮询。
|
||
</Text>
|
||
</View>
|
||
) : null}
|
||
|
||
{bind && (
|
||
<View style={styles.banner}>
|
||
<Text style={styles.bannerText}>
|
||
以下仅远程控制 PC 上的转播与 PM2 进程;推流与 FFmpeg 在电脑端执行,手机不跑拉流/转码。
|
||
</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.toolbarCard}>
|
||
<View style={styles.toolbarRow}>
|
||
<View style={styles.toolbarLeft}>
|
||
<View
|
||
style={[
|
||
styles.statusChip,
|
||
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
||
]}
|
||
>
|
||
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
||
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
||
</View>
|
||
<Text style={styles.statusLineText} numberOfLines={1}>
|
||
{r.statusMeta.line}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||
<View style={styles.proLabelRow}>
|
||
<Text style={styles.proLabelZh}>多频道</Text>
|
||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||
<Text style={styles.proBadgeText}>PRO</Text>
|
||
</View>
|
||
</View>
|
||
<View style={styles.proSwitchWrap}>
|
||
<Switch
|
||
value={r.multiChannelPro}
|
||
disabled={unbound}
|
||
onValueChange={(v) => {
|
||
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)"
|
||
/>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
{bind ? (
|
||
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
||
<Picker
|
||
enabled={!r.emptyHint}
|
||
selectedValue={r.currentProcess}
|
||
onValueChange={(v) => r.setCurrentProcess(String(v))}
|
||
style={styles.picker}
|
||
dropdownIconColor={theme.text}
|
||
>
|
||
{!r.entries.length ? (
|
||
<Picker.Item label="(无任务)" value="" color={theme.muted} />
|
||
) : (
|
||
r.entries.map((e) => (
|
||
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
|
||
))
|
||
)}
|
||
</Picker>
|
||
</View>
|
||
) : null}
|
||
|
||
{bind && r.emptyHint ? (
|
||
<View style={styles.emptyCard}>
|
||
<Text style={styles.emptyTitle}>还没有可用的直播任务</Text>
|
||
<Text style={styles.hint}>{r.emptyHint}</Text>
|
||
</View>
|
||
) : unbound ? (
|
||
<>
|
||
<View style={[styles.card, styles.cardAccent]}>
|
||
<Text style={styles.cardTitle}>直播开关</Text>
|
||
<LiveSwitchGrid
|
||
loadingAction={r.loadingAction}
|
||
disabledAll
|
||
onAction={() => {}}
|
||
/>
|
||
</View>
|
||
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>YouTube 相关设置</Text>
|
||
<Text style={styles.hintSmall}>配对后从 PC 读取 youtube.ini,与桌面端一致。</Text>
|
||
<TextInput
|
||
style={[styles.area, styles.inputDisabled]}
|
||
multiline
|
||
editable={false}
|
||
value=""
|
||
placeholder="串流密钥、分辨率等"
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
</View>
|
||
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>直播地址列表</Text>
|
||
<Text style={styles.hintSmall}>要转播的直播间地址,一行一个。</Text>
|
||
<TextInput
|
||
style={[styles.area, styles.inputDisabled]}
|
||
multiline
|
||
editable={false}
|
||
value=""
|
||
placeholder="配对后显示"
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
</View>
|
||
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>OBS 推流</Text>
|
||
<Text style={styles.hintSmall}>OBS 选自定义推流,服务器:</Text>
|
||
<Text style={styles.mono}>srt://live.local:10080/live/obs</Text>
|
||
</View>
|
||
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>当前直播日志</Text>
|
||
<Text style={styles.hintSmall}>每秒刷新 · 配对后显示进程输出与异常</Text>
|
||
<ScrollView style={styles.logBox} nestedScrollEnabled>
|
||
<Text style={styles.log}>{r.logText || '—'}</Text>
|
||
</ScrollView>
|
||
</View>
|
||
</>
|
||
) : (
|
||
<>
|
||
<View style={[styles.card, styles.cardAccent]}>
|
||
<Text style={styles.cardTitle}>直播开关</Text>
|
||
{r.isYoutube && r.multiChannelPro && r.proLiveRows.length > 0 ? (
|
||
<ProLiveSwitchGrid
|
||
rows={r.proLiveRows}
|
||
committedChannelKeys={r.committedChannelKeys}
|
||
proChannelEditors={r.proChannelEditors}
|
||
highlightedChannelId={r.proChannelId}
|
||
loadingAction={r.loadingAction}
|
||
onSelectChannel={(cid) => 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 ? (
|
||
<Text style={styles.hintSmall}>
|
||
{r.proChannelsList.length > 0
|
||
? '暂无已保存的线路密钥。请在「当前频道」中保存串流密钥后,对应行将显示于此。'
|
||
: '暂无频道。'}
|
||
</Text>
|
||
) : (
|
||
<LiveSwitchGrid
|
||
loadingAction={r.loadingAction}
|
||
onAction={(a) => void r.runPm2Action(a)}
|
||
/>
|
||
)}
|
||
</View>
|
||
|
||
{r.isYoutube && r.multiChannelPro && (
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>当前频道</Text>
|
||
{r.proChannelsError ? (
|
||
<Text style={styles.err}>{r.proChannelsError}</Text>
|
||
) : (
|
||
<>
|
||
<View style={styles.pickerWrap}>
|
||
<Picker
|
||
selectedValue={r.proChannelId}
|
||
onValueChange={(v) => r.setProChannelId(String(v))}
|
||
style={styles.picker}
|
||
dropdownIconColor={theme.text}
|
||
>
|
||
{r.proChannelsList.map((c) => (
|
||
<Picker.Item
|
||
key={c.id}
|
||
label={`${c.name} (${c.keyPreview || '无密钥'})`}
|
||
value={c.id}
|
||
color={theme.text}
|
||
/>
|
||
))}
|
||
</Picker>
|
||
</View>
|
||
<View style={styles.row}>
|
||
<Pressable
|
||
style={[styles.btn, styles.btnSec]}
|
||
disabled={!!r.newChannelDraft || r.addChannelBusy}
|
||
onPress={() => r.setNewChannelDraft({ keyText: '' })}
|
||
>
|
||
<Text style={styles.btnSecText}>新增频道</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btn, styles.btnSec]}
|
||
disabled={r.proChannelsList.length <= 1 || r.deleteChannelBusyId !== null}
|
||
onPress={() => void r.deleteProChannel(r.proChannelId)}
|
||
>
|
||
<Text style={styles.btnSecText}>
|
||
{r.deleteChannelBusyId === r.proChannelId ? '…' : '删除当前频道'}
|
||
</Text>
|
||
</Pressable>
|
||
</View>
|
||
{r.newChannelDraft ? (
|
||
<View style={styles.newChBox}>
|
||
<Text style={styles.fieldLbl}>新频道串流密钥</Text>
|
||
<TextInput
|
||
style={styles.inputSingle}
|
||
value={r.newChannelDraft.keyText}
|
||
onChangeText={(v) => r.setNewChannelDraft({ keyText: v })}
|
||
placeholder="粘贴密钥"
|
||
placeholderTextColor={theme.muted}
|
||
autoCapitalize="none"
|
||
/>
|
||
<View style={styles.row}>
|
||
<Pressable
|
||
style={[styles.btn, styles.btnSec]}
|
||
onPress={() => r.setNewChannelDraft(null)}
|
||
>
|
||
<Text style={styles.btnSecText}>取消</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btn, r.addChannelBusy && styles.btnDisabled]}
|
||
disabled={r.addChannelBusy}
|
||
onPress={() => void r.submitNewChannelDraft()}
|
||
>
|
||
<Text style={styles.btnText}>{r.addChannelBusy ? '…' : '提交'}</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{r.isYoutube && r.multiChannelPro && !r.proChannelsError && r.proChannelsList.length > 0 && (
|
||
<>
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>YouTube 密钥(当前线路)</Text>
|
||
<TextInput
|
||
style={styles.area}
|
||
multiline
|
||
value={r.proChannelEditors[r.proChannelId]?.keys?.[0] ?? ''}
|
||
onChangeText={r.setProChannelKeyText}
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
<Pressable
|
||
style={[
|
||
styles.btn,
|
||
(r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId) && styles.btnDisabled,
|
||
]}
|
||
disabled={r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId}
|
||
onPress={() => void r.saveProChannelKey()}
|
||
>
|
||
{r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId ? (
|
||
<ActivityIndicator color="#fff" />
|
||
) : (
|
||
<Text style={styles.btnText}>保存密钥</Text>
|
||
)}
|
||
</Pressable>
|
||
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
|
||
</View>
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>直播地址列表(当前线路)</Text>
|
||
<TextInput
|
||
style={styles.area}
|
||
multiline
|
||
value={r.proChannelEditors[r.proChannelId]?.urlText ?? ''}
|
||
onChangeText={r.setProChannelUrlText}
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
<View style={styles.row}>
|
||
<Pressable style={[styles.btn, styles.btnSec]} onPress={() => void r.reloadProUrlFromServer()}>
|
||
<Text style={styles.btnSecText}>重新读取</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btn, styles.btnSec]}
|
||
onPress={() => void r.saveProUrlConfig()}
|
||
disabled={r.saveUrlLoading}
|
||
>
|
||
<Text style={styles.btnSecText}>{r.saveUrlLoading ? '…' : '保存地址'}</Text>
|
||
</Pressable>
|
||
</View>
|
||
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
|
||
</View>
|
||
</>
|
||
)}
|
||
|
||
{r.isYoutube && !r.multiChannelPro && (
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>YouTube 相关设置</Text>
|
||
<Text style={styles.fieldLbl}>串流密钥</Text>
|
||
<TextInput
|
||
style={styles.inputSingle}
|
||
value={r.ytIniModel.keys[0] ?? ''}
|
||
onChangeText={(v) =>
|
||
r.setYtIniModel((m) => ({ ...m, keys: [v], activeIndex: 0 }))
|
||
}
|
||
placeholder="粘贴或编辑串流密钥"
|
||
placeholderTextColor={theme.muted}
|
||
autoCapitalize="none"
|
||
/>
|
||
<Text style={styles.fieldLbl}>输出分辨率</Text>
|
||
<View style={styles.pickerWrap}>
|
||
<Picker
|
||
selectedValue={r.ytIniModel.options.output_resolution ?? '720p'}
|
||
onValueChange={(v) =>
|
||
r.setYtIniModel((m) => ({
|
||
...m,
|
||
options: { ...m.options, output_resolution: String(v) },
|
||
}))
|
||
}
|
||
style={styles.picker}
|
||
dropdownIconColor={theme.text}
|
||
>
|
||
<Picker.Item label="720p" value="720p" color={theme.text} />
|
||
<Picker.Item label="1080p" value="1080p" color={theme.text} />
|
||
</Picker>
|
||
</View>
|
||
<Text style={styles.fieldLbl}>是否使用 GPU(NVENC)</Text>
|
||
<View style={styles.pickerWrap}>
|
||
<Picker
|
||
selectedValue={r.ytIniModel.options.use_gpu ?? (r.gpuCaps?.nvenc_available ? '是' : '无')}
|
||
onValueChange={(v) =>
|
||
r.setYtIniModel((m) => ({
|
||
...m,
|
||
options: { ...m.options, use_gpu: String(v) },
|
||
}))
|
||
}
|
||
style={styles.picker}
|
||
dropdownIconColor={theme.text}
|
||
>
|
||
{r.gpuCaps?.nvenc_available ? (
|
||
<>
|
||
<Picker.Item label="是" value="是" color={theme.text} />
|
||
<Picker.Item label="否" value="否" color={theme.text} />
|
||
<Picker.Item label="无" value="无" color={theme.text} />
|
||
</>
|
||
) : (
|
||
<Picker.Item label="无(未检测到 NVENC)" value="无" color={theme.text} />
|
||
)}
|
||
</Picker>
|
||
</View>
|
||
<Text style={styles.fieldLbl}>音频降噪</Text>
|
||
<View style={styles.pickerWrap}>
|
||
<Picker
|
||
selectedValue={r.ytIniModel.options.audio_denoise ?? '否'}
|
||
onValueChange={(v) =>
|
||
r.setYtIniModel((m) => ({
|
||
...m,
|
||
options: { ...m.options, audio_denoise: String(v) },
|
||
}))
|
||
}
|
||
style={styles.picker}
|
||
dropdownIconColor={theme.text}
|
||
>
|
||
<Picker.Item label="否" value="否" color={theme.text} />
|
||
<Picker.Item label="是" value="是" color={theme.text} />
|
||
</Picker>
|
||
</View>
|
||
<View style={styles.row}>
|
||
<Pressable style={[styles.btn, styles.btnSec]} onPress={() => void r.loadYoutubeIniModel()}>
|
||
<Text style={styles.btnSecText}>重新读取</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btn, r.saveYoutubeLoading && styles.btnDisabled]}
|
||
disabled={r.saveYoutubeLoading}
|
||
onPress={() => void r.saveYoutubeIniModel()}
|
||
>
|
||
{r.saveYoutubeLoading ? (
|
||
<ActivityIndicator color="#fff" />
|
||
) : (
|
||
<Text style={styles.btnText}>保存</Text>
|
||
)}
|
||
</Pressable>
|
||
</View>
|
||
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
|
||
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && (
|
||
<Text style={styles.hintSmall}>Google 授权与拉取密钥请在 PC 端操作。</Text>
|
||
)}
|
||
</View>
|
||
)}
|
||
|
||
{((r.isYoutube && !r.multiChannelPro) || r.isTiktok) && (
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>直播地址列表</Text>
|
||
<Text style={styles.hintSmall}>要转播的直播间地址写在这里,一行一个。</Text>
|
||
<TextInput
|
||
style={styles.area}
|
||
multiline
|
||
value={r.urlText}
|
||
onChangeText={r.setUrlText}
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
<View style={styles.row}>
|
||
<Pressable
|
||
style={[styles.btn, styles.btnSec]}
|
||
onPress={() => void r.loadConfig('/get_url_config', r.setUrlText, r.setUrlStatus)}
|
||
>
|
||
<Text style={styles.btnSecText}>重新读取</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btn, r.saveUrlLoading && styles.btnDisabled]}
|
||
disabled={r.saveUrlLoading}
|
||
onPress={() => {
|
||
r.setSaveUrlLoading(true)
|
||
void r.saveConfig('/save_url_config', r.urlText, r.setUrlStatus, () =>
|
||
r.setSaveUrlLoading(false),
|
||
)
|
||
}}
|
||
>
|
||
<Text style={styles.btnText}>保存</Text>
|
||
</Pressable>
|
||
</View>
|
||
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && r.isObs && (
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>OBS 推流</Text>
|
||
<Text style={styles.hintSmall}>在 OBS 里选「自定义」推流,服务器填下面这一行:</Text>
|
||
<Text style={styles.mono}>srt://live.local:10080/live/obs</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>当前直播日志</Text>
|
||
<Text style={styles.hintSmall}>每 2 秒刷新 · 与上方状态同源</Text>
|
||
<ScrollView style={styles.logBox} nestedScrollEnabled>
|
||
<Text style={styles.log}>{r.logText || '—'}</Text>
|
||
</ScrollView>
|
||
</View>
|
||
</>
|
||
)}
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
)
|
||
}
|
||
|
||
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 },
|
||
})
|