fix(player): prevent duplicate back actions

This commit is contained in:
edde746
2026-07-11 12:59:57 +02:00
parent e07172ade4
commit 3a982c2a7a
7 changed files with 81 additions and 70 deletions
@@ -287,10 +287,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!),
onSeekCompleted: _notifyWatchTogetherSeek,
onBack: _handleBackButton,
onHome: _handleHomeButton,
onDismissPrompt: (_showPlayNextDialog || _showStillWatchingPrompt)
? _dismissPlaybackPromptForBack
: null,
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
canControl: canControl,
+2 -6
View File
@@ -1474,9 +1474,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true;
// Screen-level Focus wraps ALL phases (loading + initialized).
// - autofocus: grabs focus when no deeper child claims it.
// - onKeyEvent: self-heals when this node has primary focus (no descendant
// focused). Nav keys are only consumed in that case; otherwise they pass
// through so DirectionalFocusAction can drive dpad nav in overlay sheets.
// - onKeyEvent: owns player-level navigation after descendants have had the
// opportunity to handle local layers such as sheets and content strips.
return Focus(
focusNode: _screenFocusNode,
autofocus: isCurrentRoute,
@@ -1485,9 +1484,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
if (!isCurrentRoute) return KeyEventResult.ignored;
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
if (navigationKey != PlayerNavigationKey.none) {
// Descendants (controls and sheets) own staged Back while focused.
// This fallback covers loading/error phases and focus drift.
if (!node.hasPrimaryFocus) return KeyEventResult.ignored;
if (navigationKey != PlayerNavigationKey.home && PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
}
+7 -5
View File
@@ -421,15 +421,16 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
}
void _autoFocus() {
if (!InputModeTracker.isKeyboardMode(context)) return;
final focusDescendant = InputModeTracker.isKeyboardMode(context);
// First post-frame: the FocusScope is now built and the node is attached.
// Grab scope focus immediately so key events (especially back) are trapped.
// Second post-frame: ListView.builder items are laid out and their
// FocusNodes are registered — focus the first descendant for dpad nav.
// Always grab scope focus so key events (especially back) are trapped, even
// when a pointer opened the sheet. In keyboard mode, a second post-frame
// callback focuses the first descendant for dpad navigation.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
_sheetFocusScopeNode.requestFocus();
if (!focusDescendant) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
// If the current top entry has an initialFocusNode that is attached,
@@ -456,11 +457,12 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
}
void _refocus() {
if (!InputModeTracker.isKeyboardMode(context)) return;
final focusDescendant = InputModeTracker.isKeyboardMode(context);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
_sheetFocusScopeNode.requestFocus();
if (!focusDescendant) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null;
@@ -70,54 +70,27 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
_showControlsWithFocus();
}
KeyEventResult _handlePlayerNavigationKeyEvent(KeyEvent event) {
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
if (navigationKey == PlayerNavigationKey.none) return KeyEventResult.ignored;
if (navigationKey != PlayerNavigationKey.home && PlatformDetector.isTV() && event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
KeyEventResult _handleLocalPlayerNavigationKeyEvent(KeyEvent event, PlayerNavigationKey navigationKey) {
if (navigationKey == PlayerNavigationKey.none || navigationKey == PlayerNavigationKey.home) {
return KeyEventResult.ignored;
}
return handlePlayerNavigationKeyAction(event, navigationKey, () {
if (navigationKey == PlayerNavigationKey.home) {
(widget.onHome ?? widget.onBack ?? () => Navigator.of(context).pop(true))();
return;
}
if (widget.onDismissPrompt != null) {
widget.onDismissPrompt!();
return;
}
_handleStagedPlayerBack(navigationKey);
});
}
final sheetController = OverlaySheetController.maybeOf(context);
if (sheetController?.isOpen ?? false) {
return handlePlayerNavigationKeyAction(event, navigationKey, sheetController!.pop);
}
void _handleStagedPlayerBack(PlayerNavigationKey navigationKey) {
final disposition = resolvePlayerBackDisposition(
navigationKey: navigationKey,
contentStripVisible: widget.chromeController.contentStripVisible,
controlsVisible: _showControls,
);
switch (disposition) {
case PlayerBackDisposition.closeContentStrip:
if (widget.chromeController.contentStripVisible) {
return handlePlayerNavigationKeyAction(event, navigationKey, () {
_desktopControlsKey.currentState?.dismissContentStrip();
widget.chromeController.setContentStripVisible(false);
_restartHideTimerForCurrentPlaybackState();
return;
case PlayerBackDisposition.exitFullscreenIfActive:
unawaited(_handlePhysicalEscape());
return;
case PlayerBackDisposition.hideControls:
_hideControls();
return;
case PlayerBackDisposition.exitPlayer:
(widget.onBack ?? () => Navigator.of(context).pop(true))();
return;
});
}
}
Future<void> _handlePhysicalEscape() async {
if (await FullscreenStateManager().exitFullscreenIfActive()) return;
if (mounted) _handleStagedPlayerBack(PlayerNavigationKey.back);
// The enclosing player screen is the sole owner of fullscreen, chrome,
// prompt, and route-exit stages.
return KeyEventResult.ignored;
}
/// Global key event handler for focus-independent shortcuts (desktop only)
@@ -136,10 +109,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
return false;
}
// Focus.onKeyEvent will not fire if focus drifted outside the controls.
if (!_focusNode.hasFocus) {
final navigationResult = _handlePlayerNavigationKeyEvent(event);
if (navigationResult != KeyEventResult.ignored) return true;
// Native key events also continue through the focus tree after global
// handlers run. Player navigation must only mutate state there.
if (classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV()) != PlayerNavigationKey.none) {
return false;
}
// Only handle when video player navigation is disabled (desktop mode without D-pad nav)
@@ -190,10 +163,12 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
}
KeyEventResult _handleControlsKeyEvent(KeyEvent event, bool isMobile) {
final navigationResult = _handlePlayerNavigationKeyEvent(event);
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
final navigationResult = _handleLocalPlayerNavigationKeyEvent(event, navigationKey);
if (navigationResult != KeyEventResult.ignored) {
return navigationResult;
}
if (navigationKey != PlayerNavigationKey.none) return KeyEventResult.ignored;
// Only handle KeyDown and KeyRepeat events.
// Consume KeyUp events for navigation keys to prevent leaking to previous routes.
@@ -301,13 +301,6 @@ class PlexVideoControls extends StatefulWidget {
/// Called when back button is pressed (for Watch Together session leave confirmation)
final VoidCallback? onBack;
/// Called for a direct Home command after player-specific input handling.
final VoidCallback? onHome;
/// Called when Back should dismiss a visible playback prompt before normal
/// player back handling.
final VoidCallback? onDismissPrompt;
/// Called when the video has effectively reached the end (e.g. credits extend
/// to EOF and can't be seeked past). Parent should route this into its normal
/// completion flow so the auto-play-next setting is honored.
@@ -408,8 +401,6 @@ class PlexVideoControls extends StatefulWidget {
this.onPlayPauseRequested,
this.onSeekCompleted,
this.onBack,
this.onHome,
this.onDismissPrompt,
this.onReachedEnd,
this.canControl = true,
this.hasFirstFrame,
@@ -5,12 +5,14 @@ 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/video_controls.dart';
void main() {
testWidgets('Back command dispatches semantic gamepad B events', (tester) async {
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
final events = <KeyEvent>[];
var actions = 0;
await tester.pumpWidget(
MaterialApp(
@@ -18,7 +20,8 @@ void main() {
focusNode: focusNode,
onKeyEvent: (_, event) {
events.add(event);
return KeyEventResult.handled;
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: false);
return handlePlayerNavigationKeyAction(event, navigationKey, () => actions++);
},
child: const SizedBox.expand(),
),
@@ -35,5 +38,6 @@ void main() {
expect(events.last, isA<KeyUpEvent>());
expect(events.map((event) => event.logicalKey), everyElement(LogicalKeyboardKey.gameButtonB));
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
expect(actions, 1);
});
}
+47
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
@@ -92,6 +93,52 @@ void main() {
expect(sheetSize.width, 700);
});
testWidgets('pointer-opened sheet claims focus and handles Back before the screen', (tester) async {
final screenFocusNode = FocusNode(debugLabel: 'Screen');
addTearDown(screenFocusNode.dispose);
var screenBacks = 0;
await tester.pumpWidget(
MaterialApp(
home: Focus(
focusNode: screenFocusNode,
autofocus: true,
onKeyEvent: (_, event) {
if (event.logicalKey == LogicalKeyboardKey.gameButtonB) {
if (event is KeyUpEvent) screenBacks++;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => OverlaySheetController.of(context).show<void>(
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('SHEET'))),
),
child: const Text('Open'),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'OverlaySheetScope');
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(screenBacks, 0);
});
group('opt-in canPop / onSystemBack', () {
// Pushes an OverlaySheetHost route on top of a home route so we can observe
// whether a simulated system back pops the route. The host's child has an