refactor: simplify rate limits

This commit is contained in:
edde746
2025-11-25 05:04:35 +01:00
parent 10d4b3088f
commit 44dcdccb2c
9 changed files with 105 additions and 120 deletions
+9 -11
View File
@@ -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<SearchScreen> with Refreshable {
List<PlexMetadata> _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<SearchScreen> with Refreshable {
return;
}
// Start new timer
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_performSearch(query);
});
_searchDebounce([query]);
}
Future<void> _performSearch(String query) async {
+50 -40
View File
@@ -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<dynamic> 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<void> _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<void> 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,
});
}
+16 -13
View File
@@ -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();
}
}
+5 -2
View File
@@ -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,
);
@@ -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() {
@@ -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) {
+13 -32
View File
@@ -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<PlexVideoControls>
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<PlexMarker> _markers = [];
@@ -119,6 +119,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
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<PlexVideoControls>
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<PlexVideoControls>
}
}
/// 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
+8
View File
@@ -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:
+1
View File
@@ -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: