fix(player): respect visible chrome on back
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user