import { useCallback, useEffect, useRef, useState } from 'react' import { View, Text, TextInput, Pressable, ScrollView, StyleSheet, Alert, Platform, } from 'react-native' import * as Clipboard from 'expo-clipboard' import { CameraView, useCameraPermissions } from 'expo-camera' import { Ionicons } from '@expo/vector-icons' import { LinearGradient } from 'expo-linear-gradient' import { SafeAreaView } from 'react-native-safe-area-context' import { useBind } from '@/context/BindContext' import { parseBindPayload, type BindPayload } from '@/lib/types' import { theme } from '@/constants/theme' function normalizeHostInput(hostInput: string): { baseUrl: string; port: number } | null { const s = hostInput.trim() if (!s) return null if (/^https?:\/\//i.test(s)) { try { const u = new URL(s) const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80)) if (!Number.isFinite(port)) return null return { baseUrl: u.origin, port } } catch { return null } } const noPath = s.split('/')[0] const i = noPath.lastIndexOf(':') if (i <= 0) return null const host = noPath.slice(0, i).trim() const port = Number(noPath.slice(i + 1)) if (!host || !Number.isFinite(port) || port <= 0) return null return { baseUrl: `http://${host}:${port}`, port } } export default function BindScreen() { const { bind, setBind, clear, ready } = useBind() const [msg, setMsg] = useState('') const [hostText, setHostText] = useState('') const [scanOn, setScanOn] = useState(false) const [perm, requestPerm] = useCameraPermissions() const scannedRef = useRef(false) useEffect(() => { if (!scanOn) scannedRef.current = false }, [scanOn]) useEffect(() => { if (!bind?.baseUrl) { setHostText('') return } const v = bind.baseUrl.replace(/^https?:\/\//i, '') setHostText(v) }, [bind?.baseUrl]) const applyPayload = useCallback( async (p: BindPayload) => { await setBind(p) setMsg('已导入绑定(含 token)。可在下方修改 IP/域名与端口后再点「保存」。') }, [setBind], ) const onBarcodeScanned = useCallback( (result: { data: string }) => { if (scannedRef.current) return const p = parseBindPayload(result.data) if (!p) { setMsg('二维码内容不是有效的绑定 JSON(需 v1、baseUrl 等字段)。') return } scannedRef.current = true void (async () => { await applyPayload(p) setScanOn(false) })() }, [applyPayload], ) const onPasteJson = useCallback(async () => { const raw = (await Clipboard.getStringAsync()).trim() const p = parseBindPayload(raw) if (!p) { setMsg('剪贴板内容不是有效的绑定 JSON。') return } await applyPayload(p) }, [applyPayload]) const onSaveHost = useCallback(async () => { const n = normalizeHostInput(hostText) if (!n) { setMsg('请输入 `ip/域名:端口`(或带 http:// 的 baseUrl)。') return } const p: BindPayload = { v: 1, baseUrl: n.baseUrl, port: n.port, token: bind?.token ?? '' } await setBind(p) setMsg('已保存绑定地址;未改动的 token 已保留。') }, [hostText, bind?.token, setBind]) const onResetHost = useCallback(async () => { setMsg('') Alert.alert('清除配对', '确定要删除当前绑定地址吗?', [ { text: '取消', style: 'cancel' }, { text: '清除', style: 'destructive', onPress: () => void clear() }, ]) }, [clear]) const copyFull = useCallback(async () => { if (!bind) return await Clipboard.setStringAsync(JSON.stringify(bind, null, 2)) setMsg('已复制完整绑定 JSON。') }, [bind]) const testConn = useCallback(async () => { if (!bind) return const url = `${bind.baseUrl}/health` setMsg('正在测试 API 连接…') const ac = new AbortController() const t = setTimeout(() => ac.abort(), 15000) try { const r = await fetch(url, { signal: ac.signal, headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined, }) const j = await r.json().catch(() => ({})) setMsg('') Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`) } catch (e) { const isAbort = e instanceof Error && e.name === 'AbortError' setMsg('') Alert.alert( isAbort ? '连接超时' : '连接失败', isAbort ? '15 秒内无响应。请确认手机能访问绑定里的 baseUrl:端口(或域名:端口),PC 上 web2 服务已启动,且防火墙/网络路由允许(换网后可能需要重新扫码或改地址)。' : e instanceof Error ? e.message : String(e), ) } finally { clearTimeout(t) } }, [bind]) const openScan = useCallback(async () => { setMsg('') if (Platform.OS === 'web') { setMsg('网页版请使用「从剪贴板导入 JSON」。') return } if (!perm?.granted) { const r = await requestPerm() if (!r.granted) { setMsg('需要相机权限才能扫码。') return } } setScanOn(true) }, [perm?.granted, requestPerm]) if (!ready) { return ( 加载中... ) } return ( 远程配对 PC 端生成二维码后扫码导入;导入后可在下方编辑 `ip/域名:端口` 再保存,token 会保留。也可仅手动填写地址(需自行保证与 PC 上 token 一致)。 {!!msg && {msg}} {bind && ( 当前已绑定 {bind.baseUrl} token:{bind.token ? '已保存' : '无'} void testConn()}> 测试 API { Alert.alert('清除配对', '确定要清除当前保存的绑定信息吗?', [ { text: '取消', style: 'cancel' }, { text: '清除', style: 'destructive', onPress: () => void clear() }, ]) }} > 清除 )} 扫码或导入 JSON 与 PC 端「远程控制」面板中的二维码内容一致(含 baseUrl、port、token)。 void openScan()}> 扫二维码 void onPasteJson()}> 从剪贴板导入 JSON {scanOn && Platform.OS !== 'web' && ( setScanOn(false)}> 关闭相机 )} 绑定地址(可编辑) 格式:`ip/域名:端口`(如 `192.168.21.222:8001`)。保存时会保留当前已存储的 token;若仅手填地址且从未扫码,请确保 token 与 PC 一致。 void onSaveHost()}> 保存 void onResetHost()}> 删除/重置 说明 推流、转码在 PC 侧执行;手机保存远程控制用的 baseUrl 与 token。 {bind && ( void copyFull()}> 复制完整绑定 JSON )} ) } const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: theme.bg }, scroll: { padding: 20, paddingBottom: 40 }, h1: { fontSize: 22, fontWeight: '700', color: theme.text, marginBottom: 8 }, lead: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 16 }, h2: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 }, body: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 10 }, block: { marginBottom: 22 }, card: { borderRadius: 12, padding: 16, marginBottom: 18, borderWidth: 1, borderColor: theme.border, }, cardTitle: { fontSize: 13, fontWeight: '600', color: theme.accent, marginBottom: 8 }, mono: { fontFamily: 'monospace', fontSize: 13, color: theme.text, marginBottom: 6 }, muted: { color: theme.muted, fontSize: 14 }, mutedSmall: { color: theme.muted, fontSize: 12, marginTop: 6 }, row: { flexDirection: 'row', gap: 10, marginTop: 12 }, etRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 }, etActions: { gap: 10, marginTop: 12 }, btn: { backgroundColor: theme.bgElevated, paddingVertical: 10, paddingHorizontal: 14, borderRadius: 8, borderWidth: 1, borderColor: theme.border, }, btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' }, btnText: { color: theme.text, fontWeight: '600', fontSize: 14 }, btnPrimary: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: '#3d7eff', paddingVertical: 14, borderRadius: 10, flex: 1, minWidth: '45%', }, btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 }, btnSecondary: { borderWidth: 1, borderColor: theme.border, borderRadius: 10, paddingVertical: 12, paddingHorizontal: 12, alignItems: 'center', backgroundColor: theme.bgCard, flex: 1, minWidth: '45%', }, btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 }, input: { minHeight: 52, borderWidth: 1, borderColor: theme.border, borderRadius: 10, padding: 12, color: theme.text, fontFamily: 'monospace', fontSize: 14, marginBottom: 12, backgroundColor: theme.bgCard, }, camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000', marginBottom: 18 }, camera: { height: 280, width: '100%' }, camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' }, msgBanner: { color: theme.success, fontSize: 14, lineHeight: 22, marginBottom: 14, padding: 12, borderRadius: 10, backgroundColor: 'rgba(61,126,255,0.12)', borderWidth: 1, borderColor: theme.border, }, })