feat: throttle seeking commands

This commit is contained in:
edde746
2025-11-07 02:16:07 +01:00
parent 47f5454f61
commit 3f40007f03
+130 -82
View File
@@ -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();