From 24b5080c385e864e282e8e225df114987634bb30 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:20:24 +0200 Subject: [PATCH] fix(video): stabilize player chrome overlays --- lib/screens/video_player/parts/build.dart | 261 +++++------ .../parts/episode_navigation.dart | 3 + .../video_player/parts/playback_prompts.dart | 15 + lib/screens/video_player/parts/seeking.dart | 2 +- .../widgets/player_prompt_overlays.dart | 416 +++++++++++------- lib/screens/video_player_screen.dart | 11 +- .../video_controls/mobile_video_controls.dart | 23 +- .../video_controls/parts/key_events.dart | 8 +- lib/widgets/video_controls/parts/markers.dart | 84 +++- .../video_controls/parts/navigation.dart | 9 +- .../video_controls/parts/playback_extras.dart | 1 + .../video_controls/parts/track_controls.dart | 2 +- .../video_controls/parts/visibility.dart | 231 +++------- .../player_chrome_controller.dart | 226 ++++++++++ .../video_controls/video_controls.dart | 89 ++-- .../player_prompt_overlays_test.dart | 174 ++++++++ .../player_chrome_controller_test.dart | 135 ++++++ test/widgets/video_controls_test.dart | 64 +++ 18 files changed, 1233 insertions(+), 521 deletions(-) create mode 100644 lib/widgets/video_controls/player_chrome_controller.dart create mode 100644 test/screens/video_player/player_prompt_overlays_test.dart create mode 100644 test/widgets/player_chrome_controller_test.dart diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index fdda77b4..01c8f2da 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -158,6 +158,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { Widget _buildVideoPlayer(BuildContext context) { // Cache platform detection to avoid multiple calls final isMobile = PlatformDetector.isMobile(context); + final hideChromeOnMouseExit = !(isMobile && !PlatformDetector.isTV()); return PopScope( canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing @@ -212,141 +213,145 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { _clearMobileZoomGesture(); _setPlayerState(() {}); }, - child: Stack( - children: [ - // macOS PiP placeholder — video is in PiP window, show background with icon - // Placed before Video so controls render on top - if (Platform.isMacOS) const VideoPlayerMacPipPlaceholder(), - Center( - child: LayoutBuilder( - builder: (context, constraints) { - final newSize = Size(constraints.maxWidth, constraints.maxHeight); - _scheduleVideoLayoutUpdate(newSize); + child: PlayerChromeInteractionRegion( + controller: _chromeController, + hideOnExit: hideChromeOnMouseExit, + child: Stack( + children: [ + // macOS PiP placeholder — video is in PiP window, show background with icon + // Placed before Video so controls render on top + if (Platform.isMacOS) const VideoPlayerMacPipPlaceholder(), + Center( + child: LayoutBuilder( + builder: (context, constraints) { + final newSize = Size(constraints.maxWidth, constraints.maxHeight); + _scheduleVideoLayoutUpdate(newSize); - // Compute canControl from Watch Together provider (reactive) - bool canControl = true; - try { - canControl = context.select( - (wt) => wt.isInSession ? wt.canControl() : true, - ); - } catch (e) { - // Watch Together not available, default to can control - } + // Compute canControl from Watch Together provider (reactive) + bool canControl = true; + try { + canControl = context.select( + (wt) => wt.isInSession ? wt.canControl() : true, + ); + } catch (e) { + // Watch Together not available, default to can control + } - VoidCallback? onNext; - if (widget.isLive) { - onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null; - } else { - onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null; - } + VoidCallback? onNext; + if (widget.isLive) { + onNext = _hasNextChannel ? () => _switchLiveChannel(1) : null; + } else { + onNext = (_nextEpisode != null && _canNavigateEpisodes()) ? _playNext : null; + } - VoidCallback? onPrevious; - if (widget.isLive) { - onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; - } else { - final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null; - onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null; - } + VoidCallback? onPrevious; + if (widget.isLive) { + onPrevious = _hasPreviousChannel ? () => _switchLiveChannel(-1) : null; + } else { + final canRestartOrPrevious = _currentMetadata.isEpisode || _previousEpisode != null; + onPrevious = (canRestartOrPrevious && _canNavigateEpisodes()) ? _restartOrPlayPrevious : null; + } - final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; - final sourceSubtitleTracks = _sourceSubtitleTracksForControls(); + final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; + final sourceSubtitleTracks = _sourceSubtitleTracksForControls(); - return Video( - player: player!, - controls: (context) => PlexVideoControls( + return Video( player: player!, - metadata: _currentMetadata, - onNext: onNext, - onPrevious: onPrevious, - availableVersions: _availableVersions, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: widget.selectedMediaSourceId, - selectedQualityPreset: _selectedQualityPreset, - serverSupportsTranscoding: _serverSupportsTranscoding, - isTranscoding: _isTranscoding, - isOfflinePlayback: _isOfflinePlayback, - sourceAudioTracks: sourceAudioTracks, - selectedAudioStreamId: _selectedAudioStreamId, - sourceSubtitleTracks: sourceSubtitleTracks, - selectedSubtitleStreamId: _selectedSourceSubtitleStreamId(sourceSubtitleTracks), - sourcePartId: _currentMediaInfo?.partId, - onTogglePIPMode: _togglePIPMode, - boxFitMode: _videoFilterManager?.boxFitMode ?? 0, - videoZoomScale: _videoFilterManager?.zoomScale ?? 1.0, - onCycleBoxFitMode: _cycleBoxFitMode, - onVideoZoomChanged: _setVideoZoom, - onZoomIn: _zoomVideoIn, - onZoomOut: _zoomVideoOut, - onResetVideoZoom: _resetVideoZoom, - onCycleAudioTrack: _cycleAudioTrack, - onCycleSubtitleTrack: _cycleSubtitleTrack, - onAudioTrackChanged: _onAudioTrackChanged, - onSubtitleTrackChanged: _onSubtitleTrackChanged, - onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, - onSeekRequested: _seekPlayback, - onSeekCompleted: _notifyWatchTogetherSeek, - onBack: _handleBackButton, - onReachedEnd: ({skipAutoPlayCountdown = false}) => - _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), - canControl: canControl, - hasFirstFrame: _hasFirstFrame, - playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, - controlsVisible: _controlsVisible, - shaderService: _shaderService, - // ignore: no-empty-block - state update triggers rebuild to reflect shader change - onShaderChanged: () => _setPlayerState(() {}), - thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null, - isLive: widget.isLive, - liveChannelName: _liveChannelName, - captureBuffer: _captureBuffer, - isAtLiveEdge: _isAtLiveEdge, - streamStartEpoch: _streamStartEpoch, - currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null, - onLiveSeek: _captureBuffer != null ? _seekLivePosition : null, - onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null, - isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, - onToggleAmbientLighting: _ambientLightingService?.isSupported == true - ? _toggleAmbientLighting - : null, - toastController: _toastController, - ), - ); - }, + controls: (context) => PlexVideoControls( + player: player!, + metadata: _currentMetadata, + onNext: onNext, + onPrevious: onPrevious, + availableVersions: _availableVersions, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: widget.selectedMediaSourceId, + selectedQualityPreset: _selectedQualityPreset, + serverSupportsTranscoding: _serverSupportsTranscoding, + isTranscoding: _isTranscoding, + isOfflinePlayback: _isOfflinePlayback, + sourceAudioTracks: sourceAudioTracks, + selectedAudioStreamId: _selectedAudioStreamId, + sourceSubtitleTracks: sourceSubtitleTracks, + selectedSubtitleStreamId: _selectedSourceSubtitleStreamId(sourceSubtitleTracks), + sourcePartId: _currentMediaInfo?.partId, + onTogglePIPMode: _togglePIPMode, + boxFitMode: _videoFilterManager?.boxFitMode ?? 0, + videoZoomScale: _videoFilterManager?.zoomScale ?? 1.0, + onCycleBoxFitMode: _cycleBoxFitMode, + onVideoZoomChanged: _setVideoZoom, + onZoomIn: _zoomVideoIn, + onZoomOut: _zoomVideoOut, + onResetVideoZoom: _resetVideoZoom, + onCycleAudioTrack: _cycleAudioTrack, + onCycleSubtitleTrack: _cycleSubtitleTrack, + onAudioTrackChanged: _onAudioTrackChanged, + onSubtitleTrackChanged: _onSubtitleTrackChanged, + onSecondarySubtitleTrackChanged: _onSecondarySubtitleTrackChanged, + onSeekRequested: _seekPlayback, + onSeekCompleted: _notifyWatchTogetherSeek, + onBack: _handleBackButton, + onReachedEnd: ({skipAutoPlayCountdown = false}) => + _onVideoCompleted(true, skipAutoPlayCountdown: skipAutoPlayCountdown), + canControl: canControl, + hasFirstFrame: _hasFirstFrame, + playNextFocusNode: _showPlayNextDialog ? _playNextConfirmFocusNode : null, + chromeController: _chromeController, + shaderService: _shaderService, + // ignore: no-empty-block - state update triggers rebuild to reflect shader change + onShaderChanged: () => _setPlayerState(() {}), + thumbnailDataBuilder: _scrubPreviewSource?.isAvailable == true ? _getThumbnailData : null, + isLive: widget.isLive, + liveChannelName: _liveChannelName, + captureBuffer: _captureBuffer, + isAtLiveEdge: _isAtLiveEdge, + streamStartEpoch: _streamStartEpoch, + currentPositionEpoch: widget.isLive ? _currentPositionEpoch : null, + onLiveSeek: _captureBuffer != null ? _seekLivePosition : null, + onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null, + isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false, + onToggleAmbientLighting: _ambientLightingService?.isSupported == true + ? _toggleAmbientLighting + : null, + toastController: _toastController, + ), + ); + }, + ), ), - ), - // Netflix-style auto-play overlay (hidden in PiP mode) - VideoPlayerPlayNextOverlay( - visible: _showPlayNextDialog, - nextEpisode: _nextEpisode, - autoPlayCountdown: _autoPlayCountdown, - cancelFocusNode: _playNextCancelFocusNode, - confirmFocusNode: _playNextConfirmFocusNode, - controlsVisible: _controlsVisible, - onCancel: _cancelAutoPlay, - onPlayNext: _playNext, - ), - // "Still watching?" overlay (hidden in PiP mode) - VideoPlayerStillWatchingOverlay( - visible: _showStillWatchingPrompt, - countdown: _stillWatchingCountdown, - pauseFocusNode: _stillWatchingPauseFocusNode, - continueFocusNode: _stillWatchingContinueFocusNode, - controlsVisible: _controlsVisible, - onPause: _onStillWatchingPause, - onContinue: _onStillWatchingContinue, - ), - // Buffering indicator (also shows during initial load, but not when exiting) - // Hidden in PiP mode - VideoPlayerBufferingOverlay( - isBuffering: _isBuffering, - hasFirstFrame: _hasFirstFrame, - isExiting: _isExiting, - ), - // Watch Together overlays (isolated from video surface repaints) - const VideoPlayerWatchTogetherOverlays(), - // Black overlay during exit (no spinner - just covers transparency) - VideoPlayerExitOverlay(isExiting: _isExiting), - ], + // Netflix-style auto-play overlay (hidden in PiP mode) + VideoPlayerPlayNextOverlay( + visible: _showPlayNextDialog, + nextEpisode: _nextEpisode, + autoPlayCountdown: _autoPlayCountdown, + cancelFocusNode: _playNextCancelFocusNode, + confirmFocusNode: _playNextConfirmFocusNode, + chromeController: _chromeController, + onCancel: _cancelAutoPlay, + onPlayNext: _playNext, + ), + // "Still watching?" overlay (hidden in PiP mode) + VideoPlayerStillWatchingOverlay( + visible: _showStillWatchingPrompt, + countdown: _stillWatchingCountdown, + pauseFocusNode: _stillWatchingPauseFocusNode, + continueFocusNode: _stillWatchingContinueFocusNode, + chromeController: _chromeController, + onPause: _onStillWatchingPause, + onContinue: _onStillWatchingContinue, + ), + // Buffering indicator (also shows during initial load, but not when exiting) + // Hidden in PiP mode + VideoPlayerBufferingOverlay( + isBuffering: _isBuffering, + hasFirstFrame: _hasFirstFrame, + isExiting: _isExiting, + ), + // Watch Together overlays (isolated from video surface repaints) + const VideoPlayerWatchTogetherOverlays(), + // Black overlay during exit (no spinner - just covers transparency) + VideoPlayerExitOverlay(isExiting: _isExiting), + ], + ), ), ), ), diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 1f30829d..d89f61cd 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -6,6 +6,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (_nextEpisode == null || _isLoadingNext) return; _autoPlayTimer?.cancel(); + _unfocusPlayNextPrompt(); _dismissStillWatching(); _notifyWatchTogetherMediaChange(metadata: _nextEpisode); @@ -40,6 +41,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { } _autoPlayTimer?.cancel(); + _unfocusPlayNextPrompt(); _dismissStillWatching(); _setPlayerState(() { @@ -167,6 +169,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { _currentMetadata = episodeMetadata; VideoPlayerScreenState._activeId = episodeMetadata.id; VideoPlayerScreenState._activeMediaIndex = requestedMediaIndex; + _unfocusPlayNextPrompt(); _showPlayNextDialog = false; _autoPlayTimer?.cancel(); _hasFirstFrame.value = false; diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 6bea0020..0a561639 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -97,6 +97,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _cancelAutoPlay() { _autoPlayTimer?.cancel(); + _unfocusPlayNextPrompt(); _progressTracker?.resumeAfterStoppedReport(); _completionTriggered = false; // Reset so it can trigger again if user seeks near end _setPlayerState(() { @@ -138,6 +139,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { } void _onStillWatchingTimeout() { + _unfocusStillWatchingPrompt(); player?.pause(); _setPlayerState(() { _showStillWatchingPrompt = false; @@ -146,6 +148,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _onStillWatchingContinue() { _stillWatchingTimer?.cancel(); + _unfocusStillWatchingPrompt(); SleepTimerService().restartTimer(); _setPlayerState(() { _showStillWatchingPrompt = false; @@ -154,6 +157,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _onStillWatchingPause() { _stillWatchingTimer?.cancel(); + _unfocusStillWatchingPrompt(); player?.pause(); _setPlayerState(() { _showStillWatchingPrompt = false; @@ -163,9 +167,20 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { void _dismissStillWatching() { _stillWatchingTimer?.cancel(); if (_showStillWatchingPrompt) { + _unfocusStillWatchingPrompt(); _setPlayerState(() { _showStillWatchingPrompt = false; }); } } + + void _unfocusPlayNextPrompt() { + _playNextCancelFocusNode.unfocus(); + _playNextConfirmFocusNode.unfocus(); + } + + void _unfocusStillWatchingPrompt() { + _stillWatchingPauseFocusNode.unfocus(); + _stillWatchingContinueFocusNode.unfocus(); + } } diff --git a/lib/screens/video_player/parts/seeking.dart b/lib/screens/video_player/parts/seeking.dart index 4f9082b0..d8b5f115 100644 --- a/lib/screens/video_player/parts/seeking.dart +++ b/lib/screens/video_player/parts/seeking.dart @@ -43,7 +43,7 @@ extension _VideoPlayerSeekingMethods on VideoPlayerScreenState { appLogger.d('Restarting Plex transcode at ${target.inSeconds}s'); _isRestartingTranscodeSeek = true; - _controlsVisible.value = true; + _chromeController.show(); final currentPlayer = player; if (currentPlayer == null) { diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index 134a4795..12cf3594 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart' show ValueListenable; +import 'package:flutter/foundation.dart' show ValueListenable, listEquals; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -12,6 +12,7 @@ import '../../../utils/platform_detector.dart'; import '../../../watch_together/providers/watch_together_provider.dart'; import '../../../watch_together/widgets/watch_together_overlay.dart'; import '../../../widgets/app_icon.dart'; +import '../../../widgets/video_controls/player_chrome_controller.dart'; class VideoPlayerMacPipPlaceholder extends StatelessWidget { const VideoPlayerMacPipPlaceholder({super.key}); @@ -169,7 +170,7 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { final int autoPlayCountdown; final FocusNode cancelFocusNode; final FocusNode confirmFocusNode; - final ValueListenable controlsVisible; + final PlayerChromeController chromeController; final VoidCallback onCancel; final VoidCallback onPlayNext; @@ -180,7 +181,7 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { required this.autoPlayCountdown, required this.cancelFocusNode, required this.confirmFocusNode, - required this.controlsVisible, + required this.chromeController, required this.onCancel, required this.onPlayNext, }); @@ -194,85 +195,75 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { if (isInPip || !visible || episode == null) { return const SizedBox.shrink(); } - return ValueListenableBuilder( - valueListenable: controlsVisible, - builder: (context, controlsShown, child) { - return AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - right: 24, - bottom: controlsShown ? 100 : 24, - child: Container( - width: 320, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.9), - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - _PlayNextEpisodeHeader(episode: episode), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: cancelFocusNode, + return _VideoPlayerPromptPosition( + chromeController: chromeController, + child: _VideoPlayerPromptInteractionHold( + chromeController: chromeController, + focusNodes: [cancelFocusNode, confirmFocusNode], + child: _VideoPlayerPromptCard( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + _PlayNextEpisodeHeader(episode: episode), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FocusableButton( + focusNode: cancelFocusNode, + onPressed: onCancel, + autoScroll: false, + onNavigateRight: () => confirmFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: OutlinedButton( onPressed: onCancel, - autoScroll: false, - onNavigateRight: () => confirmFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onCancel, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.common.cancel), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), ), + child: Text(t.common.cancel), ), ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: confirmFocusNode, + ), + const SizedBox(width: 8), + Expanded( + child: FocusableButton( + focusNode: confirmFocusNode, + onPressed: onPlayNext, + autoScroll: false, + onNavigateLeft: () => cancelFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: FilledButton( onPressed: onPlayNext, - autoScroll: false, - onNavigateLeft: () => cancelFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: FilledButton( - onPressed: onPlayNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - if (autoPlayCountdown > 0) ...[ - Text('$autoPlayCountdown'), - const SizedBox(width: 4), - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), - ] else - Text(t.videoControls.playNext), - ], - ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row( + mainAxisAlignment: .center, + children: [ + if (autoPlayCountdown > 0) ...[ + Text('$autoPlayCountdown'), + const SizedBox(width: 4), + const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + ] else + Text(t.videoControls.playNext), + ], ), ), ), - ], - ), - ], - ), + ), + ], + ), + ], ), - ); - }, + ), + ), ); }, ); @@ -337,7 +328,7 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { final int countdown; final FocusNode pauseFocusNode; final FocusNode continueFocusNode; - final ValueListenable controlsVisible; + final PlayerChromeController chromeController; final VoidCallback onPause; final VoidCallback onContinue; @@ -347,7 +338,7 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { required this.countdown, required this.pauseFocusNode, required this.continueFocusNode, - required this.controlsVisible, + required this.chromeController, required this.onPause, required this.onContinue, }); @@ -360,92 +351,217 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { if (isInPip || !visible) { return const SizedBox.shrink(); } - return ValueListenableBuilder( - valueListenable: controlsVisible, - builder: (context, controlsShown, child) { - return AnimatedPositioned( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - right: 24, - bottom: controlsShown ? 100 : 24, - child: Container( - width: 320, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.9), - borderRadius: const BorderRadius.all(Radius.circular(12)), - ), - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - t.videoControls.stillWatching, - style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), - ), - const SizedBox(height: 4), - Text( - t.videoControls.pausingIn(seconds: '$countdown'), - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: pauseFocusNode, + return _VideoPlayerPromptPosition( + chromeController: chromeController, + child: _VideoPlayerPromptInteractionHold( + chromeController: chromeController, + focusNodes: [pauseFocusNode, continueFocusNode], + child: _VideoPlayerPromptCard( + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text( + t.videoControls.stillWatching, + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), + ), + const SizedBox(height: 4), + Text( + t.videoControls.pausingIn(seconds: '$countdown'), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: FocusableButton( + focusNode: pauseFocusNode, + onPressed: onPause, + autoScroll: false, + onNavigateRight: () => continueFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: OutlinedButton( onPressed: onPause, - autoScroll: false, - onNavigateRight: () => continueFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onPause, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.videoControls.pauseButton), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), ), + child: Text(t.videoControls.pauseButton), ), ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: continueFocusNode, + ), + const SizedBox(width: 8), + Expanded( + child: FocusableButton( + focusNode: continueFocusNode, + onPressed: onContinue, + autoScroll: false, + onNavigateLeft: () => pauseFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: FilledButton( onPressed: onContinue, - autoScroll: false, - onNavigateLeft: () => pauseFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: FilledButton( - onPressed: onContinue, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - Text('$countdown'), - const SizedBox(width: 4), - Text(t.videoControls.continueWatching), - ], - ), + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row( + mainAxisAlignment: .center, + children: [ + Text('$countdown'), + const SizedBox(width: 4), + Text(t.videoControls.continueWatching), + ], ), ), ), - ], - ), - ], - ), + ), + ], + ), + ], ), - ); - }, + ), + ), ); }, ); } } + +class _VideoPlayerPromptPosition extends StatelessWidget { + final PlayerChromeController chromeController; + final Widget child; + + const _VideoPlayerPromptPosition({required this.chromeController, required this.child}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: chromeController, + builder: (context, controlsShown, child) { + return AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + right: 24, + bottom: controlsShown ? 100 : 24, + child: child!, + ); + }, + child: child, + ); + } +} + +class _VideoPlayerPromptCard extends StatelessWidget { + final Widget child; + + const _VideoPlayerPromptCard({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + width: 320, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.9), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: child, + ); + } +} + +class _VideoPlayerPromptInteractionHold extends StatefulWidget { + final PlayerChromeController chromeController; + final List focusNodes; + final Widget child; + + const _VideoPlayerPromptInteractionHold({ + required this.chromeController, + required this.focusNodes, + required this.child, + }); + + @override + State<_VideoPlayerPromptInteractionHold> createState() => _VideoPlayerPromptInteractionHoldState(); +} + +class _VideoPlayerPromptInteractionHoldState extends State<_VideoPlayerPromptInteractionHold> { + bool _hovered = false; + bool _focused = false; + + @override + void initState() { + super.initState(); + _addFocusListeners(widget.focusNodes); + _syncFocusState(); + } + + @override + void didUpdateWidget(_VideoPlayerPromptInteractionHold oldWidget) { + super.didUpdateWidget(oldWidget); + if (!listEquals(oldWidget.focusNodes, widget.focusNodes)) { + _removeFocusListeners(oldWidget.focusNodes); + _addFocusListeners(widget.focusNodes); + _syncFocusState(); + } + if (oldWidget.chromeController != widget.chromeController) { + oldWidget.chromeController.release(PlayerChromeHold.promptInteraction); + _syncHold(); + } + } + + @override + void dispose() { + _removeFocusListeners(widget.focusNodes); + widget.chromeController.release(PlayerChromeHold.promptInteraction, notify: false, restartAutoHide: false); + super.dispose(); + } + + void _addFocusListeners(List nodes) { + for (final node in nodes) { + node.addListener(_syncFocusState); + } + } + + void _removeFocusListeners(List nodes) { + for (final node in nodes) { + node.removeListener(_syncFocusState); + } + } + + void _syncFocusState() { + final focused = widget.focusNodes.any((node) => node.hasFocus); + if (_focused == focused) return; + _focused = focused; + _syncHold(); + } + + void _setHovered(bool hovered) { + if (_hovered == hovered) return; + _hovered = hovered; + if (hovered) widget.chromeController.recordPointerActivity(); + _syncHold(); + } + + void _syncHold() { + if (_hovered || _focused) { + widget.chromeController.hold(PlayerChromeHold.promptInteraction); + } else { + widget.chromeController.release(PlayerChromeHold.promptInteraction); + } + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => _setHovered(true), + onHover: (_) => widget.chromeController.recordPointerActivity(), + onExit: (_) => _setHovered(false), + child: widget.child, + ); + } +} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 83640d16..6c60f87e 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -74,6 +74,7 @@ import '../utils/snackbar_helper.dart'; import '../utils/video_player_navigation.dart'; import 'video_player/widgets/player_prompt_overlays.dart'; import '../widgets/overlay_sheet.dart'; +import '../widgets/video_controls/player_chrome_controller.dart'; import '../widgets/video_controls/video_controls.dart'; import '../widgets/video_controls/widgets/player_toast_indicator.dart'; import '../focus/focusable_button.dart'; @@ -452,7 +453,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final ValueNotifier _isBuffering = ValueNotifier(false); final ValueNotifier _hasFirstFrame = ValueNotifier(false); final ValueNotifier _isExiting = ValueNotifier(false); - final ValueNotifier _controlsVisible = ValueNotifier(true); + final PlayerChromeController _chromeController = PlayerChromeController(); @override void initState() { @@ -1112,7 +1113,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin _isBuffering.dispose(); _hasFirstFrame.dispose(); _isExiting.dispose(); - _controlsVisible.dispose(); + _chromeController.dispose(); _toastController.dispose(); // Stop progress tracking and send final state. Normal back navigation @@ -1360,11 +1361,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin // focused, e.g. after controls auto-hide), redirect to first descendant. if (node.hasPrimaryFocus) { if (event.isActionable) { - _controlsVisible.value = true; - final descendants = node.traversalDescendants; - if (descendants.isNotEmpty) { - descendants.first.requestFocus(); - } + _chromeController.show(focusTarget: PlayerChromeFocusTarget.playPause); } return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored; } diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 096d7981..ede03d63 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -8,6 +8,7 @@ import '../../media/media_source_info.dart'; import '../../services/scrub_preview_source.dart'; import '../../utils/desktop_window_padding.dart'; import '../../i18n/strings.g.dart'; +import 'player_chrome_controller.dart'; import 'widgets/circular_control_button.dart'; import 'widgets/content_strip.dart'; import 'widgets/first_frame_guard.dart'; @@ -75,8 +76,8 @@ class MobileVideoControls extends StatefulWidget { /// Callback when a queue item is selected from the content strip final Function(MediaItem)? onQueueItemSelected; - /// Notifier for controls visibility (used to reset strip on hide) - final ValueNotifier? controlsVisible; + /// Shared controller for chrome visibility (used to reset strip on hide) + final PlayerChromeController? chromeController; /// Called when the content strip visibility changes final ValueChanged? onStripVisibilityChanged; @@ -112,7 +113,7 @@ class MobileVideoControls extends StatefulWidget { this.serverId, this.showQueueTab = false, this.onQueueItemSelected, - this.controlsVisible, + this.chromeController, this.onStripVisibilityChanged, }); @@ -135,21 +136,21 @@ class _MobileVideoControlsState extends State with SingleTi super.initState(); _stripAnim = AnimationController(vsync: this, duration: const Duration(milliseconds: 250)); _stripAnim.addListener(_onStripAnimChanged); - widget.controlsVisible?.addListener(_onControlsVisibilityChanged); + widget.chromeController?.addListener(_onChromeVisibilityChanged); } @override void didUpdateWidget(MobileVideoControls oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.controlsVisible != widget.controlsVisible) { - oldWidget.controlsVisible?.removeListener(_onControlsVisibilityChanged); - widget.controlsVisible?.addListener(_onControlsVisibilityChanged); + if (oldWidget.chromeController != widget.chromeController) { + oldWidget.chromeController?.removeListener(_onChromeVisibilityChanged); + widget.chromeController?.addListener(_onChromeVisibilityChanged); } } @override void dispose() { - widget.controlsVisible?.removeListener(_onControlsVisibilityChanged); + widget.chromeController?.removeListener(_onChromeVisibilityChanged); _stripAnim.removeListener(_onStripAnimChanged); _stripAnim.dispose(); super.dispose(); @@ -163,13 +164,13 @@ class _MobileVideoControlsState extends State with SingleTi } } - void _onControlsVisibilityChanged() { - if (widget.controlsVisible?.value == false && _stripVisible) { + void _onChromeVisibilityChanged() { + if (widget.chromeController?.controlsVisible == false && _stripVisible) { // Just notify parent that strip is no longer active — don't animate, // let the overlay fade out with the strip still showing. _stripVisible = false; widget.onStripVisibilityChanged?.call(false); - } else if (widget.controlsVisible?.value == true && _stripAnim.value > 0) { + } else if (widget.chromeController?.controlsVisible == true && _stripAnim.value > 0) { // Reset strip when controls reappear so page 0 is shown. _stripAnim.value = 0; } diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index c9238b45..3310b26c 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -89,9 +89,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { final backResult = handleBackKeyAction(event, () { if (PlatformDetector.isTV()) { if (_showControls) { - if (_isContentStripVisible) { + if (widget.chromeController.contentStripVisible) { _desktopControlsKey.currentState?.dismissContentStrip(); - _setControlsState(() => _isContentStripVisible = false); + widget.chromeController.setContentStripVisible(false); _restartHideTimerIfPlaying(); return; } @@ -190,9 +190,9 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { final backResult = handleBackKeyAction(event, () { if (PlatformDetector.isTV()) { if (_showControls) { - if (_isContentStripVisible) { + if (widget.chromeController.contentStripVisible) { _desktopControlsKey.currentState?.dismissContentStrip(); - _setControlsState(() => _isContentStripVisible = false); + widget.chromeController.setContentStripVisible(false); _restartHideTimerIfPlaying(); return; } diff --git a/lib/widgets/video_controls/parts/markers.dart b/lib/widgets/video_controls/parts/markers.dart index 0434437b..513c697e 100644 --- a/lib/widgets/video_controls/parts/markers.dart +++ b/lib/widgets/video_controls/parts/markers.dart @@ -3,37 +3,70 @@ part of '../video_controls.dart'; extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { void _listenToPosition() { _positionSubscription = widget.player.streams.position.listen((position) { - if (_markers.isEmpty || !_markersLoaded) { - return; - } - - MediaMarker? foundMarker; - for (final marker in _markers) { - if (marker.containsPosition(position)) { - foundMarker = marker; - break; - } - } - - if (foundMarker != _currentMarker && mounted) { - _updateCurrentMarker(foundMarker); - } + _syncCurrentMarkerForPosition(position); }); } + void _syncCurrentMarkerForCurrentPosition() { + _syncCurrentMarkerForPosition(widget.player.state.position); + } + + void _syncCurrentMarkerForPosition(Duration position) { + if (!_hasRenderedFirstFrame || _markers.isEmpty || !_markersLoaded) { + _clearCurrentMarker(); + return; + } + + MediaMarker? foundMarker; + for (final marker in _markers) { + if (marker.containsPosition(position)) { + foundMarker = marker; + break; + } + } + + if (foundMarker != _currentMarker && mounted) { + _updateCurrentMarker(foundMarker); + } + } + + void _clearCurrentMarker() { + final hasMarkerState = + _currentMarker != null || + _skipButtonDismissed || + _autoSkipTimer != null || + _autoSkipProgress != 0.0 || + _skipButtonDismissTimer != null; + if (!hasMarkerState) return; + + if (_currentMarker != null || _skipButtonDismissed) { + _setControlsState(() { + _currentMarker = null; + _skipButtonDismissed = false; + }); + } + if (_skipMarkerFocusNode.hasFocus) _skipMarkerFocusNode.unfocus(); + _cancelAutoSkipTimer(); + _cancelSkipButtonDismissTimer(); + } + /// Updates the current marker and manages auto-skip/focus behavior. void _updateCurrentMarker(MediaMarker? foundMarker) { + if (!_hasRenderedFirstFrame) { + _clearCurrentMarker(); + return; + } + + if (foundMarker == null) { + _clearCurrentMarker(); + return; + } + _setControlsState(() { _currentMarker = foundMarker; _skipButtonDismissed = false; }); - if (foundMarker == null) { - _cancelAutoSkipTimer(); - _cancelSkipButtonDismissTimer(); - return; - } - _startAutoSkipTimer(foundMarker); // Auto-skip OFF: dismiss button after 7s if no interaction @@ -53,7 +86,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { } Future _skipMarker({bool skipAutoPlayCountdown = false}) async { - if (_currentMarker == null) return; + if (_currentMarker == null || !_hasRenderedFirstFrame) return; final marker = _currentMarker!; final endTime = marker.endTime; @@ -83,6 +116,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { void _startAutoSkipTimer(MediaMarker marker) { _cancelAutoSkipTimer(); + if (!_hasRenderedFirstFrame) return; final shouldAutoSkip = (marker.isCredits && _autoSkipCredits) || (!marker.isCredits && _autoSkipIntro); @@ -112,9 +146,10 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { } void _cancelAutoSkipTimer() { + final hadTimer = _autoSkipTimer != null; _autoSkipTimer?.cancel(); _autoSkipTimer = null; - if (mounted) { + if (mounted && (hadTimer || _autoSkipProgress != 0.0)) { _setControlsState(() { _autoSkipProgress = 0.0; }); @@ -125,6 +160,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { /// button and cancels any active auto-skip countdown. void _startSkipButtonDismissTimer() { _skipButtonDismissTimer?.cancel(); + if (!_hasRenderedFirstFrame) return; _skipButtonDismissTimer = Timer(const Duration(seconds: 7), () { if (!mounted || _currentMarker == null) return; _setControlsState(() { @@ -141,7 +177,7 @@ extension _PlexVideoControlsMarkerMethods on _PlexVideoControlsState { /// Perform the appropriate skip action based on marker type and next episode availability void _performAutoSkip({bool skipAutoPlayCountdown = false}) { - if (_currentMarker == null) return; + if (_currentMarker == null || !_hasRenderedFirstFrame) return; unawaited(_skipMarker(skipAutoPlayCountdown: skipAutoPlayCountdown)); } diff --git a/lib/widgets/video_controls/parts/navigation.dart b/lib/widgets/video_controls/parts/navigation.dart index 9cc3ff03..4a0de339 100644 --- a/lib/widgets/video_controls/parts/navigation.dart +++ b/lib/widgets/video_controls/parts/navigation.dart @@ -48,16 +48,11 @@ extension _PlexVideoControlsNavigationMethods on _PlexVideoControlsState { serverId: widget.metadata.serverId, showQueueTab: playbackState.isQueueActive, onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, - onCancelAutoHide: () => _hideTimer?.cancel(), + onCancelAutoHide: widget.chromeController.cancelAutoHide, onStartAutoHide: _startHideTimer, onSeekCompleted: widget.onSeekCompleted, onContentStripVisibilityChanged: (visible) { - _setControlsState(() => _isContentStripVisible = visible); - if (visible) { - _hideTimer?.cancel(); - } else { - _restartHideTimerIfPlaying(); - } + widget.chromeController.setContentStripVisible(visible); }, ), ); diff --git a/lib/widgets/video_controls/parts/playback_extras.dart b/lib/widgets/video_controls/parts/playback_extras.dart index 6dc2a0c1..17bfbfb4 100644 --- a/lib/widgets/video_controls/parts/playback_extras.dart +++ b/lib/widgets/video_controls/parts/playback_extras.dart @@ -33,5 +33,6 @@ extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState { _chaptersLoaded = true; _markersLoaded = true; }); + _syncCurrentMarkerForCurrentPosition(); } } diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index 3288cd5d..9820bb24 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -130,7 +130,7 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { onSubtitleTrackChanged: _onSubtitleTrackChanged, onSecondarySubtitleTrackChanged: widget.onSecondarySubtitleTrackChanged, onLoadSeekTimes: null, - onCancelAutoHide: () => _hideTimer?.cancel(), + onCancelAutoHide: widget.chromeController.cancelAutoHide, onStartAutoHide: _startHideTimer, // Sync offsets are now driven by listenable rebuilds — the sheet writes // to SettingsService and the parent re-reads via `_audioSyncOffset` / diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index d979937b..f3525518 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -3,20 +3,16 @@ part of '../video_controls.dart'; extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready void _onFirstFrameReady() { - if (widget.hasFirstFrame?.value == true) { - _startHideTimer(); + final hasFrame = widget.hasFirstFrame?.value ?? true; + widget.chromeController.setHasFirstFrame(hasFrame); + if (hasFrame) { // Retry with network-first if initial cache-first returned empty if (_chapters.isEmpty && _markers.isEmpty) { _loadPlaybackExtras(forceRefresh: true); } - } - } - - /// Called when controlsVisible is set externally (e.g. screen-level focus recovery - /// after controls auto-hide ejects focus on Android TV). - void _onControlsVisibleExternal() { - if (widget.controlsVisible?.value == true && !_showControls && mounted) { - _showControlsWithFocus(); + _syncCurrentMarkerForCurrentPosition(); + } else { + _clearCurrentMarker(); } } @@ -33,11 +29,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// Listen to playback state changes to manage auto-hide timer void _listenToPlayingState() { _playingSubscription = widget.player.streams.playing.listen((isPlaying) { - if (isPlaying && _showControls) { - _startHideTimer(); - } else if (!isPlaying && _showControls) { - _startPausedHideTimer(); - } + widget.chromeController.setPlaying(isPlaying); }); } @@ -48,12 +40,8 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { if (_isLongPressing) { _handleLongPressCancel(); } - _setControlsState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - _hideTimer?.cancel(); + widget.chromeController.show(restartAutoHide: false); + widget.chromeController.cancelAutoHide(); } }); } @@ -69,85 +57,14 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// Shared hide logic: hides controls, notifies parent, updates traffic lights, restores focus. void _hideControls() { - if (!mounted || !_showControls || _forceShowControls) return; - _setControlsState(() { - _showControls = false; - _isContentStripVisible = false; - // Dismiss skip button with controls — after this it only re-appears with controls - if (_currentMarker != null) { - _skipButtonDismissed = true; - } - }); - _desktopControlsKey.currentState?.hideContentStrip(); - _cancelSkipButtonDismissTimer(); - widget.controlsVisible?.value = false; - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - // Reclaim focus so the global key handler stays active for TV dpad, - // but skip if an overlay sheet owns focus — stealing it would break - // sheet navigation (e.g. the compact sync bar). - final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; - if (!sheetOpen) { - // Always request primary focus on _focusNode — not just when hasFocus is - // false. hasFocus is true when a descendant (e.g. play/pause) has focus, - // but we need _focusNode itself to hold primary focus so its onKeyEvent - // fires for the next d-pad press (otherwise focus escapes to the screen- - // level self-heal handler which shows controls with play/pause focus). - _focusNode.requestFocus(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_focusNode.hasPrimaryFocus) { - _focusNode.requestFocus(); - } - }); - } + if (!mounted) return; + widget.chromeController.hide(); } - void _startHideTimer() { - _hideTimer?.cancel(); - - // Don't auto-hide while loading first frame (user needs to see spinner and back button) - final hasFrame = widget.hasFirstFrame?.value ?? true; - if (!hasFrame) return; - - if (_forceShowControls) return; - - // Only auto-hide while playing; keep controls visible while paused. - if (widget.player.state.playing) { - _hideTimer = Timer(_hideDelay, () { - // Also check hasFirstFrame in callback (in case it changed) - final stillLoading = !(widget.hasFirstFrame?.value ?? true); - if (mounted && widget.player.state.playing && !stillLoading) { - _hideControls(); - } - }); - } - } - - /// Auto-hide controls after pause (does not check playing state in callback). - void _startPausedHideTimer() { - _hideTimer?.cancel(); - if (_forceShowControls) return; - _hideTimer = Timer(_hideDelay, () { - _hideControls(); - }); - } + void _startHideTimer() => widget.chromeController.startAutoHide(); /// Restart the hide timer on user interaction (if video is playing) - void _restartHideTimerIfPlaying() { - if (widget.player.state.playing) { - _startHideTimer(); - } - } - - /// Hide controls immediately when the mouse leaves the player area (desktop only). - void _hideControlsFromPointerExit() { - final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV(); - if (isMobile) return; - - _hideTimer?.cancel(); - _hideControls(); - } + void _restartHideTimerIfPlaying() => widget.chromeController.restartAutoHideIfPlaying(); void _handlePointerSignal(PointerSignalEvent event) { if (event is PointerScrollEvent && _keyboardService != null) { @@ -163,43 +80,15 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// Show controls in response to pointer activity (mouse/trackpad movement). void _showControlsFromPointerActivity() { - final nowMs = _pointerActivityStopwatch.elapsedMilliseconds; - final shouldThrottle = _showControls && nowMs - _lastPointerActivityMs < 120; - if (shouldThrottle) return; - _lastPointerActivityMs = nowMs; - - if (!_showControls) { - _setControlsState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - // On macOS, keep window controls in sync with the overlay - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - - // Keep the overlay visible while the user is moving the pointer - _restartHideTimerIfPlaying(); + final handled = widget.chromeController.recordPointerActivity(); + if (!handled) return; // Cancel auto-skip when user moves pointer over the player _cancelAutoSkipTimer(); } void _toggleControls() { - if (_showControls) { - _hideControls(); - } else { - _setControlsState(() { - _showControls = true; - }); - widget.controlsVisible?.value = true; - _startHideTimer(); - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } + widget.chromeController.toggle(); // Cancel auto-skip on any tap _cancelAutoSkipTimer(); } @@ -228,7 +117,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { } }); if (locking) { - _hideControls(); + widget.chromeController.hide(ignoreHolds: true); _startLockIconHideTimer(); } } @@ -244,11 +133,9 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { _setControlsState(() { _isScreenLocked = false; _showLockIcon = false; - _showControls = true; }); _lockIconTimer?.cancel(); - widget.controlsVisible?.value = true; - _startHideTimer(); + widget.chromeController.show(); } void _updateTrafficLightVisibility() async { @@ -258,7 +145,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { // In normal windowed mode, toggle with controls as before. final isMaximizedOrFullscreen = await windowManager.isMaximized() || await MacOSWindowService.isFullscreen(); if (!mounted || generation != _trafficLightVisibilityGeneration) return; - final visible = isMaximizedOrFullscreen || _forceShowControls ? true : _showControls; + final visible = isMaximizedOrFullscreen || _showControls; await MacOSWindowService.setTrafficLightsVisible(visible); } @@ -283,12 +170,10 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { void _onMacPipChanged() { if (!mounted) return; final inPip = _pipService.isPipActive.value; - _setControlsState(() => _forceShowControls = inPip); if (inPip) { - _hideTimer?.cancel(); - widget.controlsVisible?.value = true; + widget.chromeController.hold(PlayerChromeHold.pip); } else { - _startHideTimer(); + widget.chromeController.release(PlayerChromeHold.pip); } } @@ -330,17 +215,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// Show controls and optionally focus play/pause on keyboard input (desktop only) void _showControlsWithFocus({bool requestFocus = true}) { - if (!_showControls) { - _setControlsState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - _startHideTimer(); + widget.chromeController.show(); if (requestFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -360,17 +235,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// Show controls and focus timeline on LEFT/RIGHT input (TV/desktop) void _showControlsWithTimelineFocus() { - if (!_showControls) { - _setControlsState(() { - _showControls = true; - }); - // Notify parent of visibility change (for popup positioning) - widget.controlsVisible?.value = true; - if (Platform.isMacOS) { - _updateTrafficLightVisibility(); - } - } - _startHideTimer(); + widget.chromeController.show(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -395,4 +260,54 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { _hideControls(); } } + + void _onChromeChanged() { + if (!mounted) return; + final controlsVisible = widget.chromeController.controlsVisible; + final visibilityChanged = controlsVisible != _lastControlsVisible; + final focusTarget = widget.chromeController.takeFocusTarget(); + _lastControlsVisible = controlsVisible; + + if (visibilityChanged && !controlsVisible) { + _desktopControlsKey.currentState?.hideContentStrip(); + _cancelSkipButtonDismissTimer(); + _setControlsState(() { + if (_currentMarker != null) _skipButtonDismissed = true; + }); + _reclaimFocusAfterControlsHide(); + } else { + _setControlsState(() {}); + } + + if (visibilityChanged && Platform.isMacOS) { + _updateTrafficLightVisibility(); + } + + if (focusTarget != null) { + _requestFocusTarget(focusTarget); + } + } + + void _reclaimFocusAfterControlsHide() { + final sheetOpen = OverlaySheetController.maybeOf(context)?.isOpen ?? false; + if (sheetOpen) return; + _focusNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }); + } + + void _requestFocusTarget(PlayerChromeFocusTarget target) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !widget.chromeController.controlsVisible) return; + switch (target) { + case PlayerChromeFocusTarget.playPause: + _desktopControlsKey.currentState?.requestPlayPauseFocus(); + case PlayerChromeFocusTarget.timeline: + _desktopControlsKey.currentState?.requestTimelineFocus(); + } + }); + } } diff --git a/lib/widgets/video_controls/player_chrome_controller.dart b/lib/widgets/video_controls/player_chrome_controller.dart new file mode 100644 index 00000000..b3590f50 --- /dev/null +++ b/lib/widgets/video_controls/player_chrome_controller.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' + show BuildContext, ListenableBuilder, MouseRegion, StatelessWidget, SystemMouseCursors, Widget; + +/// Reasons that keep the video-player chrome visible and suppress auto-hide. +enum PlayerChromeHold { pip, contentStrip, promptInteraction } + +/// Focus target to request after chrome has rebuilt visible controls. +enum PlayerChromeFocusTarget { playPause, timeline } + +/// Owns video-player chrome visibility and auto-hide policy for one player route. +class PlayerChromeController extends ChangeNotifier implements ValueListenable { + PlayerChromeController({bool controlsVisible = true}) : _controlsVisible = controlsVisible; + + bool _controlsVisible; + bool _contentStripVisible = false; + bool _playing = false; + bool _hasFirstFrame = true; + Duration _hideDelay = const Duration(seconds: 3); + Timer? _hideTimer; + PlayerChromeFocusTarget? _pendingFocusTarget; + final Set _holds = {}; + final Stopwatch _pointerActivityStopwatch = Stopwatch()..start(); + int _lastPointerActivityMs = -1000; + + @override + bool get value => _controlsVisible; + + bool get controlsVisible => _controlsVisible; + bool get contentStripVisible => _contentStripVisible; + bool get hasVisibleHold => _holds.isNotEmpty; + bool isHeld(PlayerChromeHold hold) => _holds.contains(hold); + PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget; + + void configure({Duration? hideDelay, bool? hasFirstFrame}) { + var restartTimer = false; + if (hideDelay != null && hideDelay != _hideDelay) { + _hideDelay = hideDelay; + restartTimer = true; + } + if (hasFirstFrame != null && hasFirstFrame != _hasFirstFrame) { + _hasFirstFrame = hasFirstFrame; + restartTimer = true; + } + if (restartTimer) _startAutoHideForCurrentPlaybackState(); + } + + void setPlaying(bool playing) { + if (_playing == playing) return; + _playing = playing; + if (!_controlsVisible) return; + if (playing) { + startAutoHide(); + } else { + startPausedAutoHide(); + } + } + + void setHasFirstFrame(bool hasFirstFrame) { + if (_hasFirstFrame == hasFirstFrame) return; + _hasFirstFrame = hasFirstFrame; + if (!_hasFirstFrame) { + cancelAutoHide(); + return; + } + _startAutoHideForCurrentPlaybackState(); + } + + void setContentStripVisible(bool visible) { + if (_contentStripVisible == visible) return; + _contentStripVisible = visible; + if (visible) { + hold(PlayerChromeHold.contentStrip); + } else { + release(PlayerChromeHold.contentStrip); + } + } + + void show({bool restartAutoHide = true, PlayerChromeFocusTarget? focusTarget}) { + var shouldNotify = false; + if (focusTarget != null) { + _pendingFocusTarget = focusTarget; + shouldNotify = true; + } + if (!_controlsVisible) { + _controlsVisible = true; + shouldNotify = true; + } + if (shouldNotify) notifyListeners(); + if (restartAutoHide) startAutoHide(); + } + + PlayerChromeFocusTarget? takeFocusTarget() { + final target = _pendingFocusTarget; + _pendingFocusTarget = null; + return target; + } + + bool hide({bool ignoreHolds = false}) { + if (!_controlsVisible) return false; + if (!ignoreHolds && _holds.isNotEmpty) return false; + cancelAutoHide(); + _controlsVisible = false; + if (_contentStripVisible) { + _contentStripVisible = false; + _holds.remove(PlayerChromeHold.contentStrip); + } + notifyListeners(); + return true; + } + + void toggle() { + if (_controlsVisible) { + hide(); + } else { + show(); + } + } + + bool recordPointerActivity() { + final nowMs = _pointerActivityStopwatch.elapsedMilliseconds; + final shouldThrottle = _controlsVisible && nowMs - _lastPointerActivityMs < 120; + if (shouldThrottle) return false; + _lastPointerActivityMs = nowMs; + + show(restartAutoHide: false); + restartAutoHideIfPlaying(); + return true; + } + + void startAutoHide() { + _hideTimer?.cancel(); + if (!_hasFirstFrame || _holds.isNotEmpty || !_playing) return; + _hideTimer = Timer(_hideDelay, () { + if (_playing && _hasFirstFrame) hide(); + }); + } + + void startPausedAutoHide() { + _hideTimer?.cancel(); + if (!_controlsVisible || !_hasFirstFrame || _holds.isNotEmpty) return; + _hideTimer = Timer(_hideDelay, hide); + } + + void _startAutoHideForCurrentPlaybackState() { + if (!_controlsVisible) { + cancelAutoHide(); + return; + } + if (_playing) { + startAutoHide(); + } else { + startPausedAutoHide(); + } + } + + void restartAutoHideIfPlaying() { + if (_playing) startAutoHide(); + } + + void hideForPointerExit() { + if (_holds.contains(PlayerChromeHold.pip)) return; + hide(ignoreHolds: true); + } + + void cancelAutoHide() { + _hideTimer?.cancel(); + _hideTimer = null; + } + + void hold(PlayerChromeHold hold) { + if (!_holds.add(hold)) return; + cancelAutoHide(); + if (!_controlsVisible) { + _controlsVisible = true; + } + notifyListeners(); + } + + void release(PlayerChromeHold hold, {bool notify = true, bool restartAutoHide = true}) { + if (!_holds.remove(hold)) return; + if (notify) notifyListeners(); + if (restartAutoHide && _holds.isEmpty) _startAutoHideForCurrentPlaybackState(); + } + + @override + void dispose() { + cancelAutoHide(); + super.dispose(); + } +} + +/// Defines the pointer boundary for all interactive video-player chrome. +class PlayerChromeInteractionRegion extends StatelessWidget { + final PlayerChromeController controller; + final bool hideOnExit; + final Widget child; + + const PlayerChromeInteractionRegion({ + super.key, + required this.controller, + required this.hideOnExit, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + return MouseRegion( + cursor: controller.controlsVisible ? SystemMouseCursors.basic : SystemMouseCursors.none, + onHover: (_) => controller.recordPointerActivity(), + onExit: (_) { + if (!hideOnExit) return; + controller.cancelAutoHide(); + controller.hideForPointerExit(); + }, + child: child, + ); + }, + ); + } +} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 876a699b..24c9fe84 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -58,6 +58,7 @@ import '../../theme/mono_tokens.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import 'icons.dart'; +import 'player_chrome_controller.dart'; import 'playback_extras_loader.dart'; import 'widgets/player_toast_indicator.dart'; import '../../utils/app_logger.dart'; @@ -144,6 +145,17 @@ effectiveVersionQualityControls({ ); } +@visibleForTesting +bool shouldShowSkipMarkerButton({ + required bool hasFirstFrame, + required bool hasMarker, + required bool hasPlayNextPrompt, + required bool skipButtonDismissed, + required bool controlsVisible, +}) { + return hasFirstFrame && hasMarker && !hasPlayNextPrompt && (!skipButtonDismissed || controlsVisible); +} + class PlexVideoControls extends StatefulWidget { final Player player; final MediaItem metadata; @@ -199,8 +211,8 @@ class PlexVideoControls extends StatefulWidget { /// Optional focus node for Play Next dialog button (for TV navigation from timeline) final FocusNode? playNextFocusNode; - /// Notifier to report controls visibility to parent (for popup positioning) - final ValueNotifier? controlsVisible; + /// Shared controller for player chrome visibility, auto-hide, and layout state. + final PlayerChromeController chromeController; /// Optional shader service for MPV shader control final ShaderService? shaderService; @@ -283,7 +295,7 @@ class PlexVideoControls extends StatefulWidget { this.canControl = true, this.hasFirstFrame, this.playNextFocusNode, - this.controlsVisible, + required this.chromeController, this.shaderService, this.onShaderChanged, this.thumbnailDataBuilder, @@ -305,12 +317,13 @@ class PlexVideoControls extends StatefulWidget { class _PlexVideoControlsState extends State with WindowListener, SettingsEffectMixin, MountedSetStateMixin { - bool _showControls = true; - bool _forceShowControls = false; + bool get _showControls => widget.chromeController.controlsVisible; + bool get _hasRenderedFirstFrame => widget.hasFirstFrame?.value ?? true; + + late bool _lastControlsVisible; bool _isLoadingExtras = false; List _chapters = []; bool _chaptersLoaded = false; - Timer? _hideTimer; bool _isFullscreen = false; bool _isAlwaysOnTop = false; late final FocusNode _focusNode; @@ -329,7 +342,6 @@ class _PlexVideoControlsState extends State Timer? _lockIconTimer; bool get _clickVideoTogglesPlayback => _settings.read(SettingsService.clickVideoTogglesPlayback); bool get _showChapterMarkersOnTimeline => _settings.read(SettingsService.showChapterMarkersOnTimeline); - bool _isContentStripVisible = false; // Whether the swipe-up content strip is showing int _trafficLightVisibilityGeneration = 0; // GlobalKey to access DesktopVideoControls state for focus management @@ -381,8 +393,6 @@ class _PlexVideoControlsState extends State // Skip marker button focus node (for TV D-pad navigation) late final FocusNode _skipMarkerFocusNode; final ValueNotifier _fallbackHasFirstFrame = ValueNotifier(true); - final Stopwatch _pointerActivityStopwatch = Stopwatch()..start(); - int _lastPointerActivityMs = -1000; double? _rateBeforeLongPress; bool _showSpeedIndicator = false; StreamSubscription? _rateSubscription; @@ -398,6 +408,7 @@ class _PlexVideoControlsState extends State @override void initState() { super.initState(); + _lastControlsVisible = widget.chromeController.controlsVisible; _focusNode = FocusNode(); _skipMarkerFocusNode = FocusNode(debugLabel: 'SkipMarkerButton'); _seekThrottle = throttle( @@ -412,6 +423,7 @@ class _PlexVideoControlsState extends State // so init wiring (orientation, focus) lives in one place. bindEffect(SettingsService.rotationLocked, _applyRotationLock); bindEffect(SettingsService.videoPlayerNavigationEnabled, (enabled) { + _configureChromeController(); if (enabled && _showControls) _focusPlayPauseIfKeyboardMode(); }, fireImmediately: false); // Rebuild on any setting that affects build output (seek labels, skip @@ -431,7 +443,9 @@ class _PlexVideoControlsState extends State SettingsService.clickVideoTogglesPlayback, SettingsService.showChapterMarkersOnTimeline, ]); - _startHideTimer(); + widget.chromeController.addListener(_onChromeChanged); + _configureChromeController(); + widget.chromeController.setPlaying(widget.player.state.playing); _initKeyboardService(); _listenToPosition(); _listenToPlayingState(); @@ -451,8 +465,6 @@ class _PlexVideoControlsState extends State HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent); // Listen for first frame to start auto-hide timer widget.hasFirstFrame?.addListener(_onFirstFrameReady); - // Listen for external requests to show controls (e.g. screen-level focus recovery) - widget.controlsVisible?.addListener(_onControlsVisibleExternal); // On macOS, show controls and disable auto-hide when PiP activates if (Platform.isMacOS) { _pipService.isPipActive.addListener(_onMacPipChanged); @@ -472,12 +484,26 @@ class _PlexVideoControlsState extends State void _setControlsState(VoidCallback fn) => setStateIfMounted(fn); + void _configureChromeController() { + widget.chromeController.configure(hideDelay: _hideDelay, hasFirstFrame: widget.hasFirstFrame?.value ?? true); + } + + @override + void didUpdateWidget(PlexVideoControls oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.chromeController != widget.chromeController) { + oldWidget.chromeController.removeListener(_onChromeChanged); + _lastControlsVisible = widget.chromeController.controlsVisible; + widget.chromeController.addListener(_onChromeChanged); + } + _configureChromeController(); + } + @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); - widget.controlsVisible?.removeListener(_onControlsVisibleExternal); + widget.chromeController.removeListener(_onChromeChanged); widget.hasFirstFrame?.removeListener(_onFirstFrameReady); - _hideTimer?.cancel(); _feedbackTimer?.cancel(); _lockIconTimer?.cancel(); _autoSkipTimer?.cancel(); @@ -583,9 +609,7 @@ class _PlexVideoControlsState extends State onPointerCancel: isMobile ? _handleTouchPointerCancel : null, onPointerSignal: _handlePointerSignal, child: MouseRegion( - cursor: (_showControls || _forceShowControls) ? SystemMouseCursors.basic : SystemMouseCursors.none, onHover: (_) => _showControlsFromPointerActivity(), - onExit: (_) => _hideControlsFromPointerExit(), child: Stack( children: [ // Keep-alive: 1px widget that continuously repaints to prevent @@ -618,9 +642,9 @@ class _PlexVideoControlsState extends State ignoring: !_showControls, child: FocusScope( // Prevent focus from entering controls when hidden - canRequestFocus: _showControls || _forceShowControls, + canRequestFocus: _showControls, child: AnimatedOpacity( - opacity: (_showControls || _forceShowControls) ? 1.0 : 0.0, + opacity: _showControls ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: Builder( builder: (context) { @@ -658,7 +682,9 @@ class _PlexVideoControlsState extends State ? Listener( behavior: HitTestBehavior.translucent, onPointerDown: (_) { - if (!_isContentStripVisible) _restartHideTimerIfPlaying(); + if (!widget.chromeController.contentStripVisible) { + _restartHideTimerIfPlaying(); + } }, child: Builder( builder: (context) { @@ -681,8 +707,8 @@ class _PlexVideoControlsState extends State onSeekCompleted: widget.onSeekCompleted, // ignore: no-empty-block - play/pause handled by parent VideoControlsState onPlayPause: () {}, - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, + onCancelAutoHide: widget.chromeController.cancelAutoHide, + onStartAutoHide: widget.chromeController.startAutoHide, onBack: widget.onBack, onNext: widget.onNext, onPrevious: widget.onPrevious, @@ -700,13 +726,12 @@ class _PlexVideoControlsState extends State onQueueItemSelected: playbackState.isQueueActive ? _onQueueItemSelected : null, - controlsVisible: widget.controlsVisible, + chromeController: widget.chromeController, onStripVisibilityChanged: (visible) { - setState(() => _isContentStripVisible = visible); if (visible) { - _hideTimer?.cancel(); + widget.chromeController.setContentStripVisible(true); } else { - _restartHideTimerIfPlaying(); + widget.chromeController.setContentStripVisible(false); } }, ); @@ -759,16 +784,20 @@ class _PlexVideoControlsState extends State ), ), // Skip intro/credits button (auto-dismisses after 7s, then only shows with controls) - if (_currentMarker != null && - widget.playNextFocusNode == null && - (!_skipButtonDismissed || _showControls)) + if (shouldShowSkipMarkerButton( + hasFirstFrame: _hasRenderedFirstFrame, + hasMarker: _currentMarker != null, + hasPlayNextPrompt: widget.playNextFocusNode != null, + skipButtonDismissed: _skipButtonDismissed, + controlsVisible: _showControls, + )) AnimatedPositioned( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, right: 24, bottom: () { if (!_showControls) return 24.0; - if (_isContentStripVisible) return 180.0; + if (widget.chromeController.contentStripVisible) return 180.0; return isMobile ? 80.0 : 115.0; }(), child: AnimatedOpacity( @@ -784,7 +813,7 @@ class _PlexVideoControlsState extends State top: _showControls ? (isMobile ? 100.0 : 60.0) : 16.0, left: 16, child: AnimatedOpacity( - opacity: (!_autoHidePerformanceOverlay || _showControls || _forceShowControls) ? 1.0 : 0.0, + opacity: (!_autoHidePerformanceOverlay || _showControls) ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: IgnorePointer(child: PlayerPerformanceOverlay(player: widget.player)), ), diff --git a/test/screens/video_player/player_prompt_overlays_test.dart b/test/screens/video_player/player_prompt_overlays_test.dart new file mode 100644 index 00000000..5345979b --- /dev/null +++ b/test/screens/video_player/player_prompt_overlays_test.dart @@ -0,0 +1,174 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/providers/playback_state_provider.dart'; +import 'package:plezy/screens/video_player/widgets/player_prompt_overlays.dart'; +import 'package:plezy/services/pip_service.dart'; +import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('play next prompt tracks chrome visibility for vertical position', (tester) async { + PipService().isPipActive.value = false; + final chromeController = PlayerChromeController(); + final cancelFocusNode = FocusNode(debugLabel: 'TestCancel'); + final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm'); + addTearDown(chromeController.dispose); + addTearDown(cancelFocusNode.dispose); + addTearDown(confirmFocusNode.dispose); + + await tester.pumpWidget( + _wrapPrompt( + VideoPlayerPlayNextOverlay( + visible: true, + nextEpisode: _episode(), + autoPlayCountdown: -1, + cancelFocusNode: cancelFocusNode, + confirmFocusNode: confirmFocusNode, + chromeController: chromeController, + onCancel: () {}, + onPlayNext: () {}, + ), + ), + ); + + expect(_promptPosition(tester).bottom, 100); + + chromeController.hide(); + await tester.pump(); + expect(_promptPosition(tester).bottom, 24); + }); + + testWidgets('hovering play next prompt holds chrome visible and stable', (tester) async { + PipService().isPipActive.value = false; + final chromeController = PlayerChromeController(); + final cancelFocusNode = FocusNode(debugLabel: 'TestCancel'); + final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm'); + addTearDown(chromeController.dispose); + addTearDown(cancelFocusNode.dispose); + addTearDown(confirmFocusNode.dispose); + + chromeController.hide(); + + await tester.pumpWidget( + _wrapPrompt( + VideoPlayerPlayNextOverlay( + visible: true, + nextEpisode: _episode(), + autoPlayCountdown: -1, + cancelFocusNode: cancelFocusNode, + confirmFocusNode: confirmFocusNode, + chromeController: chromeController, + onCancel: () {}, + onPlayNext: () {}, + ), + ), + ); + + expect(_promptPosition(tester).bottom, 24); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: tester.getCenter(find.text('Cancel'))); + await tester.pump(); + + expect(chromeController.controlsVisible, isTrue); + expect(chromeController.isHeld(PlayerChromeHold.promptInteraction), isTrue); + expect(_promptPosition(tester).bottom, 100); + expect(chromeController.hide(), isFalse); + expect(_promptPosition(tester).bottom, 100); + }); + + testWidgets('focused play next prompt holds chrome visible', (tester) async { + PipService().isPipActive.value = false; + final chromeController = PlayerChromeController(); + final cancelFocusNode = FocusNode(debugLabel: 'TestCancel'); + final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm'); + addTearDown(chromeController.dispose); + addTearDown(cancelFocusNode.dispose); + addTearDown(confirmFocusNode.dispose); + + await tester.pumpWidget( + _wrapPrompt( + VideoPlayerPlayNextOverlay( + visible: true, + nextEpisode: _episode(), + autoPlayCountdown: -1, + cancelFocusNode: cancelFocusNode, + confirmFocusNode: confirmFocusNode, + chromeController: chromeController, + onCancel: () {}, + onPlayNext: () {}, + ), + ), + ); + + confirmFocusNode.requestFocus(); + await tester.pump(); + + expect(chromeController.isHeld(PlayerChromeHold.promptInteraction), isTrue); + expect(chromeController.hide(), isFalse); + }); + + testWidgets('removing a held prompt releases hold without notifying during dispose', (tester) async { + PipService().isPipActive.value = false; + final chromeController = PlayerChromeController(); + final cancelFocusNode = FocusNode(debugLabel: 'TestCancel'); + final confirmFocusNode = FocusNode(debugLabel: 'TestConfirm'); + addTearDown(chromeController.dispose); + addTearDown(cancelFocusNode.dispose); + addTearDown(confirmFocusNode.dispose); + + await tester.pumpWidget( + _wrapPrompt( + VideoPlayerPlayNextOverlay( + visible: true, + nextEpisode: _episode(), + autoPlayCountdown: -1, + cancelFocusNode: cancelFocusNode, + confirmFocusNode: confirmFocusNode, + chromeController: chromeController, + onCancel: () {}, + onPlayNext: () {}, + ), + ), + ); + + chromeController.hold(PlayerChromeHold.promptInteraction); + var notifications = 0; + chromeController.addListener(() => notifications++); + + await tester.pumpWidget(_wrapPrompt(const SizedBox.shrink())); + + expect(chromeController.isHeld(PlayerChromeHold.promptInteraction), isFalse); + expect(notifications, 0); + }); +} + +Widget _wrapPrompt(Widget child) { + return ChangeNotifierProvider( + create: (_) => PlaybackStateProvider(), + child: MaterialApp( + home: Scaffold(body: Stack(children: [child])), + ), + ); +} + +AnimatedPositioned _promptPosition(WidgetTester tester) { + return tester.widget(find.byType(AnimatedPositioned)); +} + +MediaItem _episode() { + return MediaItem( + id: 'episode-2', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 2', + parentIndex: 1, + index: 2, + ); +} diff --git a/test/widgets/player_chrome_controller_test.dart b/test/widgets/player_chrome_controller_test.dart new file mode 100644 index 00000000..60a76dfa --- /dev/null +++ b/test/widgets/player_chrome_controller_test.dart @@ -0,0 +1,135 @@ +import 'dart:ui' show PointerDeviceKind; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/video_controls/player_chrome_controller.dart'; + +void main() { + group('PlayerChromeController', () { + testWidgets('auto-hides visible controls while playing', (tester) async { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + + controller.configure(hideDelay: const Duration(milliseconds: 100)); + controller.setPlaying(true); + + expect(controller.controlsVisible, isTrue); + await tester.pump(const Duration(milliseconds: 99)); + expect(controller.controlsVisible, isTrue); + await tester.pump(const Duration(milliseconds: 1)); + expect(controller.controlsVisible, isFalse); + }); + + testWidgets('visible holds suppress auto-hide until released', (tester) async { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + + controller.configure(hideDelay: const Duration(milliseconds: 100)); + controller.setPlaying(true); + controller.hold(PlayerChromeHold.promptInteraction); + + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.controlsVisible, isTrue); + + controller.release(PlayerChromeHold.promptInteraction); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.controlsVisible, isFalse); + }); + + testWidgets('releasing a hold while paused restarts paused auto-hide', (tester) async { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + + controller.configure(hideDelay: const Duration(milliseconds: 100)); + controller.setPlaying(true); + controller.setPlaying(false); + controller.hold(PlayerChromeHold.promptInteraction); + + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.controlsVisible, isTrue); + + controller.release(PlayerChromeHold.promptInteraction); + await tester.pump(const Duration(milliseconds: 99)); + expect(controller.controlsVisible, isTrue); + await tester.pump(const Duration(milliseconds: 1)); + expect(controller.controlsVisible, isFalse); + }); + + testWidgets('changing hide delay restarts paused auto-hide timer', (tester) async { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + + controller.configure(hideDelay: const Duration(milliseconds: 200)); + controller.setPlaying(true); + controller.setPlaying(false); + + await tester.pump(const Duration(milliseconds: 100)); + controller.configure(hideDelay: const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 299)); + expect(controller.controlsVisible, isTrue); + await tester.pump(const Duration(milliseconds: 1)); + expect(controller.controlsVisible, isFalse); + }); + + test('show stores focus target and notifies even when already visible', () { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + var notifications = 0; + controller.addListener(() => notifications++); + + controller.show(focusTarget: PlayerChromeFocusTarget.playPause); + + expect(notifications, 1); + expect(controller.pendingFocusTarget, PlayerChromeFocusTarget.playPause); + expect(controller.takeFocusTarget(), PlayerChromeFocusTarget.playPause); + expect(controller.takeFocusTarget(), isNull); + }); + + test('silent release removes hold without notifying listeners', () { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + controller.hold(PlayerChromeHold.promptInteraction); + var notifications = 0; + controller.addListener(() => notifications++); + + controller.release(PlayerChromeHold.promptInteraction, notify: false, restartAutoHide: false); + + expect(controller.isHeld(PlayerChromeHold.promptInteraction), isFalse); + expect(notifications, 0); + }); + + testWidgets('interaction region shows on hover and hides on exit', (tester) async { + final controller = PlayerChromeController(); + addTearDown(controller.dispose); + controller.hide(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + height: 200, + child: PlayerChromeInteractionRegion( + controller: controller, + hideOnExit: true, + child: const ColoredBox(color: Colors.black), + ), + ), + ), + ), + ); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: const Offset(250, 250)); + await tester.pump(); + await mouse.moveTo(const Offset(20, 20)); + await tester.pump(); + expect(controller.controlsVisible, isTrue); + + await mouse.moveTo(const Offset(250, 250)); + await tester.pump(); + expect(controller.controlsVisible, isFalse); + }); + }); +} diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 65cf45f2..5256a998 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -118,6 +118,70 @@ void main() { }); }); + group('shouldShowSkipMarkerButton', () { + test('does not show before the first frame is rendered', () { + expect( + shouldShowSkipMarkerButton( + hasFirstFrame: false, + hasMarker: true, + hasPlayNextPrompt: false, + skipButtonDismissed: false, + controlsVisible: true, + ), + isFalse, + ); + }); + + test('shows after first frame when marker is active and not dismissed', () { + expect( + shouldShowSkipMarkerButton( + hasFirstFrame: true, + hasMarker: true, + hasPlayNextPrompt: false, + skipButtonDismissed: false, + controlsVisible: false, + ), + isTrue, + ); + }); + + test('does not show when dismissed until controls are visible again', () { + expect( + shouldShowSkipMarkerButton( + hasFirstFrame: true, + hasMarker: true, + hasPlayNextPrompt: false, + skipButtonDismissed: true, + controlsVisible: false, + ), + isFalse, + ); + expect( + shouldShowSkipMarkerButton( + hasFirstFrame: true, + hasMarker: true, + hasPlayNextPrompt: false, + skipButtonDismissed: true, + controlsVisible: true, + ), + isTrue, + ); + }); + + test('does not show while play next prompt is active', () { + expect( + shouldShowSkipMarkerButton( + hasFirstFrame: true, + hasMarker: true, + hasPlayNextPrompt: true, + skipButtonDismissed: false, + controlsVisible: true, + ), + isFalse, + ); + }); + }); + group('mobileSkipZoneForTap', () { const size = Size(1000, 600);