优化视频延迟

This commit is contained in:
2026-10-13 14:39:01 +08:00
parent e4f7cc7a90
commit 68984aa7d2
2 changed files with 105 additions and 53 deletions

View File

@@ -39,19 +39,41 @@ class LocalCameraCubit extends Cubit<LocalCameraState> {
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<void> start() async {
_listenConnectivity();
@@ -263,6 +285,7 @@ class LocalCameraCubit extends Cubit<LocalCameraState> {
}
_videoController = c;
_attachControllerListener(c);
_startLagWatchdog();
return true;
} on TimeoutException {
debugPrint('🎬 [拉流] 超时:$url');
@@ -299,6 +322,7 @@ class LocalCameraCubit extends Cubit<LocalCameraState> {
/// 释放当前控制器并摘掉监听。
Future<void> _disposeController() async {
_stopLagWatchdog();
_stallTimer?.cancel();
_stallTimer = null;
final c = _videoController;
@@ -317,6 +341,55 @@ class LocalCameraCubit extends Cubit<LocalCameraState> {
_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<LocalCameraState> {
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<LocalCameraState> {
void _listenConnectivity() {
_connectivitySub?.cancel();
_connectivitySub = Connectivity().onConnectivityChanged.listen(
(List<ConnectivityResult> results) {
// 盯以太网掉线(拔掉以太网线 / 共享网络断开),而非任意网络
final ethernetLost = !results.contains(ConnectivityResult.ethernet);
if (ethernetLost &&
(state.isPlaying || state.phase == LocalCameraPhase.connecting)) {
(List<ConnectivityResult> 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<LocalCameraState> {
_userStopped = true;
_openWatchdog?.cancel();
_stallTimer?.cancel();
_lagTimer?.cancel();
_connectivitySub?.cancel();
await _disposeController();
return super.close();

View File

@@ -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<LocalCameraCubit>();
return BlocConsumer<LocalCameraCubit, LocalCameraState>(
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<LocalCameraCubit, LocalCameraState>(
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<LocalCameraCubit>().runDiscovery();
},
child: const Text('重试'),
),
],
),
);
}
// 无网络 / 未发现摄像头:统一由 _buildContent 的内嵌状态区展示,不弹窗。
}
// ===================== 链条子组件 =====================