From 2231f42f5523c2c041348e678b90ff2a2d7b02bc Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:43:53 +0100 Subject: [PATCH] refactor: discover page performance improvements --- lib/main.dart | 43 ++++--- lib/providers/hidden_libraries_provider.dart | 11 +- lib/providers/multi_server_provider.dart | 2 + lib/providers/settings_provider.dart | 52 ++++++-- lib/providers/user_profile_provider.dart | 9 ++ lib/screens/discover_screen.dart | 121 +++++++++++++----- lib/services/data_aggregation_service.dart | 116 +++++++++++++++-- .../playback_initialization_service.dart | 3 +- 8 files changed, 283 insertions(+), 74 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 7ed0850c..4d76f777 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,7 +21,6 @@ import 'providers/playback_state_provider.dart'; import 'services/multi_server_manager.dart'; import 'services/data_aggregation_service.dart'; import 'services/server_registry.dart'; -import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/orientation_helper.dart'; import 'i18n/strings.g.dart'; @@ -40,20 +39,22 @@ void main() async { PaintingBinding.instance.imageCache.maximumSizeBytes = 500 << 20; // 500MB PaintingBinding.instance.imageCache.maximumSize = 500; // 500 images + // Initialize services in parallel where possible + final futures = >[]; + // Initialize window_manager for desktop platforms if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { - await windowManager.ensureInitialized(); + futures.add(windowManager.ensureInitialized()); } - // Configure macOS window with custom titlebar - await MacOSTitlebarService.setupCustomTitlebar(); + // Configure macOS window with custom titlebar (depends on window manager) + futures.add(MacOSTitlebarService.setupCustomTitlebar()); - // Note: Orientation will be set dynamically based on device type in MainApp + // Initialize storage service + futures.add(StorageService.getInstance().then((_) {})); - await StorageService.getInstance(); - - // Initialize language codes for track selection - await LanguageCodes.initialize(); + // Wait for all parallel services to complete + await Future.wait(futures); // Initialize logger level based on debug setting final debugEnabled = settings.getEnableDebugLogging(); @@ -90,12 +91,16 @@ class MainApp extends StatelessWidget { ), ChangeNotifierProvider(create: (context) => ServerStateProvider()), // Existing providers - ChangeNotifierProvider( - create: (context) => UserProfileProvider()..initialize(), - ), + ChangeNotifierProvider(create: (context) => UserProfileProvider()), ChangeNotifierProvider(create: (context) => ThemeProvider()), - ChangeNotifierProvider(create: (context) => SettingsProvider()), - ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider()), + ChangeNotifierProvider( + create: (context) => SettingsProvider(), + lazy: true, + ), + ChangeNotifierProvider( + create: (context) => HiddenLibrariesProvider(), + lazy: true, + ), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ], child: Consumer( @@ -155,7 +160,7 @@ class _SetupScreenState extends State { _loadSavedCredentials(); } - void _checkForUpdatesOnStartup() async { + Future _checkForUpdatesOnStartup() async { // Delay slightly to allow UI to settle await Future.delayed(const Duration(milliseconds: 500)); @@ -284,10 +289,7 @@ class _SetupScreenState extends State { appLogger.i('Successfully connected to $connectedCount servers'); if (mounted) { - // Check for updates BEFORE navigation - _checkForUpdatesOnStartup(); - - // Navigate to main screen + // Navigate to main screen immediately // Get first connected client for backward compatibility final firstClient = multiServerProvider.serverManager.onlineClients.values.first; @@ -298,6 +300,9 @@ class _SetupScreenState extends State { builder: (context) => MainScreen(client: firstClient), ), ); + + // Check for updates in background after navigation + _checkForUpdatesOnStartup(); } } else { // All connections failed diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart index 9f3eca99..0db4b3de 100644 --- a/lib/providers/hidden_libraries_provider.dart +++ b/lib/providers/hidden_libraries_provider.dart @@ -10,13 +10,17 @@ class HiddenLibrariesProvider extends ChangeNotifier { bool _isInitialized = false; /// Get an unmodifiable copy of hidden library keys - Set get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys); + Set get hiddenLibraryKeys { + if (!_isInitialized) _initialize(); + return Set.unmodifiable(_hiddenLibraryKeys); + } /// Check if the provider has completed initialization bool get isInitialized => _isInitialized; HiddenLibrariesProvider() { - _initialize(); + // Don't initialize immediately if lazy-loaded + // _initialize() will be called when first accessed } /// Initialize the provider by loading hidden libraries from storage @@ -30,6 +34,7 @@ class HiddenLibrariesProvider extends ChangeNotifier { /// Hide a library by its key /// Updates both in-memory state and persistent storage Future hideLibrary(String libraryKey) async { + if (!_isInitialized) await _initialize(); if (!_hiddenLibraryKeys.contains(libraryKey)) { _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..add(libraryKey); await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); @@ -40,6 +45,7 @@ class HiddenLibrariesProvider extends ChangeNotifier { /// Unhide a library by its key /// Updates both in-memory state and persistent storage Future unhideLibrary(String libraryKey) async { + if (!_isInitialized) await _initialize(); if (_hiddenLibraryKeys.contains(libraryKey)) { _hiddenLibraryKeys = Set.from(_hiddenLibraryKeys)..remove(libraryKey); await _storageService.saveHiddenLibraries(_hiddenLibraryKeys); @@ -49,6 +55,7 @@ class HiddenLibrariesProvider extends ChangeNotifier { /// Check if a specific library is hidden bool isLibraryHidden(String libraryKey) { + if (!_isInitialized) _initialize(); return _hiddenLibraryKeys.contains(libraryKey); } diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index a2f6fd08..b2fc2e9f 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -67,6 +67,7 @@ class MultiServerProvider extends ChangeNotifier { /// Clear all server connections void clearAllConnections() { _serverManager.disconnectAll(); + _aggregationService.clearCache(); // Clear cached data when servers change appLogger.d('MultiServerProvider: All connections cleared'); notifyListeners(); } @@ -79,6 +80,7 @@ class MultiServerProvider extends ChangeNotifier { }) async { // Clear existing connections first _serverManager.disconnectAll(); + _aggregationService.clearCache(); // Clear cached data when servers change appLogger.d( 'MultiServerProvider: Cleared connections, reconnecting to ${servers.length} servers', ); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index cd9ffa3c..2f81d46d 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -2,58 +2,82 @@ import 'package:flutter/material.dart'; import '../services/settings_service.dart'; class SettingsProvider extends ChangeNotifier { - late SettingsService _settingsService; + SettingsService? _settingsService; LibraryDensity _libraryDensity = LibraryDensity.normal; ViewMode _viewMode = ViewMode.grid; bool _useSeasonPoster = false; bool _showHeroSection = true; + bool _isInitialized = false; SettingsProvider() { - _initializeSettings(); + // Don't initialize immediately if lazy-loaded + // _initializeSettings() will be called when first accessed } Future _initializeSettings() async { + if (_isInitialized) return; + _settingsService = await SettingsService.getInstance(); - _libraryDensity = _settingsService.getLibraryDensity(); - _viewMode = _settingsService.getViewMode(); - _useSeasonPoster = _settingsService.getUseSeasonPoster(); - _showHeroSection = _settingsService.getShowHeroSection(); + _libraryDensity = _settingsService!.getLibraryDensity(); + _viewMode = _settingsService!.getViewMode(); + _useSeasonPoster = _settingsService!.getUseSeasonPoster(); + _showHeroSection = _settingsService!.getShowHeroSection(); + _isInitialized = true; notifyListeners(); } - LibraryDensity get libraryDensity => _libraryDensity; - ViewMode get viewMode => _viewMode; - bool get useSeasonPoster => _useSeasonPoster; - bool get showHeroSection => _showHeroSection; + LibraryDensity get libraryDensity { + if (!_isInitialized) _initializeSettings(); + return _libraryDensity; + } + + ViewMode get viewMode { + if (!_isInitialized) _initializeSettings(); + return _viewMode; + } + + bool get useSeasonPoster { + if (!_isInitialized) _initializeSettings(); + return _useSeasonPoster; + } + + bool get showHeroSection { + if (!_isInitialized) _initializeSettings(); + return _showHeroSection; + } Future setLibraryDensity(LibraryDensity density) async { + if (!_isInitialized) await _initializeSettings(); if (_libraryDensity != density) { _libraryDensity = density; - await _settingsService.setLibraryDensity(density); + await _settingsService!.setLibraryDensity(density); notifyListeners(); } } Future setViewMode(ViewMode mode) async { + if (!_isInitialized) await _initializeSettings(); if (_viewMode != mode) { _viewMode = mode; - await _settingsService.setViewMode(mode); + await _settingsService!.setViewMode(mode); notifyListeners(); } } Future setUseSeasonPoster(bool value) async { + if (!_isInitialized) await _initializeSettings(); if (_useSeasonPoster != value) { _useSeasonPoster = value; - await _settingsService.setUseSeasonPoster(value); + await _settingsService!.setUseSeasonPoster(value); notifyListeners(); } } Future setShowHeroSection(bool value) async { + if (!_isInitialized) await _initializeSettings(); if (_showHeroSection != value) { _showHeroSection = value; - await _settingsService.setShowHeroSection(value); + await _settingsService!.setShowHeroSection(value); notifyListeners(); } } diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index f2cd9b46..685b29f7 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -16,6 +16,7 @@ class UserProfileProvider extends ChangeNotifier { PlexUserProfile? _profileSettings; bool _isLoading = false; String? _error; + bool _isInitialized = false; PlexHome? get home => _home; PlexHomeUser? get currentUser => _currentUser; @@ -56,6 +57,12 @@ class UserProfileProvider extends ChangeNotifier { } Future initialize() async { + // Prevent duplicate initialization + if (_isInitialized) { + appLogger.d('UserProfileProvider: Already initialized, skipping'); + return; + } + appLogger.d('UserProfileProvider: Initializing...'); try { _authService = await PlexAuthService.create(); @@ -90,6 +97,7 @@ class UserProfileProvider extends ChangeNotifier { // Don't set error here, cached profile (if any) was already loaded } + _isInitialized = true; appLogger.d('UserProfileProvider: Initialization complete'); } catch (e) { appLogger.e( @@ -100,6 +108,7 @@ class UserProfileProvider extends ChangeNotifier { // Ensure services are null on failure _authService = null; _storageService = null; + _isInitialized = false; // Allow retry on failure } } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index da3cf72c..2be52fd5 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -59,6 +59,8 @@ class _DiscoverScreenState extends State List _hubs = []; bool _isLoading = true; bool _isInitialLoad = true; + bool _isOnDeckLoaded = false; + bool _areHubsLoading = true; String? _errorMessage; final PageController _heroController = PageController(); final ScrollController _scrollController = ScrollController(); @@ -276,6 +278,8 @@ class _DiscoverScreenState extends State appLogger.d('Loading discover content from all servers'); setState(() { _isLoading = true; + _isOnDeckLoaded = false; + _areHubsLoading = true; _errorMessage = null; }); @@ -290,16 +294,45 @@ class _DiscoverScreenState extends State throw Exception('No servers available'); } - // Fetch on deck and hubs from all servers in parallel for optimal performance - final results = await Future.wait([ - multiServerProvider.aggregationService.getOnDeckFromAllServers( - limit: 20, - ), - multiServerProvider.aggregationService.getHubsFromAllServers(), - ]); + // Start OnDeck and libraries fetch in parallel + final onDeckFuture = multiServerProvider.aggregationService + .getOnDeckFromAllServers(limit: 20); + final librariesFuture = multiServerProvider.aggregationService + .getLibrariesFromAllServersGrouped(); - final onDeck = results[0] as List; - final allHubs = results[1] as List; + // Wait for OnDeck to complete and show it immediately + final onDeck = await onDeckFuture; + + setState(() { + _onDeck = onDeck; + _isOnDeckLoaded = true; + _isLoading = false; // Show content, but hubs still loading + + // Reset hero index to avoid sync issues + _currentHeroIndex = 0; + }); + + // Focus the hero on initial load + if (_isInitialLoad && onDeck.isNotEmpty) { + _isInitialLoad = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _heroFocusNode.requestFocus(); + } + }); + } + + // Sync PageController to first page after OnDeck loads + if (_heroController.hasClients && onDeck.isNotEmpty) { + _heroController.jumpToPage(0); + } + + // Wait for libraries and then fetch hubs + final librariesByServer = await librariesFuture; + + // Fetch hubs using the pre-fetched libraries + final allHubs = await multiServerProvider.aggregationService + .getHubsFromAllServers(librariesByServer: librariesByServer); // Filter out duplicate hubs that we already fetch separately final filteredHubs = allHubs.where((hub) { @@ -316,35 +349,17 @@ class _DiscoverScreenState extends State 'Received ${onDeck.length} on deck items and ${filteredHubs.length} hubs from all servers', ); setState(() { - _onDeck = onDeck; _hubs = filteredHubs; - _isLoading = false; - - // Reset hero index to avoid sync issues - _currentHeroIndex = 0; + _areHubsLoading = false; }); - // Sync PageController to first page after data loads - if (_heroController.hasClients && onDeck.isNotEmpty) { - _heroController.jumpToPage(0); - } - - // Focus the hero on initial load - if (_isInitialLoad && onDeck.isNotEmpty) { - _isInitialLoad = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _heroFocusNode.requestFocus(); - } - }); - } - appLogger.d('Discover content loaded successfully'); } catch (e) { appLogger.e('Failed to load discover content', error: e); setState(() { _errorMessage = 'Failed to load content: $e'; _isLoading = false; + _areHubsLoading = false; }); } } @@ -721,7 +736,53 @@ class _DiscoverScreenState extends State ), ), - if (_onDeck.isEmpty && _hubs.isEmpty) + // Show loading skeleton for hubs while they're loading + if (_areHubsLoading && _hubs.isEmpty) + for (int i = 0; i < 3; i++) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hub title skeleton + Container( + width: 200, + height: 24, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 16), + // Hub items skeleton + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(right: 12), + width: 140, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + ); + }, + ), + ), + ], + ), + ), + ), + + if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading) SliverFillRemaining( child: Center( child: Column( diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 92b77026..c04e9b2b 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -12,8 +12,29 @@ 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) + 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; + } + /// Fetch libraries from all online servers /// Libraries are automatically tagged with server info by PlexClient Future> getLibrariesFromAllServers() async { @@ -53,8 +74,67 @@ class DataAggregationService { return result; } - /// Fetch recommendation hubs from all servers - Future> getHubsFromAllServers({int? limit}) async { + /// 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 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, + ); + + // Cache the results + _cachedLibrariesByServer = librariesByServer; + _librariesCacheTime = DateTime.now(); + + appLogger.d( + 'Fetched $totalLibraries libraries from ${clients.length} servers', + ); + return librariesByServer; + } + + /// Fetch recommendation hubs from all servers using pre-fetched libraries + Future> getHubsFromAllServers({ + int? limit, + Map>? librariesByServer, + }) async { final clients = _serverManager.onlineClients; if (clients.isEmpty) { @@ -62,21 +142,29 @@ class DataAggregationService { return []; } + // Use pre-fetched libraries or fetch them if not provided + final libraries = + librariesByServer ?? await getLibrariesFromAllServersGrouped(); + appLogger.d('Fetching hubs from ${clients.length} servers'); final allHubs = []; - // Fetch from all servers in parallel + // Fetch from all servers in parallel using cached libraries final hubFutures = clients.entries.map((entry) async { final serverId = entry.key; final client = entry.value; try { - // Get libraries for this server - final libraries = await client.getLibraries(); + // Use pre-fetched libraries for this server + final serverLibraries = libraries[serverId] ?? []; + if (serverLibraries.isEmpty) { + appLogger.w('No libraries available for server $serverId'); + return []; + } // Filter to only visible movie/show libraries - final visibleLibraries = libraries.where((library) { + final visibleLibraries = serverLibraries.where((library) { if (library.type != 'movie' && library.type != 'show') { return false; } @@ -90,7 +178,11 @@ class DataAggregationService { final libraryHubFutures = visibleLibraries.map((library) async { try { // Hubs are now tagged with server info at the source - return await client.getLibraryHubs(library.key); + final hubs = await client.getLibraryHubs(library.key); + appLogger.d( + 'Fetched ${hubs.length} hubs for ${library.title} on $serverId', + ); + return hubs; } catch (e) { appLogger.w( 'Failed to fetch hubs for library ${library.title}: $e', @@ -239,9 +331,14 @@ class DataAggregationService { final serverId = entry.key; final client = entry.value; final server = _serverManager.getServer(serverId); + final sw = Stopwatch()..start(); try { - return await operation(serverId, client, server); + final result = await operation(serverId, client, server); + appLogger.d( + '$operationName for server $serverId completed in ${sw.elapsedMilliseconds}ms with ${result.length} items', + ); + return result; } catch (e, stackTrace) { appLogger.e( 'Failed $operationName from server $serverId', @@ -249,6 +346,9 @@ class DataAggregationService { stackTrace: stackTrace, ); _serverManager.updateServerStatus(serverId, false); + appLogger.d( + '$operationName for server $serverId failed after ${sw.elapsedMilliseconds}ms', + ); return []; } }); diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 72d74d77..5caf6929 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -80,7 +80,8 @@ class PlaybackInitializationService { externalSubtitles.add( SubtitleTrack.uri( url, - title: plexTrack.displayTitle ?? + title: + plexTrack.displayTitle ?? plexTrack.language ?? 'Track ${plexTrack.id}', language: plexTrack.languageCode,