diff --git a/lib/main.dart b/lib/main.dart index 7e31b98d..0cdf2117 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'providers/server_state_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/hidden_libraries_provider.dart'; +import 'providers/libraries_provider.dart'; import 'providers/playback_state_provider.dart'; import 'providers/download_provider.dart'; import 'providers/offline_mode_provider.dart'; @@ -245,6 +246,7 @@ class _MainAppState extends State with WidgetsBindingObserver { ChangeNotifierProvider(create: (context) => ThemeProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true), ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true), + ChangeNotifierProvider(create: (context) => LibrariesProvider()), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ], @@ -357,6 +359,11 @@ class _SetupScreenState extends State { appLogger.i('Successfully connected to $connectedCount servers'); if (mounted) { + // Initialize and load libraries + final librariesProvider = context.read(); + librariesProvider.initialize(multiServerProvider.aggregationService); + await librariesProvider.loadLibraries(); + // Now that Plex clients are available, trigger initial watch sync context.read().onServersConnected(); diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart new file mode 100644 index 00000000..20658d42 --- /dev/null +++ b/lib/providers/libraries_provider.dart @@ -0,0 +1,142 @@ +import 'package:flutter/foundation.dart'; + +import '../models/plex_library.dart'; +import '../services/data_aggregation_service.dart'; +import '../services/storage_service.dart'; +import '../utils/app_logger.dart'; +import '../utils/content_utils.dart'; + +/// Load state for the libraries provider +enum LibrariesLoadState { initial, loading, loaded, error } + +/// Provider that serves as the single source of truth for library data. +/// Both SideNavigationRail and LibrariesScreen consume this provider +/// instead of independently fetching library data. +class LibrariesProvider extends ChangeNotifier { + DataAggregationService? _aggregationService; + List _libraries = []; + LibrariesLoadState _loadState = LibrariesLoadState.initial; + String? _errorMessage; + + /// Unmodifiable list of all libraries (filtered for supported types, ordered) + List get libraries => List.unmodifiable(_libraries); + + /// Whether libraries are currently being loaded + bool get isLoading => _loadState == LibrariesLoadState.loading; + + /// Whether libraries have been loaded at least once + bool get hasLoaded => _loadState == LibrariesLoadState.loaded; + + /// Current load state + LibrariesLoadState get loadState => _loadState; + + /// Error message if loading failed + String? get errorMessage => _errorMessage; + + /// Whether libraries are available + bool get hasLibraries => _libraries.isNotEmpty; + + /// Initialize the provider with the aggregation service. + /// This should be called after server connection is established. + void initialize(DataAggregationService service) { + _aggregationService = service; + } + + /// Load libraries from all connected servers. + /// Filters out music libraries and applies saved ordering. + Future loadLibraries() async { + if (_aggregationService == null) { + appLogger.w('LibrariesProvider: Cannot load libraries - not initialized'); + return; + } + + _loadState = LibrariesLoadState.loading; + _errorMessage = null; + notifyListeners(); + + try { + // Fetch libraries from all servers + final allLibraries = await _aggregationService!.getLibrariesFromAllServers(); + + // Filter out music libraries (not supported) + final filteredLibraries = allLibraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList(); + + // Apply saved library order + final storage = await StorageService.getInstance(); + final savedOrder = storage.getLibraryOrder(); + final orderedLibraries = _applyLibraryOrder(filteredLibraries, savedOrder); + + _libraries = orderedLibraries; + _loadState = LibrariesLoadState.loaded; + _errorMessage = null; + + appLogger.i('LibrariesProvider: Loaded ${_libraries.length} libraries'); + notifyListeners(); + } catch (e, stackTrace) { + appLogger.e('LibrariesProvider: Failed to load libraries', error: e, stackTrace: stackTrace); + _loadState = LibrariesLoadState.error; + _errorMessage = e.toString(); + notifyListeners(); + } + } + + /// Refresh libraries by clearing cache and reloading. + Future refresh() async { + if (_aggregationService == null) { + appLogger.w('LibrariesProvider: Cannot refresh - not initialized'); + return; + } + + // Clear aggregation service cache + _aggregationService!.clearCache(); + + // Reload libraries + await loadLibraries(); + } + + /// Update the library order and persist it. + Future updateLibraryOrder(List orderedLibraries) async { + _libraries = List.from(orderedLibraries); + notifyListeners(); + + // Save the new order + final storage = await StorageService.getInstance(); + final libraryKeys = orderedLibraries.map((lib) => lib.globalKey).toList(); + await storage.saveLibraryOrder(libraryKeys); + + appLogger.d('LibrariesProvider: Updated library order'); + } + + /// Clear all library data (for profile switch or logout). + void clear() { + _libraries = []; + _loadState = LibrariesLoadState.initial; + _errorMessage = null; + notifyListeners(); + appLogger.d('LibrariesProvider: Cleared library data'); + } + + /// Apply saved library order to a list of libraries. + List _applyLibraryOrder(List libraries, List? savedOrder) { + if (savedOrder == null || savedOrder.isEmpty) { + return libraries; + } + + // Create a map for quick lookup + final libraryMap = {for (var lib in libraries) lib.globalKey: lib}; + + // Build ordered list based on saved order + final orderedLibraries = []; + for (final key in savedOrder) { + final lib = libraryMap.remove(key); + if (lib != null) { + orderedLibraries.add(lib); + } + } + + // Add any new libraries that weren't in the saved order + orderedLibraries.addAll(libraryMap.values); + + return orderedLibraries; + } +} diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 1f231775..e623dcca 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -12,6 +12,7 @@ import '../../models/plex_library.dart'; import '../../models/plex_metadata.dart'; import '../../models/plex_sort.dart'; import '../../providers/hidden_libraries_provider.dart'; +import '../../providers/libraries_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/platform_detector.dart'; @@ -81,8 +82,6 @@ class _LibrariesScreenState extends State final _collectionsTabKey = GlobalKey>(); final _playlistsTabKey = GlobalKey>(); - List _allLibraries = []; // All libraries from API (unfiltered) - bool _isLoadingLibraries = true; String? _errorMessage; String? _selectedLibraryGlobalKey; bool _isInitialLoad = true; @@ -123,13 +122,61 @@ class _LibrariesScreenState extends State super.initState(); _tabController = TabController(length: 4, vsync: this); _tabController.addListener(_onTabChanged); - _loadLibraries(); + + // Initialize with libraries from the provider + WidgetsBinding.instance.addPostFrameCallback((_) { + _initializeWithLibraries(); + }); // Register L1/R1 callbacks for tab navigation GamepadService.onL1Pressed = _goToPreviousTab; GamepadService.onR1Pressed = _goToNextTab; } + /// Initialize the screen with libraries from the provider. + /// This handles initial library selection and content loading. + Future _initializeWithLibraries() async { + final librariesProvider = context.read(); + final hiddenLibrariesProvider = context.read(); + final allLibraries = librariesProvider.libraries; + + if (allLibraries.isEmpty) { + // No libraries available yet + return; + } + + // Compute visible libraries for initial load + final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); + + // Load saved preferences + final storage = await StorageService.getInstance(); + final savedLibraryKey = storage.getSelectedLibraryKey(); + + // Find the library by key in visible libraries + String? libraryGlobalKeyToLoad; + if (savedLibraryKey != null) { + // Check if saved library exists and is visible + final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey); + if (libraryExists) { + libraryGlobalKeyToLoad = savedLibraryKey; + } + } + + // Fallback to first visible library if saved key not found + if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) { + libraryGlobalKeyToLoad = visibleLibraries.first.globalKey; + } + + if (libraryGlobalKeyToLoad != null && mounted) { + final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKeyToLoad); + if (savedFilters.isNotEmpty) { + _selectedFilters = Map.from(savedFilters); + } + _loadLibraryContent(libraryGlobalKeyToLoad); + } + } + void _goToPreviousTab() { if (_tabController.index > 0) { setState(() { @@ -353,118 +400,13 @@ class _LibrariesScreenState extends State } /// Check if libraries come from multiple servers - bool get _hasMultipleServers { - final uniqueServerIds = _allLibraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet(); + bool _hasMultipleServers(List libraries) { + final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet(); return uniqueServerIds.length > 1; } - Future _loadLibraries() async { - // Extract context dependencies before async gap - final multiServerProvider = Provider.of(context, listen: false); - final hiddenLibrariesProvider = Provider.of(context, listen: false); - - setState(() { - _isLoadingLibraries = true; - _errorMessage = null; - }); - - try { - // Check if we have any connected servers - if (!multiServerProvider.hasConnectedServers) { - throw Exception(t.errors.noClientAvailable); - } - - final storage = await StorageService.getInstance(); - - // Fetch libraries from all servers - final allLibraries = await multiServerProvider.aggregationService.getLibrariesFromAllServers(); - - // Filter out music libraries (type: 'artist') since music playback is not yet supported - // Only show movie and TV show libraries - final filteredLibraries = allLibraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).toList(); - - // Load saved library order and apply it - final savedOrder = storage.getLibraryOrder(); - final orderedLibraries = _applyLibraryOrder(filteredLibraries, savedOrder); - - _updateState(() { - _allLibraries = orderedLibraries; // Store all libraries with ordering applied - _isLoadingLibraries = false; - }); - - if (allLibraries.isNotEmpty) { - // Compute visible libraries for initial load - final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; - final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); - - // Load saved preferences - final savedLibraryKey = storage.getSelectedLibraryKey(); - - // Find the library by key in visible libraries - String? libraryGlobalKeyToLoad; - if (savedLibraryKey != null) { - // Check if saved library exists and is visible - final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey); - if (libraryExists) { - libraryGlobalKeyToLoad = savedLibraryKey; - } - } - - // Fallback to first visible library if saved key not found - if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) { - libraryGlobalKeyToLoad = visibleLibraries.first.globalKey; - } - - if (libraryGlobalKeyToLoad != null && mounted) { - final savedFilters = storage.getLibraryFilters(sectionId: libraryGlobalKeyToLoad); - if (savedFilters.isNotEmpty) { - _selectedFilters = Map.from(savedFilters); - } - _loadLibraryContent(libraryGlobalKeyToLoad); - } - } - } catch (e) { - _updateState(() { - _errorMessage = _getErrorMessage(e, 'libraries'); - _isLoadingLibraries = false; - }); - } - } - - List _applyLibraryOrder(List libraries, List? savedOrder) { - if (savedOrder == null || savedOrder.isEmpty) { - return libraries; - } - - // Create a map for quick lookup - final libraryMap = {for (var lib in libraries) lib.globalKey: lib}; - - // Build ordered list based on saved order - final orderedLibraries = []; - final addedKeys = {}; - - // Add libraries in saved order - for (final key in savedOrder) { - if (libraryMap.containsKey(key)) { - orderedLibraries.add(libraryMap[key]!); - addedKeys.add(key); - } - } - - // Add any new libraries that weren't in the saved order - for (final library in libraries) { - if (!addedKeys.contains(library.globalKey)) { - orderedLibraries.add(library); - } - } - - return orderedLibraries; - } - - Future _saveLibraryOrder() async { - final storage = await StorageService.getInstance(); - final libraryKeys = _allLibraries.map((lib) => lib.globalKey).toList(); - await storage.saveLibraryOrder(libraryKeys); + /// Notify parent that library order changed + void _notifyLibraryOrderChanged() { widget.onLibraryOrderChanged?.call(); } @@ -475,10 +417,14 @@ class _LibrariesScreenState extends State } Future _loadLibraryContent(String libraryGlobalKey) async { + // Get libraries from provider + final librariesProvider = context.read(); + final allLibraries = librariesProvider.libraries; + // Compute visible libraries based on current provider state final hiddenLibrariesProvider = Provider.of(context, listen: false); final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; - final visibleLibraries = _allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); + final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); // Find the library by key final libraryIndex = visibleLibraries.indexWhere((lib) => lib.globalKey == libraryGlobalKey); @@ -672,7 +618,8 @@ class _LibrariesScreenState extends State // Public method to refresh content (for normal navigation) @override void refresh() { - _loadLibraries(); + // Reinitialize with current libraries + _initializeWithLibraries(); } // Refresh the currently active tab @@ -709,14 +656,19 @@ class _LibrariesScreenState extends State @override void fullRefresh() { appLogger.d('LibrariesScreen.fullRefresh() called - reloading all content'); - // Reload libraries and clear any selected library/filters + // Clear local state _selectedLibraryGlobalKey = null; _selectedFilters.clear(); _items.clear(); - _loadLibraries(); + _errorMessage = null; + setState(() {}); + + // Reinitialize with current libraries from provider + _initializeWithLibraries(); } Future _toggleLibraryVisibility(PlexLibrary library) async { + final librariesProvider = context.read(); final hiddenLibrariesProvider = Provider.of(context, listen: false); final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(library.globalKey); @@ -731,7 +683,8 @@ class _LibrariesScreenState extends State // If we just hid the selected library, select the first visible one if (isCurrentlySelected) { // Compute visible libraries after hiding - final visibleLibraries = _allLibraries + final allLibraries = librariesProvider.libraries; + final visibleLibraries = allLibraries .where((lib) => !hiddenLibrariesProvider.hiddenLibraryKeys.contains(lib.globalKey)) .toList(); @@ -799,20 +752,20 @@ class _LibrariesScreenState extends State } void _showLibraryManagementSheet() { + final librariesProvider = context.read(); final hiddenLibrariesProvider = Provider.of(context, listen: false); + final allLibraries = librariesProvider.libraries; if (PlatformDetector.isTV()) { showDialog( context: context, builder: (context) => _LibraryManagementSheet( isDialog: true, - allLibraries: List.from(_allLibraries), + allLibraries: List.from(allLibraries), hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, onReorder: (reorderedLibraries) { - setState(() { - _allLibraries = reorderedLibraries; - }); - _saveLibraryOrder(); + librariesProvider.updateLibraryOrder(reorderedLibraries); + _notifyLibraryOrderChanged(); }, onToggleVisibility: _toggleLibraryVisibility, getLibraryMenuItems: _getLibraryMenuItems, @@ -824,13 +777,11 @@ class _LibrariesScreenState extends State context: context, isScrollControlled: true, builder: (context) => _LibraryManagementSheet( - allLibraries: List.from(_allLibraries), + allLibraries: List.from(allLibraries), hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, onReorder: (reorderedLibraries) { - setState(() { - _allLibraries = reorderedLibraries; - }); - _saveLibraryOrder(); + librariesProvider.updateLibraryOrder(reorderedLibraries); + _notifyLibraryOrderChanged(); }, onToggleVisibility: _toggleLibraryVisibility, getLibraryMenuItems: _getLibraryMenuItems, @@ -1057,7 +1008,7 @@ class _LibrariesScreenState extends State children: [ AppIcon(ContentTypeHelper.getLibraryIcon(selectedLibrary.type), fill: 1, size: 20), const SizedBox(width: 8), - if (_hasMultipleServers && selectedLibrary.serverName != null) + if (_hasMultipleServers(visibleLibraries) && selectedLibrary.serverName != null) Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -1083,12 +1034,17 @@ class _LibrariesScreenState extends State @override Widget build(BuildContext context) { + // Watch libraries provider for updates + final librariesProvider = context.watch(); + final allLibraries = librariesProvider.libraries; + final isLoadingLibraries = librariesProvider.isLoading; + // Watch for hidden libraries changes to trigger rebuild final hiddenLibrariesProvider = context.watch(); final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; // Compute visible libraries (filtered from all libraries) - final visibleLibraries = _allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); + final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); return Scaffold( body: CustomScrollView( @@ -1102,7 +1058,7 @@ class _LibrariesScreenState extends State shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ - if (_allLibraries.isNotEmpty) + if (allLibraries.isNotEmpty) IconButton( icon: const AppIcon(Symbols.edit_rounded, fill: 1), tooltip: t.libraries.manageLibraries, @@ -1115,14 +1071,17 @@ class _LibrariesScreenState extends State ), ], ), - if (_isLoadingLibraries) + if (isLoadingLibraries) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())) else if (_errorMessage != null && visibleLibraries.isEmpty) SliverFillRemaining( child: ErrorStateWidget( message: _errorMessage!, icon: Symbols.error_outline_rounded, - onRetry: _loadLibraries, + onRetry: () { + final librariesProvider = context.read(); + librariesProvider.refresh(); + }, ), ) else if (visibleLibraries.isEmpty) @@ -1164,7 +1123,7 @@ class _LibrariesScreenState extends State children: [ LibraryRecommendedTab( key: _recommendedTabKey, - library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), isActive: _tabController.index == 0, suppressAutoFocus: _suppressAutoFocus, onDataLoaded: () => _handleTabDataLoaded(0), @@ -1172,7 +1131,7 @@ class _LibrariesScreenState extends State ), LibraryBrowseTab( key: _browseTabKey, - library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), isActive: _tabController.index == 1, suppressAutoFocus: _suppressAutoFocus, onDataLoaded: () => _handleTabDataLoaded(1), @@ -1180,7 +1139,7 @@ class _LibrariesScreenState extends State ), LibraryCollectionsTab( key: _collectionsTabKey, - library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), isActive: _tabController.index == 2, suppressAutoFocus: _suppressAutoFocus, onDataLoaded: () => _handleTabDataLoaded(2), @@ -1188,7 +1147,7 @@ class _LibrariesScreenState extends State ), LibraryPlaylistsTab( key: _playlistsTabKey, - library: _allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), + library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey), isActive: _tabController.index == 3, suppressAutoFocus: _suppressAutoFocus, onDataLoaded: () => _handleTabDataLoaded(3), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 71d95149..321b7ea7 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -18,6 +18,7 @@ import '../navigation/navigation_tabs.dart'; import '../providers/multi_server_provider.dart'; import '../providers/server_state_provider.dart'; import '../providers/hidden_libraries_provider.dart'; +import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../providers/settings_provider.dart'; import '../services/offline_watch_sync_service.dart'; @@ -478,8 +479,12 @@ class _MainScreenState extends State with RouteAware, WindowListener final multiServerProvider = context.read(); final serverStateProvider = context.read(); final hiddenLibrariesProvider = context.read(); + final librariesProvider = context.read(); final playbackStateProvider = context.read(); + // Clear libraries provider state before reconnecting + librariesProvider.clear(); + // Reconnect to all servers with new profile tokens if (servers.isNotEmpty) { final storage = await StorageService.getInstance(); @@ -492,6 +497,10 @@ class _MainScreenState extends State with RouteAware, WindowListener if (connectedCount > 0) { if (!mounted) return; context.read().onServersConnected(); + + // Reload libraries after reconnection + librariesProvider.initialize(multiServerProvider.aggregationService); + await librariesProvider.refresh(); } } @@ -517,8 +526,7 @@ class _MainScreenState extends State with RouteAware, WindowListener refreshable.fullRefresh(); } - // Refresh sidebar libraries for new profile - _sideNavKey.currentState?.reloadLibraries(); + // Sidebar automatically updates since it watches LibrariesProvider } void _selectTab(int index) { diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index b755c5a3..cb10bd71 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -12,27 +12,12 @@ import 'plex_auth_service.dart'; class DataAggregationService { final MultiServerManager _serverManager; - // Cache for libraries with TTL - Map>? _cachedLibrariesByServer; - DateTime? _librariesCacheTime; - static const Duration _librariesCacheTTL = Duration(hours: 1); - DataAggregationService(this._serverManager); - /// Clear the libraries cache (useful for server changes or logout) + /// Clear any cached data (for compatibility with existing callers) void clearCache() { - _cachedLibrariesByServer = null; - _librariesCacheTime = null; - } - - /// Check if libraries cache is still valid - bool get _isLibrariesCacheValid { - if (_cachedLibrariesByServer == null || _librariesCacheTime == null) { - return false; - } - - final cacheAge = DateTime.now().difference(_librariesCacheTime!); - return cacheAge < _librariesCacheTTL; + // Cache is now managed by LibrariesProvider + // This method is kept for compatibility } /// Fetch libraries from all online servers @@ -83,32 +68,6 @@ class DataAggregationService { return result; } - /// Fetch libraries from all servers and cache them for hub fetching - /// This allows libraries to be fetched in parallel with other operations - Future>> getLibrariesFromAllServersGrouped({bool forceRefresh = false}) async { - // Return cached libraries if still valid and not forcing refresh - if (!forceRefresh && _isLibrariesCacheValid) { - appLogger.d('Using cached libraries data'); - return _cachedLibrariesByServer!; - } - - final librariesByServer = await _perServerGrouped( - operationName: 'fetching libraries', - operation: (serverId, client, server) async { - return await client.getLibraries(); - }, - ); - - // Cache the results - _cachedLibrariesByServer = librariesByServer; - _librariesCacheTime = DateTime.now(); - - final totalLibraries = librariesByServer.values.fold(0, (sum, libs) => sum + libs.length); - appLogger.d('Fetched $totalLibraries libraries from ${librariesByServer.length} servers'); - - return librariesByServer; - } - /// Fetch recommendation hubs from all servers /// When useGlobalHubs is true (default), uses the global /hubs endpoint /// to get the true home page hubs like "Recently Added Movies", "Recently Added TV" @@ -208,8 +167,8 @@ class DataAggregationService { Set? hiddenLibraryKeys, Map>? librariesByServer, }) async { - // Use pre-fetched libraries or fetch them if not provided - final libraries = librariesByServer ?? await getLibrariesFromAllServersGrouped(); + // Use pre-fetched libraries or fetch and group them + final libraries = librariesByServer ?? groupLibrariesByServer(await getLibrariesFromAllServers()); appLogger.d('Fetching per-library hubs from ${clients.length} servers'); @@ -395,15 +354,4 @@ class DataAggregationService { final results = await _perServerRaw(operationName: operationName, operation: operation); return [for (final (_, items) in results) ...items]; } - - /// 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. - Future>> _perServerGrouped({ - required String operationName, - required Future> Function(String serverId, PlexClient client, PlexServer? server) operation, - }) async { - final results = await _perServerRaw(operationName: operationName, operation: operation); - return {for (final (id, items) in results) id: items}; - } } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 62f34e14..75e284d8 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -12,11 +12,9 @@ import '../focus/focus_memory_tracker.dart'; import '../models/plex_library.dart'; import '../navigation/navigation_tabs.dart'; import '../providers/hidden_libraries_provider.dart'; -import '../providers/multi_server_provider.dart'; +import '../providers/libraries_provider.dart'; import '../services/fullscreen_state_manager.dart'; -import '../services/storage_service.dart'; import '../theme/mono_tokens.dart'; -import '../utils/content_utils.dart'; import '../i18n/strings.g.dart'; /// Reusable navigation rail item widget that handles focus, selection, and interaction @@ -143,8 +141,6 @@ class SideNavigationRail extends StatefulWidget { class SideNavigationRailState extends State { bool _librariesExpanded = true; - List _libraries = []; - bool _isLoadingLibraries = true; // Collapsed/expanded state bool _isHovered = false; @@ -175,7 +171,6 @@ class SideNavigationRailState extends State { }, debugLabelPrefix: 'nav', ); - _loadLibraries(); } @override @@ -215,64 +210,15 @@ class SideNavigationRailState extends State { _focusTracker.restoreFocus(fallbackKey: _kHome); } - /// Fetch, filter, and order libraries (pure logic, no state changes) - Future> _resolveLibraries(MultiServerProvider provider, StorageService storage) async { - if (!provider.hasConnectedServers) return []; - - final libraries = await provider.aggregationService.getLibrariesFromAllServers(); - - // Filter out unsupported library types (music) - var filtered = libraries.where((lib) => !ContentTypeHelper.isMusicLibrary(lib)).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 _resolveLibraries(provider, storage); - - if (mounted) { - setState(() { - _libraries = libraries; - _isLoadingLibraries = false; - }); - // Prune stale library focus nodes - _focusTracker.pruneExcept(_buildValidFocusKeys(libraries)); - } - } catch (e) { - if (mounted) { - setState(() { - _isLoadingLibraries = false; - }); - } - } - } - - /// Reload libraries (called when servers change) + /// Reload libraries (called when servers change or profile switches) void reloadLibraries() { - setState(() { - _isLoadingLibraries = true; - }); - _loadLibraries(); + final librariesProvider = context.read(); + librariesProvider.refresh(); } IconData _getLibraryIcon(String type) { @@ -309,11 +255,16 @@ class SideNavigationRailState extends State { @override Widget build(BuildContext context) { final t = tokens(context); + final librariesProvider = context.watch(); final hiddenLibrariesProvider = context.watch(); final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; - // Filter visible libraries - final visibleLibraries = _libraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); + // Get libraries from provider and filter visible ones + final allLibraries = librariesProvider.libraries; + final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); + + // Prune stale focus nodes when libraries change + _focusTracker.pruneExcept(_buildValidFocusKeys(allLibraries)); final isCollapsed = !_shouldExpand; @@ -461,6 +412,8 @@ class SideNavigationRailState extends State { } Widget _buildLibrariesSection(List visibleLibraries, dynamic t, {bool isCollapsed = false}) { + final librariesProvider = context.watch(); + final isLoading = librariesProvider.isLoading; final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null; final isLibrariesFocused = _focusTracker.isFocused(_kLibraries); @@ -565,7 +518,7 @@ class SideNavigationRailState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), - _isLoadingLibraries + isLoading ? Padding( padding: const EdgeInsets.all(16), child: Center(