Compare commits
2 Commits
aba40c8cb7
...
9f8b02ea4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f8b02ea4f | ||
|
|
3bc1599763 |
4
app.json
@@ -10,7 +10,7 @@
|
||||
"splash": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#2D2AF6"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
@@ -19,7 +19,7 @@
|
||||
"package": "com.douyin.recorder.remote",
|
||||
"usesCleartextTraffic": true,
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#2D2AF6",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { Tabs } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
@@ -8,17 +9,28 @@ import { useClientOnlyValue } from '@/components/useClientOnlyValue'
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
const dark = colorScheme === 'dark'
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
||||
tabBarInactiveTintColor: dark ? 'rgba(255,255,255,0.38)' : 'rgba(0,0,0,0.35)',
|
||||
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', letterSpacing: 0.2 },
|
||||
tabBarStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: dark ? '#161a22' : '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)',
|
||||
height: Platform.OS === 'ios' ? 88 : 64,
|
||||
paddingTop: 6,
|
||||
},
|
||||
headerStyle: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
||||
backgroundColor: dark ? '#161a22' : '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: dark ? 0.25 : 0.08,
|
||||
shadowRadius: 12,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
elevation: 4,
|
||||
},
|
||||
headerTintColor: Colors[colorScheme].text,
|
||||
headerShown: useClientOnlyValue(false, true),
|
||||
|
||||
@@ -49,7 +49,7 @@ function SnapshotNet({ data }: { data: unknown }) {
|
||||
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.snapLine}>
|
||||
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
国内站点 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -196,7 +196,7 @@ export default function DeskScreen() {
|
||||
</Text>
|
||||
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
抖音 {row.douyinHint ?? '—'}
|
||||
源站 {row.douyinHint ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
标题 {row.streamTitle ?? '—'}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useNavigation } from 'expo-router'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
import { Picker } from '@react-native-picker/picker'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { useRecorder } from '@/hooks/useRecorder'
|
||||
import { theme } from '@/constants/theme'
|
||||
@@ -121,7 +125,7 @@ function ProLiveSwitchGrid({
|
||||
</Pressable>
|
||||
{href ? (
|
||||
<Pressable onPress={() => void Linking.openURL(href)}>
|
||||
<Text style={styles.proLink}>在浏览器打开抖音页</Text>
|
||||
<Text style={styles.proLink}>在浏览器打开源站页</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.proActions}>
|
||||
@@ -199,8 +203,27 @@ function LiveSwitchGrid({
|
||||
}
|
||||
|
||||
export default function RecordScreen() {
|
||||
const navigation = useNavigation()
|
||||
const { bind } = useBind()
|
||||
const { session, openLogin, logout } = useAuth()
|
||||
const r = useRecorder()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
onPress={() => void (session ? logout() : openLogin())}
|
||||
style={{ paddingHorizontal: 12, paddingVertical: 6 }}
|
||||
>
|
||||
<Text style={{ color: theme.accent, fontWeight: '700', fontSize: 14 }}>
|
||||
{session?.record?.email
|
||||
? `${String(session.record.email).split('@')[0]}…`
|
||||
: '登录'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
),
|
||||
})
|
||||
}, [navigation, session, openLogin, logout])
|
||||
const unbound = !bind
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
@@ -258,49 +281,58 @@ export default function RecordScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbarCard}>
|
||||
<View style={styles.toolbarRow}>
|
||||
<View style={styles.toolbarLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusChip,
|
||||
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
||||
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
||||
<Animated.View entering={FadeInDown.duration(420).springify()}>
|
||||
<LinearGradient
|
||||
colors={['rgba(91,157,255,0.14)', 'rgba(20,22,28,0.92)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.toolbarGradient}
|
||||
>
|
||||
<View style={styles.toolbarCard}>
|
||||
<View style={styles.toolbarRow}>
|
||||
<View style={styles.toolbarLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusChip,
|
||||
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
||||
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
||||
</View>
|
||||
<Text style={styles.statusLineText} numberOfLines={1}>
|
||||
{r.statusMeta.line}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.statusLineText} numberOfLines={1}>
|
||||
{r.statusMeta.line}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||||
<View style={styles.proLabelRow}>
|
||||
<Text style={styles.proLabelZh}>多频道</Text>
|
||||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||||
<Text style={styles.proBadgeText}>PRO</Text>
|
||||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||||
<View style={styles.proLabelRow}>
|
||||
<Text style={styles.proLabelZh}>多频道</Text>
|
||||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||||
<Text style={styles.proBadgeText}>PRO</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.proSwitchWrap}>
|
||||
<Switch
|
||||
value={r.multiChannelPro}
|
||||
disabled={unbound}
|
||||
onValueChange={(v) => {
|
||||
if (unbound) return
|
||||
void r.togglePro(v)
|
||||
}}
|
||||
trackColor={{
|
||||
false: 'rgba(255,255,255,0.12)',
|
||||
true: 'rgba(91, 157, 255, 0.45)',
|
||||
}}
|
||||
thumbColor={r.multiChannelPro ? '#7eb6ff' : '#5c6370'}
|
||||
ios_backgroundColor="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.proSwitchWrap}>
|
||||
<Switch
|
||||
value={r.multiChannelPro}
|
||||
disabled={unbound}
|
||||
onValueChange={(v) => {
|
||||
if (unbound) return
|
||||
void r.togglePro(v)
|
||||
}}
|
||||
trackColor={{
|
||||
false: 'rgba(255,255,255,0.12)',
|
||||
true: 'rgba(91, 157, 255, 0.45)',
|
||||
}}
|
||||
thumbColor={r.multiChannelPro ? '#7eb6ff' : '#5c6370'}
|
||||
ios_backgroundColor="rgba(255,255,255,0.1)"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
||||
{bind ? (
|
||||
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
||||
@@ -719,9 +751,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 14,
|
||||
},
|
||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||
toolbarCard: {
|
||||
toolbarGradient: {
|
||||
marginBottom: 12,
|
||||
borderRadius: 14,
|
||||
borderRadius: 16,
|
||||
padding: 1,
|
||||
},
|
||||
toolbarCard: {
|
||||
borderRadius: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.07)',
|
||||
backgroundColor: theme.bgElevated,
|
||||
|
||||
@@ -246,7 +246,7 @@ export default function NetworkScreen() {
|
||||
|
||||
{!bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 抖音 与测速。</Text>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 国内站点 与测速。</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -268,7 +268,7 @@ export default function NetworkScreen() {
|
||||
onSolution={openSolution}
|
||||
/>
|
||||
<SiteCard
|
||||
title="抖音(国内参考)"
|
||||
title="国内站点(参考)"
|
||||
site={view.douyin}
|
||||
speed={view.speed.domestic}
|
||||
loading={loading && !!bind}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { BindProvider } from '@/context/BindContext';
|
||||
import { PocketBaseAuthModal } from '@/components/PocketBaseAuthModal';
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
@@ -55,12 +57,15 @@ function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<BindProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
</BindProvider>
|
||||
<AuthProvider>
|
||||
<BindProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<PocketBaseAuthModal />
|
||||
</ThemeProvider>
|
||||
</BindProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 438 KiB |
135
components/PocketBaseAuthModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Linking,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
} from 'react-native'
|
||||
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { theme } from '@/constants/theme'
|
||||
|
||||
const VIP_URL = 'https://vip.hackrobot.cn/'
|
||||
|
||||
export function PocketBaseAuthModal() {
|
||||
const { loginVisible, closeLogin, login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const onSubmit = async () => {
|
||||
setErr(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
await login(email.trim(), password)
|
||||
setPassword('')
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={loginVisible} animationType="fade" transparent onRequestClose={closeLogin}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.backdrop}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<Pressable style={styles.scrim} onPress={closeLogin} />
|
||||
<View style={styles.sheet}>
|
||||
<Text style={styles.title}>会员授权</Text>
|
||||
<Text style={styles.sub}>
|
||||
开始或重新开始直播前,请使用异度星球账号登录。授权保存在本机,可随时在首页退出。
|
||||
</Text>
|
||||
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||
<Text style={styles.lbl}>邮箱</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="you@example.com"
|
||||
placeholderTextColor={theme.muted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
<Text style={styles.lbl}>密码</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={theme.muted}
|
||||
secureTextEntry
|
||||
/>
|
||||
<Pressable
|
||||
style={[styles.btn, busy && styles.btnDisabled]}
|
||||
disabled={busy}
|
||||
onPress={() => void onSubmit()}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.btnText}>登录并授权</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<View style={styles.row}>
|
||||
<Pressable onPress={() => void Linking.openURL(VIP_URL)}>
|
||||
<Text style={styles.link}>注册</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.sep}>·</Text>
|
||||
<Pressable onPress={closeLogin}>
|
||||
<Text style={styles.linkMuted}>取消</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, justifyContent: 'center', padding: 20 },
|
||||
scrim: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.55)' },
|
||||
sheet: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
backgroundColor: theme.bgCard,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
title: { fontSize: 18, fontWeight: '700', color: theme.text, marginBottom: 8 },
|
||||
sub: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 14 },
|
||||
err: { color: theme.danger, fontSize: 13, marginBottom: 10 },
|
||||
lbl: { fontSize: 12, fontWeight: '600', color: theme.muted, marginBottom: 6 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
color: theme.text,
|
||||
marginBottom: 12,
|
||||
backgroundColor: theme.bg,
|
||||
},
|
||||
btn: {
|
||||
backgroundColor: theme.accent,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
btnDisabled: { opacity: 0.6 },
|
||||
btnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
row: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 16, gap: 8 },
|
||||
link: { color: theme.accent, fontWeight: '600', fontSize: 14 },
|
||||
linkMuted: { color: theme.muted, fontSize: 14 },
|
||||
sep: { color: theme.muted },
|
||||
})
|
||||
166
context/AuthContext.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { Alert } from 'react-native'
|
||||
|
||||
import { clearPbSession, loadPbSession, savePbSession, type StoredPbSession } from '@/lib/authStorage'
|
||||
import {
|
||||
isLiveAllowed,
|
||||
pbAuthRefresh,
|
||||
pbAuthWithPassword,
|
||||
type PbUserRecord,
|
||||
} from '@/lib/pocketbase'
|
||||
|
||||
type Ctx = {
|
||||
session: StoredPbSession | null
|
||||
ready: boolean
|
||||
loginVisible: boolean
|
||||
openLogin: () => void
|
||||
closeLogin: () => void
|
||||
login: (email: string, password: string) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
refreshSession: () => Promise<boolean>
|
||||
/** 用于远程 API 请求头 */
|
||||
pbToken: string | null
|
||||
/** 开始/重启直播前调用;未登录则弹出登录;无权限则 false */
|
||||
ensureCanControlLive: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<Ctx | null>(null)
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [session, setSession] = useState<StoredPbSession | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [loginVisible, setLoginVisible] = useState(false)
|
||||
const pendingGate = useRef<((ok: boolean) => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await loadPbSession()
|
||||
if (s) {
|
||||
try {
|
||||
const r = await pbAuthRefresh(s.token)
|
||||
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||
setSession(next)
|
||||
await savePbSession(next)
|
||||
} catch {
|
||||
await clearPbSession()
|
||||
setSession(null)
|
||||
}
|
||||
}
|
||||
setReady(true)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const openLogin = useCallback(() => setLoginVisible(true), [])
|
||||
const closeLogin = useCallback(() => {
|
||||
setLoginVisible(false)
|
||||
const p = pendingGate.current
|
||||
pendingGate.current = null
|
||||
p?.(false)
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const r = await pbAuthWithPassword(email, password)
|
||||
if (!isLiveAllowed(r.record)) {
|
||||
throw new Error('该账号暂无直播权限,请联系管理员')
|
||||
}
|
||||
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||
setSession(next)
|
||||
await savePbSession(next)
|
||||
setLoginVisible(false)
|
||||
const p = pendingGate.current
|
||||
pendingGate.current = null
|
||||
p?.(true)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await clearPbSession()
|
||||
setSession(null)
|
||||
}, [])
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
if (!session?.token) return false
|
||||
try {
|
||||
const r = await pbAuthRefresh(session.token)
|
||||
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||
setSession(next)
|
||||
await savePbSession(next)
|
||||
return isLiveAllowed(r.record)
|
||||
} catch {
|
||||
await clearPbSession()
|
||||
setSession(null)
|
||||
return false
|
||||
}
|
||||
}, [session?.token])
|
||||
|
||||
const ensureCanControlLive = useCallback(async (): Promise<boolean> => {
|
||||
if (session?.token) {
|
||||
try {
|
||||
const r = await pbAuthRefresh(session.token)
|
||||
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||
setSession(next)
|
||||
await savePbSession(next)
|
||||
if (!isLiveAllowed(r.record)) {
|
||||
Alert.alert('无权限', '账号已被管理员停用远程直播权限。')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
await clearPbSession()
|
||||
setSession(null)
|
||||
}
|
||||
}
|
||||
return new Promise<boolean>((resolve) => {
|
||||
pendingGate.current = (ok) => resolve(ok)
|
||||
setLoginVisible(true)
|
||||
})
|
||||
}, [session?.token])
|
||||
|
||||
const pbToken = session?.token ?? null
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
session,
|
||||
ready,
|
||||
loginVisible,
|
||||
openLogin,
|
||||
closeLogin,
|
||||
login,
|
||||
logout,
|
||||
refreshSession,
|
||||
pbToken,
|
||||
ensureCanControlLive,
|
||||
}),
|
||||
[
|
||||
session,
|
||||
ready,
|
||||
loginVisible,
|
||||
openLogin,
|
||||
closeLogin,
|
||||
login,
|
||||
logout,
|
||||
refreshSession,
|
||||
pbToken,
|
||||
ensureCanControlLive,
|
||||
],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): Ctx {
|
||||
const c = useContext(AuthContext)
|
||||
if (!c) throw new Error('useAuth outside AuthProvider')
|
||||
return c
|
||||
}
|
||||
|
||||
export function useAuthOptional(): Ctx | null {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert } from 'react-native'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch, apiJson } from '@/lib/api'
|
||||
import { reportLiveControlEvent } from '@/lib/liveTelemetry'
|
||||
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
||||
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
||||
@@ -72,6 +74,9 @@ function pickProcessForEntries(filtered: Entry[], multi: boolean): string {
|
||||
|
||||
export function useRecorder() {
|
||||
const { bind } = useBind()
|
||||
const { pbToken, ensureCanControlLive, session } = useAuth()
|
||||
const pbTokRef = useRef(pbToken)
|
||||
pbTokRef.current = pbToken
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [currentProcess, setCurrentProcess] = useState('')
|
||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||
@@ -135,7 +140,7 @@ export function useRecorder() {
|
||||
useEffect(() => {
|
||||
if (!bindKey) return
|
||||
let cancelled = false
|
||||
void apiFetch(bindRef.current!, '/host/gpu_status')
|
||||
void apiFetch(bindRef.current!, '/host/gpu_status', { pbToken: pbTokRef.current })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((j: GpuCaps) => {
|
||||
if (!cancelled)
|
||||
@@ -166,7 +171,7 @@ export function useRecorder() {
|
||||
const loadProcessMonitor = useCallback(async () => {
|
||||
const b = bindRef.current
|
||||
if (!b) throw new Error('尚未绑定 PC')
|
||||
const res = await apiFetch(b, '/process_monitor')
|
||||
const res = await apiFetch(b, '/process_monitor', { pbToken: pbTokRef.current })
|
||||
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) => ({
|
||||
@@ -186,6 +191,7 @@ export function useRecorder() {
|
||||
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||
b,
|
||||
'/relay_pro/channels',
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!d.ok) {
|
||||
setProChannelsError(d.error || '无法加载 Pro 频道')
|
||||
@@ -220,7 +226,7 @@ export function useRecorder() {
|
||||
}
|
||||
setYoutubeStatus('正在将当前配置同步到多频道…')
|
||||
try {
|
||||
const res = await apiFetch(b, '/relay_pro/channels')
|
||||
const res = await apiFetch(b, '/relay_pro/channels', { pbToken: pbTokRef.current })
|
||||
const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string }
|
||||
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
||||
const firstId = d.channels[0].id
|
||||
@@ -231,6 +237,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const kj = (await keyRes.json()) as { error?: string }
|
||||
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
||||
@@ -241,6 +248,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: urlText }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const uj = (await urlRes.json()) as { error?: string }
|
||||
@@ -258,6 +266,7 @@ export function useRecorder() {
|
||||
options: ytIniModel.options,
|
||||
header: ytIniModel.header || '',
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
@@ -282,6 +291,7 @@ export function useRecorder() {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||
const data = (await res.json()) as { content?: string }
|
||||
@@ -310,6 +320,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||
@@ -330,6 +341,7 @@ export function useRecorder() {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
@@ -375,6 +387,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(modelToSave),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
@@ -404,6 +417,7 @@ export function useRecorder() {
|
||||
keys: keysOne,
|
||||
activeIndex: 0,
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
@@ -450,6 +464,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: ed.urlText }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
@@ -469,6 +484,7 @@ export function useRecorder() {
|
||||
const ur = await apiFetch(
|
||||
b,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!ur.ok) {
|
||||
setUrlStatus('读取失败')
|
||||
@@ -518,9 +534,17 @@ export function useRecorder() {
|
||||
}
|
||||
const proc = procForPm2(processOverride)
|
||||
const isStatus = action === 'status'
|
||||
if (!isStatus) setLoadingAction(action)
|
||||
if (!isStatus) {
|
||||
if (action === 'start' || action === 'restart') {
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
}
|
||||
setLoadingAction(action)
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`)
|
||||
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`, {
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
|
||||
@@ -551,6 +575,15 @@ export function useRecorder() {
|
||||
const actionLabel =
|
||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
|
||||
if (action === 'start' || action === 'restart' || action === 'stop') {
|
||||
void reportLiveControlEvent(
|
||||
bindRef.current,
|
||||
pbTokRef.current,
|
||||
session?.record?.id,
|
||||
proc,
|
||||
action,
|
||||
)
|
||||
}
|
||||
const statusProc = processOverride ?? proc
|
||||
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
||||
}
|
||||
@@ -561,18 +594,22 @@ export function useRecorder() {
|
||||
if (!isStatus) setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[bindRef, procForPm2, getEntry],
|
||||
[bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const runProChannelStart = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!channelId) return
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
const pm = proChannelPm2Process(channelId)
|
||||
setLoadingAction('start')
|
||||
try {
|
||||
const saved = await saveProChannelKeysFor(channelId)
|
||||
if (!saved) return
|
||||
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`)
|
||||
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`, {
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
const e = getEntry(pm)
|
||||
@@ -584,6 +621,7 @@ export function useRecorder() {
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
||||
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start')
|
||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
@@ -591,18 +629,22 @@ export function useRecorder() {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const runProChannelRestart = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!channelId) return
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
const pm = proChannelPm2Process(channelId)
|
||||
setLoadingAction('restart')
|
||||
try {
|
||||
const saved = await saveProChannelKeysFor(channelId)
|
||||
if (!saved) return
|
||||
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`)
|
||||
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`, {
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
const e = getEntry(pm)
|
||||
@@ -614,6 +656,7 @@ export function useRecorder() {
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
||||
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart')
|
||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
@@ -621,7 +664,7 @@ export function useRecorder() {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const submitNewChannelDraft = useCallback(async () => {
|
||||
@@ -637,6 +680,7 @@ export function useRecorder() {
|
||||
name: `频道 ${proChannelsList.length + 1}`,
|
||||
youtubeKey: key,
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||
@@ -675,6 +719,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId }),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const d = (await res.json()) as { error?: string; message?: string }
|
||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||
@@ -848,6 +893,7 @@ export function useRecorder() {
|
||||
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||
bind,
|
||||
'/relay_pro/channels',
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (cancelled) return
|
||||
if (!d.ok) {
|
||||
@@ -878,7 +924,7 @@ export function useRecorder() {
|
||||
let cancelled = false
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await apiFetch(bind, '/relay_pro/live_status')
|
||||
const r = await apiFetch(bind, '/relay_pro/live_status', { pbToken: pbTokRef.current })
|
||||
if (!r.ok || cancelled) return
|
||||
const j = (await r.json()) as ProRelayStatusPayload
|
||||
if (!cancelled) setProRelayStatus(j)
|
||||
@@ -910,6 +956,7 @@ export function useRecorder() {
|
||||
const dr = await apiFetch(
|
||||
b,
|
||||
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!dr.ok) continue
|
||||
const cd = (await dr.json()) as {
|
||||
@@ -951,6 +998,7 @@ export function useRecorder() {
|
||||
const ur = await apiFetch(
|
||||
bind,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!ur.ok || cancelled) return
|
||||
const j = (await ur.json()) as { content?: string }
|
||||
|
||||
14
lib/api.ts
@@ -6,22 +6,28 @@ function joinUrl(base: string, path: string): string {
|
||||
return `${b}${p}`
|
||||
}
|
||||
|
||||
export type ApiFetchInit = RequestInit & { pbToken?: string | null }
|
||||
|
||||
export async function apiFetch(
|
||||
bind: BindPayload | null,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
init?: ApiFetchInit,
|
||||
): Promise<Response> {
|
||||
if (!bind) {
|
||||
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
||||
}
|
||||
const headers = new Headers(init?.headers)
|
||||
const { pbToken, ...rest } = init ?? {}
|
||||
const headers = new Headers(rest.headers)
|
||||
if (bind.token?.trim()) {
|
||||
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
|
||||
}
|
||||
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
|
||||
if (pbToken?.trim()) {
|
||||
headers.set('X-PB-Token', pbToken.trim())
|
||||
}
|
||||
return fetch(joinUrl(bind.baseUrl, path), { ...rest, headers })
|
||||
}
|
||||
|
||||
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
|
||||
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: ApiFetchInit): Promise<T> {
|
||||
const res = await apiFetch(bind, path, init)
|
||||
if (!res.ok) {
|
||||
const t = await res.text()
|
||||
|
||||
29
lib/authStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { PbUserRecord } from './pocketbase'
|
||||
|
||||
const KEY = 'pb_auth_session_v1'
|
||||
|
||||
export type StoredPbSession = {
|
||||
token: string
|
||||
record: PbUserRecord
|
||||
}
|
||||
|
||||
export async function loadPbSession(): Promise<StoredPbSession | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(KEY)
|
||||
if (!raw) return null
|
||||
const j = JSON.parse(raw) as StoredPbSession
|
||||
if (!j?.token || !j?.record?.id) return null
|
||||
return j
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePbSession(s: StoredPbSession): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(s))
|
||||
}
|
||||
|
||||
export async function clearPbSession(): Promise<void> {
|
||||
await AsyncStorage.removeItem(KEY)
|
||||
}
|
||||
21
lib/liveTelemetry.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import type { BindPayload } from '@/lib/types'
|
||||
import { pbCreateLiveSession } from '@/lib/pocketbase'
|
||||
|
||||
export async function reportLiveControlEvent(
|
||||
bind: BindPayload | null,
|
||||
pbToken: string | null,
|
||||
userId: string | undefined,
|
||||
process: string,
|
||||
event: 'start' | 'restart' | 'stop',
|
||||
): Promise<void> {
|
||||
if (!bind || !pbToken?.trim() || !userId) return
|
||||
let machine: Record<string, unknown> = {}
|
||||
try {
|
||||
const r = await apiFetch(bind, '/host/machine_summary', { pbToken: pbToken.trim() })
|
||||
if (r.ok) machine = (await r.json()) as Record<string, unknown>
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
await pbCreateLiveSession(pbToken.trim(), userId, { process, event, machine })
|
||||
}
|
||||
99
lib/pocketbase.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/** PocketBase REST(与 payjsapi 同源实例 https://pocketbase.hackrobot.cn) */
|
||||
|
||||
export const POCKETBASE_URL = 'https://pocketbase.hackrobot.cn'
|
||||
|
||||
export type PbUserRecord = {
|
||||
id: string
|
||||
email?: string
|
||||
name?: string
|
||||
live_allowed?: boolean
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export type PbAuthResponse = {
|
||||
token: string
|
||||
record: PbUserRecord
|
||||
}
|
||||
|
||||
function joinPb(path: string): string {
|
||||
const b = POCKETBASE_URL.replace(/\/$/, '')
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return `${b}${p}`
|
||||
}
|
||||
|
||||
export async function pbAuthWithPassword(email: string, password: string): Promise<PbAuthResponse> {
|
||||
const res = await fetch(joinPb('/api/collections/users/auth-with-password'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: email.trim(), password }),
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
typeof data.message === 'string'
|
||||
? data.message
|
||||
: typeof data.data === 'object' && data.data && typeof (data.data as { message?: string }).message === 'string'
|
||||
? (data.data as { message: string }).message
|
||||
: `登录失败 (${res.status})`
|
||||
throw new Error(msg)
|
||||
}
|
||||
const token = String(data.token ?? '')
|
||||
const record = data.record as PbUserRecord | undefined
|
||||
if (!token || !record?.id) throw new Error('登录响应异常')
|
||||
return { token, record }
|
||||
}
|
||||
|
||||
export async function pbAuthRefresh(token: string): Promise<PbAuthResponse> {
|
||||
const res = await fetch(joinPb('/api/collections/users/auth-refresh'), {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.trim()}` },
|
||||
})
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||
if (!res.ok) {
|
||||
throw new Error(typeof data.message === 'string' ? data.message : `刷新失败 (${res.status})`)
|
||||
}
|
||||
const newToken = String(data.token ?? '')
|
||||
const record = data.record as PbUserRecord | undefined
|
||||
if (!newToken || !record?.id) throw new Error('刷新响应异常')
|
||||
return { token: newToken, record }
|
||||
}
|
||||
|
||||
export function isLiveAllowed(record: PbUserRecord | null | undefined): boolean {
|
||||
if (!record) return false
|
||||
return record.live_allowed !== false
|
||||
}
|
||||
|
||||
export type LiveSessionPayload = {
|
||||
process: string
|
||||
event: 'start' | 'restart' | 'stop'
|
||||
machine: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 需在 PocketBase 新建集合 `live_sessions`,见项目内说明 */
|
||||
export async function pbCreateLiveSession(
|
||||
token: string,
|
||||
userId: string,
|
||||
payload: LiveSessionPayload,
|
||||
): Promise<void> {
|
||||
const res = await fetch(joinPb('/api/collections/live_sessions/records'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token.trim()}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user: userId,
|
||||
process: payload.process,
|
||||
event: payload.event,
|
||||
machine: payload.machine,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const t = await res.text()
|
||||
if (res.status === 404) {
|
||||
console.warn('[live_sessions] 集合未创建或规则未放行:', t)
|
||||
return
|
||||
}
|
||||
console.warn('[live_sessions] 写入失败:', res.status, t)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"generate:branding": "node ./scripts/generate-branding.mjs",
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"run:android": "expo run:android",
|
||||
@@ -38,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"pngjs": "^7.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true,
|
||||
|
||||
9
pnpm-lock.yaml
generated
@@ -87,6 +87,9 @@ importers:
|
||||
'@types/react':
|
||||
specifier: ~19.2.2
|
||||
version: 19.2.14
|
||||
pngjs:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
typescript:
|
||||
specifier: ~5.9.2
|
||||
version: 5.9.3
|
||||
@@ -2571,6 +2574,10 @@ packages:
|
||||
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
pngjs@7.0.0:
|
||||
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
||||
engines: {node: '>=14.19.0'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
@@ -6285,6 +6292,8 @@ snapshots:
|
||||
|
||||
pngjs@3.4.0: {}
|
||||
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.4.49:
|
||||
|
||||
301
scripts/generate-branding.mjs
Normal file
@@ -0,0 +1,301 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PNG } from "pngjs";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
const outDir = path.join(root, "assets", "images");
|
||||
|
||||
const COLORS = {
|
||||
indigo: [0x2d, 0x2a, 0xf6, 0xff],
|
||||
cyan: [0x27, 0xd7, 0xff, 0xff],
|
||||
white: [0xff, 0xff, 0xff, 0xff],
|
||||
red: [0xff, 0x3b, 0x30, 0xff],
|
||||
transparent: [0x00, 0x00, 0x00, 0x00],
|
||||
};
|
||||
|
||||
function clamp01(x) {
|
||||
return Math.max(0, Math.min(1, x));
|
||||
}
|
||||
|
||||
function lerp(a, b, t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
function mixRGBA(c1, c2, t) {
|
||||
return [
|
||||
Math.round(lerp(c1[0], c2[0], t)),
|
||||
Math.round(lerp(c1[1], c2[1], t)),
|
||||
Math.round(lerp(c1[2], c2[2], t)),
|
||||
Math.round(lerp(c1[3], c2[3], t)),
|
||||
];
|
||||
}
|
||||
|
||||
function putPixel(img, x, y, rgba) {
|
||||
const idx = (img.width * y + x) << 2;
|
||||
img.data[idx] = rgba[0];
|
||||
img.data[idx + 1] = rgba[1];
|
||||
img.data[idx + 2] = rgba[2];
|
||||
img.data[idx + 3] = rgba[3];
|
||||
}
|
||||
|
||||
function over(dst, src) {
|
||||
const sa = src[3] / 255;
|
||||
const da = dst[3] / 255;
|
||||
const outA = sa + da * (1 - sa);
|
||||
if (outA <= 0) return [0, 0, 0, 0];
|
||||
const outR = (src[0] * sa + dst[0] * da * (1 - sa)) / outA;
|
||||
const outG = (src[1] * sa + dst[1] * da * (1 - sa)) / outA;
|
||||
const outB = (src[2] * sa + dst[2] * da * (1 - sa)) / outA;
|
||||
return [Math.round(outR), Math.round(outG), Math.round(outB), Math.round(outA * 255)];
|
||||
}
|
||||
|
||||
function sampleGradient(x, y, size) {
|
||||
const t = clamp01((x + y) / (2 * (size - 1)));
|
||||
return mixRGBA(COLORS.indigo, COLORS.cyan, t);
|
||||
}
|
||||
|
||||
function sdRoundRect(px, py, cx, cy, hw, hh, r) {
|
||||
const x = Math.abs(px - cx) - hw;
|
||||
const y = Math.abs(py - cy) - hh;
|
||||
const ax = Math.max(x, 0);
|
||||
const ay = Math.max(y, 0);
|
||||
const outside = Math.hypot(ax, ay) - r;
|
||||
const inside = Math.min(Math.max(x, y), 0);
|
||||
return outside + inside;
|
||||
}
|
||||
|
||||
function sdCircle(px, py, cx, cy, r) {
|
||||
return Math.hypot(px - cx, py - cy) - r;
|
||||
}
|
||||
|
||||
function aaCoverage(sd, feather) {
|
||||
// Signed distance -> coverage (1 inside, 0 outside) with soft edge.
|
||||
return clamp01(0.5 - sd / feather);
|
||||
}
|
||||
|
||||
function render({ size, supersample = 3, draw, background = "gradient" }) {
|
||||
const hi = size * supersample;
|
||||
const img = new PNG({ width: hi, height: hi });
|
||||
|
||||
for (let y = 0; y < hi; y++) {
|
||||
for (let x = 0; x < hi; x++) {
|
||||
const bx =
|
||||
background === "transparent"
|
||||
? COLORS.transparent
|
||||
: sampleGradient(x / supersample, y / supersample, size);
|
||||
putPixel(img, x, y, bx);
|
||||
}
|
||||
}
|
||||
|
||||
draw(img, supersample);
|
||||
|
||||
// Downsample (box filter).
|
||||
const out = new PNG({ width: size, height: size });
|
||||
const n = supersample * supersample;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0,
|
||||
a = 0;
|
||||
for (let sy = 0; sy < supersample; sy++) {
|
||||
for (let sx = 0; sx < supersample; sx++) {
|
||||
const hx = x * supersample + sx;
|
||||
const hy = y * supersample + sy;
|
||||
const idx = (hi * hy + hx) << 2;
|
||||
r += img.data[idx];
|
||||
g += img.data[idx + 1];
|
||||
b += img.data[idx + 2];
|
||||
a += img.data[idx + 3];
|
||||
}
|
||||
}
|
||||
putPixel(out, x, y, [
|
||||
Math.round(r / n),
|
||||
Math.round(g / n),
|
||||
Math.round(b / n),
|
||||
Math.round(a / n),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function drawCamera({ includeRedDot, strokePx, glow = false, silhouette = false, roundedIcon = false }) {
|
||||
return (img, ss) => {
|
||||
const size = img.width / ss;
|
||||
const hi = img.width;
|
||||
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
|
||||
const bodyW = size * 0.56;
|
||||
const bodyH = size * 0.36;
|
||||
const r = size * 0.06;
|
||||
const lensR = size * 0.10;
|
||||
const topBumpW = size * 0.18;
|
||||
const topBumpH = size * 0.08;
|
||||
|
||||
const bodyCx = cx;
|
||||
const bodyCy = cy;
|
||||
|
||||
const bumpCx = cx - bodyW * 0.38 + topBumpW / 2;
|
||||
const bumpCy = cy - bodyH / 2 - topBumpH * 0.05;
|
||||
|
||||
const dotCx = cx + bodyW * 0.36;
|
||||
const dotCy = cy - bodyH * 0.28;
|
||||
const dotR = size * 0.04;
|
||||
|
||||
const feather = 0.9; // in hi-res pixels
|
||||
const stroke = strokePx;
|
||||
|
||||
function applyOver(x, y, color) {
|
||||
const idx = (img.width * y + x) << 2;
|
||||
const dst = [img.data[idx], img.data[idx + 1], img.data[idx + 2], img.data[idx + 3]];
|
||||
const out = over(dst, color);
|
||||
img.data[idx] = out[0];
|
||||
img.data[idx + 1] = out[1];
|
||||
img.data[idx + 2] = out[2];
|
||||
img.data[idx + 3] = out[3];
|
||||
}
|
||||
|
||||
for (let y = 0; y < hi; y++) {
|
||||
for (let x = 0; x < hi; x++) {
|
||||
const px = (x + 0.5) / ss;
|
||||
const py = (y + 0.5) / ss;
|
||||
|
||||
// Optional rounded icon mask (for 1024 icon and favicon).
|
||||
if (roundedIcon) {
|
||||
const sdMask = sdRoundRect(px, py, cx, cy, size / 2, size / 2, size * 0.215);
|
||||
const m = aaCoverage(sdMask, 1.25 / ss);
|
||||
if (m < 1) {
|
||||
// apply alpha mask by scaling current alpha
|
||||
const idx = (img.width * y + x) << 2;
|
||||
img.data[idx + 3] = Math.round(img.data[idx + 3] * m);
|
||||
}
|
||||
}
|
||||
|
||||
if (silhouette) {
|
||||
const sdBody = sdRoundRect(px, py, bodyCx, bodyCy, bodyW / 2, bodyH / 2, r);
|
||||
const sdBump = sdRoundRect(px, py, bumpCx, bumpCy, topBumpW / 2, topBumpH / 2, r * 0.6);
|
||||
const inside = Math.min(sdBody, sdBump);
|
||||
const hole = sdCircle(px, py, cx, cy, lensR * 0.62);
|
||||
const sd = Math.max(inside, -hole); // even-odd hole
|
||||
const cov = aaCoverage(sd, feather / ss);
|
||||
if (cov > 0) applyOver(x, y, [COLORS.white[0], COLORS.white[1], COLORS.white[2], Math.round(255 * cov)]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Outline stroke using distance to shapes.
|
||||
const sdBody = sdRoundRect(px, py, bodyCx, bodyCy, bodyW / 2, bodyH / 2, r);
|
||||
const sdBump = sdRoundRect(px, py, bumpCx, bumpCy, topBumpW / 2, topBumpH / 2, r * 0.6);
|
||||
const sdLens = sdCircle(px, py, cx, cy, lensR);
|
||||
|
||||
const d = Math.min(Math.abs(sdBody), Math.abs(sdBump), Math.abs(sdLens));
|
||||
const cov = aaCoverage(d - stroke / 2, feather / ss);
|
||||
if (cov > 0) {
|
||||
const alpha = Math.round(255 * cov);
|
||||
applyOver(x, y, [COLORS.white[0], COLORS.white[1], COLORS.white[2], alpha]);
|
||||
}
|
||||
|
||||
if (includeRedDot) {
|
||||
const sdDot = sdCircle(px, py, dotCx, dotCy, dotR);
|
||||
const covDot = aaCoverage(sdDot, feather / ss);
|
||||
if (covDot > 0) {
|
||||
applyOver(x, y, [COLORS.red[0], COLORS.red[1], COLORS.red[2], Math.round(255 * covDot)]);
|
||||
}
|
||||
}
|
||||
|
||||
if (glow) {
|
||||
// soft radial glow behind the logo area
|
||||
const glowR = size * 0.38;
|
||||
const gd = sdCircle(px, py, cx, cy, glowR);
|
||||
const gCov = clamp01(1 - Math.max(gd, 0) / (size * 0.22));
|
||||
if (gCov > 0) {
|
||||
applyOver(x, y, [255, 255, 255, Math.round(38 * gCov)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function writePng(filePath, png) {
|
||||
const buf = PNG.sync.write(png);
|
||||
await fs.writeFile(filePath, buf);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
|
||||
// icon.png (1024)
|
||||
await writePng(
|
||||
path.join(outDir, "icon.png"),
|
||||
render({
|
||||
size: 1024,
|
||||
supersample: 3,
|
||||
background: "gradient",
|
||||
draw: drawCamera({ includeRedDot: true, strokePx: 18, roundedIcon: true }),
|
||||
})
|
||||
);
|
||||
|
||||
// splash-icon.png (2048)
|
||||
await writePng(
|
||||
path.join(outDir, "splash-icon.png"),
|
||||
render({
|
||||
size: 2048,
|
||||
supersample: 2,
|
||||
background: "gradient",
|
||||
draw: drawCamera({ includeRedDot: true, strokePx: 26, glow: true }),
|
||||
})
|
||||
);
|
||||
|
||||
// android-icon-background.png (1024)
|
||||
await writePng(
|
||||
path.join(outDir, "android-icon-background.png"),
|
||||
render({ size: 1024, supersample: 2, background: "gradient", draw: () => {} })
|
||||
);
|
||||
|
||||
// android-icon-foreground.png (1024, transparent bg)
|
||||
await writePng(
|
||||
path.join(outDir, "android-icon-foreground.png"),
|
||||
render({
|
||||
size: 1024,
|
||||
supersample: 3,
|
||||
background: "transparent",
|
||||
draw: drawCamera({ includeRedDot: true, strokePx: 20 }),
|
||||
})
|
||||
);
|
||||
|
||||
// android-icon-monochrome.png (1024, transparent bg)
|
||||
await writePng(
|
||||
path.join(outDir, "android-icon-monochrome.png"),
|
||||
render({
|
||||
size: 1024,
|
||||
supersample: 3,
|
||||
background: "transparent",
|
||||
draw: drawCamera({ includeRedDot: false, strokePx: 0, silhouette: true }),
|
||||
})
|
||||
);
|
||||
|
||||
// favicon.png (48)
|
||||
await writePng(
|
||||
path.join(outDir, "favicon.png"),
|
||||
render({
|
||||
size: 48,
|
||||
supersample: 6,
|
||||
background: "gradient",
|
||||
draw: drawCamera({ includeRedDot: true, strokePx: 2, roundedIcon: true }),
|
||||
})
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Branding assets generated in assets/images/");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||