diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart new file mode 100644 index 00000000..2ec4336f --- /dev/null +++ b/lib/database/download_operations.dart @@ -0,0 +1,167 @@ +import 'package:drift/drift.dart'; + +import 'app_database.dart'; +import '../models/download_models.dart'; + +/// Extension methods on AppDatabase for download operations +extension DownloadDatabaseOperations on AppDatabase { + /// Insert a new download into the database + Future insertDownload({ + required String serverId, + required String ratingKey, + required String globalKey, + required String type, + String? parentRatingKey, + String? grandparentRatingKey, + required int status, + }) async { + await into(downloadedMedia).insert( + DownloadedMediaCompanion.insert( + serverId: serverId, + ratingKey: ratingKey, + globalKey: globalKey, + type: type, + parentRatingKey: Value(parentRatingKey), + grandparentRatingKey: Value(grandparentRatingKey), + status: status, + ), + mode: InsertMode.insertOrReplace, + ); + } + + /// Add item to download queue + Future addToQueue({ + required String mediaGlobalKey, + int priority = 0, + bool downloadSubtitles = true, + bool downloadArtwork = true, + }) async { + await into(downloadQueue).insert( + DownloadQueueCompanion.insert( + mediaGlobalKey: mediaGlobalKey, + priority: Value(priority), + addedAt: DateTime.now().millisecondsSinceEpoch, + downloadSubtitles: Value(downloadSubtitles), + downloadArtwork: Value(downloadArtwork), + ), + mode: InsertMode.insertOrReplace, + ); + } + + /// Get next item from queue (highest priority, oldest first) + /// Only returns items that are not paused + Future getNextQueueItem() async { + // Join with downloadedMedia to check status and filter out paused items + final query = select( + downloadQueue, + ).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]); + + query + ..where(downloadedMedia.status.equals(DownloadStatus.queued.index)) + ..orderBy([ + OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc), + OrderingTerm(expression: downloadQueue.addedAt), + ]) + ..limit(1); + + final result = await query.getSingleOrNull(); + return result?.readTable(downloadQueue); + } + + /// Update download status + Future updateDownloadStatus(String globalKey, int status) async { + await (update( + downloadedMedia, + )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status))); + } + + /// Update download progress + Future updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async { + await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( + DownloadedMediaCompanion( + progress: Value(progress), + downloadedBytes: Value(downloadedBytes), + totalBytes: Value(totalBytes), + ), + ); + } + + /// Update video file path + Future updateVideoFilePath(String globalKey, String filePath) async { + await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( + DownloadedMediaCompanion( + videoFilePath: Value(filePath), + downloadedAt: Value(DateTime.now().millisecondsSinceEpoch), + ), + ); + } + + /// Update artwork paths + Future updateArtworkPaths({required String globalKey, String? thumbPath}) async { + await (update( + downloadedMedia, + )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath))); + } + + /// Update download error and increment retry count + Future updateDownloadError(String globalKey, String errorMessage) async { + // Get current retry count to increment it + final existing = await getDownloadedMedia(globalKey); + final currentCount = existing?.retryCount ?? 0; + + await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( + DownloadedMediaCompanion(errorMessage: Value(errorMessage), retryCount: Value(currentCount + 1)), + ); + } + + /// Clear download error and reset retry count (for retry) + Future clearDownloadError(String globalKey) async { + await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( + const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)), + ); + } + + /// Remove item from queue + Future removeFromQueue(String mediaGlobalKey) async { + await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go(); + } + + /// Get downloaded media item + Future getDownloadedMedia(String globalKey) { + return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull(); + } + + /// Delete a download + Future deleteDownload(String globalKey) async { + await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go(); + await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); + } + + /// Get all downloaded episodes for a season + Future> getEpisodesBySeason(String seasonKey) { + return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get(); + } + + /// Get all downloaded episodes for a show + Future> getEpisodesByShow(String showKey) { + return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get(); + } + + /// Get all downloaded items for a specific server + Future> getDownloadsByServerId(String serverId) { + return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get(); + } + + /// Update the background_downloader task ID for a download + Future updateBgTaskId(String globalKey, String? taskId) async { + await (update( + downloadedMedia, + )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId))); + } + + /// Get the background_downloader task ID for a download + Future getBgTaskId(String globalKey) async { + final item = await getDownloadedMedia(globalKey); + return item?.bgTaskId; + } +} diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index 5f1d633f..3211da34 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -63,36 +63,47 @@ extension DpadKeyExtension on LogicalKeyboardKey { bool get isDownKey => this == LogicalKeyboardKey.arrowDown; } -/// Global helper to suppress the next SELECT key-up event. -class SelectKeyUpSuppressor { - static bool _suppressSelectUntilKeyUp = false; +/// Base class for suppressing key-up events after a key category triggers an +/// action (e.g. opening a sheet). While suppressed, all events for the matched +/// key category are consumed; suppression auto-clears on [KeyUpEvent]. +class _KeyUpSuppressor { + final bool Function(LogicalKeyboardKey) _keyMatcher; - static void suppressSelectUntilKeyUp() { - _suppressSelectUntilKeyUp = true; - } + _KeyUpSuppressor(this._keyMatcher); - static void clearSuppression() { - _suppressSelectUntilKeyUp = false; - } + bool _suppressed = false; - static bool consumeIfSuppressed(KeyEvent event) { - if (!_suppressSelectUntilKeyUp) return false; - if (event.logicalKey.isSelectKey) { - if (event is KeyUpEvent) { - _suppressSelectUntilKeyUp = false; - } + void suppress() => _suppressed = true; + + void clearSuppression() => _suppressed = false; + + /// Returns `true` (consumed) when the event belongs to the matched key + /// category and suppression is active. Clears suppression on [KeyUpEvent]. + bool consumeIfSuppressed(KeyEvent event) { + if (!_suppressed) return false; + if (_keyMatcher(event.logicalKey)) { + if (event is KeyUpEvent) _suppressed = false; return true; } return false; } } +/// Global helper to suppress the next SELECT key-up event. +class SelectKeyUpSuppressor { + static final _instance = _KeyUpSuppressor((k) => k.isSelectKey); + + static void suppressSelectUntilKeyUp() => _instance.suppress(); + static void clearSuppression() => _instance.clearSuppression(); + static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event); +} + /// Global helper to suppress the next BACK key-up event. /// /// Use this when a modal (bottom sheet, dialog) closes to prevent /// the BACK key-up from propagating to the underlying screen. class BackKeyUpSuppressor { - static bool _suppressBackUntilKeyUp = false; + static final _instance = _KeyUpSuppressor((k) => k.isBackKey); static bool _closedViaBackKey = false; /// Mark that a modal is being closed via back key press. @@ -109,26 +120,17 @@ class BackKeyUpSuppressor { _closedViaBackKey = false; return; } - _suppressBackUntilKeyUp = true; + _instance.suppress(); } /// Clear any pending suppression. Call when opening a new modal /// to ensure stale suppression from previous closes doesn't affect it. static void clearSuppression() { - _suppressBackUntilKeyUp = false; + _instance.clearSuppression(); _closedViaBackKey = false; } - static bool consumeIfSuppressed(KeyEvent event) { - if (!_suppressBackUntilKeyUp) return false; - if (event.logicalKey.isBackKey) { - if (event is KeyUpEvent) { - _suppressBackUntilKeyUp = false; - } - return true; - } - return false; - } + static bool consumeIfSuppressed(KeyEvent event) => _instance.consumeIfSuppressed(event); } /// Tracks whether a back key is currently physically pressed. diff --git a/lib/main.dart b/lib/main.dart index 89bb5776..a0474016 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -155,7 +155,7 @@ void main() async { ); } -Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint hint) { +Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) { if (breadcrumb == null) return null; final message = breadcrumb.message; @@ -168,7 +168,7 @@ Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint hint) { ); } -FutureOr _beforeSend(SentryEvent event, Hint hint) { +FutureOr _beforeSend(SentryEvent event, Hint _) { // Drop event if user opted out of crash reporting final instance = SettingsService.instanceOrNull; if (instance != null && !instance.getCrashReporting()) return null; @@ -361,14 +361,12 @@ class _MainAppState extends State with WidgetsBindingObserver { create: (context) => OfflineWatchProvider( syncService: _offlineWatchSyncService, downloadProvider: context.read(), - apiCache: PlexApiCache.instance, ), update: (_, syncService, downloadProvider, previous) { return previous ?? OfflineWatchProvider( syncService: syncService, downloadProvider: downloadProvider, - apiCache: PlexApiCache.instance, ); }, ), diff --git a/lib/models/plex_media_info.dart b/lib/models/plex_media_info.dart index 1c92a219..701e713d 100644 --- a/lib/models/plex_media_info.dart +++ b/lib/models/plex_media_info.dart @@ -1,5 +1,6 @@ import '../utils/app_logger.dart'; import '../utils/codec_utils.dart'; +import '../utils/track_label_builder.dart' show buildTrackLabel; class PlexMediaInfo { final String videoUrl; @@ -23,9 +24,9 @@ class PlexMediaInfo { static PlexMediaInfo? fromMetadataJson(Map metadata) { final media = metadata['Media'] as List?; if (media == null || media.isEmpty) return null; - final parts = media[0]['Part'] as List?; + final parts = media.first['Part'] as List?; if (parts == null || parts.isEmpty) return null; - final streams = parts[0]['Stream'] as List?; + final streams = parts.first['Stream'] as List?; final audioTracks = []; final subtitleTracks = []; @@ -75,27 +76,11 @@ class PlexMediaInfo { } } -/// Builds a track label from parts with the standard `' ยท '` joiner pattern. +/// Mixin for building track labels with a consistent pattern. /// -/// Shared by both Plex track models and MPV track label utilities. -/// If [title] is non-empty it is added first, then [language], then [extraParts]. -/// Falls back to `'$fallbackPrefix ${index + 1}'` when no parts are available. -String buildTrackLabel({ - String? title, - String? language, - List extraParts = const [], - required int index, - String fallbackPrefix = 'Track', -}) { - final parts = []; - if (title != null && title.isNotEmpty) parts.add(title); - if (language != null && language.isNotEmpty) parts.add(language); - parts.addAll(extraParts); - return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' ยท '); -} - -/// Mixin for building track labels with a consistent pattern -mixin TrackLabelBuilder { +/// Used by [PlexAudioTrack] and [PlexSubtitleTrack] to provide a [buildLabel] +/// method that delegates to the shared [buildTrackLabel] function. +mixin _TrackLabelMixin { int get id; int? get index; String? get displayTitle; @@ -112,7 +97,7 @@ mixin TrackLabelBuilder { } } -class PlexAudioTrack with TrackLabelBuilder { +class PlexAudioTrack with _TrackLabelMixin { @override final int id; @override @@ -147,7 +132,7 @@ class PlexAudioTrack with TrackLabelBuilder { } } -class PlexSubtitleTrack with TrackLabelBuilder { +class PlexSubtitleTrack with _TrackLabelMixin { @override final int id; @override diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index f7c2f9a0..08906475 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -309,10 +309,12 @@ class CompanionRemoteProvider with ChangeNotifier { appLogger.d('CompanionRemote: Attempting reconnect...'); // Clean up old peer service without triggering intentional disconnect _cleanupSubscriptions(); - await _peerService?.disconnect(); - - _peerService = CompanionRemotePeerService(); - _setupPeerServiceListeners(); + try { + await _peerService?.disconnect(); + } finally { + _peerService = CompanionRemotePeerService(); + _setupPeerServiceListeners(); + } await _peerService!.joinSession(_lastSessionId!, _lastPin!, _deviceName, _platform, _lastHostAddress!); diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index c83ed8bd..a2bb243a 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -3,11 +3,11 @@ import 'dart:io'; import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:plezy/utils/content_utils.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../models/download_models.dart'; import '../models/plex_metadata.dart'; import '../services/download_manager_service.dart'; import '../services/download_storage_service.dart'; +import '../services/storage_service.dart'; import '../services/plex_api_cache.dart'; import '../services/plex_client.dart'; import '../utils/app_logger.dart'; @@ -118,7 +118,7 @@ class DownloadProvider extends ChangeNotifier { } } - // Load total episode counts from SharedPreferences + // Load total episode counts from StorageService await _loadTotalEpisodeCounts(); appLogger.i( @@ -131,32 +131,24 @@ class DownloadProvider extends ChangeNotifier { } } - /// Load total episode counts from SharedPreferences + /// Load total episode counts from StorageService Future _loadTotalEpisodeCounts() async { try { - final prefs = await SharedPreferences.getInstance(); - final keys = prefs.getKeys().where((k) => k.startsWith('episode_count_')); + final storage = await StorageService.getInstance(); + final counts = storage.loadAllEpisodeCounts(); + _totalEpisodeCounts.addAll(counts); - for (final key in keys) { - final globalKey = key.replaceFirst('episode_count_', ''); - final count = prefs.getInt(key); - if (count != null) { - _totalEpisodeCounts[globalKey] = count; - appLogger.d('๐Ÿ“‚ Loaded episode count from SharedPrefs: $globalKey = $count'); - } - } - - appLogger.i('๐Ÿ“š Loaded ${_totalEpisodeCounts.length} episode counts from SharedPreferences'); + appLogger.i('Loaded ${_totalEpisodeCounts.length} episode counts from StorageService'); } catch (e) { appLogger.w('Failed to load episode counts', error: e); } } - /// Persist total episode count to SharedPreferences + /// Persist total episode count to StorageService Future _persistTotalEpisodeCount(String globalKey, int count) async { try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setInt('episode_count_$globalKey', count); + final storage = await StorageService.getInstance(); + await storage.saveTotalEpisodeCount(globalKey, count); appLogger.d('Persisted episode count for $globalKey: $count'); } catch (e) { appLogger.w('Failed to persist episode count for $globalKey', error: e); @@ -409,7 +401,7 @@ class DownloadProvider extends ChangeNotifier { countSource = 'metadata.leafCount'; } else if (storedCount != null && storedCount > 0) { totalEpisodes = storedCount; - countSource = 'stored count (SharedPreferences)'; + countSource = 'stored count (StorageService)'; } else { totalEpisodes = downloadedCount; countSource = 'downloaded episodes (fallback)'; @@ -874,10 +866,10 @@ class DownloadProvider extends ChangeNotifier { final meta = _metadata[globalKey]; if (meta?.type == 'show' || meta?.type == 'season') { final removedCount = _totalEpisodeCounts.remove(globalKey); - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('episode_count_$globalKey'); + final storage = await StorageService.getInstance(); + await storage.removeEpisodeCount(globalKey); appLogger.i( - '๐Ÿ—‘๏ธ Removed episode count for $globalKey\n' + 'Removed episode count for $globalKey\n' ' - Removed count value: $removedCount\n' ' - Metadata type: ${meta?.type}\n' ' - Metadata title: ${meta?.title}\n' diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index ef96dfcb..b139b9a5 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart'; import '../models/plex_metadata.dart'; import '../services/offline_watch_sync_service.dart'; -import '../services/plex_api_cache.dart'; import '../utils/watch_state_notifier.dart'; import 'download_provider.dart'; import '../utils/global_key_utils.dart'; @@ -16,16 +15,12 @@ import '../utils/global_key_utils.dart'; class OfflineWatchProvider extends ChangeNotifier { final OfflineWatchSyncService _syncService; final DownloadProvider _downloadProvider; - // ignore: unused_field - reserved for future cached metadata lookup - final PlexApiCache _apiCache; OfflineWatchProvider({ required OfflineWatchSyncService syncService, required DownloadProvider downloadProvider, - required PlexApiCache apiCache, }) : _syncService = syncService, - _downloadProvider = downloadProvider, - _apiCache = apiCache { + _downloadProvider = downloadProvider { // Listen to sync service changes to update UI _syncService.addListener(_onSyncServiceChanged); } diff --git a/lib/screens/companion_remote/pairing_screen.dart b/lib/screens/companion_remote/pairing_screen.dart index f5365d43..a4bfb74d 100644 --- a/lib/screens/companion_remote/pairing_screen.dart +++ b/lib/screens/companion_remote/pairing_screen.dart @@ -279,7 +279,7 @@ class _PairingScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const Icon(Icons.keyboard, size: 64, color: Colors.blue), + Icon(Icons.keyboard, size: 64, color: Theme.of(context).colorScheme.primary), const SizedBox(height: 24), Text( t.companionRemote.pairing.pairWithDesktop, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index ac0c288b..31638738 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -15,12 +15,13 @@ import '../widgets/media_grid_delegate.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/overlay_sheet.dart'; import '../focus/focusable_action_bar.dart'; -import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; import 'libraries/sort_bottom_sheet.dart'; import 'libraries/state_messages.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; +import 'focusable_detail_screen_mixin.dart'; /// Screen to display full content of a recommendation hub class HubDetailScreen extends StatefulWidget { @@ -32,7 +33,8 @@ class HubDetailScreen extends StatefulWidget { State createState() => _HubDetailScreenState(); } -class _HubDetailScreenState extends State with Refreshable, GridFocusNodeMixin { +class _HubDetailScreenState extends State + with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin { PlexClient get client => _getClientForHub(); List _items = []; @@ -43,14 +45,42 @@ class _HubDetailScreenState extends State with Refreshable, Gri bool _isLoading = false; String? _errorMessage; - late final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'hub_detail_first_item'); - final _actionBarKey = GlobalKey(); - bool _isAppBarFocused = false; - bool _backHandledByKeyEvent = false; - /// Key for getting a context below OverlaySheetHost final GlobalKey _overlayChildKey = GlobalKey(); + @override + bool get hasItems => _filteredItems.isNotEmpty; + + @override + List getAppBarActions() { + return [ + FocusableAction( + icon: Symbols.swap_vert_rounded, + tooltip: t.libraries.sort, + onPressed: _showSortBottomSheet, + ), + ]; + } + + /// Override to add bounds check for filtered items (sorting can change item order) + @override + void navigateToGrid() { + if (!hasItems) return; + + final targetIndex = + shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length ? lastFocusedGridIndex! : 0; + + setState(() { + isAppBarFocused = false; + }); + + if (targetIndex == 0) { + firstItemFocusNode.requestFocus(); + } else { + getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus(); + } + } + /// Get the correct PlexClient for this hub's server PlexClient _getClientForHub() { return context.getClientForServer(widget.hub.serverId!); @@ -69,43 +99,15 @@ class _HubDetailScreenState extends State with Refreshable, Gri // Load sorts based on the library type _loadSorts(); // Auto-focus first grid item in keyboard mode after first frame - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - if (InputModeTracker.isKeyboardMode(context) && _filteredItems.isNotEmpty) { - _firstItemFocusNode.requestFocus(); - } - }); + autoFocusFirstItemAfterLoad(); } @override void dispose() { - _firstItemFocusNode.dispose(); - disposeGridFocusNodes(); + disposeFocusResources(); super.dispose(); } - void _focusGrid() { - if (_filteredItems.isEmpty) return; - final targetIndex = - shouldRestoreGridFocus && lastFocusedGridIndex! < _filteredItems.length ? lastFocusedGridIndex! : 0; - if (targetIndex == 0) { - _firstItemFocusNode.requestFocus(); - } else { - getGridItemFocusNode(targetIndex, prefix: 'hub_detail_item').requestFocus(); - } - } - - void _navigateToAppBar() { - setState(() => _isAppBarFocused = true); - _actionBarKey.currentState?.getFocusNode(0).requestFocus(); - } - - void _handleBackFromContent() { - _backHandledByKeyEvent = true; - _navigateToAppBar(); - } - - Future _loadSorts() async { try { final client = _getClientForHub(); @@ -279,40 +281,27 @@ class _HubDetailScreenState extends State with Refreshable, Gri @override Widget build(BuildContext context) { - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - return PopScope( - canPop: !isKeyboardMode || _isAppBarFocused, + canPop: false, onPopInvokedWithResult: (didPop, _) { - if (didPop || _backHandledByKeyEvent) { - _backHandledByKeyEvent = false; - return; + if (BackKeyCoordinator.consumeIfHandled()) return; + if (didPop) return; + final shouldPop = handleBackNavigation(); + if (shouldPop && mounted) { + Navigator.pop(context); } - _navigateToAppBar(); }, child: OverlaySheetHost( child: Scaffold( key: _overlayChildKey, body: CustomScrollView( + controller: scrollController, clipBehavior: Clip.none, slivers: [ CustomAppBar( title: Text(widget.hub.title), pinned: true, - actions: [ - FocusableActionBar( - key: _actionBarKey, - onNavigateDown: _focusGrid, - onBack: () => Navigator.pop(context), - actions: [ - FocusableAction( - icon: Symbols.swap_vert_rounded, - tooltip: t.libraries.sort, - onPressed: _showSortBottomSheet, - ), - ], - ), - ], + actions: buildFocusableAppBarActions(), ), if (_errorMessage != null) SliverFillRemaining( @@ -372,7 +361,7 @@ class _HubDetailScreenState extends State with Refreshable, Gri (context, index) { final item = _filteredItems[index]; final focusNode = index == 0 - ? _firstItemFocusNode + ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'hub_detail_item'); final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount); @@ -381,9 +370,9 @@ class _HubDetailScreenState extends State with Refreshable, Gri focusNode: focusNode, item: item, onRefresh: _handleItemRefresh, - onNavigateUp: isFirstRow ? _navigateToAppBar : null, + onNavigateUp: isFirstRow ? navigateToAppBar : null, onNavigateLeft: isFirstColumn ? () {} : null, - onBack: _handleBackFromContent, + onBack: handleBackFromContent, onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), mixedHubContext: isMixedHub, ); diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index f5f0d6b5..61a2ad2f 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1270,14 +1270,14 @@ class _SkeletonCard extends StatelessWidget { // Poster area โ€” matches the Expanded poster in _buildGridCard Expanded( child: ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: const BorderRadius.all(Radius.circular(8)), child: const SkeletonLoader(child: SizedBox.expand()), ), ), const SizedBox(height: 4), // Title bar SkeletonLoader( - borderRadius: BorderRadius.circular(4), + borderRadius: const BorderRadius.all(Radius.circular(4)), child: const SizedBox(height: 13, width: double.infinity), ), const SizedBox(height: 3), @@ -1286,7 +1286,7 @@ class _SkeletonCard extends StatelessWidget { alignment: Alignment.centerLeft, widthFactor: 0.6, child: SkeletonLoader( - borderRadius: BorderRadius.circular(4), + borderRadius: const BorderRadius.all(Radius.circular(4)), child: const SizedBox(height: 11), ), ), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index ba3f6045..6bfe6989 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -110,6 +110,11 @@ class _MediaDetailScreenState extends State with WatchStateAw String _toGlobalKey(String ratingKey, {String? serverId}) => buildGlobalKey(serverId ?? widget.metadata.serverId ?? '', ratingKey); + /// Calls [setState] only if the widget is still mounted. + void _setStateIfMounted(VoidCallback fn) { + if (mounted) setState(fn); + } + // WatchStateAware: watch the show/movie and all season ratingKeys @override Set? get watchedRatingKeys { @@ -229,8 +234,8 @@ class _MediaDetailScreenState extends State with WatchStateAw final metadata = result['metadata'] as PlexMetadata?; final onDeckEpisode = result['onDeckEpisode'] as PlexMetadata?; - if (metadata != null && mounted) { - setState(() { + if (metadata != null) { + _setStateIfMounted(() { _fullMetadata = metadata.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName); _onDeckEpisode = onDeckEpisode?.copyWith( serverId: widget.metadata.serverId, @@ -242,13 +247,11 @@ class _MediaDetailScreenState extends State with WatchStateAw // Refresh seasons for updated watched counts (also without loader) if (widget.metadata.isShow) { final seasons = await client.getChildren(widget.metadata.ratingKey); - if (mounted) { - setState(() { - _seasons = seasons - .map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) - .toList(); - }); - } + _setStateIfMounted(() { + _seasons = seasons + .map((s) => s.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) + .toList(); + }); } } catch (e) { // Silently fail - data will refresh on next navigation @@ -1047,14 +1050,12 @@ class _MediaDetailScreenState extends State with WatchStateAw final seasonsWithServerId = seasons .map((season) => season.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); - if (!mounted) return; - setState(() { + _setStateIfMounted(() { _seasons = seasonsWithServerId; _isLoadingSeasons = false; }); } catch (e) { - if (!mounted) return; - setState(() { + _setStateIfMounted(() { _isLoadingSeasons = false; }); } finally { @@ -1131,11 +1132,9 @@ class _MediaDetailScreenState extends State with WatchStateAw .map((extra) => extra.copyWith(serverId: widget.metadata.serverId, serverName: widget.metadata.serverName)) .toList(); - if (mounted) { - setState(() { - _extras = extrasWithServerId; - }); - } + _setStateIfMounted(() { + _extras = extrasWithServerId; + }); } catch (e) { // Silently fail - extras section won't appear if fetch fails } @@ -1604,8 +1603,8 @@ class _MediaDetailScreenState extends State with WatchStateAw final offlineWatchProvider = context.read(); final nextEpisode = await offlineWatchProvider.getNextUnwatchedEpisode(widget.metadata.ratingKey); - if (nextEpisode != null && mounted) { - setState(() { + if (nextEpisode != null) { + _setStateIfMounted(() { _onDeckEpisode = nextEpisode; }); appLogger.d('Offline OnDeck: S${nextEpisode.parentIndex}E${nextEpisode.index} - ${nextEpisode.title}'); @@ -1645,8 +1644,7 @@ class _MediaDetailScreenState extends State with WatchStateAw } // Single setState to minimize rebuilds - scroll position is preserved by controller - if (!mounted) return; - setState(() { + _setStateIfMounted(() { _fullMetadata = metadataWithServerId; if (updatedSeasons != null) { _seasons = updatedSeasons; diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index a23d594a..eefe6329 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -542,11 +542,11 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen String _toGlobalKey(String ratingKey, {String? serverId}) => buildGlobalKey(serverId ?? widget.season.serverId ?? '', ratingKey); + /// Calls [setState] only if the widget is still mounted. + void _setStateIfMounted(VoidCallback fn) { + if (mounted) setState(fn); + } + // WatchStateAware: watch all episode ratingKeys @override Set? get watchedRatingKeys => _episodes.map((e) => e.ratingKey).toSet(); @@ -160,14 +165,12 @@ class _SeasonDetailScreenState extends State // Episodes are automatically tagged with server info by PlexClient final episodes = await _client!.getChildren(widget.season.ratingKey); - if (!mounted) return; - setState(() { + _setStateIfMounted(() { _episodes = episodes; _isLoadingEpisodes = false; }); } catch (e) { - if (!mounted) return; - setState(() { + _setStateIfMounted(() { _isLoadingEpisodes = false; }); } @@ -573,14 +576,14 @@ class _EpisodeCardState extends State<_EpisodeCard> { value: 1.0, strokeWidth: 1.5, valueColor: AlwaysStoppedAnimation( - getMutedColor(Colors.blue).withValues(alpha: 0.3), + getMutedColor(Theme.of(context).colorScheme.primary).withValues(alpha: 0.3), ), ), // Progress circle CircularProgressIndicator( value: progress?.progressPercent, strokeWidth: 1.5, - valueColor: AlwaysStoppedAnimation(getMutedColor(Colors.blue)), + valueColor: AlwaysStoppedAnimation(getMutedColor(Theme.of(context).colorScheme.primary)), ), ], ), diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index eb0a6bf0..66826e73 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:plezy/utils/http_client.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; @@ -130,7 +131,7 @@ class _LogsScreenState extends State { ); try { - final response = await Dio().post( + final response = await createHttpClient().post( 'https://ice.plezy.app/logs', data: logText, options: Options(contentType: 'text/plain'), diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ee10771f..f73f221a 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -57,7 +57,7 @@ import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/language_codes.dart'; import '../utils/snackbar_helper.dart'; -import '../utils/track_label_builder.dart' as tlb; +import '../utils/track_label_builder.dart'; import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/overlay_sheet.dart'; @@ -434,10 +434,24 @@ class VideoPlayerScreenState extends State with WidgetsBindin // exhausts the process address space on memory-constrained devices. final heapMB = await PlayerAndroid.getHeapSize(); if (heapMB > 0) { - final autoBackMB = heapMB <= 256 ? 16 : (heapMB <= 512 ? 32 : 48); + int autoBackMB; + if (heapMB <= 256) { + autoBackMB = 16; + } else if (heapMB <= 512) { + autoBackMB = 32; + } else { + autoBackMB = 48; + } if (bufferSizeMB == 0) { // Auto mode: cap both forward and back buffer based on heap - final autoForwardMB = heapMB <= 256 ? 32 : (heapMB <= 512 ? 64 : 100); + int autoForwardMB; + if (heapMB <= 256) { + autoForwardMB = 32; + } else if (heapMB <= 512) { + autoForwardMB = 64; + } else { + autoForwardMB = 100; + } await player!.setProperty('demuxer-max-bytes', '${autoForwardMB * 1024 * 1024}'); await player!.setProperty('demuxer-max-back-bytes', '${autoBackMB * 1024 * 1024}'); } else { @@ -1279,6 +1293,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin final presetId = settings.getGlobalShaderPreset(); final preset = ShaderPreset.fromId(presetId) ?? ShaderPreset.none; await _shaderService!.applyPreset(preset); + if (!mounted) return; context.read().setCurrentPreset(preset); } catch (e) { appLogger.d('Could not apply shader preset', error: e); @@ -1578,7 +1593,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (mounted) { final label = next.id == 'no' ? 'Subtitles: Off' - : 'Subtitles: ${tlb.TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}'; + : 'Subtitles: ${TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}'; showAppSnackBar(context, label, duration: const Duration(seconds: 1)); } } @@ -1597,7 +1612,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (mounted) { final label = - 'Audio: ${tlb.TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; + 'Audio: ${TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; showAppSnackBar(context, label, duration: const Duration(seconds: 1)); } } diff --git a/lib/services/base_peer_service.dart b/lib/services/base_peer_service.dart new file mode 100644 index 00000000..8d4ae792 --- /dev/null +++ b/lib/services/base_peer_service.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +/// Error types that can occur in peer services. +/// +/// This is a superset covering both Watch Together relay errors and +/// Companion Remote direct-connection errors. +enum PeerErrorType { + connectionFailed, + peerDisconnected, + dataChannelError, + serverError, + timeout, + invalidSession, + authFailed, + networkError, + unknown, +} + +/// Represents an error in a peer service. +class PeerError { + final PeerErrorType type; + final String message; + final dynamic originalError; + + const PeerError({required this.type, required this.message, this.originalError}); + + @override + String toString() => 'PeerError($type): $message'; +} + +/// Mixin that provides WebSocket keepalive ping/pong timer management. +/// +/// Subclasses must implement [sendPing] to send the actual ping message +/// over their specific transport. The mixin manages periodic pings and +/// an optional pong timeout that closes the connection if no data arrives. +mixin KeepaliveMixin { + Timer? _pingTimer; + Timer? _pongTimer; + + /// How often to send a keepalive ping. + Duration get pingInterval; + + /// How long to wait for any incoming data before considering the + /// connection dead. Set to [Duration.zero] to disable pong timeout. + Duration get pongTimeout; + + /// Send a keepalive ping over the transport. + void sendPing(); + + /// Called when the pong timeout fires (no data received within [pongTimeout]). + /// Override to close the underlying socket/channel. + void onPongTimeout(); + + /// Start the periodic ping timer and arm the pong timeout. + void startKeepalive() { + _pingTimer?.cancel(); + _pingTimer = Timer.periodic(pingInterval, (_) => sendPing()); + resetPongTimer(); + } + + /// Reset the pong timeout (call on every incoming message). + void resetPongTimer() { + if (pongTimeout == Duration.zero) return; + _pongTimer?.cancel(); + _pongTimer = Timer(pongTimeout, onPongTimeout); + } + + /// Stop all keepalive timers. + void stopKeepalive() { + _pingTimer?.cancel(); + _pingTimer = null; + _pongTimer?.cancel(); + _pongTimer = null; + } +} diff --git a/lib/services/companion_remote/companion_remote_peer_service.dart b/lib/services/companion_remote/companion_remote_peer_service.dart index a394da60..1d3a7ce7 100644 --- a/lib/services/companion_remote/companion_remote_peer_service.dart +++ b/lib/services/companion_remote/companion_remote_peer_service.dart @@ -8,31 +8,17 @@ import 'package:web_socket_channel/io.dart'; import '../../models/companion_remote/remote_command.dart'; import '../../models/companion_remote/remote_session.dart'; import '../../utils/app_logger.dart'; +import '../base_peer_service.dart'; -enum RemotePeerErrorType { - connectionFailed, - peerDisconnected, - dataChannelError, - serverError, - timeout, - invalidSession, - authFailed, - networkError, - unknown, -} +// Re-export so callers that import from here get the types. +export '../base_peer_service.dart' show PeerError, PeerErrorType; -class RemotePeerError { - final RemotePeerErrorType type; - final String message; - final dynamic originalError; +/// Backward-compatible aliases so existing callers that reference the +/// Remote-specific names keep compiling. +typedef RemotePeerErrorType = PeerErrorType; +typedef RemotePeerError = PeerError; - const RemotePeerError({required this.type, required this.message, this.originalError}); - - @override - String toString() => 'RemotePeerError($type): $message'; -} - -class CompanionRemotePeerService { +class CompanionRemotePeerService with KeepaliveMixin { // Server-side (host) fields HttpServer? _server; WebSocket? _clientSocket; @@ -52,7 +38,11 @@ class CompanionRemotePeerService { final _errorController = StreamController.broadcast(); final _connectionStateController = StreamController.broadcast(); - Timer? _pingTimer; + // Keepalive (via KeepaliveMixin) + @override + Duration get pingInterval => const Duration(seconds: 5); + @override + Duration get pongTimeout => Duration.zero; // No pong timeout; host just replies inline // Auth rate limiting int _failedAuthAttempts = 0; @@ -291,7 +281,7 @@ class CompanionRemotePeerService { _clientSocket = null; _deviceDisconnectedController.add(null); _connectionStateController.add(RemoteSessionStatus.disconnected); - _stopPingTimer(); + stopKeepalive(); } }, onError: (error) { @@ -328,6 +318,7 @@ class CompanionRemotePeerService { _connectionStateController.add(RemoteSessionStatus.connecting); _channel = IOWebSocketChannel.connect(Uri.parse(url)); + await _channel!.ready; // Send authentication message final authMessage = jsonEncode({ @@ -361,7 +352,7 @@ class CompanionRemotePeerService { sendDeviceInfo(deviceName, platform); // Start ping timer - _startPingTimer(); + startKeepalive(); } else if (messageType == 'authFailed') { final message = json['message'] as String? ?? 'Authentication failed'; appLogger.w('CompanionRemote: $message'); @@ -395,7 +386,7 @@ class CompanionRemotePeerService { appLogger.d('CompanionRemote: Connection closed'); _deviceDisconnectedController.add(null); _connectionStateController.add(RemoteSessionStatus.disconnected); - _stopPingTimer(); + stopKeepalive(); // Reconnection is handled by CompanionRemoteProvider }, onError: (error) { @@ -432,7 +423,9 @@ class CompanionRemotePeerService { onTimeout: () async { // Clean up channel on timeout if (_channel != null) { - await _channel!.sink.close(); + try { + await _channel!.sink.close(); + } catch (_) {} _channel = null; } throw const RemotePeerError(type: RemotePeerErrorType.timeout, message: 'Timed out joining session'); @@ -528,18 +521,16 @@ class CompanionRemotePeerService { } } - void _startPingTimer() { - _stopPingTimer(); - _pingTimer = Timer.periodic(const Duration(seconds: 5), (_) { - if (isConnected) { - sendCommand(const RemoteCommand(type: RemoteCommandType.ping)); - } - }); + @override + void sendPing() { + if (isConnected) { + sendCommand(const RemoteCommand(type: RemoteCommandType.ping)); + } } - void _stopPingTimer() { - _pingTimer?.cancel(); - _pingTimer = null; + @override + void onPongTimeout() { + // Not used โ€” pong timeout is disabled for companion remote. } bool _shouldSendAck(RemoteCommand command) { @@ -594,15 +585,19 @@ class CompanionRemotePeerService { Future disconnect() async { appLogger.d('CompanionRemote: Disconnecting'); - _stopPingTimer(); + stopKeepalive(); if (_clientSocket != null) { - await _clientSocket!.close(); + try { + await _clientSocket!.close(); + } catch (_) {} _clientSocket = null; } if (_channel != null) { - await _channel!.sink.close(); + try { + await _channel!.sink.close(); + } catch (_) {} _channel = null; } diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index f442caab..e3fe6c61 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -6,6 +6,7 @@ import 'package:dart_discord_presence/dart_discord_presence.dart'; import 'package:dio/dio.dart'; import '../models/plex_metadata.dart'; +import '../utils/http_client.dart'; import '../utils/app_logger.dart'; import 'plex_client.dart'; import 'settings_service.dart'; @@ -299,7 +300,7 @@ class DiscordRPCService { if (imageUrl.isEmpty) return null; // Fetch image data - final dio = Dio(); + final dio = createHttpClient(); final imageResponse = await dio.get>( imageUrl, options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 10)), diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 7d3e4d93..6245b873 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -3,10 +3,11 @@ import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; -import 'package:drift/drift.dart'; import 'package:path/path.dart' as path; import 'package:plezy/utils/content_utils.dart'; +import 'package:plezy/utils/http_client.dart'; import '../database/app_database.dart'; +import '../database/download_operations.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; import '../models/download_models.dart'; @@ -20,170 +21,6 @@ import '../utils/codec_utils.dart'; import '../utils/global_key_utils.dart'; import '../utils/plex_cache_parser.dart'; -/// Extension methods on AppDatabase for download operations - -extension DownloadDatabaseOperations on AppDatabase { - /// Insert a new download into the database - Future insertDownload({ - required String serverId, - required String ratingKey, - required String globalKey, - required String type, - String? parentRatingKey, - String? grandparentRatingKey, - required int status, - }) async { - await into(downloadedMedia).insert( - DownloadedMediaCompanion.insert( - serverId: serverId, - ratingKey: ratingKey, - globalKey: globalKey, - type: type, - parentRatingKey: Value(parentRatingKey), - grandparentRatingKey: Value(grandparentRatingKey), - status: status, - ), - mode: InsertMode.insertOrReplace, - ); - } - - /// Add item to download queue - Future addToQueue({ - required String mediaGlobalKey, - int priority = 0, - bool downloadSubtitles = true, - bool downloadArtwork = true, - }) async { - await into(downloadQueue).insert( - DownloadQueueCompanion.insert( - mediaGlobalKey: mediaGlobalKey, - priority: Value(priority), - addedAt: DateTime.now().millisecondsSinceEpoch, - downloadSubtitles: Value(downloadSubtitles), - downloadArtwork: Value(downloadArtwork), - ), - mode: InsertMode.insertOrReplace, - ); - } - - /// Get next item from queue (highest priority, oldest first) - /// Only returns items that are not paused - Future getNextQueueItem() async { - // Join with downloadedMedia to check status and filter out paused items - final query = select( - downloadQueue, - ).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]); - - query - ..where(downloadedMedia.status.equals(DownloadStatus.queued.index)) - ..orderBy([ - OrderingTerm(expression: downloadQueue.priority, mode: OrderingMode.desc), - OrderingTerm(expression: downloadQueue.addedAt), - ]) - ..limit(1); - - final result = await query.getSingleOrNull(); - return result?.readTable(downloadQueue); - } - - /// Update download status - Future updateDownloadStatus(String globalKey, int status) async { - await (update( - downloadedMedia, - )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status))); - } - - /// Update download progress - Future updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async { - await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( - DownloadedMediaCompanion( - progress: Value(progress), - downloadedBytes: Value(downloadedBytes), - totalBytes: Value(totalBytes), - ), - ); - } - - /// Update video file path - Future updateVideoFilePath(String globalKey, String filePath) async { - await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( - DownloadedMediaCompanion( - videoFilePath: Value(filePath), - downloadedAt: Value(DateTime.now().millisecondsSinceEpoch), - ), - ); - } - - /// Update artwork paths - Future updateArtworkPaths({required String globalKey, String? thumbPath}) async { - await (update( - downloadedMedia, - )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath))); - } - - /// Update download error and increment retry count - Future updateDownloadError(String globalKey, String errorMessage) async { - // Get current retry count to increment it - final existing = await getDownloadedMedia(globalKey); - final currentCount = existing?.retryCount ?? 0; - - await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( - DownloadedMediaCompanion(errorMessage: Value(errorMessage), retryCount: Value(currentCount + 1)), - ); - } - - /// Clear download error and reset retry count (for retry) - Future clearDownloadError(String globalKey) async { - await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write( - const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)), - ); - } - - /// Remove item from queue - Future removeFromQueue(String mediaGlobalKey) async { - await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go(); - } - - /// Get downloaded media item - Future getDownloadedMedia(String globalKey) { - return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull(); - } - - /// Delete a download - Future deleteDownload(String globalKey) async { - await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go(); - await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go(); - } - - /// Get all downloaded episodes for a season - Future> getEpisodesBySeason(String seasonKey) { - return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get(); - } - - /// Get all downloaded episodes for a show - Future> getEpisodesByShow(String showKey) { - return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get(); - } - - /// Get all downloaded items for a specific server - Future> getDownloadsByServerId(String serverId) { - return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get(); - } - - /// Update the background_downloader task ID for a download - Future updateBgTaskId(String globalKey, String? taskId) async { - await (update( - downloadedMedia, - )..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId))); - } - - /// Get the background_downloader task ID for a download - Future getBgTaskId(String globalKey) async { - final item = await getDownloadedMedia(globalKey); - return item?.bgTaskId; - } -} - /// Context for a download that's been enqueued with background_downloader. /// Carries metadata needed between enqueue and completion callback. class _DownloadContext { @@ -284,7 +121,7 @@ class DownloadManagerService { DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, Dio? dio}) : _database = database, _storageService = storageService, - _dio = dio ?? Dio(); + _dio = dio ?? createHttpClient(); /// Initialize background_downloader with callbacks, notifications, and concurrency config. Future _initializeFileDownloader() async { diff --git a/lib/services/in_app_review_service.dart b/lib/services/in_app_review_service.dart index 8242460d..2a42f814 100644 --- a/lib/services/in_app_review_service.dart +++ b/lib/services/in_app_review_service.dart @@ -14,6 +14,13 @@ class InAppReviewService { final InAppReview _inAppReview = InAppReview.instance; + // Cached SharedPreferences instance (lazy-initialized) + SharedPreferences? _prefs; + + Future _getPrefs() async { + return _prefs ??= await SharedPreferences.getInstance(); + } + // SharedPreferences keys static const String _keyQualifyingSessionsCount = 'review_qualifying_sessions_count'; static const String _keyLastPromptTime = 'review_last_prompt_time'; @@ -62,20 +69,20 @@ class InAppReviewService { /// Increment the qualifying sessions counter Future _incrementQualifyingSessions() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); final currentCount = prefs.getInt(_keyQualifyingSessionsCount) ?? 0; await prefs.setInt(_keyQualifyingSessionsCount, currentCount + 1); } /// Get the current qualifying sessions count Future _getQualifyingSessionsCount() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); return prefs.getInt(_keyQualifyingSessionsCount) ?? 0; } /// Check if we should request a review based on session count and cooldown Future _shouldRequestReview() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); // Check session count final sessionCount = await _getQualifyingSessionsCount(); @@ -127,7 +134,7 @@ class InAppReviewService { /// Record that the review prompt was shown Future _recordPromptShown() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); await prefs.setString(_keyLastPromptTime, DateTime.now().toIso8601String()); // Reset session count so user needs to use app more before next prompt await prefs.setInt(_keyQualifyingSessionsCount, 0); @@ -135,7 +142,7 @@ class InAppReviewService { /// Get debug info about the current state (for development/testing) Future> getDebugInfo() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); final sessionCount = prefs.getInt(_keyQualifyingSessionsCount) ?? 0; final lastPromptString = prefs.getString(_keyLastPromptTime); final isAvailable = await _inAppReview.isAvailable(); @@ -153,7 +160,7 @@ class InAppReviewService { /// Reset all stored data (for testing purposes) Future reset() async { - final prefs = await SharedPreferences.getInstance(); + final prefs = await _getPrefs(); await prefs.remove(_keyQualifyingSessionsCount); await prefs.remove(_keyLastPromptTime); _sessionStartTime = null; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 5f36b013..4658817c 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -961,7 +961,7 @@ class SettingsService extends BaseSharedPreferencesService { await prefs.setString(_keyMpvConfigText, text); } - /// Parse raw config text into a Map (skip blanks and # comments) + /// Parse raw config text into a `Map` (skip blanks and # comments) static Map parseMpvConfigText(String text) { final result = {}; for (final line in text.split('\n')) { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 4abf0138..260d571f 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -284,6 +284,41 @@ class StorageService extends BaseSharedPreferencesService { await prefs.remove(_keyServerOrder); } + // Episode Count Persistence (for partial download detection) + + static const String _prefixEpisodeCount = 'episode_count_'; + + /// Save the total episode count for a show/season + Future saveTotalEpisodeCount(String globalKey, int count) async { + await prefs.setInt('$_prefixEpisodeCount$globalKey', count); + } + + /// Get the total episode count for a show/season + int? getTotalEpisodeCount(String globalKey) { + return prefs.getInt('$_prefixEpisodeCount$globalKey'); + } + + /// Load all persisted episode counts + Map loadAllEpisodeCounts() { + final counts = {}; + final keys = prefs.getKeys().where((k) => k.startsWith(_prefixEpisodeCount)); + + for (final key in keys) { + final globalKey = key.replaceFirst(_prefixEpisodeCount, ''); + final count = prefs.getInt(key); + if (count != null) { + counts[globalKey] = count; + } + } + + return counts; + } + + /// Remove the episode count for a specific show/season + Future removeEpisodeCount(String globalKey) async { + await prefs.remove('$_prefixEpisodeCount$globalKey'); + } + // Private helper methods /// Helper to read and decode JSON `List` from preferences diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index d25c9e48..5b3538ce 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -4,6 +4,7 @@ import 'package:auto_updater/auto_updater.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:dio/dio.dart'; import 'package:logger/logger.dart'; +import 'package:plezy/utils/http_client.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Service to check for new versions on GitHub @@ -153,7 +154,7 @@ class UpdateService { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; - final dio = Dio(); + final dio = createHttpClient(); final response = await dio.get( 'https://api.github.com/repos/$_githubRepo/releases/latest', options: Options(headers: {'Accept': 'application/vnd.github+json'}), diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index 94bede84..814c274b 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -5,33 +5,27 @@ import 'platform_detector.dart'; /// Utility class for calculating consistent grid sizes across the app class GridSizeCalculator { - /// Screen width breakpoint for tablet devices - static const double tabletBreakpoint = ScreenBreakpoints.tablet; - - /// Screen width breakpoint for desktop devices - static const double desktopBreakpoint = ScreenBreakpoints.desktop; - /// Calculates the maximum cross-axis extent for grid items based on screen size and density static double getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { final screenWidth = MediaQuery.of(context).size.width; final isTV = PlatformDetector.isTV(); - final isDesktop = screenWidth > desktopBreakpoint; - final isTablet = screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; + final isDesktopOrLarger = ScreenBreakpoints.isDesktopOrLarger(screenWidth); + final isTablet = ScreenBreakpoints.isTablet(screenWidth); switch (density) { case LibraryDensity.comfortable: if (isTV) return GridLayoutConstants.comfortableTV; - if (isDesktop) return GridLayoutConstants.comfortableDesktop; + if (isDesktopOrLarger) return GridLayoutConstants.comfortableDesktop; if (isTablet) return GridLayoutConstants.comfortableTablet; return GridLayoutConstants.comfortableMobile; case LibraryDensity.compact: if (isTV) return GridLayoutConstants.compactTV; - if (isDesktop) return GridLayoutConstants.compactDesktop; + if (isDesktopOrLarger) return GridLayoutConstants.compactDesktop; if (isTablet) return GridLayoutConstants.compactTablet; return GridLayoutConstants.compactMobile; case LibraryDensity.normal: if (isTV) return GridLayoutConstants.normalTV; - if (isDesktop) return GridLayoutConstants.normalDesktop; + if (isDesktopOrLarger) return GridLayoutConstants.normalDesktop; if (isTablet) return GridLayoutConstants.normalTablet; return GridLayoutConstants.normalMobile; } @@ -106,22 +100,6 @@ class GridSizeCalculator { } } - /// Returns whether the current screen is a desktop-sized screen - static bool isDesktop(BuildContext context) { - return MediaQuery.of(context).size.width > desktopBreakpoint; - } - - /// Returns whether the current screen is a tablet-sized screen - static bool isTablet(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - return screenWidth > tabletBreakpoint && screenWidth <= desktopBreakpoint; - } - - /// Returns whether the current screen is a mobile-sized screen - static bool isMobile(BuildContext context) { - return MediaQuery.of(context).size.width <= tabletBreakpoint; - } - /// Calculates the number of columns for a given available width. /// /// Uses the same formula as Flutter's SliverGridDelegateWithMaxCrossAxisExtent: diff --git a/lib/utils/http_client.dart b/lib/utils/http_client.dart new file mode 100644 index 00000000..b68b0905 --- /dev/null +++ b/lib/utils/http_client.dart @@ -0,0 +1,18 @@ +import 'package:dio/dio.dart'; + +/// Creates a plain [Dio] instance with sensible default timeouts for ad-hoc +/// HTTP requests that don't go through [PlexClient]. +/// +/// Use this instead of bare `Dio()` so every call site gets consistent timeout +/// behaviour without duplicating configuration. +Dio createHttpClient({ + Duration connectTimeout = const Duration(seconds: 10), + Duration receiveTimeout = const Duration(seconds: 30), +}) { + return Dio( + BaseOptions( + connectTimeout: connectTimeout, + receiveTimeout: receiveTimeout, + ), + ); +} diff --git a/lib/utils/track_label_builder.dart b/lib/utils/track_label_builder.dart index 9258d3e2..ce84ddf5 100644 --- a/lib/utils/track_label_builder.dart +++ b/lib/utils/track_label_builder.dart @@ -1,10 +1,25 @@ import 'codec_utils.dart'; -import '../models/plex_media_info.dart' show buildTrackLabel; + +/// Builds a track label from parts with the standard `' ยท '` joiner pattern. +/// +/// Shared by both Plex track models and MPV track label utilities. +/// If [title] is non-empty it is added first, then [language], then [extraParts]. +/// Falls back to `'$fallbackPrefix ${index + 1}'` when no parts are available. +String buildTrackLabel({ + String? title, + String? language, + List extraParts = const [], + required int index, + String fallbackPrefix = 'Track', +}) { + final parts = []; + if (title != null && title.isNotEmpty) parts.add(title); + if (language != null && language.isNotEmpty) parts.add(language); + parts.addAll(extraParts); + return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' ยท '); +} /// Utility for building track labels for audio and subtitle tracks. -/// -/// Delegates to the shared [buildTrackLabel] function so Plex-model and -/// MPV-player label logic stays consistent. class TrackLabelBuilder { TrackLabelBuilder._(); diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart index 11bf0708..3347594f 100644 --- a/lib/watch_together/services/watch_together_peer_service.dart +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -4,23 +4,12 @@ import 'dart:convert'; import 'package:uuid/uuid.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../services/base_peer_service.dart'; import '../../utils/app_logger.dart'; import '../models/sync_message.dart'; -/// Error types that can occur in the peer service -enum PeerErrorType { connectionFailed, peerDisconnected, dataChannelError, serverError, timeout, unknown } - -/// Represents an error in the peer service -class PeerError { - final PeerErrorType type; - final String message; - final dynamic originalError; - - const PeerError({required this.type, required this.message, this.originalError}); - - @override - String toString() => 'PeerError($type): $message'; -} +// Re-export so existing callers that import from here keep working. +export '../../services/base_peer_service.dart' show PeerError, PeerErrorType; /// Service for managing Watch Together connections via a WebSocket relay /// @@ -29,7 +18,7 @@ class PeerError { /// - Joining sessions (as guest) /// - Sending/receiving sync messages through the relay server /// - Reconnection on WebSocket drops -class WatchTogetherPeerService { +class WatchTogetherPeerService with KeepaliveMixin { static const String _baseUrl = 'https://ice.plezy.app'; static String get healthUrl => '$_baseUrl/health'; static const String _relayUrl = 'wss://ice.plezy.app/relay'; @@ -53,11 +42,11 @@ class WatchTogetherPeerService { static const int _maxReconnectAttempts = 3; Timer? _reconnectTimer; - // Keepalive - Timer? _pingTimer; - Timer? _pongTimer; - static const Duration _pingInterval = Duration(seconds: 15); - static const Duration _pongTimeout = Duration(seconds: 30); + // Keepalive (via KeepaliveMixin) + @override + Duration get pingInterval => const Duration(seconds: 15); + @override + Duration get pongTimeout => const Duration(seconds: 30); /// Stream of peer IDs when a new peer connects Stream get onPeerConnected => _peerConnectedController.stream; @@ -111,7 +100,7 @@ class WatchTogetherPeerService { _channelSubscription?.cancel(); _channelSubscription = channel.stream.listen( (data) { - _resetPongTimer(); + resetPongTimer(); _handleServerMessage(data as String, setupCompleter: setupCompleter); }, onError: (error) { @@ -203,7 +192,7 @@ class WatchTogetherPeerService { } case 'pong': - // Handled by _resetPongTimer already + // Handled by resetPongTimer() already break; default: @@ -214,30 +203,15 @@ class WatchTogetherPeerService { } } - /// Start the keepalive ping timer. - void _startPingTimer() { - _pingTimer?.cancel(); - _pingTimer = Timer.periodic(_pingInterval, (_) { - _sendRaw({'type': 'ping'}); - }); - _resetPongTimer(); - } + @override + void sendPing() => _sendRaw({'type': 'ping'}); - /// Reset the pong timeout timer (called on every incoming message). - void _resetPongTimer() { - _pongTimer?.cancel(); - _pongTimer = Timer(_pongTimeout, () { - appLogger.w('WatchTogether: Pong timeout โ€” closing WebSocket'); + @override + void onPongTimeout() { + appLogger.w('WatchTogether: Pong timeout โ€” closing WebSocket'); + try { _channel?.sink.close(); - }); - } - - /// Stop keepalive timers. - void _stopTimers() { - _pingTimer?.cancel(); - _pingTimer = null; - _pongTimer?.cancel(); - _pongTimer = null; + } catch (_) {} } /// Send a raw JSON map to the relay. @@ -251,7 +225,7 @@ class WatchTogetherPeerService { /// Handle the WebSocket being closed unexpectedly โ€” attempt reconnection. void _handleWebSocketClosed() { - _stopTimers(); + stopKeepalive(); _channelSubscription?.cancel(); _channelSubscription = null; _channel = null; @@ -295,7 +269,7 @@ class WatchTogetherPeerService { final completer = Completer(); _listenToChannel(channel, setupCompleter: completer); - _startPingTimer(); + startKeepalive(); // Re-send create or join if (_isHost) { @@ -332,7 +306,7 @@ class WatchTogetherPeerService { final completer = Completer(); _listenToChannel(channel, setupCompleter: completer); - _startPingTimer(); + startKeepalive(); _sendRaw({'type': 'create', 'sessionId': _sessionId, 'peerId': _myPeerId}); @@ -369,7 +343,7 @@ class WatchTogetherPeerService { final completer = Completer(); _listenToChannel(channel, setupCompleter: completer); - _startPingTimer(); + startKeepalive(); _sendRaw({'type': 'join', 'sessionId': _sessionId, 'peerId': _myPeerId}); @@ -408,12 +382,14 @@ class WatchTogetherPeerService { _reconnectTimer?.cancel(); _reconnectTimer = null; - _stopTimers(); + stopKeepalive(); _channelSubscription?.cancel(); _channelSubscription = null; - await _channel?.sink.close(); + try { + await _channel?.sink.close(); + } catch (_) {} _channel = null; _connectedPeers.clear(); diff --git a/lib/watch_together/services/watch_together_sync_manager.dart b/lib/watch_together/services/watch_together_sync_manager.dart index f4bf9f55..e01539d9 100644 --- a/lib/watch_together/services/watch_together_sync_manager.dart +++ b/lib/watch_together/services/watch_together_sync_manager.dart @@ -28,11 +28,8 @@ class WatchTogetherSyncManager { bool _isRemoteAction = false; // Flag to prevent echo bool _isSyncing = false; // Flag for UI indicator during sync - // Stream subscriptions - StreamSubscription? _playingSubscription; - StreamSubscription? _bufferingSubscription; - StreamSubscription? _rateSubscription; - StreamSubscription? _messageSubscription; + // Stream subscriptions (cancelled together in detachPlayer) + final List> _subscriptions = []; // Position sync timer (host broadcasts position periodically) Timer? _positionSyncTimer; @@ -171,16 +168,11 @@ class WatchTogetherSyncManager { _hasClockOffset = false; _pendingPingTimestamp = null; - _playingSubscription?.cancel(); - _bufferingSubscription?.cancel(); - _rateSubscription?.cancel(); - _messageSubscription?.cancel(); + for (final subscription in _subscriptions) { + subscription.cancel(); + } + _subscriptions.clear(); _positionSyncTimer?.cancel(); - - _playingSubscription = null; - _bufferingSubscription = null; - _rateSubscription = null; - _messageSubscription = null; _positionSyncTimer = null; _player = null; @@ -190,7 +182,7 @@ class WatchTogetherSyncManager { /// Set up subscriptions to player streams void _setupPlayerSubscriptions() { // Listen to playing state changes - _playingSubscription = _player!.streams.playing.listen((isPlaying) async { + _subscriptions.add(_player!.streams.playing.listen((isPlaying) async { if (_isRemoteAction) return; if (isPlaying == _lastKnownPlaying) return; _lastKnownPlaying = isPlaying; @@ -212,10 +204,10 @@ class WatchTogetherSyncManager { if (!isPlaying) _deferredPlay = false; _broadcastPlayPause(isPlaying); - }); + })); // Listen to buffering state changes - _bufferingSubscription = _player!.streams.buffering.listen((isBuffering) async { + _subscriptions.add(_player!.streams.buffering.listen((isBuffering) async { if (_isRemoteAction) return; // Announce ready when we stop buffering for the first time (video loaded) @@ -232,10 +224,10 @@ class WatchTogetherSyncManager { // Broadcast for UI (peer buffering indicators) โ€” no playback control _peerService.broadcast(SyncMessage.buffering(isBuffering, peerId: _peerService.myPeerId)); - }); + })); // Listen to rate changes - _rateSubscription = _player!.streams.rate.listen((rate) { + _subscriptions.add(_player!.streams.rate.listen((rate) { if (_isRemoteAction) return; if (rate != _lastKnownRate) { @@ -244,12 +236,12 @@ class WatchTogetherSyncManager { _peerService.broadcast(SyncMessage.rate(rate, peerId: _peerService.myPeerId)); } } - }); + })); } /// Set up subscription to incoming sync messages void _setupMessageSubscription() { - _messageSubscription = _peerService.onMessageReceived.listen(_handleMessage); + _subscriptions.add(_peerService.onMessageReceived.listen(_handleMessage)); } /// Start periodic position sync (host only) @@ -537,74 +529,54 @@ class WatchTogetherSyncManager { } } - /// Apply remote play command - Future _applyRemotePlay({Duration? position}) async { + /// Shared helper for applying remote actions with proper guarding + Future _applyRemoteAction(Future Function() action) async { if (_player == null) return; - - appLogger.d('WatchTogether: Applying remote PLAY${position != null ? ' at ${position.inSeconds}s' : ''}'); _isRemoteAction = true; try { + await action(); + } on StateError catch (e) { + appLogger.w('WatchTogether: Player disposed during remote action', error: e); + detachPlayer(); + } finally { + _isRemoteAction = false; + } + } + + /// Apply remote play command + Future _applyRemotePlay({Duration? position}) async { + appLogger.d('WatchTogether: Applying remote PLAY${position != null ? ' at ${position.inSeconds}s' : ''}'); + await _applyRemoteAction(() async { if (position != null) { await _player!.seek(position); } await _player!.play(); _lastKnownPlaying = true; - } on StateError catch (e) { - appLogger.w('WatchTogether: Player disposed during remote PLAY', error: e); - detachPlayer(); - } finally { - _isRemoteAction = false; - } + }); } /// Apply remote pause command Future _applyRemotePause() async { - if (_player == null) return; - appLogger.d('WatchTogether: Applying remote PAUSE'); - _isRemoteAction = true; - try { + await _applyRemoteAction(() async { await _player!.pause(); _lastKnownPlaying = false; - } on StateError catch (e) { - appLogger.w('WatchTogether: Player disposed during remote PAUSE', error: e); - detachPlayer(); - } finally { - _isRemoteAction = false; - } + }); } /// Apply remote seek command Future _applyRemoteSeek(Duration position) async { - if (_player == null) return; - appLogger.d('WatchTogether: Applying remote SEEK to ${position.inSeconds}s'); - _isRemoteAction = true; - try { - await _player!.seek(position); - } on StateError catch (e) { - appLogger.w('WatchTogether: Player disposed during remote SEEK', error: e); - detachPlayer(); - } finally { - _isRemoteAction = false; - } + await _applyRemoteAction(() => _player!.seek(position)); } /// Apply remote rate change Future _applyRemoteRate(double rate) async { - if (_player == null) return; - appLogger.d('WatchTogether: Applying remote RATE: $rate'); - _isRemoteAction = true; - try { + await _applyRemoteAction(() async { await _player!.setRate(rate); _lastKnownRate = rate; - } on StateError catch (e) { - appLogger.w('WatchTogether: Player disposed during remote RATE', error: e); - detachPlayer(); - } finally { - _isRemoteAction = false; - } + }); } /// Check and correct position drift diff --git a/lib/widgets/artwork_picker_dialog.dart b/lib/widgets/artwork_picker_dialog.dart index 6ede9cb6..af9cbd5a 100644 --- a/lib/widgets/artwork_picker_dialog.dart +++ b/lib/widgets/artwork_picker_dialog.dart @@ -129,9 +129,7 @@ class _ArtworkPickerDialogState extends State { height: 400, child: _isLoading ? const Center(child: CircularProgressIndicator()) - : (_artworkList == null || _artworkList!.isEmpty) - ? Center(child: Text(t.metadataEdit.noArtworkAvailable)) - : _buildGrid(), + : _buildArtworkContent(), ), actions: [ if (_isApplying) @@ -167,6 +165,13 @@ class _ArtworkPickerDialogState extends State { ); } + Widget _buildArtworkContent() { + if (_artworkList == null || _artworkList!.isEmpty) { + return Center(child: Text(t.metadataEdit.noArtworkAvailable)); + } + return _buildGrid(); + } + Widget _buildGrid() { final crossAxisCount = _isPosters ? 3 : 2; final aspectRatio = _isPosters ? 2.0 / 3.0 : 16.0 / 9.0; diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 8c77bb3a..7ad0ccf6 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -182,6 +182,7 @@ class FocusableRadioListTile extends StatelessWidget { subtitle: subtitle, secondary: secondary, value: value, + // groupValue and onChanged provided by RadioGroup ancestor dense: dense, focusNode: focusNode, autofocus: autofocus, diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index fc12da91..9280c52a 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -170,11 +170,14 @@ class MediaCardState extends State { @override Widget build(BuildContext context) { final settingsProvider = context.watch(); - final viewMode = widget.forceListMode - ? ViewMode.list - : widget.forceGridMode - ? ViewMode.grid - : settingsProvider.viewMode; + final ViewMode viewMode; + if (widget.forceListMode) { + viewMode = ViewMode.list; + } else if (widget.forceGridMode) { + viewMode = ViewMode.grid; + } else { + viewMode = settingsProvider.viewMode; + } final semanticLabel = _buildSemanticLabel(); final localPosterPath = _getLocalPosterPath(context); diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 67a6cdf0..68411725 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -549,15 +549,13 @@ class _OverlaySheetHostState extends State with SingleTickerPr mainAxisSize: MainAxisSize.min, children: [ // M3 drag handle: 32x4, rounded, with 12dp top / 4dp bottom margin - Padding( - padding: const EdgeInsets.only(top: 12, bottom: 4), - child: Container( - width: 32, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: const BorderRadius.all(Radius.circular(2)), - ), + Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 4), + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: const BorderRadius.all(Radius.circular(2)), ), ), Flexible(child: content), diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index f555ad57..1f4abec4 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -41,30 +41,33 @@ class TrackSelectionHelper { } /// Build the "Off" list tile for track selection - static Widget buildOffTile({required bool isSelected, required VoidCallback onTap, FocusNode? focusNode}) { - return _buildSelectableTile(label: 'Off', isSelected: isSelected, onTap: onTap, focusNode: focusNode); + static Widget buildOffTile({required BuildContext context, required bool isSelected, required VoidCallback onTap, FocusNode? focusNode}) { + return _buildSelectableTile(context: context, label: 'Off', isSelected: isSelected, onTap: onTap, focusNode: focusNode); } /// Build a track selection list tile static Widget buildTrackTile({ + required BuildContext context, required String label, required bool isSelected, required VoidCallback onTap, FocusNode? focusNode, }) { - return _buildSelectableTile(label: label, isSelected: isSelected, onTap: onTap, focusNode: focusNode); + return _buildSelectableTile(context: context, label: label, isSelected: isSelected, onTap: onTap, focusNode: focusNode); } static Widget _buildSelectableTile({ + required BuildContext context, required String label, required bool isSelected, required VoidCallback onTap, FocusNode? focusNode, }) { + final primaryColor = Theme.of(context).colorScheme.primary; return FocusableListTile( focusNode: focusNode, - title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : null)), - trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null, + title: Text(label, style: TextStyle(color: isSelected ? primaryColor : null)), + trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primaryColor) : null, onTap: onTap, ); } diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 88556232..3a6de3db 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -111,9 +111,9 @@ class _ChapterSheetState extends State { if (isCurrentChapter) Positioned.fill( child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(4)), - border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)), ), ), ), @@ -124,19 +124,19 @@ class _ChapterSheetState extends State { title: Text( chapter.label, style: TextStyle( - color: isCurrentChapter ? Colors.blue : null, + color: isCurrentChapter ? Theme.of(context).colorScheme.primary : null, fontWeight: isCurrentChapter ? FontWeight.bold : FontWeight.normal, ), ), subtitle: Text( formatDurationTimestamp(chapter.startTime), style: TextStyle( - color: isCurrentChapter ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted, + color: isCurrentChapter ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) : tokens(context).textMuted, fontSize: 12, ), ), trailing: isCurrentChapter - ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) + ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: Theme.of(context).colorScheme.primary) : null, onTap: () { widget.player.seek(chapter.startTime); diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index ec65b752..a3000718 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -42,12 +42,13 @@ class QueueSheet extends StatelessWidget { final item = items[index]; final isCurrent = item.playQueueItemID == currentItemID; + final primaryColor = Theme.of(context).colorScheme.primary; return FocusableListTile( leading: _buildThumbnail(context, item, isCurrent), title: Text( item.title, style: TextStyle( - color: isCurrent ? Colors.blue : null, + color: isCurrent ? primaryColor : null, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, ), maxLines: 1, @@ -56,13 +57,13 @@ class QueueSheet extends StatelessWidget { subtitle: Text( _buildSubtitle(item), style: TextStyle( - color: isCurrent ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted, + color: isCurrent ? primaryColor.withValues(alpha: 0.7) : tokens(context).textMuted, fontSize: 12, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - trailing: isCurrent ? const AppIcon(Symbols.play_circle_rounded, fill: 1, color: Colors.blue) : null, + trailing: isCurrent ? AppIcon(Symbols.play_circle_rounded, fill: 1, color: primaryColor) : null, onTap: () { onItemSelected(item); OverlaySheetController.of(context).close(); @@ -103,9 +104,9 @@ class QueueSheet extends StatelessWidget { if (isCurrent) Positioned.fill( child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(4)), - border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)), ), ), ), diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 02c11693..104c37e5 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -155,6 +155,7 @@ class _AudioColumn extends StatelessWidget { index: index, ); return TrackSelectionHelper.buildTrackTile( + context: context, label: label, isSelected: track.id == selectedId, onTap: () { @@ -200,6 +201,7 @@ class _SubtitleColumn extends StatelessWidget { itemBuilder: (context, index) { if (index == 0) { return TrackSelectionHelper.buildOffTile( + context: context, isSelected: isOffSelected, onTap: () { player.selectSubtitleTrack(SubtitleTrack.off); @@ -217,6 +219,7 @@ class _SubtitleColumn extends StatelessWidget { index: index - 1, ); return TrackSelectionHelper.buildTrackTile( + context: context, label: label, isSelected: !isOffSelected && track.id == selectedSub.id, onTap: () { diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index b2d104c1..c447b3d2 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../models/plex_media_version.dart'; -import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../helpers/track_selection_helper.dart'; import 'base_video_control_sheet.dart'; /// Bottom sheet for selecting video version @@ -35,9 +34,10 @@ class _VersionSheetState extends State { final version = widget.availableVersions[index]; final isSelected = index == widget.selectedMediaIndex; - return FocusableListTile( - title: Text(version.displayLabel, style: TextStyle(color: isSelected ? Colors.blue : null)), - trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null, + return TrackSelectionHelper.buildTrackTile( + context: context, + label: version.displayLabel, + isSelected: isSelected, onTap: () { OverlaySheetController.of(context).close(); widget.onVersionSelected(index); diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 76cbb5d8..1bf81b69 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -524,9 +524,10 @@ class _VideoSettingsSheetState extends State { final isSelected = (currentRate - speed).abs() < 0.01; final label = speed == 1.0 ? 'Normal' : '${speed.toStringAsFixed(2)}x'; - return ListTile( - title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : null)), - trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null, + final primary = Theme.of(context).colorScheme.primary; + return FocusableListTile( + title: Text(label, style: TextStyle(color: isSelected ? primary : null)), + trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null, onTap: () async { widget.player.setRate(speed); // Save as default playback speed @@ -641,9 +642,10 @@ class _VideoSettingsSheetState extends State { final isSelected = device.name == currentDevice.name; final label = device.description.isEmpty ? device.name : device.description; - return ListTile( - title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : null)), - trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null, + final primary = Theme.of(context).colorScheme.primary; + return FocusableListTile( + title: Text(label, style: TextStyle(color: isSelected ? primary : null)), + trailing: isSelected ? AppIcon(Symbols.check_rounded, fill: 1, color: primary) : null, onTap: () { widget.player.setAudioDevice(device); OverlaySheetController.of(context).close(); diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 288083af..b652cd7f 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -2183,7 +2183,7 @@ class _PlexVideoControlsState extends State with WindowListen flex: (_autoSkipProgress * 100).round(), child: Container( decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.2), + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(tokens(context).radiusSm), ), ), diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index e7e72d8f..586308ce 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -91,11 +91,11 @@ class _ContentStripState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (_hasBothTabs) _buildTabBar(context), + if (_hasBothTabs) _buildTabBar(), const SizedBox(height: 8), SizedBox( height: 106, - child: _activeTab == _StripTab.chapters ? _buildChapterStrip(context) : _buildQueueStrip(context), + child: _activeTab == _StripTab.chapters ? _buildChapterStrip() : _buildQueueStrip(), ), ], ), @@ -103,18 +103,18 @@ class _ContentStripState extends State { ); } - Widget _buildTabBar(BuildContext context) { + Widget _buildTabBar() { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildTabLabel(context, t.videoControls.chapters, _StripTab.chapters), + _buildTabLabel(t.videoControls.chapters, _StripTab.chapters), const SizedBox(width: 24), - _buildTabLabel(context, t.videoControls.queue, _StripTab.queue), + _buildTabLabel(t.videoControls.queue, _StripTab.queue), ], ); } - Widget _buildTabLabel(BuildContext context, String label, _StripTab tab) { + Widget _buildTabLabel(String label, _StripTab tab) { final isActive = _activeTab == tab; return GestureDetector( onTap: () => setState(() => _activeTab = tab), @@ -133,14 +133,14 @@ class _ContentStripState extends State { Container( height: 2, width: 40, - color: isActive ? Colors.blue : Colors.transparent, + color: isActive ? Theme.of(context).colorScheme.primary : Colors.transparent, ), ], ), ); } - Widget _buildChapterStrip(BuildContext context) { + Widget _buildChapterStrip() { return StreamBuilder( stream: widget.player.streams.position, initialData: widget.player.state.position, @@ -192,7 +192,7 @@ class _ContentStripState extends State { width: 120, height: 68, fit: BoxFit.cover, - errorWidget: (_, __, ___) => + errorWidget: (_, _, _) => const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), ) : null, @@ -206,7 +206,7 @@ class _ContentStripState extends State { ); } - Widget _buildQueueStrip(BuildContext context) { + Widget _buildQueueStrip() { return Consumer( builder: (context, playbackState, _) { final items = playbackState.loadedItems; @@ -246,7 +246,7 @@ class _ContentStripState extends State { width: 120, height: 68, fit: BoxFit.cover, - errorWidget: (_, __, ___) => + errorWidget: (_, _, _) => const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), ) : null, @@ -304,9 +304,9 @@ class _ContentStripState extends State { if (isCurrent) Positioned.fill( child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(6)), - border: Border.fromBorderSide(BorderSide(color: Colors.blue, width: 2)), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(6)), + border: Border.fromBorderSide(BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)), ), ), ), @@ -318,7 +318,7 @@ class _ContentStripState extends State { Text( title, style: TextStyle( - color: isCurrent ? Colors.blue : Colors.white, + color: isCurrent ? Theme.of(context).colorScheme.primary : Colors.white, fontSize: 11, fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal, ), @@ -329,7 +329,7 @@ class _ContentStripState extends State { Text( subtitle, style: TextStyle( - color: isCurrent ? Colors.blue.withValues(alpha: 0.7) : tokens(context).textMuted, + color: isCurrent ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7) : tokens(context).textMuted, fontSize: 10, ), maxLines: 1, diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index 27705b72..478d3b87 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -298,7 +298,7 @@ class PerformanceStatsService { decoderFrameDropCount: _parseInt(results[14]), cacheDuration: _parseDouble(results[15]), // Video-dependent properties - displayFps: _parseDouble(videoResults?[0]), + displayFps: _parseDouble(videoResults?.first), pixelformat: videoResults?[1], hwPixelformat: videoResults?[2], colormatrix: videoResults?[3], diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 7940d60b..a7e3bc62 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -236,7 +236,7 @@ class _SyncOffsetControlState extends State { min: _sliderMin, max: _sliderMax, divisions: _sliderDivisions, - activeColor: Colors.blue, + activeColor: Theme.of(context).colorScheme.primary, inactiveColor: Theme.of(context).colorScheme.outlineVariant, onChanged: (value) { setState(() { @@ -328,7 +328,7 @@ class _SyncOffsetControlState extends State { min: _sliderMin, max: _sliderMax, divisions: _sliderDivisions, - activeColor: Colors.blue, + activeColor: Theme.of(context).colorScheme.primary, inactiveColor: Theme.of(context).colorScheme.outlineVariant, onChanged: (value) { setState(() {