优化视频延迟
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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 的内嵌状态区展示,不弹窗。
|
||||
}
|
||||
|
||||
// ===================== 链条子组件 =====================
|
||||
|
||||
Reference in New Issue
Block a user