's'
This commit is contained in:
41
mobile/.gitignore
vendored
Normal file
41
mobile/.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
|
||||
1
mobile/.vscode/extensions.json
vendored
Normal file
1
mobile/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
mobile/.vscode/settings.json
vendored
Normal file
7
mobile/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
48
mobile/app.json
Normal file
48
mobile/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
|
||||
}
|
||||
}
|
||||
}
|
||||
64
mobile/app/(tabs)/_layout.tsx
Normal file
64
mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
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="bind"
|
||||
options={{
|
||||
title: '配对',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="qr-code-outline" size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: '录制',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="radio-outline" size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="desk"
|
||||
options={{
|
||||
title: '工作台',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="grid-outline" size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="network"
|
||||
options={{
|
||||
title: '网络',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="wifi-outline" size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="more"
|
||||
options={{
|
||||
title: '更多',
|
||||
tabBarIcon: ({ color }) => <Ionicons name="menu-outline" size={24} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
278
mobile/app/(tabs)/bind.tsx
Normal file
278
mobile/app/(tabs)/bind.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Linking,
|
||||
Alert,
|
||||
} 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 { parseBindPayload, type BindPayload } from '@/lib/types'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
|
||||
|
||||
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 applyPayload = useCallback(
|
||||
async (p: BindPayload | null) => {
|
||||
if (!p) {
|
||||
setMsg('无法解析:请确认扫到的是 PC 端「生成绑定二维码」的 JSON。')
|
||||
return
|
||||
}
|
||||
await setBind(p)
|
||||
setMsg('已保存配对。请到「录制」页操作。')
|
||||
setScanOn(false)
|
||||
scannedRef.current = false
|
||||
},
|
||||
[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 字段(可粘贴到官方客户端)。')
|
||||
}, [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])
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<Text style={styles.muted}>加载中…</Text>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.h1}>配对与 EasyTier</Text>
|
||||
<Text style={styles.lead}>
|
||||
在 PC 控制台「网络 → 远程控制」开启远程并生成二维码,用手机扫描;无需公网,请确保手机与 PC 在同一 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>
|
||||
<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>
|
||||
</LinearGradient>
|
||||
)}
|
||||
|
||||
<View style={styles.block}>
|
||||
<Text style={styles.h2}>EasyTier 无公网互通</Text>
|
||||
<Text style={styles.body}>
|
||||
PC 端已内置 easytier-core 并写入组网名与密钥。手机需运行官方 EasyTier 客户端并填入
|
||||
<Text style={{ fontWeight: '700', color: theme.text }}>相同</Text>
|
||||
组网参数,获得虚拟 IP 后即可访问上方 API 地址(通常为 http://虚拟IP:8001)。
|
||||
</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 发行版(含 Android)</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 },
|
||||
etActions: { gap: 10, marginTop: 12 },
|
||||
btn: {
|
||||
backgroundColor: theme.bgElevated,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
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,
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.bgCard,
|
||||
},
|
||||
btnSecondaryText: { color: theme.text, fontWeight: '600' },
|
||||
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 },
|
||||
})
|
||||
76
mobile/app/(tabs)/desk.tsx
Normal file
76
mobile/app/(tabs)/desk.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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 { theme } from '@/constants/theme'
|
||||
|
||||
export default function DeskScreen() {
|
||||
const { bind } = useBind()
|
||||
const [raw, setRaw] = useState<string>('')
|
||||
const [err, setErr] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!bind) {
|
||||
setErr('未绑定 PC')
|
||||
setRaw('')
|
||||
return
|
||||
}
|
||||
setErr('')
|
||||
try {
|
||||
const r = await apiFetch(bind, '/client/overview')
|
||||
if (!r.ok) throw new Error(String(r.status))
|
||||
const j = await r.json()
|
||||
setRaw(JSON.stringify(j, null, 2))
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setRaw('')
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} />}
|
||||
>
|
||||
<Text style={styles.h1}>工作台</Text>
|
||||
<Text style={styles.lead}>来自后端 /client/overview 的摘要(与桌面工作台同源数据)。</Text>
|
||||
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||
{raw ? <Text style={styles.pre}>{raw}</Text> : !err ? <Text style={styles.muted}>暂无数据</Text> : null}
|
||||
</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, marginBottom: 16, lineHeight: 22 },
|
||||
pre: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: '#c8cdd5',
|
||||
lineHeight: 18,
|
||||
backgroundColor: theme.bgCard,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
err: { color: theme.danger, marginBottom: 12 },
|
||||
muted: { color: theme.muted },
|
||||
})
|
||||
410
mobile/app/(tabs)/index.tsx
Normal file
410
mobile/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
} from 'react-native'
|
||||
import { Picker } from '@react-native-picker/picker'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { useRecorder } from '@/hooks/useRecorder'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
function ctrlStyle(action: string): object {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
return styles.ctrl_start
|
||||
case 'stop':
|
||||
return styles.ctrl_stop
|
||||
case 'restart':
|
||||
return styles.ctrl_restart
|
||||
case 'status':
|
||||
return styles.ctrl_status
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const variantColor: Record<string, string> = {
|
||||
online: theme.success,
|
||||
stopped: theme.danger,
|
||||
error: theme.danger,
|
||||
warn: theme.warn,
|
||||
offline: theme.danger,
|
||||
loading: theme.muted,
|
||||
unknown: theme.accent,
|
||||
empty: theme.accent,
|
||||
}
|
||||
|
||||
export default function RecordScreen() {
|
||||
const { bind } = useBind()
|
||||
const r = useRecorder()
|
||||
|
||||
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
|
||||
|
||||
if (r.bootError && bind) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<Text style={styles.err}>无法连接:{r.bootError}</Text>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
if (!bind) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<Text style={styles.hint}>请先在「配对」页扫码绑定 PC。</Text>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView contentContainerStyle={styles.scroll}>
|
||||
<View style={styles.toolbar}>
|
||||
<Text style={styles.label}>当前录制</Text>
|
||||
<View style={styles.pickerWrap}>
|
||||
<Picker
|
||||
enabled={!r.emptyHint}
|
||||
selectedValue={r.currentProcess}
|
||||
onValueChange={(v) => r.setCurrentProcess(String(v))}
|
||||
style={styles.picker}
|
||||
dropdownIconColor={theme.text}
|
||||
>
|
||||
{r.entries.map((e) => (
|
||||
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
{r.isYoutube && (
|
||||
<Pressable
|
||||
style={styles.proRow}
|
||||
onPress={() => void r.togglePro(!r.multiChannelPro)}
|
||||
>
|
||||
<Text style={styles.proLabel}>Pro 转播</Text>
|
||||
<Text style={[styles.proVal, r.multiChannelPro && { color: theme.accent }]}>
|
||||
{r.multiChannelPro ? '开' : '关'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[styles.pill, { borderColor: statusColor }]}>
|
||||
<View style={[styles.dot, { backgroundColor: statusColor }]} />
|
||||
<Text style={[styles.pillText, { color: statusColor }]}>{r.statusMeta.line}</Text>
|
||||
</View>
|
||||
|
||||
{r.emptyHint ? (
|
||||
<Text style={styles.hint}>{r.emptyHint}</Text>
|
||||
) : (
|
||||
<>
|
||||
{r.ent && (
|
||||
<View style={styles.meta}>
|
||||
<Text style={styles.metaLabel}>任务</Text>
|
||||
<Text style={styles.metaName}>{r.ent.label}</Text>
|
||||
<Text style={styles.metaFile}>{r.ent.script}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{r.isYoutube && r.multiChannelPro && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>Pro 线路</Text>
|
||||
{r.proChannelsError ? (
|
||||
<Text style={styles.err}>{r.proChannelsError}</Text>
|
||||
) : (
|
||||
<View style={styles.pickerWrap}>
|
||||
<Picker
|
||||
selectedValue={r.proChannelId}
|
||||
onValueChange={(v) => r.setProChannelId(String(v))}
|
||||
style={styles.picker}
|
||||
dropdownIconColor={theme.text}
|
||||
>
|
||||
{r.proChannelsList.map((c) => (
|
||||
<Picker.Item
|
||||
key={c.id}
|
||||
label={`${c.name} (${c.keyPreview || '无密钥'})`}
|
||||
value={c.id}
|
||||
color={theme.text}
|
||||
/>
|
||||
))}
|
||||
</Picker>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{r.isYoutube && r.multiChannelPro && !r.proChannelsError && r.proChannelsList.length > 0 && (
|
||||
<>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>YouTube 密钥(当前线路)</Text>
|
||||
<TextInput
|
||||
style={styles.area}
|
||||
multiline
|
||||
value={r.proYoutubeKey}
|
||||
onChangeText={r.setProYoutubeKey}
|
||||
placeholderTextColor={theme.muted}
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.btn, r.saveProKeyLoading && styles.btnDisabled]}
|
||||
disabled={r.saveProKeyLoading}
|
||||
onPress={() => void r.saveProChannelKey()}
|
||||
>
|
||||
{r.saveProKeyLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.btnText}>保存密钥</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>地址列表(当前线路)</Text>
|
||||
<TextInput
|
||||
style={styles.area}
|
||||
multiline
|
||||
value={r.urlText}
|
||||
onChangeText={r.setUrlText}
|
||||
placeholderTextColor={theme.muted}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnSec]}
|
||||
onPress={() => void r.saveProUrlConfig()}
|
||||
disabled={r.saveUrlLoading}
|
||||
>
|
||||
<Text style={styles.btnSecText}>{r.saveUrlLoading ? '…' : '保存地址'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{r.isYoutube && !r.multiChannelPro && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>youtube.ini</Text>
|
||||
<TextInput
|
||||
style={styles.area}
|
||||
multiline
|
||||
value={r.youtubeText}
|
||||
onChangeText={r.setYoutubeText}
|
||||
placeholderTextColor={theme.muted}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnSec]}
|
||||
onPress={() => void r.loadConfig('/get_config', r.setYoutubeText, r.setYoutubeStatus)}
|
||||
>
|
||||
<Text style={styles.btnSecText}>重新读取</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btn, r.saveYoutubeLoading && styles.btnDisabled]}
|
||||
disabled={r.saveYoutubeLoading}
|
||||
onPress={() => {
|
||||
r.setSaveYoutubeLoading(true)
|
||||
void r.saveConfig('/save_config', r.youtubeText, r.setYoutubeStatus, () =>
|
||||
r.setSaveYoutubeLoading(false),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.btnText}>保存</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
|
||||
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && (
|
||||
<Text style={styles.hintSmall}>Google 授权与拉取密钥请在 PC 端操作。</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{((r.isYoutube && !r.multiChannelPro) || r.isTiktok) && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>直播地址列表</Text>
|
||||
<TextInput
|
||||
style={styles.area}
|
||||
multiline
|
||||
value={r.urlText}
|
||||
onChangeText={r.setUrlText}
|
||||
placeholderTextColor={theme.muted}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
style={[styles.btn, styles.btnSec]}
|
||||
onPress={() => void r.loadConfig('/get_url_config', r.setUrlText, r.setUrlStatus)}
|
||||
>
|
||||
<Text style={styles.btnSecText}>重新读取</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.btn, r.saveUrlLoading && styles.btnDisabled]}
|
||||
disabled={r.saveUrlLoading}
|
||||
onPress={() => {
|
||||
r.setSaveUrlLoading(true)
|
||||
void r.saveConfig('/save_url_config', r.urlText, r.setUrlStatus, () =>
|
||||
r.setSaveUrlLoading(false),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.btnText}>保存</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && r.isObs && (
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>OBS 推流</Text>
|
||||
<Text style={styles.mono}>srt://live.local:10080/live/obs</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.card, styles.cardAccent]}>
|
||||
<Text style={styles.cardTitle}>录制开关</Text>
|
||||
<View style={styles.grid}>
|
||||
{(
|
||||
[
|
||||
['start', '开始录制'],
|
||||
['stop', '停止录制'],
|
||||
['restart', '重新开始'],
|
||||
['status', '刷新状态'],
|
||||
] as const
|
||||
).map(([action, label]) => (
|
||||
<Pressable
|
||||
key={action}
|
||||
style={[styles.ctrl, ctrlStyle(action), r.loadingAction === action && styles.btnDisabled]}
|
||||
disabled={r.loadingAction !== null && action !== 'status'}
|
||||
onPress={() => void r.runPm2Action(action)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.ctrlText,
|
||||
action === 'status' && { color: theme.text },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>运行记录</Text>
|
||||
<ScrollView style={styles.logBox} nestedScrollEnabled>
|
||||
<Text style={styles.log}>{r.logText || '—'}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
safe: { flex: 1, backgroundColor: theme.bg },
|
||||
scroll: { padding: 16, paddingBottom: 32 },
|
||||
toolbar: { marginBottom: 12 },
|
||||
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
|
||||
pickerWrap: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: theme.bgCard,
|
||||
},
|
||||
picker: { color: theme.text },
|
||||
proRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 12,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
proLabel: { color: theme.text, fontWeight: '600' },
|
||||
proVal: { color: theme.muted, fontWeight: '600' },
|
||||
pill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
gap: 8,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
marginBottom: 14,
|
||||
},
|
||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||
pillText: { fontSize: 13, fontWeight: '600' },
|
||||
meta: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: theme.bgElevated,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
marginBottom: 12,
|
||||
},
|
||||
metaLabel: { fontSize: 12, color: theme.muted },
|
||||
metaName: { fontSize: 15, fontWeight: '600', color: theme.text, marginTop: 4 },
|
||||
metaFile: { fontSize: 11, fontFamily: 'monospace', color: theme.muted, marginTop: 4 },
|
||||
card: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardAccent: {
|
||||
borderColor: 'rgba(91,157,255,0.35)',
|
||||
backgroundColor: 'rgba(91,157,255,0.06)',
|
||||
},
|
||||
cardTitle: { fontSize: 15, fontWeight: '600', color: theme.text, marginBottom: 10 },
|
||||
area: {
|
||||
minHeight: 100,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
color: theme.text,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
marginBottom: 10,
|
||||
backgroundColor: theme.bg,
|
||||
},
|
||||
row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap' },
|
||||
btn: {
|
||||
backgroundColor: '#3d7eff',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 10,
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnDisabled: { opacity: 0.55 },
|
||||
btnText: { color: '#fff', fontWeight: '700' },
|
||||
btnSec: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border },
|
||||
btnSecText: { color: theme.text, fontWeight: '600' },
|
||||
hint: { color: theme.muted, fontSize: 14, lineHeight: 22 },
|
||||
hintSmall: { color: theme.muted, fontSize: 12, marginTop: 8 },
|
||||
err: { color: theme.danger, fontSize: 14 },
|
||||
mono: { fontFamily: 'monospace', color: theme.accent, fontSize: 13 },
|
||||
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
|
||||
ctrl: { flexGrow: 1, minWidth: '42%', paddingVertical: 14, borderRadius: 10, alignItems: 'center' },
|
||||
ctrl_start: { backgroundColor: '#0e7a0d' },
|
||||
ctrl_stop: { backgroundColor: '#d1342c' },
|
||||
ctrl_restart: { backgroundColor: '#e68600' },
|
||||
ctrl_status: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border },
|
||||
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
|
||||
logBox: { maxHeight: 280, backgroundColor: '#1e2128', borderRadius: 10, padding: 10 },
|
||||
log: { fontFamily: 'monospace', fontSize: 11, color: '#d0d4dc', lineHeight: 18 },
|
||||
})
|
||||
177
mobile/app/(tabs)/more.tsx
Normal file
177
mobile/app/(tabs)/more.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
Pressable,
|
||||
Switch,
|
||||
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 ProxyState = { enabled: boolean; http: string; socks: string }
|
||||
|
||||
export default function MoreScreen() {
|
||||
const { bind } = useBind()
|
||||
const [proxy, setProxy] = useState<ProxyState>({ enabled: false, http: '', socks: '' })
|
||||
const [metrics, setMetrics] = useState<string>('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!bind) return
|
||||
setMsg('')
|
||||
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 || ''),
|
||||
})
|
||||
}
|
||||
const mr = await apiFetch(bind, '/system_metrics')
|
||||
if (mr.ok) {
|
||||
const m = await mr.json()
|
||||
setMetrics(JSON.stringify(m, null, 2))
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}, [load])
|
||||
|
||||
const saveProxy = useCallback(async () => {
|
||||
if (!bind) return
|
||||
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))
|
||||
setMsg('代理已保存')
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [bind, proxy])
|
||||
|
||||
if (!bind) {
|
||||
return (
|
||||
<SafeAreaView style={styles.safe}>
|
||||
<Text style={styles.muted}>请先完成配对</Text>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} />}
|
||||
>
|
||||
<Text style={styles.h1}>更多</Text>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>录制代理(仅进程)</Text>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>启用</Text>
|
||||
<Switch value={proxy.enabled} onValueChange={(v) => setProxy((p) => ({ ...p, enabled: v }))} />
|
||||
</View>
|
||||
<Text style={styles.label}>HTTP</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={proxy.http}
|
||||
onChangeText={(t) => setProxy((p) => ({ ...p, http: t }))}
|
||||
placeholder="http://..."
|
||||
placeholderTextColor={theme.muted}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Text style={styles.label}>SOCKS</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={proxy.socks}
|
||||
onChangeText={(t) => setProxy((p) => ({ ...p, socks: t }))}
|
||||
placeholder="socks5://..."
|
||||
placeholderTextColor={theme.muted}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<Pressable style={styles.btn} onPress={() => void saveProxy()}>
|
||||
<Text style={styles.btnText}>保存代理</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>系统指标</Text>
|
||||
{metrics ? <Text style={styles.pre}>{metrics}</Text> : <Text style={styles.muted}>加载中…</Text>}
|
||||
</View>
|
||||
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.cardTitle}>微信 Chatbot</Text>
|
||||
<Text style={styles.muted}>
|
||||
扫码登录与 Chatbot 配置依赖本机环境与桌面端逻辑,请在 Windows 控制台「微信」页完成。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!!msg && <Text style={styles.ok}>{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: 16 },
|
||||
card: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
marginBottom: 14,
|
||||
},
|
||||
cardTitle: { fontSize: 15, fontWeight: '600', color: theme.text, marginBottom: 12 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', 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,
|
||||
},
|
||||
btn: {
|
||||
backgroundColor: '#3d7eff',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnText: { color: '#fff', fontWeight: '700' },
|
||||
pre: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 10,
|
||||
color: '#c8cdd5',
|
||||
lineHeight: 16,
|
||||
},
|
||||
muted: { color: theme.muted, fontSize: 13, lineHeight: 20 },
|
||||
ok: { color: theme.success, marginTop: 8 },
|
||||
})
|
||||
98
mobile/app/(tabs)/network.tsx
Normal file
98
mobile/app/(tabs)/network.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
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 { theme } from '@/constants/theme'
|
||||
|
||||
export default function NetworkScreen() {
|
||||
const { bind } = useBind()
|
||||
const [raw, setRaw] = useState<string>('')
|
||||
const [err, setErr] = useState('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!bind) {
|
||||
setErr('未绑定')
|
||||
setRaw('')
|
||||
return
|
||||
}
|
||||
setErr('')
|
||||
try {
|
||||
const r = await apiFetch(bind, '/network_status')
|
||||
if (!r.ok) throw new Error(String(r.status))
|
||||
const j = await r.json()
|
||||
setRaw(JSON.stringify(j, null, 2))
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
setRaw('')
|
||||
}
|
||||
}, [bind])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
await load()
|
||||
setRefreshing(false)
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} />}
|
||||
>
|
||||
<Text style={styles.h1}>网络</Text>
|
||||
<Text style={styles.lead}>Google / 抖音 连通与测速。无公网时请通过「配对」页完成 EasyTier 组网。</Text>
|
||||
|
||||
{bind && (
|
||||
<View style={styles.et}>
|
||||
<Text style={styles.etTitle}>EasyTier(本机已保存)</Text>
|
||||
<Text style={styles.mono}>组网名 {bind.easytier.networkName}</Text>
|
||||
<Text style={styles.mutedSmall}>
|
||||
密钥与 peer 已随扫码写入;与 PC 完全一致即可在虚拟网内访问 {bind.baseUrl}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||
{raw ? <Text style={styles.pre}>{raw}</Text> : !err && bind ? <Text style={styles.muted}>暂无</Text> : null}
|
||||
</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, marginBottom: 16, lineHeight: 22 },
|
||||
et: {
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(124,92,255,0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124,92,255,0.25)',
|
||||
marginBottom: 16,
|
||||
},
|
||||
etTitle: { fontSize: 14, fontWeight: '600', color: theme.text, marginBottom: 8 },
|
||||
mono: { fontFamily: 'monospace', fontSize: 12, color: theme.accent },
|
||||
mutedSmall: { fontSize: 12, color: theme.muted, marginTop: 8, lineHeight: 18 },
|
||||
pre: {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
color: '#c8cdd5',
|
||||
lineHeight: 18,
|
||||
backgroundColor: theme.bgCard,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
err: { color: theme.danger, marginBottom: 12 },
|
||||
muted: { color: theme.muted },
|
||||
})
|
||||
38
mobile/app/+html.tsx
Normal file
38
mobile/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
mobile/app/+not-found.tsx
Normal file
40
mobile/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
mobile/app/_layout.tsx
Normal file
58
mobile/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
mobile/assets/fonts/SpaceMono-Regular.ttf
Normal file
BIN
mobile/assets/fonts/SpaceMono-Regular.ttf
Normal file
Binary file not shown.
BIN
mobile/assets/images/android-icon-background.png
Normal file
BIN
mobile/assets/images/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/assets/images/android-icon-foreground.png
Normal file
BIN
mobile/assets/images/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
mobile/assets/images/android-icon-monochrome.png
Normal file
BIN
mobile/assets/images/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/assets/images/favicon.png
Normal file
BIN
mobile/assets/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
mobile/assets/images/icon.png
Normal file
BIN
mobile/assets/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
BIN
mobile/assets/images/splash-icon.png
Normal file
BIN
mobile/assets/images/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
77
mobile/components/EditScreenInfo.tsx
Normal file
77
mobile/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
mobile/components/ExternalLink.tsx
Normal file
24
mobile/components/ExternalLink.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Link } 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: string }
|
||||
) {
|
||||
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
mobile/components/StyledText.tsx
Normal file
5
mobile/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
mobile/components/Themed.tsx
Normal file
45
mobile/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
mobile/components/useClientOnlyValue.ts
Normal file
4
mobile/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
mobile/components/useClientOnlyValue.web.ts
Normal file
12
mobile/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
mobile/components/useColorScheme.ts
Normal file
6
mobile/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
mobile/components/useColorScheme.web.ts
Normal file
8
mobile/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
mobile/constants/Colors.ts
Normal file
18
mobile/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
mobile/constants/theme.ts
Normal file
13
mobile/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',
|
||||
}
|
||||
49
mobile/context/BindContext.tsx
Normal file
49
mobile/context/BindContext.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
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)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const setBind = useCallback(async (p: BindPayload | null) => {
|
||||
setBindState(p)
|
||||
if (p) await saveBindPayload(p)
|
||||
else await clearBindPayload()
|
||||
}, [])
|
||||
|
||||
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
|
||||
}
|
||||
416
mobile/hooks/useRecorder.ts
Normal file
416
mobile/hooks/useRecorder.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch, apiJson } from '@/lib/api'
|
||||
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
||||
|
||||
type Entry = { script: string; label: string; pm2: string }
|
||||
|
||||
const UI_DROPDOWN_YOUTUBE_ONLY = true
|
||||
const UI_SHOW_YOUTUBE_GOOGLE_OAUTH = false
|
||||
|
||||
type StatusVariant =
|
||||
| 'loading'
|
||||
| 'online'
|
||||
| 'stopped'
|
||||
| 'error'
|
||||
| 'warn'
|
||||
| 'offline'
|
||||
| 'unknown'
|
||||
| 'empty'
|
||||
|
||||
function statusPresentation(processStatus: string): { line: string; variant: StatusVariant } {
|
||||
if (processStatus === 'online') return { line: '正在录制', variant: 'online' }
|
||||
if (processStatus === 'stopped') return { line: '已停止', variant: 'stopped' }
|
||||
if (processStatus === 'errored') return { line: '出现异常', variant: 'error' }
|
||||
if (processStatus === 'launching' || processStatus === 'starting')
|
||||
return { line: '正在启动…', variant: 'warn' }
|
||||
if (processStatus === 'waiting restart') return { line: '等待重启', variant: 'warn' }
|
||||
if (processStatus === 'not_found') return { line: '尚未开始', variant: 'offline' }
|
||||
if (processStatus === 'invalid') return { line: '无法识别', variant: 'error' }
|
||||
return { line: '状态未知', variant: 'unknown' }
|
||||
}
|
||||
|
||||
export function useRecorder() {
|
||||
const { bind } = useBind()
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [currentProcess, setCurrentProcess] = useState('')
|
||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||
line: '—',
|
||||
variant: 'loading',
|
||||
})
|
||||
const [logText, setLogText] = useState('')
|
||||
const [bootError, setBootError] = useState<string | null>(null)
|
||||
const [emptyHint, setEmptyHint] = useState<string | null>(null)
|
||||
|
||||
const [youtubeText, setYoutubeText] = useState('')
|
||||
const [urlText, setUrlText] = useState('')
|
||||
const [youtubeStatus, setYoutubeStatus] = useState('就绪')
|
||||
const [urlStatus, setUrlStatus] = useState('就绪')
|
||||
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null)
|
||||
const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false)
|
||||
const [saveUrlLoading, setSaveUrlLoading] = useState(false)
|
||||
|
||||
const [multiChannelPro, setMultiChannelProState] = useState(false)
|
||||
const [proChannelId, setProChannelId] = useState('')
|
||||
const [proChannelsList, setProChannelsList] = useState<
|
||||
{ id: string; name: string; keyPreview: string; douyinUrl?: string }[]
|
||||
>([])
|
||||
const [proChannelsError, setProChannelsError] = useState<string | null>(null)
|
||||
const [proYoutubeKey, setProYoutubeKey] = useState('')
|
||||
const [saveProKeyLoading, setSaveProKeyLoading] = useState(false)
|
||||
|
||||
const lastLiveStatusRef = useRef<{ process: string; status: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void getMultiChannelPro().then(setMultiChannelProState)
|
||||
}, [])
|
||||
|
||||
const togglePro = useCallback(async (v: boolean) => {
|
||||
setMultiChannelProState(v)
|
||||
await setMultiChannelPro(v)
|
||||
}, [])
|
||||
|
||||
const getEntry = useCallback(
|
||||
(name: string) => entries.find((e) => e.pm2 === name) ?? null,
|
||||
[entries],
|
||||
)
|
||||
|
||||
const ent = useMemo(() => getEntry(currentProcess), [getEntry, currentProcess])
|
||||
const script = ent?.script ?? ''
|
||||
const isYoutube = ent ? isYoutubeScript(script) : false
|
||||
const isTiktok = ent ? isTiktokScript(script) : false
|
||||
const isObs = ent ? isObsScript(script) : false
|
||||
|
||||
const loadProcessMonitor = useCallback(async () => {
|
||||
const res = await apiFetch(bind, '/process_monitor')
|
||||
if (!res.ok) throw new Error(String(res.status))
|
||||
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
||||
const raw: Entry[] = (data.entries || []).map((e) => ({
|
||||
script: e.script,
|
||||
label: e.label || e.script,
|
||||
pm2: e.pm2 || pm2NameFromScript(e.script),
|
||||
}))
|
||||
const filtered = UI_DROPDOWN_YOUTUBE_ONLY ? raw.filter((e) => isYoutubeScript(e.script)) : raw
|
||||
setEntries(filtered)
|
||||
return filtered
|
||||
}, [bind])
|
||||
|
||||
const loadConfig = useCallback(
|
||||
async (endpoint: string, setContent: (s: string) => void, setSt: (s: string) => void) => {
|
||||
if (!bind || !currentProcess) return
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||
)
|
||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||
const data = (await res.json()) as { content?: string }
|
||||
setContent(data.content || '')
|
||||
setSt('已读到最新内容')
|
||||
} catch (err) {
|
||||
setSt(`读取失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
[bind, currentProcess],
|
||||
)
|
||||
|
||||
const saveConfig = useCallback(
|
||||
async (
|
||||
endpoint: string,
|
||||
content: string,
|
||||
setSt: (s: string) => void,
|
||||
endLoading: () => void,
|
||||
) => {
|
||||
if (!bind) return
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
)
|
||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||
const data = (await res.json()) as { message?: string }
|
||||
setSt(data.message || '已保存')
|
||||
} catch (err) {
|
||||
setSt(`未能保存:${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
endLoading()
|
||||
}
|
||||
},
|
||||
[bind, currentProcess],
|
||||
)
|
||||
|
||||
const saveProChannelKey = useCallback(async () => {
|
||||
if (!bind || !proChannelId) return
|
||||
setSaveProKeyLoading(true)
|
||||
try {
|
||||
const res = await apiFetch(bind, '/relay_pro/channel_key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId: proChannelId, youtubeKey: proYoutubeKey }),
|
||||
})
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
setYoutubeStatus(data.message || '密钥已保存')
|
||||
} catch (err) {
|
||||
setYoutubeStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setSaveProKeyLoading(false)
|
||||
}
|
||||
}, [bind, proChannelId, proYoutubeKey])
|
||||
|
||||
const saveProUrlConfig = useCallback(async () => {
|
||||
if (!bind || !proChannelId) return
|
||||
setSaveUrlLoading(true)
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: urlText }),
|
||||
},
|
||||
)
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
setUrlStatus(data.message || '已保存')
|
||||
} catch (err) {
|
||||
setUrlStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
setSaveUrlLoading(false)
|
||||
}
|
||||
}, [bind, proChannelId, urlText])
|
||||
|
||||
const runPm2Action = useCallback(
|
||||
async (action: string) => {
|
||||
if (!bind) return
|
||||
const isStatus = action === 'status'
|
||||
if (!isStatus) setLoadingAction(action)
|
||||
try {
|
||||
if (action === 'start' && isYoutube && multiChannelPro) {
|
||||
if (!proChannelId) throw new Error('请先选择一条转播线路')
|
||||
const sync = await apiFetch(
|
||||
bind,
|
||||
`/relay_pro/sync_active?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const sj = (await sync.json().catch(() => ({}))) as { error?: string }
|
||||
if (!sync.ok) throw new Error(sj.error || '同步失败')
|
||||
}
|
||||
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`/${action}?process=${encodeURIComponent(currentProcess)}`,
|
||||
)
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
|
||||
if (action === 'status') {
|
||||
const ps = data.process_status as string
|
||||
const prev = lastLiveStatusRef.current
|
||||
const prevPs = prev?.process === currentProcess ? prev.status : undefined
|
||||
if (!prev || prev.process !== currentProcess || prev.status !== ps) {
|
||||
lastLiveStatusRef.current = { process: currentProcess, status: ps }
|
||||
}
|
||||
const { line, variant } = statusPresentation(ps)
|
||||
setStatusMeta({ line, variant })
|
||||
|
||||
let textOut = `── 状态 · ${new Date().toLocaleTimeString()} ──\n${data.raw_status}\n\n`
|
||||
const rl = data.recent_log as string
|
||||
const re = data.recent_error as string
|
||||
textOut += `【输出】\n${rl || '(暂无)'}\n\n【异常】\n${re || '(暂无)'}`
|
||||
setLogText(textOut)
|
||||
} else {
|
||||
const e = getEntry(currentProcess)
|
||||
const who = e ? e.label : '当前任务'
|
||||
const out =
|
||||
typeof data.output === 'string'
|
||||
? data.output
|
||||
: [data.pm2_output, data.message, data.note]
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
const actionLabel =
|
||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
|
||||
setTimeout(() => void runPm2Action('status'), 1500)
|
||||
}
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
setStatusMeta({ line: '状态未知', variant: 'unknown' })
|
||||
} finally {
|
||||
if (!isStatus) setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[bind, currentProcess, getEntry, isYoutube, multiChannelPro, proChannelId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!bind) {
|
||||
setBootError(null)
|
||||
setEmptyHint('请先在「配对」页扫码绑定 PC。')
|
||||
setEntries([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const filtered = await loadProcessMonitor()
|
||||
if (cancelled) return
|
||||
setBootError(null)
|
||||
if (!filtered.length) {
|
||||
setEmptyHint(
|
||||
UI_DROPDOWN_YOUTUBE_ONLY
|
||||
? '没有发现 YouTube 录制任务。'
|
||||
: '没有发现可用的录制任务。',
|
||||
)
|
||||
setStatusMeta({ line: '暂无可选任务', variant: 'empty' })
|
||||
return
|
||||
}
|
||||
setEmptyHint(null)
|
||||
setCurrentProcess(filtered[0].pm2)
|
||||
} catch (e) {
|
||||
setBootError(e instanceof Error ? e.message : String(e))
|
||||
setStatusMeta({ line: '未连接', variant: 'offline' })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bind, loadProcessMonitor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!entries.length || !currentProcess || !bind) return
|
||||
void runPm2Action('status')
|
||||
}, [entries.length, currentProcess, bind]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!entries.length || !currentProcess || !bind) return
|
||||
const id = setInterval(() => void runPm2Action('status'), 2000)
|
||||
return () => clearInterval(id)
|
||||
}, [entries.length, currentProcess, bind]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
if (!ent || !currentProcess || !bind) return
|
||||
if (isYoutube && multiChannelPro) return
|
||||
if (isYoutube) void loadConfig('/get_config', setYoutubeText, setYoutubeStatus)
|
||||
if (isYoutube || isTiktok) void loadConfig('/get_url_config', setUrlText, setUrlStatus)
|
||||
}, [ent, currentProcess, isYoutube, isTiktok, loadConfig, multiChannelPro, bind])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bind || !isYoutube || !multiChannelPro) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||
bind,
|
||||
'/relay_pro/channels',
|
||||
)
|
||||
if (cancelled) return
|
||||
if (!d.ok) {
|
||||
setProChannelsError(d.error || '无法加载 Pro 频道')
|
||||
setProChannelsList([])
|
||||
return
|
||||
}
|
||||
setProChannelsError(null)
|
||||
setProChannelsList(d.channels || [])
|
||||
setProChannelId((prev) => {
|
||||
if (prev && (d.channels || []).some((c) => c.id === prev)) return prev
|
||||
return d.channels?.[0]?.id ?? ''
|
||||
})
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setProChannelsError(e instanceof Error ? e.message : String(e))
|
||||
setProChannelsList([])
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bind, isYoutube, multiChannelPro])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bind || !isYoutube || !multiChannelPro || !proChannelId) return
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const dr = await apiFetch(
|
||||
bind,
|
||||
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||
)
|
||||
if (!dr.ok) {
|
||||
if (!cancelled) setYoutubeStatus('读取线路失败')
|
||||
return
|
||||
}
|
||||
const cd = (await dr.json()) as { youtubeKey?: string }
|
||||
if (cancelled) return
|
||||
setProYoutubeKey(cd.youtubeKey || '')
|
||||
const ur = await apiFetch(
|
||||
bind,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||
)
|
||||
if (ur.ok) {
|
||||
const u = (await ur.json()) as { content?: string }
|
||||
if (!cancelled) {
|
||||
setUrlText(u.content || '')
|
||||
setUrlStatus('已读取当前线路地址')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setUrlStatus(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bind, isYoutube, multiChannelPro, proChannelId])
|
||||
|
||||
return {
|
||||
bootError,
|
||||
emptyHint,
|
||||
entries,
|
||||
currentProcess,
|
||||
setCurrentProcess,
|
||||
statusMeta,
|
||||
logText,
|
||||
youtubeText,
|
||||
setYoutubeText,
|
||||
urlText,
|
||||
setUrlText,
|
||||
youtubeStatus,
|
||||
urlStatus,
|
||||
loadingAction,
|
||||
saveYoutubeLoading,
|
||||
saveUrlLoading,
|
||||
multiChannelPro,
|
||||
togglePro,
|
||||
proChannelId,
|
||||
setProChannelId,
|
||||
proChannelsList,
|
||||
proChannelsError,
|
||||
proYoutubeKey,
|
||||
setProYoutubeKey,
|
||||
saveProKeyLoading,
|
||||
saveProUrlConfig,
|
||||
saveProChannelKey,
|
||||
runPm2Action,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
setSaveYoutubeLoading,
|
||||
setSaveUrlLoading,
|
||||
ent,
|
||||
isYoutube,
|
||||
isTiktok,
|
||||
isObs,
|
||||
UI_SHOW_YOUTUBE_GOOGLE_OAUTH,
|
||||
setYoutubeStatus,
|
||||
setUrlStatus,
|
||||
}
|
||||
}
|
||||
8908
mobile/package-lock.json
generated
Normal file
8908
mobile/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
mobile/package.json
Normal file
44
mobile/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"run:android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-picker/picker": "^2.11.1",
|
||||
"@react-navigation/native": "^7.1.33",
|
||||
"expo": "~55.0.8",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-clipboard": "~8.0.7",
|
||||
"expo-constants": "~55.0.9",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-linking": "~55.0.8",
|
||||
"expo-router": "~55.0.7",
|
||||
"expo-splash-screen": "~55.0.12",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-symbols": "~55.0.5",
|
||||
"expo-web-browser": "~55.0.10",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
|
||||
}
|
||||
6869
mobile/pnpm-lock.yaml
generated
Normal file
6869
mobile/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
mobile/tsconfig.json
Normal file
17
mobile/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user