fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)

Physical Escape inside the player resolved to exitFullscreenIfActive on
Windows and Linux whenever HTPC-style player navigation was off, so it dropped
the window out of fullscreen regardless of who put it there. For anyone running
with "start in fullscreen" (or who had toggled fullscreen from the browse UI),
backing out of a movie left the app windowed, with "exit fullscreen on player
close" switched off.

Track fullscreen ownership instead: FullscreenStateManager now exposes a scope
that the player opens in initState and closes in dispose, and setFullscreen —
the single funnel every desktop platform reports through (window_manager on
Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) —
records whether the fullscreen currently active was entered inside that scope.
Escape only exits fullscreen the player itself entered; otherwise it is plain
Back. The scope is depth-counted so the next-episode swap, where the incoming
screen's initState runs before the outgoing screen's dispose, carries ownership
across rather than resetting it.

Nothing changes for a user who fullscreens from inside the player: Escape still
exits fullscreen first, then acts as Back. The fullscreen toggle button and its
shortcut are untouched, as is exitFullscreenOnPlayerClose.

Fixes #1624.
This commit is contained in:
Vincent Vallee
2026-08-05 07:14:11 +02:00
committed by GitHub
parent 3b0b407cd0
commit 8879941d29
5 changed files with 166 additions and 6 deletions
+10 -1
View File
@@ -845,6 +845,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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.
@@ -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();
}
}
@@ -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
@@ -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);
});
});
}
+35 -3
View File
@@ -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,
);
});
});