refactor: discover page performance improvements

This commit is contained in:
edde746
2025-12-03 17:09:10 +01:00
parent 4339e00fe2
commit 2231f42f55
8 changed files with 283 additions and 74 deletions
+24 -19
View File
@@ -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 = <Future<void>>[];
// 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<ThemeProvider>(
@@ -155,7 +160,7 @@ class _SetupScreenState extends State<SetupScreen> {
_loadSavedCredentials();
}
void _checkForUpdatesOnStartup() async {
Future<void> _checkForUpdatesOnStartup() async {
// Delay slightly to allow UI to settle
await Future.delayed(const Duration(milliseconds: 500));
@@ -284,10 +289,7 @@ class _SetupScreenState extends State<SetupScreen> {
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<SetupScreen> {
builder: (context) => MainScreen(client: firstClient),
),
);
// Check for updates in background after navigation
_checkForUpdatesOnStartup();
}
} else {
// All connections failed
+9 -2
View File
@@ -10,13 +10,17 @@ class HiddenLibrariesProvider extends ChangeNotifier {
bool _isInitialized = false;
/// Get an unmodifiable copy of hidden library keys
Set<String> get hiddenLibraryKeys => Set.unmodifiable(_hiddenLibraryKeys);
Set<String> 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<void> 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<void> 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);
}
+2
View File
@@ -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',
);
+38 -14
View File
@@ -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<void> _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<void> setLibraryDensity(LibraryDensity density) async {
if (!_isInitialized) await _initializeSettings();
if (_libraryDensity != density) {
_libraryDensity = density;
await _settingsService.setLibraryDensity(density);
await _settingsService!.setLibraryDensity(density);
notifyListeners();
}
}
Future<void> setViewMode(ViewMode mode) async {
if (!_isInitialized) await _initializeSettings();
if (_viewMode != mode) {
_viewMode = mode;
await _settingsService.setViewMode(mode);
await _settingsService!.setViewMode(mode);
notifyListeners();
}
}
Future<void> setUseSeasonPoster(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_useSeasonPoster != value) {
_useSeasonPoster = value;
await _settingsService.setUseSeasonPoster(value);
await _settingsService!.setUseSeasonPoster(value);
notifyListeners();
}
}
Future<void> setShowHeroSection(bool value) async {
if (!_isInitialized) await _initializeSettings();
if (_showHeroSection != value) {
_showHeroSection = value;
await _settingsService.setShowHeroSection(value);
await _settingsService!.setShowHeroSection(value);
notifyListeners();
}
}
+9
View File
@@ -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<void> 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
}
}
+91 -30
View File
@@ -59,6 +59,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
List<PlexHub> _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<DiscoverScreen>
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<DiscoverScreen>
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<PlexMetadata>;
final allHubs = results[1] as List<PlexHub>;
// 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<DiscoverScreen>
'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<DiscoverScreen>
),
),
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(
+108 -8
View File
@@ -12,8 +12,29 @@ 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)
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<List<PlexLibrary>> getLibrariesFromAllServers() async {
@@ -53,8 +74,67 @@ class DataAggregationService {
return result;
}
/// Fetch recommendation hubs from all servers
Future<List<PlexHub>> 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<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 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, <PlexLibrary>[]);
}
});
final libraryResults = await Future.wait(libraryFutures);
final librariesByServer = Map.fromEntries(libraryResults);
final totalLibraries = libraryResults.fold<int>(
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<List<PlexHub>> getHubsFromAllServers({
int? limit,
Map<String, List<PlexLibrary>>? 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 = <PlexHub>[];
// 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] ?? <PlexLibrary>[];
if (serverLibraries.isEmpty) {
appLogger.w('No libraries available for server $serverId');
return <PlexHub>[];
}
// 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 <T>[];
}
});
@@ -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,