Compare commits
2 Commits
aba40c8cb7
...
vip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f8b02ea4f | ||
|
|
3bc1599763 |
4
app.json
@@ -10,7 +10,7 @@
|
|||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splash-icon.png",
|
"image": "./assets/images/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#2D2AF6"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"package": "com.douyin.recorder.remote",
|
"package": "com.douyin.recorder.remote",
|
||||||
"usesCleartextTraffic": true,
|
"usesCleartextTraffic": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#2D2AF6",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Platform } from 'react-native'
|
||||||
import { Tabs } from 'expo-router'
|
import { Tabs } from 'expo-router'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
|
||||||
@@ -8,17 +9,28 @@ import { useClientOnlyValue } from '@/components/useClientOnlyValue'
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
|
const dark = colorScheme === 'dark'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
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: {
|
tabBarStyle: {
|
||||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
backgroundColor: dark ? '#161a22' : '#fff',
|
||||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
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: {
|
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,
|
headerTintColor: Colors[colorScheme].text,
|
||||||
headerShown: useClientOnlyValue(false, true),
|
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 ?? '—'}
|
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.snapLine}>
|
<Text style={styles.snapLine}>
|
||||||
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
国内站点 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -196,7 +196,7 @@ export default function DeskScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
||||||
<Text style={styles.liveLine} numberOfLines={2}>
|
<Text style={styles.liveLine} numberOfLines={2}>
|
||||||
抖音 {row.douyinHint ?? '—'}
|
源站 {row.douyinHint ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.liveLine} numberOfLines={2}>
|
<Text style={styles.liveLine} numberOfLines={2}>
|
||||||
标题 {row.streamTitle ?? '—'}
|
标题 {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 {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
import { Picker } from '@react-native-picker/picker'
|
import { Picker } from '@react-native-picker/picker'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { useRecorder } from '@/hooks/useRecorder'
|
import { useRecorder } from '@/hooks/useRecorder'
|
||||||
import { theme } from '@/constants/theme'
|
import { theme } from '@/constants/theme'
|
||||||
@@ -121,7 +125,7 @@ function ProLiveSwitchGrid({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
{href ? (
|
{href ? (
|
||||||
<Pressable onPress={() => void Linking.openURL(href)}>
|
<Pressable onPress={() => void Linking.openURL(href)}>
|
||||||
<Text style={styles.proLink}>在浏览器打开抖音页</Text>
|
<Text style={styles.proLink}>在浏览器打开源站页</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.proActions}>
|
<View style={styles.proActions}>
|
||||||
@@ -199,8 +203,27 @@ function LiveSwitchGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RecordScreen() {
|
export default function RecordScreen() {
|
||||||
|
const navigation = useNavigation()
|
||||||
const { bind } = useBind()
|
const { bind } = useBind()
|
||||||
|
const { session, openLogin, logout } = useAuth()
|
||||||
const r = useRecorder()
|
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 unbound = !bind
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
@@ -258,49 +281,58 @@ export default function RecordScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.toolbarCard}>
|
<Animated.View entering={FadeInDown.duration(420).springify()}>
|
||||||
<View style={styles.toolbarRow}>
|
<LinearGradient
|
||||||
<View style={styles.toolbarLeft}>
|
colors={['rgba(91,157,255,0.14)', 'rgba(20,22,28,0.92)']}
|
||||||
<View
|
start={{ x: 0, y: 0 }}
|
||||||
style={[
|
end={{ x: 1, y: 1 }}
|
||||||
styles.statusChip,
|
style={styles.toolbarGradient}
|
||||||
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
>
|
||||||
]}
|
<View style={styles.toolbarCard}>
|
||||||
>
|
<View style={styles.toolbarRow}>
|
||||||
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
<View style={styles.toolbarLeft}>
|
||||||
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
<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>
|
</View>
|
||||||
<Text style={styles.statusLineText} numberOfLines={1}>
|
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||||||
{r.statusMeta.line}
|
<View style={styles.proLabelRow}>
|
||||||
</Text>
|
<Text style={styles.proLabelZh}>多频道</Text>
|
||||||
</View>
|
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||||||
</View>
|
<Text style={styles.proBadgeText}>PRO</Text>
|
||||||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
</View>
|
||||||
<View style={styles.proLabelRow}>
|
</View>
|
||||||
<Text style={styles.proLabelZh}>多频道</Text>
|
<View style={styles.proSwitchWrap}>
|
||||||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
<Switch
|
||||||
<Text style={styles.proBadgeText}>PRO</Text>
|
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>
|
</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>
|
||||||
</View>
|
</Animated.View>
|
||||||
|
|
||||||
{bind ? (
|
{bind ? (
|
||||||
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
||||||
@@ -719,9 +751,13 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
},
|
},
|
||||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||||
toolbarCard: {
|
toolbarGradient: {
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
|
padding: 1,
|
||||||
|
},
|
||||||
|
toolbarCard: {
|
||||||
|
borderRadius: 15,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255,255,255,0.07)',
|
borderColor: 'rgba(255,255,255,0.07)',
|
||||||
backgroundColor: theme.bgElevated,
|
backgroundColor: theme.bgElevated,
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export default function NetworkScreen() {
|
|||||||
|
|
||||||
{!bind && (
|
{!bind && (
|
||||||
<View style={styles.banner}>
|
<View style={styles.banner}>
|
||||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 抖音 与测速。</Text>
|
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 国内站点 与测速。</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export default function NetworkScreen() {
|
|||||||
onSolution={openSolution}
|
onSolution={openSolution}
|
||||||
/>
|
/>
|
||||||
<SiteCard
|
<SiteCard
|
||||||
title="抖音(国内参考)"
|
title="国内站点(参考)"
|
||||||
site={view.douyin}
|
site={view.douyin}
|
||||||
speed={view.speed.domestic}
|
speed={view.speed.domestic}
|
||||||
loading={loading && !!bind}
|
loading={loading && !!bind}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { useEffect } from 'react';
|
|||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
import { BindProvider } from '@/context/BindContext';
|
import { BindProvider } from '@/context/BindContext';
|
||||||
|
import { PocketBaseAuthModal } from '@/components/PocketBaseAuthModal';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Catch any errors thrown by the Layout component.
|
// Catch any errors thrown by the Layout component.
|
||||||
@@ -55,12 +57,15 @@ function RootLayoutNav() {
|
|||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BindProvider>
|
<AuthProvider>
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<BindProvider>
|
||||||
<Stack>
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack>
|
||||||
</Stack>
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
</ThemeProvider>
|
</Stack>
|
||||||
</BindProvider>
|
<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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Alert } from 'react-native'
|
import { Alert } from 'react-native'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { apiFetch, apiJson } from '@/lib/api'
|
import { apiFetch, apiJson } from '@/lib/api'
|
||||||
|
import { reportLiveControlEvent } from '@/lib/liveTelemetry'
|
||||||
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
||||||
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
||||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
||||||
@@ -72,6 +74,9 @@ function pickProcessForEntries(filtered: Entry[], multi: boolean): string {
|
|||||||
|
|
||||||
export function useRecorder() {
|
export function useRecorder() {
|
||||||
const { bind } = useBind()
|
const { bind } = useBind()
|
||||||
|
const { pbToken, ensureCanControlLive, session } = useAuth()
|
||||||
|
const pbTokRef = useRef(pbToken)
|
||||||
|
pbTokRef.current = pbToken
|
||||||
const [entries, setEntries] = useState<Entry[]>([])
|
const [entries, setEntries] = useState<Entry[]>([])
|
||||||
const [currentProcess, setCurrentProcess] = useState('')
|
const [currentProcess, setCurrentProcess] = useState('')
|
||||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||||
@@ -135,7 +140,7 @@ export function useRecorder() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bindKey) return
|
if (!bindKey) return
|
||||||
let cancelled = false
|
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((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||||
.then((j: GpuCaps) => {
|
.then((j: GpuCaps) => {
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
@@ -166,7 +171,7 @@ export function useRecorder() {
|
|||||||
const loadProcessMonitor = useCallback(async () => {
|
const loadProcessMonitor = useCallback(async () => {
|
||||||
const b = bindRef.current
|
const b = bindRef.current
|
||||||
if (!b) throw new Error('尚未绑定 PC')
|
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))
|
if (!res.ok) throw new Error(String(res.status))
|
||||||
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
||||||
const raw: Entry[] = (data.entries || []).map((e) => ({
|
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 }>(
|
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||||
b,
|
b,
|
||||||
'/relay_pro/channels',
|
'/relay_pro/channels',
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
setProChannelsError(d.error || '无法加载 Pro 频道')
|
setProChannelsError(d.error || '无法加载 Pro 频道')
|
||||||
@@ -220,7 +226,7 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
setYoutubeStatus('正在将当前配置同步到多频道…')
|
setYoutubeStatus('正在将当前配置同步到多频道…')
|
||||||
try {
|
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 }
|
const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string }
|
||||||
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
||||||
const firstId = d.channels[0].id
|
const firstId = d.channels[0].id
|
||||||
@@ -231,6 +237,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const kj = (await keyRes.json()) as { error?: string }
|
const kj = (await keyRes.json()) as { error?: string }
|
||||||
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
||||||
@@ -241,6 +248,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: urlText }),
|
body: JSON.stringify({ content: urlText }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const uj = (await urlRes.json()) as { error?: string }
|
const uj = (await urlRes.json()) as { error?: string }
|
||||||
@@ -258,6 +266,7 @@ export function useRecorder() {
|
|||||||
options: ytIniModel.options,
|
options: ytIniModel.options,
|
||||||
header: ytIniModel.header || '',
|
header: ytIniModel.header || '',
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -282,6 +291,7 @@ export function useRecorder() {
|
|||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||||
const data = (await res.json()) as { content?: string }
|
const data = (await res.json()) as { content?: string }
|
||||||
@@ -310,6 +320,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ content }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||||
@@ -330,6 +341,7 @@ export function useRecorder() {
|
|||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
@@ -375,6 +387,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(modelToSave),
|
body: JSON.stringify(modelToSave),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
@@ -404,6 +417,7 @@ export function useRecorder() {
|
|||||||
keys: keysOne,
|
keys: keysOne,
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
@@ -450,6 +464,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: ed.urlText }),
|
body: JSON.stringify({ content: ed.urlText }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
@@ -469,6 +484,7 @@ export function useRecorder() {
|
|||||||
const ur = await apiFetch(
|
const ur = await apiFetch(
|
||||||
b,
|
b,
|
||||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!ur.ok) {
|
if (!ur.ok) {
|
||||||
setUrlStatus('读取失败')
|
setUrlStatus('读取失败')
|
||||||
@@ -518,9 +534,17 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
const proc = procForPm2(processOverride)
|
const proc = procForPm2(processOverride)
|
||||||
const isStatus = action === 'status'
|
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 {
|
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('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
|
|
||||||
@@ -551,6 +575,15 @@ export function useRecorder() {
|
|||||||
const actionLabel =
|
const actionLabel =
|
||||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||||
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
|
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
|
const statusProc = processOverride ?? proc
|
||||||
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
||||||
}
|
}
|
||||||
@@ -561,18 +594,22 @@ export function useRecorder() {
|
|||||||
if (!isStatus) setLoadingAction(null)
|
if (!isStatus) setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bindRef, procForPm2, getEntry],
|
[bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const runProChannelStart = useCallback(
|
const runProChannelStart = useCallback(
|
||||||
async (channelId: string) => {
|
async (channelId: string) => {
|
||||||
if (!channelId) return
|
if (!channelId) return
|
||||||
|
const allowed = await ensureCanControlLive()
|
||||||
|
if (!allowed) return
|
||||||
const pm = proChannelPm2Process(channelId)
|
const pm = proChannelPm2Process(channelId)
|
||||||
setLoadingAction('start')
|
setLoadingAction('start')
|
||||||
try {
|
try {
|
||||||
const saved = await saveProChannelKeysFor(channelId)
|
const saved = await saveProChannelKeysFor(channelId)
|
||||||
if (!saved) return
|
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('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
const e = getEntry(pm)
|
const e = getEntry(pm)
|
||||||
@@ -584,6 +621,7 @@ export function useRecorder() {
|
|||||||
.filter((x): x is string => typeof x === 'string')
|
.filter((x): x is string => typeof x === 'string')
|
||||||
.join('\n') || '已完成'
|
.join('\n') || '已完成'
|
||||||
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
||||||
|
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start')
|
||||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||||
@@ -591,18 +629,22 @@ export function useRecorder() {
|
|||||||
setLoadingAction(null)
|
setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const runProChannelRestart = useCallback(
|
const runProChannelRestart = useCallback(
|
||||||
async (channelId: string) => {
|
async (channelId: string) => {
|
||||||
if (!channelId) return
|
if (!channelId) return
|
||||||
|
const allowed = await ensureCanControlLive()
|
||||||
|
if (!allowed) return
|
||||||
const pm = proChannelPm2Process(channelId)
|
const pm = proChannelPm2Process(channelId)
|
||||||
setLoadingAction('restart')
|
setLoadingAction('restart')
|
||||||
try {
|
try {
|
||||||
const saved = await saveProChannelKeysFor(channelId)
|
const saved = await saveProChannelKeysFor(channelId)
|
||||||
if (!saved) return
|
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('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
const e = getEntry(pm)
|
const e = getEntry(pm)
|
||||||
@@ -614,6 +656,7 @@ export function useRecorder() {
|
|||||||
.filter((x): x is string => typeof x === 'string')
|
.filter((x): x is string => typeof x === 'string')
|
||||||
.join('\n') || '已完成'
|
.join('\n') || '已完成'
|
||||||
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
||||||
|
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart')
|
||||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||||
@@ -621,7 +664,7 @@ export function useRecorder() {
|
|||||||
setLoadingAction(null)
|
setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitNewChannelDraft = useCallback(async () => {
|
const submitNewChannelDraft = useCallback(async () => {
|
||||||
@@ -637,6 +680,7 @@ export function useRecorder() {
|
|||||||
name: `频道 ${proChannelsList.length + 1}`,
|
name: `频道 ${proChannelsList.length + 1}`,
|
||||||
youtubeKey: key,
|
youtubeKey: key,
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
||||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||||
@@ -675,6 +719,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ channelId }),
|
body: JSON.stringify({ channelId }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const d = (await res.json()) as { error?: string; message?: string }
|
const d = (await res.json()) as { error?: string; message?: string }
|
||||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
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 }>(
|
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||||
bind,
|
bind,
|
||||||
'/relay_pro/channels',
|
'/relay_pro/channels',
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
@@ -878,7 +924,7 @@ export function useRecorder() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
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
|
if (!r.ok || cancelled) return
|
||||||
const j = (await r.json()) as ProRelayStatusPayload
|
const j = (await r.json()) as ProRelayStatusPayload
|
||||||
if (!cancelled) setProRelayStatus(j)
|
if (!cancelled) setProRelayStatus(j)
|
||||||
@@ -910,6 +956,7 @@ export function useRecorder() {
|
|||||||
const dr = await apiFetch(
|
const dr = await apiFetch(
|
||||||
b,
|
b,
|
||||||
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!dr.ok) continue
|
if (!dr.ok) continue
|
||||||
const cd = (await dr.json()) as {
|
const cd = (await dr.json()) as {
|
||||||
@@ -951,6 +998,7 @@ export function useRecorder() {
|
|||||||
const ur = await apiFetch(
|
const ur = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!ur.ok || cancelled) return
|
if (!ur.ok || cancelled) return
|
||||||
const j = (await ur.json()) as { content?: string }
|
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}`
|
return `${b}${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiFetchInit = RequestInit & { pbToken?: string | null }
|
||||||
|
|
||||||
export async function apiFetch(
|
export async function apiFetch(
|
||||||
bind: BindPayload | null,
|
bind: BindPayload | null,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: ApiFetchInit,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
if (!bind) {
|
if (!bind) {
|
||||||
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
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()) {
|
if (bind.token?.trim()) {
|
||||||
headers.set('Authorization', `Bearer ${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)
|
const res = await apiFetch(bind, path, init)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text()
|
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",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"generate:branding": "node ./scripts/generate-branding.mjs",
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"run:android": "expo run:android",
|
"run:android": "expo run:android",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
@@ -87,6 +87,9 @@ importers:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ~19.2.2
|
specifier: ~19.2.2
|
||||||
version: 19.2.14
|
version: 19.2.14
|
||||||
|
pngjs:
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.0.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.9.2
|
specifier: ~5.9.2
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -2571,6 +2574,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
|
||||||
engines: {node: '>=4.0.0'}
|
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:
|
postcss-value-parser@4.2.0:
|
||||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||||
|
|
||||||
@@ -6285,6 +6292,8 @@ snapshots:
|
|||||||
|
|
||||||
pngjs@3.4.0: {}
|
pngjs@3.4.0: {}
|
||||||
|
|
||||||
|
pngjs@7.0.0: {}
|
||||||
|
|
||||||
postcss-value-parser@4.2.0: {}
|
postcss-value-parser@4.2.0: {}
|
||||||
|
|
||||||
postcss@8.4.49:
|
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);
|
||||||
|
});
|
||||||
|
|
||||||