import 'dart:async'; import 'dart:io' show Directory, Platform, ProcessInfo; import 'dart:ui' show AppExitResponse; import 'package:flutter/foundation.dart'; // ignore: depend_on_referenced_packages import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:window_manager/window_manager.dart'; import 'package:provider/provider.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'connection/connection.dart'; import 'connection/connection_bootstrap.dart'; import 'connection/connection_registry.dart'; import 'navigation/profile_navigation_scope.dart'; import 'navigation/profile_session_screen.dart'; import 'profiles/active_profile_binder.dart'; import 'profiles/active_profile_provider.dart'; import 'profiles/profile.dart'; import 'profiles/profile_connection_cleanup.dart'; import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_registry.dart'; import 'mixins/mounted_set_state_mixin.dart'; import 'profiles/plex_home_service.dart'; import 'screens/auth_screen.dart'; import 'screens/profile/pin_entry_dialog.dart'; import 'screens/profile/profile_switch_screen.dart'; import 'services/storage_service.dart'; import 'services/device_performance.dart'; import 'services/macos_window_service.dart'; import 'services/native_window_service.dart'; import 'services/fullscreen_state_manager.dart'; import 'services/settings_service.dart'; import 'utils/platform_detector.dart'; import 'services/apple_tv_remote_touch_service.dart'; import 'services/discord_rpc_service.dart'; import 'package:path_provider/path_provider.dart'; import 'services/image_cache_service.dart'; import 'services/gamepad_service.dart'; import 'services/trakt/trakt_scrobble_service.dart'; import 'services/trakt/trakt_sync_service.dart'; import 'services/trackers/tracker_coordinator.dart'; import 'providers/user_profile_provider.dart'; import 'providers/multi_server_provider.dart'; import 'providers/theme_provider.dart'; import 'providers/download_provider.dart'; import 'providers/offline_mode_provider.dart'; import 'providers/offline_watch_provider.dart'; import 'providers/shader_provider.dart'; import 'utils/snackbar_helper.dart'; import 'services/multi_server_manager.dart'; import 'services/offline_watch_sync_service.dart'; import 'services/data_aggregation_service.dart'; import 'services/server_registry.dart'; import 'services/download_manager_service.dart'; import 'services/pip_service.dart'; import 'services/download_storage_service.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'services/jellyfin_api_cache.dart'; import 'services/plex_api_cache.dart'; import 'database/app_database.dart'; import 'database/tvos_database_recovery_store.dart'; import 'screens/video_player_screen.dart'; import 'utils/app_logger.dart'; import 'utils/managed_http_client.dart'; import 'utils/media_server_http_client.dart'; import 'utils/orientation_helper.dart'; import 'utils/watch_state_notifier.dart'; import 'i18n/strings.g.dart'; import 'widgets/app_icon.dart'; import 'focus/input_mode_tracker.dart'; import 'focus/key_event_utils.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'utils/navigation_transitions.dart'; import 'utils/log_redaction_manager.dart'; import 'package:package_info_plus/package_info_plus.dart'; const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false); const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1'; const String gitCommit = String.fromEnvironment('GIT_COMMIT'); const String _sentryEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT'); const String _sentryDist = String.fromEnvironment('SENTRY_DIST'); // Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events // at (0,0) as barrier taps, causing modals to dismiss immediately. // Remove when Flutter PR #179643 is merged. bool _zeroOffsetPointerGuardInstalled = false; void _installZeroOffsetPointerGuard() { if (_zeroOffsetPointerGuardInstalled || !Platform.isIOS) return; GestureBinding.instance.pointerRouter.addGlobalRoute(_absorbZeroOffsetPointerEvent); _zeroOffsetPointerGuardInstalled = true; } void _absorbZeroOffsetPointerEvent(PointerEvent event) { if (event is PointerDownEvent && event.position == Offset.zero) { GestureBinding.instance.cancelPointer(event.pointer); } } /// Register platform plugin stores manually for tvOS. Flutter's tool /// doesn't support tvOS so it never generates a plugin registrant for it. /// Each plugin whose iOS Swift implementation is tvOS-compatible must be /// wired here; the Swift side (GeneratedPluginRegistrant.m / AppDelegate) /// also needs to call the plugin's Swift register(with:) to attach its /// message channels. void _registerTvosPlatformPlugins() { if (!Platform.isIOS) return; // tvOS reports as iOS via dart:io. SharedPreferencesFoundation.registerWith(); } Future main() async { final binding = WidgetsFlutterBinding.ensureInitialized(); // Keep the accessibility tree available to Maestro and other UI automation // without adding release-build overhead. if (kDebugMode) binding.ensureSemantics(); _installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug // On tvOS, Flutter's generated plugin registrant doesn't run (no tvOS // target in Flutter's tool), so register platform stores manually for // the plugins we use. _registerTvosPlatformPlugins(); if (_enableSentry) { final packageInfo = await PackageInfo.fromPlatform(); await SentryFlutter.init((options) { options.dsn = _sentryDsn; options.release = gitCommit.isNotEmpty ? 'plezy@${gitCommit.substring(0, 7)}' : 'plezy@${packageInfo.version}+${packageInfo.buildNumber}'; if (_sentryEnvironment.isNotEmpty) options.environment = _sentryEnvironment; if (_sentryDist.isNotEmpty) options.dist = _sentryDist; options.tracesSampleRate = 0; options.attachStacktrace = true; options.enableAutoSessionTracking = false; options.recordHttpBreadcrumbs = false; options.captureNativeFailedRequests = false; options.enableAppHangTracking = !kDebugMode; options.appHangTimeoutInterval = const Duration(seconds: 3); options.beforeSend = _beforeSend; options.beforeBreadcrumb = _beforeBreadcrumb; }, appRunner: _bootstrapApp); return; } await _bootstrapApp(); } Future _bootstrapApp() async { final startupWatch = Stopwatch()..start(); var lastStartupMarkMs = 0; void markStartupPhase(String phase) { if (!kProfileMode) return; final elapsedMs = startupWatch.elapsedMilliseconds; appLogger.i('Startup phase $phase: ${elapsedMs - lastStartupMarkMs}ms (total ${elapsedMs}ms)'); lastStartupMarkMs = elapsedMs; } final settings = await SettingsService.getInstance(); markStartupPhase('settings'); final savedLocale = settings.read(SettingsService.appLocale); unawaited(LocaleSettings.setLocale(savedLocale)); await initializeDateFormatting(savedLocale.languageCode, null); markStartupPhase('locale'); // One-time cleanup of the old flutter_cache_manager image cache directory // (replaced by cached_network_image_ce in a prior refactor). if (!settings.read(SettingsService.cleanedOldImageCache)) { try { final tempDir = await getTemporaryDirectory(); final oldCacheDir = Directory('${tempDir.path}/plexImageCache'); if (await oldCacheDir.exists()) { await oldCacheDir.delete(recursive: true); } } catch (_) { // Best-effort; the directory may be locked or already partial. } await settings.write(SettingsService.cleanedOldImageCache, true); } final futures = >[]; if (PlatformDetector.isDesktopOS()) { if (Platform.isMacOS) { futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar())); } else { futures.add(windowManager.ensureInitialized()); } } // Initialize TV detection on every platform: auto-detect covers Android // leanback and Apple TV; the force-TV setting applies anywhere, including // desktop home-theater setups. futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode))); // Visual-effects tier (auto-detects low-end Android; full elsewhere). futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects))); if (Platform.isAndroid) { PipService(); } // Hook Windows native fullscreen callback (no-op elsewhere). NativeWindowService.initialize(); final storageFuture = StorageService.getInstance(); futures.add(storageFuture); await Future.wait(futures); final storage = await storageFuture; markStartupPhase('platform-services'); final databaseBootstrap = await AppDatabase.open(isTvos: PlatformDetector.isAppleTV()); markStartupPhase('database-recovery'); // Configure image cache — keep budget modest to leave headroom for Skia // decode buffers. Runs after the futures so the effects tier is resolved. DevicePerformance.applyImageCacheBudget(); // The PLEX_TOKEN dart-define (screenshot automation) is consumed by // [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry // is available — keeps the deprecated legacy slots out of runtime paths. final debugEnabled = settings.read(SettingsService.enableDebugLogging); setLoggerLevel(debugEnabled); final packageInfo = await PackageInfo.fromPlatform(); final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : ''; String renderer = ''; if (Platform.isAndroid) { final rendererName = await const MethodChannel('com.plezy/theme').invokeMethod('getRenderer'); renderer = ' [$rendererName]'; // Tag crash reports with the active renderer while Impeller rolls back // out to Android TV, so device-specific regressions are attributable. // configureScope returns FutureOr; Future.sync flattens it for unawaited. unawaited(Future.sync(() => Sentry.configureScope((scope) => scope.setTag('renderer', rendererName ?? 'unknown')))); } appLogger.i( 'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer' ' [effects: ${DevicePerformance.describeSync()}]', ); if (Platform.isAndroid) { // Baseline for the RSS watchdog thresholds and a sanity anchor against // `adb shell dumpsys meminfo` when tuning them. appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB'); } markStartupPhase('environment'); await DownloadStorageService.instance.initialize(settings); markStartupPhase('download-storage'); FullscreenStateManager().startMonitoring(); // Apply "start in fullscreen" preference on desktop. macOS does not restore // fullscreen state on its own (frame autosave only persists windowed geometry), // so it needs the same explicit handling as Windows/Linux. if (PlatformDetector.isDesktopOS() && settings.read(SettingsService.startInFullscreen)) { unawaited(FullscreenStateManager().enterFullscreen()); } // Initialize gamepad service (all platforms — universal_gamepad auto-registers // and intercepts input events, so we must listen to re-dispatch them) GamepadService.instance.start(); if (PlatformDetector.isAppleTV()) { AppleTvRemoteTouchService.instance.start(); } if (PlatformDetector.isDesktopOS()) { unawaited(DiscordRPCService.instance.initialize()); } await TraktScrobbleService.instance.initialize(); markStartupPhase('trakt-scrobble'); _registerShaderLicenses(); // In release mode, show a colored placeholder instead of a blank/white screen // when a widget build() throws an unhandled exception. ErrorWidget.builder = (FlutterErrorDetails details) { if (kDebugMode) return ErrorWidget(details.exception); return const ColoredBox(color: Color(0xFF000000)); }; final appDatabase = databaseBootstrap.database; markStartupPhase('pre-runApp'); runApp( MainApp( settings: settings, storage: storage, appDatabase: appDatabase, databaseRecoveryOutcome: databaseBootstrap.recoveryOutcome, ), ); } Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) { if (breadcrumb == null) return null; final message = breadcrumb.message; final data = breadcrumb.data; if (message == null && (data == null || data.isEmpty)) return breadcrumb; if (message != null) breadcrumb.message = LogRedactionManager.redact(message); if (data != null) breadcrumb.data = data.map((k, v) => MapEntry(k, v is String ? LogRedactionManager.redact(v) : v)); return breadcrumb; } FutureOr _beforeSend(SentryEvent event, Hint _) { // Drop event if user opted out of crash reporting. final instance = SettingsService.instanceOrNull; if (instance != null && !instance.read(SettingsService.crashReporting)) return null; // Drop unactionable errors final exceptions = event.exceptions; if (exceptions != null) { bool shouldDrop(SentryException e) { final v = e.value; final lowerValue = v?.toLowerCase(); // Windows file-lock errors from cache manager cleanup if (e.type == 'FileSystemException' && v != null && v.contains('plexImageCache') && v.contains('errno = 32')) { return true; } if (e.type == 'FileSystemException' && lowerValue != null && lowerValue.contains('cached_network_image_ce') && (lowerValue.contains('lock failed') || lowerValue.contains('writefrom failed'))) { return true; } // Linux without DBus/NetworkManager if (e.type == 'DBusServiceUnknownException' || (v != null && v.contains('system_bus_socket'))) { return true; } // Device out of disk space if (v != null && (v.contains('SQLITE_FULL') || v.contains('No space left on device') || v.contains('errno = 112') || v.contains('database or disk is full'))) { return true; } // Native HTTP errors from CFNetwork (server errors, not actionable) if (e.type == 'HTTPClientError') return true; // Benign EventChannel teardown race: the engine replies this when a // 'cancel' lands after the stream is already gone, and the framework // reports it via FlutterError — nothing was ever wrong user-side. if (e.type == 'PlatformException' && v != null && v.contains('No active stream to cancel')) return true; // Discord RPC errors when Discord is not running if (e.type == 'DiscordStateException') return true; return false; } if (exceptions.any(shouldDrop)) return null; // Scrub Plex tokens and server URLs from exception messages for (final e in exceptions) { final value = e.value; if (value != null) { e.value = LogRedactionManager.redact(value); } } } // Enrich TimeoutException with operation name + duration as tags/fingerprint. // value format: "TimeoutException after 0:00:05.000000: timed out" if (exceptions != null) { final timeoutException = exceptions.where((e) => e.type == 'TimeoutException').firstOrNull; if (timeoutException != null) { final value = timeoutException.value ?? ''; final colonIdx = value.indexOf(': '); final message = colonIdx >= 0 ? value.substring(colonIdx + 2) : value; final operation = message.endsWith(' timed out') ? message.substring(0, message.length - ' timed out'.length) : null; final durationMatch = RegExp(r'after (\d+:\d{2}:\d{2}\.\d+)').firstMatch(value); final tags = event.tags ??= {}; if (operation != null) tags['timeout.operation'] = operation; if (durationMatch != null) tags['timeout.duration'] = durationMatch.group(1)!; event.fingerprint = ['TimeoutException', ?operation]; } } // Scrub breadcrumb messages and data final breadcrumbs = event.breadcrumbs; if (breadcrumbs != null) { for (final b in breadcrumbs) { final message = b.message; final data = b.data; if (message != null) b.message = LogRedactionManager.redact(message); if (data != null) b.data = data.map((k, v) => MapEntry(k, v is String ? LogRedactionManager.redact(v) : v)); } } return event; } void _registerShaderLicenses() { LicenseRegistry.addLicense(() async* { yield const LicenseEntryWithLineBreaks( ['Anime4K'], 'MIT License\n' '\n' 'Copyright (c) 2019-2021 bloc97\n' 'All rights reserved.\n' '\n' 'Permission is hereby granted, free of charge, to any person obtaining a copy ' 'of this software and associated documentation files (the "Software"), to deal ' 'in the Software without restriction, including without limitation the rights ' 'to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ' 'copies of the Software, and to permit persons to whom the Software is ' 'furnished to do so, subject to the following conditions:\n' '\n' 'The above copyright notice and this permission notice shall be included in all ' 'copies or substantial portions of the Software.\n' '\n' 'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ' 'IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ' 'FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ' 'AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ' 'LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ' 'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE ' 'SOFTWARE.', ); yield const LicenseEntryWithLineBreaks( ['NVIDIA Image Scaling (NVScaler)'], 'The MIT License (MIT)\n' '\n' 'Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\n' '\n' 'Permission is hereby granted, free of charge, to any person obtaining a copy of ' 'this software and associated documentation files (the "Software"), to deal in ' 'the Software without restriction, including without limitation the rights to ' 'use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of ' 'the Software, and to permit persons to whom the Software is furnished to do so, ' 'subject to the following conditions:\n' '\n' 'The above copyright notice and this permission notice shall be included in all ' 'copies or substantial portions of the Software.\n' '\n' 'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ' 'IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS ' 'FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR ' 'COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER ' 'IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN ' 'CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.', ); }); } final rootNavigatorKey = GlobalKey(); @visibleForTesting bool shouldEnterOfflineModeAfterStartupBind({required bool bindingSucceeded, required bool hasOnlineServers}) { return !bindingSucceeded && !hasOnlineServers; } /// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the /// profile-scoped widget tree. Routes through the app-global /// [rootNavigatorKey] so the dialog survives profile-session remounts. Returns /// `null` when no Navigator is available yet (early boot, post-dispose) so the /// binder treats it as "PIN cancelled". Future _rootPinPrompt(Profile profile, {String? errorMessage}) { final ctx = rootNavigatorKey.currentContext; if (ctx == null) return Future.value(null); return showPinEntryDialog(ctx, profile.displayName, errorMessage: errorMessage); } class MainApp extends StatefulWidget { final SettingsService settings; final StorageService storage; final AppDatabase appDatabase; final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; const MainApp({ super.key, required this.settings, required this.storage, required this.appDatabase, required this.databaseRecoveryOutcome, }); @override State createState() => _MainAppState(); } class _MainAppState extends State with WidgetsBindingObserver { late final MultiServerManager _serverManager; late final DataAggregationService _aggregationService; late final AppDatabase _appDatabase; late final DownloadManagerService _downloadManager; late final OfflineWatchSyncService _offlineWatchSyncService; late final AppLifecycleListener _appLifecycleListener; StreamSubscription? _watchStateSubscription; /// WiFi-reconnect sync trigger, listening on [OfflineModeProvider] — the /// app's single connectivity subscription lives there. VoidCallback? _connectivitySyncListener; OfflineModeProvider? _connectivitySyncProvider; Timer? _syncDebounce; final Set _pendingSyncKeys = {}; bool _isAutoDeleteRunning = false; bool _lastConnectivityWasWifi = false; bool _shutdownStarted = false; /// Last time server health probes ran from a resume event (cooldown for desktop) DateTime _lastResumeProbe = DateTime(0); /// Periodic RSS watchdog timer (desktop + Android). Timer? _memoryCheckTimer; /// Last watchdog eviction, for the cooldown; RSS at that moment so a /// still-climbing RSS can re-evict inside the cooldown window. DateTime _lastRssEviction = DateTime(0); int _lastEvictionRss = 0; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _startRssWatchdog(); _serverManager = MultiServerManager(); _aggregationService = DataAggregationService(_serverManager); _appDatabase = widget.appDatabase; PlexApiCache.initialize(_appDatabase); JellyfinApiCache.initialize(_appDatabase); _downloadManager = DownloadManagerService( database: _appDatabase, storageService: DownloadStorageService.instance, clientResolver: _serverManager.resolveDownloadClient, ); _downloadManager.recoveryFuture = _downloadManager.recoverInterruptedDownloads(); _offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager); // Trakt sync service (subscribes to WatchStateNotifier, requires serverManager // to resolve PlexClients for GUID lookups). TraktSyncService.instance.initialize(serverManager: _serverManager); // Tracker singletons init once per app; per-profile hydration happens in // the profile-scoped provider subtree's create callbacks. unawaited(TrackerCoordinator.instance.initialize()); _appLifecycleListener = AppLifecycleListener( onExitRequested: () async { await _shutdownForExit(); return AppExitResponse.exit; }, ); } Future _shutdownForExit() async { if (_shutdownStarted) return; _shutdownStarted = true; _syncDebounce?.cancel(); await _watchStateSubscription?.cancel(); _removeConnectivitySyncListener(); _memoryCheckTimer?.cancel(); _downloadManager.dispose(); TrackerCoordinator.instance.cancelInFlight(); TraktScrobbleService.instance.cancelInFlight(); await TraktSyncService.instance.dispose(); await _serverManager.disconnectAllGracefully(); await Future.wait([ httpClient.closeGracefully(drainTimeout: const Duration(seconds: 5)), closeArtworkHttpClientGracefully(), ], eagerError: false); await ManagedHttpClient.closeAllGracefully(); await _appDatabase.close(); } @override void dispose() { _syncDebounce?.cancel(); _watchStateSubscription?.cancel(); _removeConnectivitySyncListener(); _memoryCheckTimer?.cancel(); _appLifecycleListener.dispose(); if (!_shutdownStarted) { _downloadManager.dispose(); _serverManager.dispose(); } WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didHaveMemoryPressure() { super.didHaveMemoryPressure(); appLogger.w('System memory pressure, evicting image caches'); _evictImageCaches(); } /// RSS-based image-cache eviction. Desktop keeps its fixed 1.5GB bar; /// Android scales to the device because LMK on a 2GB TV box kills well /// below any fixed desktop threshold — and Android trim callbacks /// ([didHaveMemoryPressure]) are best-effort, LMK can kill without ever /// delivering one (#1349). void _startRssWatchdog() { final int threshold; final Duration period; if (PlatformDetector.isDesktopOS()) { threshold = 1536 << 20; // 1.5GB period = const Duration(seconds: 30); } else if (Platform.isAndroid) { final totalMem = DevicePerformance.totalMemBytes; threshold = totalMem != null ? (totalMem * 0.45).round().clamp(512 << 20, 1536 << 20) : 1 << 30; // Decode bursts can spike RSS in seconds on low-end boxes; the read // itself is an in-process syscall, cheap enough for a short period. period = DevicePerformance.isLowEndHardware ? const Duration(seconds: 15) : const Duration(seconds: 30); } else { return; // iOS/tvOS: jetsam pressure arrives via didHaveMemoryPressure. } _memoryCheckTimer = Timer.periodic(period, (_) { final rss = ProcessInfo.currentRss; if (rss <= threshold) return; final cache = PaintingBinding.instance.imageCache; // Floor + cooldown: clearing an already-small cache buys nothing, and // refetch churn is its own memory-spike and jank source. Inside the // cooldown, re-evict only if RSS kept climbing past the last eviction. if (cache.currentSizeBytes < (8 << 20)) return; final now = DateTime.now(); final inCooldown = now.difference(_lastRssEviction) < const Duration(seconds: 60); if (inCooldown && rss <= _lastEvictionRss) return; _lastRssEviction = now; _lastEvictionRss = rss; appLogger.w( 'RSS high (${rss >> 20}MB > ${threshold >> 20}MB), evicting image caches ' '(cache ${cache.currentSizeBytes >> 20}MB/${cache.currentSize} images, ${cache.liveImageCount} live)', ); _evictImageCaches(); }); } void _evictImageCaches() { PaintingBinding.instance.imageCache.clear(); PaintingBinding.instance.imageCache.clearLiveImages(); } /// Fires [_autoDeleteAndSync] on each WiFi/Ethernet reconnect so rules run /// as soon as the device is back online. Listens on [OfflineModeProvider], /// which owns the app's single connectivity subscription and notifies on /// connection-type changes. Rapid flapping is bounded by the executor's /// cooldown. void _startConnectivitySyncTrigger(DownloadProvider downloadProvider, OfflineModeProvider offlineModeProvider) { _removeConnectivitySyncListener(); _lastConnectivityWasWifi = offlineModeProvider.hasWifiOrEthernet; _connectivitySyncProvider = offlineModeProvider; _connectivitySyncListener = () { final hasWifi = offlineModeProvider.hasWifiOrEthernet; final transitioned = hasWifi && !_lastConnectivityWasWifi; _lastConnectivityWasWifi = hasWifi; if (transitioned) { appLogger.d('Connectivity moved onto WiFi/Ethernet — triggering sync pass'); _autoDeleteAndSync(downloadProvider); } }; offlineModeProvider.addListener(_connectivitySyncListener!); } void _removeConnectivitySyncListener() { final listener = _connectivitySyncListener; if (listener != null) _connectivitySyncProvider?.removeListener(listener); _connectivitySyncListener = null; _connectivitySyncProvider = null; } /// Run auto-delete (if enabled) and then a sync-rule pass. /// /// When [targetKeys] is non-null, only those rules are re-evaluated /// (cooldown doesn't apply — targeted runs are always "we know this /// changed"). When null, every rule runs via the executor, with [force] /// gating the cooldown: `true` for user-initiated drains, `false` for /// background probes like a connectivity reconnect. Future _autoDeleteAndSync( DownloadProvider downloadProvider, { List? targetKeys, bool force = false, }) async { if (_isAutoDeleteRunning) { if (targetKeys != null) _pendingSyncKeys.addAll(targetKeys); return; } _isAutoDeleteRunning = true; try { await downloadProvider.refreshMetadataFromCache(); final activeGlobalKey = VideoPlayerScreenState.activeGlobalKey; final settings = SettingsService.instanceOrNull; if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) { final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeGlobalKey: activeGlobalKey); if (deleted.isNotEmpty) { final msg = deleted.length == 1 ? t.messages.autoRemovedWatchedDownload(title: deleted.first) : t.messages.autoRemovedWatchedDownloads(n: deleted.length); showMainSnackBar(msg); } } if (targetKeys != null) { for (final key in targetKeys) { if (!downloadProvider.hasSyncRule(key)) continue; final result = await downloadProvider.executeSyncRuleFor(key, _serverManager); if (result != null && result.queuedCount > 0) { final title = result.title ?? 'Unknown'; showMainSnackBar(t.downloads.syncedNewEpisodes(count: '1', title: '$title (${result.queuedCount})')); } } } else { final synced = await downloadProvider.executeSyncRules(_serverManager, force: force); if (synced.isNotEmpty) { showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first)); } } } finally { _isAutoDeleteRunning = false; if (_pendingSyncKeys.isNotEmpty) { final queuedKeys = _pendingSyncKeys.toList(); _pendingSyncKeys.clear(); unawaited(_autoDeleteAndSync(downloadProvider, targetKeys: queuedKeys)); } } } @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.resumed: // App came back to foreground - trigger sync check _offlineWatchSyncService.onAppResumed(); TraktSyncService.instance.flushQueue(); // Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep. // On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown // to avoid piling up network probes from rapid alt-tabbing. final now = DateTime.now(); final cooldown = (Platform.isIOS || Platform.isAndroid) ? const Duration(seconds: 10) : const Duration(minutes: 2); if (now.difference(_lastResumeProbe) >= cooldown) { _lastResumeProbe = now; // Await health check before reconnecting so stale "online" servers // get marked offline and included in the reconnection sweep. unawaited(() async { await _serverManager.checkServerHealth(); await _serverManager.reconnectOfflineServers(); }()); } case AppLifecycleState.paused: case AppLifecycleState.detached: // Database is session-scoped and must survive suspend/resume. // Closing here would kill the Drift isolate channel while services // (sync, downloads, cache) still hold references to the executor. // SQLite WAL mode handles process death; desktop uses onExitRequested. if (PlatformDetector.isDesktopOS()) { if (ProcessInfo.currentRss > 1024 * 1024 * 1024) { // 1GB _evictImageCaches(); } } else if (Platform.isAndroid) { // A backgrounded app is LMK's first candidate; shed the image // caches at a lower bar than the foreground watchdog to survive // the HOME press on low-RAM boxes. final totalMem = DevicePerformance.totalMemBytes; final bar = totalMem != null ? (totalMem * 0.35).round() : 768 << 20; if (ProcessInfo.currentRss > bar) { _evictImageCaches(); } } case AppLifecycleState.inactive: case AppLifecycleState.hidden: // Transitional states - don't trigger session events break; } } @override Widget build(BuildContext context) { return MultiProvider( providers: [ // Expose AppDatabase + ConnectionRegistry so screens (Settings, Setup) // can manage stored Jellyfin/Plex connections without re-creating // the registry per-call site. Provider.value(value: widget.settings), Provider.value(value: widget.storage), Provider.value(value: _appDatabase), Provider(create: (_) => ConnectionRegistry(_appDatabase)), Provider(create: (_) => ProfileRegistry(_appDatabase)), Provider(create: (_) => ProfileConnectionRegistry(_appDatabase)), Provider( create: (context) { // start() resolves StorageService internally — the singleton was // already initialised eagerly during boot, so the await is a // microtask hop in practice. final service = PlexHomeService( connections: context.read(), profileConnections: context.read(), storage: context.read(), ); unawaited(service.start()); return service; }, dispose: (_, s) => s.dispose(), ), ChangeNotifierProvider( create: (context) { final provider = ActiveProfileProvider( registry: context.read(), plexHome: context.read(), connections: context.read(), storage: context.read(), ); unawaited(provider.initialize()); return provider; }, ), ChangeNotifierProvider( create: (context) { _serverManager.onJellyfinConnectionUpdated = context.read().upsert; return MultiServerProvider(_serverManager, _aggregationService); }, ), ChangeNotifierProxyProvider( create: (context) { final provider = OfflineModeProvider( _serverManager, multiServerProvider: context.read(), ); provider.initialize(); // Initialize immediately so statusStream listener is ready return provider; }, update: (_, multiServerProvider, previous) { final provider = previous ?? OfflineModeProvider(_serverManager, multiServerProvider: multiServerProvider); provider.updateMultiServerProvider(multiServerProvider); provider.initialize(); // Idempotent - safe to call again return provider; }, ), // Profile binder owns the cold-start client connect: Plex token // refresh + Jellyfin client creation. Hoisted out of MainScreen so // the splash can await its first settle — without this, MainScreen // mounts (and discover/libraries query) before any client exists. // It is intentionally not auto-started here: SetupScreen first checks // whether startup should go straight offline, otherwise the binder's // microtask can begin network work before the offline decision lands. Provider( lazy: false, create: (context) { final activeProfile = context.read(); return ActiveProfileBinder( activeProfile: activeProfile, connections: context.read(), profileConnections: context.read(), serverManager: _serverManager, multiServerProvider: context.read(), pinPrompt: _rootPinPrompt, shouldDeferInitialBind: (_) async { final settings = await SettingsService.getInstance(); return settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; }, ); }, dispose: (_, binder) => binder.dispose(), ), // Download provider. Downloads are shared, but sync rules are scoped to // the active profile and reload when the profile changes. ChangeNotifierProxyProvider( create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase), update: (context, activeProfile, previous) { final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase); provider.setActiveProfileId(activeProfile.activeId); return provider; }, ), ChangeNotifierProxyProvider( create: (context) { final offlineModeProvider = context.read(); final downloadProvider = context.read(); final activeProfile = context.read(); _offlineWatchSyncService.setActiveProfileId( activeProfile.activeId, // Legacy-adoption gate: only trust the count once the provider // has hydrated (locals + cached home users) — a transient // count of 1 mid-load would permanently mis-adopt pre-profile // watch actions. availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null, ); // Offline-sync drain replays a batch of queued watch actions without // per-item data, so we can't target rules — force a full pass. _offlineWatchSyncService.onWatchStatesRefreshed = () async { await _autoDeleteAndSync(downloadProvider, force: true); }; // In-session watch events carry the episode's parent chain, so we // only re-evaluate rules that actually cover the watched item — // leaves unrelated collection/playlist rules alone. Debounced so // binge-watching coalesces into one pass. _watchStateSubscription = WatchStateNotifier().stream.listen((event) { if (event.changeType != WatchStateChangeType.watched) return; if (VideoPlayerScreenState.activeGlobalKey == event.globalKey) return; _pendingSyncKeys.addAll(downloadProvider.syncRuleKeysForWatchEvent(event)); _syncDebounce?.cancel(); _syncDebounce = Timer(const Duration(seconds: 5), () { final keys = _pendingSyncKeys.toList(); _pendingSyncKeys.clear(); _autoDeleteAndSync(downloadProvider, targetKeys: keys); }); }); _startConnectivitySyncTrigger(downloadProvider, offlineModeProvider); // Thread the offline flag into services so queue/resume paths can // short-circuit instead of hitting the network and failing. downloadProvider.setOfflineSource(offlineModeProvider); _offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider); return _offlineWatchSyncService; }, update: (_, activeProfile, previous) { final provider = previous ?? _offlineWatchSyncService; provider.setActiveProfileId( activeProfile.activeId, availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null, ); return provider; }, ), ChangeNotifierProxyProvider2( create: (context) => OfflineWatchProvider( syncService: context.read(), downloadProvider: context.read(), ), update: (_, syncService, downloadProvider, previous) { return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); }, ), ChangeNotifierProxyProvider2( create: (context) => UserProfileProvider(storageService: context.read()), update: (context, activeProfile, connections, previous) { final provider = previous ?? UserProfileProvider(storageService: context.read()); provider.attach( connections: connections, activeProfile: activeProfile, profileConnections: context.read(), serverManager: context.read().serverManager, ); return provider; }, ), ChangeNotifierProvider(create: (context) => ThemeProvider()), // Shader presets are app-global — deliberately outside the // profile-scoped session in ProfileSessionScreen. ChangeNotifierProvider(create: (context) => ShaderProvider()), ], child: _AppShell(databaseRecoveryOutcome: widget.databaseRecoveryOutcome), ); } } /// App-global shell: theme consumer, translations, root input handling, and the /// root MaterialApp. Profile-scoped providers/navigation live in /// [ProfileSessionScreen], not here, so root auth/PIN/global dialogs survive a /// profile switch. class _AppShell extends StatelessWidget { const _AppShell({required this.databaseRecoveryOutcome}); final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; @override Widget build(BuildContext context) { return Consumer( builder: (context, themeProvider, child) { return TranslationProvider( child: Builder( builder: (context) { return Listener( onPointerDown: (event) { if ((event.buttons & kBackMouseButton) != 0) { unawaited(() async { final rootNavigator = rootNavigatorKey.currentState; if (rootNavigator?.canPop() ?? false) { await rootNavigator?.maybePop(); return; } await profileNavigationRegistry.maybePopProfileRoute(); }()); } }, behavior: HitTestBehavior.translucent, child: InputModeTracker( child: MaterialApp( title: t.app.title, debugShowCheckedModeBanner: false, theme: themeProvider.lightTheme, darkTheme: themeProvider.darkTheme, themeMode: themeProvider.materialThemeMode, navigatorKey: rootNavigatorKey, navigatorObservers: [BackKeySuppressorObserver()], home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome), // Siri Remote select + gamepad A report as // LogicalKeyboardKey.{select,gameButtonA} which aren't // in Flutter's default shortcut set — Material-level // widgets (menu items, showModalBottomSheet actions) // ignore them. Map both to ActivateIntent so tapping // select on tvOS activates the focused widget. shortcuts: { ...WidgetsApp.defaultShortcuts, const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(), const SingleActivator(LogicalKeyboardKey.gameButtonA): const ActivateIntent(), const SingleActivator(LogicalKeyboardKey.goBack): const DismissIntent(), const SingleActivator(LogicalKeyboardKey.browserBack): const DismissIntent(), const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(), }, builder: (context, child) => ScaffoldMessenger( key: rootScaffoldMessengerKey, child: Scaffold( backgroundColor: Colors.transparent, body: _AppleTvScale(child: child), ), ), ), ), ); }, ), ); }, ); } } /// On Apple TV the system hands Flutter a 1920×1080 surface at /// devicePixelRatio 1.0, the same logical pixel count as a phablet. That's /// too dense for a 10ft viewing distance, so everything ends up tiny. We /// shrink the effective logical size to half and scale the rendered output /// back up so fonts, icons, and paddings end up visually ~2× larger — roughly /// matching the UI feel of Android TV (which renders at lower logical DPI). class _AppleTvScale extends StatelessWidget { final Widget? child; const _AppleTvScale({required this.child}); static const double _scale = 2.0; @override Widget build(BuildContext context) { if (child == null || !PlatformDetector.isAppleTV()) { return child ?? const SizedBox.shrink(); } return LayoutBuilder( builder: (context, constraints) { final logicalSize = Size(constraints.maxWidth / _scale, constraints.maxHeight / _scale); final outerQ = MediaQuery.of(context); // tvOS reports conservative overscan insets (~60pt top/bottom, // ~90pt left/right). Modern TVs don't overscan, so treat them as // dead margin and zero them out — the UI can use the full surface. return Transform.scale( scale: _scale, alignment: .topLeft, transformHitTests: true, child: SizedBox( width: logicalSize.width, height: logicalSize.height, child: MediaQuery( data: outerQ.copyWith( size: logicalSize, devicePixelRatio: outerQ.devicePixelRatio * _scale, padding: .zero, viewPadding: .zero, viewInsets: .zero, systemGestureInsets: .zero, ), child: child!, ), ), ); }, ); } } @visibleForTesting bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) { return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired; } class OrientationAwareSetup extends StatefulWidget { const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome}); final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; @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 SetupScreen(databaseRecoveryOutcome: widget.databaseRecoveryOutcome); } } class SetupScreen extends StatefulWidget { const SetupScreen({ super.key, required this.databaseRecoveryOutcome, this.initializeAuthServices = true, this.debugRecoveryRequiredRouter, }); final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; final bool initializeAuthServices; @visibleForTesting final FutureOr Function(BuildContext context, String message)? debugRecoveryRequiredRouter; @override State createState() => _SetupScreenState(); } class _SetupScreenState extends State with MountedSetStateMixin { String _statusMessage = ''; bool _enteringOffline = false; // Per-server connection status: serverId -> (name, connected?) final Map _serverStatus = {}; @override void initState() { super.initState(); _loadSavedCredentials(); } void _setStatus(String message) { setStateIfMounted(() => _statusMessage = message); } Future _enterOfflineMode() async { if (_enteringOffline) return; _enteringOffline = true; _setStatus(t.common.startingOfflineMode); await context.read().ensureInitialized(); if (!mounted) return; unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen(isOfflineMode: true)))); } Future _loadSavedCredentials() async { if (shouldBypassSetupForDatabaseRecovery(widget.databaseRecoveryOutcome)) { final message = t.auth.localDataRecoveryRequired; final debugRouter = widget.debugRecoveryRequiredRouter; if (debugRouter != null) { await Future.sync(() => debugRouter(context, message)); return; } await Future.delayed(Duration.zero); if (mounted) { unawaited( Navigator.pushReplacement( context, fadeRoute( AuthScreen( initialErrorMessage: message, initializeServices: widget.initializeAuthServices, databaseRecoveryRequired: true, ), ), ), ); } return; } _setStatus(t.common.checkingNetwork); final storage = await StorageService.getInstance(); final registry = ServerRegistry(storage); // Idempotent: brings legacy SharedPreferences state (plexToken, // currentUserUUID, homeUsersCache) into the new ConnectionRegistry + // ProfileRegistry tables. No-op on subsequent launches. if (mounted) { try { final connRegistry = context.read(); final profileConnections = context.read(); final profileRegistry = context.read(); final activeProfiles = context.read(); final serverManager = context.read().serverManager; final bootstrap = ConnectionBootstrap( storage: storage, connectionRegistry: connRegistry, serverRegistry: registry, profileRegistry: profileRegistry, ); await bootstrap.run(); final pruned = await pruneUnreferencedJellyfinConnections( profileConnections: profileConnections, connections: connRegistry, storage: storage, serverManager: serverManager, ); if (pruned > 0) { appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}'); } // Provider initialization starts before this screen runs the legacy // migration. Reload after bootstrap so copied Plex Home users and the // selected active profile are visible before setup decides binding is // already settled and navigates to MainScreen. await activeProfiles.reloadFromStorage(); } catch (e, st) { appLogger.w('Boot-time migration failed', error: e, stackTrace: st); } } // Check network connectivity early to fast-path airplane mode. // Timeout guards against connectivity_plus hanging on some Android TV devices after force-close. bool hasNetwork; unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Checking network connectivity', category: 'setup'))); try { final connectivityResult = await Connectivity().checkConnectivity().timeout( const Duration(seconds: 3), onTimeout: () => [ConnectivityResult.other], ); hasNetwork = !connectivityResult.contains(ConnectivityResult.none); } catch (e) { // connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager hasNetwork = true; } unawaited( Sentry.addBreadcrumb(Breadcrumb(message: 'Network check done: hasNetwork=$hasNetwork', category: 'setup')), ); _setStatus(t.common.loadingServers); if (!mounted) return; // Snapshot ConnectionRegistry before we cross any awaits — Provider lookups // through `context` after async gaps trip the use_build_context_synchronously // lint, and reading early is safe because the registry is a singleton. final connectionRegistry = context.read(); final List allConnections; try { allConnections = await connectionRegistry.list(); } catch (e, st) { // Defence-in-depth: a DB-open failure here used to propagate // uncaught and strand the splash forever (#1022). Route to auth so // the user is never trapped, and surface to Sentry so an unknown // regression doesn't go silent. appLogger.e('Setup: failed to load connections; returning to auth', error: e, stackTrace: st); unawaited(Sentry.captureException(e, stackTrace: st)); if (mounted) { unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen()))); } return; } if (allConnections.isEmpty) { if (mounted) { unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen()))); } return; } if (!mounted) return; // No network — skip connection attempts and go straight to offline mode if (!hasNetwork) { await _enterOfflineMode(); return; } if (mounted) { setState(() { for (final conn in allConnections) { if (conn is PlexAccountConnection) { for (final s in conn.servers) { _serverStatus[s.clientIdentifier] = (s.name, null); } } else if (conn is JellyfinConnection) { _serverStatus[conn.serverMachineId] = (conn.serverName, null); } } }); } final plexCount = allConnections.whereType().fold(0, (n, c) => n + c.servers.length); final jellyfinCount = allConnections.whereType().length; unawaited( Sentry.addBreadcrumb( Breadcrumb( message: 'Handing off to MainScreen with $plexCount Plex server(s) + $jellyfinCount Jellyfin', category: 'setup', ), ), ); _setStatus(t.common.connectingToServers); // Snapshot Provider refs before further awaits. final activeProfile = context.read(); // The Provider is `lazy: false` so the binder is constructed already, but // SetupScreen starts it only after the offline fast path has been ruled out. final binder = context.read(); final downloadProvider = context.read(); // Wait for the active profile to load from disk so the binder has a // profile to bind. `initialize` is fire-and-forget at provider creation, // so awaiting here pulls control through the same future and triggers // the listener-driven rebind synchronously. await activeProfile.reloadFromStorage(); if (!mounted) return; if (activeProfile.active == null && activeProfile.profiles.isEmpty) { appLogger.w('Setup: stored connections exist but no profiles resolved after bootstrap; returning to auth'); unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen()))); return; } // Wire the per-server status listener before either branch so the splash // checkmarks fill in even while the user is choosing a profile. _bindServerStatusListener(activeProfile, _serverManagerFromContext); // Start only after network/offline startup has been decided and the // active profile snapshot is hydrated. This prevents an eager binder // microtask from racing the no-network/manual-offline fast path. binder.start(); // If "prompt for profile on launch" is on (or no profile is selected // yet), surface the picker BEFORE waiting for the previously-active // profile's bind to settle — otherwise the user sees the splash fully // connect before the prompt arrives. The picker's own `_switchTo` calls // `awaitBindingSettle` after activation, so by the time it pops, the // chosen profile's bind is settled. final settings = await SettingsService.getInstance(); if (!mounted) return; final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; final requireOnOpen = settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; final shouldPrompt = hasNoActive || requireOnOpen; var bindingSucceeded = activeProfile.lastBindingSucceeded; if (shouldPrompt) { await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true))); if (!mounted) return; bindingSucceeded = activeProfile.active != null && activeProfile.lastBindingSucceeded; } else { // Now wait for the binder to settle. This is the Plex/Jellyfin server // race: per-server status flips on the splash list as each client comes // online, and we don't push MainScreen until they're all done (success // or fail). Eliminates the "Failed to load discover content: No servers // available" race the old eager-navigate flow caused. bindingSucceeded = await activeProfile.awaitBindingSettle(); if (!mounted) return; } if (shouldEnterOfflineModeAfterStartupBind( bindingSucceeded: bindingSucceeded, hasOnlineServers: _serverManagerFromContext().onlineServerIds.isNotEmpty, )) { appLogger.w('Setup: no servers online after startup bind; starting offline mode'); await _enterOfflineMode(); return; } // Repopulate metadata for downloaded items now that per-backend caches // are resolvable (the Connections row + live JellyfinClient are in // place). Without this the downloads list and sync-rule titles render // empty until something forces a later refresh. await downloadProvider.refreshMetadataFromCache(); if (!mounted) return; unawaited(Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: shouldPrompt)))); } /// Wire per-server status updates from [MultiServerManager] into the /// splash list so the user sees check/cross marks land as the binder /// brings each client online. [MultiServerManager.connectProgressStream] /// fires as each individual server settles; [MultiServerManager.statusStream] /// emits once per connect pass and back-fills anything the progress stream /// missed (e.g. servers torn down by the binder's visibility sweep). /// Best-effort: stops listening when the state goes away. StreamSubscription>? _statusSub; StreamSubscription<({String serverId, bool online})>? _connectProgressSub; void _bindServerStatusListener(ActiveProfileProvider _, MultiServerManager Function() resolveManager) { _statusSub?.cancel(); _connectProgressSub?.cancel(); final manager = resolveManager(); _connectProgressSub = manager.connectProgressStream.listen((progress) { if (!mounted) return; final existing = _serverStatus[progress.serverId]; if (existing == null) return; setState(() { _serverStatus[progress.serverId] = (existing.$1, progress.online); }); }); _statusSub = manager.statusStream.listen((status) { if (!mounted) return; setState(() { for (final entry in status.entries) { final existing = _serverStatus[entry.key]; if (existing != null) { _serverStatus[entry.key] = (existing.$1, entry.value); } } }); }); } MultiServerManager _serverManagerFromContext() => context.read().serverManager; @override void dispose() { _statusSub?.cancel(); _connectProgressSub?.cancel(); super.dispose(); } Widget _buildStatusText(BuildContext context) { return AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: Text( _statusMessage, key: ValueKey(_statusMessage), textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)), ), ); } Widget _buildServerStatusList(BuildContext context) { if (_serverStatus.isEmpty) return const SizedBox.shrink(); final textTheme = Theme.of(context).textTheme; final dimColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5); const coralColor = Color(0xFFE5A00D); const successColor = Color(0xFF4CAF50); const failColor = Color(0xFFEF5350); return Column( mainAxisSize: .min, children: _serverStatus.entries.map((entry) { final (name, connected) = entry.value; final Widget statusIcon; if (connected == null) { statusIcon = const SizedBox( width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor), ); } else if (connected) { statusIcon = const AppIcon(Symbols.check_circle_rounded, size: 14, color: successColor); } else { statusIcon = const AppIcon(Symbols.cancel_rounded, size: 14, color: failColor); } return Padding( key: ValueKey(entry.key), padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisSize: .min, children: [ statusIcon, const SizedBox(width: 8), Text(name, style: textTheme.bodySmall?.copyWith(color: dimColor)), ], ), ); }).toList(), ); } @override Widget build(BuildContext context) { const coralColor = Color(0xFFE5A00D); return ColoredBox( color: Theme.of(context).scaffoldBackgroundColor, child: Stack( children: [ Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)), Positioned( left: 0, right: 0, bottom: MediaQuery.sizeOf(context).height * 0.5 - 170, child: _buildStatusText(context), ), Positioned( left: 0, right: 0, top: MediaQuery.sizeOf(context).height * 0.5 + 180, child: Center( child: _serverStatus.isEmpty ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: coralColor), ) : _buildServerStatusList(context), ), ), ], ), ); } }