diff --git a/lib/core/data/services/playback_queue.dart b/lib/core/data/services/playback_queue.dart index bf14670..4f1a04f 100644 --- a/lib/core/data/services/playback_queue.dart +++ b/lib/core/data/services/playback_queue.dart @@ -4,6 +4,9 @@ import '../models/drama.dart'; /// A player session has one navigation meaning: browse dramas or browse episodes. class PlaybackQueue { + PlaybackQueue.fromEntries(List entries) + : entries = List.unmodifiable(entries); + PlaybackQueue.home(List dramas) : entries = List.unmodifiable( dramas diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 43727cb..cd3a82d 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -137,6 +137,47 @@ class PlaybackSession extends ChangeNotifier { if (_visible && !_overlayOpen) _warmNeighbors(this); } + /// 在剧维度和集维度之间切换队列,但保留当前 active controller 与播放状态。 + void remapQueuePreservingActive( + List nextEpisodes, { + required int currentIndex, + int? preloadIndex, + int? previousIndex, + bool notify = true, + }) { + if (_closed || + currentIndex < 0 || + currentIndex >= nextEpisodes.length || + _active == null) { + return; + } + _generation++; + episodes + ..clear() + ..addAll(nextEpisodes); + _active!.index = currentIndex; + _currentIndex = currentIndex; + _selected = true; + _loading = false; + _error = null; + _rememberNeighbors(preloadIndex, previousIndex); + final staleNext = _next; + final stalePrevious = _previous; + final staleHandoff = _handoff; + _next = null; + _previous = null; + _handoff = null; + if (notify) _notify(); + unawaited( + _enqueue(() async { + await _retire(staleNext); + await _retire(stalePrevious); + await _retire(staleHandoff); + }), + ); + if (_visible && !_overlayOpen) _warmNeighbors(this); + } + void _rememberNeighbors(int? preloadIndex, int? previousIndex) { _nextIndex = preloadIndex; _previousIndex = previousIndex; diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index b474a6b..dece37b 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -212,6 +212,68 @@ class PlayerLogic extends ChangeNotifier { unawaited(selectEpisode(initialIndex)); } + /// 独立播放页把 feed 的“剧索引”切换成 series 的“集索引”,保留 active player。 + void adoptSeriesHandoff({ + required Drama drama, + required List seriesEpisodes, + required int episodeIndex, + }) { + if (episodeIndex < 0 || episodeIndex >= seriesEpisodes.length) return; + homeMode = false; + embedded = false; + feedDramas = null; + pageDrama = drama; + initialIndex = episodeIndex; + queue = PlaybackQueue.series(drama); + visibleIndices = List.generate(queue.length, (i) => i); + sessionEpisodes + ..clear() + ..addAll(seriesEpisodes); + session.remapQueuePreservingActive( + List.of(seriesEpisodes), + currentIndex: episodeIndex, + notify: false, + preloadIndex: episodeIndex + 1 < seriesEpisodes.length + ? episodeIndex + 1 + : null, + previousIndex: episodeIndex > 0 ? episodeIndex - 1 : null, + ); + } + + /// 返回推荐页时恢复剧维度 queue,并把当前集写回该剧的 feed 条目。 + void restoreFeedHandoff({ + required PlayerPage page, + required PlaybackQueue sourceQueue, + required List sourceEpisodes, + required int sourceIndex, + }) { + if (sourceIndex < 0 || sourceIndex >= sourceQueue.length) return; + final currentEpisode = session.currentEpisode; + final feedEpisodes = List.of(sourceEpisodes); + feedEpisodes[sourceIndex] = currentEpisode; + final entries = List.of(sourceQueue.entries); + entries[sourceIndex] = PlaybackEntry( + entries[sourceIndex].drama, + currentEpisode, + ); + feedDramas = page.feedDramas; + pageDrama = page.drama; + homeMode = page.homeMode; + embedded = page.embedded; + initialIndex = sourceIndex; + queue = PlaybackQueue.fromEntries(entries); + visibleIndices = List.generate(queue.length, (i) => i); + sessionEpisodes + ..clear() + ..addAll(feedEpisodes); + session.remapQueuePreservingActive( + List.of(feedEpisodes), + currentIndex: sourceIndex, + preloadIndex: nextVisibleIndex(sourceIndex), + previousIndex: previousVisibleIndex(sourceIndex), + ); + } + /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 void updateFeed( List dramas, { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 75627bc..87f9feb 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -10,10 +10,12 @@ import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/repositories/comment_repository.dart'; +import '../../core/data/services/catalog_controller.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_queue.dart'; import '../../core/data/services/playback_session.dart'; import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/playback_thumbnail_service.dart'; @@ -58,12 +60,22 @@ const double _feedRefreshTopGap = 56; class PlayerPageHandoff { PlayerPageHandoff({ required this.logic, - required this.initialIndex, + required this.sourceQueue, + required this.sourceEpisodes, + required this.sourceIndex, + required this.seriesDrama, + required this.seriesEpisodes, + required this.episodeIndex, required this.prepareSource, }); final PlayerLogic logic; - final int initialIndex; + final PlaybackQueue sourceQueue; + final List sourceEpisodes; + final int sourceIndex; + final Drama seriesDrama; + final List seriesEpisodes; + final int episodeIndex; final Future Function() prepareSource; PageController? destinationController; VoidCallback? activateDestination; @@ -150,6 +162,8 @@ class _PlayerPageState extends State bool _presentationSuspended = false; bool _presentationReturning = false; bool _leavingHandoff = false; + bool _preparingFullPlayer = false; + PlayerPageHandoff? _activeHandoff; PageController? _returnPageController; final Set _precachedPosterUrls = {}; @@ -185,10 +199,12 @@ class _PlayerPageState extends State ); _pageController = logic.pages; } else { - final position = logic.visibleIndices.indexOf(handoff.initialIndex); - _pageController = PageController( - initialPage: position < 0 ? 0 : position, + logic.adoptSeriesHandoff( + drama: handoff.seriesDrama, + seriesEpisodes: handoff.seriesEpisodes, + episodeIndex: handoff.episodeIndex, ); + _pageController = PageController(initialPage: handoff.episodeIndex); handoff.destinationController = _pageController; logic.session.setRouteVisible(widget.active); } @@ -1163,13 +1179,56 @@ class _PlayerPageState extends State } Future _presentFullPlayer(BuildContext context, int index) async { - if (_presentationActive || !_pageController.hasClients) return; + if (_presentationActive || + _preparingFullPlayer || + !_pageController.hasClients) { + return; + } + _preparingFullPlayer = true; + final sourceIndex = logic.session.currentIndex; + final sourceEpisode = logic.session.currentEpisode; + final sourceQueue = logic.queue; + final sourceEpisodes = List.of(logic.sessionEpisodes); + var seriesDrama = sourceQueue.entryAt(sourceIndex).drama; + try { + seriesDrama = await context.read().requireDetail( + seriesDrama, + ); + } catch (_) { + // 详情不可用时仍使用当前剧已知的集列表。 + } + _preparingFullPlayer = false; + if (!mounted || + !context.mounted || + logic.session.currentIndex != sourceIndex || + logic.session.currentEpisode.id != sourceEpisode.id) { + return; + } + final seriesEpisodes = List.of(seriesDrama.episodes); + var episodeIndex = seriesEpisodes.indexWhere( + (episode) => episode.id == sourceEpisode.id, + ); + if (episodeIndex < 0) { + episodeIndex = seriesEpisodes.indexWhere( + (episode) => episode.number == sourceEpisode.number, + ); + } + if (episodeIndex < 0) { + seriesEpisodes.insert(0, sourceEpisode); + episodeIndex = 0; + } final sourcePageController = _pageController; final handoff = PlayerPageHandoff( logic: logic, - initialIndex: index, + sourceQueue: sourceQueue, + sourceEpisodes: sourceEpisodes, + sourceIndex: sourceIndex, + seriesDrama: seriesDrama, + seriesEpisodes: seriesEpisodes, + episodeIndex: episodeIndex, prepareSource: _prepareSourceAfterFullPlayer, ); + _activeHandoff = handoff; handoff.activateDestination = () { final destination = handoff.destinationController; if (destination != null) logic.pages = destination; @@ -1179,8 +1238,8 @@ class _PlayerPageState extends State final route = Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlayerPage( - drama: logic.queue.entryAt(index).drama, - initialIndex: index, + drama: seriesDrama, + initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, @@ -1210,17 +1269,23 @@ class _PlayerPageState extends State _presentationSuspended = false; _presentationReturning = false; }); + _activeHandoff = null; logic.session.setRouteVisible(widget.active); _logFullPlayerRoute('restored'); } Future _prepareSourceAfterFullPlayer() async { if (!mounted) return; - final page = logic.visibleIndices.indexOf(logic.session.currentIndex); - _returnPageController = PageController( - initialPage: page < 0 ? 0 : page, - ); + final handoff = _activeHandoff; + if (handoff == null) return; logic.attach(widget, isMounted: () => mounted); + logic.restoreFeedHandoff( + page: widget, + sourceQueue: handoff.sourceQueue, + sourceEpisodes: handoff.sourceEpisodes, + sourceIndex: handoff.sourceIndex, + ); + _returnPageController = PageController(initialPage: handoff.sourceIndex); setState(() { _presentationSuspended = false; _presentationReturning = true; @@ -1247,5 +1312,4 @@ class _PlayerPageState extends State 'position=${video?.controller.value.position.inMilliseconds ?? '-'}', ); } - } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b7ee656..92e4388 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -95,9 +95,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); - final recommendPlayer = find.byKey( - const Key('recommend-feed-player'), - ); + final recommendPlayer = find.byKey(const Key('recommend-feed-player')); final renderStack = find.descendant( of: recommendPlayer, matching: find.byType(PlayerRenderStack), @@ -107,15 +105,12 @@ void main() { final playerState = tester.state(recommendPlayer); final currentSurface = find.descendant( of: recommendPlayer, - matching: find.byKey( - const ValueKey('player-render-slot-current'), - ), + matching: find.byKey(const ValueKey('player-render-slot-current')), ); final sourceRender = tester.widget(renderStack); - final sourceIndex = - sourceRender.slots[PlayerRenderSlotRole.current]?.index; + final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index; + final currentEpisodeId = factory.episodes.first.id; final controller = factory.created.first.controller; - final createdBefore = factory.created.length; final requestsBefore = playback.requests.length; expect(controller.value.position, const Duration(seconds: 5)); expect( @@ -132,20 +127,24 @@ void main() { final fullPlayer = tester.widget(find.byType(PlayerPage)); expect(fullPlayer.homeMode, isFalse); expect(fullPlayer.handoff, isNotNull); - expect(fullPlayer.handoff!.initialIndex, sourceIndex); - expect(fullPlayer.handoff!.logic.session.currentIndex, sourceIndex); + expect(fullPlayer.handoff!.sourceIndex, sourceIndex); + expect( + fullPlayer.handoff!.logic.session.currentIndex, + fullPlayer.handoff!.episodeIndex, + ); expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byKey(const Key('recommend-top-nav')), findsNothing); expect(find.byKey(const Key('home-feed-gesture')), findsNothing); expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(factory.created.length, createdBefore); + expect( + factory.episodes.where((episode) => episode.id == currentEpisodeId), + hasLength(1), + ); expect(playback.requests.length, requestsBefore); expect(controller.value.position, const Duration(seconds: 5)); final fullCurrentSurface = find.descendant( of: find.byType(PlayerPage), - matching: find.byKey( - const ValueKey('player-render-slot-current'), - ), + matching: find.byKey(const ValueKey('player-render-slot-current')), ); expect( find.descendant( @@ -165,7 +164,10 @@ void main() { expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(recommendPlayer, findsOneWidget); expect(renderStack, findsOneWidget); - expect(factory.created.length, createdBefore); + expect( + factory.episodes.where((episode) => episode.id == currentEpisodeId), + hasLength(1), + ); expect(playback.requests.length, requestsBefore); expect(controller.value.position, const Duration(seconds: 5)); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); @@ -195,6 +197,10 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); + final fullPlayer = tester.widget(find.byType(PlayerPage)); + final handoff = fullPlayer.handoff!; + final dramaId = handoff.seriesDrama.id; + final firstEpisodeId = handoff.logic.session.currentEpisode.id; final createdBefore = factory.created.length; await tester.drag( find.byKey(const Key('player-video-tap')), @@ -205,8 +211,44 @@ void main() { expect(find.byType(PlayerPage), findsOneWidget); expect(factory.created.length, lessThanOrEqualTo(createdBefore + 1)); expect(find.byKey(const Key('player-render-stack')), findsOneWidget); + final switchedEpisode = handoff.logic.session.currentEpisode; + expect(switchedEpisode.id, isNot(firstEpisodeId)); + expect(switchedEpisode.number, 2); + expect( + handoff.logic.queue.entryAt(handoff.logic.session.currentIndex).drama.id, + dramaId, + ); + expect(handoff.logic.session.userWantsPlay, isTrue); + expect( + factory.episodes.where((episode) => episode.id == switchedEpisode.id), + hasLength(1), + ); + final switchedVideo = handoff.logic.session.active! as VideoPlayerPort; + final switchedController = switchedVideo.controller; + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); + expect(handoff.logic.session.currentIndex, handoff.sourceIndex); + expect( + handoff.logic.queue.entryAt(handoff.sourceIndex).episode.id, + switchedEpisode.id, + ); + final sourceCurrentSurface = find.descendant( + of: find.byType(PlayerPage), + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + expect( + tester.widget(sourceCurrentSurface).controller, + same(switchedController), + ); + expect(switchedController.value.position, const Duration(seconds: 5)); + expect( + find.descendant( + of: sourceCurrentSurface, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, + ); }); testWidgets('should_show_one_paused_overlay_when_entering_full_player', ( @@ -236,6 +278,48 @@ void main() { expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); + testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-recommend'))); + await tester.pumpAndSettle(); + + final feedPage = find + .descendant( + of: find.byKey(const Key('recommend-feed-player')), + matching: find.byType(PageView), + ) + .first; + await tester.drag(feedPage, const Offset(0, -620)); + await tester.pumpAndSettle(); + final sourceRender = tester.widget( + find.byType(PlayerRenderStack), + ); + final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index; + expect(sourceIndex, isNotNull); + expect(sourceIndex, greaterThan(0)); + final feedIndex = sourceIndex!; + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + final fullPlayer = tester.widget(find.byType(PlayerPage)); + final handoff = fullPlayer.handoff!; + expect(handoff.sourceIndex, feedIndex); + expect(handoff.episodeIndex, 0); + expect(handoff.logic.session.currentIndex, handoff.episodeIndex); + expect( + handoff.seriesDrama.id, + handoff.sourceQueue.entryAt(feedIndex).drama.id, + ); + expect( + handoff.logic.session.currentEpisode.id, + handoff.sourceEpisodes[feedIndex].id, + ); + + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + }); + testWidgets('should_render_player_loading_indicators_at_compact_size', ( tester, ) async { @@ -940,10 +1024,7 @@ void main() { ), ], ); - await _pumpApp( - tester, - catalog: FakeCatalogRepository(dramas: [longCopy]), - ); + await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy])); await _openSeriesPlayer( tester, cardKey: const Key('home-feed-card-long-copy-demo'), @@ -966,7 +1047,9 @@ void main() { ); expect(tester.widget(synopsis).maxLines, 2); expect( - tester.widget(find.byKey(const Key('player-synopsis-gap'))).padding, + tester + .widget(find.byKey(const Key('player-synopsis-gap'))) + .padding, const EdgeInsets.only(left: 6), ); final synopsisRect = tester.getRect(synopsis); @@ -983,10 +1066,15 @@ void main() { find.descendant(of: toggle, matching: find.text('Less')), findsOneWidget, ); - expect(tester.widget(find.byKey(const Key('player-synopsis'))).maxLines, isNull); + expect( + tester.widget(find.byKey(const Key('player-synopsis'))).maxLines, + isNull, + ); final lessRect = tester.getRect(toggle); final textRect = tester.getRect(find.byKey(const Key('player-synopsis'))); - final block = tester.getRect(find.byKey(const Key('player-episode-synopsis'))); + final block = tester.getRect( + find.byKey(const Key('player-episode-synopsis')), + ); expect(lessRect.top, greaterThanOrEqualTo(textRect.bottom - 1)); expect(block.right - lessRect.right, lessThanOrEqualTo(2)); @@ -1596,10 +1684,12 @@ Future _pumpApp( class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; + final List episodes = []; PlayerPort create(Episode episode) { final player = _RouteVideoPlayer(episode); created.add(player); + episodes.add(episode); return player; } }