From ca4546871c6d363e1ac59e4c914882fa2d2ecd57 Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sat, 31 Oct 2026 21:22:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(playback):=20=E4=BD=8D=E7=BD=AE=E5=9B=9E?= =?UTF-8?q?=E9=80=80=E4=BF=9D=E6=8A=A4=20+=20=E6=81=A2=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E5=B7=B2=E7=9F=A5=E4=BD=8D=E7=BD=AE=20+=20?= =?UTF-8?q?=E7=BC=93=E5=86=B2=E6=84=9F=E7=9F=A5=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对应 10/24 日志里的"播到一半中断后从头重播": 23:05:26 ffurl_read -> 从 77s 重连成功 23:05:33 再断 -> position 归 0 -> 看门狗"从 0s 继续",听感就是重新开始 - 新增 _lastKnownPosition:位置流累积,切歌归零; 所有恢复路径(看门狗 / 网络切换 / 打开失败)都用它续播 - 位置回退保护(抽成 utils/position_guard.dart): 已播 >10s 且新位置 <5s 时忽略回退,除非用户 seek / 切歌 / 正在恢复 - _scheduleOpenRetry 改为缓冲感知: 缓冲余量 >=12s 时延迟 4s 重试(不打断),否则 1.2s; 重试前若位置已推进超过 1s,判定 mpv 自愈,取消重连 - 收藏列表点击改为整队入队(setQueue,从点击项开始) 测试:新增 position_guard 6 个用例,累计 25 个全绿。 真机验证:收藏整队日志 "17 首,从第 16 首开始",播放正常。 --- lib/pages/home_page.dart | 40 ++++++++++++++++- lib/services/audio_service.dart | 64 ++++++++++++++++++++++++-- lib/utils/position_guard.dart | 20 +++++++++ test/position_guard_test.dart | 79 +++++++++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 lib/utils/position_guard.dart create mode 100644 test/position_guard_test.dart diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 8169aac..ef554b1 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -336,6 +336,44 @@ class _HomePageState extends BaseState { } } + /// 收藏列表整队入队:点哪首就从哪首开始,队列是整个收藏列表 + Future _playFavorites(int index) async { + if (_favorites.isEmpty) return; + try { + final headers = await WebDAVService.instance.getAuthHeaders(); + if (headers.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请先登录 WebDAV'), + backgroundColor: Colors.orange, + ), + ); + } + return; + } + + final songs = _favorites + .map((item) => Song( + id: item.path, + title: item.displayTitle, + artist: item.displaySubtitle, + url: WebDAVService.instance.getFileUrl(item.path), + )) + .toList(); + + if (!mounted) return; + debugPrint('⭐ [Home] 收藏整队入队:${songs.length} 首,从第 $index 首开始'); + await context.read().setQueue(songs, startIndex: index); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('播放失败: $e'), backgroundColor: Colors.red), + ); + } + } + } + // ⭐ 退出/重新登录对话框 void _showReauthDialog() { showDialog( @@ -773,7 +811,7 @@ class _HomePageState extends BaseState { if (mounted) safeSetState(() {}); }, ), - onTap: () => _playSong(song), + onTap: () => _playFavorites(index), ), ), ); diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 9473ff1..baa7257 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -13,6 +13,7 @@ import '../metadata/metadata_service.dart'; import '../database/song_database.dart'; import '../utils/artwork_helper.dart'; import '../utils/song_key.dart'; +import '../utils/position_guard.dart'; import '../repositories/playlist_repository.dart'; import '../models/playlist.dart'; import 'webdav_service.dart'; @@ -103,6 +104,15 @@ class AudioService extends ChangeNotifier { Duration _lastObservedPosition = Duration.zero; DateTime _lastProgressAt = DateTime.now(); + /// 最后已知的有效播放位置。 + /// + /// 打开失败/重连瞬间 mpv 会短暂把 position 报成 0, + /// 恢复时若读实时位置就会"从头开始"。所有恢复路径都用这个值续播。 + Duration _lastKnownPosition = Duration.zero; + + /// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的) + bool _isRecovering = false; + /// 当前歌曲开始加载的时间,用于"起播看门狗": /// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、 /// 也不发 buffering / error(实测公网链路可静默卡 75s+)。 @@ -312,6 +322,8 @@ class AudioService extends ChangeNotifier { _isChangingTrack = true; _handlingCompletion = false; _hasStartedCurrentPlayback = false; + // 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置" + _lastKnownPosition = Duration.zero; try { _playbackGeneration++; @@ -556,10 +568,29 @@ class AudioService extends ChangeNotifier { if (_openRetryGeneration == _playbackGeneration) return; _openRetryGeneration = _playbackGeneration; - debugPrint('🔁 [AudioService] 打开失败,1.5s 后自动重试'); - Timer(const Duration(milliseconds: 1500), () { + + // 缓冲余量决定重试节奏: + // 余量充足 -> 慢一点重试,期间 mpv 还有东西可播,用户不会察觉; + // 余量见底 -> 尽快重连,尽量减少静音时间。 + final bufferAhead = bufferedNotifier.value - positionNotifier.value; + final hasBuffer = bufferAhead >= const Duration(seconds: 12); + final delay = hasBuffer + ? const Duration(seconds: 4) + : const Duration(milliseconds: 1200); + final baseline = _lastKnownPosition; + + debugPrint('🔁 [AudioService] 读取出错,缓冲余量 ${bufferAhead.inSeconds}s,' + '${delay.inMilliseconds}ms 后重试'); + + Timer(delay, () { if (_currentSong == null) return; if (_isChangingTrack) return; + // mpv 可能自己接上了:位置在推进就不打断它 + if (positionNotifier.value > + baseline + const Duration(seconds: 1)) { + debugPrint('♻️ [AudioService] 播放已自行恢复,取消重连'); + return; + } _recoverFromStall(force: true); }); } @@ -584,8 +615,11 @@ class AudioService extends ChangeNotifier { } _lastStallRecoveryAt = now; - final resumeAt = positionNotifier.value; + final resumeAt = _lastKnownPosition > Duration.zero + ? _lastKnownPosition + : positionNotifier.value; debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)'); + _isRecovering = true; try { _trackStartAt = DateTime.now(); _playReadyAt = null; @@ -595,12 +629,15 @@ class AudioService extends ChangeNotifier { if (resumeAt > Duration.zero) { await PlaybackService().seek(resumeAt); positionNotifier.value = resumeAt; + _lastKnownPosition = resumeAt; } debugPrint('✅ [AudioService] 自动恢复完成'); } catch (e) { debugPrint('❌ [AudioService] 自动恢复失败: $e'); _lastPlaybackError = '缓冲卡住,自动恢复失败'; notifyListeners(); + } finally { + _isRecovering = false; } } @@ -972,6 +1009,27 @@ class AudioService extends ChangeNotifier { debugPrint('🎯 [AudioService] positionStream ignored: user seeking'); return; } + + // 位置回退保护:打开失败/重连时 mpv 会短暂报 0。 + // 非用户操作、非切歌、非恢复中时,不允许从 77s 掉回 0s, + // 否则界面进度会归零,恢复时也会"从头播"。 + if (shouldIgnorePositionRegression( + lastKnown: _lastKnownPosition, + incoming: position, + isChangingTrack: _isChangingTrack, + isRecovering: _isRecovering, + )) { + debugPrint('🛡️ [AudioService] 忽略位置回退: ' + '${_lastKnownPosition.inSeconds}s -> ${position.inSeconds}s'); + return; + } + + if (position > _lastKnownPosition || + _isChangingTrack || + _isRecovering) { + _lastKnownPosition = position; + } + // 首次拿到 >0 的播放位置:这是"真正出声"的时刻, // 与 play() 发起时间相减即可算出起播延迟。 if (!_firstAudioReported && position > Duration.zero) { diff --git a/lib/utils/position_guard.dart b/lib/utils/position_guard.dart new file mode 100644 index 0000000..137b3dd --- /dev/null +++ b/lib/utils/position_guard.dart @@ -0,0 +1,20 @@ +// lib/utils/position_guard.dart + +/// 位置回退保护。 +/// +/// 打开失败 / 重连瞬间,mpv 会短暂把 position 报成 0 或几秒。 +/// 如果直接采用,界面进度会归零,恢复时也会"从头播"。 +/// +/// 规则:只有在"已经播过一段时间"且"新位置几乎归零"时才算异常回退; +/// 用户 seek、切歌、我们自己的恢复流程都允许回退,不拦截。 +bool shouldIgnorePositionRegression({ + required Duration lastKnown, + required Duration incoming, + required bool isChangingTrack, + required bool isRecovering, +}) { + if (isChangingTrack || isRecovering) return false; + if (lastKnown <= const Duration(seconds: 10)) return false; + if (incoming >= const Duration(seconds: 5)) return false; + return (lastKnown - incoming) > const Duration(seconds: 5); +} diff --git a/test/position_guard_test.dart b/test/position_guard_test.dart new file mode 100644 index 0000000..3db9d23 --- /dev/null +++ b/test/position_guard_test.dart @@ -0,0 +1,79 @@ +// test/position_guard_test.dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:qingting_player/utils/position_guard.dart'; + +void main() { + group('shouldIgnorePositionRegression', () { + test('正常播放推进:不拦截', () { + expect( + shouldIgnorePositionRegression( + lastKnown: const Duration(seconds: 77), + incoming: const Duration(seconds: 78), + isChangingTrack: false, + isRecovering: false, + ), + isFalse, + ); + }); + + test('77s 突然归零:拦截(这就是"从头重播"的元凶)', () { + expect( + shouldIgnorePositionRegression( + lastKnown: const Duration(seconds: 77), + incoming: Duration.zero, + isChangingTrack: false, + isRecovering: false, + ), + isTrue, + ); + }); + + test('用户切歌:允许回退', () { + expect( + shouldIgnorePositionRegression( + lastKnown: const Duration(seconds: 200), + incoming: Duration.zero, + isChangingTrack: true, + isRecovering: false, + ), + isFalse, + ); + }); + + test('自动恢复中:允许回退(我们自己重开的)', () { + expect( + shouldIgnorePositionRegression( + lastKnown: const Duration(seconds: 200), + incoming: Duration.zero, + isChangingTrack: false, + isRecovering: true, + ), + isFalse, + ); + }); + + test('刚开始播放(最后位置 <10s):不拦截,避免误伤起播', () { + expect( + shouldIgnorePositionRegression( + lastKnown: const Duration(seconds: 8), + incoming: Duration.zero, + isChangingTrack: false, + isRecovering: false, + ), + isFalse, + ); + }); + + test('小幅回退(77s -> 73s):不拦截', () { + expect( + shouldIgnorePositionRegression( + lastKnown: const Duration(seconds: 77), + incoming: const Duration(seconds: 73), + isChangingTrack: false, + isRecovering: false, + ), + isFalse, + ); + }); + }); +}