diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index d11f033..75627bc 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -55,6 +55,20 @@ const double _playerLoadingSize = 24; const double _playerLoadingStrokeWidth = 2; const double _feedRefreshTopGap = 56; +class PlayerPageHandoff { + PlayerPageHandoff({ + required this.logic, + required this.initialIndex, + required this.prepareSource, + }); + + final PlayerLogic logic; + final int initialIndex; + final Future Function() prepareSource; + PageController? destinationController; + VoidCallback? activateDestination; +} + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -82,6 +96,7 @@ class PlayerPage extends StatefulWidget { this.onChannelSelected, this.wallet, this.downloads, + this.handoff, }); final Drama drama; @@ -108,6 +123,7 @@ class PlayerPage extends StatefulWidget { final ValueChanged? onChannelSelected; final WalletController? wallet; final DownloadController? downloads; + final PlayerPageHandoff? handoff; @override State createState() => _PlayerPageState(); @@ -116,6 +132,8 @@ 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; PageRoute? _subscribedRoute; @@ -128,7 +146,11 @@ class _PlayerPageState extends State DateTime? _lastDoubleTapAt; DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; - bool _fullPlayerPresented = false; + bool _presentationActive = false; + bool _presentationSuspended = false; + bool _presentationReturning = false; + bool _leavingHandoff = false; + PageController? _returnPageController; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -138,13 +160,17 @@ class _PlayerPageState extends State @override void initState() { super.initState(); - logic = PlayerBinding().create( - history: context.read(), - playback: context.read(), - positionCache: context.read(), - wallet: widget.wallet, - downloads: widget.downloads, - ); + 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.attach(widget, isMounted: () => mounted); _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, @@ -152,10 +178,20 @@ class _PlayerPageState extends State _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) ..addListener(_handleSpriteFrameChanged); WidgetsBinding.instance.addObserver(this); - logic.start( - factory: context.read(), - coordinator: context.read(), - ); + if (handoff == null) { + logic.start( + factory: context.read(), + coordinator: context.read(), + ); + _pageController = logic.pages; + } else { + final position = logic.visibleIndices.indexOf(handoff.initialIndex); + _pageController = PageController( + initialPage: position < 0 ? 0 : position, + ); + handoff.destinationController = _pageController; + logic.session.setRouteVisible(widget.active); + } } @override @@ -258,13 +294,13 @@ class _PlayerPageState extends State @override void didPushNext() { - if (_fullPlayerPresented) return; + if (_presentationActive) return; logic.onRouteCovered(); } @override void didPopNext() { - if (_fullPlayerPresented) return; + if (_presentationActive) return; logic.onRouteRevealed(); } @@ -276,7 +312,11 @@ class _PlayerPageState extends State ..removeListener(_handleSpriteFrameChanged) ..dispose(); _thumbnailService.dispose(); - logic.dispose(); + if (_ownsLogic) { + logic.dispose(); + } else { + _pageController.dispose(); + } super.dispose(); } @@ -513,6 +553,8 @@ 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; @@ -523,7 +565,7 @@ class _PlayerPageState extends State final pages = NotificationListener( onNotification: _handlePageScroll, child: PageView.builder( - controller: logic.pages, + controller: _pageController, scrollDirection: Axis.vertical, allowImplicitScrolling: true, physics: widget.onRefreshFeed == null @@ -563,7 +605,7 @@ class _PlayerPageState extends State final pageLayer = widget.onRefreshFeed == null ? pages : FeedPullRefresh( - pageController: logic.pages, + pageController: _pageController, topInset: MediaQuery.paddingOf(context).top + _feedRefreshTopGap, onRefresh: _refreshHomeFeed, @@ -573,7 +615,7 @@ class _PlayerPageState extends State fit: StackFit.expand, children: [ PlayerRenderStack( - pageController: logic.pages, + pageController: _pageController, slots: _renderSlots(indices), ), pageLayer, @@ -620,7 +662,7 @@ class _PlayerPageState extends State ), ], ); - return AnnotatedRegion( + final player = AnnotatedRegion( key: const Key('player-system-overlay'), value: SystemUiOverlayStyle.light.copyWith( statusBarColor: AppColors.transparent, @@ -641,6 +683,14 @@ class _PlayerPageState extends State ), ), ); + if (widget.handoff == null) return player; + return PopScope( + canPop: _leavingHandoff, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) unawaited(_leaveHandoff(context)); + }, + child: player, + ); } void _openCurrentMore() { @@ -701,6 +751,18 @@ class _PlayerPageState extends State }; } + Widget _buildPresentationReturnLayer() { + final indices = logic.visibleIndices; + final controller = _returnPageController ?? _pageController; + return AnimatedBuilder( + animation: logic.session, + builder: (context, _) => PlayerRenderStack( + pageController: controller, + slots: _renderSlots(indices), + ), + ); + } + PlayerRenderSlotData _renderSlot( PlayerRenderSlotRole role, int? index, @@ -933,7 +995,7 @@ class _PlayerPageState extends State ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (widget.homeMode && !_fullPlayerPresented) + else if (widget.homeMode && !_presentationActive) HomeFeedOverlay( l10n: l10n, drama: drama, @@ -1024,9 +1086,8 @@ class _PlayerPageState extends State Widget _standardPlaybackOverlay( BuildContext context, int index, - InteractionController interactions, { - bool presentation = false, - }) { + InteractionController interactions, + ) { if (logic.pip || logic.pipPreparing) return const SizedBox.expand(); final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); @@ -1067,8 +1128,8 @@ class _PlayerPageState extends State ), scrubbing: _scrubbing, actions: PlayerOverlayActions( - onBack: presentation - ? () => Navigator.of(context).pop() + onBack: widget.handoff != null + ? () => unawaited(_leaveHandoff(context)) : widget.embedded ? null : () => unawaited(logic.leave(context)), @@ -1098,56 +1159,82 @@ class _PlayerPageState extends State onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, - onVerticalDragUpdate: presentation - ? _handleFullPlayerDragUpdate - : null, - onVerticalDragEnd: presentation ? _handleFullPlayerDragEnd : null, ); } Future _presentFullPlayer(BuildContext context, int index) async { - if (_fullPlayerPresented || !logic.pages.hasClients) return; - // The route owns presentation only; PlaybackSession keeps the controller. - final interactions = context.read(); + if (_presentationActive || !_pageController.hasClients) return; + final sourcePageController = _pageController; + final handoff = PlayerPageHandoff( + logic: logic, + initialIndex: index, + prepareSource: _prepareSourceAfterFullPlayer, + ); + handoff.activateDestination = () { + final destination = handoff.destinationController; + if (destination != null) logic.pages = destination; + }; _logFullPlayerRoute('present'); - setState(() => _fullPlayerPresented = true); - await Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - barrierColor: null, - barrierDismissible: false, - transitionDuration: const Duration(milliseconds: 180), - reverseTransitionDuration: const Duration(milliseconds: 160), - pageBuilder: (routeContext, _, _) => Material( - color: AppColors.transparent, - child: Scaffold( - backgroundColor: AppColors.transparent, - body: ListenableBuilder( - listenable: logic, - builder: (context, _) => AnimatedBuilder( - animation: logic.session, - builder: (context, _) { - final current = logic.session.currentIndex; - return _standardPlaybackOverlay( - routeContext, - current, - interactions, - presentation: true, - ); - }, - ), - ), - ), + setState(() => _presentationActive = true); + final route = Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayerPage( + drama: logic.queue.entryAt(index).drama, + initialIndex: index, + handoff: handoff, + wallet: logic.wallet, + downloads: logic.downloads, ), - transitionsBuilder: (context, animation, _, child) => - FadeTransition(opacity: animation, child: child), ), ); + await WidgetsBinding.instance.endOfFrame; + if (mounted) { + setState(() => _presentationSuspended = true); + await WidgetsBinding.instance.endOfFrame; + } + sourcePageController.dispose(); + handoff.activateDestination?.call(); + await route; if (!mounted) return; - setState(() => _fullPlayerPresented = false); + 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; + }); + 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, + ); + logic.attach(widget, isMounted: () => mounted); + setState(() { + _presentationSuspended = false; + _presentationReturning = true; + }); + await WidgetsBinding.instance.endOfFrame; + } + + Future _leaveHandoff(BuildContext context) async { + if (_leavingHandoff) return; + setState(() => _leavingHandoff = true); + await widget.handoff?.prepareSource(); + if (context.mounted) Navigator.of(context).pop(); + } + void _logFullPlayerRoute(String phase) { if (!kDebugMode) return; final active = logic.session.active; @@ -1161,32 +1248,4 @@ class _PlayerPageState extends State ); } - void _handleFullPlayerDragUpdate(DragUpdateDetails details) { - if (!logic.pages.hasClients) return; - final position = logic.pages.position; - final next = (position.pixels - details.delta.dy).clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); - if ((next - position.pixels).abs() < 0.5) return; - position.jumpTo(next); - } - - void _handleFullPlayerDragEnd(DragEndDetails details) { - if (!logic.pages.hasClients || logic.visibleIndices.isEmpty) return; - final position = logic.pages.position; - final page = position.pixels / position.viewportDimension; - final velocity = details.primaryVelocity ?? 0; - var target = page.round(); - if (velocity < -260) target = page.floor() + 1; - if (velocity > 260) target = page.ceil() - 1; - target = target.clamp(0, logic.visibleIndices.length - 1); - unawaited( - logic.pages.animateToPage( - target, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOut, - ), - ); - } } diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index a2ce47b..0a89365 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -36,8 +36,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { required this.actions, required this.controlsVisible, required this.scrubbing, - this.onVerticalDragUpdate, - this.onVerticalDragEnd, }); /// 展示数据。 @@ -51,8 +49,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { /// 正在拖动进度条。拖动期间清屏,只留进度条。 final bool scrubbing; - final GestureDragUpdateCallback? onVerticalDragUpdate; - final GestureDragEndCallback? onVerticalDragEnd; @override Widget build(BuildContext context) { @@ -69,8 +65,6 @@ class PlayerPlaybackOverlay extends StatelessWidget { _handleLongPressStart(details, constraints.maxWidth), onLongPressEnd: (_) => actions.onBoostEnd(), onLongPressCancel: actions.onBoostEnd, - onVerticalDragUpdate: onVerticalDragUpdate, - onVerticalDragEnd: onVerticalDragEnd, ), ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 0d79293..b7ee656 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -23,6 +23,7 @@ import 'package:fruitshorts/pages/auth/auth_view.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'; @@ -104,13 +105,15 @@ void main() { expect(recommendPlayer, findsOneWidget); expect(renderStack, findsOneWidget); final playerState = tester.state(recommendPlayer); - final renderState = tester.state(renderStack); 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 controller = factory.created.first.controller; final createdBefore = factory.created.length; final requestsBefore = playback.requests.length; @@ -126,19 +129,37 @@ void main() { 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!.initialIndex, sourceIndex); + expect(fullPlayer.handoff!.logic.session.currentIndex, sourceIndex); + 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(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: currentSurface, + of: fullCurrentSurface, matching: find.byKey(const Key('player-single-placeholder')), ), findsNothing, ); + expect( + tester.widget(fullCurrentSurface).controller, + same(controller), + ); - tester.state(find.byType(Navigator).first).pop(); + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); @@ -147,8 +168,9 @@ void main() { expect(factory.created.length, createdBefore); 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(tester.state(renderStack), same(renderState)); expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); expect( find.descendant( @@ -157,6 +179,10 @@ void main() { ), findsNothing, ); + expect( + tester.widget(currentSurface).controller, + same(controller), + ); }); testWidgets('should_swipe_full_player_without_creating_another_session', ( @@ -179,6 +205,35 @@ void main() { expect(find.byType(PlayerPage), findsOneWidget); expect(factory.created.length, lessThanOrEqualTo(createdBefore + 1)); expect(find.byKey(const Key('player-render-stack')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + }); + + testWidgets('should_show_one_paused_overlay_when_entering_full_player', ( + 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-gesture'))); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + expect(find.byType(PlayerPage), 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); + + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); testWidgets('should_render_player_loading_indicators_at_compact_size', ( diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index f402be6..a85104d 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -474,10 +474,11 @@ void main() { expect(find.byType(PlayerPage), findsOneWidget); expect( tester.widget(find.byType(PlayerPage)).homeMode, - isTrue, + isFalse, ); expect(find.byKey(const Key('player-back')), findsOneWidget); - tester.state(find.byType(Navigator).first).pop(); + expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); @@ -485,7 +486,14 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); expect(find.byType(PlayerPage), findsOneWidget); + expect( + tester.widget(find.byType(PlayerPage)).homeMode, + isFalse, + ); expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('home-tabs-bar')), findsNothing); + await tester.tap(find.byKey(const Key('player-back'))); + await tester.pumpAndSettle(); }); testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', (