's'
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
48
app.json
Normal file
48
app.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "直播录制助手",
|
||||||
|
"slug": "live-recorder-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "mobile",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"splash": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff"
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.douyin.recorder.remote",
|
||||||
|
"usesCleartextTraffic": true,
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"permissions": ["CAMERA"]
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"bundler": "metro",
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "用于扫描 PC 端绑定二维码(含 EasyTier 组网信息)。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
assets/fonts/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/images/android-icon-background.png
Normal file
BIN
assets/images/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
BIN
assets/images/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
BIN
assets/images/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/favicon.png
Normal file
BIN
assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/images/icon.png
Normal file
BIN
assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/images/splash-icon.png
Normal file
BIN
assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
77
components/EditScreenInfo.tsx
Normal file
77
components/EditScreenInfo.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ExternalLink } from './ExternalLink';
|
||||||
|
import { MonoText } from './StyledText';
|
||||||
|
import { Text, View } from './Themed';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
|
||||||
|
export default function EditScreenInfo({ path }: { path: string }) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={styles.getStartedContainer}>
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Open up the code for this screen:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[styles.codeHighlightContainer, styles.homeScreenFilename]}
|
||||||
|
darkColor="rgba(255,255,255,0.05)"
|
||||||
|
lightColor="rgba(0,0,0,0.05)">
|
||||||
|
<MonoText>{path}</MonoText>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
style={styles.getStartedText}
|
||||||
|
lightColor="rgba(0,0,0,0.8)"
|
||||||
|
darkColor="rgba(255,255,255,0.8)">
|
||||||
|
Change any of the text, save the file, and your app will automatically update.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpContainer}>
|
||||||
|
<ExternalLink
|
||||||
|
style={styles.helpLink}
|
||||||
|
href="https://docs.expo.io/get-started/create-a-new-app/#opening-the-app-on-your-phonetablet">
|
||||||
|
<Text style={styles.helpLinkText} lightColor={Colors.light.tint}>
|
||||||
|
Tap here if your app doesn't automatically update after making changes
|
||||||
|
</Text>
|
||||||
|
</ExternalLink>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
getStartedContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginHorizontal: 50,
|
||||||
|
},
|
||||||
|
homeScreenFilename: {
|
||||||
|
marginVertical: 7,
|
||||||
|
},
|
||||||
|
codeHighlightContainer: {
|
||||||
|
borderRadius: 3,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
},
|
||||||
|
getStartedText: {
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
helpContainer: {
|
||||||
|
marginTop: 15,
|
||||||
|
marginHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
helpLink: {
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
helpLinkText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
24
components/ExternalLink.tsx
Normal file
24
components/ExternalLink.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Link, type Href } from 'expo-router';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import React from 'react';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export function ExternalLink(
|
||||||
|
props: Omit<React.ComponentProps<typeof Link>, 'href'> & { href: Href }
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...props}
|
||||||
|
href={props.href}
|
||||||
|
onPress={(e) => {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
e.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
WebBrowser.openBrowserAsync(props.href as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
components/StyledText.tsx
Normal file
5
components/StyledText.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Text, TextProps } from './Themed';
|
||||||
|
|
||||||
|
export function MonoText(props: TextProps) {
|
||||||
|
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
|
||||||
|
}
|
||||||
45
components/Themed.tsx
Normal file
45
components/Themed.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Learn more about Light and Dark modes:
|
||||||
|
* https://docs.expo.io/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
import { Text as DefaultText, View as DefaultView } from 'react-native';
|
||||||
|
|
||||||
|
import { useColorScheme } from './useColorScheme';
|
||||||
|
|
||||||
|
import Colors from '@/constants/Colors';
|
||||||
|
|
||||||
|
type ThemeProps = {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TextProps = ThemeProps & DefaultText['props'];
|
||||||
|
export type ViewProps = ThemeProps & DefaultView['props'];
|
||||||
|
|
||||||
|
export function useThemeColor(
|
||||||
|
props: { light?: string; dark?: string },
|
||||||
|
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
|
||||||
|
) {
|
||||||
|
const theme = useColorScheme();
|
||||||
|
const colorFromProps = props[theme];
|
||||||
|
|
||||||
|
if (colorFromProps) {
|
||||||
|
return colorFromProps;
|
||||||
|
} else {
|
||||||
|
return Colors[theme][colorName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Text(props: TextProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
|
||||||
|
|
||||||
|
return <DefaultText style={[{ color }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function View(props: ViewProps) {
|
||||||
|
const { style, lightColor, darkColor, ...otherProps } = props;
|
||||||
|
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||||
|
|
||||||
|
return <DefaultView style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
4
components/useClientOnlyValue.ts
Normal file
4
components/useClientOnlyValue.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// This function is web-only as native doesn't currently support server (or build-time) rendering.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
12
components/useClientOnlyValue.web.ts
Normal file
12
components/useClientOnlyValue.web.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// `useEffect` is not invoked during server rendering, meaning
|
||||||
|
// we can use this to determine if we're on the server or not.
|
||||||
|
export function useClientOnlyValue<S, C>(server: S, client: C): S | C {
|
||||||
|
const [value, setValue] = React.useState<S | C>(server);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setValue(client);
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
6
components/useColorScheme.ts
Normal file
6
components/useColorScheme.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useColorScheme as useColorSchemeCore } from 'react-native';
|
||||||
|
|
||||||
|
export const useColorScheme = () => {
|
||||||
|
const coreScheme = useColorSchemeCore();
|
||||||
|
return coreScheme === 'unspecified' ? 'light' : coreScheme;
|
||||||
|
};
|
||||||
8
components/useColorScheme.web.ts
Normal file
8
components/useColorScheme.web.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// NOTE: The default React Native styling doesn't support server rendering.
|
||||||
|
// Server rendered styles should not change between the first render of the HTML
|
||||||
|
// and the first render on the client. Typically, web developers will use CSS media queries
|
||||||
|
// to render different styles on the client and server, these aren't directly supported in React Native
|
||||||
|
// but can be achieved using a styling library like Nativewind.
|
||||||
|
export function useColorScheme() {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
18
constants/Colors.ts
Normal file
18
constants/Colors.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const accent = '#5b9dff';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
light: {
|
||||||
|
text: '#0f172a',
|
||||||
|
background: '#f3f5fb',
|
||||||
|
tint: '#2563eb',
|
||||||
|
tabIconDefault: '#94a3b8',
|
||||||
|
tabIconSelected: '#2563eb',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: '#e8eaed',
|
||||||
|
background: '#14161c',
|
||||||
|
tint: accent,
|
||||||
|
tabIconDefault: '#64748b',
|
||||||
|
tabIconSelected: accent,
|
||||||
|
},
|
||||||
|
};
|
||||||
13
constants/theme.ts
Normal file
13
constants/theme.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** 与桌面端 App.css 深色主题对齐 */
|
||||||
|
export const theme = {
|
||||||
|
bg: '#14161c',
|
||||||
|
bgCard: '#22262e',
|
||||||
|
bgElevated: '#282c36',
|
||||||
|
border: 'rgba(255,255,255,0.08)',
|
||||||
|
text: '#e8eaed',
|
||||||
|
muted: '#8b929e',
|
||||||
|
accent: '#5b9dff',
|
||||||
|
success: '#3dd68c',
|
||||||
|
danger: '#f56565',
|
||||||
|
warn: '#f6ad55',
|
||||||
|
}
|
||||||
65
context/BindContext.tsx
Normal file
65
context/BindContext.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Platform } from 'react-native'
|
||||||
|
import { isExpoEasyTierVpnAvailable, saveAndPrepareTunnel } from '@/lib/easyTierVpn'
|
||||||
|
import type { BindPayload } from '@/lib/types'
|
||||||
|
import { clearBindPayload, loadBindPayload, saveBindPayload } from '@/lib/storage'
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
bind: BindPayload | null
|
||||||
|
ready: boolean
|
||||||
|
setBind: (p: BindPayload | null) => Promise<void>
|
||||||
|
clear: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const BindContext = createContext<Ctx | null>(null)
|
||||||
|
|
||||||
|
export function BindProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [bind, setBindState] = useState<BindPayload | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const p = await loadBindPayload()
|
||||||
|
setBindState(p)
|
||||||
|
setReady(true)
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || !bind) return
|
||||||
|
if (Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
|
||||||
|
void saveAndPrepareTunnel(bind.easytier).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [ready, bind])
|
||||||
|
|
||||||
|
const setBind = useCallback(async (p: BindPayload | null) => {
|
||||||
|
setBindState(p)
|
||||||
|
if (p) await saveBindPayload(p)
|
||||||
|
else await clearBindPayload()
|
||||||
|
if (p && Platform.OS === 'android' && isExpoEasyTierVpnAvailable()) {
|
||||||
|
try {
|
||||||
|
await saveAndPrepareTunnel(p.easytier)
|
||||||
|
} catch {
|
||||||
|
/* 组网内核未就绪或用户未授权 VPN 时不阻断配对 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clear = useCallback(async () => {
|
||||||
|
setBindState(null)
|
||||||
|
await clearBindPayload()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ bind, ready, setBind, clear }),
|
||||||
|
[bind, ready, setBind, clear],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <BindContext.Provider value={value}>{children}</BindContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBind(): Ctx {
|
||||||
|
const c = useContext(BindContext)
|
||||||
|
if (!c) throw new Error('useBind outside BindProvider')
|
||||||
|
return c
|
||||||
|
}
|
||||||
1048
hooks/useRecorder.ts
Normal file
1048
hooks/useRecorder.ts
Normal file
File diff suppressed because it is too large
Load Diff
31
lib/api.ts
Normal file
31
lib/api.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { BindPayload } from './types'
|
||||||
|
|
||||||
|
function joinUrl(base: string, path: string): string {
|
||||||
|
const b = base.replace(/\/$/, '')
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${b}${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetch(
|
||||||
|
bind: BindPayload | null,
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> {
|
||||||
|
if (!bind) {
|
||||||
|
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
||||||
|
}
|
||||||
|
const headers = new Headers(init?.headers)
|
||||||
|
if (bind.token?.trim()) {
|
||||||
|
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
|
||||||
|
}
|
||||||
|
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await apiFetch(bind, path, init)
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text()
|
||||||
|
throw new Error(t || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
36
lib/easyTierVpn.ts
Normal file
36
lib/easyTierVpn.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { requireOptionalNativeModule } from 'expo-modules-core'
|
||||||
|
|
||||||
|
export type EasyTierConfigInput = {
|
||||||
|
networkName: string
|
||||||
|
networkSecret: string
|
||||||
|
peerUrls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type NativeModule = {
|
||||||
|
isAvailable: () => boolean
|
||||||
|
saveAndPrepareTunnel: (configJson: string) => Promise<Record<string, unknown>>
|
||||||
|
checkVpnPermissionGranted: () => Promise<Record<string, unknown>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const Native = requireOptionalNativeModule<NativeModule>('ExpoEasyTierVpn')
|
||||||
|
|
||||||
|
export function isExpoEasyTierVpnAvailable(): boolean {
|
||||||
|
try {
|
||||||
|
return Native?.isAvailable?.() === true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAndPrepareTunnel(config: EasyTierConfigInput): Promise<Record<string, unknown>> {
|
||||||
|
if (!Native?.saveAndPrepareTunnel) {
|
||||||
|
return { ok: false, reason: 'native_module_missing' }
|
||||||
|
}
|
||||||
|
return Native.saveAndPrepareTunnel(JSON.stringify(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkVpnPermissionGranted(): Promise<boolean> {
|
||||||
|
if (!Native?.checkVpnPermissionGranted) return false
|
||||||
|
const r = await Native.checkVpnPermissionGranted()
|
||||||
|
return r?.granted === true
|
||||||
|
}
|
||||||
88
lib/pcTypes.ts
Normal file
88
lib/pcTypes.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/** 与 Electron NetworkDashboard / SystemMetrics 对齐的数据结构 */
|
||||||
|
|
||||||
|
export type SiteInfo = {
|
||||||
|
host: string
|
||||||
|
reachable: boolean
|
||||||
|
latency_ms: number
|
||||||
|
error: string | null
|
||||||
|
resolved_ip: string | null
|
||||||
|
country: string | null
|
||||||
|
country_code: string | null
|
||||||
|
geo_error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpeedBlock = {
|
||||||
|
label: string
|
||||||
|
download_mbps: number | null
|
||||||
|
download_error: string | null
|
||||||
|
download_bytes_sampled: number
|
||||||
|
upload_mbps: number | null
|
||||||
|
upload_error: string | null
|
||||||
|
upload_target: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NetworkPayload = {
|
||||||
|
google: SiteInfo
|
||||||
|
douyin: SiteInfo
|
||||||
|
routes: { different: boolean | null; note: string }
|
||||||
|
speed: { overseas: SpeedBlock; domestic: SpeedBlock }
|
||||||
|
meta?: { at: number; download_cap_bytes: number; upload_bytes: number }
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SystemMetricsPayload = {
|
||||||
|
ts?: number
|
||||||
|
cpu_percent?: number | null
|
||||||
|
cpu_count_logical?: number
|
||||||
|
memory?: { percent?: number; used?: number; total?: number; available?: number }
|
||||||
|
swap?: { percent?: number; used?: number; total?: number }
|
||||||
|
disks?: Array<{
|
||||||
|
mountpoint?: string
|
||||||
|
percent?: number
|
||||||
|
free?: number
|
||||||
|
total?: number
|
||||||
|
}>
|
||||||
|
network?: { up_mbps?: number | null; down_mbps?: number | null }
|
||||||
|
ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }>
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientOverview = {
|
||||||
|
youtubeChannelCount?: number
|
||||||
|
youtubeChannels?: { name: string; keyPreview: string; douyinPreview?: string }[]
|
||||||
|
relayPairingPolicy?: string
|
||||||
|
relayValidationErrors?: string[]
|
||||||
|
relayNotes?: string[]
|
||||||
|
wechat?: {
|
||||||
|
configured?: boolean
|
||||||
|
nickName?: string
|
||||||
|
wcId?: string
|
||||||
|
online?: boolean
|
||||||
|
proxyError?: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RelayLivePayload = {
|
||||||
|
youtubeProcess?: string
|
||||||
|
processStatus?: string
|
||||||
|
processOnline?: boolean
|
||||||
|
relayRows?: Array<{
|
||||||
|
channelId?: string | null
|
||||||
|
channelName?: string
|
||||||
|
anchor?: string
|
||||||
|
douyinHint?: string
|
||||||
|
streamTitle?: string
|
||||||
|
youtubeKeySuffix?: string
|
||||||
|
durationSec?: number | null
|
||||||
|
processOnline?: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YoutubeIniModel = {
|
||||||
|
keys: string[]
|
||||||
|
activeIndex: number
|
||||||
|
options: Record<string, string>
|
||||||
|
header: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GpuCaps = { nvidia_detected: boolean; nvenc_available: boolean }
|
||||||
17
lib/scriptUtils.ts
Normal file
17
lib/scriptUtils.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function pm2NameFromScript(script: string): string {
|
||||||
|
const base = String(script).split(/[/\\]/).pop() || ''
|
||||||
|
const i = base.lastIndexOf('.')
|
||||||
|
return i > 0 ? base.slice(0, i) : base
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubeScript(script: string): boolean {
|
||||||
|
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('youtube')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTiktokScript(script: string): boolean {
|
||||||
|
return /\.py$/i.test(script) && pm2NameFromScript(script).startsWith('tiktok')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObsScript(script: string): boolean {
|
||||||
|
return pm2NameFromScript(script).startsWith('obs')
|
||||||
|
}
|
||||||
39
lib/storage.ts
Normal file
39
lib/storage.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import type { BindPayload } from './types'
|
||||||
|
|
||||||
|
const KEY = 'bind_payload_v1'
|
||||||
|
|
||||||
|
export async function loadBindPayload(): Promise<BindPayload | null> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const j = JSON.parse(raw) as BindPayload
|
||||||
|
if (j.v !== 1 || !j.baseUrl) return null
|
||||||
|
return j
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBindPayload(p: BindPayload): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(KEY, JSON.stringify(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearBindPayload(): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORAGE_MULTI_CHANNEL_PRO = 'recording_multi_channel_pro'
|
||||||
|
|
||||||
|
export async function getMultiChannelPro(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const v = await AsyncStorage.getItem(STORAGE_MULTI_CHANNEL_PRO)
|
||||||
|
return v === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMultiChannelPro(on: boolean): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(STORAGE_MULTI_CHANNEL_PRO, on ? '1' : '0')
|
||||||
|
}
|
||||||
54
lib/types.ts
Normal file
54
lib/types.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export type EasyTierConfig = {
|
||||||
|
networkName: string
|
||||||
|
networkSecret: string
|
||||||
|
peerUrls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_EASYTIER_PEER_URLS = ['tcp://public.easytier.top:11010']
|
||||||
|
|
||||||
|
function normalizePeerUrls(value: unknown): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const next = Array.isArray(value) ? value.map(String) : []
|
||||||
|
const normalized = next
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => {
|
||||||
|
if (!item || seen.has(item)) return false
|
||||||
|
seen.add(item)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return normalized.length > 0 ? normalized : [...DEFAULT_EASYTIER_PEER_URLS]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 Electron `remote:get-bind-payload` 输出一致,供扫码导入 */
|
||||||
|
export type BindPayload = {
|
||||||
|
v: 1
|
||||||
|
baseUrl: string
|
||||||
|
port: number
|
||||||
|
token: string
|
||||||
|
easytier: EasyTierConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBindPayload(raw: string): BindPayload | null {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(raw) as Record<string, unknown>
|
||||||
|
if (j.v !== 1) return null
|
||||||
|
const et = j.easytier as Record<string, unknown> | undefined
|
||||||
|
if (!et || typeof j.baseUrl !== 'string' || typeof j.token !== 'string') return null
|
||||||
|
const networkName = String(et.networkName ?? '').trim()
|
||||||
|
const networkSecret = String(et.networkSecret ?? '').trim()
|
||||||
|
if (!networkName || !networkSecret) return null
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
baseUrl: String(j.baseUrl).replace(/\/$/, ''),
|
||||||
|
port: typeof j.port === 'number' ? j.port : Number(j.port) || 8001,
|
||||||
|
token: String(j.token),
|
||||||
|
easytier: {
|
||||||
|
networkName,
|
||||||
|
networkSecret,
|
||||||
|
peerUrls: normalizePeerUrls(et.peerUrls),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
71
lib/youtubeConfigUtils.ts
Normal file
71
lib/youtubeConfigUtils.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/** 与 electron youtubeConfigUtils 对齐(远程控制仅用于展示/解析) */
|
||||||
|
|
||||||
|
export function parseYoutubeStreamKeySuffix(iniContent: string): string {
|
||||||
|
for (const line of iniContent.split(/\r?\n/)) {
|
||||||
|
const t = line.trim()
|
||||||
|
if (t.startsWith('#') || !t) continue
|
||||||
|
const m = t.match(/^(?:key|stream_key)\s*=\s*(.+)$/i)
|
||||||
|
if (m) {
|
||||||
|
const v = m[1].trim().split(/[#;]/)[0].trim()
|
||||||
|
if (v && !v.startsWith('rtmp')) return v.length > 8 ? v.slice(-8) : v
|
||||||
|
if (v.includes('live2/')) {
|
||||||
|
const parts = v.split('/')
|
||||||
|
const last = parts[parts.length - 1] || ''
|
||||||
|
return last.length > 8 ? last.slice(-8) : last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDouyinLiveUrls(urlIni: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const line of urlIni.split(/\r?\n/)) {
|
||||||
|
const t = line.trim()
|
||||||
|
if (!t || t.startsWith('#')) continue
|
||||||
|
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue
|
||||||
|
const full = t.match(/(https?:\/\/live\.douyin\.com\/[a-zA-Z0-9_-]+)/i)
|
||||||
|
if (full) {
|
||||||
|
out.push(full[1].replace(/^http:\/\//i, 'https://'))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const rel = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)
|
||||||
|
if (rel) {
|
||||||
|
out.push(`https://live.douyin.com/${rel[1]}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const n = t.match(/(\d{8,})/)
|
||||||
|
if (n) out.push(`https://live.douyin.com/${n[1]}`)
|
||||||
|
}
|
||||||
|
return [...new Set(out)].slice(0, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDouyinRoomHints(urlIni: string): string[] {
|
||||||
|
const out: string[] = []
|
||||||
|
for (const line of urlIni.split(/\r?\n/)) {
|
||||||
|
const t = line.trim()
|
||||||
|
if (!t || t.startsWith('#')) continue
|
||||||
|
if (!/douyin\.com|v\.douyin\.com/i.test(t)) continue
|
||||||
|
const m = t.match(/live\.douyin\.com\/([a-zA-Z0-9_-]+)/i)
|
||||||
|
if (m) {
|
||||||
|
out.push(m[1])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const n = t.match(/(\d{8,})/)
|
||||||
|
out.push(n ? n[1] : t.slice(0, 96))
|
||||||
|
}
|
||||||
|
return [...new Set(out)].slice(0, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { room: string; href: string } {
|
||||||
|
const combined = `${urlIni}\n${douyinUrl ?? ''}`
|
||||||
|
const urls = extractDouyinLiveUrls(combined)
|
||||||
|
const hints = extractDouyinRoomHints(combined)
|
||||||
|
const room = hints[0] || '—'
|
||||||
|
const href = urls[0] || (hints[0] ? `https://live.douyin.com/${hints[0]}` : '')
|
||||||
|
return { room, href }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proChannelPm2Process(channelId: string): string {
|
||||||
|
return `youtube2__${channelId}`
|
||||||
|
}
|
||||||
19
modules/expo-easytier-vpn/android/build.gradle
Normal file
19
modules/expo-easytier-vpn/android/build.gradle
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.library'
|
||||||
|
id 'expo-module-gradle-plugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'expo.modules.easytier'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "expo.modules.easytier"
|
||||||
|
defaultConfig {
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
o/bundleLibRuntimeToDirDebug
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
o/classes
|
||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Automatically generated file. DO NOT MODIFY
|
||||||
|
*/
|
||||||
|
package expo.modules.easytier;
|
||||||
|
|
||||||
|
public final class BuildConfig {
|
||||||
|
public static final boolean DEBUG = Boolean.parseBoolean("true");
|
||||||
|
public static final String LIBRARY_PACKAGE_NAME = "expo.modules.easytier";
|
||||||
|
public static final String BUILD_TYPE = "debug";
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="expo.modules.easytier" >
|
||||||
|
|
||||||
|
<uses-sdk android:minSdkVersion="24" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "AAPT_FRIENDLY_MERGED_MANIFESTS",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "expo.modules.easytier",
|
||||||
|
"variantName": "debug",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "SINGLE",
|
||||||
|
"filters": [],
|
||||||
|
"attributes": [],
|
||||||
|
"outputFile": "AndroidManifest.xml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elementType": "File"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
aarFormatVersion=1.0
|
||||||
|
aarMetadataVersion=1.0
|
||||||
|
minCompileSdk=1
|
||||||
|
minCompileSdkExtension=0
|
||||||
|
minAndroidGradlePluginVersion=1.0.0
|
||||||
|
coreLibraryDesugaringEnabled=false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
#Wed Mar 25 11:49:16 CDT 2026
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\build\generated\res\resValues\debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\build\generated\res\resValues\debug"/></dataSet><mergedItems/></merger>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\build\intermediates\shader_assets\debug\compileDebugShaders\out"/></dataSet></merger>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\jniLibs"/></dataSet></merger>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\main\shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="C:\project\mobile\modules\expo-easytier-vpn\android\src\debug\shaders"/></dataSet></merger>
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
R_DEF: Internal format may change without notice
|
||||||
|
local
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
1<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
3 package="expo.modules.easytier" >
|
||||||
|
4
|
||||||
|
5 <uses-sdk android:minSdkVersion="24" />
|
||||||
|
6
|
||||||
|
7 <uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
7-->C:\project\mobile\modules\expo-easytier-vpn\android\src\main\AndroidManifest.xml:2:3-65
|
||||||
|
7-->C:\project\mobile\modules\expo-easytier-vpn\android\src\main\AndroidManifest.xml:2:20-62
|
||||||
|
8
|
||||||
|
9</manifest>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="expo.modules.easytier" >
|
||||||
|
|
||||||
|
<uses-sdk android:minSdkVersion="24" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0 Warning/Error
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
expo.modules.easytier
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user