Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { useMemo } from 'react'
|
||
import {
|
||
View,
|
||
Text,
|
||
ScrollView,
|
||
TextInput,
|
||
Pressable,
|
||
StyleSheet,
|
||
ActivityIndicator,
|
||
} 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'
|
||
|
||
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,
|
||
}
|
||
|
||
export default function RecordScreen() {
|
||
const { bind } = useBind()
|
||
const r = useRecorder()
|
||
|
||
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
|
||
|
||
if (r.bootError && bind) {
|
||
return (
|
||
<SafeAreaView style={styles.safe}>
|
||
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
||
</SafeAreaView>
|
||
)
|
||
}
|
||
|
||
if (!bind) {
|
||
return (
|
||
<SafeAreaView style={styles.safe}>
|
||
<Text style={styles.hint}>请先在「配对」页扫码绑定 PC。</Text>
|
||
</SafeAreaView>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||
<ScrollView contentContainerStyle={styles.scroll}>
|
||
<View style={styles.toolbar}>
|
||
<Text style={styles.label}>当前录制</Text>
|
||
<View style={styles.pickerWrap}>
|
||
<Picker
|
||
enabled={!r.emptyHint}
|
||
selectedValue={r.currentProcess}
|
||
onValueChange={(v) => r.setCurrentProcess(String(v))}
|
||
style={styles.picker}
|
||
dropdownIconColor={theme.text}
|
||
>
|
||
{r.entries.map((e) => (
|
||
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
|
||
))}
|
||
</Picker>
|
||
</View>
|
||
{r.isYoutube && (
|
||
<Pressable
|
||
style={styles.proRow}
|
||
onPress={() => void r.togglePro(!r.multiChannelPro)}
|
||
>
|
||
<Text style={styles.proLabel}>Pro 转播</Text>
|
||
<Text style={[styles.proVal, r.multiChannelPro && { color: theme.accent }]}>
|
||
{r.multiChannelPro ? '开' : '关'}
|
||
</Text>
|
||
</Pressable>
|
||
)}
|
||
</View>
|
||
|
||
<View style={[styles.pill, { borderColor: statusColor }]}>
|
||
<View style={[styles.dot, { backgroundColor: statusColor }]} />
|
||
<Text style={[styles.pillText, { color: statusColor }]}>{r.statusMeta.line}</Text>
|
||
</View>
|
||
|
||
{r.emptyHint ? (
|
||
<Text style={styles.hint}>{r.emptyHint}</Text>
|
||
) : (
|
||
<>
|
||
{r.ent && (
|
||
<View style={styles.meta}>
|
||
<Text style={styles.metaLabel}>任务</Text>
|
||
<Text style={styles.metaName}>{r.ent.label}</Text>
|
||
<Text style={styles.metaFile}>{r.ent.script}</Text>
|
||
</View>
|
||
)}
|
||
|
||
{r.isYoutube && r.multiChannelPro && (
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>Pro 线路</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>
|
||
)}
|
||
|
||
{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.proYoutubeKey}
|
||
onChangeText={r.setProYoutubeKey}
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
<Pressable
|
||
style={[styles.btn, r.saveProKeyLoading && styles.btnDisabled]}
|
||
disabled={r.saveProKeyLoading}
|
||
onPress={() => void r.saveProChannelKey()}
|
||
>
|
||
{r.saveProKeyLoading ? (
|
||
<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.urlText}
|
||
onChangeText={r.setUrlText}
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
<View style={styles.row}>
|
||
<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.ini</Text>
|
||
<TextInput
|
||
style={styles.area}
|
||
multiline
|
||
value={r.youtubeText}
|
||
onChangeText={r.setYoutubeText}
|
||
placeholderTextColor={theme.muted}
|
||
/>
|
||
<View style={styles.row}>
|
||
<Pressable
|
||
style={[styles.btn, styles.btnSec]}
|
||
onPress={() => void r.loadConfig('/get_config', r.setYoutubeText, r.setYoutubeStatus)}
|
||
>
|
||
<Text style={styles.btnSecText}>重新读取</Text>
|
||
</Pressable>
|
||
<Pressable
|
||
style={[styles.btn, r.saveYoutubeLoading && styles.btnDisabled]}
|
||
disabled={r.saveYoutubeLoading}
|
||
onPress={() => {
|
||
r.setSaveYoutubeLoading(true)
|
||
void r.saveConfig('/save_config', r.youtubeText, r.setYoutubeStatus, () =>
|
||
r.setSaveYoutubeLoading(false),
|
||
)
|
||
}}
|
||
>
|
||
<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>
|
||
<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.mono}>srt://live.local:10080/live/obs</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={[styles.card, styles.cardAccent]}>
|
||
<Text style={styles.cardTitle}>录制开关</Text>
|
||
<View style={styles.grid}>
|
||
{(
|
||
[
|
||
['start', '开始录制'],
|
||
['stop', '停止录制'],
|
||
['restart', '重新开始'],
|
||
['status', '刷新状态'],
|
||
] as const
|
||
).map(([action, label]) => (
|
||
<Pressable
|
||
key={action}
|
||
style={[styles.ctrl, ctrlStyle(action), r.loadingAction === action && styles.btnDisabled]}
|
||
disabled={r.loadingAction !== null && action !== 'status'}
|
||
onPress={() => void r.runPm2Action(action)}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.ctrlText,
|
||
action === 'status' && { color: theme.text },
|
||
]}
|
||
>
|
||
{label}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
</View>
|
||
</View>
|
||
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>运行记录</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 },
|
||
scroll: { padding: 16, paddingBottom: 32 },
|
||
toolbar: { marginBottom: 12 },
|
||
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
|
||
pickerWrap: {
|
||
borderWidth: 1,
|
||
borderColor: theme.border,
|
||
borderRadius: 10,
|
||
overflow: 'hidden',
|
||
backgroundColor: theme.bgCard,
|
||
},
|
||
picker: { color: theme.text },
|
||
proRow: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
marginTop: 12,
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 12,
|
||
backgroundColor: theme.bgCard,
|
||
borderRadius: 10,
|
||
borderWidth: 1,
|
||
borderColor: theme.border,
|
||
},
|
||
proLabel: { color: theme.text, fontWeight: '600' },
|
||
proVal: { color: theme.muted, fontWeight: '600' },
|
||
pill: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
alignSelf: 'flex-start',
|
||
gap: 8,
|
||
paddingVertical: 8,
|
||
paddingHorizontal: 12,
|
||
borderRadius: 999,
|
||
borderWidth: 1,
|
||
marginBottom: 14,
|
||
},
|
||
dot: { width: 8, height: 8, borderRadius: 4 },
|
||
pillText: { fontSize: 13, fontWeight: '600' },
|
||
meta: {
|
||
padding: 12,
|
||
borderRadius: 10,
|
||
backgroundColor: theme.bgElevated,
|
||
borderWidth: 1,
|
||
borderColor: theme.border,
|
||
marginBottom: 12,
|
||
},
|
||
metaLabel: { fontSize: 12, color: theme.muted },
|
||
metaName: { fontSize: 15, fontWeight: '600', color: theme.text, marginTop: 4 },
|
||
metaFile: { fontSize: 11, fontFamily: 'monospace', color: theme.muted, marginTop: 4 },
|
||
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 },
|
||
area: {
|
||
minHeight: 100,
|
||
borderWidth: 1,
|
||
borderColor: theme.border,
|
||
borderRadius: 10,
|
||
padding: 10,
|
||
color: theme.text,
|
||
fontFamily: 'monospace',
|
||
fontSize: 12,
|
||
marginBottom: 10,
|
||
backgroundColor: theme.bg,
|
||
},
|
||
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 },
|
||
hintSmall: { color: theme.muted, fontSize: 12, marginTop: 8 },
|
||
err: { color: theme.danger, fontSize: 14 },
|
||
mono: { fontFamily: 'monospace', color: theme.accent, fontSize: 13 },
|
||
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||
ctrl: { flexGrow: 1, minWidth: '42%', paddingVertical: 14, borderRadius: 10, alignItems: 'center' },
|
||
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 },
|
||
})
|