From 3f40007f03b2935bd6e9e4e8fd98551a7254b446 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 7 Nov 2025 02:16:07 +0100 Subject: [PATCH] feat: throttle seeking commands --- .../video_controls/video_controls.dart | 212 +++++++++++------- 1 file changed, 130 insertions(+), 82 deletions(-) diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 122cfdf8..b462c664 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -92,6 +92,9 @@ class _PlexVideoControlsState extends State 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 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 // 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 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 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 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 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 final currentPosition = widget.player.state.position; // Get state reference before async operations - final videoPlayerState = context.findAncestorStateOfType(); + final videoPlayerState = context + .findAncestorStateOfType(); // Save the preference final settingsService = await SettingsService.getInstance();