fix(video): stabilize player chrome overlays

This commit is contained in:
edde746
2026-06-02 14:20:24 +02:00
parent 985c279b1f
commit 24b5080c38
18 changed files with 1233 additions and 521 deletions
+133 -128
View File
@@ -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<WatchTogetherProvider, bool>(
(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<WatchTogetherProvider, bool>(
(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 <MediaAudioTrack>[];
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
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),
],
),
),
),
),
@@ -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;
@@ -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();
}
}
+1 -1
View File
@@ -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) {
@@ -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<bool> 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<bool>(
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<bool> 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<bool>(
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<bool>(
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<FocusNode> 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<FocusNode> nodes) {
for (final node in nodes) {
node.addListener(_syncFocusState);
}
}
void _removeFocusListeners(List<FocusNode> 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,
);
}
}
+4 -7
View File
@@ -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<VideoPlayerScreen> with WidgetsBindin
final ValueNotifier<bool> _isBuffering = ValueNotifier<bool>(false);
final ValueNotifier<bool> _hasFirstFrame = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isExiting = ValueNotifier<bool>(false);
final ValueNotifier<bool> _controlsVisible = ValueNotifier<bool>(true);
final PlayerChromeController _chromeController = PlayerChromeController();
@override
void initState() {
@@ -1112,7 +1113,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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;
}
@@ -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<bool>? controlsVisible;
/// Shared controller for chrome visibility (used to reset strip on hide)
final PlayerChromeController? chromeController;
/// Called when the content strip visibility changes
final ValueChanged<bool>? 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<MobileVideoControls> 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<MobileVideoControls> 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;
}
@@ -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;
}
+60 -24
View File
@@ -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<void> _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));
}
@@ -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);
},
),
);
@@ -33,5 +33,6 @@ extension _PlexVideoControlsPlaybackExtrasMethods on _PlexVideoControlsState {
_chaptersLoaded = true;
_markersLoaded = true;
});
_syncCurrentMarkerForCurrentPosition();
}
}
@@ -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` /
+73 -158
View File
@@ -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();
}
});
}
}
@@ -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<bool> {
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<PlayerChromeHold> _holds = <PlayerChromeHold>{};
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,
);
},
);
}
}
+59 -30
View File
@@ -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<bool>? 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<PlexVideoControls>
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<MediaChapter> _chapters = [];
bool _chaptersLoaded = false;
Timer? _hideTimer;
bool _isFullscreen = false;
bool _isAlwaysOnTop = false;
late final FocusNode _focusNode;
@@ -329,7 +342,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
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<PlexVideoControls>
// Skip marker button focus node (for TV D-pad navigation)
late final FocusNode _skipMarkerFocusNode;
final ValueNotifier<bool> _fallbackHasFirstFrame = ValueNotifier<bool>(true);
final Stopwatch _pointerActivityStopwatch = Stopwatch()..start();
int _lastPointerActivityMs = -1000;
double? _rateBeforeLongPress;
bool _showSpeedIndicator = false;
StreamSubscription<double>? _rateSubscription;
@@ -398,6 +408,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
@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<PlexVideoControls>
// so init wiring (orientation, focus) lives in one place.
bindEffect<bool>(SettingsService.rotationLocked, _applyRotationLock);
bindEffect<bool>(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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
? 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<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
),
),
// 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<PlexVideoControls>
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)),
),
@@ -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<AnimatedPositioned>(find.byType(AnimatedPositioned));
}
MediaItem _episode() {
return MediaItem(
id: 'episode-2',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 2',
parentIndex: 1,
index: 2,
);
}
@@ -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);
});
});
}
+64
View File
@@ -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);