fix(player): respect visible chrome on back

This commit is contained in:
edde746
2026-07-11 17:38:22 +02:00
parent 3a982c2a7a
commit 4443b76145
13 changed files with 424 additions and 52 deletions
+20 -32
View File
@@ -572,11 +572,23 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
final PlayerChromeController _chromeController = PlayerChromeController();
late final PlayerNavigationCoordinator _playerNavigationCoordinator;
@override
void initState() {
super.initState();
_playerNavigationCoordinator = PlayerNavigationCoordinator(
chromeController: _chromeController,
isPromptOpen: () => _showPlayNextDialog || _showStillWatchingPrompt,
dismissPrompt: _dismissPlaybackPromptForBack,
isChromePresented: () => _isPlayerInitialized && player != null && _chromeController.controlsPresented,
exitFullscreenIfActive: FullscreenStateManager().exitFullscreenIfActive,
exitPlayer: () => unawaited(_handleBackButton()),
navigateHome: _handleHomeButton,
isActive: () => mounted,
);
_currentMetadata = widget.metadata;
_activeId = widget.metadata.id;
_activeMediaIndex = widget.selectedMediaIndex;
@@ -1142,38 +1154,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
}
void _handleScreenPlayerNavigation(PlayerNavigationKey navigationKey) {
if (navigationKey == PlayerNavigationKey.home) {
_handleHomeButton();
return;
if (navigationKey != PlayerNavigationKey.home) {
final sheetController = OverlaySheetController.maybeOf(context);
if (sheetController?.isOpen ?? false) {
sheetController!.pop();
return;
}
}
if (_showPlayNextDialog || _showStillWatchingPrompt) {
_dismissPlaybackPromptForBack();
return;
}
final disposition = resolvePlayerBackDisposition(
navigationKey: navigationKey,
contentStripVisible: _chromeController.contentStripVisible,
controlsVisible: _chromeController.controlsVisible,
);
switch (disposition) {
case PlayerBackDisposition.closeContentStrip:
_chromeController.setContentStripVisible(false);
return;
case PlayerBackDisposition.exitFullscreenIfActive:
unawaited(_handleScreenPhysicalEscape());
return;
case PlayerBackDisposition.hideControls:
_chromeController.hide();
return;
case PlayerBackDisposition.exitPlayer:
unawaited(_handleBackButton());
return;
}
}
Future<void> _handleScreenPhysicalEscape() async {
if (await FullscreenStateManager().exitFullscreenIfActive()) return;
if (mounted) _handleScreenPlayerNavigation(PlayerNavigationKey.back);
_playerNavigationCoordinator.handle(navigationKey);
}
Future<void> _restoreSystemUiAndOrientation() async {
@@ -1530,7 +1518,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
BackKeyCoordinator.markHandled();
_handleBackButton();
_handleScreenPlayerNavigation(PlayerNavigationKey.back);
},
child: Builder(
builder: (sheetContext) => _isPlayerInitialized && player != null
+4
View File
@@ -506,6 +506,9 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
// Back key: pop sub-page or close sheet
if (event.logicalKey.isBackKey) {
if (PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
return handleBackKeyAction(event, _handleBack);
}
@@ -558,6 +561,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (_isOpen && !_isClosing) {
if (BackKeyCoordinator.consumeIfHandled()) return;
_handleBack();
return;
}
@@ -19,6 +19,7 @@ import '../../i18n/strings.g.dart';
import '../../focus/focusable_wrapper.dart';
import '../../models/livetv_capture_buffer.dart';
import 'models/track_controls_state.dart';
import 'player_chrome_controller.dart';
import 'widgets/content_strip.dart';
import 'widgets/live_timeline_bar.dart';
import 'widgets/first_frame_guard.dart';
@@ -101,6 +102,7 @@ class DesktopVideoControls extends StatefulWidget {
/// Called when content strip visibility changes
final ValueChanged<bool>? onContentStripVisibilityChanged;
final PlayerChromeController? chromeController;
/// Called when a seek should be executed by the owning screen.
final Future<void> Function(Duration position)? onSeekRequested;
@@ -150,6 +152,7 @@ class DesktopVideoControls extends StatefulWidget {
this.onCancelAutoHide,
this.onStartAutoHide,
this.onContentStripVisibilityChanged,
this.chromeController,
this.onSeekRequested,
this.onSeekCompleted,
});
@@ -242,10 +245,21 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
_nextItemFocusNode,
_goToLiveFocusNode,
];
widget.chromeController?.addListener(_onChromeControllerChanged);
}
@override
void didUpdateWidget(DesktopVideoControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.chromeController != widget.chromeController) {
oldWidget.chromeController?.removeListener(_onChromeControllerChanged);
widget.chromeController?.addListener(_onChromeControllerChanged);
}
}
@override
void dispose() {
widget.chromeController?.removeListener(_onChromeControllerChanged);
_keyRepeatThumbnailTimer?.cancel();
_timelineSeekDebounceTimer?.cancel();
_timelinePreviewClearTimer?.cancel();
@@ -265,6 +279,12 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
super.dispose();
}
void _onChromeControllerChanged() {
if (widget.chromeController?.contentStripVisible == false) {
hideContentStrip();
}
}
/// Request focus on the play/pause button (called when controls shown via keyboard)
void requestPlayPauseFocus() {
_playPauseFocusNode.requestFocus();
@@ -177,10 +177,15 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
}
void _onChromeVisibilityChanged() {
final controlsVisible = widget.chromeController?.controlsVisible ?? true;
final chromeController = widget.chromeController;
final controlsVisible = chromeController?.controlsVisible ?? true;
final wasControlsVisible = _lastControlsVisible;
_lastControlsVisible = controlsVisible;
if (chromeController?.contentStripVisible == false && _stripAnim.value > 0) {
_stripAnim.reverse();
}
if (!controlsVisible && wasControlsVisible && _stripVisible) {
// Just notify parent that strip is no longer active — don't animate,
// let the overlay fade out with the strip still showing.
@@ -74,6 +74,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
if (navigationKey == PlayerNavigationKey.none || navigationKey == PlayerNavigationKey.home) {
return KeyEventResult.ignored;
}
if (PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
final sheetController = OverlaySheetController.maybeOf(context);
if (sheetController?.isOpen ?? false) {
@@ -57,6 +57,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState {
onContentStripVisibilityChanged: (visible) {
widget.chromeController.setContentStripVisible(visible);
},
chromeController: widget.chromeController,
),
);
}
@@ -162,17 +162,6 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
}
}
/// macOS PiP changed — force controls visible while PiP is active
void _onMacPipChanged() {
if (!mounted) return;
final inPip = _pipService.isPipActive.value;
if (inPip) {
widget.chromeController.hold(PlayerChromeHold.pip);
} else {
widget.chromeController.release(PlayerChromeHold.pip);
}
}
Future<void> _toggleFullscreen() async {
if (!PlatformDetector.isDesktopOS()) return;
await FullscreenStateManager().toggleFullscreen();
@@ -15,6 +15,7 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable<b
PlayerChromeController({this._controlsVisible = true});
bool _controlsVisible;
bool _controlsPresented = true;
bool _contentStripVisible = false;
bool _playing = false;
bool _hasFirstFrame = true;
@@ -29,6 +30,9 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable<b
bool get value => _controlsVisible;
bool get controlsVisible => _controlsVisible;
/// Whether controls may still be visibly rendered during their fade-out.
bool get controlsPresented => _controlsPresented;
bool get contentStripVisible => _contentStripVisible;
bool get hasVisibleHold => _holds.isNotEmpty;
bool isHeld(PlayerChromeHold hold) => _holds.contains(hold);
@@ -79,6 +83,7 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable<b
}
void show({bool restartAutoHide = true, PlayerChromeFocusTarget? focusTarget}) {
_controlsPresented = true;
var shouldNotify = false;
if (focusTarget != null) {
_pendingFocusTarget = focusTarget;
@@ -111,6 +116,12 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable<b
return true;
}
/// Called when the controls opacity animation reaches its hidden target.
void markControlsHidden() {
if (_controlsVisible) return;
_controlsPresented = false;
}
void toggle() {
if (_controlsVisible) {
hide();
@@ -174,6 +185,7 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable<b
if (!_controlsVisible) {
_controlsVisible = true;
}
_controlsPresented = true;
notifyListeners();
}
+84 -7
View File
@@ -185,6 +185,81 @@ enum PlayerNavigationKey { none, physicalEscape, back, home }
enum PlayerBackDisposition { closeContentStrip, exitFullscreenIfActive, hideControls, exitPlayer }
/// Coordinates the player-level stages shared by keyboard, controller, and
/// companion navigation after descendants have handled local overlays.
class PlayerNavigationCoordinator {
final PlayerChromeController chromeController;
final bool Function() isPromptOpen;
final VoidCallback dismissPrompt;
final bool Function() isChromePresented;
final Future<bool> Function() exitFullscreenIfActive;
final VoidCallback exitPlayer;
final VoidCallback navigateHome;
final bool Function() isActive;
bool _handlingPhysicalEscape = false;
PlayerNavigationCoordinator({
required this.chromeController,
required this.isPromptOpen,
required this.dismissPrompt,
required this.isChromePresented,
required this.exitFullscreenIfActive,
required this.exitPlayer,
required this.navigateHome,
bool Function()? isActive,
}) : isActive = isActive ?? _alwaysActive;
static bool _alwaysActive() => true;
void handle(PlayerNavigationKey navigationKey) {
if (navigationKey == PlayerNavigationKey.home) {
navigateHome();
return;
}
if (isPromptOpen()) {
dismissPrompt();
return;
}
if (chromeController.contentStripVisible) {
chromeController.setContentStripVisible(false);
return;
}
if (navigationKey == PlayerNavigationKey.physicalEscape) {
unawaited(_handlePhysicalEscape());
return;
}
_handleSemanticBack();
}
void _handleSemanticBack() {
if (isChromePresented()) {
chromeController.hide();
return;
}
exitPlayer();
}
Future<void> _handlePhysicalEscape() async {
if (_handlingPhysicalEscape) return;
_handlingPhysicalEscape = true;
final chromeWasPresented = isChromePresented();
try {
if (await exitFullscreenIfActive()) return;
if (!isActive()) return;
if (chromeController.contentStripVisible) {
chromeController.setContentStripVisible(false);
} else if (chromeWasPresented || isChromePresented()) {
chromeController.hide();
} else {
exitPlayer();
}
} finally {
_handlingPhysicalEscape = false;
}
}
}
PlayerBackDisposition resolvePlayerBackDisposition({
required PlayerNavigationKey navigationKey,
required bool contentStripVisible,
@@ -613,11 +688,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
// Listen for first frame to start auto-hide timer
widget.hasFirstFrame?.addListener(_onFirstFrameReady);
// On macOS, show controls and disable auto-hide when PiP activates
if (Platform.isMacOS) {
_pipService.isPipActive.addListener(_onMacPipChanged);
}
// Defer context-dependent initialization to after first build
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
@@ -707,7 +777,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
}
if (Platform.isMacOS) {
FullscreenStateManager().removeListener(_onFullscreenStateChanged);
_pipService.isPipActive.removeListener(_onMacPipChanged);
_trafficLightVisibilityGeneration++;
unawaited(MacOSWindowService.setTrafficLightsVisible(true));
}
@@ -726,7 +795,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
void _onEdgeAdjustmentPipChanged() {
final isInPip = _pipService.isPipActive.value;
_deviceAdjustmentService.setRestoreSuppressed(isInPip);
if (isInPip) _cancelEdgeAdjustmentGesture();
if (isInPip) {
widget.chromeController.hold(PlayerChromeHold.pip);
_cancelEdgeAdjustmentGesture();
} else {
widget.chromeController.release(PlayerChromeHold.pip);
}
}
@override
@@ -839,6 +913,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
child: AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
onEnd: () {
if (!_showControls) widget.chromeController.markControlsHidden();
},
child: Builder(
builder: (context) {
return GestureDetector(
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/companion_remote/remote_command.dart';
import 'package:plezy/services/companion_remote/companion_remote_receiver.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
void main() {
@@ -13,6 +14,18 @@ void main() {
addTearDown(focusNode.dispose);
final events = <KeyEvent>[];
var actions = 0;
var exits = 0;
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
final coordinator = PlayerNavigationCoordinator(
chromeController: chromeController,
isPromptOpen: () => false,
dismissPrompt: () {},
isChromePresented: () => chromeController.controlsPresented,
exitFullscreenIfActive: () async => false,
exitPlayer: () => exits++,
navigateHome: () {},
);
await tester.pumpWidget(
MaterialApp(
@@ -21,7 +34,10 @@ void main() {
onKeyEvent: (_, event) {
events.add(event);
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: false);
return handlePlayerNavigationKeyAction(event, navigationKey, () => actions++);
return handlePlayerNavigationKeyAction(event, navigationKey, () {
actions++;
coordinator.handle(navigationKey);
});
},
child: const SizedBox.expand(),
),
@@ -39,5 +55,7 @@ void main() {
expect(events.map((event) => event.logicalKey), everyElement(LogicalKeyboardKey.gameButtonB));
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
expect(actions, 1);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
});
}
+19
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
void main() {
@@ -221,5 +222,23 @@ void main() {
expect(find.text('Open'), findsOneWidget, reason: 'screen not popped');
expect(backs, 0, reason: 'onSystemBack not called while a sheet was open');
});
testWidgets('system back does not duplicate a handled TV key Back on an open sheet', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsOneWidget);
expect(backs, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
});
});
}
@@ -119,6 +119,32 @@ void main() {
expect(controller.takeFocusTarget(), isNull);
});
test('hide keeps controls presented until the opacity animation completes', () {
final controller = PlayerChromeController();
addTearDown(controller.dispose);
controller.hide();
expect(controller.controlsVisible, isFalse);
expect(controller.controlsPresented, isTrue);
controller.markControlsHidden();
expect(controller.controlsPresented, isFalse);
});
test('a stale fade-out completion cannot hide controls that were shown again', () {
final controller = PlayerChromeController();
addTearDown(controller.dispose);
controller.hide();
controller.show();
controller.markControlsHidden();
expect(controller.controlsVisible, isTrue);
expect(controller.controlsPresented, isTrue);
});
test('silent release removes hold without notifying listeners', () {
final controller = PlayerChromeController();
addTearDown(controller.dispose);
+210
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
@@ -10,6 +11,7 @@ import 'package:plezy/models/shader_preset.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/video_controls/video_controls.dart';
import 'package:plezy/widgets/video_controls/player_chrome_controller.dart';
import 'package:plezy/widgets/video_controls/painters/buffer_range_painter.dart';
import 'package:plezy/widgets/video_controls/widgets/mobile_skip_zones.dart';
import 'package:plezy/widgets/video_controls/widgets/skip_marker_button.dart';
@@ -318,6 +320,214 @@ void main() {
});
});
group('PlayerNavigationCoordinator focus dispatch', () {
PlayerNavigationCoordinator coordinatorFor(
PlayerChromeController chromeController, {
bool Function()? isPromptOpen,
VoidCallback? dismissPrompt,
Future<bool> Function()? exitFullscreenIfActive,
VoidCallback? exitPlayer,
VoidCallback? navigateHome,
bool Function()? isActive,
}) {
return PlayerNavigationCoordinator(
chromeController: chromeController,
isPromptOpen: isPromptOpen ?? () => false,
dismissPrompt: dismissPrompt ?? () {},
isChromePresented: () => chromeController.controlsPresented,
exitFullscreenIfActive: exitFullscreenIfActive ?? () async => false,
exitPlayer: exitPlayer ?? () {},
navigateHome: navigateHome ?? () {},
isActive: isActive,
);
}
Future<void> pumpNavigationFocus(WidgetTester tester, PlayerNavigationCoordinator coordinator) async {
await tester.pumpWidget(
MaterialApp(
home: Focus(
autofocus: true,
onKeyEvent: (_, event) {
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: false);
return handlePlayerNavigationKeyAction(event, navigationKey, () => coordinator.handle(navigationKey));
},
child: const SizedBox.expand(),
),
),
);
await tester.pump();
}
testWidgets('one Back hides presented chrome and the next exits once after fade-out', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(chromeController, exitPlayer: () => exits++);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(chromeController.controlsVisible, isFalse);
expect(chromeController.controlsPresented, isTrue);
expect(exits, 0);
chromeController.markControlsHidden();
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(exits, 1);
});
testWidgets('physical Escape outside fullscreen hides presented chrome without exiting', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var fullscreenChecks = 0;
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () async {
fullscreenChecks++;
return false;
},
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(fullscreenChecks, 1);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
});
testWidgets('physical Escape preserves event-time chrome presentation across fullscreen check', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
final fullscreenResult = Completer<bool>();
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () => fullscreenResult.future,
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
chromeController.hide();
chromeController.markControlsHidden();
fullscreenResult.complete(false);
await tester.pump();
expect(chromeController.controlsVisible, isFalse);
expect(chromeController.controlsPresented, isFalse);
expect(exits, 0);
});
testWidgets('physical Escape exits native fullscreen before chrome', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () async => true,
exitPlayer: () => exits++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
expect(chromeController.controlsVisible, isTrue);
expect(exits, 0);
});
testWidgets('physical Escape does nothing after its player route is disposed', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
final fullscreenResult = Completer<bool>();
var active = true;
var exits = 0;
final coordinator = coordinatorFor(
chromeController,
exitFullscreenIfActive: () => fullscreenResult.future,
exitPlayer: () => exits++,
isActive: () => active,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
active = false;
fullscreenResult.complete(false);
await tester.pump();
expect(chromeController.controlsVisible, isTrue);
expect(exits, 0);
});
testWidgets('Back closes the content strip without hiding chrome or exiting', (tester) async {
final chromeController = PlayerChromeController()..setContentStripVisible(true);
addTearDown(chromeController.dispose);
var exits = 0;
final coordinator = coordinatorFor(chromeController, exitPlayer: () => exits++);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
expect(chromeController.contentStripVisible, isFalse);
expect(chromeController.controlsVisible, isTrue);
expect(exits, 0);
chromeController.cancelAutoHide();
});
testWidgets('Home bypasses staged Back layers', (tester) async {
final chromeController = PlayerChromeController()..setContentStripVisible(true);
addTearDown(chromeController.dispose);
var promptOpen = true;
var promptDismissals = 0;
var homeNavigations = 0;
final coordinator = coordinatorFor(
chromeController,
isPromptOpen: () => promptOpen,
dismissPrompt: () {
promptOpen = false;
promptDismissals++;
},
navigateHome: () => homeNavigations++,
);
await pumpNavigationFocus(tester, coordinator);
await tester.sendKeyEvent(LogicalKeyboardKey.home);
expect(homeNavigations, 1);
expect(promptDismissals, 0);
expect(chromeController.contentStripVisible, isTrue);
expect(chromeController.controlsVisible, isTrue);
});
testWidgets('global observation and focus dispatch produce one native Back action', (tester) async {
final chromeController = PlayerChromeController();
addTearDown(chromeController.dispose);
var globalEvents = 0;
var exits = 0;
bool globalHandler(KeyEvent event) {
if (classifyPlayerNavigationKey(event, isAppleTV: false) != PlayerNavigationKey.none) {
globalEvents++;
}
return false;
}
HardwareKeyboard.instance.addHandler(globalHandler);
addTearDown(() => HardwareKeyboard.instance.removeHandler(globalHandler));
await pumpNavigationFocus(tester, coordinatorFor(chromeController, exitPlayer: () => exits++));
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
expect(globalEvents, 2);
expect(chromeController.controlsVisible, isFalse);
expect(exits, 0);
});
});
group('resolvePlayerBackDisposition', () {
test('closes a content strip before other Back behavior', () {
expect(