From 18c322f66ab86b81aaacbc33c30c71a548b01638 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 13 Dec 2025 00:16:50 +0100 Subject: [PATCH] refactor: navigation/offline handling and other improvments --- lib/database/app_database.dart | 26 + lib/focus/dpad_navigator.dart | 69 +-- lib/mixins/refreshable.dart | 20 + lib/models/plex_metadata.dart | 46 ++ lib/navigation/navigation_tabs.dart | 100 ++++ lib/providers/offline_mode_provider.dart | 22 +- lib/providers/offline_watch_provider.dart | 21 +- .../base_media_list_detail_screen.dart | 18 +- lib/screens/discover_screen.dart | 4 +- .../libraries/adaptive_media_grid.dart | 23 +- lib/screens/libraries/libraries_screen.dart | 2 +- .../libraries/tabs/library_browse_tab.dart | 3 +- lib/screens/main_screen.dart | 119 ++-- lib/screens/search_screen.dart | 3 +- lib/services/data_aggregation_service.dart | 105 ++-- lib/services/download_storage_service.dart | 137 +++-- lib/services/media_controls_manager.dart | 16 +- lib/services/multi_server_manager.dart | 144 +++-- lib/services/offline_watch_sync_service.dart | 161 ++++-- lib/services/play_queue_launcher.dart | 40 +- .../playback_initialization_service.dart | 48 +- lib/services/playback_progress_tracker.dart | 5 + lib/services/plex_client.dart | 462 +++++++++------- lib/services/settings_service.dart | 510 ++++++++---------- lib/services/storage_service.dart | 123 +++-- lib/services/track_selection_service.dart | 104 ++-- lib/services/update_service.dart | 54 +- .../collection_playlist_play_helper.dart | 32 -- lib/utils/grid_cross_axis_extent.dart | 51 -- lib/utils/grid_size_calculator.dart | 51 ++ lib/utils/layout_constants.dart | 40 +- lib/utils/library_refresh_notifier.dart | 52 +- lib/utils/log_redaction_manager.dart | 90 +++- lib/utils/media_navigation_helper.dart | 107 +++- lib/utils/sliver_adaptive_media_builder.dart | 27 +- lib/utils/smart_deletion_handler.dart | 16 +- lib/widgets/focused_scroll_scaffold.dart | 1 - lib/widgets/hub_section.dart | 7 +- lib/widgets/media_card.dart | 136 ++--- lib/widgets/media_context_menu.dart | 68 ++- lib/widgets/media_grid_delegate.dart | 25 +- lib/widgets/side_navigation_rail.dart | 310 ++++++----- 42 files changed, 1891 insertions(+), 1507 deletions(-) create mode 100644 lib/navigation/navigation_tabs.dart delete mode 100644 lib/utils/collection_playlist_play_helper.dart delete mode 100644 lib/utils/grid_cross_axis_extent.dart diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 6e29ce56..7dd01486 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -69,6 +69,32 @@ class AppDatabase extends _$AppDatabase { .getSingleOrNull(); } + /// Get the latest actions for multiple items in a single query + /// + /// Returns a map of globalKey -> latest action for each key. + /// Keys with no actions will not be present in the returned map. + Future> getLatestWatchActionsForKeys( + Set globalKeys, + ) async { + if (globalKeys.isEmpty) return {}; + + // Query all actions for the given keys + final allActions = + await (select(offlineWatchProgress) + ..where((t) => t.globalKey.isIn(globalKeys)) + ..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])) + .get(); + + // Group by globalKey and take the latest (first due to ordering) + final result = {}; + for (final action in allActions) { + // Only keep the first (latest) action for each key + result.putIfAbsent(action.globalKey, () => action); + } + + return result; + } + /// Insert or update a progress action (merges with existing) Future upsertProgressAction({ required String serverId, diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index dde96f5f..676a1bb6 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -1,54 +1,55 @@ import 'package:flutter/services.dart'; +/// Shared sets for keyboard key categories. +final _dpadDirectionKeys = { + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, +}; + +final _selectKeys = { + LogicalKeyboardKey.select, + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + LogicalKeyboardKey.gameButtonA, +}; + +final _backKeys = { + LogicalKeyboardKey.escape, + LogicalKeyboardKey.goBack, + LogicalKeyboardKey.browserBack, + LogicalKeyboardKey.gameButtonB, +}; + +final _contextMenuKeys = { + LogicalKeyboardKey.contextMenu, + LogicalKeyboardKey.gameButtonX, +}; + /// Extension methods for checking D-pad related keys. extension DpadKeyExtension on LogicalKeyboardKey { /// Whether this key is a D-pad directional key. - bool get isDpadDirection { - return this == LogicalKeyboardKey.arrowUp || - this == LogicalKeyboardKey.arrowDown || - this == LogicalKeyboardKey.arrowLeft || - this == LogicalKeyboardKey.arrowRight; - } + bool get isDpadDirection => _dpadDirectionKeys.contains(this); /// Whether this key is a select/activate key. - bool get isSelectKey { - return this == LogicalKeyboardKey.select || - this == LogicalKeyboardKey.enter || - this == LogicalKeyboardKey.numpadEnter || - this == LogicalKeyboardKey.gameButtonA; - } + bool get isSelectKey => _selectKeys.contains(this); /// Whether this key is a back/cancel key. - bool get isBackKey { - return this == LogicalKeyboardKey.escape || - this == LogicalKeyboardKey.goBack || - this == LogicalKeyboardKey.browserBack || - this == LogicalKeyboardKey.gameButtonB; - } + bool get isBackKey => _backKeys.contains(this); /// Whether this key is a context menu key. - bool get isContextMenuKey { - return this == LogicalKeyboardKey.contextMenu || - this == LogicalKeyboardKey.gameButtonX; - } + bool get isContextMenuKey => _contextMenuKeys.contains(this); /// Whether this key moves focus left. - bool get isLeftKey { - return this == LogicalKeyboardKey.arrowLeft; - } + bool get isLeftKey => this == LogicalKeyboardKey.arrowLeft; /// Whether this key moves focus right. - bool get isRightKey { - return this == LogicalKeyboardKey.arrowRight; - } + bool get isRightKey => this == LogicalKeyboardKey.arrowRight; /// Whether this key moves focus up. - bool get isUpKey { - return this == LogicalKeyboardKey.arrowUp; - } + bool get isUpKey => this == LogicalKeyboardKey.arrowUp; /// Whether this key moves focus down. - bool get isDownKey { - return this == LogicalKeyboardKey.arrowDown; - } + bool get isDownKey => this == LogicalKeyboardKey.arrowDown; } diff --git a/lib/mixins/refreshable.dart b/lib/mixins/refreshable.dart index 065e83e4..709d6039 100644 --- a/lib/mixins/refreshable.dart +++ b/lib/mixins/refreshable.dart @@ -1,3 +1,23 @@ mixin Refreshable { void refresh(); } + +/// Mixin for screens that support full refresh (clearing all cached data) +mixin FullRefreshable { + void fullRefresh(); +} + +/// Mixin for screens with focusable tab content +mixin FocusableTab { + void focusActiveTabIfReady(); +} + +/// Mixin for screens with focusable search input +mixin SearchInputFocusable { + void focusSearchInput(); +} + +/// Mixin for screens that can load a specific library by key +mixin LibraryLoadable { + void loadLibraryByKey(String libraryGlobalKey); +} diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 34ba2beb..928b255c 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -5,6 +5,34 @@ import 'plex_role.dart'; part 'plex_metadata.g.dart'; +/// Media type enum for type-safe media type handling +enum PlexMediaType { + movie, + show, + season, + episode, + artist, + album, + track, + collection, + playlist, + clip, + photo, + unknown; + + /// Whether this type represents video content + bool get isVideo => this == movie || this == episode || this == clip; + + /// Whether this type is part of a show hierarchy + bool get isShowRelated => this == show || this == season || this == episode; + + /// Whether this type represents music content + bool get isMusic => this == artist || this == album || this == track; + + /// Whether this type can be played directly + bool get isPlayable => isVideo || this == track; +} + @JsonSerializable() class PlexMetadata with MultiServerFields { final String ratingKey; @@ -61,6 +89,24 @@ class PlexMetadata with MultiServerFields { /// Global unique identifier across all servers (serverId:ratingKey) String get globalKey => serverId != null ? '$serverId:$ratingKey' : ratingKey; + /// Parsed media type enum for type-safe comparisons + PlexMediaType get mediaType { + return switch (type.toLowerCase()) { + 'movie' => PlexMediaType.movie, + 'show' => PlexMediaType.show, + 'season' => PlexMediaType.season, + 'episode' => PlexMediaType.episode, + 'artist' => PlexMediaType.artist, + 'album' => PlexMediaType.album, + 'track' => PlexMediaType.track, + 'collection' => PlexMediaType.collection, + 'playlist' => PlexMediaType.playlist, + 'clip' => PlexMediaType.clip, + 'photo' => PlexMediaType.photo, + _ => PlexMediaType.unknown, + }; + } + PlexMetadata({ required this.ratingKey, required this.key, diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart new file mode 100644 index 00000000..3ea61411 --- /dev/null +++ b/lib/navigation/navigation_tabs.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/widgets/app_icon.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../i18n/strings.g.dart'; + +/// Navigation tab identifiers +enum NavigationTabId { + discover, + libraries, + search, + downloads, + settings, +} + +/// Represents a navigation tab with its configuration +class NavigationTab { + final NavigationTabId id; + final bool onlineOnly; + final IconData icon; + final String Function() getLabel; + + const NavigationTab({ + required this.id, + required this.onlineOnly, + required this.icon, + required this.getLabel, + }); + + NavigationDestination toDestination() { + return NavigationDestination( + icon: AppIcon(icon, fill: 1), + selectedIcon: AppIcon(icon, fill: 1), + label: getLabel(), + ); + } + + /// Get the index for a tab ID in the visible tabs list + static int indexFor(NavigationTabId id, {required bool isOffline}) { + final tabs = getVisibleTabs(isOffline: isOffline); + return tabs.indexWhere((tab) => tab.id == id); + } + + /// Get tabs filtered by offline mode + static List getVisibleTabs({required bool isOffline}) { + return allNavigationTabs + .where((tab) => !isOffline || !tab.onlineOnly) + .toList(); + } + + /// Check if a visual index corresponds to a specific tab ID + static bool isTabAtIndex( + NavigationTabId id, + int index, { + required bool isOffline, + }) { + return indexFor(id, isOffline: isOffline) == index; + } +} + +// Label getters (must be top-level for const constructor) +String _getHomeLabel() => t.navigation.home; +String _getLibrariesLabel() => t.navigation.libraries; +String _getSearchLabel() => t.navigation.search; +String _getDownloadsLabel() => t.navigation.downloads; +String _getSettingsLabel() => t.navigation.settings; + +/// All navigation tabs in display order +const allNavigationTabs = [ + NavigationTab( + id: NavigationTabId.discover, + onlineOnly: true, + icon: Symbols.home_rounded, + getLabel: _getHomeLabel, + ), + NavigationTab( + id: NavigationTabId.libraries, + onlineOnly: true, + icon: Symbols.video_library_rounded, + getLabel: _getLibrariesLabel, + ), + NavigationTab( + id: NavigationTabId.search, + onlineOnly: true, + icon: Symbols.search_rounded, + getLabel: _getSearchLabel, + ), + NavigationTab( + id: NavigationTabId.downloads, + onlineOnly: false, + icon: Symbols.download_rounded, + getLabel: _getDownloadsLabel, + ), + NavigationTab( + id: NavigationTabId.settings, + onlineOnly: false, + icon: Symbols.settings_rounded, + getLabel: _getSettingsLabel, + ), +]; diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index b1c120f3..4c4159b7 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -26,16 +26,22 @@ class OfflineModeProvider extends ChangeNotifier { /// Whether at least one Plex server is reachable bool get hasServerConnection => _hasServerConnection; + /// Updates network and server connection flags + Future _updateConnectionFlags() async { + final connectivityResult = await Connectivity().checkConnectivity(); + _hasNetworkConnection = !connectivityResult.contains( + ConnectivityResult.none, + ); + _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty; + } + /// Initialize the provider and start monitoring Future initialize() async { if (_isInitialized) return; _isInitialized = true; // Check initial connectivity - final connectivityResult = await Connectivity().checkConnectivity(); - _hasNetworkConnection = !connectivityResult.contains( - ConnectivityResult.none, - ); + await _updateConnectionFlags(); // Monitor connectivity changes _connectivitySubscription = Connectivity().onConnectivityChanged.listen(( @@ -59,18 +65,12 @@ class OfflineModeProvider extends ChangeNotifier { } }); - // Check initial server status - _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty; notifyListeners(); } /// Force a refresh of connectivity status Future refresh() async { - final connectivityResult = await Connectivity().checkConnectivity(); - _hasNetworkConnection = !connectivityResult.contains( - ConnectivityResult.none, - ); - _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty; + await _updateConnectionFlags(); notifyListeners(); } diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index 7943b949..213fbbe1 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -120,9 +120,16 @@ class OfflineWatchProvider extends ChangeNotifier { final episodes = _getSortedEpisodes(showRatingKey); if (episodes.isEmpty) return null; + // Batch fetch all watch statuses in a single query + final globalKeys = episodes.map((e) => e.globalKey).toSet(); + final localStatuses = + await _syncService.getLocalWatchStatusesBatched(globalKeys); + // Find first unwatched episode for (final episode in episodes) { - final watched = await isWatched(episode.globalKey); + final localStatus = localStatuses[episode.globalKey]; + final watched = + localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false; if (!watched) { return episode; } @@ -182,15 +189,25 @@ class OfflineWatchProvider extends ChangeNotifier { /// Get downloaded episodes for a show with their watch status. /// /// Returns a list of (episode, isWatched) pairs. + /// Uses batched database query for efficiency. Future> getEpisodesWithWatchStatus(String showRatingKey) async { final episodes = _downloadProvider.getDownloadedEpisodesForShow( showRatingKey, ); + if (episodes.isEmpty) return []; + + // Batch fetch all watch statuses in a single query + final globalKeys = episodes.map((e) => e.globalKey).toSet(); + final localStatuses = + await _syncService.getLocalWatchStatusesBatched(globalKeys); + final results = <(PlexMetadata, bool)>[]; for (final episode in episodes) { - final watched = await isWatched(episode.globalKey); + final localStatus = localStatuses[episode.globalKey]; + final watched = + localStatus ?? _downloadProvider.getMetadata(episode.globalKey)?.isWatched ?? false; results.add((episode, watched)); } diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 3a041dd3..8f3f4ae9 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -6,7 +6,8 @@ import '../../services/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; -import '../utils/collection_playlist_play_helper.dart'; +import '../services/play_queue_launcher.dart'; +import '../models/plex_playlist.dart'; import '../utils/app_logger.dart'; import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; @@ -97,12 +98,23 @@ abstract class BaseMediaListDetailScreen } final client = _getClientForMediaItem(); + final item = mediaItem; - await playCollectionOrPlaylist( + final launcher = PlayQueueLauncher( context: context, client: client, - item: mediaItem, + serverId: item is PlexMetadata + ? item.serverId + : (item as PlexPlaylist).serverId, + serverName: item is PlexMetadata + ? item.serverName + : (item as PlexPlaylist).serverName, + ); + + await launcher.launchFromCollectionOrPlaylist( + item: item, shuffle: shuffle, + showLoadingIndicator: false, ); } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 19ac3d62..7cd368d5 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -39,7 +39,7 @@ class DiscoverScreen extends StatefulWidget { } class _DiscoverScreenState extends State - with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin { + with Refreshable, FullRefreshable, ItemUpdatable, SingleTickerProviderStateMixin { static const Duration _heroAutoScrollDuration = Duration(seconds: 8); @override @@ -1184,7 +1184,7 @@ class _DiscoverScreenState extends State final isEpisode = heroItem.type.toLowerCase() == 'episode'; final showName = heroItem.grandparentTitle ?? heroItem.title; final screenWidth = MediaQuery.of(context).size.width; - final isLargeScreen = screenWidth > 800; + final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth); // Determine content type label for chip final contentTypeLabel = heroItem.type.toLowerCase() == 'movie' diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 80f194e4..df2aaf22 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../providers/settings_provider.dart'; import '../../services/settings_service.dart' show ViewMode, LibraryDensity; import '../../utils/grid_size_calculator.dart'; +import '../../utils/layout_constants.dart'; /// A widget that automatically switches between grid and list view /// based on user settings, providing a consistent layout pattern @@ -20,10 +21,10 @@ class AdaptiveMediaGrid extends StatelessWidget { final VoidCallback? onRefresh; /// Optional padding around the grid/list - final EdgeInsets padding; + final EdgeInsets? padding; /// Child aspect ratio for grid items (width / height) - final double childAspectRatio; + final double? childAspectRatio; /// Optional focus node for the first item (for programmatic focus) final FocusNode? firstItemFocusNode; @@ -36,8 +37,8 @@ class AdaptiveMediaGrid extends StatelessWidget { required this.items, required this.itemBuilder, this.onRefresh, - this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), - this.childAspectRatio = 2 / 3.3, + this.padding, + this.childAspectRatio, this.firstItemFocusNode, this.onBack, }); @@ -61,24 +62,28 @@ class AdaptiveMediaGrid extends StatelessWidget { ViewMode viewMode, LibraryDensity density, ) { + final effectivePadding = padding ?? GridLayoutConstants.gridPadding; + final effectiveAspectRatio = + childAspectRatio ?? GridLayoutConstants.posterAspectRatio; + if (viewMode == ViewMode.list) { return ListView.builder( - padding: padding, + padding: effectivePadding, itemCount: items.length, itemBuilder: (context, index) => itemBuilder(context, items[index], index), ); } else { return GridView.builder( - padding: padding, + padding: effectivePadding, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( context, density, ), - childAspectRatio: childAspectRatio, - crossAxisSpacing: 0, - mainAxisSpacing: 0, + childAspectRatio: effectiveAspectRatio, + crossAxisSpacing: GridLayoutConstants.crossAxisSpacing, + mainAxisSpacing: GridLayoutConstants.mainAxisSpacing, ), itemCount: items.length, itemBuilder: (context, index) => diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 0c9a0bd6..9921aca9 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -40,7 +40,7 @@ class LibrariesScreen extends StatefulWidget { } class _LibrariesScreenState extends State - with Refreshable, ItemUpdatable, SingleTickerProviderStateMixin { + with Refreshable, FullRefreshable, FocusableTab, LibraryLoadable, ItemUpdatable, SingleTickerProviderStateMixin { @override PlexClient get client { final multiServerProvider = Provider.of( diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 69cb0abe..a29a0c67 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -21,7 +21,6 @@ import '../error_state_widget.dart'; import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart' show ViewMode; import '../../../mixins/item_updatable.dart'; -import '../../../mixins/library_tab_state.dart'; import '../../../i18n/strings.g.dart'; import 'base_library_tab.dart'; @@ -45,7 +44,7 @@ class LibraryBrowseTab extends BaseLibraryTab { class _LibraryBrowseTabState extends BaseLibraryTabState - with ItemUpdatable, LibraryTabStateMixin, LibraryTabFocusMixin { + with ItemUpdatable, LibraryTabFocusMixin { @override PlexClient get client => getClientForLibrary(); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index b8e40170..357758aa 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -10,6 +10,7 @@ import '../utils/provider_extensions.dart'; import '../utils/platform_detector.dart'; import '../main.dart'; import '../mixins/refreshable.dart'; +import '../navigation/navigation_tabs.dart'; import '../providers/multi_server_provider.dart'; import '../providers/server_state_provider.dart'; import '../providers/hidden_libraries_provider.dart'; @@ -166,20 +167,22 @@ class _MainScreenState extends State with RouteAware { ]; } - int _normalizeIndexForMode(int current, bool offline) { - if (offline) { - // Only two tabs exist offline: 0 = Downloads, 1 = Settings - if (current <= 0) return 0; - if (current == 1) return 1; - return 0; - } + /// Normalize tab index when switching between offline/online modes. + /// Preserves the current tab if it exists in the new mode, otherwise defaults to first tab. + int _normalizeIndexForMode(int currentIndex, bool wasOffline, bool isOffline) { + if (wasOffline == isOffline) return currentIndex; - // Map offline indices back to online equivalents when reconnecting - if (current == 0) return 3; // Downloads tab - if (current == 1) return 4; // Settings tab - if (current < 0) return 0; - if (current > 4) return 0; - return current; + final oldTabs = _getVisibleTabs(wasOffline); + final newTabs = _getVisibleTabs(isOffline); + + // Get the tab ID at the current index (or first tab if out of bounds) + final currentTabId = currentIndex >= 0 && currentIndex < oldTabs.length + ? oldTabs[currentIndex].id + : oldTabs.first.id; + + // Find the same tab in the new mode's tab list + final newIndex = newTabs.indexWhere((tab) => tab.id == currentTabId); + return newIndex >= 0 ? newIndex : 0; } void _handleOfflineStatusChanged() { @@ -187,11 +190,12 @@ class _MainScreenState extends State with RouteAware { if (newOffline == _isOffline) return; + final wasOffline = _isOffline; setState(() { _isOffline = newOffline; _screens = _buildScreens(_isOffline); _selectedLibraryGlobalKey = _isOffline ? null : _selectedLibraryGlobalKey; - _currentIndex = _normalizeIndexForMode(_currentIndex, _isOffline); + _currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline); }); // Refresh sidebar focus after rebuilding navigation @@ -223,9 +227,8 @@ class _MainScreenState extends State with RouteAware { // When content regains focus while on Libraries, retry focusing the active tab if (_currentIndex == 1) { WidgetsBinding.instance.addPostFrameCallback((_) { - final librariesState = _librariesKey.currentState; - if (librariesState != null) { - (librariesState as dynamic).focusActiveTabIfReady(); + if (_librariesKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); } }); } @@ -323,21 +326,18 @@ class _MainScreenState extends State with RouteAware { appLogger.d('Cleared all provider states for profile switch'); // Full refresh discover screen (reload all content for new profile) - final discoverState = _discoverKey.currentState; - if (discoverState != null) { - (discoverState as dynamic).fullRefresh(); + if (_discoverKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); } // Full refresh libraries screen (clear filters and reload for new profile) - final librariesState = _librariesKey.currentState; - if (librariesState != null) { - (librariesState as dynamic).fullRefresh(); + if (_librariesKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); } // Full refresh search screen (clear search for new profile) - final searchState = _searchKey.currentState; - if (searchState != null) { - (searchState as dynamic).fullRefresh(); + if (_searchKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); } } @@ -356,16 +356,14 @@ class _MainScreenState extends State with RouteAware { } // Ensure the libraries screen applies focus when brought into view if (index == 1 && previousIndex != 1) { - final librariesState = _librariesKey.currentState; - if (librariesState != null) { - (librariesState as dynamic).focusActiveTabIfReady(); + if (_librariesKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); } } // Focus search input when selecting Search tab if (index == 2) { - final searchState = _searchKey.currentState; - if (searchState != null) { - (searchState as dynamic).focusSearchInput(); + if (_searchKey.currentState case final SearchInputFocusable searchable) { + searchable.focusSearchInput(); } } } @@ -377,57 +375,22 @@ class _MainScreenState extends State with RouteAware { _currentIndex = 1; // Switch to Libraries tab }); // Tell LibrariesScreen to load this library - final librariesState = _librariesKey.currentState; - if (librariesState != null) { - (librariesState as dynamic).loadLibraryByKey(libraryGlobalKey); - (librariesState as dynamic).focusActiveTabIfReady(); + if (_librariesKey.currentState case final LibraryLoadable loadable) { + loadable.loadLibraryByKey(libraryGlobalKey); } + if (_librariesKey.currentState case final FocusableTab focusable) { + focusable.focusActiveTabIfReady(); + } + } + + /// Get navigation tabs filtered by offline mode + List _getVisibleTabs(bool isOffline) { + return NavigationTab.getVisibleTabs(isOffline: isOffline); } /// Build navigation destinations for bottom navigation bar. List _buildNavDestinations(bool isOffline) { - if (isOffline) { - return [ - NavigationDestination( - icon: const AppIcon(Symbols.download_rounded, fill: 1), - selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1), - label: t.navigation.downloads, - ), - NavigationDestination( - icon: const AppIcon(Symbols.settings_rounded, fill: 1), - selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1), - label: t.navigation.settings, - ), - ]; - } - - return [ - NavigationDestination( - icon: const AppIcon(Symbols.home_rounded, fill: 1), - selectedIcon: const AppIcon(Symbols.home_rounded, fill: 1), - label: t.navigation.home, - ), - NavigationDestination( - icon: const AppIcon(Symbols.video_library_rounded, fill: 1), - selectedIcon: const AppIcon(Symbols.video_library_rounded, fill: 1), - label: t.navigation.libraries, - ), - NavigationDestination( - icon: const AppIcon(Symbols.search_rounded, fill: 1), - selectedIcon: const AppIcon(Symbols.search_rounded, fill: 1), - label: t.navigation.search, - ), - NavigationDestination( - icon: const AppIcon(Symbols.download_rounded, fill: 1), - selectedIcon: const AppIcon(Symbols.download_rounded, fill: 1), - label: t.navigation.downloads, - ), - NavigationDestination( - icon: const AppIcon(Symbols.settings_rounded, fill: 1), - selectedIcon: const AppIcon(Symbols.settings_rounded, fill: 1), - label: t.navigation.settings, - ), - ]; + return _getVisibleTabs(isOffline).map((tab) => tab.toDestination()).toList(); } @override diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 42640718..28729830 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -21,7 +21,8 @@ class SearchScreen extends StatefulWidget { State createState() => _SearchScreenState(); } -class _SearchScreenState extends State with Refreshable { +class _SearchScreenState extends State + with Refreshable, FullRefreshable, SearchInputFocusable { final _searchController = TextEditingController(); final _searchFocusNode = FocusNode(debugLabel: 'SearchInput'); List _searchResults = []; diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 24f68217..c6c7bc02 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -85,48 +85,25 @@ class DataAggregationService { return _cachedLibrariesByServer!; } - final clients = _serverManager.onlineClients; - - if (clients.isEmpty) { - appLogger.w('No online servers available for fetching libraries'); - return {}; - } - - appLogger.d('Fetching libraries from ${clients.length} servers'); - - final libraryFutures = clients.entries.map((entry) async { - final serverId = entry.key; - final client = entry.value; - try { - final libraries = await client.getLibraries(); - appLogger.d( - 'Fetched ${libraries.length} libraries for server $serverId', - ); - return MapEntry(serverId, libraries); - } catch (e) { - appLogger.e( - 'Failed to fetch libraries from server $serverId', - error: e, - ); - return MapEntry(serverId, []); - } - }); - - final libraryResults = await Future.wait(libraryFutures); - - final librariesByServer = Map.fromEntries(libraryResults); - final totalLibraries = libraryResults.fold( - 0, - (sum, entry) => sum + entry.value.length, + final librariesByServer = await _perServerGrouped( + operationName: 'fetching libraries', + operation: (serverId, client, server) async { + return await client.getLibraries(); + }, ); // Cache the results _cachedLibrariesByServer = librariesByServer; _librariesCacheTime = DateTime.now(); - appLogger.d( - 'Fetched $totalLibraries libraries from ${clients.length} servers', + final totalLibraries = librariesByServer.values.fold( + 0, + (sum, libs) => sum + libs.length, ); + appLogger.d( + 'Fetched $totalLibraries libraries from ${librariesByServer.length} servers', + ); + return librariesByServer; } @@ -368,4 +345,62 @@ class DataAggregationService { return allResults; } + + /// Higher-order helper for per-server fan-out operations that groups results by server + /// + /// Similar to [_perServer] but returns a Map with results grouped by serverId + /// instead of flattening into a single list. + /// + /// Type parameter `T` is the item type returned by the operation + /// [operationName] is used for logging (e.g., "fetching libraries") + /// [operation] is the async function to run per server, returning `List` + Future>> _perServerGrouped({ + required String operationName, + required Future> Function( + String serverId, + PlexClient client, + PlexServer? server, + ) + operation, + }) async { + final clients = _serverManager.onlineClients; + + if (clients.isEmpty) { + appLogger.w('No online servers available for $operationName'); + return {}; + } + + appLogger.d('$operationName from ${clients.length} servers'); + + // Execute operation on all servers in parallel + final futures = clients.entries.map((entry) async { + final serverId = entry.key; + final client = entry.value; + final server = _serverManager.getServer(serverId); + final sw = Stopwatch()..start(); + + try { + final result = await operation(serverId, client, server); + appLogger.d( + '$operationName for server $serverId completed in ${sw.elapsedMilliseconds}ms with ${result.length} items', + ); + return MapEntry(serverId, result); + } catch (e, stackTrace) { + appLogger.e( + 'Failed $operationName from server $serverId', + error: e, + stackTrace: stackTrace, + ); + _serverManager.updateServerStatus(serverId, false); + appLogger.d( + '$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms', + ); + return MapEntry(serverId, []); + } + }); + + final results = await Future.wait(futures); + + return Map.fromEntries(results); + } } diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index 051f6ee1..9ce1ea05 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -126,10 +126,9 @@ class DownloadStorageService { // Default path logic final baseDir = await _getBaseAppDir(); - _baseDownloadsDir = Directory(path.join(baseDir.path, 'downloads')); - if (!await _baseDownloadsDir!.exists()) { - await _baseDownloadsDir!.create(recursive: true); - } + _baseDownloadsDir = await _ensureDirectoryExists( + Directory(path.join(baseDir.path, 'downloads')), + ); return _baseDownloadsDir!; } @@ -142,11 +141,11 @@ class DownloadStorageService { final parent = customDir.parent; final artworkDir = Directory(path.join(parent.path, 'artwork')); try { - if (!await artworkDir.exists()) { - await artworkDir.create(recursive: true); + // Validate writeability for custom artwork path + if (await isDirectoryWritable(artworkDir)) { + _artworkDirectoryPath = artworkDir.path; + return artworkDir; } - _artworkDirectoryPath = artworkDir.path; - return artworkDir; } catch (e) { // Fall through to default if we can't create artwork dir } @@ -154,10 +153,9 @@ class DownloadStorageService { // Default: Get the app base directory directly (not downloads directory) final baseDir = await _getBaseAppDir(); - final artworkDir = Directory(path.join(baseDir.path, 'artwork')); - if (!await artworkDir.exists()) { - await artworkDir.create(recursive: true); - } + final artworkDir = await _ensureDirectoryExists( + Directory(path.join(baseDir.path, 'artwork')), + ); // Cache the path for synchronous access _artworkDirectoryPath = artworkDir.path; return artworkDir; @@ -202,11 +200,9 @@ class DownloadStorageService { /// Get directory for a specific media item Future getMediaDirectory(String serverId, String ratingKey) async { final baseDir = await getDownloadsDirectory(); - final mediaDir = Directory(path.join(baseDir.path, serverId, ratingKey)); - if (!await mediaDir.exists()) { - await mediaDir.create(recursive: true); - } - return mediaDir; + return _ensureDirectoryExists( + Directory(path.join(baseDir.path, serverId, ratingKey)), + ); } /// Get video file path @@ -267,25 +263,46 @@ class DownloadStorageService { .trim(); } - /// Get movie directory: downloads/Movies/{Movie Name} ({Year})/ - Future getMovieDirectory(PlexMetadata movie) async { - final baseDir = await getDownloadsDirectory(); - final movieName = _sanitizeFileName(movie.title); - final year = movie.year; - final movieFolder = year != null ? '$movieName ($year)' : movieName; - final dir = Directory(path.join(baseDir.path, 'Movies', movieFolder)); + /// Ensure a directory exists, creating it if necessary + Future _ensureDirectoryExists(Directory dir) async { if (!await dir.exists()) { await dir.create(recursive: true); } return dir; } + /// Format a media title with optional year: "Title (YYYY)" or "Title" + String _formatTitleWithYear(String title, int? year) { + final sanitized = _sanitizeFileName(title); + return year != null ? '$sanitized ($year)' : sanitized; + } + + /// Get the folder name for a movie: "Movie Name (YYYY)" + String _getMovieFolderName(PlexMetadata movie) { + return _formatTitleWithYear(movie.title, movie.year); + } + + /// Get the folder name for a TV show: "Show Name (YYYY)" + /// [showYear]: Pass explicitly for episodes (episode.year may differ from show's year) + String _getShowFolderName(PlexMetadata metadata, {int? showYear}) { + final title = metadata.grandparentTitle ?? metadata.title; + final year = showYear ?? metadata.year; + return _formatTitleWithYear(title, year); + } + + /// Get movie directory: downloads/Movies/{Movie Name} ({Year})/ + Future getMovieDirectory(PlexMetadata movie) async { + final baseDir = await getDownloadsDirectory(); + final movieFolder = _getMovieFolderName(movie); + return _ensureDirectoryExists( + Directory(path.join(baseDir.path, 'Movies', movieFolder)), + ); + } + /// Get movie video file path: .../Movie Name (YYYY)/Movie Name (YYYY).{ext} Future getMovieVideoPath(PlexMetadata movie, String extension) async { final movieDir = await getMovieDirectory(movie); - final movieName = _sanitizeFileName(movie.title); - final year = movie.year; - final fileName = year != null ? '$movieName ($year)' : movieName; + final fileName = _getMovieFolderName(movie); return path.join(movieDir.path, '$fileName.$extension'); } @@ -306,18 +323,10 @@ class DownloadStorageService { int? showYear, }) async { final baseDir = await getDownloadsDirectory(); - // For episodes, use grandparentTitle; for shows, use title - final showName = _sanitizeFileName( - metadata.grandparentTitle ?? metadata.title, + final showFolder = _getShowFolderName(metadata, showYear: showYear); + return _ensureDirectoryExists( + Directory(path.join(baseDir.path, 'TV Shows', showFolder)), ); - // Use explicit showYear if provided, otherwise fall back to metadata.year - final year = showYear ?? metadata.year; - final showFolder = year != null ? '$showName ($year)' : showName; - final dir = Directory(path.join(baseDir.path, 'TV Shows', showFolder)); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dir; } /// Get show artwork path: downloads/TV Shows/{Show}/poster.jpg @@ -338,11 +347,9 @@ class DownloadStorageService { }) async { final showDir = await getShowDirectory(metadata, showYear: showYear); final seasonNum = (metadata.parentIndex ?? 0).toString().padLeft(2, '0'); - final dir = Directory(path.join(showDir.path, 'Season $seasonNum')); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dir; + return _ensureDirectoryExists( + Directory(path.join(showDir.path, 'Season $seasonNum')), + ); } /// Get season artwork path: .../Season XX/poster.jpg @@ -394,13 +401,9 @@ class DownloadStorageService { int? showYear, }) async { final base = await _getEpisodeBasePath(episode, showYear: showYear); - final subsDir = Directory( - path.join(base.seasonDirPath, '${base.fileName}_subs'), + return _ensureDirectoryExists( + Directory(path.join(base.seasonDirPath, '${base.fileName}_subs')), ); - if (!await subsDir.exists()) { - await subsDir.create(recursive: true); - } - return subsDir; } /// Get episode subtitle path @@ -421,14 +424,10 @@ class DownloadStorageService { /// Get subtitles directory for movie Future getMovieSubtitlesDirectory(PlexMetadata movie) async { final movieDir = await getMovieDirectory(movie); - final movieName = _sanitizeFileName(movie.title); - final year = movie.year; - final baseName = year != null ? '$movieName ($year)' : movieName; - final subsDir = Directory(path.join(movieDir.path, '${baseName}_subs')); - if (!await subsDir.exists()) { - await subsDir.create(recursive: true); - } - return subsDir; + final baseName = _getMovieFolderName(movie); + return _ensureDirectoryExists( + Directory(path.join(movieDir.path, '${baseName}_subs')), + ); } /// Get movie subtitle path @@ -544,13 +543,9 @@ class DownloadStorageService { /// Files are downloaded here first, then copied to SAF if using SAF mode Future getCacheDownloadDirectory() async { final cacheDir = await getApplicationDocumentsDirectory(); - final downloadCache = Directory( - path.join(cacheDir.path, '.download_cache'), + return _ensureDirectoryExists( + Directory(path.join(cacheDir.path, '.download_cache')), ); - if (!await downloadCache.exists()) { - await downloadCache.create(recursive: true); - } - return downloadCache; } /// Get temporary file path for downloading (before copying to SAF) @@ -636,10 +631,7 @@ class DownloadStorageService { /// Get path components for SAF based on media type /// Returns list of directory names to create under the SAF base List getMovieSafPathComponents(PlexMetadata movie) { - final movieName = _sanitizeFileName(movie.title); - final year = movie.year; - final movieFolder = year != null ? '$movieName ($year)' : movieName; - return ['Movies', movieFolder]; + return ['Movies', _getMovieFolderName(movie)]; } /// Get path components for episode SAF storage @@ -647,21 +639,14 @@ class DownloadStorageService { PlexMetadata episode, { int? showYear, }) { - final showName = _sanitizeFileName( - episode.grandparentTitle ?? episode.title, - ); - final year = showYear ?? episode.year; - final showFolder = year != null ? '$showName ($year)' : showName; + final showFolder = _getShowFolderName(episode, showYear: showYear); final seasonNum = (episode.parentIndex ?? 0).toString().padLeft(2, '0'); return ['TV Shows', showFolder, 'Season $seasonNum']; } /// Get SAF file name for a movie String getMovieSafFileName(PlexMetadata movie, String extension) { - final movieName = _sanitizeFileName(movie.title); - final year = movie.year; - final fileName = year != null ? '$movieName ($year)' : movieName; - return '$fileName.$extension'; + return '${_getMovieFolderName(movie)}.$extension'; } /// Get SAF file name for an episode diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 990e5c2d..0b9fcd3f 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -16,15 +16,19 @@ class MediaControlsManager { /// Stream of control events from OS media controls Stream get controlEvents => OsMediaControls.controlEvents; - /// Throttled playback state update (1 second interval, leading edge only) + /// Throttled playback state update (1 second interval, leading + trailing) late final Throttle _throttledUpdate; + /// Cached control enabled state to avoid redundant platform calls + bool? _lastCanGoNext; + bool? _lastCanGoPrevious; + MediaControlsManager() { _throttledUpdate = throttle( _doUpdatePlaybackState, const Duration(seconds: 1), leading: true, - trailing: false, + trailing: true, // Send final position at end of throttle window ); } @@ -117,6 +121,14 @@ class MediaControlsManager { bool canGoNext = false, bool canGoPrevious = false, }) async { + // Skip if unchanged (avoid redundant platform calls) + if (canGoNext == _lastCanGoNext && canGoPrevious == _lastCanGoPrevious) { + return; + } + + _lastCanGoNext = canGoNext; + _lastCanGoPrevious = canGoPrevious; + try { final controls = []; if (canGoPrevious) controls.add(MediaControl.previous); diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index e91256fe..c1dff021 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -66,6 +66,62 @@ class MultiServerManager { /// Check if a server is online bool isServerOnline(String serverId) => _serverStatus[serverId] ?? false; + /// Creates and initializes a PlexClient for a given server + /// + /// Handles finding working connection, loading cached endpoint, + /// creating config, and building client with failover support. + Future _createClientForServer({ + required PlexServer server, + required String clientIdentifier, + }) async { + final serverId = server.clientIdentifier; + + // Find best working connection + PlexConnection? workingConnection; + await for (final connection in server.findBestWorkingConnection()) { + workingConnection = connection; + break; + } + + if (workingConnection == null) { + throw Exception('No working connection found'); + } + + final baseUrl = workingConnection.uri; + + // Get storage and load cached endpoint for this server + final storage = await StorageService.getInstance(); + final cachedEndpoint = storage.getServerEndpoint(serverId); + + // Create PlexClient with failover support + final prioritizedEndpoints = server.prioritizedEndpointUrls( + preferredFirst: cachedEndpoint ?? baseUrl, + ); + final config = await PlexConfig.create( + baseUrl: baseUrl, + token: server.accessToken, + clientIdentifier: clientIdentifier, + ); + + final client = PlexClient( + config, + serverId: serverId, + serverName: server.name, + prioritizedEndpoints: prioritizedEndpoints, + onEndpointChanged: (newUrl) async { + await storage.saveServerEndpoint(serverId, newUrl); + appLogger.i( + 'Updated endpoint for ${server.name} after failover: $newUrl', + ); + }, + ); + + // Save the initial endpoint + await storage.saveServerEndpoint(serverId, baseUrl); + + return client; + } + /// Connect to all available servers in parallel /// Returns the number of successfully connected servers Future connectToAllServers( @@ -93,52 +149,11 @@ class MultiServerManager { try { appLogger.d('Attempting connection to server: ${server.name}'); - // Find best working connection for this server - PlexConnection? workingConnection; - - await for (final connection in server.findBestWorkingConnection()) { - workingConnection = connection; - // Use first working connection (could wait for optimal, but that's slower) - break; - } - - if (workingConnection == null) { - throw Exception('No working connection found'); - } - - final baseUrl = workingConnection.uri; - appLogger.d('Connected to ${server.name} at $baseUrl'); - - // Get storage and load cached endpoint for this server - final storage = await StorageService.getInstance(); - final cachedEndpoint = storage.getServerEndpoint(serverId); - - // Create PlexClient with the working connection and failover support - final prioritizedEndpoints = server.prioritizedEndpointUrls( - preferredFirst: cachedEndpoint ?? baseUrl, - ); - final config = await PlexConfig.create( - baseUrl: baseUrl, - token: server.accessToken, + final client = await _createClientForServer( + server: server, clientIdentifier: effectiveClientId, ); - final client = PlexClient( - config, - serverId: serverId, - serverName: server.name, - prioritizedEndpoints: prioritizedEndpoints, - onEndpointChanged: (newUrl) async { - await storage.saveServerEndpoint(serverId, newUrl); - appLogger.i( - 'Updated endpoint for ${server.name} after failover: $newUrl', - ); - }, - ); - - // Save the initial endpoint - await storage.saveServerEndpoint(serverId, baseUrl); - // Store the client and server info _clients[serverId] = client; _servers[serverId] = server; @@ -204,50 +219,11 @@ class MultiServerManager { try { appLogger.d('Adding server: ${server.name}'); - // Find best working connection - PlexConnection? workingConnection; - - await for (final connection in server.findBestWorkingConnection()) { - workingConnection = connection; - break; - } - - if (workingConnection == null) { - throw Exception('No working connection found'); - } - - final baseUrl = workingConnection.uri; - - // Get storage and load cached endpoint for this server - final storage = await StorageService.getInstance(); - final cachedEndpoint = storage.getServerEndpoint(serverId); - - // Create PlexClient with failover support - final prioritizedEndpoints = server.prioritizedEndpointUrls( - preferredFirst: cachedEndpoint ?? baseUrl, - ); - final config = await PlexConfig.create( - baseUrl: baseUrl, - token: server.accessToken, + final client = await _createClientForServer( + server: server, clientIdentifier: effectiveClientId, ); - final client = PlexClient( - config, - serverId: serverId, - serverName: server.name, - prioritizedEndpoints: prioritizedEndpoints, - onEndpointChanged: (newUrl) async { - await storage.saveServerEndpoint(serverId, newUrl); - appLogger.i( - 'Updated endpoint for ${server.name} after failover: $newUrl', - ); - }, - ); - - // Save the initial endpoint - await storage.saveServerEndpoint(serverId, baseUrl); - // Store _clients[serverId] = client; _servers[serverId] = server; diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 0cc74d05..0d35bca7 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -21,6 +21,7 @@ class OfflineWatchSyncService extends ChangeNotifier { OfflineModeProvider? _offlineModeProvider; VoidCallback? _offlineModeListener; bool _isSyncing = false; + bool _isBidirectionalSyncing = false; DateTime? _lastSyncTime; bool _hasPerformedStartupSync = false; @@ -75,28 +76,39 @@ class OfflineWatchSyncService extends ChangeNotifier { /// /// Push always happens immediately. Pull respects [minSyncInterval] unless [force] is true. Future _performBidirectionalSync({bool force = false}) async { + // Prevent overlapping bidirectional syncs + if (_isBidirectionalSyncing) { + appLogger.d('Bidirectional sync already in progress, skipping'); + return; + } + if (_serverManager.onlineClients.isEmpty) { appLogger.d('Skipping watch sync - no connected servers available yet'); return; } - // Always push local changes to server (never throttle outbound sync) - await syncPendingItems(); + _isBidirectionalSyncing = true; + try { + // Always push local changes to server (never throttle outbound sync) + await syncPendingItems(); - // Only throttle the pull from server - if (!force && _lastSyncTime != null) { - final elapsed = DateTime.now().difference(_lastSyncTime!); - if (elapsed < minSyncInterval) { - appLogger.d( - 'Skipping server pull - last sync was ${elapsed.inMinutes}m ago (min: ${minSyncInterval.inMinutes}m)', - ); - return; + // Only throttle the pull from server + if (!force && _lastSyncTime != null) { + final elapsed = DateTime.now().difference(_lastSyncTime!); + if (elapsed < minSyncInterval) { + appLogger.d( + 'Skipping server pull - last sync was ${elapsed.inMinutes}m ago (min: ${minSyncInterval.inMinutes}m)', + ); + return; + } } - } - // Pull latest states from server - await syncWatchStatesFromServer(); - _lastSyncTime = DateTime.now(); + // Pull latest states from server + await syncWatchStatesFromServer(); + _lastSyncTime = DateTime.now(); + } finally { + _isBidirectionalSyncing = false; + } } /// Called when app becomes active - syncs if interval has passed. @@ -217,6 +229,40 @@ class OfflineWatchSyncService extends ChangeNotifier { } } + /// Get local watch statuses for multiple items in a single database query. + /// + /// Returns a map of globalKey -> watch status (true/false/null). + /// More efficient than calling getLocalWatchStatus multiple times. + Future> getLocalWatchStatusesBatched( + Set globalKeys, + ) async { + if (globalKeys.isEmpty) return {}; + + final actions = await _database.getLatestWatchActionsForKeys(globalKeys); + final result = {}; + + for (final key in globalKeys) { + final action = actions[key]; + if (action == null) { + result[key] = null; + continue; + } + + switch (action.actionType) { + case 'watched': + result[key] = true; + case 'unwatched': + result[key] = false; + case 'progress': + result[key] = action.shouldMarkWatched; + default: + result[key] = null; + } + } + + return result; + } + /// Get the local view offset (resume position) for a media item. /// /// Returns the locally tracked position, or null if none exists. @@ -240,6 +286,7 @@ class OfflineWatchSyncService extends ChangeNotifier { /// Sync all pending items to their respective servers. /// /// Called automatically when connectivity is restored, or manually. + /// Actions are batched by server to reduce connectivity lookups. Future syncPendingItems() async { if (_isSyncing) { appLogger.d('Sync already in progress, skipping'); @@ -259,6 +306,9 @@ class OfflineWatchSyncService extends ChangeNotifier { appLogger.i('Syncing ${pendingActions.length} pending watch actions'); + // First pass: handle retry limit exceeded and group by server + final actionsByServer = >{}; + for (final action in pendingActions) { // Delete items that have exceeded retry limit if (action.syncAttempts >= maxSyncAttempts) { @@ -270,43 +320,54 @@ class OfflineWatchSyncService extends ChangeNotifier { continue; } - // Get the client for this server - final client = _serverManager.getClient(action.serverId); - if (client == null) { - // Check if this server still exists in the app - if (_serverManager.getServer(action.serverId) == null) { - appLogger.w( - 'Deleting action ${action.id} - server ${action.serverId} no longer exists', - ); - await _database.deleteWatchAction(action.id); - } else { - appLogger.w( - 'No client available for server ${action.serverId}, will retry', - ); - await _database.updateSyncAttempt( - action.id, - 'Server not available', - ); - } - continue; - } - - // Check if server is online - if (!_serverManager.isServerOnline(action.serverId)) { - appLogger.d('Server ${action.serverId} is offline, skipping'); - continue; - } - - try { - await _syncAction(client, action); - // Success - delete the action from queue - await _database.deleteWatchAction(action.id); - appLogger.d( - 'Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}', + // Check if server still exists + if (_serverManager.getServer(action.serverId) == null) { + appLogger.w( + 'Deleting action ${action.id} - server ${action.serverId} no longer exists', ); - } catch (e) { - appLogger.w('Failed to sync action ${action.id}: $e'); - await _database.updateSyncAttempt(action.id, e.toString()); + await _database.deleteWatchAction(action.id); + continue; + } + + actionsByServer.putIfAbsent(action.serverId, () => []).add(action); + } + + // Second pass: process each server's actions with single connectivity check + for (final entry in actionsByServer.entries) { + final serverId = entry.key; + final actions = entry.value; + + await _withOnlineClient(serverId, (client) async { + for (final action in actions) { + try { + await _syncAction(client, action); + // Success - delete the action from queue + await _database.deleteWatchAction(action.id); + appLogger.d( + 'Successfully synced action ${action.id}: ${action.actionType} for ${action.ratingKey}', + ); + } catch (e) { + appLogger.w('Failed to sync action ${action.id}: $e'); + await _database.updateSyncAttempt(action.id, e.toString()); + } + } + }); + + // If _withOnlineClient returned null (server offline), mark actions for retry + if (_serverManager.getClient(serverId) == null || + !_serverManager.isServerOnline(serverId)) { + for (final action in actions) { + // Only update if we haven't already processed it + final stillPending = await _database.getLatestWatchAction( + '${action.serverId}:${action.ratingKey}', + ); + if (stillPending != null && stillPending.id == action.id) { + await _database.updateSyncAttempt( + action.id, + 'Server not available', + ); + } + } } } } finally { diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 2de17119..9e7ae35b 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -152,9 +152,9 @@ class PlayQueueLauncher { required PlexMetadata metadata, bool showLoadingIndicator = true, }) async { - final itemType = metadata.type.toLowerCase(); + final mediaType = metadata.mediaType; - if (itemType != 'show' && itemType != 'season') { + if (mediaType != PlexMediaType.show && mediaType != PlexMediaType.season) { return PlayQueueError( Exception('Shuffle play only works for shows and seasons'), ); @@ -166,7 +166,7 @@ class PlayQueueLauncher { execute: () async { // Determine the rating key for the play queue String showRatingKey; - if (itemType == 'show') { + if (mediaType == PlexMediaType.show) { showRatingKey = metadata.ratingKey; } else { // For seasons, we need the show's rating key @@ -244,24 +244,18 @@ class PlayQueueLauncher { required String action, required Future Function() execute, }) async { + // Show loading indicator + if (showLoading && context.mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center(child: CircularProgressIndicator()), + ); + } + try { - // Show loading indicator - if (showLoading && context.mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => - const Center(child: CircularProgressIndicator()), - ); - } - final result = await execute(); - // Close loading indicator - if (showLoading && context.mounted && Navigator.canPop(context)) { - Navigator.pop(context); - } - // Handle empty queue result if (result is PlayQueueEmpty && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -273,11 +267,6 @@ class PlayQueueLauncher { } catch (e) { appLogger.e('Failed to $action', error: e); - // Close loading indicator if it's still open - if (showLoading && context.mounted && Navigator.canPop(context)) { - Navigator.pop(context); - } - if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -289,6 +278,11 @@ class PlayQueueLauncher { } return PlayQueueError(e); + } finally { + // Close loading indicator (guaranteed cleanup) + if (showLoading && context.mounted && Navigator.canPop(context)) { + Navigator.pop(context); + } } } } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index d1317c5b..18981568 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -17,6 +17,11 @@ class PlaybackInitializationService { PlaybackInitializationService({required this.client, this.database}); + /// Format a video path as a URL (adds file:// prefix for file paths) + String _formatVideoUrl(String path) { + return path.contains('://') ? path : 'file://$path'; + } + /// Check if content is available offline and return local path /// /// Returns the local file path if the video is downloaded and completed. @@ -50,27 +55,22 @@ class PlaybackInitializationService { final storageService = DownloadStorageService.instance; final storedPath = downloadedItem.videoFilePath!; - // Check if this is a SAF URI (content://) - if (storageService.isSafUri(storedPath)) { - // SAF URIs are already valid for media players - appLogger.d('Found offline video (SAF): $storedPath'); - return storedPath; + // Get readable path (handles both SAF URIs and file paths) + final readablePath = await storageService.getReadablePath(storedPath); + + // For file paths (not SAF), verify the file exists + if (!storageService.isSafUri(storedPath)) { + final file = File(readablePath); + if (!await file.exists()) { + appLogger.w( + 'Offline video file not found: $readablePath (stored as: $storedPath)', + ); + return null; + } } - // Convert relative path to absolute path (handles both old absolute and new relative paths) - final absolutePath = await storageService.ensureAbsolutePath(storedPath); - - // Verify file exists - final file = File(absolutePath); - if (!await file.exists()) { - appLogger.w( - 'Offline video file not found: $absolutePath (stored as: $storedPath)', - ); - return null; - } - - appLogger.d('Found offline video: $absolutePath'); - return absolutePath; + appLogger.d('Found offline video: $readablePath'); + return readablePath; } catch (e) { appLogger.w('Error checking offline video path', error: e); return null; @@ -113,12 +113,10 @@ class PlaybackInitializationService { playbackData.mediaInfo, ); - // Return result with local file path (file:// prefix for file paths, keep SAF URIs as-is) + // Return result with local file path return PlaybackInitializationResult( availableVersions: playbackData.availableVersions, - videoUrl: offlineVideoPath.contains('://') - ? offlineVideoPath - : 'file://$offlineVideoPath', + videoUrl: _formatVideoUrl(offlineVideoPath), mediaInfo: playbackData.mediaInfo, externalSubtitles: externalSubtitles, isOffline: true, @@ -131,9 +129,7 @@ class PlaybackInitializationService { ); return PlaybackInitializationResult( availableVersions: [], - videoUrl: offlineVideoPath.contains('://') - ? offlineVideoPath - : 'file://$offlineVideoPath', + videoUrl: _formatVideoUrl(offlineVideoPath), mediaInfo: null, externalSubtitles: const [], isOffline: true, diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index bdccf8b4..25fff345 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -62,6 +62,11 @@ class PlaybackProgressTracker { return; } + // Send initial progress immediately (don't wait for first timer tick) + if (player.state.playing) { + _sendProgress('playing'); + } + _progressTimer = Timer.periodic(updateInterval, (timer) { if (player.state.playing) { _sendProgress('playing'); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 4cebd187..3e01a41a 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -535,6 +535,121 @@ class PlexClient { return null; } + /// Fetch metadata with cache support for offline mode and network fallback. + /// + /// Returns the raw response data (Map) or null if not available. + /// Used by playback methods to share caching logic. + Future?> _fetchMetadataWithCache( + String ratingKey, { + Map? queryParams, + }) async { + final cacheKey = '/library/metadata/$ratingKey'; + + // Offline mode: cache only + if (_offlineMode) { + return await _cache.get(serverId, cacheKey); + } + + // Online: try network first + try { + final response = await _dio.get( + '/library/metadata/$ratingKey', + queryParameters: queryParams, + ); + + // Cache at base endpoint + if (response.data != null) { + await _cache.put(serverId, cacheKey, response.data); + } + + return response.data; + } catch (e) { + // Network failed - try cache as fallback + appLogger.w('Network request failed for metadata, trying cache', error: e); + return await _cache.get(serverId, cacheKey); + } + } + + /// Get first metadata JSON from response data + Map? _getFirstMetadataJsonFromData(Map? data) { + if (data == null) return null; + final container = data['MediaContainer']; + if (container != null && + container['Metadata'] != null && + (container['Metadata'] as List).isNotEmpty) { + return container['Metadata'][0]; + } + return null; + } + + /// Parse audio and subtitle tracks from a stream list + ({List audio, List subtitles}) _parseStreams( + List? streams, + ) { + final audioTracks = []; + final subtitleTracks = []; + + if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks); + + for (var stream in streams) { + final streamType = stream['streamType'] as int?; + + if (streamType == 2) { + // Audio track + audioTracks.add( + PlexAudioTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + channels: stream['channels'] as int?, + selected: stream['selected'] == 1, + ), + ); + } else if (streamType == 3) { + // Subtitle track + subtitleTracks.add( + PlexSubtitleTrack( + id: stream['id'] as int, + index: stream['index'] as int?, + codec: stream['codec'] as String?, + language: stream['language'] as String?, + languageCode: stream['languageCode'] as String?, + title: stream['title'] as String?, + displayTitle: stream['displayTitle'] as String?, + selected: stream['selected'] == 1, + forced: stream['forced'] == 1, + key: stream['key'] as String?, + ), + ); + } + } + + return (audio: audioTracks, subtitles: subtitleTracks); + } + + /// Parse chapters from metadata JSON + List _parseChapters(Map? metadataJson) { + if (metadataJson == null || metadataJson['Chapter'] == null) { + return []; + } + + final chapterList = metadataJson['Chapter'] as List; + return chapterList.map((chapter) { + return PlexChapter( + id: chapter['id'] as int, + index: chapter['index'] as int?, + startTimeOffset: chapter['startTimeOffset'] as int?, + endTimeOffset: chapter['endTimeOffset'] as int?, + title: chapter['tag'] as String? ?? chapter['title'] as String?, + thumb: chapter['thumb'] as String?, + ); + }).toList(); + } + /// Set per-media language preferences (audio and subtitle) /// For TV shows, use grandparentRatingKey to set preference for the entire series /// For movies, use the movie's ratingKey @@ -811,9 +926,10 @@ class PlexClient { /// Get video URL for direct playback /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) + /// Uses cache for offline mode support and network fallback. Future getVideoUrl(String ratingKey, {int mediaIndex = 0}) async { - final response = await _dio.get('/library/metadata/$ratingKey'); - final metadataJson = _getFirstMetadataJson(response); + final data = await _fetchMetadataWithCache(ratingKey); + final metadataJson = _getFirstMetadataJsonFromData(data); if (metadataJson != null && metadataJson['Media'] != null && @@ -1002,12 +1118,13 @@ class PlexClient { /// Get detailed media info including chapters and tracks /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) + /// Uses cache for offline mode support and network fallback. Future getMediaInfo( String ratingKey, { int mediaIndex = 0, }) async { - final response = await _dio.get('/library/metadata/$ratingKey'); - final metadataJson = _getFirstMetadataJson(response); + final data = await _fetchMetadataWithCache(ratingKey); + final metadataJson = _getFirstMetadataJsonFromData(data); if (metadataJson != null && metadataJson['Media'] != null && @@ -1025,70 +1142,15 @@ class PlexClient { final partKey = part['key'] as String?; if (partKey != null) { - // Parse streams (audio and subtitle tracks) - final streams = part['Stream'] as List? ?? []; - final audioTracks = []; - final subtitleTracks = []; - - for (var stream in streams) { - final streamType = stream['streamType'] as int?; - - if (streamType == 2) { - // Audio track - audioTracks.add( - PlexAudioTrack( - id: stream['id'] as int, - index: stream['index'] as int?, - codec: stream['codec'] as String?, - language: stream['language'] as String?, - languageCode: stream['languageCode'] as String?, - title: stream['title'] as String?, - displayTitle: stream['displayTitle'] as String?, - channels: stream['channels'] as int?, - selected: stream['selected'] == 1, - ), - ); - } else if (streamType == 3) { - // Subtitle track - subtitleTracks.add( - PlexSubtitleTrack( - id: stream['id'] as int, - index: stream['index'] as int?, - codec: stream['codec'] as String?, - language: stream['language'] as String?, - languageCode: stream['languageCode'] as String?, - title: stream['title'] as String?, - displayTitle: stream['displayTitle'] as String?, - selected: stream['selected'] == 1, - forced: stream['forced'] == 1, - key: stream['key'] as String?, - ), - ); - } - } - - // Parse chapters - final chapters = []; - if (metadataJson['Chapter'] != null) { - final chapterList = metadataJson['Chapter'] as List; - for (var chapter in chapterList) { - chapters.add( - PlexChapter( - id: chapter['id'] as int, - index: chapter['index'] as int?, - startTimeOffset: chapter['startTimeOffset'] as int?, - endTimeOffset: chapter['endTimeOffset'] as int?, - title: chapter['title'] as String?, - thumb: chapter['thumb'] as String?, - ), - ); - } - } + // Parse streams using helper + final streams = _parseStreams(part['Stream'] as List?); + // Parse chapters using helper + final chapters = _parseChapters(metadataJson); return PlexMediaInfo( videoUrl: '${config.baseUrl}$partKey?X-Plex-Token=${config.token}', - audioTracks: audioTracks, - subtitleTracks: subtitleTracks, + audioTracks: streams.audio, + subtitleTracks: streams.subtitles, chapters: chapters, ); } @@ -1100,9 +1162,10 @@ class PlexClient { /// Get all available media versions for a media item /// Returns a list of PlexMediaVersion objects representing different quality/format options + /// Uses cache for offline mode support and network fallback. Future> getMediaVersions(String ratingKey) async { - final response = await _dio.get('/library/metadata/$ratingKey'); - final metadataJson = _getFirstMetadataJson(response); + final data = await _fetchMetadataWithCache(ratingKey); + final metadataJson = _getFirstMetadataJsonFromData(data); if (metadataJson != null && metadataJson['Media'] != null && @@ -1121,12 +1184,13 @@ class PlexClient { /// Get consolidated video playback data (URL, media info, and versions) in a single API call /// This method combines the functionality of getVideoUrl(), getMediaInfo(), and getMediaVersions() /// to reduce redundant API calls during video playback initialization. + /// Uses cache for offline mode support and network fallback. Future getVideoPlaybackData( String ratingKey, { int mediaIndex = 0, }) async { - final response = await _dio.get('/library/metadata/$ratingKey'); - final metadataJson = _getFirstMetadataJson(response); + final data = await _fetchMetadataWithCache(ratingKey); + final metadataJson = _getFirstMetadataJsonFromData(data); String? videoUrl; PlexMediaInfo? mediaInfo; @@ -1158,71 +1222,16 @@ class PlexClient { // Get video URL videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; - // Parse streams (audio and subtitle tracks) for media info - final streams = part['Stream'] as List? ?? []; - final audioTracks = []; - final subtitleTracks = []; - - for (var stream in streams) { - final streamType = stream['streamType'] as int?; - - if (streamType == 2) { - // Audio track - audioTracks.add( - PlexAudioTrack( - id: stream['id'] as int, - index: stream['index'] as int?, - codec: stream['codec'] as String?, - language: stream['language'] as String?, - languageCode: stream['languageCode'] as String?, - title: stream['title'] as String?, - displayTitle: stream['displayTitle'] as String?, - channels: stream['channels'] as int?, - selected: stream['selected'] == 1, - ), - ); - } else if (streamType == 3) { - // Subtitle track - subtitleTracks.add( - PlexSubtitleTrack( - id: stream['id'] as int, - index: stream['index'] as int?, - codec: stream['codec'] as String?, - language: stream['language'] as String?, - languageCode: stream['languageCode'] as String?, - title: stream['title'] as String?, - displayTitle: stream['displayTitle'] as String?, - selected: stream['selected'] == 1, - forced: stream['forced'] == 1, - key: stream['key'] as String?, - ), - ); - } - } - - // Parse chapters - final chapters = []; - if (metadataJson['Chapter'] != null) { - final chapterList = metadataJson['Chapter'] as List; - for (var chapter in chapterList) { - chapters.add( - PlexChapter( - id: chapter['id'] as int, - index: chapter['index'] as int?, - startTimeOffset: chapter['startTimeOffset'] as int?, - endTimeOffset: chapter['endTimeOffset'] as int?, - title: chapter['title'] as String?, - thumb: chapter['thumb'] as String?, - ), - ); - } - } + // Parse streams using helper + final streams = _parseStreams(part['Stream'] as List?); + // Parse chapters using helper + final chapters = _parseChapters(metadataJson); // Create media info mediaInfo = PlexMediaInfo( videoUrl: videoUrl, - audioTracks: audioTracks, - subtitleTracks: subtitleTracks, + audioTracks: streams.audio, + subtitleTracks: streams.subtitles, chapters: chapters, partId: part['id'] as int?, ); @@ -1238,10 +1247,11 @@ class PlexClient { } /// Get file information for a media item + /// Uses cache for offline mode support and network fallback. Future getFileInfo(String ratingKey) async { try { - final response = await _dio.get('/library/metadata/$ratingKey'); - final metadataJson = _getFirstMetadataJson(response); + final data = await _fetchMetadataWithCache(ratingKey); + final metadataJson = _getFirstMetadataJsonFromData(data); if (metadataJson != null && metadataJson['Media'] != null && @@ -1384,7 +1394,13 @@ class PlexClient { } /// Get available sort options for a library section - Future> getLibrarySorts(String sectionId) async { + /// + /// If [libraryType] is provided (e.g., 'movie', 'show'), it's used for fallback + /// sorts without needing to re-fetch the library sections list. + Future> getLibrarySorts( + String sectionId, { + String? libraryType, + }) async { try { // Use the dedicated sorts endpoint final response = await _dio.get('/library/sections/$sectionId/sorts'); @@ -1397,78 +1413,56 @@ class PlexClient { } // Fallback: return common sort options if API doesn't provide them - return _getFallbackSorts(sectionId); + return _getFallbackSorts(libraryType); } catch (e) { appLogger.e('Failed to get library sorts: $e'); // Return fallback sort options on error - return _getFallbackSorts(sectionId); + return _getFallbackSorts(libraryType); } } - Future> _getFallbackSorts(String sectionId) async { - try { - // Get library type to determine which sorts to include - final librariesResponse = await _dio.get('/library/sections'); - final libraries = _extractDirectoryList( - librariesResponse, - PlexLibrary.fromJson, + /// Build fallback sort options based on library type. + /// + /// If [libraryType] is null, returns generic sorts without the show-specific options. + List _getFallbackSorts(String? libraryType) { + final fallbackSorts = [ + PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), + PlexSort( + key: 'addedAt', + descKey: 'addedAt:desc', + title: 'Date Added', + defaultDirection: 'desc', + ), + ]; + + // Add "Latest Episode Air Date" only for TV show libraries + if (libraryType?.toLowerCase() == 'show') { + fallbackSorts.add( + PlexSort( + key: 'episode.originallyAvailableAt', + descKey: 'episode.originallyAvailableAt:desc', + title: 'Latest Episode Air Date', + defaultDirection: 'desc', + ), ); - final library = libraries.firstWhere( - (lib) => lib.key == sectionId, - orElse: () => libraries.first, - ); - - final fallbackSorts = [ - PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), - PlexSort( - key: 'addedAt', - descKey: 'addedAt:desc', - title: 'Date Added', - defaultDirection: 'desc', - ), - ]; - - // Add "Latest Episode Air Date" only for TV show libraries - if (library.type.toLowerCase() == 'show') { - fallbackSorts.add( - PlexSort( - key: 'episode.originallyAvailableAt', - descKey: 'episode.originallyAvailableAt:desc', - title: 'Latest Episode Air Date', - defaultDirection: 'desc', - ), - ); - } - - fallbackSorts.addAll([ - PlexSort( - key: 'originallyAvailableAt', - descKey: 'originallyAvailableAt:desc', - title: 'Release Date', - defaultDirection: 'desc', - ), - PlexSort( - key: 'rating', - descKey: 'rating:desc', - title: 'Rating', - defaultDirection: 'desc', - ), - ]); - - return fallbackSorts; - } catch (e) { - appLogger.e('Failed to get fallback sorts: $e'); - // Return minimal fallback options - return [ - PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), - PlexSort( - key: 'addedAt', - descKey: 'addedAt:desc', - title: 'Date Added', - defaultDirection: 'desc', - ), - ]; } + + fallbackSorts.addAll([ + PlexSort( + key: 'originallyAvailableAt', + descKey: 'originallyAvailableAt:desc', + title: 'Release Date', + defaultDirection: 'desc', + ), + PlexSort( + key: 'rating', + descKey: 'rating:desc', + title: 'Rating', + defaultDirection: 'desc', + ), + ]); + + return fallbackSorts; } /// Get library hubs (recommendations for a specific library section) @@ -2208,6 +2202,80 @@ class PlexClient { await _dio.get('/library/sections/$sectionId/analyze'); } + // ============================================================================ + // Library Statistics Methods + // ============================================================================ + + /// Get total item count for a library section efficiently. + /// Uses X-Plex-Container-Size: 1 to get totalSize with minimal data transfer. + Future getLibraryTotalCount(String sectionId) async { + try { + final response = await _dio.get( + '/library/sections/$sectionId/all', + queryParameters: { + 'X-Plex-Container-Start': 0, + 'X-Plex-Container-Size': 1, + }, + ); + final container = _getMediaContainer(response); + // Try totalSize first, fall back to size if not available + return container?['totalSize'] as int? ?? + container?['size'] as int? ?? + 0; + } catch (e) { + appLogger.e('Failed to get library total count: $e'); + return 0; + } + } + + /// Get total episode count for a TV show library. + /// Uses the allLeaves endpoint to count all episodes. + Future getLibraryEpisodeCount(String sectionId) async { + try { + final response = await _dio.get( + '/library/sections/$sectionId/allLeaves', + queryParameters: { + 'X-Plex-Container-Start': 0, + 'X-Plex-Container-Size': 1, + }, + ); + final container = _getMediaContainer(response); + return container?['totalSize'] as int? ?? + container?['size'] as int? ?? + 0; + } catch (e) { + appLogger.e('Failed to get library episode count: $e'); + return 0; + } + } + + /// Get watch history count for a time period. + /// [since] - Optional DateTime to filter history from this date onwards. + /// Returns the total count of items watched. + Future getWatchHistoryCount({DateTime? since}) async { + try { + final queryParams = { + 'X-Plex-Container-Start': 0, + 'X-Plex-Container-Size': 1, + }; + if (since != null) { + final epochSeconds = since.millisecondsSinceEpoch ~/ 1000; + queryParams['viewedAt>'] = epochSeconds; + } + final response = await _dio.get( + '/status/sessions/history/all', + queryParameters: queryParams, + ); + final container = _getMediaContainer(response); + return container?['totalSize'] as int? ?? + container?['size'] as int? ?? + 0; + } catch (e) { + appLogger.e('Failed to get watch history count: $e'); + return 0; + } + } + Future _handleEndpointSwitch(String newBaseUrl) async { if (config.baseUrl == newBaseUrl) { return; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 74bcd867..7c4f19fc 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -60,17 +60,23 @@ class SettingsService extends BaseSharedPreferencesService { ); } + /// Generic helper to get an enum value from preferences + T _getEnumValue(String key, List values, T defaultValue) { + final stored = prefs.getString(key); + if (stored == null) return defaultValue; + return values.firstWhere( + (v) => v.name == stored, + orElse: () => defaultValue, + ); + } + // Theme Mode Future setThemeMode(ThemeMode mode) async { await prefs.setString(_keyThemeMode, mode.name); } ThemeMode getThemeMode() { - final modeString = prefs.getString(_keyThemeMode); - return ThemeMode.values.firstWhere( - (mode) => mode.name == modeString, - orElse: () => ThemeMode.system, - ); + return _getEnumValue(_keyThemeMode, ThemeMode.values, ThemeMode.system); } // Debug Logging @@ -135,11 +141,7 @@ class SettingsService extends BaseSharedPreferencesService { } LibraryDensity getLibraryDensity() { - final densityString = prefs.getString(_keyLibraryDensity); - return LibraryDensity.values.firstWhere( - (density) => density.name == densityString, - orElse: () => LibraryDensity.normal, - ); + return _getEnumValue(_keyLibraryDensity, LibraryDensity.values, LibraryDensity.normal); } // View Mode @@ -148,11 +150,7 @@ class SettingsService extends BaseSharedPreferencesService { } ViewMode getViewMode() { - final modeString = prefs.getString(_keyViewMode); - return ViewMode.values.firstWhere( - (mode) => mode.name == modeString, - orElse: () => ViewMode.grid, - ); + return _getEnumValue(_keyViewMode, ViewMode.values, ViewMode.grid); } // Use Season Poster @@ -360,19 +358,17 @@ class SettingsService extends BaseSharedPreferencesService { final jsonString = prefs.getString(_keyKeyboardShortcuts); if (jsonString == null) return getDefaultKeyboardShortcuts(); - try { - final decoded = json.decode(jsonString) as Map; - final shortcuts = decoded.map( - (key, value) => MapEntry(key, value.toString()), - ); + final decoded = _decodeJsonStringToMap(jsonString); + if (decoded.isEmpty) return getDefaultKeyboardShortcuts(); - // Merge with defaults to ensure all keys exist - final defaults = getDefaultKeyboardShortcuts(); - defaults.addAll(shortcuts); - return defaults; - } catch (e) { - return getDefaultKeyboardShortcuts(); - } + final shortcuts = decoded.map( + (key, value) => MapEntry(key, value.toString()), + ); + + // Merge with defaults to ensure all keys exist + final defaults = getDefaultKeyboardShortcuts(); + defaults.addAll(shortcuts); + return defaults; } Future setKeyboardShortcut(String action, String key) async { @@ -459,6 +455,15 @@ class SettingsService extends BaseSharedPreferencesService { } // Helper methods for HotKey serialization + static const _modifierMap = { + 'alt': HotKeyModifier.alt, + 'control': HotKeyModifier.control, + 'shift': HotKeyModifier.shift, + 'meta': HotKeyModifier.meta, + 'capsLock': HotKeyModifier.capsLock, + 'fn': HotKeyModifier.fn, + }; + Map _serializeHotKey(HotKey hotKey) { return { 'key': hotKey.key.toString(), @@ -472,24 +477,7 @@ class SettingsService extends BaseSharedPreferencesService { final modifierNames = (data['modifiers'] as List).cast(); final modifiers = modifierNames - .map((name) { - switch (name) { - case 'alt': - return HotKeyModifier.alt; - case 'control': - return HotKeyModifier.control; - case 'shift': - return HotKeyModifier.shift; - case 'meta': - return HotKeyModifier.meta; - case 'capsLock': - return HotKeyModifier.capsLock; - case 'fn': - return HotKeyModifier.fn; - default: - return null; - } - }) + .map((name) => _modifierMap[name]) .where((m) => m != null) .cast() .toList(); @@ -507,255 +495,205 @@ class SettingsService extends BaseSharedPreferencesService { return null; } + // Unified map for USB HID codes to PhysicalKeyboardKey + static const _usbHidKeyMap = { + // Special keys + '0007002c': PhysicalKeyboardKey.space, + '0007002a': PhysicalKeyboardKey.backspace, + '0007004c': PhysicalKeyboardKey.delete, + '00070028': PhysicalKeyboardKey.enter, + '00070029': PhysicalKeyboardKey.escape, + '0007002b': PhysicalKeyboardKey.tab, + '00070039': PhysicalKeyboardKey.capsLock, + // Arrow keys + '00070050': PhysicalKeyboardKey.arrowLeft, + '00070052': PhysicalKeyboardKey.arrowUp, + '0007004f': PhysicalKeyboardKey.arrowRight, + '00070051': PhysicalKeyboardKey.arrowDown, + // Navigation keys + '0007004a': PhysicalKeyboardKey.home, + '0007004d': PhysicalKeyboardKey.end, + '0007004b': PhysicalKeyboardKey.pageUp, + '0007004e': PhysicalKeyboardKey.pageDown, + // Symbol keys + '0007002d': PhysicalKeyboardKey.equal, + '0007002e': PhysicalKeyboardKey.minus, + // Function keys + '0007003a': PhysicalKeyboardKey.f1, + '0007003b': PhysicalKeyboardKey.f2, + '0007003c': PhysicalKeyboardKey.f3, + '0007003d': PhysicalKeyboardKey.f4, + '0007003e': PhysicalKeyboardKey.f5, + '0007003f': PhysicalKeyboardKey.f6, + '00070040': PhysicalKeyboardKey.f7, + '00070041': PhysicalKeyboardKey.f8, + '00070042': PhysicalKeyboardKey.f9, + '00070043': PhysicalKeyboardKey.f10, + '00070044': PhysicalKeyboardKey.f11, + '00070045': PhysicalKeyboardKey.f12, + // Number keys + '00070027': PhysicalKeyboardKey.digit0, + '0007001e': PhysicalKeyboardKey.digit1, + '0007001f': PhysicalKeyboardKey.digit2, + '00070020': PhysicalKeyboardKey.digit3, + '00070021': PhysicalKeyboardKey.digit4, + '00070022': PhysicalKeyboardKey.digit5, + '00070023': PhysicalKeyboardKey.digit6, + '00070024': PhysicalKeyboardKey.digit7, + '00070025': PhysicalKeyboardKey.digit8, + '00070026': PhysicalKeyboardKey.digit9, + // Letter keys + '00070004': PhysicalKeyboardKey.keyA, + '00070005': PhysicalKeyboardKey.keyB, + '00070006': PhysicalKeyboardKey.keyC, + '00070007': PhysicalKeyboardKey.keyD, + '00070008': PhysicalKeyboardKey.keyE, + '00070009': PhysicalKeyboardKey.keyF, + '0007000a': PhysicalKeyboardKey.keyG, + '0007000b': PhysicalKeyboardKey.keyH, + '0007000c': PhysicalKeyboardKey.keyI, + '0007000d': PhysicalKeyboardKey.keyJ, + '0007000e': PhysicalKeyboardKey.keyK, + '0007000f': PhysicalKeyboardKey.keyL, + '00070010': PhysicalKeyboardKey.keyM, + '00070011': PhysicalKeyboardKey.keyN, + '00070012': PhysicalKeyboardKey.keyO, + '00070013': PhysicalKeyboardKey.keyP, + '00070014': PhysicalKeyboardKey.keyQ, + '00070015': PhysicalKeyboardKey.keyR, + '00070016': PhysicalKeyboardKey.keyS, + '00070017': PhysicalKeyboardKey.keyT, + '00070018': PhysicalKeyboardKey.keyU, + '00070019': PhysicalKeyboardKey.keyV, + '0007001a': PhysicalKeyboardKey.keyW, + '0007001b': PhysicalKeyboardKey.keyX, + '0007001c': PhysicalKeyboardKey.keyY, + '0007001d': PhysicalKeyboardKey.keyZ, + }; + + // Map for pattern-based key name matching (lowercase keys for case-insensitive matching) + static const _keyNameMap = { + 'space': PhysicalKeyboardKey.space, + 'backspace': PhysicalKeyboardKey.backspace, + 'delete': PhysicalKeyboardKey.delete, + 'enter': PhysicalKeyboardKey.enter, + 'escape': PhysicalKeyboardKey.escape, + 'tab': PhysicalKeyboardKey.tab, + 'capslock': PhysicalKeyboardKey.capsLock, + 'arrowleft': PhysicalKeyboardKey.arrowLeft, + 'arrowup': PhysicalKeyboardKey.arrowUp, + 'arrowright': PhysicalKeyboardKey.arrowRight, + 'arrowdown': PhysicalKeyboardKey.arrowDown, + 'home': PhysicalKeyboardKey.home, + 'end': PhysicalKeyboardKey.end, + 'pageup': PhysicalKeyboardKey.pageUp, + 'pagedown': PhysicalKeyboardKey.pageDown, + 'equal': PhysicalKeyboardKey.equal, + 'minus': PhysicalKeyboardKey.minus, + }; + + // Function keys map + static const _functionKeyMap = { + 'f1': PhysicalKeyboardKey.f1, + 'f2': PhysicalKeyboardKey.f2, + 'f3': PhysicalKeyboardKey.f3, + 'f4': PhysicalKeyboardKey.f4, + 'f5': PhysicalKeyboardKey.f5, + 'f6': PhysicalKeyboardKey.f6, + 'f7': PhysicalKeyboardKey.f7, + 'f8': PhysicalKeyboardKey.f8, + 'f9': PhysicalKeyboardKey.f9, + 'f10': PhysicalKeyboardKey.f10, + 'f11': PhysicalKeyboardKey.f11, + 'f12': PhysicalKeyboardKey.f12, + }; + + // Digit keys map + static const _digitKeyMap = { + 'digit0': PhysicalKeyboardKey.digit0, + 'digit1': PhysicalKeyboardKey.digit1, + 'digit2': PhysicalKeyboardKey.digit2, + 'digit3': PhysicalKeyboardKey.digit3, + 'digit4': PhysicalKeyboardKey.digit4, + 'digit5': PhysicalKeyboardKey.digit5, + 'digit6': PhysicalKeyboardKey.digit6, + 'digit7': PhysicalKeyboardKey.digit7, + 'digit8': PhysicalKeyboardKey.digit8, + 'digit9': PhysicalKeyboardKey.digit9, + }; + + // Letter keys map + static const _letterKeyMap = { + 'keya': PhysicalKeyboardKey.keyA, + 'keyb': PhysicalKeyboardKey.keyB, + 'keyc': PhysicalKeyboardKey.keyC, + 'keyd': PhysicalKeyboardKey.keyD, + 'keye': PhysicalKeyboardKey.keyE, + 'keyf': PhysicalKeyboardKey.keyF, + 'keyg': PhysicalKeyboardKey.keyG, + 'keyh': PhysicalKeyboardKey.keyH, + 'keyi': PhysicalKeyboardKey.keyI, + 'keyj': PhysicalKeyboardKey.keyJ, + 'keyk': PhysicalKeyboardKey.keyK, + 'keyl': PhysicalKeyboardKey.keyL, + 'keym': PhysicalKeyboardKey.keyM, + 'keyn': PhysicalKeyboardKey.keyN, + 'keyo': PhysicalKeyboardKey.keyO, + 'keyp': PhysicalKeyboardKey.keyP, + 'keyq': PhysicalKeyboardKey.keyQ, + 'keyr': PhysicalKeyboardKey.keyR, + 'keys': PhysicalKeyboardKey.keyS, + 'keyt': PhysicalKeyboardKey.keyT, + 'keyu': PhysicalKeyboardKey.keyU, + 'keyv': PhysicalKeyboardKey.keyV, + 'keyw': PhysicalKeyboardKey.keyW, + 'keyx': PhysicalKeyboardKey.keyX, + 'keyy': PhysicalKeyboardKey.keyY, + 'keyz': PhysicalKeyboardKey.keyZ, + }; + // Helper method to find PhysicalKeyboardKey by string representation PhysicalKeyboardKey? _findKeyByString(String keyString) { - // Handle exact string matches first for better performance - const keyMap = { - 'PhysicalKeyboardKey#0002c': PhysicalKeyboardKey.space, - 'PhysicalKeyboardKey#7002a': PhysicalKeyboardKey.backspace, - 'PhysicalKeyboardKey#7004c': PhysicalKeyboardKey.delete, - 'PhysicalKeyboardKey#70028': PhysicalKeyboardKey.enter, - 'PhysicalKeyboardKey#70029': PhysicalKeyboardKey.escape, - 'PhysicalKeyboardKey#7002b': PhysicalKeyboardKey.tab, - 'PhysicalKeyboardKey#7004a': PhysicalKeyboardKey.home, - 'PhysicalKeyboardKey#7004d': PhysicalKeyboardKey.end, - 'PhysicalKeyboardKey#7004b': PhysicalKeyboardKey.pageUp, - 'PhysicalKeyboardKey#7004e': PhysicalKeyboardKey.pageDown, - 'PhysicalKeyboardKey#70050': PhysicalKeyboardKey.arrowLeft, - 'PhysicalKeyboardKey#70052': PhysicalKeyboardKey.arrowUp, - 'PhysicalKeyboardKey#7004f': PhysicalKeyboardKey.arrowRight, - 'PhysicalKeyboardKey#70051': PhysicalKeyboardKey.arrowDown, - }; + final normalized = keyString.toLowerCase(); - // Check exact matches first - if (keyMap.containsKey(keyString)) { - return keyMap[keyString]; - } - - // Alternative approach: extract USB HID usage code from the toString() output + // Try extracting USB HID code from toString() output // Format: PhysicalKeyboardKey#ec9ed(usbHidUsage: "0x0007002c", debugName: "Space") - try { - final usbHidMatch = RegExp( - r'usbHidUsage: "0x([0-9a-fA-F]+)"', - ).firstMatch(keyString); - if (usbHidMatch != null) { - final usbHidCode = usbHidMatch.group(1)!.toLowerCase(); - - // Map USB HID codes to PhysicalKeyboardKey objects - const usbHidMap = { - '0007002c': PhysicalKeyboardKey.space, - '0007002a': PhysicalKeyboardKey.backspace, - '0007004c': PhysicalKeyboardKey.delete, - '00070028': PhysicalKeyboardKey.enter, - '00070029': PhysicalKeyboardKey.escape, - '0007002b': PhysicalKeyboardKey.tab, - '00070039': PhysicalKeyboardKey.capsLock, - // Function keys - '0007003a': PhysicalKeyboardKey.f1, - '0007003b': PhysicalKeyboardKey.f2, - '0007003c': PhysicalKeyboardKey.f3, - '0007003d': PhysicalKeyboardKey.f4, - '0007003e': PhysicalKeyboardKey.f5, - '0007003f': PhysicalKeyboardKey.f6, - '00070040': PhysicalKeyboardKey.f7, - '00070041': PhysicalKeyboardKey.f8, - '00070042': PhysicalKeyboardKey.f9, - '00070043': PhysicalKeyboardKey.f10, - '00070044': PhysicalKeyboardKey.f11, - '00070045': PhysicalKeyboardKey.f12, - // Number keys - '00070027': PhysicalKeyboardKey.digit0, - '0007001e': PhysicalKeyboardKey.digit1, - '0007001f': PhysicalKeyboardKey.digit2, - '00070020': PhysicalKeyboardKey.digit3, - '00070021': PhysicalKeyboardKey.digit4, - '00070022': PhysicalKeyboardKey.digit5, - '00070023': PhysicalKeyboardKey.digit6, - '00070024': PhysicalKeyboardKey.digit7, - '00070025': PhysicalKeyboardKey.digit8, - '00070026': PhysicalKeyboardKey.digit9, - // Letter keys - '00070004': PhysicalKeyboardKey.keyA, - '00070005': PhysicalKeyboardKey.keyB, - '00070006': PhysicalKeyboardKey.keyC, - '00070007': PhysicalKeyboardKey.keyD, - '00070008': PhysicalKeyboardKey.keyE, - '00070009': PhysicalKeyboardKey.keyF, - '0007000a': PhysicalKeyboardKey.keyG, - '0007000b': PhysicalKeyboardKey.keyH, - '0007000c': PhysicalKeyboardKey.keyI, - '0007000d': PhysicalKeyboardKey.keyJ, - '0007000e': PhysicalKeyboardKey.keyK, - '0007000f': PhysicalKeyboardKey.keyL, - '00070010': PhysicalKeyboardKey.keyM, - '00070011': PhysicalKeyboardKey.keyN, - '00070012': PhysicalKeyboardKey.keyO, - '00070013': PhysicalKeyboardKey.keyP, - '00070014': PhysicalKeyboardKey.keyQ, - '00070015': PhysicalKeyboardKey.keyR, - '00070016': PhysicalKeyboardKey.keyS, - '00070017': PhysicalKeyboardKey.keyT, - '00070018': PhysicalKeyboardKey.keyU, - '00070019': PhysicalKeyboardKey.keyV, - '0007001a': PhysicalKeyboardKey.keyW, - '0007001b': PhysicalKeyboardKey.keyX, - '0007001c': PhysicalKeyboardKey.keyY, - '0007001d': PhysicalKeyboardKey.keyZ, - // Arrow keys - '00070050': PhysicalKeyboardKey.arrowLeft, - '00070052': PhysicalKeyboardKey.arrowUp, - '0007004f': PhysicalKeyboardKey.arrowRight, - '00070051': PhysicalKeyboardKey.arrowDown, - // Other common keys - '0007002d': PhysicalKeyboardKey.equal, - '0007002e': PhysicalKeyboardKey.minus, - '0007004a': PhysicalKeyboardKey.home, - '0007004d': PhysicalKeyboardKey.end, - '0007004b': PhysicalKeyboardKey.pageUp, - '0007004e': PhysicalKeyboardKey.pageDown, - }; - - if (usbHidMap.containsKey(usbHidCode)) { - return usbHidMap[usbHidCode]; - } - } - } catch (e) { - // Ignore parsing errors + final usbHidMatch = RegExp(r'usbhidusage: "0x([0-9a-f]+)"').firstMatch(normalized); + if (usbHidMatch != null) { + final usbHidCode = usbHidMatch.group(1)!; + final key = _usbHidKeyMap[usbHidCode]; + if (key != null) return key; } - // Fall back to contains() checks for partial matches - if (keyString.contains('space')) { - return PhysicalKeyboardKey.space; - } else if (keyString.contains('arrowUp')) { - return PhysicalKeyboardKey.arrowUp; - } else if (keyString.contains('arrowDown')) { - return PhysicalKeyboardKey.arrowDown; - } else if (keyString.contains('arrowLeft')) { - return PhysicalKeyboardKey.arrowLeft; - } else if (keyString.contains('arrowRight')) { - return PhysicalKeyboardKey.arrowRight; - } else if (keyString.contains('equal')) { - return PhysicalKeyboardKey.equal; - } else if (keyString.contains('minus')) { - return PhysicalKeyboardKey.minus; - } else if (keyString.contains('escape')) { - return PhysicalKeyboardKey.escape; - } else if (keyString.contains('enter')) { - return PhysicalKeyboardKey.enter; - } else if (keyString.contains('tab')) { - return PhysicalKeyboardKey.tab; - } else if (keyString.contains('backspace')) { - return PhysicalKeyboardKey.backspace; - } else if (keyString.contains('delete')) { - return PhysicalKeyboardKey.delete; - } else if (keyString.contains('home')) { - return PhysicalKeyboardKey.home; - } else if (keyString.contains('end')) { - return PhysicalKeyboardKey.end; - } else if (keyString.contains('pageUp')) { - return PhysicalKeyboardKey.pageUp; - } else if (keyString.contains('pageDown')) { - return PhysicalKeyboardKey.pageDown; - } else { - // Try function keys F1-F12 - for (int i = 1; i <= 12; i++) { - if (keyString.contains('f$i') || keyString.contains('F$i')) { - switch (i) { - case 1: - return PhysicalKeyboardKey.f1; - case 2: - return PhysicalKeyboardKey.f2; - case 3: - return PhysicalKeyboardKey.f3; - case 4: - return PhysicalKeyboardKey.f4; - case 5: - return PhysicalKeyboardKey.f5; - case 6: - return PhysicalKeyboardKey.f6; - case 7: - return PhysicalKeyboardKey.f7; - case 8: - return PhysicalKeyboardKey.f8; - case 9: - return PhysicalKeyboardKey.f9; - case 10: - return PhysicalKeyboardKey.f10; - case 11: - return PhysicalKeyboardKey.f11; - case 12: - return PhysicalKeyboardKey.f12; - } - } + // Try direct name matches + for (final entry in _keyNameMap.entries) { + if (normalized.contains(entry.key)) { + return entry.value; } - - // Try number keys 0-9 - for (int i = 0; i <= 9; i++) { - if (keyString.contains('digit$i') || keyString.contains('Digit$i')) { - switch (i) { - case 0: - return PhysicalKeyboardKey.digit0; - case 1: - return PhysicalKeyboardKey.digit1; - case 2: - return PhysicalKeyboardKey.digit2; - case 3: - return PhysicalKeyboardKey.digit3; - case 4: - return PhysicalKeyboardKey.digit4; - case 5: - return PhysicalKeyboardKey.digit5; - case 6: - return PhysicalKeyboardKey.digit6; - case 7: - return PhysicalKeyboardKey.digit7; - case 8: - return PhysicalKeyboardKey.digit8; - case 9: - return PhysicalKeyboardKey.digit9; - } - } - } - - // Try letter keys A-Z (both upper and lower case patterns) - const letterKeys = { - 'A': PhysicalKeyboardKey.keyA, - 'B': PhysicalKeyboardKey.keyB, - 'C': PhysicalKeyboardKey.keyC, - 'D': PhysicalKeyboardKey.keyD, - 'E': PhysicalKeyboardKey.keyE, - 'F': PhysicalKeyboardKey.keyF, - 'G': PhysicalKeyboardKey.keyG, - 'H': PhysicalKeyboardKey.keyH, - 'I': PhysicalKeyboardKey.keyI, - 'J': PhysicalKeyboardKey.keyJ, - 'K': PhysicalKeyboardKey.keyK, - 'L': PhysicalKeyboardKey.keyL, - 'M': PhysicalKeyboardKey.keyM, - 'N': PhysicalKeyboardKey.keyN, - 'O': PhysicalKeyboardKey.keyO, - 'P': PhysicalKeyboardKey.keyP, - 'Q': PhysicalKeyboardKey.keyQ, - 'R': PhysicalKeyboardKey.keyR, - 'S': PhysicalKeyboardKey.keyS, - 'T': PhysicalKeyboardKey.keyT, - 'U': PhysicalKeyboardKey.keyU, - 'V': PhysicalKeyboardKey.keyV, - 'W': PhysicalKeyboardKey.keyW, - 'X': PhysicalKeyboardKey.keyX, - 'Y': PhysicalKeyboardKey.keyY, - 'Z': PhysicalKeyboardKey.keyZ, - }; - - for (final entry in letterKeys.entries) { - if (keyString.contains('key${entry.key}') || - keyString.contains('Key${entry.key}')) { - return entry.value; - } - } - - return null; } + + // Try function keys (check longer patterns first to avoid f1 matching f10) + for (final entry in _functionKeyMap.entries) { + if (normalized.contains(entry.key)) { + return entry.value; + } + } + + // Try digit keys + for (final entry in _digitKeyMap.entries) { + if (normalized.contains(entry.key)) { + return entry.value; + } + } + + // Try letter keys + for (final entry in _letterKeyMap.entries) { + if (normalized.contains(entry.key)) { + return entry.value; + } + } + + return null; } // Media Version Preferences diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ce446f13..9aafed7a 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -22,6 +22,33 @@ class StorageService extends BaseSharedPreferencesService { static const String _keyEnabledServers = 'enabled_servers'; static const String _keyServerOrder = 'server_order'; + // Key prefixes for per-id storage + static const String _prefixServerEndpoint = 'server_endpoint_'; + static const String _prefixLibraryFilters = 'library_filters_'; + static const String _prefixLibrarySort = 'library_sort_'; + static const String _prefixLibraryGrouping = 'library_grouping_'; + static const String _prefixLibraryTab = 'library_tab_'; + + // Key groups for bulk clearing + static const List _credentialKeys = [ + _keyServerUrl, + _keyToken, + _keyPlexToken, + _keyServerData, + _keyClientId, + _keyUserProfile, + _keyCurrentUserUUID, + _keyHomeUsersCache, + _keyHomeUsersCacheExpiry, + ]; + + static const List _libraryPreferenceKeys = [ + _keySelectedLibraryIndex, + _keyLibraryFilters, + _keyLibraryOrder, + _keyHiddenLibraries, + ]; + StorageService._(); static Future getInstance() async { @@ -50,16 +77,16 @@ class StorageService extends BaseSharedPreferencesService { // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(String serverId, String url) async { - await prefs.setString('server_endpoint_$serverId', url); + await prefs.setString('$_prefixServerEndpoint$serverId', url); LogRedactionManager.registerServerUrl(url); } String? getServerEndpoint(String serverId) { - return prefs.getString('server_endpoint_$serverId'); + return prefs.getString('$_prefixServerEndpoint$serverId'); } Future clearServerEndpoint(String serverId) async { - await prefs.remove('server_endpoint_$serverId'); + await prefs.remove('$_prefixServerEndpoint$serverId'); } // Server Access Token @@ -93,8 +120,7 @@ class StorageService extends BaseSharedPreferencesService { // Server Data (full PlexServer object as JSON) Future saveServerData(Map serverJson) async { - final jsonString = json.encode(serverJson); - await prefs.setString(_keyServerData, jsonString); + await _setJsonMap(_keyServerData, serverJson); } Map? getServerData() { @@ -131,15 +157,7 @@ class StorageService extends BaseSharedPreferencesService { // Clear all credentials Future clearCredentials() async { await Future.wait([ - prefs.remove(_keyServerUrl), - prefs.remove(_keyToken), - prefs.remove(_keyPlexToken), - prefs.remove(_keyServerData), - prefs.remove(_keyClientId), - prefs.remove(_keyUserProfile), - prefs.remove(_keyCurrentUserUUID), - prefs.remove(_keyHomeUsersCache), - prefs.remove(_keyHomeUsersCacheExpiry), + ..._credentialKeys.map((k) => prefs.remove(k)), clearMultiServerData(), ]); LogRedactionManager.clearTrackedValues(); @@ -172,16 +190,17 @@ class StorageService extends BaseSharedPreferencesService { Map filters, { String? sectionId, }) async { - final jsonString = json.encode(filters); final key = sectionId != null - ? 'library_filters_$sectionId' + ? '$_prefixLibraryFilters$sectionId' : _keyLibraryFilters; + // Note: using Map which json.encode handles correctly + final jsonString = json.encode(filters); await prefs.setString(key, jsonString); } Map getLibraryFilters({String? sectionId}) { final scopedKey = sectionId != null - ? 'library_filters_$sectionId' + ? '$_prefixLibraryFilters$sectionId' : _keyLibraryFilters; // Prefer per-library filters when available @@ -202,36 +221,34 @@ class StorageService extends BaseSharedPreferencesService { bool descending = false, }) async { final sortData = {'key': sortKey, 'descending': descending}; - await prefs.setString('library_sort_$sectionId', json.encode(sortData)); + await _setJsonMap('$_prefixLibrarySort$sectionId', sortData); } Map? getLibrarySort(String sectionId) { - return _readJsonMap('library_sort_$sectionId', legacyStringOk: true); + return _readJsonMap('$_prefixLibrarySort$sectionId', legacyStringOk: true); } // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') Future saveLibraryGrouping(String sectionId, String grouping) async { - await prefs.setString('library_grouping_$sectionId', grouping); + await prefs.setString('$_prefixLibraryGrouping$sectionId', grouping); } String? getLibraryGrouping(String sectionId) { - return prefs.getString('library_grouping_$sectionId'); + return prefs.getString('$_prefixLibraryGrouping$sectionId'); } // Library Tab (per-library, saves last selected tab index) Future saveLibraryTab(String sectionId, int tabIndex) async { - await prefs.setInt('library_tab_$sectionId', tabIndex); + await prefs.setInt('$_prefixLibraryTab$sectionId', tabIndex); } int? getLibraryTab(String sectionId) { - return prefs.getInt('library_tab_$sectionId'); + return prefs.getInt('$_prefixLibraryTab$sectionId'); } // Hidden Libraries (stored as JSON array of library section IDs) Future saveHiddenLibraries(Set libraryKeys) async { - final list = libraryKeys.toList(); - final jsonString = json.encode(list); - await prefs.setString(_keyHiddenLibraries, jsonString); + await _setStringList(_keyHiddenLibraries, libraryKeys.toList()); } Set getHiddenLibraries() { @@ -249,30 +266,24 @@ class StorageService extends BaseSharedPreferencesService { // Clear library preferences Future clearLibraryPreferences() async { await Future.wait([ - prefs.remove(_keySelectedLibraryIndex), - prefs.remove(_keyLibraryFilters), - prefs.remove(_keyLibraryOrder), - prefs.remove(_keyHiddenLibraries), + ..._libraryPreferenceKeys.map((k) => prefs.remove(k)), + _clearKeysWithPrefix(_prefixLibrarySort), + _clearKeysWithPrefix(_prefixLibraryFilters), + _clearKeysWithPrefix(_prefixLibraryGrouping), + _clearKeysWithPrefix(_prefixLibraryTab), ]); - - // Also clear all library sort preferences - final keys = prefs.getKeys(); - final sortKeys = keys.where((key) => key.startsWith('library_sort_')); - await Future.wait(sortKeys.map((key) => prefs.remove(key))); } // Library Order (stored as JSON list of library keys) Future saveLibraryOrder(List libraryKeys) async { - final jsonString = json.encode(libraryKeys); - await prefs.setString(_keyLibraryOrder, jsonString); + await _setStringList(_keyLibraryOrder, libraryKeys); } List? getLibraryOrder() => _getStringList(_keyLibraryOrder); // User Profile (stored as JSON string) Future saveUserProfile(Map profileJson) async { - final jsonString = json.encode(profileJson); - await prefs.setString(_keyUserProfile, jsonString); + await _setJsonMap(_keyUserProfile, profileJson); } Map? getUserProfile() { @@ -290,8 +301,7 @@ class StorageService extends BaseSharedPreferencesService { // Home Users Cache (stored as JSON string with expiry) Future saveHomeUsersCache(Map homeData) async { - final jsonString = json.encode(homeData); - await prefs.setString(_keyHomeUsersCache, jsonString); + await _setJsonMap(_keyHomeUsersCache, homeData); // Set cache expiry to 1 hour from now final expiry = DateTime.now() @@ -370,24 +380,17 @@ class StorageService extends BaseSharedPreferencesService { /// Clear all multi-server data Future clearMultiServerData() async { - // Clear all server endpoint caches - final keys = prefs.getKeys(); - final endpointKeys = keys.where( - (key) => key.startsWith('server_endpoint_'), - ); - await Future.wait([ clearServersList(), clearEnabledServers(), clearServerOrder(), - ...endpointKeys.map((key) => prefs.remove(key)), + _clearKeysWithPrefix(_prefixServerEndpoint), ]); } /// Server Order (stored as JSON list of server IDs) Future saveServerOrder(List serverIds) async { - final jsonString = json.encode(serverIds); - await prefs.setString(_keyServerOrder, jsonString); + await _setStringList(_keyServerOrder, serverIds); } List? getServerOrder() => _getStringList(_keyServerOrder); @@ -446,4 +449,24 @@ class StorageService extends BaseSharedPreferencesService { return {}; } } + + /// Remove all keys matching a prefix + Future _clearKeysWithPrefix(String prefix) async { + final keys = prefs.getKeys().where((k) => k.startsWith(prefix)); + await Future.wait(keys.map((k) => prefs.remove(k))); + } + + // Public JSON helpers for reducing boilerplate + + /// Save a JSON-encodable map to storage + Future _setJsonMap(String key, Map data) async { + final jsonString = json.encode(data); + await prefs.setString(key, jsonString); + } + + /// Save a string list as JSON array + Future _setStringList(String key, List list) async { + final jsonString = json.encode(list); + await prefs.setString(key, jsonString); + } } diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 34bddd5d..4ddf0ec0 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -18,6 +18,62 @@ class TrackSelectionService { required this.metadata, }); + /// Build list of preferred languages from a user profile + List _buildPreferredLanguages(PlexUserProfile profile, {required bool isAudio}) { + final primary = isAudio + ? profile.defaultAudioLanguage + : profile.defaultSubtitleLanguage; + final list = isAudio + ? profile.defaultAudioLanguages + : profile.defaultSubtitleLanguages; + + final result = []; + if (primary != null && primary.isNotEmpty) { + result.add(primary); + } + if (list != null) { + result.addAll(list); + } + return result; + } + + /// Find a track by preferred language with variation lookup and logging + T? _findTrackByPreferredLanguage( + List tracks, + String preferredLanguage, + String? Function(T) getLanguage, + String Function(T) getDescription, + String trackType, + ) { + final languageVariations = LanguageCodes.getVariations(preferredLanguage); + appLogger.d( + 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', + ); + + return _findTrackByLanguageVariations( + tracks, + preferredLanguage, + languageVariations, + getLanguage, + getDescription, + trackType, + ); + } + + /// Apply a filter to tracks, falling back to original if filter produces empty result + List _applyFilterWithFallback( + List tracks, + List Function(List) filter, + String filterDescription, + ) { + final filtered = filter(tracks); + if (filtered.isNotEmpty) { + return filtered; + } + appLogger.d('No tracks match $filterDescription, using all tracks'); + return tracks; + } + /// Generic track matching for audio and subtitle tracks /// Returns the best matching track based on hierarchical criteria: /// 1. Exact match (id + title + language) @@ -98,15 +154,7 @@ class TrackSelectionService { return null; } - // Build list of preferred languages - final preferredLanguages = []; - if (profile.defaultAudioLanguage != null && - profile.defaultAudioLanguage!.isNotEmpty) { - preferredLanguages.add(profile.defaultAudioLanguage!); - } - if (profile.defaultAudioLanguages != null) { - preferredLanguages.addAll(profile.defaultAudioLanguages!); - } + final preferredLanguages = _buildPreferredLanguages(profile, isAudio: true); if (preferredLanguages.isEmpty) { appLogger.d('Cannot use profile: No defaultAudioLanguage(s) specified'); @@ -117,16 +165,9 @@ class TrackSelectionService { // Try to find track matching any preferred language for (final preferredLanguage in preferredLanguages) { - // Get all possible language code variations (e.g., "en" → ["en", "eng"]) - final languageVariations = LanguageCodes.getVariations(preferredLanguage); - appLogger.d( - 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', - ); - - final match = _findTrackByLanguageVariations( + final match = _findTrackByPreferredLanguage( availableTracks, preferredLanguage, - languageVariations, (t) => t.language, (t) => t.title ?? 'Track ${t.id}', 'audio track', @@ -211,15 +252,7 @@ class TrackSelectionService { // Mode 2: Always enabled (or continuing from mode 1 with foreign audio) appLogger.d('Selecting subtitle track based on preferences'); - // Build list of preferred languages - final preferredLanguages = []; - if (profile.defaultSubtitleLanguage != null && - profile.defaultSubtitleLanguage!.isNotEmpty) { - preferredLanguages.add(profile.defaultSubtitleLanguage!); - } - if (profile.defaultSubtitleLanguages != null) { - preferredLanguages.addAll(profile.defaultSubtitleLanguages!); - } + final preferredLanguages = _buildPreferredLanguages(profile, isAudio: false); if (preferredLanguages.isEmpty) { appLogger.d( @@ -230,7 +263,7 @@ class TrackSelectionService { appLogger.d('Preferred languages: ${preferredLanguages.join(", ")}'); - // Apply filtering based on preferences + // Apply filtering with fallback to original tracks if filter produces empty result var candidateTracks = availableTracks; // Filter by SDH (defaultSubtitleAccessibility: 0-3) @@ -246,22 +279,17 @@ class TrackSelectionService { ); // If no candidates after filtering, relax filters - if (candidateTracks.isEmpty) { - appLogger.d('No tracks match strict filters, relaxing filters'); - candidateTracks = availableTracks; - } + candidateTracks = _applyFilterWithFallback( + availableTracks, + (_) => candidateTracks, + 'strict filters', + ); // Try to find track matching any preferred language for (final preferredLanguage in preferredLanguages) { - final languageVariations = LanguageCodes.getVariations(preferredLanguage); - appLogger.d( - 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', - ); - - final match = _findTrackByLanguageVariations( + final match = _findTrackByPreferredLanguage( candidateTracks, preferredLanguage, - languageVariations, (t) => t.language, (t) => t.title ?? 'Track ${t.id}', 'subtitle', diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index 33813c25..6f6fcd0a 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -63,15 +63,20 @@ class UpdateService { await prefs.setString(_keyLastCheckTime, DateTime.now().toIso8601String()); } - /// Check for updates on GitHub (manual check, ignores cooldown) - /// Returns a map with update info, or null if no update or error - static Future?> checkForUpdates({ - bool silent = false, + /// Internal method that performs the actual update check + /// [respectCooldown] - if true, checks cooldown and updates last check time + static Future?> _performUpdateCheck({ + required bool respectCooldown, }) async { if (!isUpdateCheckEnabled) { return null; } + // Check cooldown if requested + if (respectCooldown && !await shouldCheckForUpdates()) { + return null; + } + try { final packageInfo = await PackageInfo.fromPlatform(); final currentVersion = packageInfo.version; @@ -94,12 +99,21 @@ class UpdateService { final hasUpdate = _isNewerVersion(cleanVersion, currentVersion); if (hasUpdate) { - // Check if this version was skipped (always check, regardless of silent mode) + // Check if this version was skipped final skippedVersion = await getSkippedVersion(); if (skippedVersion == cleanVersion) { + // Update last check time even when skipped (if respecting cooldown) + if (respectCooldown) { + await _updateLastCheckTime(); + } return null; } + // Update last check time on success (if respecting cooldown) + if (respectCooldown) { + await _updateLastCheckTime(); + } + return { 'hasUpdate': true, 'currentVersion': currentVersion, @@ -111,6 +125,11 @@ class UpdateService { }; } } + + // Update last check time even when no update (if respecting cooldown) + if (respectCooldown) { + await _updateLastCheckTime(); + } } catch (e) { _logger.e('Failed to check for updates: $e'); } @@ -118,25 +137,18 @@ class UpdateService { return null; } + /// Check for updates on GitHub (manual check, ignores cooldown) + /// Returns a map with update info, or null if no update or error + static Future?> checkForUpdates({ + bool silent = false, + }) async { + return _performUpdateCheck(respectCooldown: false); + } + /// Check for updates on startup (respects cooldown and skipped versions) /// Returns update info if available, null otherwise static Future?> checkForUpdatesOnStartup() async { - if (!isUpdateCheckEnabled) { - return null; - } - - // Check cooldown - if (!await shouldCheckForUpdates()) { - return null; - } - - // Perform the check - final updateInfo = await checkForUpdates(silent: true); - - // Update last check time - await _updateLastCheckTime(); - - return updateInfo; + return _performUpdateCheck(respectCooldown: true); } /// Parse version string into list of integers diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart deleted file mode 100644 index c4c9efae..00000000 --- a/lib/utils/collection_playlist_play_helper.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import '../services/plex_client.dart'; -import '../services/play_queue_launcher.dart'; -import '../models/plex_metadata.dart'; -import '../models/plex_playlist.dart'; - -/// Helper function to play a collection or playlist. -/// -/// This is a convenience wrapper around [PlayQueueLauncher.launchFromCollectionOrPlaylist]. -Future playCollectionOrPlaylist({ - required BuildContext context, - required PlexClient client, - required dynamic item, // PlexMetadata (collection) or PlexPlaylist - required bool shuffle, -}) async { - final launcher = PlayQueueLauncher( - context: context, - client: client, - serverId: item is PlexMetadata - ? item.serverId - : (item as PlexPlaylist).serverId, - serverName: item is PlexMetadata - ? item.serverName - : (item as PlexPlaylist).serverName, - ); - - await launcher.launchFromCollectionOrPlaylist( - item: item, - shuffle: shuffle, - showLoadingIndicator: false, // Caller typically handles loading UI - ); -} diff --git a/lib/utils/grid_cross_axis_extent.dart b/lib/utils/grid_cross_axis_extent.dart deleted file mode 100644 index 812d010f..00000000 --- a/lib/utils/grid_cross_axis_extent.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import '../services/settings_service.dart'; - -/// Calculates the max cross-axis extent for grid items, accounting for outer padding. -double getMaxCrossAxisExtentWithPadding( - BuildContext context, - LibraryDensity density, - double horizontalPadding, -) { - final screenWidth = MediaQuery.of(context).size.width; - final availableWidth = screenWidth - horizontalPadding; - - if (screenWidth >= 900) { - // Wide screens (desktop/large tablet landscape): Responsive division - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(0, maxItemWidth); - } else if (screenWidth >= 600) { - // Medium screens (tablets): Fixed 4-5-6 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 4, - LibraryDensity.normal => 5, - LibraryDensity.compact => 6, - }; - return availableWidth / targetItemCount; - } else { - // Small screens (phones): Fixed 2-3-4 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 2, - LibraryDensity.normal => 3, - LibraryDensity.compact => 4, - }; - return availableWidth / targetItemCount; - } -} diff --git a/lib/utils/grid_size_calculator.dart b/lib/utils/grid_size_calculator.dart index b8408ef1..4d1c567c 100644 --- a/lib/utils/grid_size_calculator.dart +++ b/lib/utils/grid_size_calculator.dart @@ -36,6 +36,57 @@ class GridSizeCalculator { } } + /// Calculates the max cross-axis extent accounting for outer padding. + /// + /// Uses responsive strategies: + /// - Wide screens (>=900px): Divisor-based calculation with max item width + /// - Medium screens (600-899px): Fixed item count (4-6 items based on density) + /// - Small screens (<600px): Fixed item count (2-4 items based on density) + static double getMaxCrossAxisExtentWithPadding( + BuildContext context, + LibraryDensity density, + double horizontalPadding, + ) { + final screenWidth = MediaQuery.of(context).size.width; + final availableWidth = screenWidth - horizontalPadding; + + if (ScreenBreakpoints.isWideTabletOrLarger(screenWidth)) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (ScreenBreakpoints.isTablet(screenWidth)) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } + } + /// Returns whether the current screen is a desktop-sized screen static bool isDesktop(BuildContext context) { return MediaQuery.of(context).size.width > desktopBreakpoint; diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index 91c5aaa6..c35550c3 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -1,14 +1,45 @@ +import 'package:flutter/widgets.dart'; + /// Layout and sizing constants used throughout the application /// Screen width breakpoints for responsive design class ScreenBreakpoints { - /// Breakpoint for tablet devices (600px) - static const double tablet = 600; + /// Breakpoint for mobile devices (< 600px) + static const double mobile = 600; + + /// Breakpoint for wide tablets / small desktops (900px) + /// Used for intermediate responsive layouts + static const double wideTablet = 900; /// Breakpoint for desktop devices (1200px) static const double desktop = 1200; /// Breakpoint for large desktop devices (1600px) static const double largeDesktop = 1600; + + // Legacy alias for backward compatibility + static const double tablet = mobile; + + /// Whether width is mobile-sized (< 600px) + static bool isMobile(double width) => width < mobile; + + /// Whether width is tablet-sized (600px - 1199px) + static bool isTablet(double width) => width >= mobile && width < desktop; + + /// Whether width is wide tablet (900px - 1199px) + /// Useful for layouts that need more columns than phone but less than desktop + static bool isWideTablet(double width) => width >= wideTablet && width < desktop; + + /// Whether width is desktop-sized (1200px - 1599px) + static bool isDesktop(double width) => width >= desktop && width < largeDesktop; + + /// Whether width is large desktop-sized (>= 1600px) + static bool isLargeDesktop(double width) => width >= largeDesktop; + + /// Whether width is desktop or larger (>= 1200px) + static bool isDesktopOrLarger(double width) => width >= desktop; + + /// Whether width is wide tablet or larger (>= 900px) + static bool isWideTabletOrLarger(double width) => width >= wideTablet; } /// Grid layout constants @@ -31,7 +62,10 @@ class GridLayoutConstants { /// Default aspect ratio for media cards (poster) static const double posterAspectRatio = 2 / 3.3; - /// Grid spacing + /// Grid spacing (edge-to-edge cards) static const double crossAxisSpacing = 0; static const double mainAxisSpacing = 0; + + /// Standard grid padding + static EdgeInsets get gridPadding => const EdgeInsets.fromLTRB(8, 0, 8, 8); } diff --git a/lib/utils/library_refresh_notifier.dart b/lib/utils/library_refresh_notifier.dart index 712348f0..e732d09c 100644 --- a/lib/utils/library_refresh_notifier.dart +++ b/lib/utils/library_refresh_notifier.dart @@ -1,7 +1,12 @@ import 'dart:async'; -/// Notifier for triggering refreshes of library tabs -/// Singleton pattern for global access +/// Types of library refresh events +enum LibraryRefreshType { collections, playlists } + +/// Notifier for triggering refreshes of library tabs. +/// +/// Singleton pattern with reinitializable state. The controller is lazily +/// created and automatically recreated if disposed and later accessed. class LibraryRefreshNotifier { static final LibraryRefreshNotifier _instance = LibraryRefreshNotifier._internal(); @@ -10,30 +15,41 @@ class LibraryRefreshNotifier { LibraryRefreshNotifier._internal(); - // Stream controllers for different tab types - final _collectionsController = StreamController.broadcast(); - final _playlistsController = StreamController.broadcast(); + /// Unified stream controller (lazily created, reinitializable) + StreamController? _controller; - // Streams that tabs can listen to - Stream get collectionsStream => _collectionsController.stream; - Stream get playlistsStream => _playlistsController.stream; + /// Ensure controller exists (creates if null or closed) + StreamController get _ensureController { + if (_controller == null || _controller!.isClosed) { + _controller = StreamController.broadcast(); + } + return _controller!; + } - // Methods to trigger refreshes + /// Unified stream of all refresh events + Stream get stream => _ensureController.stream; + + /// Stream for collections tab (backward compatible) + Stream get collectionsStream => + stream.where((t) => t == LibraryRefreshType.collections).map((_) {}); + + /// Stream for playlists tab (backward compatible) + Stream get playlistsStream => + stream.where((t) => t == LibraryRefreshType.playlists).map((_) {}); + + /// Notify that collections have changed void notifyCollectionsChanged() { - if (!_collectionsController.isClosed) { - _collectionsController.add(null); - } + _ensureController.add(LibraryRefreshType.collections); } + /// Notify that playlists have changed void notifyPlaylistsChanged() { - if (!_playlistsController.isClosed) { - _playlistsController.add(null); - } + _ensureController.add(LibraryRefreshType.playlists); } - // Cleanup + /// Dispose controller (can be reinitialized later by accessing stream) void dispose() { - _collectionsController.close(); - _playlistsController.close(); + _controller?.close(); + _controller = null; } } diff --git a/lib/utils/log_redaction_manager.dart b/lib/utils/log_redaction_manager.dart index 15437c9e..1c201845 100644 --- a/lib/utils/log_redaction_manager.dart +++ b/lib/utils/log_redaction_manager.dart @@ -1,24 +1,38 @@ +import 'dart:collection'; + class LogRedactionManager { - static final Set _tokens = {}; - static final Set _urls = {}; - static final Set _customValues = {}; + // Size limits for bounded sets (FIFO eviction when exceeded) + static const int _maxTokens = 50; + static const int _maxUrls = 20; + static const int _maxCustomValues = 50; + + // Use LinkedHashSet for FIFO ordering + static final Set _tokens = LinkedHashSet(); + static final Set _urls = LinkedHashSet(); + static final Set _customValues = LinkedHashSet(); + static final RegExp _ipv4Pattern = RegExp( r'\b(\d{1,3})([.-])(\d{1,3})\2(\d{1,3})\2(\d{1,3})\b', ); static final RegExp _ipv4HostPattern = RegExp(r'^\d{1,3}([.-]\d{1,3}){3}$'); + // Combined regex for single-pass redaction (rebuilt on set changes) + static RegExp? _combinedPattern; + /// Register a server access token or Plex.tv token for redaction. static void registerToken(String? token) { final normalized = _normalize(token); if (normalized == null) return; - _tokens.add(normalized); + _addWithLimit(_tokens, normalized, _maxTokens); // Tokens often appear URL encoded in query params. final encoded = Uri.encodeQueryComponent(normalized); if (encoded != normalized) { - _tokens.add(encoded); + _addWithLimit(_tokens, encoded, _maxTokens); } + + _rebuildCombinedPattern(); } /// Register the server/base URL currently in use. @@ -42,26 +56,29 @@ class LogRedactionManager { : normalized; if (strippedSlash.isNotEmpty) { - _urls.add(strippedSlash); - _urls.add('$strippedSlash/'); // Include trailing slash variant. + _addWithLimit(_urls, strippedSlash, _maxUrls); + _addWithLimit(_urls, '$strippedSlash/', _maxUrls); } // Capture origin and host-level strings as well to cover most cases. if (uri != null && uri.host.isNotEmpty) { final origin = '${uri.scheme.isEmpty ? 'https' : uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; - _urls.add(origin); + _addWithLimit(_urls, origin, _maxUrls); if (origin.endsWith('/')) { - _urls.add(origin.substring(0, origin.length - 1)); + _addWithLimit(_urls, origin.substring(0, origin.length - 1), _maxUrls); } } + + _rebuildCombinedPattern(); } /// Register other sensitive values that need redaction. static void registerCustomValue(String? value) { final normalized = _normalize(value); if (normalized == null) return; - _customValues.add(normalized); + _addWithLimit(_customValues, normalized, _maxCustomValues); + _rebuildCombinedPattern(); } /// Reset any tracked sensitive values (e.g., on logout). @@ -69,32 +86,59 @@ class LogRedactionManager { _tokens.clear(); _urls.clear(); _customValues.clear(); + _combinedPattern = null; } /// Redact known sensitive values from the provided message. static String redact(String message) { - var redacted = message; - - redacted = redacted.replaceAllMapped( + // Pass 1: IPv4 addresses (regex pattern) + var redacted = message.replaceAllMapped( _ipv4Pattern, (match) => _maskIpv4(match.group(1)!, match.group(2)!, match.group(5)!), ); - for (final url in _urls) { - redacted = redacted.replaceAll(url, _maskUrlPreview(url)); - } - - for (final token in _tokens) { - redacted = redacted.replaceAll(token, '[REDACTED_TOKEN]'); - } - - for (final custom in _customValues) { - redacted = redacted.replaceAll(custom, '[REDACTED]'); + // Pass 2: All tracked values in single pass + if (_combinedPattern != null) { + redacted = redacted.replaceAllMapped(_combinedPattern!, (match) { + final value = match.group(0)!; + if (_tokens.contains(value)) return '[REDACTED_TOKEN]'; + if (_urls.contains(value)) return _maskUrlPreview(value); + return '[REDACTED]'; + }); } return redacted; } + /// Rebuild the combined regex pattern from all tracked values. + static void _rebuildCombinedPattern() { + final allLiterals = [ + ..._tokens.map(RegExp.escape), + ..._urls.map(RegExp.escape), + ..._customValues.map(RegExp.escape), + ]; + + if (allLiterals.isEmpty) { + _combinedPattern = null; + return; + } + + // Sort by length descending so longer matches are preferred + allLiterals.sort((a, b) => b.length.compareTo(a.length)); + _combinedPattern = RegExp(allLiterals.join('|')); + } + + /// Add value to set with FIFO eviction if limit exceeded. + static void _addWithLimit(Set set, String value, int maxSize) { + if (set.contains(value)) return; // Already tracked + + // Evict oldest entries if at capacity + while (set.length >= maxSize) { + set.remove(set.first); + } + set.add(value); + } + static String? _normalize(String? value) { if (value == null) return null; final trimmed = value.trim(); diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index f49bc036..bfdb0630 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -1,24 +1,45 @@ import 'package:flutter/material.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; +import '../screens/collection_detail_screen.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; import '../screens/playlist/playlist_detail_screen.dart'; import 'video_player_navigation.dart'; +/// Result of media navigation indicating what action was taken +enum MediaNavigationResult { + /// Navigation completed successfully + navigated, + /// Navigation completed, parent list should be refreshed (e.g., collection deleted) + listRefreshNeeded, + /// Item type not supported (e.g., music content) + unsupported, +} + /// Navigates to the appropriate screen based on the item type. /// /// For episodes, starts playback directly via video player. /// For seasons, navigates to season detail screen. /// For playlists, navigates to playlist detail screen. +/// For collections, navigates to collection detail screen. /// For other types (shows, movies), navigates to media detail screen. +/// For music types (artist, album, track), returns [MediaNavigationResult.unsupported]. /// /// The [onRefresh] callback is invoked with the item's ratingKey after /// returning from the detail screen, allowing the caller to refresh state. -Future navigateToMediaItem( +/// +/// Set [isOffline] to true for downloaded content without server access. +/// +/// Returns a [MediaNavigationResult] indicating what action was taken: +/// - [MediaNavigationResult.navigated]: Navigation completed, item refresh handled +/// - [MediaNavigationResult.listRefreshNeeded]: Caller should refresh entire list +/// - [MediaNavigationResult.unsupported]: Item type not supported, caller should handle +Future navigateToMediaItem( BuildContext context, dynamic item, { void Function(String)? onRefresh, + bool isOffline = false, }) async { // Handle playlists if (item is PlexPlaylist) { @@ -28,33 +49,67 @@ Future navigateToMediaItem( builder: (context) => PlaylistDetailScreen(playlist: item), ), ); - return; + return MediaNavigationResult.navigated; } - final itemType = (item as PlexMetadata).type.toLowerCase(); + final metadata = item as PlexMetadata; - // For episodes, start playback directly - if (itemType == 'episode') { - final result = await navigateToVideoPlayer(context, metadata: item); - if (result == true) { - onRefresh?.call(item.ratingKey); - } - } else if (itemType == 'season') { - await Navigator.push( - context, - MaterialPageRoute(builder: (context) => SeasonDetailScreen(season: item)), - ); - onRefresh?.call(item.ratingKey); - } else { - // For all other types (shows, movies), show detail screen - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MediaDetailScreen(metadata: item), - ), - ); - if (result == true) { - onRefresh?.call(item.ratingKey); - } + switch (metadata.mediaType) { + case PlexMediaType.collection: + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CollectionDetailScreen(collection: metadata), + ), + ); + // If collection was deleted, signal that list refresh is needed + if (result == true) { + return MediaNavigationResult.listRefreshNeeded; + } + return MediaNavigationResult.navigated; + + case PlexMediaType.artist: + case PlexMediaType.album: + case PlexMediaType.track: + // Music types not supported + return MediaNavigationResult.unsupported; + + case PlexMediaType.episode: + // For episodes, start playback directly + final result = await navigateToVideoPlayer( + context, + metadata: metadata, + isOffline: isOffline, + ); + if (result == true) { + onRefresh?.call(metadata.ratingKey); + } + return MediaNavigationResult.navigated; + + case PlexMediaType.season: + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SeasonDetailScreen(season: metadata), + ), + ); + onRefresh?.call(metadata.ratingKey); + return MediaNavigationResult.navigated; + + default: + // For all other types (shows, movies), show detail screen + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen( + metadata: metadata, + isOffline: isOffline, + ), + ), + ); + if (result == true) { + onRefresh?.call(metadata.ratingKey); + } + return MediaNavigationResult.navigated; } } diff --git a/lib/utils/sliver_adaptive_media_builder.dart b/lib/utils/sliver_adaptive_media_builder.dart index 9147aac8..a5e11013 100644 --- a/lib/utils/sliver_adaptive_media_builder.dart +++ b/lib/utils/sliver_adaptive_media_builder.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../services/settings_service.dart'; import 'grid_size_calculator.dart'; +import 'layout_constants.dart'; /// Builds an adaptive Sliver widget that switches between grid and list /// based on the current view mode setting. @@ -13,14 +14,22 @@ Widget buildAdaptiveMediaSliverBuilder({ required Widget Function(BuildContext context, T item, int index) itemBuilder, required ViewMode viewMode, required LibraryDensity density, - EdgeInsets padding = const EdgeInsets.all(16), - double childAspectRatio = 2 / 3.3, - double crossAxisSpacing = 8, - double mainAxisSpacing = 8, + EdgeInsets? padding, + double? childAspectRatio, + double? crossAxisSpacing, + double? mainAxisSpacing, }) { + final effectivePadding = padding ?? GridLayoutConstants.gridPadding; + final effectiveAspectRatio = + childAspectRatio ?? GridLayoutConstants.posterAspectRatio; + final effectiveCrossAxisSpacing = + crossAxisSpacing ?? GridLayoutConstants.crossAxisSpacing; + final effectiveMainAxisSpacing = + mainAxisSpacing ?? GridLayoutConstants.mainAxisSpacing; + if (viewMode == ViewMode.list) { return SliverPadding( - padding: padding, + padding: effectivePadding, sliver: SliverList( delegate: SliverChildBuilderDelegate((context, index) { final item = items[index]; @@ -30,16 +39,16 @@ Widget buildAdaptiveMediaSliverBuilder({ ); } else { return SliverPadding( - padding: padding, + padding: effectivePadding, sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( context, density, ), - childAspectRatio: childAspectRatio, - crossAxisSpacing: crossAxisSpacing, - mainAxisSpacing: mainAxisSpacing, + childAspectRatio: effectiveAspectRatio, + crossAxisSpacing: effectiveCrossAxisSpacing, + mainAxisSpacing: effectiveMainAxisSpacing, ), delegate: SliverChildBuilderDelegate((context, index) { final item = items[index]; diff --git a/lib/utils/smart_deletion_handler.dart b/lib/utils/smart_deletion_handler.dart index e87a7b04..cd490a60 100644 --- a/lib/utils/smart_deletion_handler.dart +++ b/lib/utils/smart_deletion_handler.dart @@ -24,23 +24,13 @@ class SmartDeletionHandler { }); try { - // Start deletion await provider.deleteDownload(globalKey); + } finally { deletionComplete = true; - - // Close dialog if shown - if (dialogShown && context.mounted) { + // Close dialog if shown (with canPop guard to prevent double-pop) + if (dialogShown && context.mounted && Navigator.canPop(context)) { Navigator.of(context).pop(); } - } catch (e) { - deletionComplete = true; - - // Close dialog if shown - if (dialogShown && context.mounted) { - Navigator.of(context).pop(); - } - - rethrow; // Let caller handle error } } diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index 48e57e6f..ff314d74 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -36,7 +36,6 @@ class FocusedScrollScaffold extends StatelessWidget { @override Widget build(BuildContext context) { return Focus( - autofocus: true, onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), child: Scaffold( body: CustomScrollView( diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index e6601de8..a93f0d55 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; +import '../utils/layout_constants.dart'; import '../focus/locked_hub_controller.dart'; import '../models/plex_hub.dart'; import '../screens/hub_detail_screen.dart'; @@ -309,11 +310,11 @@ class HubSectionState extends State { builder: (context, constraints) { // Responsive card width based on screen size final screenWidth = constraints.maxWidth; - final cardWidth = screenWidth > 1600 + final cardWidth = ScreenBreakpoints.isLargeDesktop(screenWidth) ? 220.0 - : screenWidth > 1200 + : ScreenBreakpoints.isDesktop(screenWidth) ? 200.0 - : screenWidth > 800 + : ScreenBreakpoints.isWideTablet(screenWidth) ? 190.0 : 160.0; diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 2ba0722f..7cde1457 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -11,13 +11,9 @@ import '../services/download_storage_service.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; -import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; import '../utils/duration_formatter.dart'; -import '../screens/media_detail_screen.dart'; -import '../screens/season_detail_screen.dart'; -import '../screens/playlist/playlist_detail_screen.dart'; -import '../screens/collection_detail_screen.dart'; +import '../utils/media_navigation_helper.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; import 'media_context_menu.dart'; @@ -74,30 +70,29 @@ class MediaCardState extends State { String _buildSemanticLabel() { final item = widget.item; - final itemType = item.type.toLowerCase(); // Build base label based on type String baseLabel; - if (itemType == 'episode') { - final episodeInfo = item.parentIndex != null && item.index != null - ? 'S${item.parentIndex} E${item.index}' - : ''; - baseLabel = t.accessibility.mediaCardEpisode( - title: item.displayTitle, - episodeInfo: episodeInfo, - ); - } else if (itemType == 'season') { - final seasonInfo = item.parentIndex != null - ? 'Season ${item.parentIndex}' - : ''; - baseLabel = t.accessibility.mediaCardSeason( - title: item.displayTitle, - seasonInfo: seasonInfo, - ); - } else if (itemType == 'movie') { - baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle); - } else { - baseLabel = t.accessibility.mediaCardShow(title: item.displayTitle); + switch (item.mediaType) { + case PlexMediaType.episode: + final episodeInfo = item.parentIndex != null && item.index != null + ? 'S${item.parentIndex} E${item.index}' + : ''; + baseLabel = t.accessibility.mediaCardEpisode( + title: item.displayTitle, + episodeInfo: episodeInfo, + ); + case PlexMediaType.season: + final seasonInfo = + item.parentIndex != null ? 'Season ${item.parentIndex}' : ''; + baseLabel = t.accessibility.mediaCardSeason( + title: item.displayTitle, + seasonInfo: seasonInfo, + ); + case PlexMediaType.movie: + baseLabel = t.accessibility.mediaCardMovie(title: item.displayTitle); + default: + baseLabel = t.accessibility.mediaCardShow(title: item.displayTitle); } // Add watched status @@ -122,85 +117,28 @@ class MediaCardState extends State { return; } - // Handle playlists - if (widget.item is PlexPlaylist) { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - PlaylistDetailScreen(playlist: widget.item as PlexPlaylist), - ), - ); - return; - } + final result = await navigateToMediaItem( + context, + widget.item, + onRefresh: widget.onRefresh, + isOffline: widget.isOffline, + ); - final itemType = widget.item.type.toLowerCase(); + if (!mounted) return; - // Handle collections - if (itemType == 'collection') { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CollectionDetailScreen(collection: widget.item), - ), - ); - - // If collection was deleted, refresh the parent list - if (result == true && mounted) { - widget.onListRefresh?.call(); - } - return; - } - - // Music content is not yet supported - if (itemType == 'artist' || itemType == 'album' || itemType == 'track') { - if (context.mounted) { + switch (result) { + case MediaNavigationResult.unsupported: ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(t.messages.musicNotSupported), duration: const Duration(seconds: 2), ), ); - } - return; - } - - // For episodes, start playback directly - if (itemType == 'episode') { - final result = await navigateToVideoPlayer( - context, - metadata: widget.item, - isOffline: widget.isOffline, - ); - // Refresh parent screen if result indicates it's needed - if (result == true) { - widget.onRefresh?.call(widget.item.ratingKey); - } - } else if (itemType == 'season') { - // For seasons, show season detail screen - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SeasonDetailScreen(season: widget.item), - ), - ); - // Season screen doesn't return a refresh flag, but we can refresh anyway - widget.onRefresh?.call(widget.item.ratingKey); - } else { - // For all other types (shows, movies), show detail screen - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MediaDetailScreen( - metadata: widget.item, - isOffline: widget.isOffline, - ), - ), - ); - // Refresh parent screen if result indicates it's needed - if (result == true) { - widget.onRefresh?.call(widget.item.ratingKey); - } + case MediaNavigationResult.listRefreshNeeded: + widget.onListRefresh?.call(); + case MediaNavigationResult.navigated: + // Item refresh already handled by onRefresh callback in helper + break; } } @@ -479,7 +417,7 @@ class _MediaCardList extends StatelessWidget { final metadata = item as PlexMetadata; // For collections, show item count - if (metadata.type.toLowerCase() == 'collection') { + if (metadata.mediaType == PlexMediaType.collection) { final count = metadata.childCount ?? metadata.leafCount; if (count != null && count > 0) { parts.add(t.playlists.itemCount(count: count)); @@ -765,7 +703,7 @@ class _MediaCardHelpers { PlexMetadata metadata, ) { // For collections, show item count - if (metadata.type.toLowerCase() == 'collection') { + if (metadata.mediaType == PlexMediaType.collection) { final count = metadata.childCount ?? metadata.leafCount; if (count != null && count > 0) { return Text( diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index d52d5091..66bdaee8 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -13,7 +13,6 @@ import '../providers/offline_mode_provider.dart'; import '../providers/offline_watch_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../utils/collection_playlist_play_helper.dart'; import '../utils/library_refresh_notifier.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; @@ -132,8 +131,8 @@ class MediaContextMenuState extends State { final isPlaylist = widget.item is PlexPlaylist; final metadata = isPlaylist ? null : widget.item as PlexMetadata; - final itemType = isPlaylist ? 'playlist' : (metadata!.type.toLowerCase()); - final isCollection = itemType == 'collection'; + final mediaType = isPlaylist ? null : metadata!.mediaType; + final isCollection = mediaType == PlexMediaType.collection; final isPartiallyWatched = !isPlaylist && @@ -226,7 +225,8 @@ class MediaContextMenuState extends State { } // Go to Series (for episodes and seasons) - if ((itemType == 'episode' || itemType == 'season') && + if ((mediaType == PlexMediaType.episode || + mediaType == PlexMediaType.season) && metadata.grandparentTitle != null) { menuActions.add( _MenuAction( @@ -238,7 +238,7 @@ class MediaContextMenuState extends State { } // Go to Season (for episodes) - if (itemType == 'episode' && metadata.parentTitle != null) { + if (mediaType == PlexMediaType.episode && metadata.parentTitle != null) { menuActions.add( _MenuAction( value: 'season', @@ -249,7 +249,8 @@ class MediaContextMenuState extends State { } // Shuffle Play (for shows and seasons) - if (itemType == 'show' || itemType == 'season') { + if (mediaType == PlexMediaType.show || + mediaType == PlexMediaType.season) { menuActions.add( _MenuAction( value: 'shuffle_play', @@ -260,7 +261,8 @@ class MediaContextMenuState extends State { } // File Info (for episodes and movies) - if (itemType == 'episode' || itemType == 'movie') { + if (mediaType == PlexMediaType.episode || + mediaType == PlexMediaType.movie) { menuActions.add( _MenuAction( value: 'fileinfo', @@ -271,10 +273,10 @@ class MediaContextMenuState extends State { } // Download options (for episodes, movies, shows, and seasons) - if (itemType == 'episode' || - itemType == 'movie' || - itemType == 'show' || - itemType == 'season') { + if (mediaType == PlexMediaType.episode || + mediaType == PlexMediaType.movie || + mediaType == PlexMediaType.show || + mediaType == PlexMediaType.season) { final downloadProvider = Provider.of( context, listen: false, @@ -304,10 +306,10 @@ class MediaContextMenuState extends State { } // Add to... (for episodes, movies, shows, and seasons) - if (itemType == 'episode' || - itemType == 'movie' || - itemType == 'show' || - itemType == 'season') { + if (mediaType == PlexMediaType.episode || + mediaType == PlexMediaType.movie || + mediaType == PlexMediaType.show || + mediaType == PlexMediaType.season) { menuActions.add( _MenuAction( value: 'add_to', @@ -1118,14 +1120,7 @@ class MediaContextMenuState extends State { bool isCollection, bool isPlaylist, ) async { - final client = _getClientForItem(); - - await playCollectionOrPlaylist( - context: context, - client: client, - item: widget.item, - shuffle: false, - ); + await _launchCollectionOrPlaylist(context, shuffle: false); } /// Handle shuffle action for collections and playlists @@ -1134,13 +1129,32 @@ class MediaContextMenuState extends State { bool isCollection, bool isPlaylist, ) async { - final client = _getClientForItem(); + await _launchCollectionOrPlaylist(context, shuffle: true); + } - await playCollectionOrPlaylist( + /// Launch playback for collection or playlist + Future _launchCollectionOrPlaylist( + BuildContext context, { + required bool shuffle, + }) async { + final client = _getClientForItem(); + final item = widget.item; + + final launcher = PlayQueueLauncher( context: context, client: client, - item: widget.item, - shuffle: true, + serverId: item is PlexMetadata + ? item.serverId + : (item as PlexPlaylist).serverId, + serverName: item is PlexMetadata + ? item.serverName + : (item as PlexPlaylist).serverName, + ); + + await launcher.launchFromCollectionOrPlaylist( + item: item, + shuffle: shuffle, + showLoadingIndicator: false, ); } diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart index 0a7f81bd..c3c31365 100644 --- a/lib/widgets/media_grid_delegate.dart +++ b/lib/widgets/media_grid_delegate.dart @@ -1,24 +1,15 @@ import 'package:flutter/material.dart'; import '../services/settings_service.dart'; import '../utils/grid_size_calculator.dart'; -import '../utils/grid_cross_axis_extent.dart'; +import '../utils/layout_constants.dart'; /// Shared grid delegate configuration for media item grids /// Maintains consistent spacing (2/3.3 aspect ratio, 0 spacing) across all media grids class MediaGridDelegate { - /// Standard aspect ratio for media cards (poster aspect) - static const double aspectRatio = 2 / 3.3; - - /// Standard cross-axis spacing between grid items - static const double crossAxisSpacing = 0; - - /// Standard main-axis spacing between grid items - static const double mainAxisSpacing = 0; - /// Creates a standard grid delegate for media items /// /// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default. - /// Set [usePaddingAware] to true to use [getMaxCrossAxisExtentWithPadding] instead. + /// Set [usePaddingAware] to true to use [GridSizeCalculator.getMaxCrossAxisExtentWithPadding] instead. static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({ required BuildContext context, required LibraryDensity density, @@ -26,14 +17,18 @@ class MediaGridDelegate { double horizontalPadding = 16, }) { final maxCrossAxisExtent = usePaddingAware - ? getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding) + ? GridSizeCalculator.getMaxCrossAxisExtentWithPadding( + context, + density, + horizontalPadding, + ) : GridSizeCalculator.getMaxCrossAxisExtent(context, density); return SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, - childAspectRatio: aspectRatio, - crossAxisSpacing: crossAxisSpacing, - mainAxisSpacing: mainAxisSpacing, + childAspectRatio: GridLayoutConstants.posterAspectRatio, + crossAxisSpacing: GridLayoutConstants.crossAxisSpacing, + mainAxisSpacing: GridLayoutConstants.mainAxisSpacing, ); } } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index d4af1157..1ac5edcf 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../models/plex_library.dart'; +import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../services/fullscreen_state_manager.dart'; @@ -15,6 +16,58 @@ import '../services/storage_service.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; +/// Tracks focus state for a set of named items, avoiding repeated boilerplate +class _FocusStateTracker { + final Map _nodes = {}; + final Set _focused = {}; + final VoidCallback _onChanged; + + _FocusStateTracker(this._onChanged); + + /// Get or create a focus node for the given key + FocusNode get(String key, {String? debugLabel}) { + return _nodes.putIfAbsent(key, () { + final node = FocusNode(debugLabel: debugLabel ?? 'nav_$key'); + node.addListener(() { + final wasFocused = _focused.contains(key); + if (node.hasFocus && !wasFocused) { + _focused.add(key); + _onChanged(); + } else if (!node.hasFocus && wasFocused) { + _focused.remove(key); + _onChanged(); + } + }); + return node; + }); + } + + /// Check if a key is currently focused + bool isFocused(String key) => _focused.contains(key); + + /// Check if a node exists for the given key + FocusNode? nodeFor(String key) => _nodes[key]; + + /// Dispose all nodes + void dispose() { + for (final node in _nodes.values) { + node.dispose(); + } + _nodes.clear(); + _focused.clear(); + } + + /// Remove nodes not in the given set of valid keys (prunes stale nodes) + void pruneExcept(Set validKeys) { + final toRemove = _nodes.keys.where((k) => !validKeys.contains(k)).toList(); + for (final key in toRemove) { + _nodes[key]?.dispose(); + _nodes.remove(key); + _focused.remove(key); + } + } +} + /// Reusable navigation rail item widget that handles focus, selection, and interaction class NavigationRailItem extends StatelessWidget { final IconData icon; @@ -123,169 +176,103 @@ class SideNavigationRailState extends State { List _libraries = []; bool _isLoadingLibraries = true; - // Focus nodes for main nav items - late FocusNode _homeFocusNode; - late FocusNode _librariesFocusNode; - late FocusNode _searchFocusNode; - late FocusNode _downloadsFocusNode; - late FocusNode _settingsFocusNode; + // Focus keys for main nav items + static const _kHome = 'home'; + static const _kLibraries = 'libraries'; + static const _kSearch = 'search'; + static const _kDownloads = 'downloads'; + static const _kSettings = 'settings'; - // Focus state tracking - bool _isHomeFocused = false; - bool _isLibrariesFocused = false; - bool _isSearchFocused = false; - bool _isDownloadsFocused = false; - bool _isSettingsFocused = false; - - // Map to store library item focus nodes and states - final Map _libraryFocusNodes = {}; - final Set _focusedLibraryKeys = {}; + // Unified focus state tracker for all nav items (main + libraries) + late final _FocusStateTracker _focusTracker; @override void initState() { super.initState(); - _homeFocusNode = FocusNode(debugLabel: 'nav_home'); - _librariesFocusNode = FocusNode(debugLabel: 'nav_libraries'); - _searchFocusNode = FocusNode(debugLabel: 'nav_search'); - _downloadsFocusNode = FocusNode(debugLabel: 'nav_downloads'); - _settingsFocusNode = FocusNode(debugLabel: 'nav_settings'); - - _homeFocusNode.addListener( - () => _onFocusChange(_homeFocusNode, () { - setState(() => _isHomeFocused = _homeFocusNode.hasFocus); - }), - ); - _librariesFocusNode.addListener( - () => _onFocusChange(_librariesFocusNode, () { - setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus); - }), - ); - _searchFocusNode.addListener( - () => _onFocusChange(_searchFocusNode, () { - setState(() => _isSearchFocused = _searchFocusNode.hasFocus); - }), - ); - _downloadsFocusNode.addListener( - () => _onFocusChange(_downloadsFocusNode, () { - setState(() => _isDownloadsFocused = _downloadsFocusNode.hasFocus); - }), - ); - _settingsFocusNode.addListener( - () => _onFocusChange(_settingsFocusNode, () { - setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus); - }), - ); - + _focusTracker = _FocusStateTracker(() { + if (mounted) setState(() {}); + }); _loadLibraries(); } - void _onFocusChange(FocusNode node, VoidCallback updateState) { - if (mounted) updateState(); - } - @override void dispose() { - _homeFocusNode.dispose(); - _librariesFocusNode.dispose(); - _searchFocusNode.dispose(); - _downloadsFocusNode.dispose(); - _settingsFocusNode.dispose(); - for (final node in _libraryFocusNodes.values) { - node.dispose(); - } + _focusTracker.dispose(); super.dispose(); } - /// Get or create a focus node for a library item - FocusNode _getLibraryFocusNode(String globalKey) { - return _libraryFocusNodes.putIfAbsent(globalKey, () { - final node = FocusNode(debugLabel: 'nav_library_$globalKey'); - node.addListener(() { - if (mounted) { - setState(() { - if (node.hasFocus) { - _focusedLibraryKeys.add(globalKey); - } else { - _focusedLibraryKeys.remove(globalKey); - } - }); - } - }); - return node; - }); - } - /// Focus the currently selected nav item void focusActiveItem() { if (widget.selectedLibraryKey != null) { // A library is selected - focus that library item - final node = _libraryFocusNodes[widget.selectedLibraryKey]; - node?.requestFocus(); + _focusTracker.nodeFor(widget.selectedLibraryKey!)?.requestFocus(); } else { // Focus main nav item based on selectedIndex - switch (widget.selectedIndex) { - case 0: - _homeFocusNode.requestFocus(); - break; - case 1: - _librariesFocusNode.requestFocus(); - break; - case 2: - _searchFocusNode.requestFocus(); - break; - case 3: - _downloadsFocusNode.requestFocus(); - break; - case 4: - _settingsFocusNode.requestFocus(); - break; - } + final key = switch (widget.selectedIndex) { + 0 => _kHome, + 1 => _kLibraries, + 2 => _kSearch, + 3 => _kDownloads, + 4 => _kSettings, + _ => null, + }; + if (key != null) _focusTracker.nodeFor(key)?.requestFocus(); } } - Future _loadLibraries() async { - final multiServerProvider = context.read(); + /// Fetch, filter, and order libraries (pure logic, no state changes) + Future> _resolveLibraries( + MultiServerProvider provider, + StorageService storage, + ) async { + if (!provider.hasConnectedServers) return []; - if (!multiServerProvider.hasConnectedServers) { - setState(() { - _isLoadingLibraries = false; - }); - return; + final libraries = + await provider.aggregationService.getLibrariesFromAllServers(); + + // Filter out unsupported library types (music) + var filtered = libraries.where((lib) => lib.type != 'artist').toList(); + + // Apply saved order + final savedOrder = storage.getLibraryOrder(); + if (savedOrder == null || savedOrder.isEmpty) return filtered; + + final libraryMap = {for (var lib in filtered) lib.globalKey: lib}; + final ordered = []; + for (final key in savedOrder) { + final lib = libraryMap.remove(key); + if (lib != null) ordered.add(lib); } + ordered.addAll(libraryMap.values); // New libraries not in saved order + return ordered; + } + + /// Build the set of valid focus keys (main nav + current libraries) + Set _buildValidFocusKeys(List libraries) { + return { + _kHome, + _kLibraries, + _kSearch, + _kDownloads, + _kSettings, + ...libraries.map((lib) => lib.globalKey), + }; + } + + Future _loadLibraries() async { + final provider = context.read(); + final storage = await StorageService.getInstance(); try { - final libraries = await multiServerProvider.aggregationService - .getLibrariesFromAllServers(); - - // Filter out music libraries (not supported) - var filteredLibraries = libraries - .where((lib) => lib.type != 'artist') - .toList(); - - // Apply saved library order - final storage = await StorageService.getInstance(); - final savedOrder = storage.getLibraryOrder(); - if (savedOrder != null && savedOrder.isNotEmpty) { - final libraryMap = { - for (var lib in filteredLibraries) lib.globalKey: lib, - }; - final orderedLibraries = []; - for (final key in savedOrder) { - if (libraryMap.containsKey(key)) { - orderedLibraries.add(libraryMap[key]!); - libraryMap.remove(key); - } - } - // Add any new libraries not in saved order - orderedLibraries.addAll(libraryMap.values); - filteredLibraries = orderedLibraries; - } + final libraries = await _resolveLibraries(provider, storage); if (mounted) { setState(() { - _libraries = filteredLibraries; + _libraries = libraries; _isLoadingLibraries = false; }); + // Prune stale library focus nodes + _focusTracker.pruneExcept(_buildValidFocusKeys(libraries)); } } catch (e) { if (mounted) { @@ -371,9 +358,9 @@ class SideNavigationRailState extends State { selectedIcon: Symbols.home_rounded, label: Translations.of(context).navigation.home, isSelected: widget.selectedIndex == 0, - isFocused: _isHomeFocused, + isFocused: _focusTracker.isFocused(_kHome), onTap: () => widget.onDestinationSelected(0), - focusNode: _homeFocusNode, + focusNode: _focusTracker.get(_kHome), ), const SizedBox(height: 8), @@ -389,44 +376,54 @@ class SideNavigationRailState extends State { selectedIcon: Symbols.search_rounded, label: Translations.of(context).navigation.search, isSelected: widget.selectedIndex == 2, - isFocused: _isSearchFocused, + isFocused: _focusTracker.isFocused(_kSearch), onTap: () => widget.onDestinationSelected(2), - focusNode: _searchFocusNode, + focusNode: _focusTracker.get(_kSearch), ), const SizedBox(height: 8), ], - // Downloads (index 0 in offline mode, 3 in online mode) + // Downloads _buildNavItem( icon: Symbols.download_rounded, selectedIcon: Symbols.download_rounded, label: Translations.of(context).navigation.downloads, - isSelected: widget.isOfflineMode - ? widget.selectedIndex == 0 - : widget.selectedIndex == 3, - isFocused: _isDownloadsFocused, - onTap: () => widget.onDestinationSelected( - widget.isOfflineMode ? 0 : 3, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.downloads, + widget.selectedIndex, + isOffline: widget.isOfflineMode, ), - focusNode: _downloadsFocusNode, + isFocused: _focusTracker.isFocused(_kDownloads), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor( + NavigationTabId.downloads, + isOffline: widget.isOfflineMode, + ), + ), + focusNode: _focusTracker.get(_kDownloads), ), const SizedBox(height: 8), - // Settings (index 1 in offline mode, 4 in online mode) + // Settings _buildNavItem( icon: Symbols.settings_rounded, selectedIcon: Symbols.settings_rounded, label: Translations.of(context).navigation.settings, - isSelected: widget.isOfflineMode - ? widget.selectedIndex == 1 - : widget.selectedIndex == 4, - isFocused: _isSettingsFocused, - onTap: () => widget.onDestinationSelected( - widget.isOfflineMode ? 1 : 4, + isSelected: NavigationTab.isTabAtIndex( + NavigationTabId.settings, + widget.selectedIndex, + isOffline: widget.isOfflineMode, ), - focusNode: _settingsFocusNode, + isFocused: _focusTracker.isFocused(_kSettings), + onTap: () => widget.onDestinationSelected( + NavigationTab.indexFor( + NavigationTabId.settings, + isOffline: widget.isOfflineMode, + ), + ), + focusNode: _focusTracker.get(_kSettings), ), ], ), @@ -472,13 +469,14 @@ class SideNavigationRailState extends State { Widget _buildLibrariesSection(List visibleLibraries, dynamic t) { final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null; + final isLibrariesFocused = _focusTracker.isFocused(_kLibraries); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Libraries header with expand/collapse Focus( - focusNode: _librariesFocusNode, + focusNode: _focusTracker.get(_kLibraries), onKeyEvent: (node, event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey.isSelectKey) { @@ -506,7 +504,7 @@ class SideNavigationRailState extends State { decoration: BoxDecoration( color: isLibrariesSelected ? t.text.withValues(alpha: 0.1) - : _isLibrariesFocused + : isLibrariesFocused ? t.text.withValues(alpha: 0.08) : null, borderRadius: BorderRadius.circular(12), @@ -615,8 +613,8 @@ class SideNavigationRailState extends State { final isSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == library.globalKey; - final isFocused = _focusedLibraryKeys.contains(library.globalKey); - final focusNode = _getLibraryFocusNode(library.globalKey); + final isFocused = _focusTracker.isFocused(library.globalKey); + final focusNode = _focusTracker.get(library.globalKey); return NavigationRailItem( icon: _getLibraryIcon(library.type),