diff --git a/lib/main.dart b/lib/main.dart index 249ba529..cc001f25 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -385,6 +385,11 @@ void _registerShaderLicenses() { final RouteObserver routeObserver = RouteObserver(); 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 /// per-screen widget tree. Routes through [rootNavigatorKey] so the dialog /// renders correctly whether the binder fires from the splash, MainScreen, @@ -1127,7 +1132,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { // Reading the binder here is enough — the Provider is `lazy: false` so // it has already constructed the binder and called `start()` during // MultiProvider build. We just need to wait for it. - context.read(); + final binder = context.read(); final downloadProvider = context.read(); // Wait for the active profile to load from disk so the binder has a @@ -1160,19 +1165,38 @@ class _SetupScreenState extends State with MountedSetStateMixin { 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. - await activeProfile.awaitBindingSettle(); + bindingSucceeded = await activeProfile.awaitBindingSettle(); if (!mounted) return; + if (!bindingSucceeded) { + appLogger.w('Setup: initial profile bind failed; retrying once before entering main screen'); + await binder.rebindActive(); + if (!mounted) return; + bindingSucceeded = activeProfile.lastBindingSucceeded; + } + } + + if (shouldEnterOfflineModeAfterStartupBind( + bindingSucceeded: bindingSucceeded, + hasOnlineServers: _serverManagerFromContext().onlineServerIds.isNotEmpty, + )) { + appLogger.w('Setup: no servers online after startup bind; starting offline mode'); + await downloadProvider.ensureInitialized(); + if (!mounted) return; + unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)))); + return; } // Repopulate metadata for downloaded items now that per-backend caches diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index dd256cec..ac388982 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -83,6 +83,16 @@ class MainScreenFocusScope extends InheritedWidget { } } +@visibleForTesting +bool shouldRetryActiveProfileBindAfterReconnect({ + required bool hasActiveProfile, + required bool hasVisibleConnectedServers, + required bool hasManagerOnlineServers, + required bool hasKnownOfflineServers, +}) { + return hasActiveProfile && !hasVisibleConnectedServers && (hasManagerOnlineServers || !hasKnownOfflineServers); +} + class MainScreen extends StatefulWidget { final bool isOfflineMode; @@ -576,16 +586,20 @@ class _MainScreenState extends State void didChangeDependencies() { super.didChangeDependencies(); - // Listen for offline/online transitions to refresh navigation & screens - // Note: We don't call _handleOfflineStatusChanged() immediately because - // widget.isOfflineMode (from SetupScreen navigation) is authoritative for - // initial state. The provider may not yet have received the server status - // update due to initialization timing. The listener handles runtime changes. + // Listen for offline/online transitions to refresh navigation & screens. + // `widget.isOfflineMode` stays authoritative when SetupScreen explicitly + // routed here offline, but if the provider already observed a failed bind + // before this listener attached, mirror that missed state after build. final provider = context.read(); if (provider != null && provider != _offlineModeProvider) { _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); _offlineModeProvider = provider; _offlineModeProvider!.addListener(_handleOfflineStatusChanged); + if (!widget.isOfflineMode && !_isOffline && provider.isOffline) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _handleOfflineStatusChanged(); + }); + } } // Listen for Live TV / DVR availability changes @@ -793,13 +807,25 @@ class _MainScreenState extends State if (_isReconnecting) return; setState(() => _isReconnecting = true); - final serverManager = context.read().serverManager; + final multiServerProvider = context.read(); + final serverManager = multiServerProvider.serverManager; + final activeProfile = context.read(); + final binder = context.read(); unawaited(() async { try { // Health check first so stale "online" servers get marked offline before // we snapshot the offline list for reconnection. await serverManager.checkServerHealth(); await serverManager.reconnectOfflineServers(forceRediscovery: true); + if (!mounted) return; + if (shouldRetryActiveProfileBindAfterReconnect( + hasActiveProfile: activeProfile.active != null, + hasVisibleConnectedServers: multiServerProvider.hasConnectedServers, + hasManagerOnlineServers: serverManager.onlineServerIds.isNotEmpty, + hasKnownOfflineServers: serverManager.offlineServerIds.isNotEmpty, + )) { + await binder.rebindActive(); + } } finally { setStateIfMounted(() => _isReconnecting = false); } diff --git a/test/screens/startup_bind_recovery_test.dart b/test/screens/startup_bind_recovery_test.dart new file mode 100644 index 00000000..36f128a8 --- /dev/null +++ b/test/screens/startup_bind_recovery_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/main.dart'; +import 'package:plezy/screens/main_screen.dart'; + +void main() { + group('startup bind recovery', () { + test('enters offline mode only when initial bind failed with no online servers', () { + expect(shouldEnterOfflineModeAfterStartupBind(bindingSucceeded: false, hasOnlineServers: false), isTrue); + expect(shouldEnterOfflineModeAfterStartupBind(bindingSucceeded: true, hasOnlineServers: false), isFalse); + expect(shouldEnterOfflineModeAfterStartupBind(bindingSucceeded: false, hasOnlineServers: true), isFalse); + }); + + test('retries active profile bind when reconnect has no visible servers', () { + expect( + shouldRetryActiveProfileBindAfterReconnect( + hasActiveProfile: true, + hasVisibleConnectedServers: false, + hasManagerOnlineServers: true, + hasKnownOfflineServers: false, + ), + isTrue, + ); + expect( + shouldRetryActiveProfileBindAfterReconnect( + hasActiveProfile: true, + hasVisibleConnectedServers: false, + hasManagerOnlineServers: false, + hasKnownOfflineServers: false, + ), + isTrue, + ); + expect( + shouldRetryActiveProfileBindAfterReconnect( + hasActiveProfile: true, + hasVisibleConnectedServers: true, + hasManagerOnlineServers: true, + hasKnownOfflineServers: false, + ), + isFalse, + ); + expect( + shouldRetryActiveProfileBindAfterReconnect( + hasActiveProfile: false, + hasVisibleConnectedServers: false, + hasManagerOnlineServers: true, + hasKnownOfflineServers: false, + ), + isFalse, + ); + expect( + shouldRetryActiveProfileBindAfterReconnect( + hasActiveProfile: true, + hasVisibleConnectedServers: false, + hasManagerOnlineServers: false, + hasKnownOfflineServers: true, + ), + isFalse, + ); + }); + }); +}