diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 62646408..3266c0a6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
diff --git a/lib/features/v2/device_list/presentation/pages/device_status_page.dart b/lib/features/v2/device_list/presentation/pages/device_status_page.dart
index 4a08fb01..15aa1582 100644
--- a/lib/features/v2/device_list/presentation/pages/device_status_page.dart
+++ b/lib/features/v2/device_list/presentation/pages/device_status_page.dart
@@ -35,6 +35,7 @@ import 'drone_station_detail_page.dart';
import '../../../../../core/network/mqtt/data/datasources/drone_osd_datasource.dart';
import 'qr_scanner_page.dart';
import 'bind_device_page.dart';
+import '../../../local_camera/presentation/pages/local_camera_page.dart';
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../../../devices/domain/entities/device_entity.dart';
import 'package:get_it/get_it.dart';
@@ -1138,6 +1139,20 @@ class _DeviceStatusViewState extends State {
// TODO: 添加设备功能
},
),
+ Divider(height: 1, color: ctx.appColors.divider),
+ _buildMenuOption(
+ ctx,
+ icon: Icons.videocam_outlined,
+ label: '本地视频',
+ onTap: () {
+ Navigator.pop(ctx);
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const LocalCameraPage(),
+ ),
+ );
+ },
+ ),
],
),
),
diff --git a/lib/features/v2/local_camera/data/lan_interface_selector.dart b/lib/features/v2/local_camera/data/lan_interface_selector.dart
new file mode 100644
index 00000000..29bf3ca6
--- /dev/null
+++ b/lib/features/v2/local_camera/data/lan_interface_selector.dart
@@ -0,0 +1,107 @@
+import 'dart:io';
+
+/// 局域网网卡筛选:本功能的摄像头固定通过 USB 以太网连接,
+/// 因此应当**只在以太网网卡所在的网段**做组播探测 / 网段扫描,
+/// 排除 WiFi(wlan*)、移动网络(rmnet*/ccmni* 等),既更快又不会扫到无关网段。
+class LanInterface {
+ final String name;
+ final String ip;
+ final InternetAddress address;
+
+ LanInterface(this.name, this.ip, this.address);
+
+ /// 该 IPv4 的 /24 网段前缀(如 10.113.170)。
+ String get prefix {
+ final s = ip.split('.');
+ return s.length == 4 ? '${s[0]}.${s[1]}.${s[2]}' : '';
+ }
+}
+
+/// WiFi / 移动网络 / 虚拟/环回 网卡的命名特征。
+bool _looksWifiOrCellular(String rawName) {
+ final n = rawName.toLowerCase();
+ const blockedPrefix = [
+ 'wlan',
+ 'ap',
+ 'rmnet',
+ 'ccmni',
+ 'gbr',
+ 'qrtr',
+ 'mobile',
+ 'vnet',
+ 'dummy',
+ 'lo',
+ 'tun',
+ 'tap',
+ 'ppp',
+ 'p2p',
+ 'wpan',
+ 'bt',
+ 'swlan',
+ 'utun',
+ 'ipsec',
+ 'starlink',
+ ];
+ for (final p in blockedPrefix) {
+ if (n.startsWith(p)) return true;
+ }
+ return false;
+}
+
+/// 明确的以太网 / USB 网卡命名特征。
+bool _looksEthernet(String rawName) {
+ final n = rawName.toLowerCase();
+ return n.startsWith('eth') ||
+ n.startsWith('en') ||
+ n.startsWith('usb') ||
+ n.contains('rndis') ||
+ n.contains('asix') ||
+ n.contains('ax88') ||
+ n.contains('mtk_eth') ||
+ n.contains('ether');
+}
+
+/// 选取用于探测/扫描的网卡。
+///
+/// 策略:
+/// 1) 取全部非回环 IPv4 网卡;
+/// 2) 优先返回"名字像以太网且不是 WiFi/移动"的网卡;
+/// 3) 若没识别到以太网命名(很多安卓不把网卡名暴露给 App),退回"排除 WiFi/移动"后的网卡;
+/// 4) 若仍为空,返回全部,交由上层兜底。
+/// [ethernetOnly]=false 时直接返回全部网卡。
+Future> selectLanInterfaces({bool ethernetOnly = true}) async {
+ final raw = [];
+ try {
+ final ifs = await NetworkInterface.list(
+ type: InternetAddressType.IPv4,
+ includeLoopback: false,
+ );
+ for (final i in ifs) {
+ for (final a in i.addresses) {
+ final ipStr = a.address.toString();
+ InternetAddress addr;
+ try {
+ addr = a.address is InternetAddress
+ ? a.address as InternetAddress
+ : InternetAddress(ipStr);
+ } catch (_) {
+ addr = InternetAddress(ipStr);
+ }
+ raw.add(LanInterface(i.name, ipStr, addr));
+ }
+ }
+ } catch (_) {
+ return const [];
+ }
+
+ if (!ethernetOnly) return raw;
+
+ final eth = raw
+ .where((e) => _looksEthernet(e.name) && !_looksWifiOrCellular(e.name))
+ .toList();
+ if (eth.isNotEmpty) return eth;
+
+ // 命名未识别到以太网:退回"排除 WiFi/移动"后的其余网卡(通常就是以太网适配器)
+ final others = raw.where((e) => !_looksWifiOrCellular(e.name)).toList();
+ return others.isNotEmpty ? others : raw;
+}
diff --git a/lib/features/v2/local_camera/data/onvif_client.dart b/lib/features/v2/local_camera/data/onvif_client.dart
new file mode 100644
index 00000000..d9071174
--- /dev/null
+++ b/lib/features/v2/local_camera/data/onvif_client.dart
@@ -0,0 +1,280 @@
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:crypto/crypto.dart';
+import 'package:flutter/foundation.dart';
+import 'package:xml/xml.dart';
+
+/// 轻量 ONVIF SOAP 客户端。默认账号 admin、密码为空(构造函数可覆盖)。
+/// 提供 GetDeviceInformation / GetCapabilities(Media XAddr) / GetProfiles / GetStreamUri。
+/// 认证采用 WS-Security UsernameToken + PasswordDigest。
+class OnvifClient {
+ final String username;
+ final String password;
+ final Random _rnd = Random.secure();
+
+ OnvifClient({this.username = 'admin', this.password = ''});
+
+ String _uuid() {
+ final bytes = List.generate(16, (_) => _rnd.nextInt(256));
+ final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ return 'urn:uuid:${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
+ '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
+ }
+
+ /// WS-Security UsernameToken + PasswordDigest。
+ /// 无用户名则不加安全头(匿名)。密码为空时仍按规范计算摘要(Nonce+Created+"")。
+ String _wsseHeader() {
+ if (username.isEmpty) return '';
+ final nonceBytes = List.generate(16, (_) => _rnd.nextInt(256));
+ final nonceB64 = base64.encode(nonceBytes);
+ final created = DateTime.now()
+ .toUtc()
+ .toIso8601String()
+ .replaceAll('.', '')
+ .replaceAll('+00:00', 'Z');
+ // digest = Base64(SHA1(Nonce + Created + Password))
+ final digestInput = [
+ ...nonceBytes,
+ ...utf8.encode(created),
+ ...utf8.encode(password),
+ ];
+ final passwordDigest = base64.encode(sha1.convert(digestInput).bytes);
+
+ return '''
+
+ $username
+ $passwordDigest
+ $nonceB64
+ $created
+
+ ''';
+ }
+
+ String _envelope({
+ required String serviceNs,
+ required String actionTail,
+ required String bodyInner,
+ }) {
+ final action = '$serviceNs/$actionTail';
+ return '''
+
+
+ $action
+ ${_uuid()}
+ ${_wsseHeader()}
+
+ $bodyInner
+''';
+ }
+
+ Future _post(String url, String body) async {
+ final client = HttpClient()..connectionTimeout = const Duration(seconds: 5);
+ try {
+ final req = await client.postUrl(Uri.parse(url));
+ req.headers.set(
+ HttpHeaders.contentTypeHeader,
+ 'application/soap+xml; charset=utf-8',
+ );
+ req.add(utf8.encode(body));
+ final resp = await req.close().timeout(const Duration(seconds: 8));
+ final text = await resp.transform(utf8.decoder).join();
+ debugPrint(
+ '📡 [ONVIF] POST $url → ${resp.statusCode} · ${_oneLine(text, 600)}',
+ );
+ if (resp.statusCode != 200) {
+ throw OnvifException('ONVIF 服务返回状态码 ${resp.statusCode}');
+ }
+ return text;
+ } on SocketException {
+ debugPrint('📡 [ONVIF] POST $url → 无法连接');
+ throw OnvifException('无法连接摄像头 ONVIF 服务,请检查网络');
+ } finally {
+ client.close(force: true);
+ }
+ }
+
+ static String _oneLine(String s, int max) {
+ final flat = s.replaceAll(RegExp(r'\s+'), ' ').trim();
+ return flat.length <= max ? flat : '${flat.substring(0, max)}…';
+ }
+
+ static String _firstText(XmlDocument doc, String name) {
+ for (final e in doc.findAllElements(name)) {
+ final t = e.innerText.trim();
+ if (t.isNotEmpty) return t;
+ }
+ return '';
+ }
+
+ /// 取设备厂商/型号信息(用于列表展示)。
+ Future<({String manufacturer, String model})> getDeviceInformation(
+ String xAddr,
+ ) async {
+ final body = _envelope(
+ serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
+ actionTail: 'GetDeviceInformation',
+ bodyInner:
+ '',
+ );
+ final resp = await _post(xAddr, body);
+ final doc = XmlDocument.parse(resp);
+ return (
+ manufacturer: _firstText(doc, 'Manufacturer'),
+ model: _firstText(doc, 'Model'),
+ );
+ }
+
+ /// 获取 Media 服务地址。
+ Future getMediaXAddr(String deviceXAddr) async {
+ final body = _envelope(
+ serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
+ actionTail: 'GetCapabilities',
+ bodyInner:
+ 'Media',
+ );
+ final resp = await _post(deviceXAddr, body);
+ var media = _extractMediaXAddr(resp);
+ if (media.isEmpty) {
+ // 兑底:部分摄像头忽略/不支持 Category 参数,不带 Category 再取一次
+ final allBody = _envelope(
+ serviceNs: 'http://www.onvif.org/ver10/device/wsdl',
+ actionTail: 'GetCapabilities',
+ bodyInner:
+ '',
+ );
+ final allResp = await _post(deviceXAddr, allBody);
+ media = _extractMediaXAddr(allResp);
+ }
+ if (media.isEmpty) {
+ throw OnvifException(
+ '未获取到 Media 服务地址(设备可能未实现完整 ONVIF,或 admin/空密码鉴权未通过;详见上方 📡 原始响应日志)',
+ );
+ }
+ return media;
+ }
+
+ /// 从 GetCapabilities 响应中提取 Media 服务地址(优先 Media 节点下的 XAddr)。
+ String _extractMediaXAddr(String resp) {
+ final doc = XmlDocument.parse(resp);
+ for (final media in doc.findAllElements('Media')) {
+ for (final x in media.findElements('XAddr')) {
+ final t = x.innerText.trim();
+ if (t.isNotEmpty) return t;
+ }
+ }
+ // 回退:任意 XAddr(很多设备只有 Media 一个服务地址)
+ return _firstText(doc, 'XAddr');
+ }
+
+ /// 获取第一个视频 Profile 的 token。
+ Future getFirstProfileToken(String mediaXAddr) async {
+ final body = _envelope(
+ serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
+ actionTail: 'GetProfiles',
+ bodyInner:
+ '',
+ );
+ final resp = await _post(mediaXAddr, body);
+ final doc = XmlDocument.parse(resp);
+ // Profiles 元素的 token 属性即 ProfileToken
+ for (final p in doc.findAllElements('Profiles')) {
+ final token = p.getAttribute('token');
+ if (token != null && token.isNotEmpty) return token;
+ }
+ throw OnvifException('未获取到视频 Profile');
+ }
+
+ /// 选择“分辨率最小”的视频 Profile(即子码流)以降低延迟与码率;
+ /// 摄像头只报一个 Profile 或无分辨率信息时退回第一个。
+ Future getLowLatencyProfileToken(String mediaXAddr) async {
+ final body = _envelope(
+ serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
+ actionTail: 'GetProfiles',
+ bodyInner:
+ '',
+ );
+ final resp = await _post(mediaXAddr, body);
+ final doc = XmlDocument.parse(resp);
+ String? firstToken;
+ String? bestToken;
+ int bestArea = -1;
+ for (final p in doc.findAllElements('Profiles')) {
+ final token = p.getAttribute('token');
+ if (token == null || token.isEmpty) continue;
+ firstToken ??= token;
+ final w = _firstIntIn(p, 'Width');
+ final h = _firstIntIn(p, 'Height');
+ if (w != null && h != null && w > 0 && h > 0) {
+ final area = w * h;
+ if (bestArea < 0 || area < bestArea) {
+ bestArea = area;
+ bestToken = token;
+ }
+ }
+ }
+ if (bestToken != null) {
+ debugPrint('🎚 [子码流] 选用最小分辨率 Profile(面积≈$bestArea)token=$bestToken');
+ return bestToken;
+ }
+ if (firstToken != null) return firstToken;
+ throw OnvifException('未获取到视频 Profile');
+ }
+
+ /// 在 [parent] 及其后代中找第一个可解析为 int 的 [name] 元素值。
+ int? _firstIntIn(XmlElement parent, String name) {
+ for (final e in parent.findAllElements(name)) {
+ final v = int.tryParse(e.innerText.trim());
+ if (v != null) return v;
+ }
+ return null;
+ }
+
+ /// 获取 RTSP 地址(已注入访问凭证)。
+ Future getStreamUri(String mediaXAddr, String profileToken) async {
+ final body = _envelope(
+ serviceNs: 'http://www.onvif.org/ver10/media/wsdl',
+ actionTail: 'GetStreamUri',
+ bodyInner:
+ '''
+ RTP-Unicast
+ RTSP
+ $profileToken
+ ''',
+ );
+ final resp = await _post(mediaXAddr, body);
+ final doc = XmlDocument.parse(resp);
+ final uri = _firstText(doc, 'Uri');
+ if (uri.isEmpty) throw OnvifException('未获取到 RTSP 地址');
+ return _injectCredentials(uri);
+ }
+
+ /// 一步到位:由 device xAddr 得到带凭证的 RTSP 地址。
+ Future resolveRtsp(String deviceXAddr) async {
+ final mediaXAddr = await getMediaXAddr(deviceXAddr);
+ final token = await getLowLatencyProfileToken(mediaXAddr);
+ return getStreamUri(mediaXAddr, token);
+ }
+
+ /// rtsp://ip/path -> rtsp://admin:pass@ip/path。
+ /// 关键:即便密码为空也必须保留冒号(拼成 'admin:@'),因为 video_player 的
+ /// Android 后端 ExoPlayer(Media3) 是用 Uri.getUserInfo().split(':') 解析账号/密码,
+ /// 写成 'admin@'(缺冒号)会解析不到密码字段导致 RTSP 鉴权失败。
+ String _injectCredentials(String rtsp) {
+ if (username.isEmpty) return rtsp;
+ final uri = Uri.parse(rtsp);
+ if (uri.userInfo.isNotEmpty) return rtsp; // 已带凭证
+ final cred = '$username:$password'; // 始终带冒号:空密码→'admin:'
+ return uri.replace(userInfo: cred).toString();
+ }
+}
+
+class OnvifException implements Exception {
+ final String message;
+ OnvifException(this.message);
+ @override
+ String toString() => message;
+}
diff --git a/lib/features/v2/local_camera/data/onvif_discovery_service.dart b/lib/features/v2/local_camera/data/onvif_discovery_service.dart
new file mode 100644
index 00000000..7ec1c71f
--- /dev/null
+++ b/lib/features/v2/local_camera/data/onvif_discovery_service.dart
@@ -0,0 +1,231 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:flutter/foundation.dart';
+import 'package:xml/xml.dart';
+
+import '../domain/entities/onvif_camera.dart';
+import 'lan_interface_selector.dart';
+
+/// 原生实现 ONVIF WS-Discovery。
+///
+/// 关键点:向组播 239.255.255.250:3702 发 Probe,并且**从本机每一块网卡各发一份**
+/// (外加向该网段的定向广播 x.x.x.255 各发一份)。这样无论默认路由落在 WiFi/移动
+/// 还是以太网,总有一份 Probe 会从摄像头真正所在的那块网卡出去,摄像头收到后
+/// 会用 ProbeMatch 把它的真实 device_service 地址(含正确端口/路径)回给我们,
+/// 从根上避免"退化成盲扫猜端口"。
+///
+/// 不依赖第三方 onvif 包,便于完全掌控"搜索中 / 未发现"等提示链路。
+class OnvifDiscoveryService {
+ static const String _multicastAddr = '239.255.255.250';
+ static const int _multicastPort = 3702;
+
+ final Random _rnd = Random.secure();
+
+ /// 搜索局域网摄像头。[timeout] 为收集窗口时长。
+ /// 返回去重后的设备列表(ip + xAddr)。搜索失败/超时返回空列表,不抛异常。
+ Future> discover({
+ Duration timeout = const Duration(seconds: 8),
+ }) async {
+ final found = {};
+ final completer = Completer>();
+ final sockets = <_ProbeSocket>[];
+ int rxCount = 0;
+
+ debugPrint('🔎 [ONVIF发现] 开始搜索 $_multicastAddr:$_multicastPort');
+
+ // 只挑“很可能是以太网”的网卡(本功能摄像头固定走 USB 以太网,
+ // 排除 WiFi/移动网卡,避免向无关网段发 Probe / 扫错网段)。
+ final lans = await selectLanInterfaces();
+
+ if (lans.isEmpty) {
+ debugPrint('🔎 [ONVIF发现] ⚠️ 未识别到可用网卡(以太网可能未接入)');
+ }
+ for (final l in lans) {
+ debugPrint('🔎 [ONVIF发现] 探测网卡 ${l.name} -> ${l.ip}');
+ }
+
+ final multicast = InternetAddress(_multicastAddr);
+
+ // 在指定本地网卡上开一个套接字:先试 3702(能收到以组播方式回的 ProbeMatch),
+ // 失败再回退临时端口。
+ Future openSocket(InternetAddress local) async {
+ for (final port in [_multicastPort, 0]) {
+ try {
+ final s = await RawDatagramSocket.bind(local, port);
+ s.broadcastEnabled = true;
+ s.multicastLoopback = true;
+ try {
+ s.joinMulticast(multicast);
+ } catch (e) {
+ debugPrint(
+ '🔎 [ONVIF发现] ${local.address} joinMulticast 失败: ${e.runtimeType}',
+ );
+ }
+ return s;
+ } catch (_) {
+ // 该端口不可用,尝试下一个
+ }
+ }
+ return null;
+ }
+
+ // 为每块网卡的每个 IPv4 各开一个 socket,并挂上共享监听
+ Future attach(InternetAddress local, String ipStr) async {
+ final s = await openSocket(local);
+ if (s == null) {
+ debugPrint('🔎 [ONVIF发现] ⚠️ $ipStr 上无法建立套接字');
+ return;
+ }
+ // 计算该 /24 的定向广播地址(x.x.x.255)
+ String? broadcast;
+ final seg = ipStr.split('.');
+ if (seg.length == 4) broadcast = '${seg[0]}.${seg[1]}.${seg[2]}.255';
+ sockets.add(_ProbeSocket(s, ipStr, broadcast));
+
+ s.listen((event) {
+ if (event != RawSocketEvent.read) return;
+ final dg = s.receive();
+ if (dg == null) return;
+ rxCount++;
+ try {
+ final xmlStr = utf8.decode(dg.data, allowMalformed: true);
+ final cam = _parseProbeMatch(xmlStr, dg.address.address);
+ debugPrint(
+ '🔎 [ONVIF发现] 收到回应 from ${dg.address.address} · ${dg.data.length}字节 → ${cam?.displayName ?? "未匹配到XAddrs"}',
+ );
+ if (cam != null) found[cam.ip] = cam;
+ } catch (e) {
+ debugPrint('🔎 [ONVIF发现] 报文解析失败: $e');
+ }
+ });
+ }
+
+ if (lans.isEmpty) {
+ await attach(InternetAddress.anyIPv4, InternetAddress.anyIPv4.address);
+ } else {
+ for (final l in lans) {
+ await attach(l.address, l.ip);
+ }
+ }
+
+ if (sockets.isEmpty) {
+ debugPrint('🔎 [ONVIF发现] ❌ 没有任何可用网卡套接字');
+ return const [];
+ }
+
+ final probe = _buildProbeMessage();
+ final probeBytes = utf8.encode(probe);
+
+ void sendProbe() {
+ for (final ps in sockets) {
+ try {
+ final sent = ps.socket.send(probeBytes, multicast, _multicastPort);
+ debugPrint('🔎 [ONVIF发现] 经 ${ps.ip} 发送组播 Probe 字节数=$sent');
+ } catch (e) {
+ debugPrint('🔎 [ONVIF发现] ${ps.ip} 发送组播 Probe 失败: $e');
+ }
+ // 额外向该网段定向广播一份,照顾不支持组播路由的环境
+ final bc = ps.broadcast;
+ if (bc != null) {
+ try {
+ ps.socket.send(probeBytes, InternetAddress(bc), _multicastPort);
+ debugPrint('🔎 [ONVIF发现] 经 ${ps.ip} 定向广播 Probe → $bc');
+ } catch (e) {
+ debugPrint('🔎 [ONVIF发现] ${ps.ip} 定向广播失败: $e');
+ }
+ }
+ }
+ }
+
+ sendProbe();
+ Timer(const Duration(milliseconds: 1000), sendProbe);
+ Timer(const Duration(milliseconds: 2000), sendProbe);
+
+ Timer(timeout, () {
+ for (final ps in sockets) {
+ ps.socket.close();
+ }
+ debugPrint(
+ '🔎 [ONVIF发现] 搜索结束:收到回应 $rxCount 条,发现摄像头 ${found.length} 个',
+ );
+ if (!completer.isCompleted) {
+ completer.complete(found.values.toList());
+ }
+ });
+
+ return completer.future;
+ }
+
+ String _uuid() {
+ final bytes = List.generate(16, (_) => _rnd.nextInt(256));
+ final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+ return 'urn:uuid:${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
+ '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
+ }
+
+ String _buildProbeMessage() {
+ return '''
+
+
+ http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe
+ ${_uuid()}
+ urn:schemas-xmlsoap-org:ws:2005:04:discovery
+ dn:NetworkVideoTransmitter
+
+
+''';
+ }
+
+ OnvifCamera? _parseProbeMatch(String xmlStr, String senderIp) {
+ try {
+ final doc = XmlDocument.parse(xmlStr);
+ final xAddrNodes =
+ doc
+ .findAllElements('XAddrs')
+ .map((e) => e.innerText.trim())
+ .where((t) => t.isNotEmpty)
+ .toList();
+ if (xAddrNodes.isEmpty) return null;
+
+ // XAddrs 可能是空格分隔的多地址,优先取 http
+ final allAddrs = xAddrNodes
+ .expand((s) => s.split(RegExp(r'\s+')))
+ .where((s) => s.startsWith('http'))
+ .toList();
+ final xAddr = allAddrs.isNotEmpty ? allAddrs.first : xAddrNodes.first;
+
+ final uri = Uri.parse(xAddr);
+ final ip = uri.host.isNotEmpty ? uri.host : senderIp;
+ final port = uri.hasPort ? uri.port : 80;
+
+ // 从 Scopes 里尽量提取型号(很多摄像头把 hw= / name= 放进 scope)
+ String model = '';
+ final scopes = doc
+ .findAllElements('Scopes')
+ .map((e) => e.innerText)
+ .join(' ');
+ final hwMatch = RegExp(r'hw=([^@\s]+)').firstMatch(scopes);
+ if (hwMatch != null) {
+ model = Uri.decodeComponent(hwMatch.group(1)!);
+ }
+
+ return OnvifCamera(ip: ip, port: port, xAddr: xAddr, model: model);
+ } catch (_) {
+ return null;
+ }
+ }
+}
+
+/// 一块网卡对应的探测套接字 + 该网卡身份/定向广播地址。
+class _ProbeSocket {
+ final RawDatagramSocket socket;
+ final String ip;
+ final String? broadcast;
+ _ProbeSocket(this.socket, this.ip, this.broadcast);
+}
diff --git a/lib/features/v2/local_camera/data/onvif_subnet_scanner.dart b/lib/features/v2/local_camera/data/onvif_subnet_scanner.dart
new file mode 100644
index 00000000..e638e268
--- /dev/null
+++ b/lib/features/v2/local_camera/data/onvif_subnet_scanner.dart
@@ -0,0 +1,224 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+
+import '../domain/entities/onvif_camera.dart';
+import 'onvif_client.dart';
+
+/// 网段扫描兜底发现(对标 IP Cam Viewer 的 "Network IP Scanner")。
+///
+/// 不依赖 WS-Discovery 组播,避免 Android 组播过滤 / USB 以太网组播不确定性:
+/// 取本机所在 /24,逐个 IP 直连常见 ONVIF HTTP 端口,命中后用
+/// [OnvifClient.getDeviceInformation] 有 SOAP 回包即视为 ONVIF 设备并列出。
+///
+/// **不做过严校验**:很多"没正规名字"的杂牌摄像头 DeviceInformation 会返回
+/// 空 manufacturer/model、或 GetCapabilities(Media) 未实现,但**它就是摄像头本物**——
+/// 只要 SOAP 有回包就列入结果;播放环节由 cubit 通过 RTSP 猜测表兜底。
+class OnvifSubnetScanner {
+ /// 常见 ONVIF device_service 端口(海康 80/8000/8080,大华 80/880,杂牌 8899/81 等)。
+ static const List _candidatePorts = [
+ 80,
+ 8000,
+ 8080,
+ 880,
+ 8081,
+ 8899,
+ 81,
+ 808,
+ ];
+
+ /// 常见 device_service 路径。
+ static const List _servicePaths = [
+ '/onvif/device_service',
+ '/onvif',
+ '/device_service',
+ ];
+
+ final OnvifClient client;
+
+ /// 单端口 TCP 连通超时(扫描要快,设短)。
+ final Duration connectTimeout;
+
+ /// 一次 ONVIF 校验的超时。
+ final Duration probeTimeout;
+
+ /// 并发扫描的主机数。
+ final int maxConcurrent;
+
+ OnvifSubnetScanner({
+ required this.client,
+ this.connectTimeout = const Duration(milliseconds: 350),
+ this.probeTimeout = const Duration(seconds: 3),
+ this.maxConcurrent = 40,
+ });
+
+ /// 扫描本机所在的所有 /24 网段,返回 ONVIF 摄像头候选。
+ Future> scan() async {
+ final localIps = {};
+ final prefixes = {};
+
+ try {
+ final ifs = await NetworkInterface.list(
+ type: InternetAddressType.IPv4,
+ includeLoopback: false,
+ );
+ // 摄像头固定走 USB 以太网 → 只扫以太网网卡所在网段(更快、不误捞 WiFi/移动)。
+ // 认不出以太网命名时,退成“排除 WiFi/移动后的网卡”;仍空则全部(保守兜底)。
+ var pick = ifs.where((i) => _looksEthernet(i.name)).toList();
+ if (pick.isEmpty) {
+ pick = ifs.where((i) => !_looksWifiOrCellular(i.name)).toList();
+ }
+ if (pick.isEmpty) pick = ifs;
+ // localIps 收集全部本机 IP(避免扫到自己),prefixes 只取选中网卡所在网段
+ for (final i in ifs) {
+ for (final a in i.addresses) {
+ localIps.add(a.address.toString());
+ }
+ }
+ for (final i in pick) {
+ debugPrint('🛰 [网段扫描] 采用网卡 ${i.name}');
+ for (final a in i.addresses) {
+ final parts = a.address.toString().split('.');
+ if (parts.length == 4) {
+ prefixes.add('${parts[0]}.${parts[1]}.${parts[2]}');
+ }
+ }
+ }
+ } catch (e) {
+ debugPrint('🛰 [网段扫描] ❌ 枚举本机网卡失败: $e');
+ return const [];
+ }
+
+ if (prefixes.isEmpty) {
+ debugPrint('🛰 [网段扫描] ⚠️ 未识别到本机 IPv4 网段,跳过扫描');
+ return const [];
+ }
+
+ debugPrint(
+ '🛰 [网段扫描] 本机 IP=${localIps.join(", ")},将扫描网段 ${prefixes.join(", ")}.*',
+ );
+
+ // 生成候选主机(排除本机自身)
+ final hosts = [];
+ for (final p in prefixes) {
+ for (var h = 1; h <= 254; h++) {
+ final ip = '$p.$h';
+ if (!localIps.contains(ip)) hosts.add(ip);
+ }
+ }
+
+ final results = {};
+ final stopwatch = Stopwatch()..start();
+
+ await _runPool(hosts, maxConcurrent, (ip) async {
+ final cam = await _probeHost(ip);
+ if (cam != null) {
+ results[cam.ip] = cam;
+ debugPrint('🛰 [网段扫描] ✅ 命中 $ip → ${cam.displayName}(${cam.xAddr})');
+ }
+ });
+
+ stopwatch.stop();
+ debugPrint(
+ '🛰 [网段扫描] 结束:扫描 ${hosts.length} 个 IP,用时 ${stopwatch.elapsedMilliseconds}ms,发现 ${results.length} 个 ONVIF 设备',
+ );
+ return results.values.toList();
+ }
+
+ /// 对单个 IP:并发探所有候选端口;只要任一 (port,path) 的
+ /// `GetDeviceInformation` 有 SOAP 回包,立即返回该端点作为候选。
+ /// **不再要求 Media 校验通过**,杂牌/精简 ONVIF 也能列出。
+ Future _probeHost(String ip) async {
+ final results = await Future.wait(
+ _candidatePorts.map(
+ (p) => _isTcpOpen(ip, p).then((ok) => MapEntry(p, ok)),
+ ),
+ );
+ // 按 _candidatePorts 优先顺序保留开放端口
+ final openPorts = [for (final e in results) if (e.value) e.key];
+
+ for (final port in openPorts) {
+ for (final path in _servicePaths) {
+ final xAddr = 'http://$ip:$port$path';
+ try {
+ final info = await client
+ .getDeviceInformation(xAddr)
+ .timeout(probeTimeout);
+ // 只要 SOAP 拿到 DeviceInformation(哪怕字段为空)就认作 ONVIF 设备。
+ debugPrint(
+ '🛰 [网段扫描] ✔ $ip:$port$path 应答 DeviceInformation(型号=${info.model.isEmpty ? info.manufacturer : info.model})',
+ );
+ return OnvifCamera(
+ ip: ip,
+ port: port,
+ xAddr: xAddr,
+ manufacturer: info.manufacturer,
+ model: info.model,
+ );
+ } catch (e) {
+ debugPrint('🛰 [网段扫描] ✖ $ip:$port$path: ${e.runtimeType}');
+ }
+ }
+ }
+ return null;
+ }
+
+ Future _isTcpOpen(String ip, int port) async {
+ Socket? s;
+ try {
+ s = await Socket.connect(ip, port, timeout: connectTimeout);
+ return true;
+ } catch (_) {
+ return false;
+ } finally {
+ s?.destroy();
+ }
+ }
+
+ /// 简易有界并发:同时最多 [max] 个任务。
+ Future _runPool(
+ List items,
+ int max,
+ Future Function(T) worker,
+ ) async {
+ var index = 0;
+ Future runner() async {
+ while (true) {
+ final i = index++;
+ if (i >= items.length) return;
+ await worker(items[i]);
+ }
+ }
+
+ final workers = >[];
+ for (var w = 0; w < max && w < items.length; w++) {
+ workers.add(runner());
+ }
+ await Future.wait(workers);
+ }
+
+ static bool _looksEthernet(String raw) {
+ final n = raw.toLowerCase();
+ return n.startsWith('eth') ||
+ n.startsWith('en') ||
+ n.startsWith('usb') ||
+ n.contains('rndis') ||
+ n.contains('asix') ||
+ n.contains('ax88') ||
+ n.contains('mtk_eth');
+ }
+
+ static bool _looksWifiOrCellular(String raw) {
+ final n = raw.toLowerCase();
+ const blocked = [
+ 'wlan', 'ap', 'rmnet', 'ccmni', 'gbr', 'qrtr', 'mobile',
+ 'vnet', 'dummy', 'lo', 'tun', 'tap', 'ppp', 'p2p', 'wpan',
+ 'bt', 'swlan', 'utun', 'ipsec',
+ ];
+ for (final p in blocked) {
+ if (n.startsWith(p)) return true;
+ }
+ return false;
+ }
+}
diff --git a/lib/features/v2/local_camera/data/rtsp_url_guesser.dart b/lib/features/v2/local_camera/data/rtsp_url_guesser.dart
new file mode 100644
index 00000000..3785d994
--- /dev/null
+++ b/lib/features/v2/local_camera/data/rtsp_url_guesser.dart
@@ -0,0 +1,59 @@
+/// 常见摄像头 RTSP 播放地址模板库(对标 IP Cam Viewer 的 brand templates)。
+///
+/// 大量"没有正规名字"的杂牌摄像头其实都基于同一批芯片方案
+/// (雄迈/XMEye、海康、大华、全志、君正、Reolink、Amcrest、Uniview 等),
+/// ONVIF 元信息经常为空,但 RTSP 路径是固定套路。
+/// 只要拿到 IP,按这份顺序挨个试,大概率命中。
+///
+/// 说明:
+/// - 空密码使用 `admin:@` 形式(libmpv/ffmpeg 均支持),比 `admin@` 更规范;
+/// - 端口默认 554(RTSP 标准);个别厂商用 555/10554/8554 也可扩。
+class RtspUrlGuesser {
+ /// 返回按"最可能命中 → 最不可能"排序的候选 URL。
+ ///
+ /// [user] / [password] 为空时会自动跳过带鉴权的前缀。
+ static List candidates({
+ required String ip,
+ required String user,
+ required String password,
+ int rtspPort = 554,
+ }) {
+ final auth = user.isEmpty
+ ? ''
+ : '$user:$password@'; // 空密码 → 'admin:@',兼容大多摄像头
+ final base = 'rtsp://$auth$ip:$rtspPort';
+ return [
+ // === 雄迈/XMEye/全志/君正 通用(市面绝大多数"没名字"的杂牌摄像头都是这批芯片) ===
+ '$base/onvif1',
+ '$base/11',
+ '$base/12', // 子码流
+ '$base/md0', // 单路 md0
+ '$base/md1',
+ '$base/0_0', // XM 双路编码
+ '$base/unicast',
+
+ // === 海康 Hikvision(部分老机型即使 ONVIF 不实现也能这样直连) ===
+ '$base/Streaming/Channels/101',
+ '$base/Streaming/Channels/1',
+ '$base/h264/ch1/main/av_stream',
+ '$base/MPEG4/ch1/main/av_stream',
+
+ // === 大华 Dahua ===
+ '$base/cam/realmonitor?channel=1&subtype=0',
+
+ // === 宇视 Uniview ===
+ '$base/media/video1',
+ '$base/unicast/c1/s1/live',
+
+ // === Reolink / Amcrest / Tendasee / 通用 ===
+ '$base/stream1',
+ '$base/video1',
+ '$base/live/ch00_0',
+ '$base/live',
+ '$base/1',
+
+ // === 极简兜底:根路径 ===
+ base,
+ ];
+ }
+}
diff --git a/lib/features/v2/local_camera/domain/entities/onvif_camera.dart b/lib/features/v2/local_camera/domain/entities/onvif_camera.dart
new file mode 100644
index 00000000..8c352b6f
--- /dev/null
+++ b/lib/features/v2/local_camera/domain/entities/onvif_camera.dart
@@ -0,0 +1,35 @@
+/// 局域网内通过 ONVIF WS-Discovery 发现的摄像头信息。
+class OnvifCamera {
+ final String ip;
+ final int port;
+ final String xAddr; // ONVIF device_service 地址
+ final String manufacturer;
+ final String model;
+
+ const OnvifCamera({
+ required this.ip,
+ required this.port,
+ required this.xAddr,
+ this.manufacturer = '',
+ this.model = '',
+ });
+
+ /// 列表展示:IP · 型号
+ String get displayName {
+ final m = model.isNotEmpty
+ ? model
+ : (manufacturer.isNotEmpty ? manufacturer : '未知型号');
+ return '$ip · $m';
+ }
+
+ OnvifCamera copyWith({String? manufacturer, String? model}) => OnvifCamera(
+ ip: ip,
+ port: port,
+ xAddr: xAddr,
+ manufacturer: manufacturer ?? this.manufacturer,
+ model: model ?? this.model,
+ );
+
+ @override
+ String toString() => 'OnvifCamera($ip:$port, model=$model, xAddr=$xAddr)';
+}
diff --git a/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart
new file mode 100644
index 00000000..214af95d
--- /dev/null
+++ b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart
@@ -0,0 +1,559 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:connectivity_plus/connectivity_plus.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:video_player/video_player.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../../data/onvif_client.dart';
+import '../../data/onvif_discovery_service.dart';
+import '../../data/onvif_subnet_scanner.dart';
+import '../../data/rtsp_url_guesser.dart';
+import '../../domain/entities/onvif_camera.dart';
+import 'local_camera_state.dart';
+
+/// 本地摄像头(ONVIF 发现 + RTSP 播放)核心 Cubit。
+/// 负责:网络检查 → WS-Discovery 搜索 → ONVIF 取 RTSP → video_player(ExoPlayer) 拉流播放 → 断流/失败处理。
+class LocalCameraCubit extends Cubit {
+ LocalCameraCubit({OnvifClient? client})
+ : _client = client ?? OnvifClient(username: 'admin', password: ''),
+ super(const LocalCameraState());
+
+ final OnvifDiscoveryService _discovery = OnvifDiscoveryService();
+ final OnvifClient _client;
+
+ /// 网段扫描兜底(组播失败时不依赖组播,直接 TCP 探设 ONVIF 设备)。
+ late final OnvifSubnetScanner _scanner = OnvifSubnetScanner(client: _client);
+
+ // ===== 播放器(video_player / Android=ExoPlayer)=====
+ // ExoPlayer 原生支持 RTSP(RtspMediaSource),默认走 TCP 交错、缓冲小,天然低延迟,
+ // 不需要 mpv 那套 cache/demuxer 手动调参。
+ VideoPlayerController? _videoController;
+ VoidCallback? _controllerListener;
+
+ /// 供页面渲染视频用;未就绪时为 null。
+ VideoPlayerController? get videoController => _videoController;
+
+ static const String _prefsKeyLastList = 'local_camera_last_list';
+
+ // ===== 提示语常量(严格按需求原文)=====
+ static const String msgNoNetworkEntry = '请打开手机以太网网络共享和连上设备的以太网线,才能搜索摄像头';
+ static const String msgNoNetwork = '请打开手机以太网网络共享(或连上网络),才能搜索摄像头';
+ static const String msgSearching = '正在搜索局域网内摄像头,请稍候...';
+ static const String msgNotFound =
+ '未发现摄像头。请检查:①网络已连接 ②摄像头开启ONVIF ③同一网段';
+ static const String msgDisconnected = '共享网络断开,请去检查';
+
+ StreamSubscription? _connectivitySub;
+ Timer? _openWatchdog;
+ Timer? _stallTimer;
+ bool _userStopped = false;
+
+ /// 页面进入时调用:检查网络并自动搜索。
+ Future start() async {
+ _listenConnectivity();
+ await _checkNetworkAndDiscover();
+ }
+
+ /// 链条:①网络检查 ②ONVIF发现 ③取流(GetStreamUri) ④拉流播放
+ Future _checkNetworkAndDiscover() async {
+ emit(state.copyWith(networkStep: ChainStatus.running));
+ final ok = await _isEthernetConnected();
+ if (!ok) {
+ final detail = await _describeNetworks();
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.noNetwork,
+ networkStep: ChainStatus.failed,
+ discoveryStep: ChainStatus.idle,
+ message: '$msgNoNetworkEntry\n\n当前网卡:$detail',
+ clearSelected: true,
+ clearRtsp: true,
+ showReconnect: false,
+ ),
+ );
+ return;
+ }
+ emit(state.copyWith(networkStep: ChainStatus.success));
+ await runDiscovery();
+ }
+
+ /// 链条②:WS-Discovery 搜索摄像头。
+ Future runDiscovery() async {
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.searching,
+ discoveryStep: ChainStatus.running,
+ streamStep: ChainStatus.idle,
+ playStep: ChainStatus.idle,
+ clearSelected: true,
+ clearRtsp: true,
+ message: msgSearching,
+ showReconnect: false,
+ ),
+ );
+
+ try {
+ var cams = await _discovery.discover();
+ // 组播对杂牌摄像头通常无应答 → 先快速校验上次缓存的 IP,命中即秒开,
+ // 避免每次都跑满 254×8 的网段扫描("有时扫不到 + 耗时长"的主因)。
+ if (cams.isEmpty) {
+ cams = await _validateCached(await loadCachedCameras());
+ }
+ if (cams.isEmpty) {
+ // 组播未发现 → 网段扫描兜底(对齐 IP Cam Viewer 的 IP Scanner,不吃组播/锁)
+ debugPrint('🔎 [发现] 组播未发现,尝试网段扫描兜底');
+ cams = await _scanner.scan();
+ }
+ if (cams.isEmpty) {
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.notFound,
+ discoveryStep: ChainStatus.failed,
+ cameras: const [],
+ message: msgNotFound,
+ showReconnect: false,
+ ),
+ );
+ return;
+ }
+
+ // 尽量补全型号(best-effort,失败不影响列表展示)
+ final enriched = await Future.wait(
+ cams.map((c) async {
+ if (c.model.isNotEmpty) return c;
+ try {
+ final info = await _client.getDeviceInformation(c.xAddr);
+ return c.copyWith(manufacturer: info.manufacturer, model: info.model);
+ } catch (_) {
+ return c;
+ }
+ }),
+ );
+
+ await _cacheCameras(enriched);
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.found,
+ discoveryStep: ChainStatus.success,
+ cameras: enriched,
+ message: null,
+ ),
+ );
+ } catch (e) {
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.notFound,
+ discoveryStep: ChainStatus.failed,
+ message: msgNotFound,
+ ),
+ );
+ }
+ }
+
+ /// 链条③④:选择摄像头 → 依次试候选 RTSP URL。
+ /// 候选顺序:① ONVIF 正规 GetStreamUri 结果 → ② RtspUrlGuesser 常见品牌模板。
+ Future selectCamera(OnvifCamera cam) async {
+ _userStopped = false;
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.connecting,
+ selected: cam,
+ streamStep: ChainStatus.running,
+ playStep: ChainStatus.idle,
+ clearRtsp: true,
+ clearMessage: true,
+ showReconnect: false,
+ ),
+ );
+
+ await _disposeController();
+ _armOpenWatchdog();
+
+ // 1) 先按 ONVIF 正道取流(能拿到就放到候选第一位)
+ final urls = [];
+ String? onvifUrl;
+ try {
+ onvifUrl = await _client.resolveRtsp(cam.xAddr);
+ urls.add(onvifUrl);
+ debugPrint('🎬 [拉流] ONVIF 解析成功:$onvifUrl');
+ } catch (e) {
+ debugPrint('🎬 [拉流] ONVIF 未给出 RTSP:$e');
+ }
+
+ // 2) 猜测表兜底(杂牌摄像头大概率命中这里)
+ urls.addAll(
+ RtspUrlGuesser.candidates(
+ ip: cam.ip,
+ user: _client.username,
+ password: _client.password,
+ ),
+ );
+ final dedup = urls.toSet().toList();
+ if (dedup.isEmpty) {
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.playFailed,
+ streamStep: ChainStatus.failed,
+ playStep: ChainStatus.failed,
+ message: '无可用 RTSP 地址',
+ showReconnect: true,
+ ),
+ );
+ return;
+ }
+
+ // 3) 逐个试:任一 URL 在 5s 内 playing=true 即成功
+ for (var i = 0; i < dedup.length; i++) {
+ final url = dedup[i];
+ debugPrint('🎬 [拉流] 尝试 #${i + 1}/${dedup.length}: $url');
+ final ok = await _tryPlay(url, timeout: const Duration(seconds: 5));
+ if (ok) {
+ _cancelWatchdog();
+ debugPrint('🎬 [拉流] 成功:$url');
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.playing,
+ rtspUrl: url,
+ streamStep: ChainStatus.success,
+ playStep: ChainStatus.success,
+ clearMessage: true,
+ showReconnect: false,
+ ),
+ );
+ return;
+ }
+ }
+
+ // 全部失败
+ _cancelWatchdog();
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.playFailed,
+ streamStep: onvifUrl != null
+ ? ChainStatus.success
+ : ChainStatus.failed,
+ playStep: ChainStatus.failed,
+ message: '无法拉流:已尝试 ONVIF 与 ${dedup.length} 个常见 RTSP 路径',
+ showReconnect: true,
+ ),
+ );
+ }
+
+ /// 打开单个候选 URL,[timeout] 内完成 initialize() 且首帧就绪即视为成功。
+ /// ExoPlayer 对 RTSP 直播默认走 TCP 交错、缓冲小;失败/超时则 dispose 后返回 false。
+ Future _tryPlay(String url, {required Duration timeout}) async {
+ VideoPlayerController? c;
+ try {
+ c = VideoPlayerController.networkUrl(Uri.parse(url));
+ await c.initialize().timeout(timeout);
+ // 直播流 initialize 完成即已连上并拿到分辨率;立即起播
+ await c.play();
+ // 短暂观察是否立刻报错(错误路径 / 鉴权失败会在这里暴露)
+ await Future.delayed(const Duration(milliseconds: 400));
+ if (c.value.hasError) {
+ debugPrint(
+ '🎬 [拉流] 起播即报错:${c.value.errorDescription}',
+ );
+ await c.dispose();
+ return false;
+ }
+ _videoController = c;
+ _attachControllerListener(c);
+ return true;
+ } on TimeoutException {
+ debugPrint('🎬 [拉流] 超时:$url');
+ await c?.dispose();
+ return false;
+ } catch (e) {
+ debugPrint('🎬 [拉流] open 抛错:$e');
+ await c?.dispose();
+ return false;
+ }
+ }
+
+ /// 监听正在播放的控制器:断流/报错/长时间缓冲 → 判定断开。
+ void _attachControllerListener(VideoPlayerController c) {
+ void listener() {
+ if (_userStopped || isClosed) return;
+ if (c.value.hasError) {
+ if (state.phase == LocalCameraPhase.playing) _handleDisconnected();
+ return;
+ }
+ if (state.phase == LocalCameraPhase.playing) {
+ if (c.value.isBuffering) {
+ _stallTimer?.cancel();
+ _stallTimer = Timer(const Duration(seconds: 12), _handleDisconnected);
+ } else {
+ _stallTimer?.cancel();
+ _stallTimer = null;
+ }
+ }
+ }
+ _controllerListener = listener;
+ c.addListener(listener);
+ }
+
+ /// 释放当前控制器并摘掉监听。
+ Future _disposeController() async {
+ _stallTimer?.cancel();
+ _stallTimer = null;
+ final c = _videoController;
+ _videoController = null;
+ if (c != null) {
+ if (_controllerListener != null) c.removeListener(_controllerListener!);
+ _controllerListener = null;
+ try {
+ await c.dispose();
+ } catch (_) {}
+ }
+ }
+
+ void _cancelWatchdog() {
+ _openWatchdog?.cancel();
+ _openWatchdog = null;
+ }
+
+ void _handleDisconnected() {
+ if (_userStopped || isClosed) return;
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.disconnected,
+ playStep: ChainStatus.failed,
+ message: msgDisconnected,
+ showReconnect: true,
+ ),
+ );
+ }
+
+ /// 点击"重新连接":有选中摄像头则重拉流,否则重新搜索。
+ Future reconnect() async {
+ final cam = state.selected;
+ if (cam == null) {
+ await _checkNetworkAndDiscover();
+ return;
+ }
+ await selectCamera(cam);
+ }
+
+ /// 返回摄像头列表(重新搜索)。
+ Future backToList() async {
+ _userStopped = true;
+ _cancelWatchdog();
+ _stallTimer?.cancel();
+ await _disposeController();
+ emit(
+ state.copyWith(
+ phase: state.cameras.isNotEmpty
+ ? LocalCameraPhase.found
+ : LocalCameraPhase.idle,
+ clearSelected: true,
+ clearRtsp: true,
+ streamStep: ChainStatus.idle,
+ playStep: ChainStatus.idle,
+ clearMessage: true,
+ showReconnect: false,
+ ),
+ );
+ if (state.cameras.isEmpty) {
+ await runDiscovery();
+ }
+ }
+
+ // ===== 共享网络(以太网)检查 =====
+ /// 本功能运行环境:手机需开启“以太网共享”并接上 USB‑C 转以太网,
+ /// 才能跟局域网摄像头通信。这里用"双信号取或"保证不漏检:
+ /// 1) `connectivity_plus` 直接报 `ConnectivityResult.ethernet`;
+ /// 2) 本机网卡里存在“名字像以太网 或 非 WiFi/移动”的 IPv4 接口。
+ Future _isEthernetConnected() async {
+ // 信号 1
+ try {
+ final results = await Connectivity().checkConnectivity();
+ if (results.contains(ConnectivityResult.ethernet)) {
+ debugPrint('🌐 [网络检查] connectivity_plus 报以太网 ✔');
+ return true;
+ }
+ debugPrint('🌐 [网络检查] connectivity_plus 结果:$results');
+ } catch (e) {
+ debugPrint('🌐 [网络检查] connectivity_plus 失败:$e');
+ }
+ // 信号 2
+ try {
+ final ifs = await NetworkInterface.list(
+ type: InternetAddressType.IPv4,
+ includeLoopback: false,
+ );
+ for (final i in ifs) {
+ if (_looksEthernet(i.name)) {
+ debugPrint('🌐 [网络检查] 网卡 ${i.name} 明确为以太网 ✔');
+ return true;
+ }
+ }
+ for (final i in ifs) {
+ if (!_looksWifiOrCellular(i.name) && i.addresses.isNotEmpty) {
+ debugPrint('🌐 [网络检查] 网卡 ${i.name} 非 WiFi/移动,视为以太网共享 ✔');
+ return true;
+ }
+ }
+ debugPrint('🌐 [网络检查] 未发现以太网:${_fmtIfs(ifs)}');
+ return false;
+ } catch (e) {
+ debugPrint('🌐 [网络检查] 枚举网卡失败:$e(保守放行,交给后续探测)');
+ return true;
+ }
+ }
+
+ /// 把当前网卡列成人读摘要(失败提示拼上,用户一眼看到“到底有没有插”)。
+ Future _describeNetworks() async {
+ try {
+ final ifs = await NetworkInterface.list(
+ type: InternetAddressType.IPv4,
+ includeLoopback: false,
+ );
+ if (ifs.isEmpty) return '无任何非回环 IPv4 网卡(以太网线可能未接好或共享未开)';
+ return _fmtIfs(ifs);
+ } catch (e) {
+ return '网卡枚举失败:$e';
+ }
+ }
+
+ static bool _looksEthernet(String raw) {
+ final n = raw.toLowerCase();
+ return n.startsWith('eth') ||
+ n.startsWith('en') ||
+ n.startsWith('usb') ||
+ n.contains('rndis') ||
+ n.contains('asix') ||
+ n.contains('ax88') ||
+ n.contains('mtk_eth');
+ }
+
+ static bool _looksWifiOrCellular(String raw) {
+ final n = raw.toLowerCase();
+ const prefixes = [
+ 'wlan', 'ap', 'rmnet', 'ccmni', 'gbr', 'qrtr', 'mobile',
+ 'vnet', 'dummy', 'lo', 'tun', 'tap', 'ppp', 'p2p', 'wpan',
+ 'bt', 'swlan', 'utun', 'ipsec',
+ ];
+ for (final p in prefixes) {
+ if (n.startsWith(p)) return true;
+ }
+ return false;
+ }
+
+ static String _fmtIfs(List ifs) {
+ if (ifs.isEmpty) return '无任何非回环 IPv4 网卡';
+ return ifs
+ .map(
+ (i) =>
+ '${i.name}(${i.addresses.map((a) => a.address.toString()).join(",")})',
+ )
+ .join(' | ');
+ }
+
+ void _listenConnectivity() {
+ _connectivitySub?.cancel();
+ _connectivitySub = Connectivity().onConnectivityChanged.listen(
+ (List results) {
+ // 盯以太网掉线(拔掉以太网线 / 共享网络断开),而非任意网络
+ final ethernetLost = !results.contains(ConnectivityResult.ethernet);
+ if (ethernetLost &&
+ (state.isPlaying || state.phase == LocalCameraPhase.connecting)) {
+ _handleDisconnected();
+ }
+ },
+ );
+ }
+
+ /// 快速校验上次缓存的候选:短超时逐个探活,任一 SOAP 有回包即保留。
+ /// 全部不可达则返回空,交由上层做满段扫描兜底。
+ Future> _validateCached(List cached) async {
+ if (cached.isEmpty) return const [];
+ final results = await Future.wait(
+ cached.map((c) async {
+ try {
+ await _client
+ .getDeviceInformation(c.xAddr)
+ .timeout(const Duration(milliseconds: 1200));
+ debugPrint('缓存校验命中 ${c.xAddr},跳过满段扫描');
+ return c;
+ } catch (_) {
+ return null;
+ }
+ }),
+ );
+ return results.whereType().toList();
+ }
+
+ void _armOpenWatchdog() {
+ _openWatchdog?.cancel();
+ _openWatchdog = Timer(const Duration(seconds: 15), () {
+ // 15s 仍未成功出画面
+ if (state.phase == LocalCameraPhase.connecting ||
+ state.playStep == ChainStatus.running) {
+ emit(
+ state.copyWith(
+ phase: LocalCameraPhase.playFailed,
+ playStep: ChainStatus.failed,
+ message: '连接超时,拉取视频流失败',
+ showReconnect: true,
+ ),
+ );
+ }
+ });
+ }
+
+ // ===== 本地缓存(shared_preferences)=====
+ Future _cacheCameras(List cams) async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ final list = cams
+ .map(
+ (c) => {
+ 'ip': c.ip,
+ 'port': c.port,
+ 'xAddr': c.xAddr,
+ 'manufacturer': c.manufacturer,
+ 'model': c.model,
+ },
+ )
+ .toList();
+ await prefs.setString(_prefsKeyLastList, jsonEncode(list));
+ } catch (_) {}
+ }
+
+ Future> loadCachedCameras() async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ final raw = prefs.getString(_prefsKeyLastList);
+ if (raw == null || raw.isEmpty) return const [];
+ final list = (jsonDecode(raw) as List).cast