diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index b49d29e8..10343f7a 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -291,6 +291,9 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { onPlayPauseRequested: () => _playOrPauseWithPlaybackIntent(player!), onSeekCompleted: _notifyWatchTogetherSeek, onBack: _handleBackButton, + onDismissPrompt: (_showPlayNextDialog || _showStillWatchingPrompt) + ? _dismissPlaybackPromptForBack + : null, onReachedEnd: ({skipAutoPlayCountdown = false}) => _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), canControl: canControl, diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 7804c57c..753390f2 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -109,6 +109,16 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { }); } + void _dismissPlaybackPromptForBack() { + if (_showPlayNextDialog) { + _cancelAutoPlay(); + return; + } + if (_showStillWatchingPrompt) { + _dismissStillWatching(); + } + } + /// Re-arm the end-of-video latch so Play Next can fire again. Callers /// decide *when* it is safe to re-arm (media reloaded, or playback moved /// back out of the end region); the latch itself refuses while a prompt diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index dbf2b401..675bb94a 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -982,6 +982,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation Future _handleBackButton() async { + if (_showPlayNextDialog || _showStillWatchingPrompt) { + _dismissPlaybackPromptForBack(); + return; + } if (_isHandlingBack) return; _isHandlingBack = true; try { diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 66cffc9a..a662c9e6 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -91,6 +91,8 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { if (PlatformDetector.isTV() && event is KeyDownEvent) { BackKeyCoordinator.markHandled(); } + final promptBackResult = handlePromptDismissBackKey(event, widget.onDismissPrompt); + if (promptBackResult != KeyEventResult.ignored) return true; final backResult = handleBackKeyAction(event, () { if (PlatformDetector.isTV()) { if (_showControls) { @@ -193,6 +195,10 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { if (PlatformDetector.isTV() && event.logicalKey.isBackKey && event is KeyDownEvent) { BackKeyCoordinator.markHandled(); } + final promptBackResult = handlePromptDismissBackKey(event, widget.onDismissPrompt); + if (promptBackResult != KeyEventResult.ignored) { + return promptBackResult; + } final backResult = handleBackKeyAction(event, () { if (PlatformDetector.isTV()) { if (_showControls) { diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 10eb2191..b50b59a0 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -159,6 +159,12 @@ bool shouldShowSkipMarkerButton({ return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible); } +@visibleForTesting +KeyEventResult handlePromptDismissBackKey(KeyEvent event, VoidCallback? onDismissPrompt) { + if (onDismissPrompt == null || !event.logicalKey.isBackKey) return KeyEventResult.ignored; + return handleBackKeyAction(event, onDismissPrompt); +} + typedef PlaybackSourceChangeCallback = Future Function({ int? newMediaIndex, @@ -214,6 +220,10 @@ class PlexVideoControls extends StatefulWidget { /// Called when back button is pressed (for Watch Together session leave confirmation) final VoidCallback? onBack; + /// 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. @@ -314,6 +324,7 @@ class PlexVideoControls extends StatefulWidget { this.onPlayPauseRequested, this.onSeekCompleted, this.onBack, + this.onDismissPrompt, this.onReachedEnd, this.canControl = true, this.hasFirstFrame, diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index c3e75b5f..5d9eefb9 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -186,6 +186,38 @@ void main() { }); }); + group('handlePromptDismissBackKey', () { + test('ignores back keys when no prompt is visible', () { + var dismissCount = 0; + + final result = handlePromptDismissBackKey(_keyUp(LogicalKeyboardKey.goBack), null); + + expect(result, KeyEventResult.ignored); + expect(dismissCount, 0); + }); + + test('consumes key down and dismisses on key up', () { + var dismissCount = 0; + void dismissPrompt() => dismissCount++; + + final downResult = handlePromptDismissBackKey(_keyDown(LogicalKeyboardKey.goBack), dismissPrompt); + final upResult = handlePromptDismissBackKey(_keyUp(LogicalKeyboardKey.goBack), dismissPrompt); + + expect(downResult, KeyEventResult.handled); + expect(upResult, KeyEventResult.handled); + expect(dismissCount, 1); + }); + + test('ignores non-back keys', () { + var dismissCount = 0; + + final result = handlePromptDismissBackKey(_keyDown(LogicalKeyboardKey.arrowLeft), () => dismissCount++); + + expect(result, KeyEventResult.ignored); + expect(dismissCount, 0); + }); + }); + group('SkipMarkerButton', () { testWidgets('tap cancels active auto-skip and performs skip', (tester) async { final focusNode = FocusNode(); @@ -723,6 +755,14 @@ void main() { }); } +KeyDownEvent _keyDown(LogicalKeyboardKey key) { + return KeyDownEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero); +} + +KeyUpEvent _keyUp(LogicalKeyboardKey key) { + return KeyUpEvent(physicalKey: PhysicalKeyboardKey.escape, logicalKey: key, timeStamp: Duration.zero); +} + Future _pumpSkipMarkerButton( WidgetTester tester, { required FocusNode focusNode,