diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index b0e27c24..63f69385 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -572,11 +572,23 @@ class VideoPlayerScreenState extends State with WidgetsBindin final ValueNotifier _hasFirstFrame = ValueNotifier(false); final ValueNotifier _isExiting = ValueNotifier(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 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 _handleScreenPhysicalEscape() async { - if (await FullscreenStateManager().exitFullscreenIfActive()) return; - if (mounted) _handleScreenPlayerNavigation(PlayerNavigationKey.back); + _playerNavigationCoordinator.handle(navigationKey); } Future _restoreSystemUiAndOrientation() async { @@ -1530,7 +1518,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin onSystemBack: () { if (BackKeyCoordinator.consumeIfHandled()) return; BackKeyCoordinator.markHandled(); - _handleBackButton(); + _handleScreenPlayerNavigation(PlayerNavigationKey.back); }, child: Builder( builder: (sheetContext) => _isPlayerInitialized && player != null diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 61e16c35..567e50e0 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -506,6 +506,9 @@ class _OverlaySheetHostState extends State 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 with SingleTickerPr onPopInvokedWithResult: (didPop, result) { if (didPop) return; if (_isOpen && !_isClosing) { + if (BackKeyCoordinator.consumeIfHandled()) return; _handleBack(); return; } diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index f621832a..8ef7636b 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -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? onContentStripVisibilityChanged; + final PlayerChromeController? chromeController; /// Called when a seek should be executed by the owning screen. final Future 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 { _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 { 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(); diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 2ea2350a..bb12d48e 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -177,10 +177,15 @@ class _MobileVideoControlsState extends State 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. diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index b8a27a50..ef2c2690 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -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) { diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index bbf1d16e..f9098318 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -57,6 +57,7 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { onContentStripVisibilityChanged: (visible) { widget.chromeController.setContentStripVisible(visible); }, + chromeController: widget.chromeController, ), ); } diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index 7c587e21..b1ca588a 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -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 _toggleFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; await FullscreenStateManager().toggleFullscreen(); diff --git a/lib/widgets/video_controls/player_chrome_controller.dart b/lib/widgets/video_controls/player_chrome_controller.dart index 75b5a8f5..6e400647 100644 --- a/lib/widgets/video_controls/player_chrome_controller.dart +++ b/lib/widgets/video_controls/player_chrome_controller.dart @@ -15,6 +15,7 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable _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