fix(player): prevent duplicate back actions
This commit is contained in:
@@ -287,10 +287,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!),
|
onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!),
|
||||||
onSeekCompleted: _notifyWatchTogetherSeek,
|
onSeekCompleted: _notifyWatchTogetherSeek,
|
||||||
onBack: _handleBackButton,
|
onBack: _handleBackButton,
|
||||||
onHome: _handleHomeButton,
|
|
||||||
onDismissPrompt: (_showPlayNextDialog || _showStillWatchingPrompt)
|
|
||||||
? _dismissPlaybackPromptForBack
|
|
||||||
: null,
|
|
||||||
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
|
onReachedEnd: ({skipAutoPlayCountdown = false}) =>
|
||||||
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
|
_onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown),
|
||||||
canControl: canControl,
|
canControl: canControl,
|
||||||
|
|||||||
@@ -1474,9 +1474,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true;
|
final isCurrentRoute = ModalRoute.of(context)?.isCurrent ?? true;
|
||||||
// Screen-level Focus wraps ALL phases (loading + initialized).
|
// Screen-level Focus wraps ALL phases (loading + initialized).
|
||||||
// - autofocus: grabs focus when no deeper child claims it.
|
// - autofocus: grabs focus when no deeper child claims it.
|
||||||
// - onKeyEvent: self-heals when this node has primary focus (no descendant
|
// - onKeyEvent: owns player-level navigation after descendants have had the
|
||||||
// focused). Nav keys are only consumed in that case; otherwise they pass
|
// opportunity to handle local layers such as sheets and content strips.
|
||||||
// through so DirectionalFocusAction can drive dpad nav in overlay sheets.
|
|
||||||
return Focus(
|
return Focus(
|
||||||
focusNode: _screenFocusNode,
|
focusNode: _screenFocusNode,
|
||||||
autofocus: isCurrentRoute,
|
autofocus: isCurrentRoute,
|
||||||
@@ -1485,9 +1484,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (!isCurrentRoute) return KeyEventResult.ignored;
|
if (!isCurrentRoute) return KeyEventResult.ignored;
|
||||||
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
|
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
|
||||||
if (navigationKey != PlayerNavigationKey.none) {
|
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) {
|
if (navigationKey != PlayerNavigationKey.home && PlatformDetector.isTV() && event is KeyDownEvent) {
|
||||||
BackKeyCoordinator.markHandled();
|
BackKeyCoordinator.markHandled();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,15 +421,16 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _autoFocus() {
|
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.
|
// First post-frame: the FocusScope is now built and the node is attached.
|
||||||
// Grab scope focus immediately so key events (especially back) are trapped.
|
// Always grab scope focus so key events (especially back) are trapped, even
|
||||||
// Second post-frame: ListView.builder items are laid out and their
|
// when a pointer opened the sheet. In keyboard mode, a second post-frame
|
||||||
// FocusNodes are registered — focus the first descendant for dpad nav.
|
// callback focuses the first descendant for dpad navigation.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_isOpen) return;
|
if (!mounted || !_isOpen) return;
|
||||||
_sheetFocusScopeNode.requestFocus();
|
_sheetFocusScopeNode.requestFocus();
|
||||||
|
if (!focusDescendant) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_isOpen) return;
|
if (!mounted || !_isOpen) return;
|
||||||
// If the current top entry has an initialFocusNode that is attached,
|
// If the current top entry has an initialFocusNode that is attached,
|
||||||
@@ -456,11 +457,12 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _refocus() {
|
void _refocus() {
|
||||||
if (!InputModeTracker.isKeyboardMode(context)) return;
|
final focusDescendant = InputModeTracker.isKeyboardMode(context);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_isOpen) return;
|
if (!mounted || !_isOpen) return;
|
||||||
_sheetFocusScopeNode.requestFocus();
|
_sheetFocusScopeNode.requestFocus();
|
||||||
|
if (!focusDescendant) return;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || !_isOpen) return;
|
if (!mounted || !_isOpen) return;
|
||||||
final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null;
|
final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null;
|
||||||
|
|||||||
@@ -70,54 +70,27 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
|||||||
_showControlsWithFocus();
|
_showControlsWithFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyEventResult _handlePlayerNavigationKeyEvent(KeyEvent event) {
|
KeyEventResult _handleLocalPlayerNavigationKeyEvent(KeyEvent event, PlayerNavigationKey navigationKey) {
|
||||||
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV());
|
if (navigationKey == PlayerNavigationKey.none || navigationKey == PlayerNavigationKey.home) {
|
||||||
if (navigationKey == PlayerNavigationKey.none) return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
|
|
||||||
if (navigationKey != PlayerNavigationKey.home && PlatformDetector.isTV() && event is KeyDownEvent) {
|
|
||||||
BackKeyCoordinator.markHandled();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return handlePlayerNavigationKeyAction(event, navigationKey, () {
|
final sheetController = OverlaySheetController.maybeOf(context);
|
||||||
if (navigationKey == PlayerNavigationKey.home) {
|
if (sheetController?.isOpen ?? false) {
|
||||||
(widget.onHome ?? widget.onBack ?? () => Navigator.of(context).pop(true))();
|
return handlePlayerNavigationKeyAction(event, navigationKey, sheetController!.pop);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
if (widget.onDismissPrompt != null) {
|
|
||||||
widget.onDismissPrompt!();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_handleStagedPlayerBack(navigationKey);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleStagedPlayerBack(PlayerNavigationKey navigationKey) {
|
if (widget.chromeController.contentStripVisible) {
|
||||||
final disposition = resolvePlayerBackDisposition(
|
return handlePlayerNavigationKeyAction(event, navigationKey, () {
|
||||||
navigationKey: navigationKey,
|
|
||||||
contentStripVisible: widget.chromeController.contentStripVisible,
|
|
||||||
controlsVisible: _showControls,
|
|
||||||
);
|
|
||||||
switch (disposition) {
|
|
||||||
case PlayerBackDisposition.closeContentStrip:
|
|
||||||
_desktopControlsKey.currentState?.dismissContentStrip();
|
_desktopControlsKey.currentState?.dismissContentStrip();
|
||||||
widget.chromeController.setContentStripVisible(false);
|
widget.chromeController.setContentStripVisible(false);
|
||||||
_restartHideTimerForCurrentPlaybackState();
|
_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 {
|
// The enclosing player screen is the sole owner of fullscreen, chrome,
|
||||||
if (await FullscreenStateManager().exitFullscreenIfActive()) return;
|
// prompt, and route-exit stages.
|
||||||
if (mounted) _handleStagedPlayerBack(PlayerNavigationKey.back);
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global key event handler for focus-independent shortcuts (desktop only)
|
/// Global key event handler for focus-independent shortcuts (desktop only)
|
||||||
@@ -136,10 +109,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus.onKeyEvent will not fire if focus drifted outside the controls.
|
// Native key events also continue through the focus tree after global
|
||||||
if (!_focusNode.hasFocus) {
|
// handlers run. Player navigation must only mutate state there.
|
||||||
final navigationResult = _handlePlayerNavigationKeyEvent(event);
|
if (classifyPlayerNavigationKey(event, isAppleTV: PlatformDetector.isAppleTV()) != PlayerNavigationKey.none) {
|
||||||
if (navigationResult != KeyEventResult.ignored) return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only handle when video player navigation is disabled (desktop mode without D-pad nav)
|
// 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) {
|
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) {
|
if (navigationResult != KeyEventResult.ignored) {
|
||||||
return navigationResult;
|
return navigationResult;
|
||||||
}
|
}
|
||||||
|
if (navigationKey != PlayerNavigationKey.none) return KeyEventResult.ignored;
|
||||||
|
|
||||||
// Only handle KeyDown and KeyRepeat events.
|
// Only handle KeyDown and KeyRepeat events.
|
||||||
// Consume KeyUp events for navigation keys to prevent leaking to previous routes.
|
// 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)
|
/// Called when back button is pressed (for Watch Together session leave confirmation)
|
||||||
final VoidCallback? onBack;
|
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
|
/// 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
|
/// 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.
|
/// completion flow so the auto-play-next setting is honored.
|
||||||
@@ -408,8 +401,6 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
this.onPlayPauseRequested,
|
this.onPlayPauseRequested,
|
||||||
this.onSeekCompleted,
|
this.onSeekCompleted,
|
||||||
this.onBack,
|
this.onBack,
|
||||||
this.onHome,
|
|
||||||
this.onDismissPrompt,
|
|
||||||
this.onReachedEnd,
|
this.onReachedEnd,
|
||||||
this.canControl = true,
|
this.canControl = true,
|
||||||
this.hasFirstFrame,
|
this.hasFirstFrame,
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/models/companion_remote/remote_command.dart';
|
import 'package:plezy/models/companion_remote/remote_command.dart';
|
||||||
import 'package:plezy/services/companion_remote/companion_remote_receiver.dart';
|
import 'package:plezy/services/companion_remote/companion_remote_receiver.dart';
|
||||||
|
import 'package:plezy/widgets/video_controls/video_controls.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Back command dispatches semantic gamepad B events', (tester) async {
|
testWidgets('Back command dispatches semantic gamepad B events', (tester) async {
|
||||||
final focusNode = FocusNode();
|
final focusNode = FocusNode();
|
||||||
addTearDown(focusNode.dispose);
|
addTearDown(focusNode.dispose);
|
||||||
final events = <KeyEvent>[];
|
final events = <KeyEvent>[];
|
||||||
|
var actions = 0;
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
@@ -18,7 +20,8 @@ void main() {
|
|||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
onKeyEvent: (_, event) {
|
onKeyEvent: (_, event) {
|
||||||
events.add(event);
|
events.add(event);
|
||||||
return KeyEventResult.handled;
|
final navigationKey = classifyPlayerNavigationKey(event, isAppleTV: false);
|
||||||
|
return handlePlayerNavigationKeyAction(event, navigationKey, () => actions++);
|
||||||
},
|
},
|
||||||
child: const SizedBox.expand(),
|
child: const SizedBox.expand(),
|
||||||
),
|
),
|
||||||
@@ -35,5 +38,6 @@ void main() {
|
|||||||
expect(events.last, isA<KeyUpEvent>());
|
expect(events.last, isA<KeyUpEvent>());
|
||||||
expect(events.map((event) => event.logicalKey), everyElement(LogicalKeyboardKey.gameButtonB));
|
expect(events.map((event) => event.logicalKey), everyElement(LogicalKeyboardKey.gameButtonB));
|
||||||
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
|
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
|
||||||
|
expect(actions, 1);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||||
|
|
||||||
@@ -92,6 +93,52 @@ void main() {
|
|||||||
expect(sheetSize.width, 700);
|
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', () {
|
group('opt-in canPop / onSystemBack', () {
|
||||||
// Pushes an OverlaySheetHost route on top of a home route so we can observe
|
// 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
|
// whether a simulated system back pops the route. The host's child has an
|
||||||
|
|||||||
Reference in New Issue
Block a user