diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 9cc02966..399be520 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,7 +1,6 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:rate_limiter/rate_limiter.dart'; import '../i18n/strings.g.dart'; import '../mixins/refreshable.dart'; @@ -26,30 +25,32 @@ class _SearchScreenState extends State with Refreshable { List _searchResults = []; bool _isSearching = false; bool _hasSearched = false; - Timer? _debounceTimer; + late final Debounce _searchDebounce; String _lastSearchedQuery = ''; @override void initState() { super.initState(); + _searchDebounce = debounce( + _performSearch, + const Duration(milliseconds: 500), + ); _searchController.addListener(_onSearchChanged); } @override void dispose() { - _debounceTimer?.cancel(); + _searchDebounce.cancel(); _searchController.removeListener(_onSearchChanged); _searchController.dispose(); super.dispose(); } void _onSearchChanged() { - // Cancel previous timer - _debounceTimer?.cancel(); - final query = _searchController.text; if (query.trim().isEmpty) { + _searchDebounce.cancel(); setState(() { _searchResults = []; _hasSearched = false; @@ -64,10 +65,7 @@ class _SearchScreenState extends State with Refreshable { return; } - // Start new timer - _debounceTimer = Timer(const Duration(milliseconds: 500), () { - _performSearch(query); - }); + _searchDebounce([query]); } Future _performSearch(String query) async { diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 2532878a..ccc9a5d6 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -1,6 +1,5 @@ -import 'dart:async'; - import 'package:os_media_controls/os_media_controls.dart'; +import 'package:rate_limiter/rate_limiter.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; @@ -17,13 +16,17 @@ class MediaControlsManager { /// Stream of control events from OS media controls Stream get controlEvents => OsMediaControls.controlEvents; - /// Last time position was updated (for throttling) - DateTime? _lastPositionUpdate; + /// Throttled playback state update (1 second interval, leading edge only) + late final Throttle _throttledUpdate; - /// Throttle interval for position updates (default: 1 second) - final Duration throttleInterval; - - MediaControlsManager({this.throttleInterval = const Duration(seconds: 1)}); + MediaControlsManager() { + _throttledUpdate = throttle( + _doUpdatePlaybackState, + const Duration(seconds: 1), + leading: true, + trailing: false, + ); + } /// Update media metadata displayed in OS media controls /// @@ -71,37 +74,31 @@ class MediaControlsManager { required double speed, bool force = false, }) async { - try { - // Throttle position updates unless forced - if (!force) { - final now = DateTime.now(); - if (_lastPositionUpdate != null) { - final timeSinceLastUpdate = now.difference(_lastPositionUpdate!); - if (timeSinceLastUpdate < throttleInterval) { - return; // Skip this update - } - } - _lastPositionUpdate = now; - } + final params = _PlaybackStateParams( + isPlaying: isPlaying, + position: position, + speed: speed, + ); - // Only update if playing (avoid excessive updates when paused) - if (isPlaying) { - await OsMediaControls.setPlaybackState( - MediaPlaybackState( - state: PlaybackState.playing, - position: position, - speed: speed, - ), - ); - } else { - await OsMediaControls.setPlaybackState( - MediaPlaybackState( - state: PlaybackState.paused, - position: position, - speed: speed, - ), - ); - } + if (force) { + // Bypass throttling for forced updates + await _doUpdatePlaybackState(params); + } else { + // Use throttled update + _throttledUpdate([params]); + } + } + + /// Internal method to actually perform the playback state update + Future _doUpdatePlaybackState(_PlaybackStateParams params) async { + try { + await OsMediaControls.setPlaybackState( + MediaPlaybackState( + state: params.isPlaying ? PlaybackState.playing : PlaybackState.paused, + position: params.position, + speed: params.speed, + ), + ); } catch (e) { appLogger.w('Failed to update media controls playback state', error: e); } @@ -146,7 +143,7 @@ class MediaControlsManager { Future clear() async { try { await OsMediaControls.clear(); - _lastPositionUpdate = null; + _throttledUpdate.cancel(); appLogger.d('Media controls cleared'); } catch (e) { appLogger.w('Failed to clear media controls', error: e); @@ -155,7 +152,7 @@ class MediaControlsManager { /// Dispose resources void dispose() { - _lastPositionUpdate = null; + _throttledUpdate.cancel(); } /// Build artist string from metadata @@ -191,3 +188,16 @@ class MediaControlsManager { return ''; } } + +/// Parameters for playback state update (used with throttle) +class _PlaybackStateParams { + final bool isPlaying; + final Duration position; + final double speed; + + const _PlaybackStateParams({ + required this.isPlaying, + required this.position, + required this.speed, + }); +} diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 0233e704..d9b147d7 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -1,7 +1,6 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; +import 'package:rate_limiter/rate_limiter.dart'; import '../models/plex_media_version.dart'; import '../utils/app_logger.dart'; @@ -30,14 +29,21 @@ class VideoFilterManager { /// Current video dimensions Size? _videoSize; - /// Debounce timer for resize events - Timer? _resizeDebounceTimer; + /// Debounced video filter update with leading edge execution + late final Debounce _debouncedUpdateVideoFilter; VideoFilterManager({ required this.player, required this.availableVersions, required this.selectedMediaIndex, - }); + }) { + _debouncedUpdateVideoFilter = debounce( + updateVideoFilter, + const Duration(milliseconds: 50), + leading: true, + trailing: true, + ); + } /// Current BoxFit mode (0=contain, 1=cover, 2=fill) int get boxFitMode => _boxFitMode; @@ -251,16 +257,13 @@ class VideoFilterManager { } } - /// Debounced version of updateVideoFilter for resize events - void debouncedUpdateVideoFilter() { - _resizeDebounceTimer?.cancel(); - _resizeDebounceTimer = Timer(const Duration(milliseconds: 50), () { - updateVideoFilter(); - }); - } + /// Debounced version of updateVideoFilter for resize events. + /// Uses leading-edge debounce: first call executes immediately, + /// subsequent calls within 50ms are debounced. + void debouncedUpdateVideoFilter() => _debouncedUpdateVideoFilter(); /// Clean up resources void dispose() { - _resizeDebounceTimer?.cancel(); + _debouncedUpdateVideoFilter.cancel(); } } diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index 39b2f667..d6aa7eee 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -107,13 +107,16 @@ class DesktopAppBarHelper { } /// Wraps a widget with GestureDetector on macOS to prevent window dragging - static Widget wrapWithGestureDetector(Widget child) { + /// + /// [opaque] - If true, uses HitTestBehavior.opaque to fully consume gestures. + /// If false (default), uses HitTestBehavior.translucent. + static Widget wrapWithGestureDetector(Widget child, {bool opaque = false}) { if (!Platform.isMacOS) { return child; } return GestureDetector( - behavior: HitTestBehavior.translucent, + behavior: opaque ? HitTestBehavior.opaque : HitTestBehavior.translucent, onPanDown: (_) {}, // Consume pan gestures to prevent window dragging child: child, ); diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index ea06a9ae..7e2fcf93 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -101,16 +101,7 @@ class DesktopVideoControls extends StatelessWidget { ), ); - // On macOS, wrap with GestureDetector to prevent window dragging - if (Platform.isMacOS) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) {}, // Consume pan gestures to prevent window dragging - child: topBar, - ); - } - - return topBar; + return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true); } Widget _buildMacOSSingleLineTitle() { diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index de12f53a..598f46b2 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,10 +1,9 @@ -import 'dart:io' show Platform; - import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../models/plex_media_info.dart'; import '../../models/plex_metadata.dart'; +import '../../utils/desktop_window_padding.dart'; import '../../utils/duration_formatter.dart'; import '../../utils/player_utils.dart'; import '../../utils/video_control_icons.dart'; @@ -110,16 +109,7 @@ class MobileVideoControls extends StatelessWidget { ), ); - // On macOS, wrap with GestureDetector to prevent window dragging - if (Platform.isMacOS) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) {}, // Consume pan gestures to prevent window dragging - child: topBar, - ); - } - - return topBar; + return DesktopAppBarHelper.wrapWithGestureDetector(topBar, opaque: true); } Widget _buildPlaybackControls(BuildContext context) { diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index fe43ce74..59de7720 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -2,6 +2,7 @@ import 'dart:async' show StreamSubscription, Timer; import 'dart:io' show Platform; import 'package:flutter/material.dart'; +import 'package:rate_limiter/rate_limiter.dart'; import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; import 'package:macos_window_utils/macos_window_utils.dart'; import 'package:media_kit/media_kit.dart'; @@ -105,9 +106,8 @@ class _PlexVideoControlsState extends State double _doubleTapFeedbackOpacity = 0.0; bool _lastDoubleTapWasForward = true; Timer? _feedbackTimer; - // Seek throttle state - Timer? _seekThrottleTimer; - Duration? _pendingSeekPosition; + // Seek throttle + late final Throttle _seekThrottle; // Current marker state PlexMarker? _currentMarker; List _markers = []; @@ -119,6 +119,12 @@ class _PlexVideoControlsState extends State void initState() { super.initState(); _focusNode = FocusNode(); + _seekThrottle = throttle( + (Duration pos) => widget.player.seek(pos), + const Duration(milliseconds: 200), + leading: true, + trailing: true, + ); _loadChapters(); _loadMarkers(); _loadSeekTimes(); @@ -234,7 +240,7 @@ class _PlexVideoControlsState extends State void dispose() { _hideTimer?.cancel(); _feedbackTimer?.cancel(); - _seekThrottleTimer?.cancel(); + _seekThrottle.cancel(); _playingSubscription?.cancel(); _focusNode.dispose(); // Remove lifecycle observer @@ -460,38 +466,13 @@ class _PlexVideoControlsState extends State } } - /// 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; - }); - } + /// Throttled seek for timeline slider - executes immediately then throttles to 200ms + void _throttledSeek(Duration position) => _seekThrottle([position]); /// 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 + _seekThrottle.cancel(); widget.player.seek(position); - _pendingSeekPosition = null; } /// Handle double-tap skip forward or backward diff --git a/pubspec.lock b/pubspec.lock index d0eaa2cf..79f07bc2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -897,6 +897,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + rate_limiter: + dependency: "direct main" + description: + name: rate_limiter + sha256: "2bae2e961adedf7fc2e8b0305d30e3a3619baf001d050c6907870c5c6235b559" + url: "https://pub.dev" + source: hosted + version: "1.0.0" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 18ff1cc3..70633f75 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: git: url: https://github.com/edde746/os-media-controls ref: 75dc5642cd148c54c03fe96976702b8d31c48599 + rate_limiter: ^1.0.0 dependency_overrides: media_kit: