fix(nav): preserve HTPC escape behavior
Use Flutter's supported desktop application-exit API so the second root Back press closes Plezy on Windows. Preserve fullscreen when video player navigation is enabled while keeping fullscreen-first Escape behavior for normal desktop use. close #1582
This commit is contained in:
@@ -591,10 +591,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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,
|
||||
|
||||
@@ -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<ui.AppExitResponse> 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<bool> requestExit() async {
|
||||
static Future<bool> 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;
|
||||
}
|
||||
|
||||
@@ -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<bool> 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -428,6 +428,7 @@ void main() {
|
||||
bool Function()? isChromePresented,
|
||||
Future<bool> 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();
|
||||
|
||||
Reference in New Issue
Block a user