From 5268adc999c782f2db405e35e8165c88e6f6bdd4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:41:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E8=AE=B0=E4=BD=8F=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E5=90=8E=E7=9A=84=E5=AE=9E=E9=99=85=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-playback-seek-position.md | 21 +++ docs/plans/PROGRESS.md | 32 +++++ .../services/playback_position_cache.dart | 14 +- lib/core/data/services/video_player_port.dart | 3 + lib/pages/player/player_logic.dart | 135 +++++++++++++----- .../playback_position_cache_test.dart | 36 +++++ test/player/player_logic_test.dart | 81 +++++++++++ test/player/player_open_detail_test.dart | 97 +++++++++---- 8 files changed, 352 insertions(+), 67 deletions(-) create mode 100644 docs/plans/2026-10-22-playback-seek-position.md diff --git a/docs/plans/2026-10-22-playback-seek-position.md b/docs/plans/2026-10-22-playback-seek-position.md new file mode 100644 index 0000000..6add3d3 --- /dev/null +++ b/docs/plans/2026-10-22-playback-seek-position.md @@ -0,0 +1,21 @@ +# Seek 后播放位置保持 + +## 目标 + +- 拖动进度条后,缓存与观看记录立即使用 seek 完成后的真实位置。 +- 拖动后立即进详情/离开也不会回退到旧进度。 +- 越界位置 clamp,锁定/票据失败不写无效缓存。 + +## 实施步骤 + +1. 增加播放位置 clamp 纯函数,缓存写入统一限制在 `0..duration`。 +2. VideoPlayerPort 暴露 seek 方法,PlayerLogic 等待 seek 完成后再读取 controller。 +3. PlayerLogic 统一当前播放快照,监听、缓存和上报共用同一份 position/duration。 +4. 增加 pending seek 屏障,saveCurrentProgress 与离开路径等待 seek 和上报完成。 +5. 补 seek 缓存、越界 clamp、seek 后进详情并返回测试。 +6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 片尾与完成逻辑继续沿用现有 controller/播放器行为。 +- 缓存仅在进程内,不改变服务端观看记录契约。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 680c60f..4ab0457 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -359,3 +359,35 @@ $ flutter test --no-pub 解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集 和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。 `pubspec.lock` 已还原,未启停服务,未操作真机。 + +## 2026-10-22 Seek 后播放位置保持 + +- 已从 `develop`(`47ca214`)创建 `feature/playback-seek-position`。 +- 新增播放位置 clamp;缓存、上报与详情返回统一使用 seek 完成后的 controller 位置。 +- PlayerLogic 增加 pending seek 屏障,saveCurrentProgress 与离开路径会等待 seek 和写入完成。 +- 监听器统一读取一次当前播放快照,避免旧位置覆盖新位置。 +- 已补 seek 目标缓存、越界 clamp、seek 后立即进详情并返回的位置测试。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/playback_position_cache_test.dart +00:00 +5: All tests passed! + +$ flutter test --no-pub test/player/player_logic_test.dart --plain-name should_cache_actual_position_after_seek_completes +00:00 +1: All tests passed! + +$ flutter test --no-pub test/player/player_open_detail_test.dart +00:00 +1: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:49 +591: All tests passed! +``` + +解读:seek 完成后缓存与上报使用实际位置,越界目标被 clamp;seek 后立即 +进详情并返回仍保持目标位置。全量静态分析零告警,591 项测试全过。 +`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/core/data/services/playback_position_cache.dart b/lib/core/data/services/playback_position_cache.dart index 6a56f1a..3825f81 100644 --- a/lib/core/data/services/playback_position_cache.dart +++ b/lib/core/data/services/playback_position_cache.dart @@ -2,6 +2,15 @@ const Duration _defaultMaxAge = Duration(hours: 12); +/// 将播放位置限制在当前媒体时长内。 +Duration clampPlaybackPosition(Duration position, Duration duration) { + if (position <= Duration.zero || duration <= Duration.zero) { + return Duration.zero; + } + if (position >= duration) return duration; + return position; +} + /// 进程内最近播放位置。 class CachedPlaybackPosition { const CachedPlaybackPosition({ @@ -37,13 +46,14 @@ class PlaybackPositionCache { required Duration position, required Duration duration, }) { - if (episodeId.isEmpty || dramaId.isEmpty || position <= Duration.zero) { + final safePosition = clampPlaybackPosition(position, duration); + if (episodeId.isEmpty || dramaId.isEmpty || safePosition <= Duration.zero) { return; } _positions[episodeId] = CachedPlaybackPosition( episodeId: episodeId, dramaId: dramaId, - position: position, + position: safePosition, duration: duration, updatedAt: _now(), ); diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index d5d4f40..2cb2b1f 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -61,6 +61,9 @@ class VideoPlayerPort implements PlayerPort { if (!_disposed && controller.value.isInitialized) await controller.pause(); } + /// 跳转到指定播放位置。 + Future seekTo(Duration position) => controller.seekTo(position); + @override Future dispose() async { if (_disposed) return; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 7dd4b30..71ffa67 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -34,6 +34,12 @@ import 'player_view.dart'; import 'widgets/drama_comments_sheet.dart'; import 'widgets/episode_picker_sheet.dart'; +typedef _PlaybackSnapshot = ({ + Duration position, + Duration duration, + bool completed, +}); + class PlayerLogic extends ChangeNotifier { PlayerLogic({ required this.history, @@ -119,6 +125,7 @@ class PlayerLogic extends ChangeNotifier { bool _deferredFeedNotifyScheduled = false; int _feedJumpSerial = 0; Future _feedUpdateFuture = Future.value(); + Future? _pendingSeek; /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 @@ -693,29 +700,24 @@ class PlayerLogic extends ChangeNotifier { var completionHandled = false; var completionProgressSaved = false; player.controller.addListener(() { - if (session.active != player || !player.controller.value.isInitialized) { - return; - } - positionCache.save( - episodeId: episode.id, - dramaId: drama.id, - position: player.controller.value.position, - duration: player.controller.value.duration, - ); - if (player.controller.value.isCompleted && !completionProgressSaved) { + if (session.active != player) return; + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return; + _cachePlaybackPosition(drama, episode, snapshot); + if (snapshot.completed && !completionProgressSaved) { completionProgressSaved = true; unawaited( queueProgress( drama, episode, - player.controller.value.duration, - duration: player.controller.value.duration, + snapshot.duration, + duration: snapshot.duration, finished: true, ), ); } if (!completionHandled && - player.controller.value.isCompleted && + snapshot.completed && session.canAutoAdvance && session.currentIndex == index) { completionHandled = true; @@ -723,7 +725,12 @@ class PlayerLogic extends ChangeNotifier { } if (!shouldReportProgress(DateTime.now())) return; unawaited( - queueProgress(drama, episode, player.controller.value.position), + queueProgress( + drama, + episode, + snapshot.position, + duration: snapshot.duration, + ), ); }); } @@ -775,27 +782,63 @@ class PlayerLogic extends ChangeNotifier { return pendingWrite; } - Future saveCurrentProgress() { + Future saveCurrentProgress() async { + final pendingSeek = _pendingSeek; + if (pendingSeek != null) await pendingSeek; + await _saveCurrentProgressNow(); + } + + Future _saveCurrentProgressNow() { final player = trackedPlayer; final episode = trackedEpisode; final drama = trackedDrama; - if (player == null || - episode == null || - drama == null || - !player.controller.value.isInitialized) { - return pendingWrite; + if (player == null || episode == null || drama == null) return pendingWrite; + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return pendingWrite; + return _persistPlaybackSnapshot(drama, episode, snapshot); + } + + _PlaybackSnapshot? _readPlaybackSnapshot(VideoPlayerPort player) { + final value = player.controller.value; + if (!value.isInitialized || value.duration <= Duration.zero) return null; + return ( + position: clampPlaybackPosition(value.position, value.duration), + duration: value.duration, + completed: value.isCompleted, + ); + } + + void _cachePlaybackPosition( + Drama drama, + Episode episode, + _PlaybackSnapshot snapshot, + ) { + if (session.isLocked || + ticketError != null || + blockedEpisodeId == episode.id) { + return; } positionCache.save( episodeId: episode.id, dramaId: drama.id, - position: player.controller.value.position, - duration: player.controller.value.duration, + position: snapshot.position, + duration: snapshot.duration, ); + } + + Future _persistPlaybackSnapshot( + Drama drama, + Episode episode, + _PlaybackSnapshot snapshot, { + bool finished = false, + }) { + _cachePlaybackPosition(drama, episode, snapshot); return queueProgress( drama, episode, - player.controller.value.position, - duration: player.controller.value.duration, + snapshot.position, + duration: snapshot.duration, + finished: finished, ); } @@ -856,23 +899,44 @@ class PlayerLogic extends ChangeNotifier { BuildContext context, VideoPlayerPort player, Duration position, + ) { + final operation = _performSeek(context, player, position); + _pendingSeek = operation; + return operation.whenComplete(() { + if (identical(_pendingSeek, operation)) _pendingSeek = null; + }); + } + + Future _performSeek( + BuildContext context, + VideoPlayerPort player, + Duration position, ) async { if (scrubbingPlayer != player || session.active != player) return; try { await session.playbackSettled; if (scrubbingPlayer != player || session.active != player) return; - await player.controller.seekTo(position); - if (session.active == player) { - // 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。 - resumeAfterSeek(); - await saveCurrentProgress(); - } + final duration = player.controller.value.duration; + final target = clampPlaybackPosition(position, duration); + if (target <= Duration.zero) return; + await player.seekTo(target); + if (session.active != player) return; + final snapshot = _readPlaybackSnapshot(player); + if (snapshot == null) return; + final entry = queue.entryAt(session.currentIndex); + final save = _persistPlaybackSnapshot( + entry.drama, + entry.episode, + snapshot, + ); + // 暂停时拖动进度后直接恢复播放,避免画面停在旧位置。 + resumeAfterSeek(); + await save; } catch (_) { if (context.mounted && session.active == player) { final l10n = AppLocalizations.of(context)!; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.seekFailed))); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(l10n.seekFailed))); } } finally { if (scrubbingPlayer == player) cancelScrub(); @@ -1013,9 +1077,8 @@ class PlayerLogic extends ChangeNotifier { await session.playbackSettled; await saveCurrentProgress(); if (!context.mounted) return; - final action = await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const MyPage())); + final action = await Navigator.of(context) + .push(MaterialPageRoute(builder: (_) => const MyPage())); if (!context.mounted) return; if (action == MyPageAction.discover) { await showDiscover(context); diff --git a/test/playback/playback_position_cache_test.dart b/test/playback/playback_position_cache_test.dart index abc1281..447a013 100644 --- a/test/playback/playback_position_cache_test.dart +++ b/test/playback/playback_position_cache_test.dart @@ -4,6 +4,42 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; void main() { + test('should_clamp_invalid_or_out_of_range_position_when_caching', () { + expect( + clampPlaybackPosition( + const Duration(seconds: -1), + const Duration(seconds: 10), + ), + Duration.zero, + ); + expect( + clampPlaybackPosition( + const Duration(seconds: 12), + const Duration(seconds: 10), + ), + const Duration(seconds: 10), + ); + expect( + clampPlaybackPosition( + const Duration(seconds: 5), + const Duration(seconds: 10), + ), + const Duration(seconds: 5), + ); + + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(minutes: 4), + duration: const Duration(minutes: 3), + ); + expect( + cache.read('episode-3', dramaId: 'drama-a')?.position, + const Duration(minutes: 3), + ); + }); + test('should_store_and_read_recent_position_when_drama_matches', () { final now = DateTime(2026, 10, 22, 12); final cache = PlaybackPositionCache(now: () => now); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 4dc1927..d16ee8d 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -218,6 +218,75 @@ void main() { logic.dispose(); await _drain(logic); }); + + testWidgets( + 'should_cache_actual_position_after_seek_completes', + (tester) async { + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (pageContext) { + context = pageContext; + return const Scaffold(body: SizedBox.expand()); + }, + ), + ), + ); + final cache = PlaybackPositionCache(); + final history = FakeWatchHistory(); + final factory = _VideoRecordingFactory(); + late PlayerLogic logic; + await tester.runAsync(() async { + logic = _logic(history, positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + }); + final player = logic.session.active! as _VideoRecordingPlayer; + logic.beginScrub(player); + + await tester.runAsync( + () => logic.seekTo( + context, + player, + const Duration(seconds: 42), + ), + ); + await tester.runAsync(() => logic.pendingWrite); + + expect( + cache.read('signal-1', dramaId: 'signal')?.position, + const Duration(seconds: 42), + ); + expect(history.writes.last, 'signal-1|42000|false'); + + logic.beginScrub(player); + await tester.runAsync( + () => logic.seekTo( + context, + player, + const Duration(minutes: 2), + ), + ); + await tester.runAsync(() => logic.pendingWrite); + + expect(player.controller.value.position, const Duration(minutes: 1)); + expect( + cache.read('signal-1', dramaId: 'signal')?.position, + const Duration(minutes: 1), + ); + expect(history.writes.last, 'signal-1|60000|false'); + logic.dispose(); + await tester.pump(); + }, + ); }); test('should_play_local_file_without_ticket_when_episode_is_downloaded', @@ -1257,6 +1326,18 @@ class _VideoRecordingPlayer extends VideoPlayerPort { @override Future pause() async {} + @override + Future seekTo(Duration position) async { + final current = controller.value; + controller.value = VideoPlayerValue( + duration: current.duration, + size: current.size, + position: position, + isInitialized: current.isInitialized, + isPlaying: current.isPlaying, + ); + } + @override Future dispose() async { if (disposed) return; diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart index 3349ace..3bbaa1e 100644 --- a/test/player/player_open_detail_test.dart +++ b/test/player/player_open_detail_test.dart @@ -48,7 +48,7 @@ void main() { channel: const MethodChannel('test/player_open_detail/pip'), ), ); - final factory = _RecordingFactory(); + final factory = _VideoRecordingFactory(); logic.attach( PlayerPage(drama: drama, initialIndex: 0), isMounted: () => true, @@ -59,15 +59,8 @@ void main() { final active = logic.session.active; final index = logic.session.currentIndex; final player = factory.created.first; - final playsBefore = player.plays; - final tracked = VideoPlayerPort(drama.episodes.first); - tracked.controller.value = const VideoPlayerValue( - duration: Duration(minutes: 5), - position: Duration(seconds: 37), - isInitialized: true, - ); logic - ..trackedPlayer = tracked + ..trackedPlayer = player ..trackedEpisode = drama.episodes.first ..trackedDrama = drama; @@ -96,22 +89,31 @@ void main() { ), ); + logic.beginScrub(player); + await logic.seekTo( + pageContext, + player, + const Duration(minutes: 1, seconds: 22), + ); + unawaited(logic.openDetail(pageContext)); await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); await _pumpSession(tester); + await tester.runAsync(() => logic.session.playbackSettled); expect(find.byType(DramaDetailPage), findsOneWidget); expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); - expect(player.pauses, 1); - expect(player.plays, playsBefore); - expect(tracked.controller.value.position, const Duration(seconds: 37)); - expect(history.writes, ['signal-1|37000|false']); + expect( + player.controller.value.position, + const Duration(minutes: 1, seconds: 22), + ); + expect(history.writes, ['signal-1|82000|false', 'signal-1|82000|false']); expect( positionCache.read('signal-1', dramaId: 'signal')?.position, - const Duration(seconds: 37), + const Duration(minutes: 1, seconds: 22), ); await tester.pageBack(); @@ -123,11 +125,14 @@ void main() { expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); - expect(player.plays, playsBefore + 1); - expect(tracked.controller.value.position, const Duration(seconds: 37)); + expect( + player.controller.value.position, + const Duration(minutes: 1, seconds: 22), + ); expect(history.writes, [ - 'signal-1|37000|false', - 'signal-1|37000|false', + 'signal-1|82000|false', + 'signal-1|82000|false', + 'signal-1|82000|false', ]); logic @@ -136,7 +141,6 @@ void main() { ..trackedDrama = null ..dispose(); await _pumpSession(tester); - await tracked.dispose(); }, ); } @@ -164,29 +168,64 @@ Future _pumpSession(WidgetTester tester) async { } } -class _RecordingFactory { - final List<_RecordingPlayer> created = []; +class _VideoRecordingFactory { + final List<_VideoRecordingPlayer> created = []; PlayerPort create(Episode episode) { - final player = _RecordingPlayer(); + final player = _VideoRecordingPlayer(episode); created.add(player); return player; } } -class _RecordingPlayer implements PlayerPort { - int plays = 0; - int pauses = 0; +class _VideoRecordingPlayer extends VideoPlayerPort { + _VideoRecordingPlayer(super.episode); @override - Future initialize() async {} + Future initialize() async { + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 5), + size: Size(1280, 720), + position: Duration(seconds: 1), + isInitialized: true, + ); + } @override - Future play() async => plays++; + Future play() async { + _setPlaying(true); + } @override - Future pause() async => pauses++; + Future pause() async { + _setPlaying(false); + } @override - Future dispose() async {} + Future seekTo(Duration position) async { + final current = controller.value; + controller.value = VideoPlayerValue( + duration: current.duration, + size: current.size, + position: position, + isInitialized: current.isInitialized, + isPlaying: current.isPlaying, + ); + } + + void _setPlaying(bool playing) { + final current = controller.value; + controller.value = VideoPlayerValue( + duration: current.duration, + size: current.size, + position: current.position, + isInitialized: current.isInitialized, + isPlaying: playing, + ); + } + + @override + Future dispose() async { + await controller.dispose(); + } }