This commit is contained in:
eric
2026-03-24 14:47:03 -05:00
parent b914f0947a
commit efcec61d36
64 changed files with 22242 additions and 32 deletions

41
mobile/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
mobile/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

48
mobile/app.json Normal file
View 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
}
}
}

View 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
View 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 },
})

View 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
View 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
View 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 },
})

View 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
View 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
View 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
View 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>
);
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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',
},
});

View 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);
}
}}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { Text, TextProps } from './Themed';
export function MonoText(props: TextProps) {
return <Text {...props} style={[props.style, { fontFamily: 'SpaceMono' }]} />;
}

View 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} />;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,6 @@
import { useColorScheme as useColorSchemeCore } from 'react-native';
export const useColorScheme = () => {
const coreScheme = useColorSchemeCore();
return coreScheme === 'unspecified' ? 'light' : coreScheme;
};

View 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';
}

View 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
View 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',
}

View 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
View 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

File diff suppressed because it is too large Load Diff

44
mobile/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

17
mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}