From d623c2164fdc8e599a16c1aecf34825fa0472bc7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:18:27 +0200 Subject: [PATCH] fix(player): exit on phone back even with the chrome up (#1938) 4443b761 staged the system-back path (strip/fullscreen/hide/exit), so on Android a back with the controls visible hid them instead of closing the player. The PlayerNavigationCoordinator now takes an exitPlayerBeforeChrome policy; the player enables it on mobile, restoring immediate exit on Back while TV/desktop keep the staged chrome handling. --- lib/screens/video_player_screen.dart | 5 +++ .../video_controls/video_controls.dart | 10 +++++ test/widgets/video_controls_test.dart | 39 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 649b0555a..ffe74fd0c 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -927,6 +927,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin dismissPrompt: _dismissPlaybackPromptForBack, isChromePresented: () => _isPlayerInitialized && player != null && _hasFirstFrame.value && _chromeController.controlsPresented, + // On phones a Back must exit the player even with the controls up + // (#1938); the staged chrome handling is TV/desktop behavior (#4443b761 + // applied it to the phone system-back path too, so back stopped + // closing the player). + exitPlayerBeforeChrome: () => PlatformDetector.isMobile(context), exitFullscreenIfActive: FullscreenStateManager().exitFullscreenIfActive, // macOS fullscreen belongs to the app window, while HTPC-style player // navigation treats physical Escape as semantic Back. In both cases the diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 0233e5bdf..bdd1300ea 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -300,6 +300,7 @@ class PlayerNavigationCoordinator { final bool Function() isChromePresented; final Future Function() exitFullscreenIfActive; final bool Function() _physicalEscapeExitsFullscreen; + final bool Function() _exitPlayerBeforeChrome; final VoidCallback exitPlayer; final VoidCallback navigateHome; final bool Function() isActive; @@ -313,14 +314,17 @@ class PlayerNavigationCoordinator { required this.isChromePresented, required this.exitFullscreenIfActive, bool Function()? physicalEscapeExitsFullscreen, + bool Function()? exitPlayerBeforeChrome, required this.exitPlayer, required this.navigateHome, bool Function()? isActive, }) : _physicalEscapeExitsFullscreen = physicalEscapeExitsFullscreen ?? _alwaysTrue, + _exitPlayerBeforeChrome = exitPlayerBeforeChrome ?? _alwaysFalse, isActive = isActive ?? _alwaysActive; static bool _alwaysActive() => true; static bool _alwaysTrue() => true; + static bool _alwaysFalse() => false; void handle(PlayerNavigationKey navigationKey) { if (navigationKey == PlayerNavigationKey.home) { @@ -331,6 +335,12 @@ class PlayerNavigationCoordinator { dismissPrompt(); return; } + if (navigationKey == PlayerNavigationKey.back && _exitPlayerBeforeChrome()) { + // Mobile Back exits even with the chrome up (#1938); the staged + // strip/fullscreen/hide/exit chain is TV and desktop behavior. + exitPlayer(); + return; + } final disposition = resolvePlayerBackDisposition( navigationKey: navigationKey, contentStripVisible: chromeController.contentStripVisible, diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index a39b40d7e..1fddaa850 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -570,6 +570,7 @@ void main() { Future Function()? exitFullscreenIfActive, bool physicalEscapeExitsFullscreen = true, bool Function()? physicalEscapeExitsFullscreenProvider, + bool Function()? exitPlayerBeforeChrome, VoidCallback? exitPlayer, VoidCallback? navigateHome, bool Function()? isActive, @@ -581,6 +582,7 @@ void main() { isChromePresented: isChromePresented ?? () => chromeController.controlsPresented, exitFullscreenIfActive: exitFullscreenIfActive ?? () async => false, physicalEscapeExitsFullscreen: physicalEscapeExitsFullscreenProvider ?? () => physicalEscapeExitsFullscreen, + exitPlayerBeforeChrome: exitPlayerBeforeChrome, exitPlayer: exitPlayer ?? () {}, navigateHome: navigateHome ?? () {}, isActive: isActive, @@ -635,6 +637,43 @@ void main() { expect(exits, 1); }); + testWidgets('mobile Back exits on the first press even with the chrome up (#1938)', (tester) async { + final chromeController = PlayerChromeController(); + addTearDown(chromeController.dispose); + var exits = 0; + final coordinator = coordinatorFor( + chromeController, + exitPlayerBeforeChrome: () => true, + exitPlayer: () => exits++, + ); + await pumpNavigationFocus(tester, coordinator); + + await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); + + expect(exits, 1, reason: 'a phone back must close the player even while the controls are up'); + expect(chromeController.controlsVisible, isTrue, reason: 'no staged hide: the player leaves directly'); + }); + + testWidgets('mobile Back dismisses an open prompt before exiting', (tester) async { + final chromeController = PlayerChromeController(); + addTearDown(chromeController.dispose); + var promptDismissals = 0; + var exits = 0; + final coordinator = coordinatorFor( + chromeController, + isPromptOpen: () => true, + dismissPrompt: () => promptDismissals++, + exitPlayerBeforeChrome: () => true, + exitPlayer: () => exits++, + ); + await pumpNavigationFocus(tester, coordinator); + + await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB); + + expect(promptDismissals, 1, reason: 'prompts keep priority over the mobile exit policy'); + expect(exits, 0); + }); + testWidgets('Back exits on the first press when the route opened with no chrome', (tester) async { final chromeController = PlayerChromeController(initiallyVisible: false); addTearDown(chromeController.dispose);