diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 908aafbe..a6acf378 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -591,10 +591,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin isChromePresented: () => _isPlayerInitialized && player != null && _hasFirstFrame.value && _chromeController.controlsPresented, exitFullscreenIfActive: FullscreenStateManager().exitFullscreenIfActive, - // macOS fullscreen belongs to the app window, not the player route. - // Escape stages through chrome/player Back and the root Home screen - // owns leaving native fullscreen. - physicalEscapeExitsFullscreen: !Platform.isMacOS, + // macOS fullscreen belongs to the app window, while HTPC-style player + // navigation treats physical Escape as semantic Back. In both cases the + // player must leave native fullscreen alone. + physicalEscapeExitsFullscreen: () => shouldPhysicalEscapeExitFullscreen( + isMacOS: Platform.isMacOS, + videoPlayerNavigationEnabled: _videoPlayerNavigationEnabled, + ), exitPlayer: () => unawaited(_handleBackButton()), navigateHome: _handleHomeButton, isActive: () => mounted, diff --git a/lib/services/app_exit_service.dart b/lib/services/app_exit_service.dart index 1a56165a..0668d258 100644 --- a/lib/services/app_exit_service.dart +++ b/lib/services/app_exit_service.dart @@ -1,9 +1,12 @@ import 'dart:io' show Platform; +import 'dart:ui' as ui; import 'package:flutter/services.dart'; import '../utils/platform_detector.dart'; +typedef AppExitApplication = Future Function(ui.AppExitType exitType, int exitCode); + class AppExitService { static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); static const MethodChannel _channel = MethodChannel('com.plezy/app_exit'); @@ -12,7 +15,7 @@ class AppExitService { /// /// tvOS has no public API for force-quitting or going Home, so callers that /// handle a physical back/Menu key should let the event continue instead. - static Future requestExit() async { + static Future requestExit({AppExitApplication? exitApplicationForTesting}) async { if (_tvosBuild || PlatformDetector.isAppleTV()) return false; if (Platform.isAndroid) { @@ -27,6 +30,14 @@ class AppExitService { } } + if (PlatformDetector.isDesktopOS()) { + final exitApplication = + exitApplicationForTesting ?? + (exitType, exitCode) => ServicesBinding.instance.exitApplication(exitType, exitCode); + final response = await exitApplication(ui.AppExitType.required, 0); + return response == ui.AppExitResponse.exit; + } + await SystemNavigator.pop(); return true; } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 56d39944..cdeabae5 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -184,6 +184,10 @@ enum PlayerNavigationKey { none, physicalEscape, back, home } enum PlayerBackDisposition { closeContentStrip, exitFullscreenIfActive, hideControls, exitPlayer } +bool shouldPhysicalEscapeExitFullscreen({required bool isMacOS, required bool videoPlayerNavigationEnabled}) { + return !isMacOS && !videoPlayerNavigationEnabled; +} + /// Coordinates the player-level stages shared by keyboard, controller, and /// companion navigation after descendants have handled local overlays. class PlayerNavigationCoordinator { @@ -192,7 +196,7 @@ class PlayerNavigationCoordinator { final VoidCallback dismissPrompt; final bool Function() isChromePresented; final Future Function() exitFullscreenIfActive; - final bool physicalEscapeExitsFullscreen; + final bool Function() _physicalEscapeExitsFullscreen; final VoidCallback exitPlayer; final VoidCallback navigateHome; final bool Function() isActive; @@ -205,13 +209,15 @@ class PlayerNavigationCoordinator { required this.dismissPrompt, required this.isChromePresented, required this.exitFullscreenIfActive, - this.physicalEscapeExitsFullscreen = true, + bool Function()? physicalEscapeExitsFullscreen, required this.exitPlayer, required this.navigateHome, bool Function()? isActive, - }) : isActive = isActive ?? _alwaysActive; + }) : _physicalEscapeExitsFullscreen = physicalEscapeExitsFullscreen ?? _alwaysTrue, + isActive = isActive ?? _alwaysActive; static bool _alwaysActive() => true; + static bool _alwaysTrue() => true; void handle(PlayerNavigationKey navigationKey) { if (navigationKey == PlayerNavigationKey.home) { @@ -226,7 +232,7 @@ class PlayerNavigationCoordinator { navigationKey: navigationKey, contentStripVisible: chromeController.contentStripVisible, controlsVisible: isChromePresented(), - physicalEscapeExitsFullscreen: physicalEscapeExitsFullscreen, + physicalEscapeExitsFullscreen: _physicalEscapeExitsFullscreen(), ); _applyDisposition(disposition); } diff --git a/test/services/app_exit_service_test.dart b/test/services/app_exit_service_test.dart new file mode 100644 index 00000000..dfea25bb --- /dev/null +++ b/test/services/app_exit_service_test.dart @@ -0,0 +1,24 @@ +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/app_exit_service.dart'; + +void main() { + test('desktop exit uses the required application exit API', () async { + ui.AppExitType? requestedType; + int? requestedCode; + + expect( + await AppExitService.requestExit( + exitApplicationForTesting: (exitType, exitCode) async { + requestedType = exitType; + requestedCode = exitCode; + return ui.AppExitResponse.exit; + }, + ), + isTrue, + ); + expect(requestedType, ui.AppExitType.required); + expect(requestedCode, 0); + }); +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 1f2b171a..afd04de6 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -428,6 +428,7 @@ void main() { bool Function()? isChromePresented, Future Function()? exitFullscreenIfActive, bool physicalEscapeExitsFullscreen = true, + bool Function()? physicalEscapeExitsFullscreenProvider, VoidCallback? exitPlayer, VoidCallback? navigateHome, bool Function()? isActive, @@ -438,7 +439,7 @@ void main() { dismissPrompt: dismissPrompt ?? () {}, isChromePresented: isChromePresented ?? () => chromeController.controlsPresented, exitFullscreenIfActive: exitFullscreenIfActive ?? () async => false, - physicalEscapeExitsFullscreen: physicalEscapeExitsFullscreen, + physicalEscapeExitsFullscreen: physicalEscapeExitsFullscreenProvider ?? () => physicalEscapeExitsFullscreen, exitPlayer: exitPlayer ?? () {}, navigateHome: navigateHome ?? () {}, isActive: isActive, @@ -557,6 +558,31 @@ void main() { expect(exits, 0); }); + testWidgets('enabling player navigation makes physical Escape preserve fullscreen', (tester) async { + final chromeController = PlayerChromeController(); + addTearDown(chromeController.dispose); + var physicalEscapeExitsFullscreen = true; + var fullscreenChecks = 0; + var exits = 0; + final coordinator = coordinatorFor( + chromeController, + exitFullscreenIfActive: () async { + fullscreenChecks++; + return true; + }, + physicalEscapeExitsFullscreenProvider: () => physicalEscapeExitsFullscreen, + exitPlayer: () => exits++, + ); + physicalEscapeExitsFullscreen = false; + await pumpNavigationFocus(tester, coordinator); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + + expect(fullscreenChecks, 0); + expect(chromeController.controlsVisible, isFalse); + expect(exits, 0); + }); + testWidgets('macOS physical Escape stages through chrome and player without leaving fullscreen', (tester) async { final chromeController = PlayerChromeController(); addTearDown(chromeController.dispose); @@ -705,6 +731,20 @@ void main() { }); }); + group('shouldPhysicalEscapeExitFullscreen', () { + test('uses fullscreen-first behavior for normal Windows and Linux navigation', () { + expect(shouldPhysicalEscapeExitFullscreen(isMacOS: false, videoPlayerNavigationEnabled: false), isTrue); + }); + + test('preserves fullscreen when HTPC-style player navigation is enabled', () { + expect(shouldPhysicalEscapeExitFullscreen(isMacOS: false, videoPlayerNavigationEnabled: true), isFalse); + }); + + test('preserves native fullscreen inside the macOS player', () { + expect(shouldPhysicalEscapeExitFullscreen(isMacOS: true, videoPlayerNavigationEnabled: false), isFalse); + }); + }); + group('SkipMarkerButton', () { testWidgets('tap activates skip', (tester) async { final focusNode = FocusNode();