From 759a02375ab2fc0209d0594df49278e0fa0d2687 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:27:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=BE=80=E8=BF=94=E6=92=AD=E6=94=BE=E4=BD=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-10-22-playback-position-continuity.md | 32 +++++++ docs/plans/PROGRESS.md | 33 +++++++ lib/app.dart | 7 ++ .../services/playback_position_cache.dart | 79 ++++++++++++++++ .../drama_detail/drama_detail_binding.dart | 3 + .../drama_detail/drama_detail_logic.dart | 20 ++++ lib/pages/drama_detail/drama_detail_view.dart | 2 + lib/pages/player/player_binding.dart | 3 + lib/pages/player/player_logic.dart | 55 ++++++++++- lib/pages/player/player_view.dart | 2 + .../drama_detail_first_frame_test.dart | 2 + .../drama_detail/drama_detail_logic_test.dart | 29 ++++++ test/drama_detail/drama_detail_view_test.dart | 24 +++++ .../playback_position_cache_test.dart | 69 ++++++++++++++ test/player/player_logic_test.dart | 94 +++++++++++++++++++ test/player/player_open_detail_test.dart | 15 ++- 16 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 docs/plans/2026-10-22-playback-position-continuity.md create mode 100644 lib/core/data/services/playback_position_cache.dart create mode 100644 test/playback/playback_position_cache_test.dart diff --git a/docs/plans/2026-10-22-playback-position-continuity.md b/docs/plans/2026-10-22-playback-position-continuity.md new file mode 100644 index 0000000..40bb925 --- /dev/null +++ b/docs/plans/2026-10-22-playback-position-continuity.md @@ -0,0 +1,32 @@ +# 播放位置连续性 + +## 目标 + +- 首页进入详情、详情返回首页、详情再进播放器时保持同一集同一秒。 +- 新增进程内最近播放位置缓存,优先于服务端旧记录。 +- 跨剧、跨集不串位;锁定/票据失败不因缓存强行起播;进程重启仍回服务端。 + +## 实施步骤 + +1. 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId/position/duration/updatedAt。 +2. App Provider 注入单一缓存实例,PlayerPage 与详情页共享。 +3. PlayerLogic 进度监听、saveCurrentProgress 写入缓存;loadResumePoint 优先命中缓存。 +4. preparePlayer 对每个待准备集按 dramaId 校验缓存,并继续使用现有位置钳位。 +5. 详情页续看状态优先使用该剧最新缓存,未命中再请求服务端。 +6. 补缓存、缓存命中不请求服务端、跨剧隔离、服务端回落和详情返回位置测试。 +7. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 缓存仅保存在进程内,App 重启后自动回落到服务端观看记录。 +- 片尾附近继续复用现有 `clampResumePosition` 完成保护。 + +## 执行结果 + +- 新增进程内按集缓存并通过 Provider 全局共享,详情页与播放器共用。 +- 进度监听、`saveCurrentProgress` 和进入详情前会写入缓存。 +- `loadResumePoint` 优先缓存,未命中或跨剧时才请求服务端。 +- 详情页继续观看与 CTA 优先进缓存集;返回首页保持原播放器与位置。 +- 已覆盖缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落和详情往返。 +- `dart analyze lib test` 零告警,`flutter test --no-pub` 589 项全过。 +- 未启停服务,未操作真机。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index f014d7e..680c60f 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -326,3 +326,36 @@ $ flutter test --no-pub 解读:用户暂停恢复、过期票据重取、locked/ticketError/浮层阻断及后台保存暂停 均通过;全量静态分析零告警,580 项测试全过。`pubspec.lock` 已还原, 未启停服务,未操作真机。 + +## 2026-10-22 播放位置连续性 + +- 已从 `develop`(`fe49c6d`)创建 `feature/playback-position-continuity`。 +- 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId、position、duration、updatedAt。 +- App Provider 注入单一缓存实例,PlayerLogic 与详情页共享。 +- 进度监听、保存进度与进入详情前更新缓存;loadResumePoint 优先缓存,未命中再回源。 +- 详情页继续观看与 CTA 优先使用最近缓存集,返回首页保持当前播放器位置。 +- 跨剧缓存不命中;片尾保护继续复用 `clampResumePosition`。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/playback_position_cache_test.dart +00:00 +4: All tests passed! + +$ flutter test --no-pub test/player/player_logic_test.dart +00:00 +38: 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:53 +589: All tests passed! +``` + +解读:缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落、详情 CTA 缓存集 +和详情往返同秒位置均通过;全量静态分析零告警,589 项测试全过。 +`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/app.dart b/lib/app.dart index 3a6393f..1ae476e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -42,6 +42,7 @@ import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; import 'core/data/services/locale_controller.dart'; import 'core/data/services/event_tracker.dart'; +import 'core/data/services/playback_position_cache.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; import 'core/data/services/wallet_controller.dart'; @@ -72,6 +73,7 @@ class LumaDramaApp extends StatefulWidget { this.watchHistoryRepository, this.favoriteRepository, this.playbackRepository, + this.playbackPositionCache, this.commentRepository, this.interactionRepository, this.noticeRepository, @@ -91,6 +93,7 @@ class LumaDramaApp extends StatefulWidget { final WatchHistoryRepository? watchHistoryRepository; final FavoriteRepository? favoriteRepository; final PlaybackRepository? playbackRepository; + final PlaybackPositionCache? playbackPositionCache; final CommentRepository? commentRepository; final InteractionRepository? interactionRepository; final NoticeRepository? noticeRepository; @@ -146,6 +149,9 @@ class _LumaDramaAppState extends State { widget.playbackRepository ?? HttpPlaybackRepository(PlaybackApi(_apiClient)); + late final PlaybackPositionCache _playbackPositionCache = + widget.playbackPositionCache ?? PlaybackPositionCache(); + late final FavoriteController _favoriteController = FavoriteController( _favorites, ); @@ -240,6 +246,7 @@ class _LumaDramaAppState extends State { Provider(create: (_) => widget.playerFactory), Provider.value(value: _catalogRepository), Provider.value(value: _playback), + Provider.value(value: _playbackPositionCache), Provider.value(value: _watchHistory), Provider.value(value: _favorites), Provider.value(value: _commentRepository), diff --git a/lib/core/data/services/playback_position_cache.dart b/lib/core/data/services/playback_position_cache.dart new file mode 100644 index 0000000..6a56f1a --- /dev/null +++ b/lib/core/data/services/playback_position_cache.dart @@ -0,0 +1,79 @@ +// Author: fruit.com.cn + +const Duration _defaultMaxAge = Duration(hours: 12); + +/// 进程内最近播放位置。 +class CachedPlaybackPosition { + const CachedPlaybackPosition({ + required this.episodeId, + required this.dramaId, + required this.position, + required this.duration, + required this.updatedAt, + }); + + final String episodeId; + final String dramaId; + final Duration position; + final Duration duration; + final DateTime updatedAt; +} + +/// 按集缓存最近播放位置,仅当前进程有效。 +class PlaybackPositionCache { + PlaybackPositionCache({ + DateTime Function()? now, + this.maxAge = _defaultMaxAge, + }) : _now = now ?? DateTime.now; + + final DateTime Function() _now; + final Duration maxAge; + final Map _positions = {}; + + /// 保存最近有效播放位置;零位置不会覆盖已有记录。 + void save({ + required String episodeId, + required String dramaId, + required Duration position, + required Duration duration, + }) { + if (episodeId.isEmpty || dramaId.isEmpty || position <= Duration.zero) { + return; + } + _positions[episodeId] = CachedPlaybackPosition( + episodeId: episodeId, + dramaId: dramaId, + position: position, + duration: duration, + updatedAt: _now(), + ); + } + + /// 读取同剧同集且未过期的最近位置。 + CachedPlaybackPosition? read(String episodeId, {required String dramaId}) { + final entry = _positions[episodeId]; + if (!_isValid(entry, dramaId)) return null; + return entry; + } + + /// 读取指定短剧最近更新的有效位置。 + CachedPlaybackPosition? latestForDrama(String dramaId) { + CachedPlaybackPosition? latest; + for (final entry in _positions.values) { + if (!_isValid(entry, dramaId)) continue; + if (latest == null || !entry.updatedAt.isBefore(latest.updatedAt)) { + latest = entry; + } + } + return latest; + } + + bool _isValid(CachedPlaybackPosition? entry, String dramaId) { + if (entry == null || + entry.dramaId != dramaId || + entry.position <= Duration.zero) { + return false; + } + return _now().difference(entry.updatedAt) <= maxAge; + } +} diff --git a/lib/pages/drama_detail/drama_detail_binding.dart b/lib/pages/drama_detail/drama_detail_binding.dart index 060dda3..4edaccd 100644 --- a/lib/pages/drama_detail/drama_detail_binding.dart +++ b/lib/pages/drama_detail/drama_detail_binding.dart @@ -4,6 +4,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import 'drama_detail_logic.dart'; class DramaDetailBinding { @@ -12,10 +13,12 @@ class DramaDetailBinding { required WatchHistoryRepository history, required PlaybackRepository playback, required FavoriteController favorites, + required PlaybackPositionCache positionCache, }) => DramaDetailLogic( drama: drama, history: history, playback: playback, favorites: favorites, + positionCache: positionCache, ); } diff --git a/lib/pages/drama_detail/drama_detail_logic.dart b/lib/pages/drama_detail/drama_detail_logic.dart index 42c0a73..82d7536 100644 --- a/lib/pages/drama_detail/drama_detail_logic.dart +++ b/lib/pages/drama_detail/drama_detail_logic.dart @@ -11,6 +11,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/wallet_controller.dart'; import '../player/player_view.dart'; @@ -20,6 +21,7 @@ class DramaDetailLogic extends ChangeNotifier { required this.history, required this.playback, required this.favorites, + required this.positionCache, }); /// 先用列表卡片渲染,取到详情后换成带完整剧集的版本。 @@ -27,6 +29,7 @@ class DramaDetailLogic extends ChangeNotifier { final WatchHistoryRepository history; final PlaybackRepository playback; final FavoriteController favorites; + final PlaybackPositionCache positionCache; bool loading = false; bool failed = false; @@ -71,6 +74,23 @@ class DramaDetailLogic extends ChangeNotifier { } Future loadContinue() async { + final cached = positionCache.latestForDrama(drama.id); + if (cached != null) { + final index = _indexOf(cached.episodeId); + if (index != null) { + progress = WatchProgress( + dramaId: cached.dramaId, + episodeId: cached.episodeId, + position: cached.position, + duration: cached.duration, + updatedAt: cached.updatedAt, + ); + continueIndex = index; + progressError = null; + notify(); + return; + } + } try { final record = await history.progressFor(drama.id); if (_disposed) return; diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index de2ae86..f3bcb48 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -12,6 +12,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/platform_share_service.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; @@ -64,6 +65,7 @@ class _DramaDetailPageState extends State { history: context.read(), playback: context.read(), favorites: context.read(), + positionCache: context.read(), ); _load(); } diff --git a/lib/pages/player/player_binding.dart b/lib/pages/player/player_binding.dart index 088245a..21ae3bc 100644 --- a/lib/pages/player/player_binding.dart +++ b/lib/pages/player/player_binding.dart @@ -4,6 +4,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/download_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/wallet_controller.dart'; import 'player_logic.dart'; @@ -11,11 +12,13 @@ class PlayerBinding { PlayerLogic create({ required WatchHistoryRepository history, required PlaybackRepository playback, + required PlaybackPositionCache positionCache, WalletController? wallet, DownloadController? downloads, }) => PlayerLogic( history: history, playback: playback, + positionCache: positionCache, pictureInPicture: AndroidPictureInPicture(), wallet: wallet, downloads: downloads, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 0e3618d..7dd4b30 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -17,6 +17,7 @@ import '../../core/data/services/android_picture_in_picture.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/platform_share_service.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; @@ -40,8 +41,10 @@ class PlayerLogic extends ChangeNotifier { required this.pictureInPicture, this.wallet, this.downloads, + PlaybackPositionCache? positionCache, Future Function()? waitForFrame, - }) : _waitForFrame = + }) : positionCache = positionCache ?? PlaybackPositionCache(), + _waitForFrame = waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame); final WatchHistoryRepository history; @@ -49,6 +52,7 @@ class PlayerLogic extends ChangeNotifier { final AndroidPictureInPicture pictureInPicture; final WalletController? wallet; final DownloadController? downloads; + final PlaybackPositionCache positionCache; final Future Function() _waitForFrame; late PageController pages; @@ -351,9 +355,22 @@ class PlayerLogic extends ChangeNotifier { /// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。 Future loadResumePoint() async { - final dramaId = queue.entryAt(initialIndex).drama.id; + final entry = queue.entryAt(initialIndex); + final cached = positionCache.read( + entry.episode.id, + dramaId: entry.drama.id, + ); + if (cached != null) { + resumePoint = ResumePoint( + episodeId: cached.episodeId, + position: cached.position, + ); + resumeLoaded = true; + if (isMounted()) notify(); + return; + } try { - resumePoint = await history.resumePointFor(dramaId); + resumePoint = await history.resumePointFor(entry.drama.id); } catch (_) { // 拿不到续看位置不影响播放:从当前集开头看 resumePoint = null; @@ -639,6 +656,18 @@ class PlayerLogic extends ChangeNotifier { await pendingWrite; // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 if (resumePoint == null) await loadResumePoint(); + if (resumePositionFor(episode.id) == null) { + final dramaId = _dramaIdForEpisode(episode.id); + final cached = dramaId == null + ? null + : positionCache.read(episode.id, dramaId: dramaId); + if (cached != null) { + resumePoint = ResumePoint( + episodeId: cached.episodeId, + position: cached.position, + ); + } + } final saved = resumePositionFor(episode.id); if (saved != null) { await player.controller.seekTo( @@ -667,6 +696,12 @@ class PlayerLogic extends ChangeNotifier { 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) { completionProgressSaved = true; unawaited( @@ -750,6 +785,12 @@ class PlayerLogic extends ChangeNotifier { !player.controller.value.isInitialized) { return pendingWrite; } + positionCache.save( + episodeId: episode.id, + dramaId: drama.id, + position: player.controller.value.position, + duration: player.controller.value.duration, + ); return queueProgress( drama, episode, @@ -851,6 +892,7 @@ class PlayerLogic extends ChangeNotifier { Future openDetail(BuildContext context) async { session.setOverlayOpen(true); + await saveCurrentProgress(); try { if (!context.mounted) return; await Navigator.of(context).push( @@ -865,6 +907,13 @@ class PlayerLogic extends ChangeNotifier { await saveCurrentProgress(); } + String? _dramaIdForEpisode(String episodeId) { + for (final entry in queue.entries) { + if (entry.episode.id == episodeId) return entry.drama.id; + } + return null; + } + Future openSeriesAt( BuildContext context, Drama drama, diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 4a7807e..27f036f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -11,6 +11,7 @@ import '../../core/data/repositories/comment_repository.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; @@ -119,6 +120,7 @@ class _PlayerPageState extends State logic = PlayerBinding().create( history: context.read(), playback: context.read(), + positionCache: context.read(), wallet: widget.wallet, downloads: widget.downloads, ); diff --git a/test/drama_detail/drama_detail_first_frame_test.dart b/test/drama_detail/drama_detail_first_frame_test.dart index 91b769e..68a5e78 100644 --- a/test/drama_detail/drama_detail_first_frame_test.dart +++ b/test/drama_detail/drama_detail_first_frame_test.dart @@ -14,6 +14,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; @@ -102,6 +103,7 @@ Future _pumpDetail( value: InMemoryWatchHistoryRepository(), ), Provider.value(value: FakePlaybackRepository()), + Provider(create: (_) => PlaybackPositionCache()), ChangeNotifierProvider.value(value: favorites), ChangeNotifierProvider( create: (_) => CatalogController(catalog)..load(), diff --git a/test/drama_detail/drama_detail_logic_test.dart b/test/drama_detail/drama_detail_logic_test.dart index 97aa4f1..8249304 100644 --- a/test/drama_detail/drama_detail_logic_test.dart +++ b/test/drama_detail/drama_detail_logic_test.dart @@ -1,9 +1,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_logic.dart'; import '../support/fake_watch_history.dart'; @@ -13,12 +15,14 @@ DramaDetailLogic _logic( Drama drama, FakeWatchHistory history, { FavoriteController? favorites, + PlaybackPositionCache? positionCache, }) => DramaDetailLogic( drama: drama, history: history, playback: FakePlaybackRepository(), favorites: favorites ?? FavoriteController(InMemoryFavoriteRepository()), + positionCache: positionCache ?? PlaybackPositionCache(), ); Drama _card(String id) => Drama( @@ -103,6 +107,31 @@ void main() { logic.dispose(); }); + test('should_prefer_recent_local_position_when_loading_continue', () async { + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-3', + dramaId: 'signal', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-1', + position: Duration(seconds: 5), + duration: Duration(minutes: 3), + ); + final logic = _logic(_detail('signal'), history, positionCache: cache); + + await logic.load(() async => _detail('signal')); + + expect(logic.continueIndex, 2); + expect(logic.progress?.position, const Duration(seconds: 80)); + expect(history.reads, isEmpty); + logic.dispose(); + }); + test('should_adjust_favorite_count_only_after_successful_changes', () async { final repository = InMemoryFavoriteRepository(cards: [_favoriteDrama]); final favorites = FavoriteController(repository); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 0123304..0df97ea 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -27,6 +27,7 @@ import 'package:fruitshorts/core/data/services/auth_session_store.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/wallet_controller.dart'; @@ -259,6 +260,25 @@ void main() { expect(player.initialIndex, 79); }); + testWidgets('should_open_recent_cached_episode_when_detail_cta_is_tapped', ( + tester, + ) async { + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'detail-k38-3', + dramaId: 'detail-k38', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + await _pumpDetail(tester, positionCache: cache); + + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.initialIndex, 2); + }); + testWidgets('should_fit_compact_viewport_and_keep_cta_visible', ( tester, ) async { @@ -283,6 +303,7 @@ Future _pumpDetail( WidgetTester tester, { Drama? drama, Size size = const Size(390, 844), + PlaybackPositionCache? positionCache, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -303,6 +324,9 @@ Future _pumpDetail( value: InMemoryWatchHistoryRepository(), ), Provider.value(value: FakePlaybackRepository()), + Provider.value( + value: positionCache ?? PlaybackPositionCache(), + ), Provider.value(value: createWidgetPlayer), Provider(create: (_) => PlaybackCoordinator()), Provider.value(value: interactions), diff --git a/test/playback/playback_position_cache_test.dart b/test/playback/playback_position_cache_test.dart new file mode 100644 index 0000000..abc1281 --- /dev/null +++ b/test/playback/playback_position_cache_test.dart @@ -0,0 +1,69 @@ +// Author: fruit.com.cn + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; + +void main() { + test('should_store_and_read_recent_position_when_drama_matches', () { + final now = DateTime(2026, 10, 22, 12); + final cache = PlaybackPositionCache(now: () => now); + + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + + final entry = cache.read('episode-3', dramaId: 'drama-a'); + expect(entry, isNotNull); + expect(entry!.position, const Duration(minutes: 1, seconds: 20)); + expect(entry.duration, const Duration(minutes: 3)); + expect(entry.updatedAt, now); + }); + + test('should_reject_cached_position_when_drama_does_not_match', () { + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(seconds: 80), + duration: const Duration(minutes: 3), + ); + + expect(cache.read('episode-3', dramaId: 'drama-b'), isNull); + }); + + test('should_use_latest_saved_episode_when_timestamps_match', () { + final now = DateTime(2026, 10, 22, 12); + final cache = PlaybackPositionCache(now: () => now); + cache.save( + episodeId: 'episode-1', + dramaId: 'drama-a', + position: const Duration(seconds: 30), + duration: const Duration(minutes: 3), + ); + cache.save( + episodeId: 'episode-2', + dramaId: 'drama-a', + position: const Duration(seconds: 45), + duration: const Duration(minutes: 3), + ); + + expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2'); + }); + + test('should_reject_expired_cached_position', () { + var now = DateTime(2026, 10, 22, 12); + final cache = PlaybackPositionCache(now: () => now); + cache.save( + episodeId: 'episode-3', + dramaId: 'drama-a', + position: const Duration(seconds: 80), + duration: const Duration(minutes: 3), + ); + now = now.add(const Duration(days: 1)); + + expect(cache.read('episode-3', dramaId: 'drama-a'), isNull); + }); +} diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index f52251c..4dc1927 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -5,6 +5,7 @@ import 'package:video_player/video_player.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/models/download_license.dart'; import 'package:fruitshorts/core/data/models/download_policy.dart'; import 'package:fruitshorts/core/data/repositories/download_repository.dart'; @@ -12,6 +13,7 @@ import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/download_controller.dart'; import 'package:fruitshorts/core/data/services/download_storage.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; @@ -48,11 +50,13 @@ AndroidPictureInPicture _pip() => PlayerLogic _logic( FakeWatchHistory history, { PlaybackRepository? playback, + PlaybackPositionCache? positionCache, Future Function()? waitForFrame, }) => PlayerLogic( history: history, playback: playback ?? FakePlaybackRepository(), pictureInPicture: _pip(), + positionCache: positionCache, waitForFrame: waitForFrame ?? () async {}, ); @@ -126,6 +130,96 @@ void main() { }); }); + group('playback position cache', () { + test('should_use_cached_resume_point_without_reading_history', () async { + final history = FakeWatchHistory()..saved = const Duration(seconds: 12); + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-1', + dramaId: 'signal', + position: const Duration(minutes: 1, seconds: 20), + duration: const Duration(minutes: 3), + ); + final logic = _logic(history, positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + + expect(logic.resumePoint?.position, const Duration(seconds: 80)); + expect(history.reads, isEmpty); + logic.dispose(); + await _drain(logic); + }); + + test('should_fall_back_to_server_when_cache_belongs_to_other_drama', + () async { + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-2', + position: Duration(seconds: 42), + duration: Duration(minutes: 3), + ); + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-1', + dramaId: 'other-drama', + position: const Duration(seconds: 80), + duration: const Duration(minutes: 3), + ); + final logic = _logic(history, positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + + expect(logic.resumePoint?.episodeId, 'signal-2'); + expect(logic.resumePoint?.position, const Duration(seconds: 42)); + expect(history.reads, ['signal']); + logic.dispose(); + await _drain(logic); + }); + + test('should_fall_back_to_server_when_cache_is_missing', () async { + final history = FakeWatchHistory() + ..records['signal'] = const WatchProgress( + dramaId: 'signal', + episodeId: 'signal-1', + position: Duration(seconds: 36), + duration: Duration(minutes: 3), + ); + final logic = _logic(history); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0, active: false), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + + expect(logic.resumePoint?.position, const Duration(seconds: 36)); + expect(history.reads, ['signal']); + logic.dispose(); + await _drain(logic); + }); + }); + test('should_play_local_file_without_ticket_when_episode_is_downloaded', () async { final playback = FakePlaybackRepository(); diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart index ae36b5b..3349ace 100644 --- a/test/player/player_open_detail_test.dart +++ b/test/player/player_open_detail_test.dart @@ -13,6 +13,7 @@ import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_position_cache.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; @@ -35,12 +36,14 @@ void main() { final history = FakeWatchHistory(); final playback = FakePlaybackRepository(); final catalog = CatalogController(FakeCatalogRepository(dramas: [drama])); + final positionCache = PlaybackPositionCache(); final favorites = FavoriteController( InMemoryFavoriteRepository(saved: {drama.id}, cards: [drama]), ); final logic = PlayerLogic( history: history, playback: playback, + positionCache: positionCache, pictureInPicture: AndroidPictureInPicture( channel: const MethodChannel('test/player_open_detail/pip'), ), @@ -74,6 +77,7 @@ void main() { providers: [ Provider.value(value: history), Provider.value(value: playback), + Provider.value(value: positionCache), ChangeNotifierProvider.value(value: catalog), ChangeNotifierProvider.value(value: favorites), Provider.value(value: FakeHaptics()), @@ -104,7 +108,11 @@ void main() { expect(player.pauses, 1); expect(player.plays, playsBefore); expect(tracked.controller.value.position, const Duration(seconds: 37)); - expect(history.writes, isEmpty); + expect(history.writes, ['signal-1|37000|false']); + expect( + positionCache.read('signal-1', dramaId: 'signal')?.position, + const Duration(seconds: 37), + ); await tester.pageBack(); await tester.pump(); @@ -117,7 +125,10 @@ void main() { expect(logic.session.userWantsPlay, isTrue); expect(player.plays, playsBefore + 1); expect(tracked.controller.value.position, const Duration(seconds: 37)); - expect(history.writes, ['signal-1|37000|false']); + expect(history.writes, [ + 'signal-1|37000|false', + 'signal-1|37000|false', + ]); logic ..trackedPlayer = null