From 23e80dd637d9876329ba518587040cc5fbbb0a0b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 15:55:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E5=A4=8D=E7=94=A8=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E4=BC=9A=E8=AF=9D=E8=BF=9B=E5=85=A5=E5=85=A8=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 240 ++++++++++++++----- lib/pages/player/widgets/player_overlay.dart | 6 + test/player/player_fullscreen_test.dart | 118 ++++++++- test/recommend/recommend_sections_test.dart | 16 +- 4 files changed, 303 insertions(+), 77 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 41b8871..d11f033 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -128,6 +128,7 @@ class _PlayerPageState extends State DateTime? _lastDoubleTapAt; DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; + bool _fullPlayerPresented = false; final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 @@ -257,11 +258,13 @@ class _PlayerPageState extends State @override void didPushNext() { + if (_fullPlayerPresented) return; logic.onRouteCovered(); } @override void didPopNext() { + if (_fullPlayerPresented) return; logic.onRouteRevealed(); } @@ -930,7 +933,7 @@ class _PlayerPageState extends State ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (widget.homeMode) + else if (widget.homeMode && !_fullPlayerPresented) HomeFeedOverlay( l10n: l10n, drama: drama, @@ -959,10 +962,8 @@ class _PlayerPageState extends State onComments: () => unawaited(_openComments(context, drama, episode)), onShare: () => logic.shareDrama(context, drama), onDetail: () => unawaited(logic.openDetail(context)), - onOpenFullPlayer: () async { - await widget.onOpenFullPlayer?.call(drama, episode); - if (!mounted || !widget.active) return; - logic.session.setUserWantsPlay(true); + onOpenFullPlayer: () { + unawaited(_presentFullPlayer(context, index)); }, onEpisodes: () => logic.showEpisodes(context), onMore: _openCurrentMore, @@ -978,64 +979,8 @@ class _PlayerPageState extends State thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, ) - else - PlayerPlaybackOverlay( - data: PlayerOverlayData( - l10n: l10n, - drama: drama, - episode: episode, - controller: videoReady ? videoPort!.controller : null, - locked: locked, - liked: interactions.isEpisodeLiked(episode.id), - likeCount: - interactions.episodeLikeCount(episode.id) ?? - episode.likeCount, - commentCount: _commentCounts[episode.id] ?? episode.commentCount, - favoriteCount: drama.favoriteCount == null - ? null - : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), - saved: context.watch().isSaved(drama.id), - playing: logic.session.userWantsPlay, - playbackSpeed: _playbackSpeed, - totalEpisodes: logic.visibleIndices.length, - boosting: _boosting, - thumbnailService: _thumbnailService, - spriteAxisService: _spriteAxisService, - ), - scrubbing: _scrubbing, - actions: PlayerOverlayActions( - onBack: widget.embedded - ? null - : () => unawaited(logic.leave(context)), - onTogglePlay: _togglePlayback, - onClear: () => setState(() => _chromeVisible = false), - onExitClear: () => setState(() => _chromeVisible = true), - onDetail: () => unawaited(logic.openDetail(context)), - onSpeed: () => unawaited(_openSpeedSheet(context)), - onEpisodes: () => logic.showEpisodes(context), - onComments: () => - unawaited(_openComments(context, drama, episode)), - onLike: () => - unawaited(_toggleLike(context, interactions, episode)), - onSave: () => unawaited(_toggleSave(context, drama)), - onShare: () => logic.shareDrama(context, drama), - onMore: () => _openMore(context, episode), - onScrubStart: videoReady - ? () { - _setScrubbing(true); - logic.beginScrub(videoPort!); - } - : null, - onScrubEnd: () => _setScrubbing(false), - onSeek: videoReady - ? (position) => logic.seekTo(context, videoPort!, position) - : null, - onLongPressCenter: () => _openMore(context, episode), - onBoostStart: _startBoost, - onBoostEnd: _endBoost, - ), - controlsVisible: _chromeVisible, - ), + else if (!widget.homeMode) + _standardPlaybackOverlay(context, index, interactions), // 票据三态之三:后端判定不可播放 —— 用 accessType 驱动文案,不在前端猜 if (selected && !theater && locked) PlayerBlockedNotice( @@ -1075,4 +1020,173 @@ class _PlayerPageState extends State ], ); } + + Widget _standardPlaybackOverlay( + BuildContext context, + int index, + InteractionController interactions, { + bool presentation = false, + }) { + if (logic.pip || logic.pipPreparing) return const SizedBox.expand(); + final l10n = AppLocalizations.of(context)!; + final entry = logic.queue.entryAt(index); + final drama = entry.drama; + final episode = _resolvedEpisode(index); + final selected = index == logic.session.currentIndex; + final locked = + logic.isEpisodeLocked(episode) || + logic.session.isLocked || + logic.blockedEpisodeId == episode.id; + final port = locked + ? null + : (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final videoReady = videoPort?.controller.value.isInitialized ?? false; + return PlayerPlaybackOverlay( + data: PlayerOverlayData( + l10n: l10n, + drama: drama, + episode: episode, + controller: videoReady ? videoPort!.controller : null, + locked: locked, + liked: interactions.isEpisodeLiked(episode.id), + likeCount: + interactions.episodeLikeCount(episode.id) ?? episode.likeCount, + commentCount: _commentCounts[episode.id] ?? episode.commentCount, + favoriteCount: drama.favoriteCount == null + ? null + : drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0), + saved: context.watch().isSaved(drama.id), + playing: logic.session.userWantsPlay, + playbackSpeed: _playbackSpeed, + totalEpisodes: logic.visibleIndices.length, + boosting: _boosting, + thumbnailService: _thumbnailService, + spriteAxisService: _spriteAxisService, + ), + scrubbing: _scrubbing, + actions: PlayerOverlayActions( + onBack: presentation + ? () => Navigator.of(context).pop() + : widget.embedded + ? null + : () => unawaited(logic.leave(context)), + onTogglePlay: _togglePlayback, + onClear: () => setState(() => _chromeVisible = false), + onExitClear: () => setState(() => _chromeVisible = true), + onDetail: () => unawaited(logic.openDetail(context)), + onSpeed: () => unawaited(_openSpeedSheet(context)), + onEpisodes: () => logic.showEpisodes(context), + onComments: () => unawaited(_openComments(context, drama, episode)), + onLike: () => unawaited(_toggleLike(context, interactions, episode)), + onSave: () => unawaited(_toggleSave(context, drama)), + onShare: () => logic.shareDrama(context, drama), + onMore: () => _openMore(context, episode), + onScrubStart: videoReady + ? () { + _setScrubbing(true); + logic.beginScrub(videoPort!); + } + : null, + onScrubEnd: () => _setScrubbing(false), + onSeek: videoReady + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + onLongPressCenter: () => _openMore(context, episode), + onBoostStart: _startBoost, + 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(); + _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, + ); + }, + ), + ), + ), + ), + transitionsBuilder: (context, animation, _, child) => + FadeTransition(opacity: animation, child: child), + ), + ); + if (!mounted) return; + setState(() => _fullPlayerPresented = false); + _logFullPlayerRoute('restored'); + } + + void _logFullPlayerRoute(String phase) { + if (!kDebugMode) return; + final active = logic.session.active; + final video = active is VideoPlayerPort ? active : null; + debugPrint( + '[player-route] ts=${DateTime.now().millisecondsSinceEpoch} ' + 'phase=$phase ' + 'episode=${logic.session.currentEpisode.id} ' + 'controller=${video == null ? '-' : identityHashCode(video.controller)} ' + 'position=${video?.controller.value.position.inMilliseconds ?? '-'}', + ); + } + + 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 0a89365..a2ce47b 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -36,6 +36,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { required this.actions, required this.controlsVisible, required this.scrubbing, + this.onVerticalDragUpdate, + this.onVerticalDragEnd, }); /// 展示数据。 @@ -49,6 +51,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { /// 正在拖动进度条。拖动期间清屏,只留进度条。 final bool scrubbing; + final GestureDragUpdateCallback? onVerticalDragUpdate; + final GestureDragEndCallback? onVerticalDragEnd; @override Widget build(BuildContext context) { @@ -65,6 +69,8 @@ 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 34a1fc7..0d79293 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -17,12 +17,14 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; 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_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/seek_progress_bar.dart'; +import 'package:video_player/video_player.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -86,7 +88,9 @@ void main() { testWidgets('should_preserve_recommend_feed_when_full_player_pops', ( tester, ) async { - await _pumpApp(tester); + final factory = _RouteVideoFactory(); + final playback = FakePlaybackRepository(); + await _pumpApp(tester, playerFactory: factory.create, playback: playback); await tester.tap(find.byKey(const Key('tab-recommend'))); await tester.pumpAndSettle(); @@ -101,27 +105,80 @@ void main() { 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 controller = factory.created.first.controller; + final createdBefore = factory.created.length; + 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); + 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)); expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, + find.descendant( + of: currentSurface, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, ); tester.state(find.byType(Navigator).first).pop(); - await tester.pump(); + await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(recommendPlayer, findsOneWidget); expect(renderStack, findsOneWidget); + expect(factory.created.length, createdBefore); + expect(playback.requests.length, requestsBefore); + expect(controller.value.position, const Duration(seconds: 5)); expect(tester.state(recommendPlayer), same(playerState)); expect(tester.state(renderStack), same(renderState)); expect(find.byKey(const Key('recommend-feed-skeleton')), findsNothing); + expect( + find.descendant( + of: currentSurface, + matching: find.byKey(const Key('player-single-placeholder')), + ), + findsNothing, + ); + }); + 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(); - expect(tester.state(recommendPlayer), same(playerState)); - expect(tester.state(renderStack), same(renderState)); + await tester.tap(find.byKey(const Key('home-feed-full-series'))); + await tester.pumpAndSettle(); + + 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); }); testWidgets('should_render_player_loading_indicators_at_compact_size', ( @@ -1482,6 +1539,55 @@ Future _pumpApp( if (settle) await tester.pumpAndSettle(); } +class _RouteVideoFactory { + final List<_RouteVideoPlayer> created = []; + + PlayerPort create(Episode episode) { + final player = _RouteVideoPlayer(episode); + created.add(player); + return player; + } +} + +class _RouteVideoPlayer extends VideoPlayerPort { + _RouteVideoPlayer(super.episode); + + @override + Future initialize() async { + controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 5), + isInitialized: true, + isPlaying: true, + ); + } + + @override + Future play() async { + final value = controller.value; + controller.value = VideoPlayerValue( + duration: value.duration, + size: value.size, + position: value.position, + isInitialized: value.isInitialized, + isPlaying: true, + ); + } + + @override + Future pause() async { + final value = controller.value; + controller.value = VideoPlayerValue( + duration: value.duration, + size: value.size, + position: value.position, + isInitialized: value.isInitialized, + isPlaying: false, + ); + } +} + class _RecordingPlayer implements PlayerPort { bool playing = false; diff --git a/test/recommend/recommend_sections_test.dart b/test/recommend/recommend_sections_test.dart index 5868e65..f402be6 100644 --- a/test/recommend/recommend_sections_test.dart +++ b/test/recommend/recommend_sections_test.dart @@ -464,28 +464,28 @@ void main() { expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); }); - testWidgets('should_open_standalone_player_from_title_and_watch_all', ( + testWidgets('should_open_full_player_presentation_from_title_and_watch_all', ( tester, ) async { await _pumpRecommend(tester); await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.pumpAndSettle(); + expect(find.byType(PlayerPage), findsOneWidget); expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, + tester.widget(find.byType(PlayerPage)).homeMode, + isTrue, ); - await tester.binding.handlePopRoute(); + expect(find.byKey(const Key('player-back')), findsOneWidget); + tester.state(find.byType(Navigator).first).pop(); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget); await tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(PlayerPage).last).homeMode, - isFalse, - ); + expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byKey(const Key('player-back')), findsOneWidget); }); testWidgets('should_pull_refresh_recommend_feed_from_the_first_video', (