refactor: simplify rate limits
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user