From 699fd427b80efcf16cbea1fa407de3a2ce249606 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Tue, 20 Oct 2026 19:24:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E8=B7=AF=E7=94=B1=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E6=97=B6=E6=9A=82=E5=81=9C=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app.dart | 2 + lib/core/navigation/app_route_observer.dart | 7 ++ lib/pages/player/player_logic.dart | 14 +++ lib/pages/player/player_view.dart | 26 +++++- test/player/player_fullscreen_test.dart | 94 ++++++++++++++++++++- 5 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 lib/core/navigation/app_route_observer.dart diff --git a/lib/app.dart b/lib/app.dart index f08cbbd..3f353e5 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -44,6 +44,7 @@ import 'core/data/services/locale_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; import 'core/data/services/wallet_controller.dart'; +import 'core/navigation/app_route_observer.dart'; import 'core/platform/haptics.dart'; import 'core/theme/app_theme.dart'; import 'core/theme/app_colors.dart'; @@ -266,6 +267,7 @@ class _LumaDramaAppState extends State { onGenerateTitle: (context) => AppLocalizations.of(context)!.appName, supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, + navigatorObservers: [appRouteObserver], localeResolutionCallback: (deviceLocale, _) => resolveAppLocale(deviceLocale), debugShowCheckedModeBanner: false, diff --git a/lib/core/navigation/app_route_observer.dart b/lib/core/navigation/app_route_observer.dart new file mode 100644 index 0000000..cb5d4e1 --- /dev/null +++ b/lib/core/navigation/app_route_observer.dart @@ -0,0 +1,7 @@ +// Author: fruit.com.cn + +import 'package:flutter/widgets.dart'; + +/// 全局页面路由观察者:供播放器在路由被覆盖时暂停。 +final RouteObserver> appRouteObserver = + RouteObserver>(); diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 3d47495..2ab8cca 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -289,6 +289,20 @@ class PlayerLogic extends ChangeNotifier { } } + /// 当前播放器页面被其它 PageRoute 覆盖时暂停并保存进度。 + void onRouteCovered() { + if (!_started) return; + session.setRouteVisible(false); + cancelScrub(); + unawaited(saveCurrentProgress()); + } + + /// 覆盖播放器的 PageRoute 返回后恢复当前 Tab 的播放状态。 + void onRouteRevealed() { + if (!_started || !isMounted()) return; + session.setRouteVisible(active); + } + void onPageUpdated(PlayerPage previous) { if (previous.active != active) { session.setRouteVisible(active && visibleIndices.isNotEmpty); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index b436a98..2bbdb2d 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -14,6 +14,7 @@ import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/formatting/error_text.dart'; +import '../../core/navigation/app_route_observer.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; @@ -86,8 +87,10 @@ class PlayerPage extends StatefulWidget { State createState() => _PlayerPageState(); } -class _PlayerPageState extends State with WidgetsBindingObserver { +class _PlayerPageState extends State + with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; + PageRoute? _subscribedRoute; bool _chromeVisible = true; double _playbackSpeed = 1.0; bool _boosting = false; @@ -121,14 +124,35 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic.onPageUpdated(oldWidget); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is! PageRoute || route == _subscribedRoute) return; + if (_subscribedRoute != null) appRouteObserver.unsubscribe(this); + _subscribedRoute = route; + appRouteObserver.subscribe(this, route); + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { logic.onLifecycle(state); } + @override + void didPushNext() { + logic.onRouteCovered(); + } + + @override + void didPopNext() { + logic.onRouteRevealed(); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); + appRouteObserver.unsubscribe(this); logic.dispose(); super.dispose(); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 0048073..29f26f2 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -10,11 +12,14 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/interaction.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; +import 'package:fruitshorts/core/data/models/player_port.dart'; 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/theme/app_colors.dart'; import 'package:fruitshorts/core/widgets/remote_image.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; @@ -61,6 +66,72 @@ void main() { _expectPlayerNavigationBar(tester); }); + testWidgets('should_pause_and_resume_player_when_page_route_covers_it', ( + tester, + ) async { + final players = <_RecordingPlayer>[]; + await _pumpApp( + tester, + playerFactory: (_) { + final player = _RecordingPlayer(); + players.add(player); + return player; + }, + ); + await _openSeriesPlayer(tester); + final player = players.firstWhere((item) => item.playing); + expect(player.playing, isTrue); + + final navigator = tester.state( + find.byType(Navigator).first, + ); + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(key: Key('test-covering-route')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('test-covering-route')), findsOneWidget); + expect(player.playing, isFalse); + + navigator.pop(); + await tester.pumpAndSettle(); + + expect(player.playing, isTrue); + }); + + testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( + tester, + ) async { + final players = <_RecordingPlayer>[]; + await _pumpApp( + tester, + playerFactory: (_) { + final player = _RecordingPlayer(); + players.add(player); + return player; + }, + ); + final player = players.firstWhere((item) => item.playing); + expect(player.playing, isTrue); + + await tester.tap(find.byKey(const Key('rail-save'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign In')); + await tester.pumpAndSettle(); + + expect(find.byType(AuthPage), findsOneWidget); + expect(player.playing, isFalse); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(player.playing, isTrue); + }); + testWidgets('should_fall_back_to_black_when_poster_is_empty', (tester) async { await _pumpApp( tester, @@ -1071,6 +1142,7 @@ Future _pumpApp( FakeCatalogRepository? catalog, FakePlaybackRepository? playback, FakeInteractionRepository? interactions, + PlayerFactory? playerFactory, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -1079,7 +1151,7 @@ Future _pumpApp( final repository = catalog ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( - playerFactory: createWidgetPlayer, + playerFactory: playerFactory ?? createWidgetPlayer, catalogRepository: repository, categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), @@ -1094,6 +1166,26 @@ Future _pumpApp( await tester.pumpAndSettle(); } +class _RecordingPlayer implements PlayerPort { + bool playing = false; + + @override + Future initialize() async {} + + @override + Future play() async { + playing = true; + } + + @override + Future pause() async { + playing = false; + } + + @override + Future dispose() async {} +} + const _lockedDrama = Drama( id: 'locked-demo', title: 'Locked Demo',