From bb2c729a7e524953a1beb6aa55876806b87c97ce Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 17:03:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E9=9A=94=E7=A6=BB=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E8=B5=84=E6=BA=90=E6=89=80=E6=9C=89=E6=9D=83=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=BB=91=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 14 +++++ lib/core/data/services/playback_session.dart | 51 ++++++++++++++- lib/pages/player/player_logic.dart | 5 +- lib/pages/player/player_view.dart | 49 ++++++++++++--- .../player/widgets/player_render_stack.dart | 15 ++++- .../player/widgets/player_video_surface.dart | 16 +++++ test/playback/playback_session_test.dart | 62 +++++++++++++++++++ test/player/player_fullscreen_test.dart | 24 +++++-- test/recommend/recommend_sections_test.dart | 12 ++-- 9 files changed, 223 insertions(+), 25 deletions(-) diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index 17d67d1..bed6800 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -15,6 +15,16 @@ class PlaybackCoordinator { if (!valid()) return false; if (_owner != session) { final previous = _owner; + if (previous != null && previous.owner != session.owner) { + if (kDebugMode) { + debugPrint( + '[player-owner] phase=reject-coordinator-handoff ' + 'owner=${session.owner.name} ' + 'activeOwner=${previous.owner.name}', + ); + } + return false; + } await previous?._releasePlayers(); if (previous != null && previous.livePlayerCount != 0) return false; _owner = null; @@ -56,6 +66,10 @@ Future _parkNeighbors( Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { if (slot == null || slot == session._active || slot.retiring) return; + if (slot.owner != session.owner) { + session._logOwnerRejection('park', slot); + return; + } if (session._next == null && slot.index == session._nextIndex) { session._next = slot; return; diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 76d4ba6..4637550 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -17,6 +17,8 @@ typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerPreparation = Future Function(Episode episode, PlayerPort player); +enum PlaybackSessionOwner { home, player } + /// Owns current plus previous/next frames; allocation stays serialized. class PlaybackSession extends ChangeNotifier { PlaybackSession({ @@ -27,6 +29,8 @@ class PlaybackSession extends ChangeNotifier { this.activate, PlaybackCoordinator? coordinator, this.waitForFrame, + this.owner = PlaybackSessionOwner.player, + this.dramaIdAt, }) : _coordinator = coordinator ?? PlaybackCoordinator(); final List episodes; @@ -39,6 +43,8 @@ class PlaybackSession extends ChangeNotifier { /// Runs only when a slot becomes current (e.g. progress listeners). final PlayerPreparation? activate; final Future Function()? waitForFrame; + final PlaybackSessionOwner owner; + final String? Function(int index)? dramaIdAt; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; _PlayerSlot? _active; @@ -161,7 +167,12 @@ class PlaybackSession extends ChangeNotifier { if (_live.length >= maxLivePlayers) { throw StateError('Playback resource limit reached'); } - final slot = _PlayerSlot(index, factory(episodes[index])); + final slot = _PlayerSlot( + index, + factory(episodes[index]), + owner: owner, + dramaId: dramaIdAt?.call(index), + ); _live.add(slot); slot.ready = () async { try { @@ -180,7 +191,19 @@ class PlaybackSession extends ChangeNotifier { } Future _retire(_PlayerSlot? slot) async { - if (slot == null || !_live.contains(slot)) return; + if (slot == null) return; + if (slot.owner != owner) { + _logOwnerRejection('retire', slot); + return; + } + if (!_live.contains(slot)) return; + if (kDebugMode) { + debugPrint( + '[player-owner] phase=retire owner=${slot.owner.name} ' + 'drama=${slot.dramaId ?? '-'} episodeIndex=${slot.index} ' + 'controller=${_playerIdentity(slot.player)}', + ); + } slot.retiring = true; final cleared = _active == slot || @@ -460,6 +483,15 @@ class PlaybackSession extends ChangeNotifier { if (!_closed) notifyListeners(); } + void _logOwnerRejection(String phase, _PlayerSlot slot) { + if (!kDebugMode) return; + debugPrint( + '[player-owner] phase=reject-$phase expected=${owner.name} ' + 'actual=${slot.owner.name} drama=${slot.dramaId ?? '-'} ' + 'episodeIndex=${slot.index} controller=${_playerIdentity(slot.player)}', + ); + } + Future close() async { if (_closed) return; _closed = true; @@ -470,9 +502,16 @@ class PlaybackSession extends ChangeNotifier { } class _PlayerSlot { - _PlayerSlot(this.index, this.player); + _PlayerSlot( + this.index, + this.player, { + required this.owner, + required this.dramaId, + }); int index; final PlayerPort player; + final PlaybackSessionOwner owner; + final String? dramaId; late final Future ready; bool prepared = false; bool activated = false; @@ -481,3 +520,9 @@ class _PlayerSlot { bool reusedWarm = false; Object? failure; } + +String _playerIdentity(PlayerPort player) { + return player is VideoPlayerPort + ? '${identityHashCode(player.controller)}' + : '${identityHashCode(player)}'; +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index b684845..69dfcb0 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -290,6 +290,10 @@ class PlayerLogic extends ChangeNotifier { activate: activatePlayer, coordinator: coordinator, waitForFrame: _waitForFrame, + owner: homeMode ? PlaybackSessionOwner.home : PlaybackSessionOwner.player, + dramaIdAt: (index) => index >= 0 && index < queue.length + ? queue.entryAt(index).drama.id + : null, ); } @@ -1231,7 +1235,6 @@ class PlayerLogic extends ChangeNotifier { initialIndex: index, wallet: wallet, downloads: downloads, - isolatePlaybackSession: true, ), ), ); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a54f0a5..fd5b85f 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -90,7 +90,7 @@ class PlayerPage extends StatefulWidget { this.wallet, this.downloads, this.handoff, - this.isolatePlaybackSession = false, + this.revealAfterFirstFrame = false, }); final Drama drama; @@ -118,7 +118,7 @@ class PlayerPage extends StatefulWidget { final WalletController? wallet; final DownloadController? downloads; final PlayerPageHandoff? handoff; - final bool isolatePlaybackSession; + final bool revealAfterFirstFrame; @override State createState() => _PlayerPageState(); @@ -141,6 +141,7 @@ class _PlayerPageState extends State DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; bool _presentationActive = false; + late bool _firstFrameReady; bool _leavingHandoff = false; bool _preparingFullPlayer = false; final Set _precachedPosterUrls = {}; @@ -152,6 +153,7 @@ class _PlayerPageState extends State @override void initState() { super.initState(); + _firstFrameReady = !widget.revealAfterFirstFrame; final handoff = widget.handoff; logic = PlayerBinding().create( history: context.read(), @@ -169,12 +171,18 @@ class _PlayerPageState extends State WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), - coordinator: handoff == null && !widget.isolatePlaybackSession + coordinator: widget.embedded ? context.read() : PlaybackCoordinator(), initialUserWantsPlay: handoff?.sourceUserWantsPlay ?? true, ); _pageController = logic.pages; + if (kDebugMode && widget.revealAfterFirstFrame) { + debugPrint( + '[player-entry] phase=waiting-first-frame ' + 'session=${identityHashCode(logic.session)}', + ); + } } @override @@ -594,6 +602,7 @@ class _PlayerPageState extends State PlayerRenderStack( pageController: _pageController, slots: _renderSlots(indices), + onCurrentVideoVisible: _handleCurrentVideoVisible, ), pageLayer, ], @@ -660,16 +669,37 @@ class _PlayerPageState extends State ), ), ); - if (widget.handoff == null) return player; + final revealContent = + !widget.revealAfterFirstFrame || + _firstFrameReady || + logic.session.isLocked || + logic.ticketError != null || + logic.blockedEpisodeId != null || + logic.session.error != null; + final presentedPlayer = widget.revealAfterFirstFrame + ? Opacity(opacity: revealContent ? 1 : 0, child: player) + : player; + if (widget.handoff == null) return presentedPlayer; return PopScope( canPop: _leavingHandoff, onPopInvokedWithResult: (didPop, _) { if (!didPop) unawaited(_leaveHandoff(context)); }, - child: player, + child: presentedPlayer, ); } + void _handleCurrentVideoVisible() { + if (!mounted || _firstFrameReady) return; + setState(() => _firstFrameReady = true); + if (kDebugMode) { + debugPrint( + '[player-entry] phase=ready ' + 'session=${identityHashCode(logic.session)}', + ); + } + } + void _openCurrentMore() { final entry = logic.queue.entryAt(logic.session.currentIndex); _openMore(context, entry.episode); @@ -1176,13 +1206,18 @@ class _PlayerPageState extends State _logFullPlayerRoute('present'); setState(() => _presentationActive = true); final route = Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PlayerPage( + PageRouteBuilder( + opaque: false, + barrierDismissible: false, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => PlayerPage( drama: seriesDrama, initialIndex: episodeIndex, handoff: handoff, wallet: logic.wallet, downloads: logic.downloads, + revealAfterFirstFrame: true, ), ), ); diff --git a/lib/pages/player/widgets/player_render_stack.dart b/lib/pages/player/widgets/player_render_stack.dart index 2f4dc29..fc19e0a 100644 --- a/lib/pages/player/widgets/player_render_stack.dart +++ b/lib/pages/player/widgets/player_render_stack.dart @@ -52,10 +52,12 @@ class PlayerRenderStack extends StatefulWidget { super.key, required this.pageController, required this.slots, + this.onCurrentVideoVisible, }); final PageController pageController; final Map slots; + final VoidCallback? onCurrentVideoVisible; @override State createState() => _PlayerRenderStackState(); @@ -68,7 +70,10 @@ class _PlayerRenderStackState extends State { void initState() { super.initState(); _lastCurrentIndex = _currentSlot.index; - _log('init current=${_lastCurrentIndex ?? '-'}'); + _log( + 'init current=${_lastCurrentIndex ?? '-'} ' + 'pageController=${identityHashCode(widget.pageController)}', + ); } @override @@ -86,7 +91,10 @@ class _PlayerRenderStackState extends State { @override void dispose() { - _log('dispose current=${_lastCurrentIndex ?? '-'}'); + _log( + 'dispose current=${_lastCurrentIndex ?? '-'} ' + 'pageController=${identityHashCode(widget.pageController)}', + ); super.dispose(); } @@ -189,6 +197,9 @@ class _PlayerRenderStackState extends State { fallback: slot.fallback, warmSlotReused: slot.warmSlotReused, waitForPlaybackFrame: slot.waitForPlaybackFrame, + onVideoVisible: slot.role == PlayerRenderSlotRole.current + ? widget.onCurrentVideoVisible + : null, ), ), ); diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 29d2198..49f52f6 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -28,6 +28,7 @@ class PlayerVideoSurface extends StatefulWidget { this.debugSlot = 'default', this.debugIndex, this.showFallbackBackground = true, + this.onVideoVisible, }); final VideoPlayerController? controller; @@ -57,6 +58,7 @@ class PlayerVideoSurface extends StatefulWidget { final bool showFallbackBackground; final String videoUrl; final bool theater; + final VoidCallback? onVideoVisible; @override State createState() => _PlayerVideoSurfaceState(); @@ -73,6 +75,7 @@ class _PlayerVideoSurfaceState extends State bool _confirmationScheduled = false; bool _transitioning = false; bool _videoVisible = false; + bool _videoVisibleNotified = false; String? _lastLayerState; @override @@ -279,6 +282,7 @@ class _PlayerVideoSurfaceState extends State }); } _logLayer('video', widget.controller?.value); + _notifyVideoVisible(); return; } if (initial) { @@ -303,6 +307,7 @@ class _PlayerVideoSurfaceState extends State _placeholderSnapshot = null; }); _logLayer('fade-end', widget.controller?.value); + _notifyVideoVisible(); } void _resetForControllerChange() { @@ -314,9 +319,20 @@ class _PlayerVideoSurfaceState extends State _confirmationScheduled = false; _transitioning = false; _videoVisible = false; + _videoVisibleNotified = false; _lastLayerState = null; } + void _notifyVideoVisible() { + if (_videoVisibleNotified) return; + _videoVisibleNotified = true; + final callback = widget.onVideoVisible; + if (callback == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) callback(); + }); + } + void _logLayer(String phase, VideoPlayerValue? value) { if (!kDebugMode) return; final fallback = phase == 'video' || phase == 'fade-end' diff --git a/test/playback/playback_session_test.dart b/test/playback/playback_session_test.dart index 7595d08..a62f69c 100644 --- a/test/playback/playback_session_test.dart +++ b/test/playback/playback_session_test.dart @@ -32,6 +32,68 @@ void main() { await session.close(); }); + test('should_not_retire_home_controller_when_player_switches_episodes', () async { + final factory = FakeFactory(); + final home = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.home, + ); + await home.select(0, preloadIndex: 1); + await home.preloadSettled; + final homePlayer = home.active; + expect(homePlayer, isNotNull); + + final player = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.player, + ); + await player.select(0, preloadIndex: 1); + await player.select(1, previousIndex: 0); + await player.preloadSettled; + + expect(home.active, same(homePlayer)); + expect( + factory.created + .firstWhere((candidate) => identical(candidate, homePlayer)) + .disposed, + isFalse, + ); + expect(player.active, isNot(same(homePlayer))); + await player.close(); + await home.close(); + }); + + test('should_keep_home_controller_alive_when_player_session_closes', () async { + final factory = FakeFactory(); + final home = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.home, + ); + await home.select(0); + final homePlayer = home.active! as FakePlayer; + + final player = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (episode) => episode.isFree, + owner: PlaybackSessionOwner.player, + ); + await player.select(0); + await player.select(1); + await player.close(); + + expect(homePlayer.disposed, isFalse); + expect(home.active, same(homePlayer)); + expect(home.currentIndex, 0); + await home.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 2e19255..131e698 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -131,7 +131,7 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - final fullPlayer = find.byType(PlayerPage); + final fullPlayer = _fullPlayerFinder(); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; @@ -223,11 +223,17 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); + expect(_fullPlayerFinder(), findsOneWidget); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - 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-center-play')).hitTestable(), + findsNothing, + ); + expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); + expect( + find.byKey(const Key('recommend-top-nav')).hitTestable(), + findsNothing, + ); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); @@ -259,7 +265,7 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - final fullLogic = _pageLogic(tester, find.byType(PlayerPage)); + final fullLogic = _pageLogic(tester, _fullPlayerFinder()); await tester.drag( find.byKey(const Key('player-video-tap')), const Offset(0, -560), @@ -1646,6 +1652,12 @@ PlayerLogic _pageLogic(WidgetTester tester, Finder page) { return state.logic as PlayerLogic; } +Finder _fullPlayerFinder() { + return find.byWidgetPredicate( + (widget) => widget is PlayerPage && widget.handoff != null, + ); +} + class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index a85104d..c39412e 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -471,13 +471,13 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byType(PlayerPage).last, findsOneWidget); expect( - tester.widget(find.byType(PlayerPage)).homeMode, + tester.widget(find.byType(PlayerPage).last).homeMode, isFalse, ); expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); @@ -485,13 +485,13 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byType(PlayerPage).last, findsOneWidget); expect( - tester.widget(find.byType(PlayerPage)).homeMode, + tester.widget(find.byType(PlayerPage).last).homeMode, isFalse, ); expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); });