Files
2026-03-26 00:48:14 -05:00

915 lines
34 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}>使 GPUNVENC</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 },
})