fix(video): stabilize player chrome overlays
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user