's'
This commit is contained in:
72
app/(tabs)/_layout.tsx
Normal file
72
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import { Tabs } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
import Colors from '@/constants/Colors'
|
||||
import { useColorScheme } from '@/components/useColorScheme'
|
||||
import { useClientOnlyValue } from '@/components/useClientOnlyValue'
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
||||
},
|
||||
headerTintColor: Colors[colorScheme].text,
|
||||
headerShown: useClientOnlyValue(false, true),
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: '直播',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="radio-outline" size={22} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="desk"
|
||||
options={{
|
||||
title: '仪表盘',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="grid-outline" size={22} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="network"
|
||||
options={{
|
||||
title: '网络',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="wifi-outline" size={22} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="bind"
|
||||
options={{
|
||||
title: '远程',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="phone-portrait-outline" size={22} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="system"
|
||||
options={{
|
||||
title: '系统',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="speedometer-outline" size={22} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="wechat"
|
||||
options={{
|
||||
href: null,
|
||||
title: '微信',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="chatbubbles-outline" size={22} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
366
app/(tabs)/bind.tsx
Normal file
366
app/(tabs)/bind.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Linking,
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types'
|
||||
import {
|
||||
checkVpnPermissionGranted,
|
||||
isExpoEasyTierVpnAvailable,
|
||||
saveAndPrepareTunnel,
|
||||
} from '@/lib/easyTierVpn'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
|
||||
const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0]
|
||||
|
||||
export default function BindScreen() {
|
||||
const { bind, setBind, clear, ready } = useBind()
|
||||
const [paste, setPaste] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [scanOn, setScanOn] = useState(false)
|
||||
const [perm, requestPerm] = useCameraPermissions()
|
||||
const scannedRef = useRef(false)
|
||||
const [etBusy, setEtBusy] = useState(false)
|
||||
|
||||
const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable()
|
||||
|
||||
const applyPayload = useCallback(
|
||||
async (p: BindPayload | null) => {
|
||||
if (!p) {
|
||||
setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。')
|
||||
return
|
||||
}
|
||||
await setBind(p)
|
||||
setMsg(
|
||||
etEmbedded
|
||||
? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。'
|
||||
: '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。'
|
||||
)
|
||||
setScanOn(false)
|
||||
scannedRef.current = false
|
||||
},
|
||||
[etEmbedded, setBind],
|
||||
)
|
||||
|
||||
const onBarCode = useCallback(
|
||||
async ({ data }: { data: string }) => {
|
||||
if (scannedRef.current) return
|
||||
scannedRef.current = true
|
||||
const p = parseBindPayload(data)
|
||||
await applyPayload(p)
|
||||
},
|
||||
[applyPayload],
|
||||
)
|
||||
|
||||
const onPasteApply = useCallback(async () => {
|
||||
const p = parseBindPayload(paste.trim())
|
||||
await applyPayload(p)
|
||||
}, [paste, applyPayload])
|
||||
|
||||
const copyEtJson = useCallback(async () => {
|
||||
if (!bind) return
|
||||
const chunk = {
|
||||
networkName: bind.easytier.networkName,
|
||||
networkSecret: bind.easytier.networkSecret,
|
||||
peerUrls: bind.easytier.peerUrls,
|
||||
}
|
||||
await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2))
|
||||
setMsg('已复制 EasyTier 组网 JSON。')
|
||||
}, [bind])
|
||||
|
||||
const copyFull = useCallback(async () => {
|
||||
if (!bind) return
|
||||
await Clipboard.setStringAsync(JSON.stringify(bind, null, 2))
|
||||
setMsg('已复制完整绑定 JSON。')
|
||||
}, [bind])
|
||||
|
||||
const testConn = useCallback(async () => {
|
||||
if (!bind) return
|
||||
try {
|
||||
const r = await fetch(`${bind.baseUrl}/health`, {
|
||||
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
|
||||
})
|
||||
const j = await r.json().catch(() => ({}))
|
||||
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
|
||||
} catch (e) {
|
||||
Alert.alert('连接失败', e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
const retryEmbeddedTunnel = useCallback(async () => {
|
||||
if (!bind || !etEmbedded) return
|
||||
setEtBusy(true)
|
||||
try {
|
||||
const r = await saveAndPrepareTunnel(bind.easytier)
|
||||
if (r.needsVpnPermission) {
|
||||
setMsg('请在系统弹窗中允许本应用建立 VPN,授权后再点“检查 VPN 权限”。')
|
||||
} else if (r.ok && r.vpnPermissionGranted) {
|
||||
setMsg(
|
||||
'VPN 权限已就绪,EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。'
|
||||
)
|
||||
} else {
|
||||
setMsg(`组网准备结果:${JSON.stringify(r)}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setEtBusy(false)
|
||||
}
|
||||
}, [bind, etEmbedded])
|
||||
|
||||
const checkVpn = useCallback(async () => {
|
||||
if (!etEmbedded) return
|
||||
setEtBusy(true)
|
||||
try {
|
||||
const ok = await checkVpnPermissionGranted()
|
||||
setMsg(
|
||||
ok
|
||||
? 'VPN 权限已授予。若仍无法访问 API,请确认当前安装包已包含完整原生 EasyTier 数据面。'
|
||||
: '尚未授予 VPN 权限,请先在系统设置中允许本应用建立 VPN。'
|
||||
)
|
||||
} finally {
|
||||
setEtBusy(false)
|
||||
}
|
||||
}, [etEmbedded])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<Text style={styles.muted}>加载中...</Text>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.h1}>远程配对</Text>
|
||||
<Text style={styles.lead}>
|
||||
在 PC 控制台“网络 - 远程控制”生成二维码后,用手机扫码导入 `baseUrl`、`token` 和 EasyTier
|
||||
参数。桌面端现在会默认附带 EasyTier 官方共享引导节点,户外网络下更容易发现对端。
|
||||
</Text>
|
||||
|
||||
{bind && (
|
||||
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
|
||||
<Text style={styles.cardTitle}>当前已绑定</Text>
|
||||
<Text style={styles.mono}>{bind.baseUrl}</Text>
|
||||
<Text style={styles.mutedSmall}>组网名 · {bind.easytier.networkName}</Text>
|
||||
<Text style={styles.mutedSmall}>引导节点 · {bootstrapPeer}</Text>
|
||||
{bind.easytier.peerUrls.length > 1 && (
|
||||
<Text style={styles.mutedSmall}>额外引导节点 · {bind.easytier.peerUrls.length - 1} 个</Text>
|
||||
)}
|
||||
<View style={styles.row}>
|
||||
<Pressable style={styles.btn} onPress={() => void testConn()}>
|
||||
<Text style={styles.btnText}>测试 API</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnDanger]}
|
||||
onPress={() => {
|
||||
Alert.alert('清除配对', '确定要清除当前保存的绑定信息吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '清除', style: 'destructive', onPress: () => void clear() },
|
||||
])
|
||||
}}
|
||||
>
|
||||
<Text style={styles.btnText}>清除</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{etEmbedded ? (
|
||||
<View style={styles.etRow}>
|
||||
<Pressable
|
||||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
||||
disabled={etBusy}
|
||||
onPress={() => void retryEmbeddedTunnel()}
|
||||
>
|
||||
<Text style={styles.btnSecondaryText}>重新应用组网参数</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
|
||||
disabled={etBusy}
|
||||
onPress={() => void checkVpn()}
|
||||
>
|
||||
<Text style={styles.btnSecondaryText}>检查 VPN 权限</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.mutedSmall}>
|
||||
{Platform.OS === 'ios'
|
||||
? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。'
|
||||
: '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'}
|
||||
</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
)}
|
||||
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.h2}>说明</Text>
|
||||
<Text style={styles.body}>
|
||||
推流和 FFmpeg 只在 PC 执行;手机只负责保存控制通道地址与 EasyTier 参数。现在桌面端会默认把
|
||||
`tcp://public.easytier.top:11010` 写进 `peerUrls`,减少没有公网 IP 时的发现失败。
|
||||
</Text>
|
||||
<Text style={styles.body}>
|
||||
当前 Android 原生模块已经能保存组网参数并申请 VPN 权限;如果你要完全在 App 内跑 EasyTier
|
||||
数据面,下一步还需要把 EasyTier Android 原生库继续接进来。
|
||||
</Text>
|
||||
<Pressable onPress={() => void Linking.openURL(ET_RELEASE)} style={styles.linkRow}>
|
||||
<Ionicons name="open-outline" size={18} color={theme.accent} />
|
||||
<Text style={styles.link}>EasyTier 官方发布与文档</Text>
|
||||
</Pressable>
|
||||
{bind && (
|
||||
<View style={styles.etActions}>
|
||||
<Pressable style={styles.btnSecondary} onPress={() => void copyEtJson()}>
|
||||
<Text style={styles.btnSecondaryText}>复制组网 JSON</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
|
||||
<Text style={styles.btnSecondaryText}>复制完整绑定 JSON</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.h2}>扫码</Text>
|
||||
{!scanOn ? (
|
||||
<Pressable
|
||||
style={styles.btnPrimary}
|
||||
onPress={async () => {
|
||||
const r = await requestPerm()
|
||||
if (!r.granted) {
|
||||
setMsg('需要相机权限才能扫码。')
|
||||
return
|
||||
}
|
||||
scannedRef.current = false
|
||||
setScanOn(true)
|
||||
}}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={22} color="#fff" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.btnPrimaryText}>打开相机扫描 PC 二维码</Text>
|
||||
</Pressable>
|
||||
) : perm?.granted ? (
|
||||
<View style={styles.camWrap}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
onBarcodeScanned={onBarCode}
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
/>
|
||||
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
|
||||
<Text style={styles.btnPrimaryText}>关闭相机</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.muted}>没有相机权限。</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.h2}>手动粘贴 JSON</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
multiline
|
||||
placeholder="粘贴 PC 端显示的绑定 JSON"
|
||||
placeholderTextColor={theme.muted}
|
||||
value={paste}
|
||||
onChangeText={setPaste}
|
||||
/>
|
||||
<Pressable style={styles.btnPrimary} onPress={() => void onPasteApply()}>
|
||||
<Text style={styles.btnPrimaryText}>解析并保存</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!!msg && <Text style={styles.msg}>{msg}</Text>}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: theme.bg },
|
||||
scroll: { padding: 20, paddingBottom: 40 },
|
||||
h1: { fontSize: 22, fontWeight: '700', color: theme.text, marginBottom: 8 },
|
||||
lead: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 16 },
|
||||
h2: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 },
|
||||
body: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 10 },
|
||||
block: { marginBottom: 22 },
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 18,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
cardTitle: { fontSize: 13, fontWeight: '600', color: theme.accent, marginBottom: 8 },
|
||||
mono: { fontFamily: 'monospace', fontSize: 13, color: theme.text, marginBottom: 6 },
|
||||
muted: { color: theme.muted, fontSize: 14 },
|
||||
mutedSmall: { color: theme.muted, fontSize: 12, marginTop: 6 },
|
||||
row: { flexDirection: 'row', gap: 10, marginTop: 12 },
|
||||
etRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 },
|
||||
etActions: { gap: 10, marginTop: 12 },
|
||||
btn: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
btnDisabled: { opacity: 0.55 },
|
||||
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
|
||||
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
|
||||
btnPrimary: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#3d7eff',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 10,
|
||||
},
|
||||
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
btnSecondary: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.bgCard,
|
||||
flex: 1,
|
||||
minWidth: '45%',
|
||||
},
|
||||
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
||||
input: {
|
||||
minHeight: 120,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
color: theme.text,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
},
|
||||
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' },
|
||||
camera: { height: 280, width: '100%' },
|
||||
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
|
||||
linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 },
|
||||
link: { color: theme.accent, fontSize: 14 },
|
||||
msg: { color: theme.success, fontSize: 14, marginTop: 8 },
|
||||
})
|
||||
300
app/(tabs)/desk.tsx
Normal file
300
app/(tabs)/desk.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
Pressable,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { ClientOverview, NetworkPayload, RelayLivePayload, SystemMetricsPayload } from '@/lib/pcTypes'
|
||||
import { getMultiChannelPro } from '@/lib/storage'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
function fmtTs(ts: unknown): string {
|
||||
if (typeof ts !== 'number') return '—'
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDur(sec: number | null | undefined): string {
|
||||
if (sec == null || Number.isNaN(sec)) return '—'
|
||||
const s = Math.floor(sec)
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const r = s % 60
|
||||
if (h > 0) return `${h}h${m}m${r}s`
|
||||
if (m > 0) return `${m}m${r}s`
|
||||
return `${r}s`
|
||||
}
|
||||
|
||||
function SnapshotNet({ data }: { data: unknown }) {
|
||||
if (!data || typeof data !== 'object') return <Text style={styles.muted}>—</Text>
|
||||
const d = data as Record<string, unknown>
|
||||
if ('google' in d && 'douyin' in d) {
|
||||
const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string }
|
||||
const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string }
|
||||
return (
|
||||
<View style={styles.snapGrid}>
|
||||
<Text style={styles.snapLine}>
|
||||
Google {g?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.snapLine}>
|
||||
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <Text style={styles.muted}>—</Text>
|
||||
}
|
||||
|
||||
function SnapshotSys({ data }: { data: unknown }) {
|
||||
if (!data || typeof data !== 'object') return <Text style={styles.muted}>—</Text>
|
||||
const d = data as Record<string, unknown>
|
||||
if ('cpu_percent' in d || 'memory' in d) {
|
||||
const mem = d.memory as { percent?: number } | undefined
|
||||
return (
|
||||
<View style={styles.snapGrid}>
|
||||
<Text style={styles.snapLine}>
|
||||
CPU {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'}
|
||||
</Text>
|
||||
<Text style={styles.snapLine}>内存 {mem?.percent != null ? `${mem.percent.toFixed(1)}%` : '—'}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return <Text style={styles.muted}>—</Text>
|
||||
}
|
||||
|
||||
export default function DeskScreen() {
|
||||
const { bind } = useBind()
|
||||
const [overview, setOverview] = useState<ClientOverview | null>(null)
|
||||
const [proLive, setProLive] = useState<RelayLivePayload | null>(null)
|
||||
const [proMode, setProMode] = useState(false)
|
||||
const [netSnap, setNetSnap] = useState<NetworkPayload | null>(null)
|
||||
const [sysSnap, setSysSnap] = useState<SystemMetricsPayload | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!bind) {
|
||||
setOverview(null)
|
||||
setProLive(null)
|
||||
setNetSnap(null)
|
||||
setSysSnap(null)
|
||||
setErr('')
|
||||
return
|
||||
}
|
||||
setErr('')
|
||||
try {
|
||||
const r = await apiFetch(bind, '/client/overview')
|
||||
if (r.ok) setOverview((await r.json()) as ClientOverview)
|
||||
else setOverview(null)
|
||||
|
||||
const multi = await getMultiChannelPro()
|
||||
setProMode(multi)
|
||||
if (multi) {
|
||||
const lr = await apiFetch(bind, '/relay_pro/live_status')
|
||||
if (lr.ok) setProLive((await lr.json()) as RelayLivePayload)
|
||||
else setProLive(null)
|
||||
} else {
|
||||
setProLive(null)
|
||||
}
|
||||
|
||||
const nr = await apiFetch(bind, '/network_status')
|
||||
if (nr.ok) setNetSnap((await nr.json()) as NetworkPayload)
|
||||
else setNetSnap(null)
|
||||
|
||||
const sr = await apiFetch(bind, '/system_metrics')
|
||||
if (sr.ok) setSysSnap((await sr.json()) as SystemMetricsPayload)
|
||||
else setSysSnap(null)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void load()
|
||||
}, [load]),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bind || !proMode) return
|
||||
const id = setInterval(() => void load(), 12000)
|
||||
return () => clearInterval(id)
|
||||
}, [bind, proMode, load])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={
|
||||
bind ? (
|
||||
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<View style={styles.head}>
|
||||
<Text style={styles.h1}>仪表盘</Text>
|
||||
<Pressable style={styles.btnSec} disabled={!bind} onPress={() => void load()}>
|
||||
<Text style={styles.btnSecText}>刷新</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.lead}>
|
||||
与桌面端同源:概览、Pro 实况、网络/负载快照。事件与客户端日志仅在桌面端 SQLite。App 仅远程控制 PC,手机不运行 FFmpeg。
|
||||
</Text>
|
||||
|
||||
{!bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后显示频道摘要与转播状态。</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!err && bind ? <Text style={styles.err}>{err}</Text> : null}
|
||||
|
||||
{overview?.relayValidationErrors && overview.relayValidationErrors.length > 0 ? (
|
||||
<View style={styles.warnBox}>
|
||||
{overview.relayValidationErrors.map((x, i) => (
|
||||
<Text key={i} style={styles.warnText}>
|
||||
{x}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{proMode && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Pro 转播实况</Text>
|
||||
<Text style={styles.meta}>
|
||||
{proLive?.youtubeProcess ?? '—'} · 状态 {proLive?.processStatus ?? '—'}{' '}
|
||||
{proLive?.processOnline ? (
|
||||
<Text style={styles.tagOn}>运行中</Text>
|
||||
) : (
|
||||
<Text style={styles.tagOff}>未在运行</Text>
|
||||
)}
|
||||
</Text>
|
||||
{(proLive?.relayRows?.length ? proLive.relayRows : []).map((row, i) => (
|
||||
<View key={`${row.channelId ?? i}`} style={styles.liveRow}>
|
||||
<Text style={styles.liveTitle}>
|
||||
{row.channelName || '未命名频道'} · key …{row.youtubeKeySuffix || '—'}
|
||||
</Text>
|
||||
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
抖音 {row.douyinHint ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
标题 {row.streamTitle ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.liveLine}>已播时长 {fmtDur(row.durationSec)}</Text>
|
||||
</View>
|
||||
))}
|
||||
{!proLive?.relayRows?.length ? <Text style={styles.muted}>暂无实况</Text> : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.rowCards}>
|
||||
<View style={[styles.miniCard, { flex: 1 }]}>
|
||||
<Text style={styles.miniTitle}>网络快照</Text>
|
||||
<Text style={styles.miniMeta}>更新 {fmtTs(netSnap?.meta?.at)}</Text>
|
||||
<SnapshotNet data={netSnap as unknown} />
|
||||
</View>
|
||||
<View style={[styles.miniCard, { flex: 1 }]}>
|
||||
<Text style={styles.miniTitle}>本机负载快照</Text>
|
||||
<Text style={styles.miniMeta}>更新 {fmtTs(sysSnap?.ts)}</Text>
|
||||
<SnapshotSys data={sysSnap as unknown} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>直播与任务事件</Text>
|
||||
<Text style={styles.muted}>
|
||||
完整事件表、用户操作与客户端运行日志由桌面端本地数据库维护;请在 Windows 客户端查看明细。
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: theme.bg },
|
||||
scroll: { padding: 16, paddingBottom: 40 },
|
||||
head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
|
||||
h1: { fontSize: 20, fontWeight: '700', color: theme.text },
|
||||
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
|
||||
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 },
|
||||
btnSec: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
btnSecText: { color: theme.text, fontWeight: '600' },
|
||||
err: { color: theme.danger, marginBottom: 10 },
|
||||
warnBox: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(245,101,101,0.1)',
|
||||
marginBottom: 12,
|
||||
},
|
||||
warnText: { color: theme.danger, fontSize: 13, marginBottom: 4 },
|
||||
card: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(110,168,254,0.25)',
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 10 },
|
||||
meta: { fontSize: 13, color: theme.muted, marginBottom: 8 },
|
||||
tagOn: { color: theme.success, fontWeight: '700' },
|
||||
tagOff: { color: theme.warn, fontWeight: '700' },
|
||||
liveRow: {
|
||||
paddingVertical: 10,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
},
|
||||
liveTitle: { fontSize: 14, fontWeight: '600', color: theme.text, marginBottom: 4 },
|
||||
liveLine: { fontSize: 12, color: theme.muted, marginTop: 2 },
|
||||
rowCards: { flexDirection: 'row', gap: 10, marginBottom: 12 },
|
||||
miniCard: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
minWidth: 0,
|
||||
},
|
||||
miniTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 4 },
|
||||
miniMeta: { fontSize: 11, color: theme.muted, marginBottom: 8 },
|
||||
snapGrid: { gap: 6 },
|
||||
snapLine: { fontSize: 12, color: theme.text, lineHeight: 18 },
|
||||
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
|
||||
})
|
||||
814
app/(tabs)/index.tsx
Normal file
814
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,814 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
Linking,
|
||||
} 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 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 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}>
|
||||
常见于 WiFi / EasyTier 瞬时断连;若「远程」里测试 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}
|
||||
|
||||
{unbound && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:在「远程」页扫码后,此处会加载任务与实时数据。</Text>
|
||||
</View>
|
||||
)}
|
||||
{bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
以下仅远程控制 PC 上的转播与 PM2 进程;推流与 FFmpeg 在电脑端执行,手机不跑拉流/转码。
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<View style={[styles.pickerWrap, unbound && styles.pickerDisabled]}>
|
||||
<Picker
|
||||
enabled={!unbound && !r.emptyHint}
|
||||
selectedValue={unbound ? '' : r.currentProcess}
|
||||
onValueChange={(v) => r.setCurrentProcess(String(v))}
|
||||
style={styles.picker}
|
||||
dropdownIconColor={theme.text}
|
||||
>
|
||||
{unbound || !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>
|
||||
<Pressable
|
||||
style={[styles.proRow, unbound && styles.pickerDisabled]}
|
||||
disabled={unbound}
|
||||
onPress={() => {
|
||||
if (unbound) {
|
||||
Alert.alert('未绑定', '配对后可切换多频道 Pro。')
|
||||
return
|
||||
}
|
||||
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>
|
||||
|
||||
{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 },
|
||||
toolbar: { marginBottom: 12 },
|
||||
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 },
|
||||
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' },
|
||||
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 },
|
||||
})
|
||||
423
app/(tabs)/network.tsx
Normal file
423
app/(tabs)/network.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
Pressable,
|
||||
TextInput,
|
||||
Switch,
|
||||
Linking,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { NetworkPayload, SiteInfo, SpeedBlock } from '@/lib/pcTypes'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
const EMPTY_SITE: SiteInfo = {
|
||||
host: '',
|
||||
reachable: false,
|
||||
latency_ms: -1,
|
||||
error: null,
|
||||
resolved_ip: null,
|
||||
country: null,
|
||||
country_code: null,
|
||||
geo_error: null,
|
||||
}
|
||||
|
||||
const EMPTY_SPEED: SpeedBlock = {
|
||||
label: '',
|
||||
download_mbps: null,
|
||||
download_error: null,
|
||||
download_bytes_sampled: 0,
|
||||
upload_mbps: null,
|
||||
upload_error: null,
|
||||
upload_target: null,
|
||||
}
|
||||
|
||||
const DEFAULT_NET: NetworkPayload = {
|
||||
google: EMPTY_SITE,
|
||||
douyin: EMPTY_SITE,
|
||||
routes: { different: null, note: '' },
|
||||
speed: { overseas: EMPTY_SPEED, domestic: EMPTY_SPEED },
|
||||
}
|
||||
|
||||
function fmtMbps(v: number | null | undefined): string {
|
||||
if (v == null || Number.isNaN(v)) return '—'
|
||||
return `${v.toFixed(2)} Mbps`
|
||||
}
|
||||
|
||||
type ProxyState = { enabled: boolean; http: string; socks: string }
|
||||
|
||||
function SiteCard({
|
||||
title,
|
||||
site,
|
||||
speed,
|
||||
loading,
|
||||
variant,
|
||||
onSolution,
|
||||
}: {
|
||||
title: string
|
||||
site: SiteInfo
|
||||
speed: SpeedBlock
|
||||
loading: boolean
|
||||
variant: 'google' | 'douyin'
|
||||
onSolution?: () => void
|
||||
}) {
|
||||
const ok = site.reachable
|
||||
let badge: ReactNode
|
||||
if (loading) {
|
||||
badge = <Text style={styles.badgePending}>检测中</Text>
|
||||
} else if (ok) {
|
||||
badge = <Text style={styles.badgeOk}>可访问</Text>
|
||||
} else if (variant === 'google' && onSolution) {
|
||||
badge = (
|
||||
<Pressable onPress={onSolution}>
|
||||
<Text style={styles.badgeLink}>查看解决方案</Text>
|
||||
</Pressable>
|
||||
)
|
||||
} else {
|
||||
badge = <Text style={styles.badgeBad}>不可访问</Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.siteCard}>
|
||||
<View style={styles.siteHead}>
|
||||
<Text style={styles.siteTitle}>{title}</Text>
|
||||
{badge}
|
||||
</View>
|
||||
<View style={[styles.kvCol, loading && styles.dim]}>
|
||||
<View style={styles.kvRow}>
|
||||
<Text style={styles.k}>延迟</Text>
|
||||
<Text style={styles.v}>
|
||||
{loading
|
||||
? '—'
|
||||
: site.latency_ms != null && site.latency_ms >= 0
|
||||
? `${site.latency_ms.toFixed(0)} ms`
|
||||
: '—'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.kvRow}>
|
||||
<Text style={styles.k}>下行(估算)</Text>
|
||||
<Text style={styles.v}>{loading ? '—' : fmtMbps(speed.download_mbps)}</Text>
|
||||
</View>
|
||||
<View style={styles.kvRow}>
|
||||
<Text style={styles.k}>上行(估算)</Text>
|
||||
<Text style={styles.v}>{loading ? '—' : fmtMbps(speed.upload_mbps)}</Text>
|
||||
</View>
|
||||
<View style={styles.kvRow}>
|
||||
<Text style={styles.k}>解析 IP</Text>
|
||||
<Text style={[styles.v, styles.mono]}>{loading ? '—' : site.resolved_ip ?? '—'}</Text>
|
||||
</View>
|
||||
<View style={styles.kvRow}>
|
||||
<Text style={styles.k}>地区(参考)</Text>
|
||||
<Text style={styles.v}>
|
||||
{loading ? '—' : site.country ?? '—'}
|
||||
{!loading && site.country_code ? ` (${site.country_code})` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!loading && (site.error || site.geo_error) ? (
|
||||
<Text style={styles.siteErr}>
|
||||
{[site.error, site.geo_error].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NetworkScreen() {
|
||||
const { bind } = useBind()
|
||||
const [data, setData] = useState<NetworkPayload | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [proxy, setProxy] = useState<ProxyState>({ enabled: false, http: '', socks: '' })
|
||||
const [proxyMsg, setProxyMsg] = useState('')
|
||||
const [savingProxy, setSavingProxy] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const loadNet = useCallback(async () => {
|
||||
if (!bind) {
|
||||
setErr(null)
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
setErr(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const r = await apiFetch(bind, '/network_status')
|
||||
const json = (await r.json()) as NetworkPayload & { error?: string }
|
||||
if (!r.ok) {
|
||||
setErr(json.error || `请求失败 ${r.status}`)
|
||||
setData(null)
|
||||
return
|
||||
}
|
||||
setData(json)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setData(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
const loadProxy = useCallback(async () => {
|
||||
if (!bind) return
|
||||
setProxyMsg('')
|
||||
try {
|
||||
const pr = await apiFetch(bind, '/proxy_config')
|
||||
if (pr.ok) {
|
||||
const p = (await pr.json()) as ProxyState
|
||||
setProxy({
|
||||
enabled: !!p.enabled,
|
||||
http: String(p.http || ''),
|
||||
socks: String(p.socks || ''),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
const saveProxy = useCallback(async () => {
|
||||
if (!bind) {
|
||||
setProxyMsg('请先配对')
|
||||
return
|
||||
}
|
||||
setSavingProxy(true)
|
||||
setProxyMsg('')
|
||||
try {
|
||||
const res = await apiFetch(bind, '/proxy_config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(proxy),
|
||||
})
|
||||
if (!res.ok) throw new Error(String(res.status))
|
||||
setProxyMsg('已保存。重启直播任务后生效。')
|
||||
} catch (e) {
|
||||
setProxyMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSavingProxy(false)
|
||||
}
|
||||
}, [bind, proxy])
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
await loadNet()
|
||||
await loadProxy()
|
||||
}, [loadNet, loadProxy])
|
||||
|
||||
useEffect(() => {
|
||||
void loadAll()
|
||||
}, [loadAll])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bind) return
|
||||
const id = setInterval(() => void loadNet(), 45000)
|
||||
return () => clearInterval(id)
|
||||
}, [bind, loadNet])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await loadAll()
|
||||
setRefreshing(false)
|
||||
}, [loadAll])
|
||||
|
||||
const view = data ?? DEFAULT_NET
|
||||
|
||||
const openSolution = useCallback(() => {
|
||||
void Linking.openURL('https://nomadro.com/')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={
|
||||
bind ? (
|
||||
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Text style={styles.h1}>网络</Text>
|
||||
<Text style={styles.lead}>连通与测速(与桌面端「网络仪表盘」同源)。</Text>
|
||||
|
||||
{!bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 抖音 与测速。</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.cardNet}>
|
||||
<View style={styles.cardHead}>
|
||||
<Text style={styles.cardTitle}>网络仪表盘</Text>
|
||||
<Pressable style={styles.btnSec} onPress={() => void loadNet()} disabled={!bind || loading}>
|
||||
<Text style={styles.btnSecText}>{loading ? '检测中…' : '重新检测'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{err && bind ? <Text style={styles.err}>{err}</Text> : null}
|
||||
<View style={styles.siteRow}>
|
||||
<SiteCard
|
||||
title="Google(海外参考)"
|
||||
site={view.google}
|
||||
speed={view.speed.overseas}
|
||||
loading={loading && !!bind}
|
||||
variant="google"
|
||||
onSolution={openSolution}
|
||||
/>
|
||||
<SiteCard
|
||||
title="抖音(国内参考)"
|
||||
site={view.douyin}
|
||||
speed={view.speed.domestic}
|
||||
loading={loading && !!bind}
|
||||
variant="douyin"
|
||||
/>
|
||||
</View>
|
||||
{view.routes?.note ? (
|
||||
<Text style={styles.routesNote}>{view.routes.note}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardProxy}>
|
||||
<Text style={styles.cardTitle}>录制专用代理</Text>
|
||||
<Text style={styles.desc}>
|
||||
仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改系统代理。与桌面端「录制专用代理」一致。
|
||||
</Text>
|
||||
<View style={styles.rowBetween}>
|
||||
<Text style={styles.label}>启用仅录制进程使用的代理</Text>
|
||||
<Switch
|
||||
value={proxy.enabled}
|
||||
disabled={!bind}
|
||||
onValueChange={(v) => setProxy((p) => ({ ...p, enabled: v }))}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.label}>HTTP 代理</Text>
|
||||
<TextInput
|
||||
style={[styles.input, !bind && styles.inputDisabled]}
|
||||
value={proxy.http}
|
||||
editable={!!bind}
|
||||
onChangeText={(t) => setProxy((p) => ({ ...p, http: t }))}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
placeholderTextColor={theme.muted}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.label}>SOCKS5</Text>
|
||||
<TextInput
|
||||
style={[styles.input, !bind && styles.inputDisabled]}
|
||||
value={proxy.socks}
|
||||
editable={!!bind}
|
||||
onChangeText={(t) => setProxy((p) => ({ ...p, socks: t }))}
|
||||
placeholder="socks5://127.0.0.1:7890"
|
||||
placeholderTextColor={theme.muted}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
style={[styles.btnPri, (!bind || savingProxy) && styles.btnDisabled]}
|
||||
disabled={!bind || savingProxy}
|
||||
onPress={() => void saveProxy()}
|
||||
>
|
||||
<Text style={styles.btnPriText}>{savingProxy ? '保存中…' : '保存'}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.btnSec} disabled={!bind} onPress={() => void loadProxy()}>
|
||||
<Text style={styles.btnSecText}>重新读取</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{!!proxyMsg && <Text style={proxyMsg.includes('已保存') ? styles.ok : styles.err}>{proxyMsg}</Text>}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: theme.bg },
|
||||
scroll: { padding: 16, paddingBottom: 40 },
|
||||
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
|
||||
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
|
||||
banner: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(124,92,255,0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124,92,255,0.25)',
|
||||
marginBottom: 14,
|
||||
},
|
||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||
cardNet: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124,92,255,0.28)',
|
||||
marginBottom: 14,
|
||||
},
|
||||
cardProxy: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(61,214,140,0.22)',
|
||||
marginBottom: 14,
|
||||
},
|
||||
cardHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
||||
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text },
|
||||
btnSec: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
||||
btnPri: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#3d7eff',
|
||||
},
|
||||
btnPriText: { color: '#fff', fontWeight: '700' },
|
||||
btnDisabled: { opacity: 0.5 },
|
||||
err: { color: theme.danger, marginBottom: 10, fontSize: 13 },
|
||||
ok: { color: theme.success, marginTop: 8, fontSize: 13 },
|
||||
siteRow: { gap: 12 },
|
||||
siteCard: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
marginBottom: 4,
|
||||
},
|
||||
siteHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
|
||||
siteTitle: { fontSize: 14, fontWeight: '600', color: theme.text, flex: 1 },
|
||||
badgePending: { fontSize: 11, fontWeight: '700', color: theme.muted },
|
||||
badgeOk: { fontSize: 11, fontWeight: '700', color: theme.success },
|
||||
badgeBad: { fontSize: 11, fontWeight: '700', color: theme.danger },
|
||||
badgeLink: { fontSize: 11, fontWeight: '700', color: theme.accent, textDecorationLine: 'underline' },
|
||||
kvCol: { gap: 6 },
|
||||
kvRow: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 8 },
|
||||
dim: { opacity: 0.72 },
|
||||
k: { fontSize: 12, color: theme.muted, fontWeight: '600', marginTop: 4 },
|
||||
v: { fontSize: 13, color: theme.text },
|
||||
mono: { fontFamily: 'monospace', fontSize: 12 },
|
||||
siteErr: { fontSize: 11, color: theme.warn, marginTop: 8, lineHeight: 16 },
|
||||
routesNote: { fontSize: 12, color: theme.muted, marginTop: 10, lineHeight: 18 },
|
||||
desc: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 12 },
|
||||
rowBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
|
||||
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
color: theme.text,
|
||||
marginBottom: 12,
|
||||
backgroundColor: theme.bg,
|
||||
},
|
||||
inputDisabled: { opacity: 0.6 },
|
||||
row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap', marginTop: 4 },
|
||||
})
|
||||
208
app/(tabs)/system.tsx
Normal file
208
app/(tabs)/system.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { SystemMetricsPayload } from '@/lib/pcTypes'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
function fmtBytes(n: number | undefined): string {
|
||||
if (n == null || Number.isNaN(n)) return '—'
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||||
return `${(n / 1024 ** 3).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
export default function SystemScreen() {
|
||||
const { bind } = useBind()
|
||||
const [data, setData] = useState<SystemMetricsPayload | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!bind) {
|
||||
setData(null)
|
||||
setErr(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(bind, '/system_metrics')
|
||||
const json = (await res.json()) as SystemMetricsPayload
|
||||
if (!res.ok) {
|
||||
setErr(json.error || `HTTP ${res.status}`)
|
||||
return
|
||||
}
|
||||
setErr(null)
|
||||
setData(json)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bind) return
|
||||
const id = setInterval(() => void load(), 2000)
|
||||
return () => clearInterval(id)
|
||||
}, [bind, load])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}, [load])
|
||||
|
||||
const m = data?.memory
|
||||
const net = data?.network
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={
|
||||
bind ? (
|
||||
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Text style={styles.h1}>系统</Text>
|
||||
<Text style={styles.lead}>本机负载与网速(与桌面端「系统」页同源)。开机启动等仅在 Windows 客户端可设置。</Text>
|
||||
|
||||
{!bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后每 2 秒刷新指标。</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>本机负载与网速</Text>
|
||||
<Text style={styles.desc}>
|
||||
网速为全机网卡合计估算;下方列出检测到的 ffmpeg 进程。
|
||||
</Text>
|
||||
{err && bind ? (
|
||||
<Text style={styles.err}>{err}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.metricsGrid}>
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.blockTitle}>CPU</Text>
|
||||
<Text style={styles.big}>{data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}</Text>
|
||||
<Text style={styles.sub}>逻辑处理器 {data?.cpu_count_logical ?? '—'} 个</Text>
|
||||
</View>
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.blockTitle}>内存</Text>
|
||||
<Text style={styles.big}>{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}</Text>
|
||||
<Text style={styles.sub}>
|
||||
{fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)})
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.blockTitle}>全机网速</Text>
|
||||
<Text style={styles.rate}>
|
||||
↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}
|
||||
</Text>
|
||||
<Text style={styles.rate}>
|
||||
↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}
|
||||
</Text>
|
||||
<Text style={styles.sub}>含所有网卡收发合计</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{data?.swap && data.swap.total != null && data.swap.total > 0 ? (
|
||||
<Text style={styles.swap}>
|
||||
交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
|
||||
{fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{data?.disks && data.disks.length > 0 ? (
|
||||
<View style={styles.diskSec}>
|
||||
<Text style={styles.diskTitle}>磁盘空间</Text>
|
||||
{data.disks.slice(0, 8).map((d, i) => (
|
||||
<Text key={i} style={styles.diskLine}>
|
||||
<Text style={styles.diskMp}>{d.mountpoint}</Text> ·{' '}
|
||||
{d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 ? (
|
||||
<View style={styles.ffSec}>
|
||||
<Text style={styles.diskTitle}>ffmpeg 进程</Text>
|
||||
{data.ffmpeg_processes.map((p, i) => (
|
||||
<Text key={i} style={styles.ffLine}>
|
||||
PID {p.pid} · {p.name ?? '—'} · {fmtBytes(p.rss)}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<View style={styles.cardMuted}>
|
||||
<Text style={styles.cardTitle}>客户端与系统集成</Text>
|
||||
<Text style={styles.muted}>开机启动、路径与 Electron 集成仅在桌面客户端可用。</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: theme.bg },
|
||||
scroll: { padding: 16, paddingBottom: 40 },
|
||||
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
|
||||
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
|
||||
banner: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(124,92,255,0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124,92,255,0.22)',
|
||||
marginBottom: 14,
|
||||
},
|
||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||
card: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124,92,255,0.22)',
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardMuted: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 },
|
||||
desc: { fontSize: 13, color: theme.muted, marginBottom: 12, lineHeight: 20 },
|
||||
err: { color: theme.danger, marginBottom: 10, fontSize: 13 },
|
||||
metricsGrid: { gap: 12 },
|
||||
block: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
blockTitle: { fontSize: 11, fontWeight: '700', color: theme.muted, marginBottom: 6 },
|
||||
big: { fontSize: 22, fontWeight: '700', color: theme.text },
|
||||
sub: { fontSize: 11, color: theme.muted, marginTop: 4, lineHeight: 16 },
|
||||
rate: { fontSize: 15, fontWeight: '600', color: theme.accent, fontFamily: 'monospace' },
|
||||
swap: { fontSize: 12, color: theme.muted, marginTop: 10, marginBottom: 8 },
|
||||
diskSec: { marginTop: 8 },
|
||||
diskTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 8 },
|
||||
diskLine: { fontSize: 12, color: theme.muted, marginBottom: 6 },
|
||||
diskMp: { fontFamily: 'monospace', color: theme.text },
|
||||
ffSec: { marginTop: 12 },
|
||||
ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' },
|
||||
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
|
||||
})
|
||||
392
app/(tabs)/wechat.tsx
Normal file
392
app/(tabs)/wechat.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Image,
|
||||
Linking,
|
||||
RefreshControl,
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
type WechatConfigView = {
|
||||
configured: boolean
|
||||
proxyUrl: string
|
||||
apiKeyMasked: string
|
||||
deviceType: string
|
||||
proxy: string
|
||||
webhookHost: string
|
||||
webhookPort: number
|
||||
webhookPath: string
|
||||
accountId: string
|
||||
session: { wcId?: string; nickName?: string }
|
||||
}
|
||||
|
||||
export default function WeChatScreen() {
|
||||
const { bind } = useBind()
|
||||
const [cfg, setCfg] = useState<WechatConfigView | null>(null)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [apiKeyInput, setApiKeyInput] = useState('')
|
||||
const [proxyUrlInput, setProxyUrlInput] = useState('')
|
||||
const [deviceType, setDeviceType] = useState('ipad')
|
||||
const [proxyLine, setProxyLine] = useState('10')
|
||||
const [wId, setWId] = useState('')
|
||||
const [qrUrl, setQrUrl] = useState('')
|
||||
const [poll, setPoll] = useState(false)
|
||||
const [proxyStatus, setProxyStatus] = useState<Record<string, unknown> | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!bind) {
|
||||
setCfg(null)
|
||||
return
|
||||
}
|
||||
setMsg('')
|
||||
try {
|
||||
const r = await apiFetch(bind, '/chatbot/wechat/config')
|
||||
if (!r.ok) throw new Error(String(r.status))
|
||||
const j = (await r.json()) as WechatConfigView
|
||||
setCfg(j)
|
||||
setProxyUrlInput(j.proxyUrl || '')
|
||||
setDeviceType(j.deviceType || 'ipad')
|
||||
setProxyLine(j.proxy || '10')
|
||||
setApiKeyInput('')
|
||||
} catch (e) {
|
||||
setCfg(null)
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!poll || !wId || !bind) return
|
||||
const tick = async () => {
|
||||
try {
|
||||
const r = await apiFetch(bind, '/chatbot/wechat/login/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wId }),
|
||||
})
|
||||
const j = (await r.json()) as {
|
||||
status?: string
|
||||
wcId?: string
|
||||
nickName?: string
|
||||
verifyUrl?: string
|
||||
error?: string
|
||||
}
|
||||
if (!r.ok) {
|
||||
setMsg(j.error || String(r.status))
|
||||
setPoll(false)
|
||||
return
|
||||
}
|
||||
if (j.status === 'logged_in') {
|
||||
setPoll(false)
|
||||
setMsg(`已登录:${j.nickName || ''}(${j.wcId || ''})`)
|
||||
void load()
|
||||
return
|
||||
}
|
||||
if (j.status === 'need_verify' && j.verifyUrl) {
|
||||
setMsg(`需要辅助验证:${j.verifyUrl}`)
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
setPoll(false)
|
||||
}
|
||||
}
|
||||
const id = setInterval(() => void tick(), 5000)
|
||||
void tick()
|
||||
return () => clearInterval(id)
|
||||
}, [poll, wId, bind, load])
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!bind) return
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const body: Record<string, string | number> = {
|
||||
proxyUrl: proxyUrlInput.trim(),
|
||||
deviceType,
|
||||
proxy: proxyLine.trim(),
|
||||
}
|
||||
if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim()
|
||||
const r = await apiFetch(bind, '/chatbot/wechat/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!r.ok) {
|
||||
const t = await r.text()
|
||||
throw new Error(t || String(r.status))
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadProxyStatus = async () => {
|
||||
if (!bind) return
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
try {
|
||||
const r = await apiFetch(bind, '/chatbot/wechat/proxy/status')
|
||||
const j = await r.json()
|
||||
if (!r.ok) {
|
||||
setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j))
|
||||
setProxyStatus(null)
|
||||
return
|
||||
}
|
||||
setProxyStatus(j as Record<string, unknown>)
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startQr = async () => {
|
||||
if (!bind) return
|
||||
setBusy(true)
|
||||
setMsg('')
|
||||
setQrUrl('')
|
||||
setWId('')
|
||||
try {
|
||||
const r = await apiFetch(bind, '/chatbot/wechat/qr/start', { method: 'POST' })
|
||||
const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string }
|
||||
if (!r.ok) {
|
||||
setMsg(j.error || String(r.status))
|
||||
return
|
||||
}
|
||||
if (!j.wId || !j.qrCodeUrl) {
|
||||
setMsg('未返回二维码')
|
||||
return
|
||||
}
|
||||
setWId(j.wId)
|
||||
setQrUrl(j.qrCodeUrl)
|
||||
setPoll(true)
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={
|
||||
bind ? (
|
||||
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Text style={styles.h1}>微信</Text>
|
||||
<Text style={styles.lead}>与桌面端「微信」页同源:openclaw-wechat 代理 API。</Text>
|
||||
|
||||
{!bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可读写配置与扫码登录。</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>微信 Chatbot</Text>
|
||||
<Text style={styles.label}>proxyUrl(代理服务根地址)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, !bind && styles.inputDisabled]}
|
||||
value={proxyUrlInput}
|
||||
editable={!!bind}
|
||||
onChangeText={setProxyUrlInput}
|
||||
placeholder="http://your-proxy:3000"
|
||||
placeholderTextColor={theme.muted}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.label}>apiKey(留空则不修改已保存密钥)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, !bind && styles.inputDisabled]}
|
||||
value={apiKeyInput}
|
||||
editable={!!bind}
|
||||
onChangeText={setApiKeyInput}
|
||||
placeholder={cfg?.apiKeyMasked ? `已保存 ${cfg.apiKeyMasked}` : 'wc_live_…'}
|
||||
placeholderTextColor={theme.muted}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.label}>deviceType</Text>
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
style={[styles.chip, deviceType === 'ipad' && styles.chipOn]}
|
||||
onPress={() => setDeviceType('ipad')}
|
||||
disabled={!bind || busy}
|
||||
>
|
||||
<Text style={styles.chipText}>ipad</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.chip, deviceType === 'mac' && styles.chipOn]}
|
||||
onPress={() => setDeviceType('mac')}
|
||||
disabled={!bind || busy}
|
||||
>
|
||||
<Text style={styles.chipText}>mac</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.label}>proxy 参数</Text>
|
||||
<TextInput
|
||||
style={[styles.input, !bind && styles.inputDisabled]}
|
||||
value={proxyLine}
|
||||
editable={!!bind}
|
||||
onChangeText={setProxyLine}
|
||||
placeholderTextColor={theme.muted}
|
||||
/>
|
||||
|
||||
<View style={styles.btnRow}>
|
||||
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void saveConfig()}>
|
||||
<Text style={styles.btnSecText}>保存配置</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void load()}>
|
||||
<Text style={styles.btnSecText}>刷新</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btnSec, !bind && styles.disabled]}
|
||||
disabled={!bind || busy}
|
||||
onPress={() => void loadProxyStatus()}
|
||||
>
|
||||
<Text style={styles.btnSecText}>查询代理状态</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{proxyStatus ? (
|
||||
<Text style={styles.pre}>{JSON.stringify(proxyStatus, null, 2)}</Text>
|
||||
) : null}
|
||||
|
||||
{cfg?.session?.wcId ? (
|
||||
<Text style={styles.hint}>本地记录:{cfg.session.nickName || '—'}({cfg.session.wcId})</Text>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[styles.btnPri, (!bind || busy || !cfg?.configured) && styles.disabled]}
|
||||
disabled={!bind || busy || !cfg?.configured}
|
||||
onPress={() => void startQr()}
|
||||
>
|
||||
<Text style={styles.btnPriText}>获取登录二维码</Text>
|
||||
</Pressable>
|
||||
{!cfg?.configured && bind ? (
|
||||
<Text style={styles.hint}>请先填写并保存 apiKey 与 proxyUrl。</Text>
|
||||
) : null}
|
||||
|
||||
{qrUrl ? (
|
||||
<View style={styles.qrBlock}>
|
||||
<Text style={styles.hint}>请使用微信扫描下方二维码。</Text>
|
||||
<Image source={{ uri: qrUrl }} style={styles.qrImg} resizeMode="contain" />
|
||||
<Pressable onPress={() => void Linking.openURL(qrUrl)}>
|
||||
<Text style={styles.link}>打开二维码链接</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!!msg && (
|
||||
<Text style={[styles.msg, msg.startsWith('已登录') ? styles.msgOk : styles.msgErr]}>{msg}</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: theme.bg },
|
||||
scroll: { padding: 16, paddingBottom: 40 },
|
||||
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
|
||||
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
|
||||
banner: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: 'rgba(100,149,237,0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(100,149,237,0.25)',
|
||||
marginBottom: 14,
|
||||
},
|
||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||
card: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(100,149,237,0.28)',
|
||||
},
|
||||
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 12 },
|
||||
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
color: theme.text,
|
||||
marginBottom: 12,
|
||||
backgroundColor: theme.bg,
|
||||
},
|
||||
inputDisabled: { opacity: 0.55 },
|
||||
row: { flexDirection: 'row', gap: 10, marginBottom: 12 },
|
||||
chip: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
chipOn: { borderColor: theme.accent, backgroundColor: 'rgba(91,157,255,0.12)' },
|
||||
chipText: { color: theme.text, fontWeight: '600' },
|
||||
btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 },
|
||||
btnSec: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
backgroundColor: theme.bgElevated,
|
||||
},
|
||||
btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 },
|
||||
btnPri: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#3d7eff',
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnPriText: { color: '#fff', fontWeight: '700' },
|
||||
disabled: { opacity: 0.45 },
|
||||
pre: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: '#c8cdd5',
|
||||
marginBottom: 12,
|
||||
lineHeight: 16,
|
||||
},
|
||||
hint: { fontSize: 13, color: theme.muted, marginTop: 8, lineHeight: 20 },
|
||||
qrBlock: { marginTop: 16, alignItems: 'center' },
|
||||
qrImg: { width: 224, height: 224, marginVertical: 12, backgroundColor: '#fff' },
|
||||
link: { color: theme.accent, fontSize: 14, textDecorationLine: 'underline' },
|
||||
msg: { marginTop: 12, fontSize: 14 },
|
||||
msgOk: { color: theme.success },
|
||||
msgErr: { color: theme.danger },
|
||||
})
|
||||
38
app/+html.tsx
Normal file
38
app/+html.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
40
app/+not-found.tsx
Normal file
40
app/+not-found.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import { Text, View } from '@/components/Themed';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>This screen doesn't exist.</Text>
|
||||
|
||||
<Link href="/" style={styles.link}>
|
||||
<Text style={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
marginTop: 15,
|
||||
paddingVertical: 15,
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 14,
|
||||
color: '#2e78b7',
|
||||
},
|
||||
});
|
||||
58
app/_layout.tsx
Normal file
58
app/_layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||
import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { BindProvider } from '@/context/BindContext';
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
ErrorBoundary,
|
||||
} from 'expo-router';
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
});
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
if (error) throw error;
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<BindProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
</BindProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user