From 6667330ce0119d6913527085c64428f82d358925 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 16:37:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E5=88=86=E7=A6=BB=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E4=B8=8E=E6=92=AD=E6=94=BE=E9=A1=B5=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 1 + lib/core/data/services/playback_queue.dart | 3 - lib/core/data/services/playback_session.dart | 54 +--- .../services/playback_session_controls.dart | 63 +++- lib/pages/player/player_logic.dart | 114 +++---- lib/pages/player/player_view.dart | 122 ++------ test/playback/playback_session_test.dart | 27 ++ test/player/player_fullscreen_test.dart | 290 ++++++++---------- 8 files changed, 312 insertions(+), 362 deletions(-) diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index 17d67d1..7c86289 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -69,6 +69,7 @@ Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { /// 当前集已经出画后,按手势方向静默准备上一集和下一集。 void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) { + if (!session.warmNeighborsEnabled) return; final request = session._generation; final nextTask = _warmOne(session, request, session._nextIndex, next: true); final previousTask = _warmOne( diff --git a/lib/core/data/services/playback_queue.dart b/lib/core/data/services/playback_queue.dart index 4f1a04f..bf14670 100644 --- a/lib/core/data/services/playback_queue.dart +++ b/lib/core/data/services/playback_queue.dart @@ -4,9 +4,6 @@ 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 cd3a82d..700648f 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -27,6 +27,7 @@ class PlaybackSession extends ChangeNotifier { this.activate, PlaybackCoordinator? coordinator, this.waitForFrame, + this.warmNeighborsEnabled = true, }) : _coordinator = coordinator ?? PlaybackCoordinator(); final List episodes; @@ -39,6 +40,7 @@ class PlaybackSession extends ChangeNotifier { /// Runs only when a slot becomes current (e.g. progress listeners). final PlayerPreparation? activate; final Future Function()? waitForFrame; + final bool warmNeighborsEnabled; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; @@ -80,6 +82,8 @@ class PlaybackSession extends ChangeNotifier { String? get error => _error; // Includes initialization and asynchronous disposal, until native release ends. int get livePlayerCount => _live.length; + bool get activeSlotActivated => _active?.activated ?? false; + bool get activeSlotPlaying => _active?.playing ?? false; int? get preloadedIndex => _next?.prepared == true ? _next?.index : null; int? get previousPreloadedIndex => _previous?.prepared == true ? _previous?.index : null; @@ -137,50 +141,9 @@ 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; + _nextIndex = warmNeighborsEnabled ? preloadIndex : null; + _previousIndex = warmNeighborsEnabled ? previousIndex : null; } Future _enqueue(Future Function() operation) { @@ -247,10 +210,13 @@ class PlaybackSession extends ChangeNotifier { int index, { int? preloadIndex, int? previousIndex, + bool? userWantsPlay, }) async { if (_closed || index < 0 || index >= episodes.length) return; // 切换到不同剧集时恢复播放;仍停留在同一集时保留用户暂停。 - if (!_selected || index != _currentIndex) { + if (userWantsPlay != null) { + _userWantsPlay = userWantsPlay; + } else if (!_selected || index != _currentIndex) { _userWantsPlay = true; } final request = ++_generation; diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index 293f070..cf7c6e1 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -6,10 +6,71 @@ part of 'playback_session.dart'; extension PlaybackSessionControls on PlaybackSession { /// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。 void prewarmNeighbors({required bool forward}) { - if (!_visible || _overlayOpen || _active?.prepared != true) return; + if (!warmNeighborsEnabled || + !_visible || + _overlayOpen || + _active?.prepared != true) { + return; + } _warmNeighbors(this, preferredNext: forward); } + /// 路由交接时同步暂停当前原生播放器,controller 仍由 session 持有。 + Future pauseForRouteHandoff() { + if (_closed) return Future.value(); + _routeVisible = false; + return _enqueue(() async { + final slot = _active; + if (slot == null) return; + await slot.player.pause(); + slot.playing = false; + }); + } + + /// 路由恢复可见后立即同步当前 controller,并等待原生播放状态落地。 + Future resumeForRouteHandoff() { + if (_closed) return Future.value(); + _routeVisible = true; + return _enqueue(() async { + final slot = _active; + if (kDebugMode) { + debugPrint( + '[feed-handoff] phase=resume slot=${slot?.index} ' + 'activated=${slot?.activated} playing=${slot?.playing} ' + 'retiring=${slot?.retiring} current=$_currentIndex ' + 'visible=$_visible user=$_userWantsPlay loading=$_loading', + ); + } + if (slot == null || slot.retiring) return; + if (!slot.activated) { + try { + await activate?.call(episodes[slot.index], slot.player); + } catch (error) { + if (kDebugMode) { + debugPrint('[feed-handoff] phase=activation-failed error=$error'); + } + } + if (_closed || _active != slot || slot.retiring) { + if (kDebugMode) { + debugPrint( + '[feed-handoff] phase=activation-aborted closed=$_closed ' + 'activeSame=${identical(_active, slot)} retiring=${slot.retiring}', + ); + } + return; + } + slot.activated = true; + } + await _applyPlayback(slot); + if (kDebugMode) { + debugPrint( + '[feed-handoff] phase=resumed activated=${slot.activated} ' + 'playing=${slot.playing}', + ); + } + }); + } + /// 选中锁定集时立即停播并释放所有原生播放器,不创建新片源。 Future selectLocked( int index, { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index dece37b..cb181a8 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -192,6 +192,7 @@ class PlayerLogic extends ChangeNotifier { void start({ required PlayerFactory factory, required PlaybackCoordinator coordinator, + bool initialUserWantsPlay = true, }) { _started = true; _sessionFactory = factory; @@ -209,69 +210,41 @@ class PlayerLogic extends ChangeNotifier { session.setRouteVisible(false); } unawaited(loadResumePoint()); - 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, + unawaited( + selectEpisode( + initialIndex, + userWantsPlay: initialUserWantsPlay ? null : false, + ), ); } - /// 返回推荐页时恢复剧维度 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), - ); + /// 独立播放页覆盖首页时只暂停并保存进度,首页 session 与 controller 保持存活。 + Future pauseForFullPlayer() async { + if (!_started) return; + cancelScrub(); + await session.pauseForRouteHandoff(); + await saveCurrentProgress(); + } + + /// 返回首页前把仍存活的 feed controller 定位到第 1 集起点。 + Future prepareFeedResume() async { + if (!_started) return; + await session.playbackSettled; + final player = session.active; + if (player is VideoPlayerPort && player.controller.value.isInitialized) { + await player.seekTo(Duration.zero); + } + } + + /// 独立播放页关闭并恢复首页路由后,等原生播放器实际恢复再结束交接。 + Future resumeAfterFullPlayer() async { + if (!_started) return; + if (visibleIndices.isNotEmpty) { + await session.resumeForRouteHandoff(); + } else { + session.setRouteVisible(false); + await session.playbackSettled; + } } /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 @@ -310,6 +283,7 @@ class PlayerLogic extends ChangeNotifier { activate: activatePlayer, coordinator: coordinator, waitForFrame: _waitForFrame, + warmNeighborsEnabled: !homeMode, ); } @@ -1053,7 +1027,11 @@ class PlayerLogic extends ChangeNotifier { } } - Future selectEpisode(int index, {int? revision}) async { + Future selectEpisode( + int index, { + int? revision, + bool? userWantsPlay, + }) async { final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; cancelScrub(); @@ -1088,7 +1066,7 @@ class PlayerLogic extends ChangeNotifier { return; } if (!ready) return; - await _selectVisible(index); + await _selectVisible(index, userWantsPlay: userWantsPlay); } /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 @@ -1098,11 +1076,12 @@ class PlayerLogic extends ChangeNotifier { } /// 按可见队列选择一集,并预热上一集与下一集。 - Future _selectVisible(int index) { + Future _selectVisible(int index, {bool? userWantsPlay}) { return session.select( index, preloadIndex: nextVisibleIndex(index), previousIndex: previousVisibleIndex(index), + userWantsPlay: userWantsPlay, ); } @@ -1237,9 +1216,7 @@ class PlayerLogic extends ChangeNotifier { Drama drama, int index, ) async { - session.setRouteVisible(false); - await session.playbackSettled; - await saveCurrentProgress(); + await pauseForFullPlayer(); if (!context.mounted) return; await Navigator.of(context).push( FadePageRoute( @@ -1248,10 +1225,13 @@ class PlayerLogic extends ChangeNotifier { initialIndex: index, wallet: wallet, downloads: downloads, + isolatePlaybackSession: true, ), ), ); - if (isMounted()) session.setRouteVisible(active); + if (!isMounted()) return; + await prepareFeedResume(); + await resumeAfterFullPlayer(); } Future showEpisodes(BuildContext context) async { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 87f9feb..92a60f0 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -15,7 +15,6 @@ 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'; @@ -59,26 +58,12 @@ const double _feedRefreshTopGap = 56; class PlayerPageHandoff { PlayerPageHandoff({ - required this.logic, - required this.sourceQueue, - required this.sourceEpisodes, - required this.sourceIndex, - required this.seriesDrama, - required this.seriesEpisodes, - required this.episodeIndex, required this.prepareSource, + required this.sourceUserWantsPlay, }); - final PlayerLogic logic; - 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; + final bool sourceUserWantsPlay; } class PlayerPage extends StatefulWidget { @@ -109,6 +94,7 @@ class PlayerPage extends StatefulWidget { this.wallet, this.downloads, this.handoff, + this.isolatePlaybackSession = false, }); final Drama drama; @@ -136,6 +122,7 @@ class PlayerPage extends StatefulWidget { final WalletController? wallet; final DownloadController? downloads; final PlayerPageHandoff? handoff; + final bool isolatePlaybackSession; @override State createState() => _PlayerPageState(); @@ -144,7 +131,6 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; - late final bool _ownsLogic; late PageController _pageController; late final PlaybackThumbnailService _thumbnailService; late final SpriteThumbnailAxisService _spriteAxisService; @@ -163,8 +149,6 @@ class _PlayerPageState extends State bool _presentationReturning = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; - PlayerPageHandoff? _activeHandoff; - PageController? _returnPageController; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -175,16 +159,13 @@ class _PlayerPageState extends State void initState() { super.initState(); final handoff = widget.handoff; - _ownsLogic = handoff == null; - logic = - handoff?.logic ?? - PlayerBinding().create( - history: context.read(), - playback: context.read(), - positionCache: context.read(), - wallet: widget.wallet, - downloads: widget.downloads, - ); + logic = PlayerBinding().create( + history: context.read(), + playback: context.read(), + positionCache: context.read(), + wallet: widget.wallet, + downloads: widget.downloads, + ); logic.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, @@ -192,22 +173,14 @@ class _PlayerPageState extends State _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); - if (handoff == null) { - logic.start( - factory: context.read(), - coordinator: context.read(), - ); - _pageController = logic.pages; - } else { - logic.adoptSeriesHandoff( - drama: handoff.seriesDrama, - seriesEpisodes: handoff.seriesEpisodes, - episodeIndex: handoff.episodeIndex, - ); - _pageController = PageController(initialPage: handoff.episodeIndex); - handoff.destinationController = _pageController; - logic.session.setRouteVisible(widget.active); - } + logic.start( + factory: context.read(), + coordinator: handoff == null && !widget.isolatePlaybackSession + ? context.read() + : PlaybackCoordinator(), + initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, + ); + _pageController = logic.pages; } @override @@ -328,11 +301,7 @@ class _PlayerPageState extends State ..removeListener(_handleSpriteFrameChanged) ..dispose(); _thumbnailService.dispose(); - if (_ownsLogic) { - logic.dispose(); - } else { - _pageController.dispose(); - } + logic.dispose(); super.dispose(); } @@ -769,11 +738,10 @@ class _PlayerPageState extends State Widget _buildPresentationReturnLayer() { final indices = logic.visibleIndices; - final controller = _returnPageController ?? _pageController; return AnimatedBuilder( animation: logic.session, builder: (context, _) => PlayerRenderStack( - pageController: controller, + pageController: _pageController, slots: _renderSlots(indices), ), ); @@ -1188,7 +1156,6 @@ class _PlayerPageState extends State 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( @@ -1217,22 +1184,17 @@ class _PlayerPageState extends State seriesEpisodes.insert(0, sourceEpisode); episodeIndex = 0; } - final sourcePageController = _pageController; + final sourceUserWantsPlay = logic.session.userWantsPlay; + try { + await logic.pauseForFullPlayer(); + } finally { + _preparingFullPlayer = false; + } + if (!mounted || !context.mounted) return; final handoff = PlayerPageHandoff( - logic: logic, - sourceQueue: sourceQueue, - sourceEpisodes: sourceEpisodes, - sourceIndex: sourceIndex, - seriesDrama: seriesDrama, - seriesEpisodes: seriesEpisodes, - episodeIndex: episodeIndex, prepareSource: _prepareSourceAfterFullPlayer, + sourceUserWantsPlay: sourceUserWantsPlay, ); - _activeHandoff = handoff; - handoff.activateDestination = () { - final destination = handoff.destinationController; - if (destination != null) logic.pages = destination; - }; _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( @@ -1251,41 +1213,24 @@ class _PlayerPageState extends State setState(() => _presentationSuspended = true); await WidgetsBinding.instance.endOfFrame; } - sourcePageController.dispose(); - handoff.activateDestination?.call(); await route; if (!mounted) return; - final currentPage = logic.visibleIndices.indexOf( - logic.session.currentIndex, - ); - final restoredPage = - _returnPageController ?? - PageController(initialPage: currentPage < 0 ? 0 : currentPage); - logic.pages = restoredPage; - _pageController = restoredPage; logic.attach(widget, isMounted: () => mounted); setState(() { _presentationActive = false; _presentationSuspended = false; _presentationReturning = false; }); - _activeHandoff = null; - logic.session.setRouteVisible(widget.active); + await logic.resumeAfterFullPlayer(); + if (!mounted) return; _logFullPlayerRoute('restored'); } Future _prepareSourceAfterFullPlayer() async { if (!mounted) return; - 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); + await logic.prepareFeedResume(); + if (!mounted) return; setState(() { _presentationSuspended = false; _presentationReturning = true; @@ -1296,6 +1241,7 @@ class _PlayerPageState extends State Future _leaveHandoff(BuildContext context) async { if (_leavingHandoff) return; setState(() => _leavingHandoff = true); + await logic.session.pauseForRouteHandoff(); await widget.handoff?.prepareSource(); if (context.mounted) Navigator.of(context).pop(); } diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index 7595d08..adb99f4 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,6 +32,33 @@ void main() { await session.close(); }); + test('should_keep_only_current_player_when_neighbor_warmup_disabled', () async { + final factory = FakeFactory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + warmNeighborsEnabled: false, + ); + + await session.select(0, preloadIndex: 1); + await session.preloadSettled; + expect(factory.created, hasLength(1)); + expect(factory.live, 1); + expect(session.preloadedIndex, isNull); + + final first = factory.created.single; + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + + expect(first.disposed, isTrue); + expect(factory.created, hasLength(2)); + expect(factory.live, 1); + expect(session.livePlayerCount, 1); + expect(session.active, same(factory.created.last)); + await session.close(); + }); + test('should_release_previous_player_immediately_when_selecting_locked', () async { final lockedEpisodes = [ Episode( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 92e4388..fa69f96 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -20,9 +20,9 @@ import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; +import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; -import 'package:fruitshorts/pages/player/widgets/player_render_stack.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -86,7 +86,7 @@ void main() { _expectPlayerNavigationBar(tester); }); - testWidgets('should_preserve_recommend_feed_when_full_player_pops', ( + testWidgets('should_keep_feed_session_unchanged_when_switching_episode', ( tester, ) async { final factory = _RouteVideoFactory(); @@ -95,85 +95,86 @@ void main() { 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(); + await tester.drag(feedPage, const Offset(0, -620)); + await tester.pumpAndSettle(); + final recommendPlayer = find.byKey(const Key('recommend-feed-player')); - final renderStack = find.descendant( - of: recommendPlayer, - matching: find.byType(PlayerRenderStack), + final sourceState = tester.state(recommendPlayer); + final sourceLogic = _pageLogic(tester, recommendPlayer); + final sourceIndex = sourceLogic.session.currentIndex; + final sourceDramaId = sourceLogic.currentDramaId; + final sourceEpisodeId = sourceLogic.session.currentEpisode.id; + final feedPlayer = sourceLogic.session.active! as _RouteVideoPlayer; + final feedPlayCalls = feedPlayer.playCalls; + expect(sourceDramaId, 'vengeance-demo'); + expect(sourceIndex, 2); + expect(sourceLogic.session.livePlayerCount, 1); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + final fullPlayer = find.byType(PlayerPage); + final fullLogic = _pageLogic(tester, fullPlayer); + expect(fullLogic.session, isNot(same(sourceLogic.session))); + expect(feedPlayer.disposed, isFalse); + expect(feedPlayer.playing, isFalse); + + for (var index = 0; index < 4; index++) { + await tester.drag( + find.byKey(const Key('player-video-tap')), + const Offset(0, -560), + ); + await tester.pumpAndSettle(); + } + + expect(fullLogic.session.currentEpisode.number, 5); + expect( + fullLogic.queue.entryAt(fullLogic.session.currentIndex).drama.id, + sourceDramaId, ); - expect(recommendPlayer, findsOneWidget); - expect(renderStack, findsOneWidget); - final playerState = tester.state(recommendPlayer); + expect(fullLogic.session.userWantsPlay, isTrue); + expect(sourceLogic.session.currentIndex, sourceIndex); + expect(sourceLogic.currentDramaId, sourceDramaId); + expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId); + expect(sourceLogic.session.active, same(feedPlayer)); + expect(feedPlayer.disposed, isFalse); + + final requestsBeforeReturn = playback.requests.length; + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + final currentSurface = find.descendant( of: recommendPlayer, matching: find.byKey(const ValueKey('player-render-slot-current')), ); - final sourceRender = tester.widget(renderStack); - final sourceIndex = sourceRender.slots[PlayerRenderSlotRole.current]?.index; - final currentEpisodeId = factory.episodes.first.id; - final controller = factory.created.first.controller; - final requestsBefore = playback.requests.length; - expect(controller.value.position, const Duration(seconds: 5)); - expect( - find.descendant( - of: currentSurface, - matching: find.byKey(const Key('player-single-placeholder')), - ), - findsNothing, - ); - - await tester.tap(find.byKey(const Key('home-feed-full-series'))); - await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); - final fullPlayer = tester.widget(find.byType(PlayerPage)); - expect(fullPlayer.homeMode, isFalse); - expect(fullPlayer.handoff, isNotNull); - 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.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')), - ); - expect( - find.descendant( - of: fullCurrentSurface, - matching: find.byKey(const Key('player-single-placeholder')), - ), - findsNothing, - ); - expect( - tester.widget(fullCurrentSurface).controller, - same(controller), - ); - - await tester.tap(find.byKey(const Key('player-back'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('recommend-page')), findsOneWidget); - expect(recommendPlayer, findsOneWidget); - expect(renderStack, findsOneWidget); - 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); - expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); - expect(tester.state(recommendPlayer), same(playerState)); - expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); + expect(tester.state(recommendPlayer), same(sourceState)); + expect(sourceLogic.session.currentIndex, sourceIndex); + expect(sourceLogic.currentDramaId, sourceDramaId); + expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId); + expect(sourceLogic.session.active, same(feedPlayer)); + expect(feedPlayer.disposed, isFalse); + expect(sourceLogic.active, isTrue); + expect(sourceLogic.session.userWantsPlay, isTrue); + expect(sourceLogic.session.canResumeAfterBackground, isTrue); + expect(sourceLogic.session.canAutoAdvance, isTrue); + expect(sourceLogic.session.isLocked, isFalse); + expect(sourceLogic.session.loading, isFalse); + expect(sourceLogic.session.activeSlotActivated, isTrue); + expect(sourceLogic.session.activeSlotPlaying, isTrue); + expect(feedPlayer.seekCalls, 1); + expect(feedPlayer.playCalls, greaterThan(feedPlayCalls)); + expect(playback.requests.length, requestsBeforeReturn); + expect(feedPlayer.controller.value.position, Duration.zero); + expect(feedPlayer.playing, isTrue); expect( find.descendant( of: currentSurface, @@ -183,72 +184,10 @@ void main() { ); expect( tester.widget(currentSurface).controller, - same(controller), - ); - }); - - testWidgets('should_swipe_full_player_without_creating_another_session', ( - tester, - ) async { - final factory = _RouteVideoFactory(); - await _pumpApp(tester, playerFactory: factory.create); - await tester.tap(find.byKey(const Key('tab-recommend'))); - await tester.pumpAndSettle(); - 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')), - const Offset(0, -560), - ); - await tester.pumpAndSettle(); - - 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, + same(feedPlayer.controller), ); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); + expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); }); testWidgets('should_show_one_paused_overlay_when_entering_full_player', ( @@ -279,7 +218,8 @@ void main() { }); testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { - await _pumpApp(tester); + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); @@ -291,33 +231,34 @@ void main() { .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!; + final recommendPlayer = find.byKey(const Key('recommend-feed-player')); + final sourceLogic = _pageLogic(tester, recommendPlayer); + final feedIndex = sourceLogic.session.currentIndex; + final feedEpisodeId = sourceLogic.session.currentEpisode.id; + final feedDramaId = sourceLogic.currentDramaId; + expect(feedIndex, 1); 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, + final fullLogic = _pageLogic(tester, find.byType(PlayerPage)); + await tester.drag( + find.byKey(const Key('player-video-tap')), + const Offset(0, -560), ); + await tester.pumpAndSettle(); + + expect(fullLogic.session.currentIndex, 1); + expect(fullLogic.session.currentEpisode.number, 2); + expect(fullLogic.queue.entryAt(1).drama.id, feedDramaId); + expect(sourceLogic.session.currentIndex, feedIndex); + expect(sourceLogic.session.currentEpisode.id, feedEpisodeId); + expect(sourceLogic.currentDramaId, feedDramaId); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); + expect(sourceLogic.session.currentIndex, feedIndex); + expect(sourceLogic.session.currentEpisode.id, feedEpisodeId); }); testWidgets('should_render_player_loading_indicators_at_compact_size', ( @@ -1682,6 +1623,11 @@ Future _pumpApp( if (settle) await tester.pumpAndSettle(); } +PlayerLogic _pageLogic(WidgetTester tester, Finder page) { + final dynamic state = tester.state(page); + return state.logic as PlayerLogic; +} + class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; @@ -1697,6 +1643,12 @@ class _RouteVideoFactory { class _RouteVideoPlayer extends VideoPlayerPort { _RouteVideoPlayer(super.episode); + bool disposed = false; + int seekCalls = 0; + int playCalls = 0; + + bool get playing => controller.value.isPlaying; + @override Future initialize() async { controller.value = const VideoPlayerValue( @@ -1710,6 +1662,7 @@ class _RouteVideoPlayer extends VideoPlayerPort { @override Future play() async { + playCalls++; final value = controller.value; controller.value = VideoPlayerValue( duration: value.duration, @@ -1731,6 +1684,25 @@ class _RouteVideoPlayer extends VideoPlayerPort { isPlaying: false, ); } + + @override + Future seekTo(Duration position) async { + seekCalls++; + final value = controller.value; + controller.value = VideoPlayerValue( + duration: value.duration, + size: value.size, + position: position, + isInitialized: value.isInitialized, + isPlaying: value.isPlaying, + ); + } + + @override + Future dispose() async { + disposed = true; + await super.dispose(); + } } class _RecordingPlayer implements PlayerPort {