feat: throttle seeking commands
This commit is contained in:
@@ -92,6 +92,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
double _doubleTapFeedbackOpacity = 0.0;
|
||||
bool _lastDoubleTapWasForward = true;
|
||||
Timer? _feedbackTimer;
|
||||
// Seek throttle state
|
||||
Timer? _seekThrottleTimer;
|
||||
Duration? _pendingSeekPosition;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -168,6 +171,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_feedbackTimer?.cancel();
|
||||
_seekThrottleTimer?.cancel();
|
||||
_focusNode.dispose();
|
||||
// Remove lifecycle observer
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
@@ -358,11 +362,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
|
||||
// Only apply SafeArea in portrait mode
|
||||
if (isPortrait) {
|
||||
return SafeArea(
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
child: child,
|
||||
);
|
||||
return SafeArea(top: top, bottom: bottom, child: child);
|
||||
}
|
||||
|
||||
// In landscape, return child without SafeArea
|
||||
@@ -380,83 +380,90 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Unified settings button (speed, sleep timer, audio sync, subtitle sync)
|
||||
ListenableBuilder(
|
||||
listenable: SleepTimerService(),
|
||||
builder: (context, _) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
final isActive =
|
||||
sleepTimer.isActive ||
|
||||
_audioSyncOffset != 0 ||
|
||||
_subtitleSyncOffset != 0;
|
||||
return VideoControlButton(
|
||||
icon: Icons.tune,
|
||||
isActive: isActive,
|
||||
onPressed: () async {
|
||||
await VideoSettingsSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_audioSyncOffset,
|
||||
_subtitleSyncOffset,
|
||||
);
|
||||
// Sheet is now closed, reload immediately
|
||||
if (mounted) {
|
||||
await _loadSeekTimes();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_hasMultipleAudioTracks(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.audiotrack,
|
||||
onPressed: () => AudioTrackSheet.show(context, widget.player),
|
||||
// Unified settings button (speed, sleep timer, audio sync, subtitle sync)
|
||||
ListenableBuilder(
|
||||
listenable: SleepTimerService(),
|
||||
builder: (context, _) {
|
||||
final sleepTimer = SleepTimerService();
|
||||
final isActive =
|
||||
sleepTimer.isActive ||
|
||||
_audioSyncOffset != 0 ||
|
||||
_subtitleSyncOffset != 0;
|
||||
return VideoControlButton(
|
||||
icon: Icons.tune,
|
||||
isActive: isActive,
|
||||
onPressed: () async {
|
||||
await VideoSettingsSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_audioSyncOffset,
|
||||
_subtitleSyncOffset,
|
||||
);
|
||||
// Sheet is now closed, reload immediately
|
||||
if (mounted) {
|
||||
await _loadSeekTimes();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_hasSubtitles(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.subtitles,
|
||||
onPressed: () => SubtitleTrackSheet.show(context, widget.player),
|
||||
),
|
||||
if (_chapters.isNotEmpty)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_library,
|
||||
onPressed: () => ChapterSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_chapters,
|
||||
_chaptersLoaded,
|
||||
if (_hasMultipleAudioTracks(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.audiotrack,
|
||||
onPressed: () => AudioTrackSheet.show(context, widget.player),
|
||||
),
|
||||
),
|
||||
if (widget.availableVersions.length > 1)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_file,
|
||||
onPressed: () => VersionSheet.show(
|
||||
context,
|
||||
widget.availableVersions,
|
||||
widget.selectedMediaIndex,
|
||||
_switchMediaVersion,
|
||||
if (_hasSubtitles(tracks))
|
||||
VideoControlButton(
|
||||
icon: Icons.subtitles,
|
||||
onPressed: () =>
|
||||
SubtitleTrackSheet.show(context, widget.player),
|
||||
),
|
||||
if (_chapters.isNotEmpty)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_library,
|
||||
onPressed: () => ChapterSheet.show(
|
||||
context,
|
||||
widget.player,
|
||||
_chapters,
|
||||
_chaptersLoaded,
|
||||
),
|
||||
),
|
||||
if (widget.availableVersions.length > 1)
|
||||
VideoControlButton(
|
||||
icon: Icons.video_file,
|
||||
onPressed: () => VersionSheet.show(
|
||||
context,
|
||||
widget.availableVersions,
|
||||
widget.selectedMediaIndex,
|
||||
_switchMediaVersion,
|
||||
),
|
||||
),
|
||||
// BoxFit mode cycle button
|
||||
if (widget.onCycleBoxFitMode != null)
|
||||
VideoControlButton(
|
||||
icon: _getBoxFitIcon(widget.boxFitMode),
|
||||
tooltip: _getBoxFitTooltip(widget.boxFitMode),
|
||||
onPressed: widget.onCycleBoxFitMode,
|
||||
),
|
||||
// Rotation lock toggle (mobile only)
|
||||
if (PlatformDetector.isMobile(context))
|
||||
VideoControlButton(
|
||||
icon: _isRotationLocked
|
||||
? Icons.screen_lock_rotation
|
||||
: Icons.screen_rotation,
|
||||
tooltip: _isRotationLocked
|
||||
? 'Unlock rotation'
|
||||
: 'Lock rotation',
|
||||
onPressed: _toggleRotationLock,
|
||||
),
|
||||
// Fullscreen toggle (desktop only)
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS)
|
||||
VideoControlButton(
|
||||
icon: _isFullscreen
|
||||
? Icons.fullscreen_exit
|
||||
: Icons.fullscreen,
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
),
|
||||
// BoxFit mode cycle button
|
||||
if (widget.onCycleBoxFitMode != null)
|
||||
VideoControlButton(
|
||||
icon: _getBoxFitIcon(widget.boxFitMode),
|
||||
tooltip: _getBoxFitTooltip(widget.boxFitMode),
|
||||
onPressed: widget.onCycleBoxFitMode,
|
||||
),
|
||||
// Rotation lock toggle (mobile only)
|
||||
if (PlatformDetector.isMobile(context))
|
||||
VideoControlButton(
|
||||
icon: _isRotationLocked ? Icons.screen_lock_rotation : Icons.screen_rotation,
|
||||
tooltip: _isRotationLocked ? 'Unlock rotation' : 'Lock rotation',
|
||||
onPressed: _toggleRotationLock,
|
||||
),
|
||||
// Fullscreen toggle (desktop only)
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS)
|
||||
VideoControlButton(
|
||||
icon: _isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
onPressed: _toggleFullscreen,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -521,6 +528,40 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
widget.player.seek(clampedPosition);
|
||||
}
|
||||
|
||||
/// Throttled seek for timeline slider - only sends seek events at most every 100ms
|
||||
void _throttledSeek(Duration position) {
|
||||
// Store the pending position
|
||||
_pendingSeekPosition = position;
|
||||
|
||||
// If timer is already active, just update the pending position
|
||||
if (_seekThrottleTimer?.isActive ?? false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute the seek immediately for the first call
|
||||
widget.player.seek(position);
|
||||
|
||||
// Start a timer to throttle subsequent seeks
|
||||
_seekThrottleTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
// If there's a pending position that's different, execute it
|
||||
if (_pendingSeekPosition != null && _pendingSeekPosition != position) {
|
||||
widget.player.seek(_pendingSeekPosition!);
|
||||
}
|
||||
_pendingSeekPosition = null;
|
||||
});
|
||||
}
|
||||
|
||||
/// Finalizes the seek when user stops scrubbing the timeline
|
||||
void _finalizeSeek(Duration position) {
|
||||
// Cancel any pending throttled seek
|
||||
_seekThrottleTimer?.cancel();
|
||||
_seekThrottleTimer = null;
|
||||
|
||||
// Execute the final position immediately to ensure accuracy
|
||||
widget.player.seek(position);
|
||||
_pendingSeekPosition = null;
|
||||
}
|
||||
|
||||
/// Get the replay icon based on the duration
|
||||
/// Returns numbered icons (replay_5, replay_10, replay_30) when available,
|
||||
/// otherwise returns generic replay icon
|
||||
@@ -878,7 +919,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
widget.metadata.index != null)
|
||||
Text(
|
||||
'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -1310,7 +1354,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
min: 0.0,
|
||||
max: duration.inMilliseconds.toDouble(),
|
||||
onChanged: (value) {
|
||||
widget.player.seek(Duration(milliseconds: value.toInt()));
|
||||
_throttledSeek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
_finalizeSeek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withValues(alpha: 0.3),
|
||||
@@ -1407,7 +1454,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
final currentPosition = widget.player.state.position;
|
||||
|
||||
// Get state reference before async operations
|
||||
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||
final videoPlayerState = context
|
||||
.findAncestorStateOfType<VideoPlayerScreenState>();
|
||||
|
||||
// Save the preference
|
||||
final settingsService = await SettingsService.getInstance();
|
||||
|
||||
Reference in New Issue
Block a user