From 68984aa7d2e224f44ffa186fdf290bdb661b0ef0 Mon Sep 17 00:00:00 2001 From: Songzex <2402265378@qq.com> Date: Tue, 13 Oct 2026 14:39:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=A7=86=E9=A2=91=E5=BB=B6?= =?UTF-8?q?=E8=BF=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cubit/local_camera_cubit.dart | 107 +++++++++++++++--- .../presentation/pages/local_camera_page.dart | 51 ++------- 2 files changed, 105 insertions(+), 53 deletions(-) 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 index 214af95d..8d701f06 100644 --- a/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart +++ b/lib/features/v2/local_camera/presentation/cubit/local_camera_cubit.dart @@ -39,19 +39,41 @@ class LocalCameraCubit extends Cubit { static const String _prefsKeyLastList = 'local_camera_last_list'; - // ===== 提示语常量(严格按需求原文)===== - static const String msgNoNetworkEntry = '请打开手机以太网网络共享和连上设备的以太网线,才能搜索摄像头'; - static const String msgNoNetwork = '请打开手机以太网网络共享(或连上网络),才能搜索摄像头'; + // ===== 提示语常量(本功能为以太网「一对一」直连,不依赖 WiFi/移动数据)===== + static const String msgNoNetworkEntry = '未检测到以太网网卡。请用 USB-C 转以太网适配器连上摄像头,并开启手机以太网共享'; + static const String msgNoNetwork = '未检测到以太网网卡(本功能不依赖 WiFi / 移动数据)'; static const String msgSearching = '正在搜索局域网内摄像头,请稍候...'; static const String msgNotFound = - '未发现摄像头。请检查:①网络已连接 ②摄像头开启ONVIF ③同一网段'; - static const String msgDisconnected = '共享网络断开,请去检查'; + '未发现摄像头。请检查:①以太网已连接 ②摄像头开启ONVIF ③同一网段'; + static const String msgDisconnected = '以太网连接已断开,请检查网线与适配器'; StreamSubscription? _connectivitySub; Timer? _openWatchdog; Timer? _stallTimer; bool _userStopped = false; + // ===== 直播延迟水位:**只打印,绝不据此做失败判定** ===== + // 三次实测教训(别再踩): + // 1) 对 RTSP 直播调 setPlaybackSpeed(>1.0) 会让 ExoPlayer 播放停滞:position 不再推 + // 进、数据继续进缓冲,积压从 4.4s 一路涨到 6.4s+ → 画面卡黑。直播进给速率被源 + // 钉在 1x,倍速追赶对 RtspMediaSource 根本不成立(它也不提供 LiveConfiguration)。 + // 2) 积压偏大时 dispose + 重建控制器,造成“每几秒黑屏一次”的反复黑屏。 + // 3) 拿积压大小当故障阈值会误报:bufferedEnd - position 量的是【媒体时间轴上已加载 + // 末尾与播放位置之差】。摄像头 RTP 时间戳推进速率与 ExoPlayer 播放时钟存在系统性 + // 偏差时,读数可以涨到 4000~6000ms 而【画面完全正常在播】。它不是可主观感知的 + // 延迟,绝不能拿它弹“解码跟不上推流”这种提示(曾经就这样误报过)。 + // 真断流自有 hasError 与 isBuffering 的 12s stallTimer 负责,这里只留日志看趋势。 + Timer? _lagTimer; + // 推进速率诊断:同一个积压数字可能是两种完全相反的病因—— + // pos 推进 ≪ 墙钟 → 解码/渲染停滞(播放追不上) + // buf 推进 > 墙钟 → 媒体时间轴/推流异常 + // 所以把两个量的绝对值与增量都打出来,一看日志就能定性。 + int _prevPosMs = -1; + int _prevBufMs = -1; + int _prevSampleAtMs = 0; + + static const int _lagSaneMaxMs = 30000; // 超出此值视为读数本身不可信 + /// 页面进入时调用:检查网络并自动搜索。 Future start() async { _listenConnectivity(); @@ -263,6 +285,7 @@ class LocalCameraCubit extends Cubit { } _videoController = c; _attachControllerListener(c); + _startLagWatchdog(); return true; } on TimeoutException { debugPrint('🎬 [拉流] 超时:$url'); @@ -299,6 +322,7 @@ class LocalCameraCubit extends Cubit { /// 释放当前控制器并摘掉监听。 Future _disposeController() async { + _stopLagWatchdog(); _stallTimer?.cancel(); _stallTimer = null; final c = _videoController; @@ -317,6 +341,55 @@ class LocalCameraCubit extends Cubit { _openWatchdog = null; } + // ===== 延迟水位观测 ===== + void _startLagWatchdog() { + _lagTimer?.cancel(); + _prevPosMs = -1; + _prevBufMs = -1; + _prevSampleAtMs = 0; + _lagTimer = Timer.periodic( + const Duration(seconds: 2), + (_) => _checkLagWatermark(), + ); + } + + void _stopLagWatchdog() { + _lagTimer?.cancel(); + _lagTimer = null; + } + + /// 只读不回写:“缓冲末尾 - 播放位置”= 播放器内部积压(直播时间轴从 0 起算)。 + void _checkLagWatermark() { + final c = _videoController; + if (c == null || _userStopped || isClosed) return; + if (state.phase != LocalCameraPhase.playing || !c.value.isInitialized) return; + if (c.value.buffered.isEmpty) return; + final int bufferedEndMs = c.value.buffered.last.end.inMilliseconds; + final int positionMs = c.value.position.inMilliseconds; + // 直播流的缓冲位置可能取到 C.POSITION_UNSET(Long.MIN_VALUE),经 Duration 转换后 + // 会变成荒谬值,只接受合理区间内的读数。 + if (bufferedEndMs <= 0 || positionMs < 0) return; + final int lagMs = bufferedEndMs - positionMs; + if (lagMs < 0 || lagMs > _lagSaneMaxMs) { + debugPrint('⏱ [延迟水位] 读数异常(lag=${lagMs}ms),本轮不判定'); + return; + } + final int nowMs = DateTime.now().millisecondsSinceEpoch; + final int dtMs = _prevSampleAtMs == 0 ? 0 : nowMs - _prevSampleAtMs; + final int dPos = _prevPosMs < 0 ? 0 : positionMs - _prevPosMs; + final int dBuf = _prevBufMs < 0 ? 0 : bufferedEndMs - _prevBufMs; + _prevPosMs = positionMs; + _prevBufMs = bufferedEndMs; + _prevSampleAtMs = nowMs; + debugPrint( + dtMs == 0 + ? '⏱ [延迟水位] 积压=${lagMs}ms pos=${positionMs}ms buf=${bufferedEndMs}ms' + : '⏱ [延迟水位] 积压=${lagMs}ms | ${dtMs}ms 内 pos推进=${dPos}ms · buf推进=${dBuf}ms', + ); + // 注意:此处故意不对 lagMs 做任何“失败”判定,也绝不 emit disconnected。 + // 积压读数受媒体时间轴偏差影响,画面正常时也能涨到几千毫秒,据此提示只会误报。 + } + void _handleDisconnected() { if (_userStopped || isClosed) return; emit( @@ -433,10 +506,14 @@ class LocalCameraCubit extends Cubit { static bool _looksWifiOrCellular(String raw) { final n = raw.toLowerCase(); + // WiFi / 移动数据 / 链路层虚拟接口:这些一律**不能**当成“以太网共享”, + // 否则手机只开了 WiFi/移动数据(没插以太网)也会被误判为“网络就绪”。 const prefixes = [ - 'wlan', 'ap', 'rmnet', 'ccmni', 'gbr', 'qrtr', 'mobile', - 'vnet', 'dummy', 'lo', 'tun', 'tap', 'ppp', 'p2p', 'wpan', - 'bt', 'swlan', 'utun', 'ipsec', + 'wlan', 'ap', 'swlan', 'nan', 'p2p', 'wpan', 'bt', 'bnep', + 'rmnet', 'r_rmnet', 'ccmni', 'gbr', 'qrtr', 'mobile', 'pdp', + 'cdma', 'qmcmda', 'vcc', 'radio', 'dfs', + 'vnet', 'dummy', 'lo', 'tun', 'tap', 'ppp', 'utun', 'ipsec', + 'ifb', 'br0', 'bridge', 'docker', 'virbr', 'veth', 'wg', 'zt', ]; for (final p in prefixes) { if (n.startsWith(p)) return true; @@ -457,11 +534,14 @@ class LocalCameraCubit extends Cubit { void _listenConnectivity() { _connectivitySub?.cancel(); _connectivitySub = Connectivity().onConnectivityChanged.listen( - (List results) { - // 盯以太网掉线(拔掉以太网线 / 共享网络断开),而非任意网络 - final ethernetLost = !results.contains(ConnectivityResult.ethernet); - if (ethernetLost && - (state.isPlaying || state.phase == LocalCameraPhase.connecting)) { + (List results) async { + // 本功能是手机 USB-C 转以太网「一对一」直连摄像头,跟 WiFi/移动数据无关。 + // 坑:connectivity_plus 上报的是系统「默认网络」,手机一旦开启 WiFi/移动数据, + // 默认网络被抢走,results 里就没有 ethernet —— 但以太网网卡其实还插着。 + // 所以不能凭它判掉线(否则播放中开 WiFi 就被误判断流),必须复核真实网卡。 + if (results.contains(ConnectivityResult.ethernet)) return; // 明确还在 + if (await _isEthernetConnected()) return; // 网卡仍在,只是默认网络变了 + if (state.isPlaying || state.phase == LocalCameraPhase.connecting) { _handleDisconnected(); } }, @@ -552,6 +632,7 @@ class LocalCameraCubit extends Cubit { _userStopped = true; _openWatchdog?.cancel(); _stallTimer?.cancel(); + _lagTimer?.cancel(); _connectivitySub?.cancel(); await _disposeController(); return super.close(); diff --git a/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart b/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart index 76cce3be..983158c3 100644 --- a/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart +++ b/lib/features/v2/local_camera/presentation/pages/local_camera_page.dart @@ -28,24 +28,13 @@ class _LocalCameraView extends StatefulWidget { } class _LocalCameraViewState extends State<_LocalCameraView> { - bool _noNetworkDialogShown = false; - @override Widget build(BuildContext context) { final cubit = context.read(); - return BlocConsumer( - listenWhen: (prev, cur) => prev.phase != cur.phase, - listener: (context, state) { - if (state.phase == LocalCameraPhase.noNetwork) { - if (!_noNetworkDialogShown) { - _noNetworkDialogShown = true; - _showNoNetworkDialog(context, state.message ?? LocalCameraCubit.msgNoNetwork); - } - } else { - _noNetworkDialogShown = false; - } - }, + // 不使用弹窗:没以太网 / 未发现摄像头都直接在页面内嵌状态区提示。 + // 本功能不依赖 WiFi/移动数据,不需要用模态弹窗拦住用户(更不该顺手关掉本页)。 + return BlocBuilder( builder: (context, state) { return PopScope( canPop: state.phase != LocalCameraPhase.playing && @@ -175,9 +164,15 @@ class _LocalCameraViewState extends State<_LocalCameraView> { if (controller == null || !controller.value.isInitialized) { return const ColoredBox(color: Colors.black); } + // 部分摄像头 SDP 不带分辨率,size 为 0×0 时 aspectRatio 算出 NaN, + // AspectRatio 会把子树尺寸崩成 0 → 明明在解码却表现为纯黑,故兵底回退 16:9。 + final Size size = controller.value.size; + final double ratio = size.width > 0 && size.height > 0 + ? size.width / size.height + : 16 / 9; return Center( child: AspectRatio( - aspectRatio: controller.value.aspectRatio, + aspectRatio: ratio, child: VideoPlayer(controller), ), ); @@ -304,31 +299,7 @@ class _LocalCameraViewState extends State<_LocalCameraView> { ); } - void _showNoNetworkDialog(BuildContext context, String message) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('网络未就绪'), - content: Text(message), - actions: [ - TextButton( - onPressed: () { - Navigator.of(ctx).pop(); - Navigator.of(context).pop(); // 关闭本地视频页 - }, - child: const Text('我知道了'), - ), - ElevatedButton( - onPressed: () { - Navigator.of(ctx).pop(); - context.read().runDiscovery(); - }, - child: const Text('重试'), - ), - ], - ), - ); - } + // 无网络 / 未发现摄像头:统一由 _buildContent 的内嵌状态区展示,不弹窗。 } // ===================== 链条子组件 =====================