diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index 7c86289..17d67d1 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -69,7 +69,6 @@ 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_session.dart b/lib/core/data/services/playback_session.dart index 700648f..76d4ba6 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -27,7 +27,6 @@ class PlaybackSession extends ChangeNotifier { this.activate, PlaybackCoordinator? coordinator, this.waitForFrame, - this.warmNeighborsEnabled = true, }) : _coordinator = coordinator ?? PlaybackCoordinator(); final List episodes; @@ -40,7 +39,6 @@ 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; @@ -142,8 +140,8 @@ class PlaybackSession extends ChangeNotifier { } void _rememberNeighbors(int? preloadIndex, int? previousIndex) { - _nextIndex = warmNeighborsEnabled ? preloadIndex : null; - _previousIndex = warmNeighborsEnabled ? previousIndex : null; + _nextIndex = preloadIndex; + _previousIndex = previousIndex; } Future _enqueue(Future Function() operation) { diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart index cf7c6e1..fbb7b10 100644 --- a/lib/core/data/services/playback_session_controls.dart +++ b/lib/core/data/services/playback_session_controls.dart @@ -6,12 +6,7 @@ part of 'playback_session.dart'; extension PlaybackSessionControls on PlaybackSession { /// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。 void prewarmNeighbors({required bool forward}) { - if (!warmNeighborsEnabled || - !_visible || - _overlayOpen || - _active?.prepared != true) { - return; - } + if (!_visible || _overlayOpen || _active?.prepared != true) return; _warmNeighbors(this, preferredNext: forward); } @@ -62,6 +57,7 @@ extension PlaybackSessionControls on PlaybackSession { slot.activated = true; } await _applyPlayback(slot); + if (_visible && !_overlayOpen && !_loading) _warmNeighbors(this); if (kDebugMode) { debugPrint( '[feed-handoff] phase=resumed activated=${slot.activated} ' diff --git a/lib/core/data/services/video_player_port.dart b/lib/core/data/services/video_player_port.dart index 2cb2b1f..032378a 100644 --- a/lib/core/data/services/video_player_port.dart +++ b/lib/core/data/services/video_player_port.dart @@ -2,6 +2,7 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:video_player/video_player.dart'; import '../../config/app_config.dart'; @@ -68,6 +69,13 @@ class VideoPlayerPort implements PlayerPort { Future dispose() async { if (_disposed) return; _disposed = true; + if (kDebugMode) { + debugPrint( + '[player-controller] phase=dispose ' + 'controller=${identityHashCode(controller)} ' + 'source=${controller.dataSource}\n${StackTrace.current}', + ); + } try { await _initialization; } catch (_) { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index cb181a8..b684845 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'package:flutter/scheduler.dart'; @@ -221,30 +222,36 @@ class PlayerLogic extends ChangeNotifier { /// 独立播放页覆盖首页时只暂停并保存进度,首页 session 与 controller 保持存活。 Future pauseForFullPlayer() async { if (!_started) return; + _logFeedHandoff('pause'); cancelScrub(); await session.pauseForRouteHandoff(); + _logFeedHandoff('paused'); 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; + _logFeedHandoff('resume'); if (visibleIndices.isNotEmpty) { await session.resumeForRouteHandoff(); } else { session.setRouteVisible(false); await session.playbackSettled; } + _logFeedHandoff('resumed'); + } + + void _logFeedHandoff(String phase) { + if (!kDebugMode) return; + final player = session.active; + final video = player is VideoPlayerPort ? player : null; + debugPrint( + '[feed-session] phase=$phase session=${identityHashCode(session)} ' + 'index=${session.currentIndex} episode=${session.currentEpisode.id} ' + 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' + 'live=${session.livePlayerCount}', + ); } /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 @@ -283,7 +290,6 @@ class PlayerLogic extends ChangeNotifier { activate: activatePlayer, coordinator: coordinator, waitForFrame: _waitForFrame, - warmNeighborsEnabled: !homeMode, ); } @@ -1230,7 +1236,6 @@ class PlayerLogic extends ChangeNotifier { ), ); if (!isMounted()) return; - await prepareFeedResume(); await resumeAfterFullPlayer(); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 92a60f0..a54f0a5 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -57,12 +57,8 @@ const double _playerLoadingStrokeWidth = 2; const double _feedRefreshTopGap = 56; class PlayerPageHandoff { - PlayerPageHandoff({ - required this.prepareSource, - required this.sourceUserWantsPlay, - }); + PlayerPageHandoff({required this.sourceUserWantsPlay}); - final Future Function() prepareSource; final bool sourceUserWantsPlay; } @@ -145,8 +141,6 @@ class _PlayerPageState extends State DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; bool _presentationActive = false; - bool _presentationSuspended = false; - bool _presentationReturning = false; bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -538,8 +532,6 @@ class _PlayerPageState extends State } Widget _buildPlayer(BuildContext context) { - if (_presentationSuspended) return const SizedBox.expand(); - if (_presentationReturning) return _buildPresentationReturnLayer(); final interactions = context.watch(); final l10n = AppLocalizations.of(context)!; final indices = logic.visibleIndices; @@ -736,17 +728,6 @@ class _PlayerPageState extends State }; } - Widget _buildPresentationReturnLayer() { - final indices = logic.visibleIndices; - return AnimatedBuilder( - animation: logic.session, - builder: (context, _) => PlayerRenderStack( - pageController: _pageController, - slots: _renderSlots(indices), - ), - ); - } - PlayerRenderSlotData _renderSlot( PlayerRenderSlotRole role, int? index, @@ -1191,10 +1172,7 @@ class _PlayerPageState extends State _preparingFullPlayer = false; } if (!mounted || !context.mounted) return; - final handoff = PlayerPageHandoff( - prepareSource: _prepareSourceAfterFullPlayer, - sourceUserWantsPlay: sourceUserWantsPlay, - ); + final handoff = PlayerPageHandoff(sourceUserWantsPlay: sourceUserWantsPlay); _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( @@ -1208,41 +1186,19 @@ class _PlayerPageState extends State ), ), ); - await WidgetsBinding.instance.endOfFrame; - if (mounted) { - setState(() => _presentationSuspended = true); - await WidgetsBinding.instance.endOfFrame; - } await route; if (!mounted) return; logic.attach(widget, isMounted: () => mounted); - setState(() { - _presentationActive = false; - _presentationSuspended = false; - _presentationReturning = false; - }); + setState(() => _presentationActive = false); await logic.resumeAfterFullPlayer(); if (!mounted) return; _logFullPlayerRoute('restored'); } - Future _prepareSourceAfterFullPlayer() async { - if (!mounted) return; - logic.attach(widget, isMounted: () => mounted); - await logic.prepareFeedResume(); - if (!mounted) return; - setState(() { - _presentationSuspended = false; - _presentationReturning = true; - }); - await WidgetsBinding.instance.endOfFrame; - } - 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/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 87884a7..29d2198 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -348,7 +348,9 @@ class _PlayerVideoSurfaceState extends State debugPrint( '[player-layer] ts=${DateTime.now().millisecondsSinceEpoch} ' 'slot=${widget.debugSlot} index=${widget.debugIndex ?? '-'} ' - 'phase=$phase init=${value?.isInitialized == true ? 1 : 0}', + 'phase=$phase state=${identityHashCode(this)} ' + 'controller=${widget.controller == null ? '-' : identityHashCode(widget.controller)} ' + 'init=${value?.isInitialized == true ? 1 : 0}', ); } diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index adb99f4..7595d08 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,33 +32,6 @@ 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 fa69f96..2e19255 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -109,14 +109,24 @@ void main() { final recommendPlayer = find.byKey(const Key('recommend-feed-player')); final sourceState = tester.state(recommendPlayer); final sourceLogic = _pageLogic(tester, recommendPlayer); + await sourceLogic.session.preloadSettled; + await tester.pumpAndSettle(); 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; + final feedPositionBefore = feedPlayer.controller.value.position; + final sourceLiveBefore = sourceLogic.session.livePlayerCount; + final sourceCurrentSurface = find.descendant( + of: recommendPlayer, + matching: find.byKey(const ValueKey('player-render-slot-current')), + ); + final sourceSurfaceState = tester.state(sourceCurrentSurface); + final sourceSurfaceElement = tester.element(sourceCurrentSurface); expect(sourceDramaId, 'vengeance-demo'); expect(sourceIndex, 2); - expect(sourceLogic.session.livePlayerCount, 1); + expect(sourceLiveBefore, greaterThan(1)); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); @@ -124,6 +134,8 @@ void main() { final fullPlayer = find.byType(PlayerPage); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); + final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; + expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); expect(feedPlayer.disposed, isFalse); expect(feedPlayer.playing, isFalse); @@ -150,6 +162,8 @@ void main() { final requestsBeforeReturn = playback.requests.length; await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); + await sourceLogic.session.preloadSettled; + await tester.pumpAndSettle(); final currentSurface = find.descendant( of: recommendPlayer, @@ -170,11 +184,15 @@ void main() { expect(sourceLogic.session.loading, isFalse); expect(sourceLogic.session.activeSlotActivated, isTrue); expect(sourceLogic.session.activeSlotPlaying, isTrue); - expect(feedPlayer.seekCalls, 1); + expect(feedPlayer.seekCalls, 0); expect(feedPlayer.playCalls, greaterThan(feedPlayCalls)); expect(playback.requests.length, requestsBeforeReturn); - expect(feedPlayer.controller.value.position, Duration.zero); + expect(feedPlayer.controller.value.position, feedPositionBefore); expect(feedPlayer.playing, isTrue); + expect(sourceLogic.session.livePlayerCount, sourceLiveBefore); + expect(sourceLogic.session.livePlayerCount, greaterThan(1)); + expect(tester.state(currentSurface), same(sourceSurfaceState)); + expect(tester.element(currentSurface), same(sourceSurfaceElement)); expect( find.descendant( of: currentSurface,