import { useCallback, useEffect, useState, type ReactNode } from 'react'
import {
View,
Text,
ScrollView,
StyleSheet,
RefreshControl,
Pressable,
TextInput,
Switch,
Linking,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import type { NetworkPayload, SiteInfo, SpeedBlock } from '@/lib/pcTypes'
import { theme } from '@/constants/theme'
const EMPTY_SITE: SiteInfo = {
host: '',
reachable: false,
latency_ms: -1,
error: null,
resolved_ip: null,
country: null,
country_code: null,
geo_error: null,
}
const EMPTY_SPEED: SpeedBlock = {
label: '',
download_mbps: null,
download_error: null,
download_bytes_sampled: 0,
upload_mbps: null,
upload_error: null,
upload_target: null,
}
const DEFAULT_NET: NetworkPayload = {
google: EMPTY_SITE,
douyin: EMPTY_SITE,
routes: { different: null, note: '' },
speed: { overseas: EMPTY_SPEED, domestic: EMPTY_SPEED },
}
function fmtMbps(v: number | null | undefined): string {
if (v == null || Number.isNaN(v)) return '—'
return `${v.toFixed(2)} Mbps`
}
type ProxyState = { enabled: boolean; http: string; socks: string }
function SiteCard({
title,
site,
speed,
loading,
variant,
onSolution,
}: {
title: string
site: SiteInfo
speed: SpeedBlock
loading: boolean
variant: 'google' | 'douyin'
onSolution?: () => void
}) {
const ok = site.reachable
let badge: ReactNode
if (loading) {
badge = 检测中
} else if (ok) {
badge = 可访问
} else if (variant === 'google' && onSolution) {
badge = (
查看解决方案
)
} else {
badge = 不可访问
}
return (
{title}
{badge}
延迟
{loading
? '—'
: site.latency_ms != null && site.latency_ms >= 0
? `${site.latency_ms.toFixed(0)} ms`
: '—'}
下行(估算)
{loading ? '—' : fmtMbps(speed.download_mbps)}
上行(估算)
{loading ? '—' : fmtMbps(speed.upload_mbps)}
解析 IP
{loading ? '—' : site.resolved_ip ?? '—'}
地区(参考)
{loading ? '—' : site.country ?? '—'}
{!loading && site.country_code ? ` (${site.country_code})` : ''}
{!loading && (site.error || site.geo_error) ? (
{[site.error, site.geo_error].filter(Boolean).join(' · ')}
) : null}
)
}
export default function NetworkScreen() {
const { bind } = useBind()
const [data, setData] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(false)
const [proxy, setProxy] = useState({ enabled: false, http: '', socks: '' })
const [proxyMsg, setProxyMsg] = useState('')
const [savingProxy, setSavingProxy] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const loadNet = useCallback(async () => {
if (!bind) {
setErr(null)
setData(null)
return
}
setErr(null)
setLoading(true)
try {
const r = await apiFetch(bind, '/network_status')
const json = (await r.json()) as NetworkPayload & { error?: string }
if (!r.ok) {
setErr(json.error || `请求失败 ${r.status}`)
setData(null)
return
}
setData(json)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setData(null)
} finally {
setLoading(false)
}
}, [bind])
const loadProxy = useCallback(async () => {
if (!bind) return
setProxyMsg('')
try {
const pr = await apiFetch(bind, '/proxy_config')
if (pr.ok) {
const p = (await pr.json()) as ProxyState
setProxy({
enabled: !!p.enabled,
http: String(p.http || ''),
socks: String(p.socks || ''),
})
}
} catch {
/* ignore */
}
}, [bind])
const saveProxy = useCallback(async () => {
if (!bind) {
setProxyMsg('请先配对')
return
}
setSavingProxy(true)
setProxyMsg('')
try {
const res = await apiFetch(bind, '/proxy_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(proxy),
})
if (!res.ok) throw new Error(String(res.status))
setProxyMsg('已保存。重启直播任务后生效。')
} catch (e) {
setProxyMsg(e instanceof Error ? e.message : String(e))
} finally {
setSavingProxy(false)
}
}, [bind, proxy])
const loadAll = useCallback(async () => {
await loadNet()
await loadProxy()
}, [loadNet, loadProxy])
useEffect(() => {
void loadAll()
}, [loadAll])
useEffect(() => {
if (!bind) return
const id = setInterval(() => void loadNet(), 45000)
return () => clearInterval(id)
}, [bind, loadNet])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await loadAll()
setRefreshing(false)
}, [loadAll])
const view = data ?? DEFAULT_NET
const openSolution = useCallback(() => {
void Linking.openURL('https://nomadro.com/')
}, [])
return (
void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
网络
连通与测速(与桌面端「网络仪表盘」同源)。
{!bind && (
未绑定 PC:配对后可检测 Google / 抖音 与测速。
)}
网络仪表盘
void loadNet()} disabled={!bind || loading}>
{loading ? '检测中…' : '重新检测'}
{err && bind ? {err} : null}
{view.routes?.note ? (
{view.routes.note}
) : null}
录制专用代理
仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改系统代理。与桌面端「录制专用代理」一致。
启用仅录制进程使用的代理
setProxy((p) => ({ ...p, enabled: v }))}
/>
HTTP 代理
setProxy((p) => ({ ...p, http: t }))}
placeholder="http://127.0.0.1:7890"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
SOCKS5
setProxy((p) => ({ ...p, socks: t }))}
placeholder="socks5://127.0.0.1:7890"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
void saveProxy()}
>
{savingProxy ? '保存中…' : '保存'}
void loadProxy()}>
重新读取
{!!proxyMsg && {proxyMsg}}
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 16, paddingBottom: 40 },
h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 },
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(124,92,255,0.08)',
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
cardNet: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.28)',
marginBottom: 14,
},
cardProxy: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(61,214,140,0.22)',
marginBottom: 14,
},
cardHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text },
btnSec: {
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 },
btnPri: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 10,
backgroundColor: '#3d7eff',
},
btnPriText: { color: '#fff', fontWeight: '700' },
btnDisabled: { opacity: 0.5 },
err: { color: theme.danger, marginBottom: 10, fontSize: 13 },
ok: { color: theme.success, marginTop: 8, fontSize: 13 },
siteRow: { gap: 12 },
siteCard: {
padding: 12,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
marginBottom: 4,
},
siteHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 },
siteTitle: { fontSize: 14, fontWeight: '600', color: theme.text, flex: 1 },
badgePending: { fontSize: 11, fontWeight: '700', color: theme.muted },
badgeOk: { fontSize: 11, fontWeight: '700', color: theme.success },
badgeBad: { fontSize: 11, fontWeight: '700', color: theme.danger },
badgeLink: { fontSize: 11, fontWeight: '700', color: theme.accent, textDecorationLine: 'underline' },
kvCol: { gap: 6 },
kvRow: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 8 },
dim: { opacity: 0.72 },
k: { fontSize: 12, color: theme.muted, fontWeight: '600', marginTop: 4 },
v: { fontSize: 13, color: theme.text },
mono: { fontFamily: 'monospace', fontSize: 12 },
siteErr: { fontSize: 11, color: theme.warn, marginTop: 8, lineHeight: 16 },
routesNote: { fontSize: 12, color: theme.muted, marginTop: 10, lineHeight: 18 },
desc: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 12 },
rowBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 },
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
input: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
marginBottom: 12,
backgroundColor: theme.bg,
},
inputDisabled: { opacity: 0.6 },
row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap', marginTop: 4 },
})