import 'package:flutter/material.dart'; import 'dart:io' show Platform; import 'package:window_manager/window_manager.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'screens/main_screen.dart'; import 'screens/auth_screen.dart'; import 'services/storage_service.dart'; import 'services/macos_titlebar_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/update_service.dart'; import 'services/settings_service.dart'; import 'utils/platform_detector.dart'; import 'services/gamepad_service.dart'; import 'providers/user_profile_provider.dart'; import 'providers/plex_client_provider.dart'; import 'providers/multi_server_provider.dart'; import 'providers/server_state_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/hidden_libraries_provider.dart'; import 'providers/playback_state_provider.dart'; import 'providers/download_provider.dart'; import 'providers/offline_mode_provider.dart'; import 'providers/offline_watch_provider.dart'; import 'watch_together/watch_together.dart'; import 'services/multi_server_manager.dart'; import 'services/offline_watch_sync_service.dart'; import 'services/data_aggregation_service.dart'; import 'services/in_app_review_service.dart'; import 'services/server_registry.dart'; import 'services/download_manager_service.dart'; import 'services/download_storage_service.dart'; import 'services/plex_api_cache.dart'; import 'database/app_database.dart'; import 'utils/app_logger.dart'; import 'utils/orientation_helper.dart'; import 'utils/language_codes.dart'; import 'i18n/strings.g.dart'; import 'focus/input_mode_tracker.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); // Initialize settings first to get saved locale final settings = await SettingsService.getInstance(); final savedLocale = settings.getAppLocale(); // Initialize localization with saved locale LocaleSettings.setLocale(savedLocale); // Configure image cache for large libraries PaintingBinding.instance.imageCache.maximumSizeBytes = 200 << 20; // 200MB // Initialize services in parallel where possible final futures = >[]; // Initialize window_manager for desktop platforms if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { futures.add(windowManager.ensureInitialized()); } // Initialize TV detection for Android if (Platform.isAndroid) { futures.add(TvDetectionService.getInstance().then((_) {})); } // Configure macOS window with custom titlebar (depends on window manager) futures.add(MacOSTitlebarService.setupCustomTitlebar()); // Initialize storage service futures.add(StorageService.getInstance().then((_) {})); // Initialize language codes for track selection futures.add(LanguageCodes.initialize()); // Wait for all parallel services to complete await Future.wait(futures); // Initialize logger level based on debug setting final debugEnabled = settings.getEnableDebugLogging(); setLoggerLevel(debugEnabled); // Initialize download storage service with settings await DownloadStorageService.instance.initialize(settings); // Start global fullscreen state monitoring FullscreenStateManager().startMonitoring(); // Initialize gamepad service for desktop platforms if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { GamepadService.instance.start(); } // DTD service is available for MCP tooling connection if needed runApp(const MainApp()); } // Global RouteObserver for tracking navigation final RouteObserver routeObserver = RouteObserver(); class MainApp extends StatefulWidget { const MainApp({super.key}); @override State createState() => _MainAppState(); } class _MainAppState extends State with WidgetsBindingObserver { // Initialize multi-server infrastructure late final MultiServerManager _serverManager; late final DataAggregationService _aggregationService; late final AppDatabase _appDatabase; late final DownloadManagerService _downloadManager; late final OfflineWatchSyncService _offlineWatchSyncService; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _serverManager = MultiServerManager(); _aggregationService = DataAggregationService(_serverManager); _appDatabase = AppDatabase(); // Initialize API cache with database PlexApiCache.initialize(_appDatabase); _downloadManager = DownloadManagerService(database: _appDatabase, storageService: DownloadStorageService.instance); _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager); // Start in-app review session tracking InAppReviewService.instance.startSession(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.resumed: // App came back to foreground - trigger sync check and start new session _offlineWatchSyncService.onAppResumed(); InAppReviewService.instance.startSession(); case AppLifecycleState.paused: case AppLifecycleState.detached: // App went to background or is closing - end session InAppReviewService.instance.endSession(); case AppLifecycleState.inactive: case AppLifecycleState.hidden: // Transitional states - don't trigger session events break; } } @override Widget build(BuildContext context) { return MultiProvider( providers: [ // Legacy provider for backward compatibility ChangeNotifierProvider(create: (context) => PlexClientProvider()), // New multi-server providers ChangeNotifierProvider(create: (context) => MultiServerProvider(_serverManager, _aggregationService)), ChangeNotifierProvider(create: (context) => ServerStateProvider()), // Offline mode provider - depends on MultiServerProvider ChangeNotifierProxyProvider( create: (_) { final provider = OfflineModeProvider(_serverManager); provider.initialize(); // Initialize immediately so statusStream listener is ready return provider; }, update: (_, multiServerProvider, previous) { final provider = previous ?? OfflineModeProvider(_serverManager); provider.initialize(); // Idempotent - safe to call again return provider; }, ), // Download provider ChangeNotifierProvider(create: (context) => DownloadProvider(downloadManager: _downloadManager)), // Offline watch sync service ChangeNotifierProvider( create: (context) { final offlineModeProvider = context.read(); final downloadProvider = context.read(); // Wire up callback to refresh download provider after watch state sync _offlineWatchSyncService.onWatchStatesRefreshed = () { downloadProvider.refreshMetadataFromCache(); }; _offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider); return _offlineWatchSyncService; }, ), // Offline watch provider - depends on sync service and download provider ChangeNotifierProxyProvider2( create: (context) => OfflineWatchProvider( syncService: _offlineWatchSyncService, downloadProvider: context.read(), apiCache: PlexApiCache.instance, ), update: (_, syncService, downloadProvider, previous) { return previous ?? OfflineWatchProvider( syncService: syncService, downloadProvider: downloadProvider, apiCache: PlexApiCache.instance, ); }, ), // Existing providers ChangeNotifierProvider(create: (context) => UserProfileProvider()), ChangeNotifierProvider(create: (context) => ThemeProvider()), ChangeNotifierProvider(create: (context) => SettingsProvider(), lazy: true), ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true), ChangeNotifierProvider(create: (context) => PlaybackStateProvider()), ChangeNotifierProvider(create: (context) => WatchTogetherProvider()), ], child: Consumer( builder: (context, themeProvider, child) { return TranslationProvider( child: InputModeTracker( child: MaterialApp( title: t.app.title, debugShowCheckedModeBanner: false, theme: themeProvider.lightTheme, darkTheme: themeProvider.darkTheme, themeMode: themeProvider.materialThemeMode, navigatorObservers: [routeObserver], home: const OrientationAwareSetup(), ), ), ); }, ), ); } } class OrientationAwareSetup extends StatefulWidget { const OrientationAwareSetup({super.key}); @override State createState() => _OrientationAwareSetupState(); } class _OrientationAwareSetupState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); _setOrientationPreferences(); } void _setOrientationPreferences() { OrientationHelper.restoreDefaultOrientations(context); } @override Widget build(BuildContext context) { return const SetupScreen(); } } class SetupScreen extends StatefulWidget { const SetupScreen({super.key}); @override State createState() => _SetupScreenState(); } class _SetupScreenState extends State { @override void initState() { super.initState(); _loadSavedCredentials(); } Future _checkForUpdatesOnStartup() async { // Delay slightly to allow UI to settle await Future.delayed(const Duration(milliseconds: 500)); if (!mounted) return; try { final updateInfo = await UpdateService.checkForUpdatesOnStartup(); if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) { _showUpdateDialog(updateInfo); } } catch (e) { appLogger.e('Error checking for updates', error: e); } } void _showUpdateDialog(Map updateInfo) { showDialog( context: context, builder: (BuildContext dialogContext) { return AlertDialog( title: Text(t.update.available), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( t.update.versionAvailable(version: updateInfo['latestVersion']), style: Theme.of(dialogContext).textTheme.titleMedium, ), const SizedBox(height: 8), Text( t.update.currentVersion(version: updateInfo['currentVersion']), style: Theme.of(dialogContext).textTheme.bodySmall, ), ], ), actions: [ TextButton( onPressed: () { Navigator.pop(dialogContext); }, child: Text(t.common.later), ), TextButton( onPressed: () async { await UpdateService.skipVersion(updateInfo['latestVersion']); if (dialogContext.mounted) { Navigator.pop(dialogContext); } }, child: Text(t.update.skipVersion), ), FilledButton( onPressed: () async { final url = Uri.parse(updateInfo['releaseUrl']); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } if (dialogContext.mounted) { Navigator.pop(dialogContext); } }, child: Text(t.update.viewRelease), ), ], ); }, ); } Future _loadSavedCredentials() async { final storage = await StorageService.getInstance(); final registry = ServerRegistry(storage); // Migrate from single-server to multi-server if needed await registry.migrateFromSingleServer(); // Refresh servers from API to get updated connection info (IPs may change) await registry.refreshServersFromApi(); // Load all configured servers final servers = await registry.getServers(); if (servers.isEmpty) { // No servers configured - show auth screen if (mounted) { Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => const AuthScreen())); } return; } // Get multi-server provider if (!mounted) return; final multiServerProvider = Provider.of(context, listen: false); try { appLogger.i('Connecting to ${servers.length} servers...'); // Get or generate client identifier final clientId = storage.getClientIdentifier(); // Connect to all servers in parallel final connectedCount = await multiServerProvider.serverManager.connectToAllServers( servers, clientIdentifier: clientId, timeout: const Duration(seconds: 10), onServerConnected: (serverId, client) { // Set first connected client in legacy provider for backward compatibility final legacyProvider = Provider.of(context, listen: false); if (legacyProvider.client == null) { legacyProvider.setClient(client); } }, ); if (connectedCount > 0) { // At least one server connected successfully appLogger.i('Successfully connected to $connectedCount servers'); if (mounted) { // Now that Plex clients are available, trigger initial watch sync context.read().onServersConnected(); // Navigate to main screen immediately // Get first connected client for backward compatibility final firstClient = multiServerProvider.serverManager.onlineClients.values.first; Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => MainScreen(client: firstClient))); // Check for updates in background after navigation _checkForUpdatesOnStartup(); } } else { // All connections failed - navigate to offline mode appLogger.w('Failed to connect to any servers, entering offline mode'); if (mounted) { // Navigate to MainScreen in offline mode // User can still access Downloads and Settings Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)), ); } } } catch (e, stackTrace) { appLogger.e('Error during multi-server connection', error: e, stackTrace: stackTrace); if (mounted) { // Navigate to MainScreen in offline mode Navigator.pushReplacement( context, MaterialPageRoute(builder: (context) => const MainScreen(isOfflineMode: true)), ); } } } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [const CircularProgressIndicator(), const SizedBox(height: 16), Text(t.app.loading)], ), ), ); } }