refactor: centralize library state in LibrariesProvider

This commit is contained in:
edde746
2026-01-24 20:03:06 +01:00
parent 2b9bb54caf
commit b7ec24c2c1
6 changed files with 273 additions and 256 deletions
+7
View File
@@ -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<MainApp> 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<SetupScreen> {
appLogger.i('Successfully connected to $connectedCount servers');
if (mounted) {
// Initialize and load libraries
final librariesProvider = context.read<LibrariesProvider>();
librariesProvider.initialize(multiServerProvider.aggregationService);
await librariesProvider.loadLibraries();
// Now that Plex clients are available, trigger initial watch sync
context.read<OfflineWatchSyncService>().onServersConnected();
+142
View File
@@ -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<PlexLibrary> _libraries = [];
LibrariesLoadState _loadState = LibrariesLoadState.initial;
String? _errorMessage;
/// Unmodifiable list of all libraries (filtered for supported types, ordered)
List<PlexLibrary> 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<void> 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<void> 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<void> updateLibraryOrder(List<PlexLibrary> 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<PlexLibrary> _applyLibraryOrder(List<PlexLibrary> libraries, List<String>? 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 = <PlexLibrary>[];
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;
}
}
+95 -136
View File
@@ -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<LibrariesScreen>
final _collectionsTabKey = GlobalKey<State<LibraryCollectionsTab>>();
final _playlistsTabKey = GlobalKey<State<LibraryPlaylistsTab>>();
List<PlexLibrary> _allLibraries = []; // All libraries from API (unfiltered)
bool _isLoadingLibraries = true;
String? _errorMessage;
String? _selectedLibraryGlobalKey;
bool _isInitialLoad = true;
@@ -123,13 +122,61 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<void> _initializeWithLibraries() async {
final librariesProvider = context.read<LibrariesProvider>();
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
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<LibrariesScreen>
}
/// 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<PlexLibrary> libraries) {
final uniqueServerIds = libraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet();
return uniqueServerIds.length > 1;
}
Future<void> _loadLibraries() async {
// Extract context dependencies before async gap
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(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<PlexLibrary> _applyLibraryOrder(List<PlexLibrary> libraries, List<String>? 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 = <PlexLibrary>[];
final addedKeys = <String>{};
// 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<void> _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<LibrariesScreen>
}
Future<void> _loadLibraryContent(String libraryGlobalKey) async {
// Get libraries from provider
final librariesProvider = context.read<LibrariesProvider>();
final allLibraries = librariesProvider.libraries;
// Compute visible libraries based on current provider state
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(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<LibrariesScreen>
// 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<LibrariesScreen>
@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<void> _toggleLibraryVisibility(PlexLibrary library) async {
final librariesProvider = context.read<LibrariesProvider>();
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(context, listen: false);
final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(library.globalKey);
@@ -731,7 +683,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// 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<LibrariesScreen>
}
void _showLibraryManagementSheet() {
final librariesProvider = context.read<LibrariesProvider>();
final hiddenLibrariesProvider = Provider.of<HiddenLibrariesProvider>(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<LibrariesScreen>
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<LibrariesScreen>
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<LibrariesScreen>
@override
Widget build(BuildContext context) {
// Watch libraries provider for updates
final librariesProvider = context.watch<LibrariesProvider>();
final allLibraries = librariesProvider.libraries;
final isLoadingLibraries = librariesProvider.isLoading;
// Watch for hidden libraries changes to trigger rebuild
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
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<LibrariesScreen>
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<LibrariesScreen>
),
],
),
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>();
librariesProvider.refresh();
},
),
)
else if (visibleLibraries.isEmpty)
@@ -1164,7 +1123,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
),
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<LibrariesScreen>
),
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<LibrariesScreen>
),
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),
+10 -2
View File
@@ -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<MainScreen> with RouteAware, WindowListener
final multiServerProvider = context.read<MultiServerProvider>();
final serverStateProvider = context.read<ServerStateProvider>();
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
final librariesProvider = context.read<LibrariesProvider>();
final playbackStateProvider = context.read<PlaybackStateProvider>();
// 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<MainScreen> with RouteAware, WindowListener
if (connectedCount > 0) {
if (!mounted) return;
context.read<OfflineWatchSyncService>().onServersConnected();
// Reload libraries after reconnection
librariesProvider.initialize(multiServerProvider.aggregationService);
await librariesProvider.refresh();
}
}
@@ -517,8 +526,7 @@ class _MainScreenState extends State<MainScreen> 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) {
+5 -57
View File
@@ -12,27 +12,12 @@ import 'plex_auth_service.dart';
class DataAggregationService {
final MultiServerManager _serverManager;
// Cache for libraries with TTL
Map<String, List<PlexLibrary>>? _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<Map<String, List<PlexLibrary>>> 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<PlexLibrary>(
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<int>(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<String>? hiddenLibraryKeys,
Map<String, List<PlexLibrary>>? 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<Map<String, List<T>>> _perServerGrouped<T>({
required String operationName,
required Future<List<T>> 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};
}
}
+14 -61
View File
@@ -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<SideNavigationRail> {
bool _librariesExpanded = true;
List<PlexLibrary> _libraries = [];
bool _isLoadingLibraries = true;
// Collapsed/expanded state
bool _isHovered = false;
@@ -175,7 +171,6 @@ class SideNavigationRailState extends State<SideNavigationRail> {
},
debugLabelPrefix: 'nav',
);
_loadLibraries();
}
@override
@@ -215,64 +210,15 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_focusTracker.restoreFocus(fallbackKey: _kHome);
}
/// Fetch, filter, and order libraries (pure logic, no state changes)
Future<List<PlexLibrary>> _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 = <PlexLibrary>[];
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<String> _buildValidFocusKeys(List<PlexLibrary> libraries) {
return {_kHome, _kLibraries, _kSearch, _kDownloads, _kSettings, ...libraries.map((lib) => lib.globalKey)};
}
Future<void> _loadLibraries() async {
final provider = context.read<MultiServerProvider>();
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>();
librariesProvider.refresh();
}
IconData _getLibraryIcon(String type) {
@@ -309,11 +255,16 @@ class SideNavigationRailState extends State<SideNavigationRail> {
@override
Widget build(BuildContext context) {
final t = tokens(context);
final librariesProvider = context.watch<LibrariesProvider>();
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
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<SideNavigationRail> {
}
Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t, {bool isCollapsed = false}) {
final librariesProvider = context.watch<LibrariesProvider>();
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<SideNavigationRail> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
_isLoadingLibraries
isLoading
? Padding(
padding: const EdgeInsets.all(16),
child: Center(