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