diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index adb3605b..f39c178e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -845,6 +845,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin super.initState(); unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.player)); + // Fullscreen entered from here on is the player's to drop; whatever was + // already fullscreen belongs to the app window (#1624). + FullscreenStateManager().beginScope(); + _playerNavigationCoordinator = PlayerNavigationCoordinator( chromeController: _chromeController, isPromptOpen: () => _showPlayNextDialog || _showStillWatchingPrompt, @@ -854,10 +858,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin 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 - // player must leave native fullscreen alone. + // player must leave native fullscreen alone. So must it when the window + // was already fullscreen before the player opened — that fullscreen is + // the app's (start-in-fullscreen, or a toggle from the browse UI) and + // Escape is plain Back (#1624). physicalEscapeExitsFullscreen: () => shouldPhysicalEscapeExitFullscreen( isMacOS: Platform.isMacOS, videoPlayerNavigationEnabled: _videoPlayerNavigationEnabled, + playerEnteredFullscreen: FullscreenStateManager().scopeOwnsFullscreen, ), exitPlayer: () => unawaited(_handleBackButton()), navigateHome: _handleHomeButton, @@ -1708,6 +1716,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin FullscreenStateManager().removeListener(_onFullscreenChanged); _fullscreenListenerAttached = false; } + FullscreenStateManager().endScope(); // Not _restoreWindowsDisplayMode(): that helper waits 200ms after clearing // the HDR hint before restoring, which dispose() cannot do. Fire the hint // clear at the still-live player and restore immediately. diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart index 60b044e3..ab02ab4c 100644 --- a/lib/services/fullscreen_state_manager.dart +++ b/lib/services/fullscreen_state_manager.dart @@ -15,13 +15,47 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { bool _isFullscreen = false; bool _isListening = false; bool _wasMaximized = false; + int _scopeDepth = 0; + bool _scopeOwnsFullscreen = false; bool get isFullscreen => _isFullscreen; + /// Whether the fullscreen currently active was entered while a scope opened + /// by [beginScope] was on screen. + /// + /// Fullscreen that predates the scope belongs to the app window — the + /// "start in fullscreen" setting, or a toggle from the browse UI — and must + /// outlive the scope that happens to be open. Only fullscreen the scope + /// itself entered is the scope's to drop (edde746/plezy#1624). + bool get scopeOwnsFullscreen => _scopeOwnsFullscreen; + + /// Opens a fullscreen ownership scope. Nested opens (the video player + /// replacing itself for the next episode, where the incoming screen's + /// initState runs before the outgoing screen's dispose) carry ownership + /// across the swap rather than resetting it. + void beginScope() { + _scopeDepth++; + if (_scopeDepth == 1) { + _scopeOwnsFullscreen = false; + } + } + + /// Closes a scope opened by [beginScope]. + void endScope() { + if (_scopeDepth == 0) return; + _scopeDepth--; + if (_scopeDepth == 0) { + _scopeOwnsFullscreen = false; + } + } + /// Manually set fullscreen state (called by NSWindowDelegate callbacks on macOS) void setFullscreen(bool value) { if (_isFullscreen != value) { _isFullscreen = value; + if (_scopeDepth > 0) { + _scopeOwnsFullscreen = value; + } notifyListeners(); } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index a3e5399b..5f5dc36a 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -221,8 +221,12 @@ enum PlayerNavigationKey { none, physicalEscape, back, home } enum PlayerBackDisposition { closeContentStrip, exitFullscreenIfActive, hideControls, exitPlayer } -bool shouldPhysicalEscapeExitFullscreen({required bool isMacOS, required bool videoPlayerNavigationEnabled}) { - return !isMacOS && !videoPlayerNavigationEnabled; +bool shouldPhysicalEscapeExitFullscreen({ + required bool isMacOS, + required bool videoPlayerNavigationEnabled, + required bool playerEnteredFullscreen, +}) { + return !isMacOS && !videoPlayerNavigationEnabled && playerEnteredFullscreen; } /// Coordinates the player-level stages shared by keyboard, controller, and diff --git a/test/services/fullscreen_state_manager_test.dart b/test/services/fullscreen_state_manager_test.dart new file mode 100644 index 00000000..c6ca7c6a --- /dev/null +++ b/test/services/fullscreen_state_manager_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/fullscreen_state_manager.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FullscreenStateManager manager; + + setUp(() { + manager = FullscreenStateManager(); + }); + + // The manager is a singleton, so every case has to hand it back windowed with + // no scope open. No test below leaves more than one scope on the stack. + tearDown(() { + manager.endScope(); + manager.setFullscreen(false); + }); + + group('scope ownership', () { + test('fullscreen predating the scope is not owned by it', () { + manager.setFullscreen(true); + + manager.beginScope(); + + expect(manager.scopeOwnsFullscreen, isFalse); + }); + + test('fullscreen entered inside the scope is owned by it', () { + manager.beginScope(); + + manager.setFullscreen(true); + + expect(manager.scopeOwnsFullscreen, isTrue); + }); + + test('leaving fullscreen inside the scope drops ownership', () { + manager.beginScope(); + manager.setFullscreen(true); + + manager.setFullscreen(false); + + expect(manager.scopeOwnsFullscreen, isFalse); + }); + + test('ownership does not survive the scope closing', () { + manager.beginScope(); + manager.setFullscreen(true); + + manager.endScope(); + + expect(manager.scopeOwnsFullscreen, isFalse); + }); + + test('ownership carries across a nested scope swap', () { + manager.beginScope(); + manager.setFullscreen(true); + + // The next episode's player is constructed before the outgoing one is + // disposed, so the scopes overlap rather than nest cleanly. + manager.beginScope(); + manager.endScope(); + + expect(manager.scopeOwnsFullscreen, isTrue); + }); + + test('fullscreen entered with no scope open is owned by nobody', () { + manager.setFullscreen(true); + + expect(manager.scopeOwnsFullscreen, isFalse); + }); + + test('unbalanced endScope does not open a scope', () { + manager.endScope(); + + manager.setFullscreen(true); + + expect(manager.scopeOwnsFullscreen, isFalse); + }); + }); +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index fdf6be24..1239db7c 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -874,15 +874,47 @@ void main() { group('shouldPhysicalEscapeExitFullscreen', () { test('uses fullscreen-first behavior for normal Windows and Linux navigation', () { - expect(shouldPhysicalEscapeExitFullscreen(isMacOS: false, videoPlayerNavigationEnabled: false), isTrue); + expect( + shouldPhysicalEscapeExitFullscreen( + isMacOS: false, + videoPlayerNavigationEnabled: false, + playerEnteredFullscreen: true, + ), + isTrue, + ); }); test('preserves fullscreen when HTPC-style player navigation is enabled', () { - expect(shouldPhysicalEscapeExitFullscreen(isMacOS: false, videoPlayerNavigationEnabled: true), isFalse); + expect( + shouldPhysicalEscapeExitFullscreen( + isMacOS: false, + videoPlayerNavigationEnabled: true, + playerEnteredFullscreen: true, + ), + isFalse, + ); }); test('preserves native fullscreen inside the macOS player', () { - expect(shouldPhysicalEscapeExitFullscreen(isMacOS: true, videoPlayerNavigationEnabled: false), isFalse); + expect( + shouldPhysicalEscapeExitFullscreen( + isMacOS: true, + videoPlayerNavigationEnabled: false, + playerEnteredFullscreen: true, + ), + isFalse, + ); + }); + + test('preserves app-owned fullscreen the player did not enter', () { + expect( + shouldPhysicalEscapeExitFullscreen( + isMacOS: false, + videoPlayerNavigationEnabled: false, + playerEnteredFullscreen: false, + ), + isFalse, + ); }); });