diff --git a/lib/main.dart b/lib/main.dart index d3335bb9..91f618e9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -711,10 +711,29 @@ class _MainAppState extends State with WidgetsBindingObserver { 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) { @@ -731,22 +750,10 @@ class _MainAppState extends State with WidgetsBindingObserver { return settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; }, - )..start(); + ); }, dispose: (_, binder) => binder.dispose(), ), - 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. Downloads are shared, but sync rules are scoped to // the active profile and reload when the profile changes. ChangeNotifierProxyProvider( @@ -1054,6 +1061,7 @@ class SetupScreen extends StatefulWidget { class _SetupScreenState extends State with MountedSetStateMixin { String _statusMessage = ''; + bool _enteringOffline = false; // Per-server connection status: serverId -> (name, connected?) final Map _serverStatus = {}; @@ -1068,6 +1076,15 @@ class _SetupScreenState extends State with MountedSetStateMixin { 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 MainScreen(isOfflineMode: true)))); + } + Future _loadSavedCredentials() async { _setStatus(t.common.checkingNetwork); @@ -1153,10 +1170,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { // No network — skip connection attempts and go straight to offline mode if (!hasNetwork) { - _setStatus(t.common.startingOfflineMode); - await context.read().ensureInitialized(); - if (!mounted) return; - unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true)))); + await _enterOfflineMode(); return; } @@ -1188,9 +1202,8 @@ class _SetupScreenState extends State with MountedSetStateMixin { // Snapshot Provider refs before further awaits. final activeProfile = context.read(); - // 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. + // 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(); @@ -1211,6 +1224,11 @@ class _SetupScreenState extends State with MountedSetStateMixin { // 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 @@ -1239,12 +1257,6 @@ class _SetupScreenState extends State with MountedSetStateMixin { // available" race the old eager-navigate flow caused. 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( @@ -1252,9 +1264,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { 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)))); + await _enterOfflineMode(); return; } diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 473f41ff..603731b1 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -22,6 +22,19 @@ typedef PlexHomePinPrompt = Future Function(Profile profile, {String? e typedef ShouldDeferInitialBind = FutureOr Function(Profile profile); +class _ProfileBindResult { + const _ProfileBindResult({required this.visibleServerIds, required this.expectedServerIds}); + + const _ProfileBindResult.empty() : visibleServerIds = const {}, expectedServerIds = const {}; + + _ProfileBindResult.visible(Set ids) + : visibleServerIds = Set.unmodifiable(ids), + expectedServerIds = Set.unmodifiable(ids); + + final Set visibleServerIds; + final Set expectedServerIds; +} + @visibleForTesting bool shouldUsePlexHomeTokenCache({required bool preVerified, required bool hasBoundOnce}) { return preVerified || !hasBoundOnce; @@ -209,19 +222,28 @@ class ActiveProfileBinder { appLogger.i('ActiveProfileBinder: rebinding for ${profile.displayName} (${profile.id})'); - final visibleServerIds = {}; + final expectedServerIds = await _expectedServerIdsForProfile(profile); + multiServerProvider.setExpectedVisibleServerIds(expectedServerIds); final localProfileHasJoinRows = profile.isLocal && (await profileConnections.listForProfile(profile.id)).isNotEmpty; - if (profile.isPlexHome) { - visibleServerIds.addAll(await _bindPlexHome(profile)); + // Bind the implicit Plex Home parent and borrowed/extra join rows in + // parallel. A slow/offline Plex parent should not add its timeout budget + // on top of an otherwise reachable Jellyfin or borrowed-server bind. + final results = await Future.wait([ + if (profile.isPlexHome) _bindPlexHome(profile), + // Both kinds also bind borrowed/extra connections via the join table. + // For plex_home this handles a Jellyfin server (or extra Plex account) + // that was attached to the profile via the borrow flow — the parent + // account is bound by `_bindPlexHome` above and isn't represented in + // the join table. + _bindJoinRows(profile), + ]); + final visibleServerIds = {}; + for (final result in results) { + visibleServerIds.addAll(result.visibleServerIds); + expectedServerIds.addAll(result.expectedServerIds); } - // Both kinds also bind borrowed/extra connections via the join table. - // For plex_home this handles a Jellyfin server (or extra Plex account) - // that was attached to the profile via the borrow flow — the parent - // account is bound by `_bindPlexHome` above and isn't represented in - // the join table. - visibleServerIds.addAll(await _bindJoinRows(profile)); // Remove servers the profile no longer has access to. Always set the // filter to the bound set (even when empty) so a profile with no @@ -232,6 +254,7 @@ class ActiveProfileBinder { serverManager.removeServer(serverId); } } + multiServerProvider.setExpectedVisibleServerIds(expectedServerIds); multiServerProvider.setVisibleServerIds(visibleServerIds); success = (profile.isLocal && !localProfileHasJoinRows) || visibleServerIds.isNotEmpty; // Once we've bound a profile with real servers in this session, @@ -253,17 +276,45 @@ class ActiveProfileBinder { } } - Future> _bindPlexHome(Profile profile) async { + Future> _expectedServerIdsForProfile(Profile profile) async { + final expected = {}; + final parentId = profile.parentConnectionId; + if (profile.isPlexHome && parentId != null) { + final account = await connections.getPlexAccount(parentId); + if (account != null) { + expected.addAll(account.servers.map((server) => server.clientIdentifier)); + } + } + + final pcs = await profileConnections.listForProfile(profile.id); + if (pcs.isEmpty) return expected; + final all = await connections.list(); + final byId = {for (final c in all) c.id: c}; + for (final pc in pcs) { + if (parentId != null && pc.connectionId == parentId) continue; + switch (byId[pc.connectionId]) { + case PlexAccountConnection(:final servers): + expected.addAll(servers.map((server) => server.clientIdentifier)); + case JellyfinConnection(:final serverMachineId): + expected.add(serverMachineId); + case null: + break; + } + } + return expected; + } + + Future<_ProfileBindResult> _bindPlexHome(Profile profile) async { final parentId = profile.parentConnectionId; final homeUuid = profile.plexHomeUserUuid; if (parentId == null || homeUuid == null) { appLogger.w('ActiveProfileBinder: ${profile.displayName} missing parent/uuid metadata'); - return const {}; + return const _ProfileBindResult.empty(); } final account = await connections.getPlexAccount(parentId); if (account == null) { appLogger.w('ActiveProfileBinder: parent connection $parentId for ${profile.displayName} not found'); - return const {}; + return const _ProfileBindResult.empty(); } final auth = await _ensureAuth(); @@ -309,7 +360,7 @@ class ActiveProfileBinder { if (e.isTransient) { return _connectFromCachedServers(account, cachedToken, profile.displayName, error: e); } - return const {}; + return const _ProfileBindResult.empty(); } } catch (e, st) { appLogger.w( @@ -317,7 +368,7 @@ class ActiveProfileBinder { error: e, stackTrace: st, ); - return const {}; + return const _ProfileBindResult.empty(); } } @@ -330,7 +381,7 @@ class ActiveProfileBinder { promptForPin: ({String? errorMessage}) => pinPrompt(profile, errorMessage: errorMessage), logLabel: profile.displayName, ); - if (!result.succeeded) return const {}; + if (!result.succeeded) return const _ProfileBindResult.empty(); // Persist the minted user-token onto the parent ProfileConnection // row. Plex Home profiles don't normally have a join row for the // parent (the borrow flow is for *other* connections layered onto @@ -360,19 +411,21 @@ class ActiveProfileBinder { /// join table). Skips Plex rows whose `connectionId` matches the parent /// (defensive guard — sync code shouldn't insert one, but treating it as /// a borrow would re-mint a redundant token). - Future> _bindJoinRows(Profile profile) async { + Future<_ProfileBindResult> _bindJoinRows(Profile profile) async { final pcs = await profileConnections.listForProfile(profile.id); if (pcs.isEmpty) { if (profile.isLocal) { appLogger.w('ActiveProfileBinder: ${profile.displayName} has no connections'); } - return const {}; + return const _ProfileBindResult.empty(); } final all = await connections.list(); final byId = {for (final c in all) c.id: c}; final parentId = profile.parentConnectionId; final visible = {}; + final expected = {}; + final futures = >[]; for (final pc in pcs) { if (parentId != null && pc.connectionId == parentId) continue; final conn = byId[pc.connectionId]; @@ -382,16 +435,22 @@ class ActiveProfileBinder { } switch (conn) { case PlexAccountConnection(): - visible.addAll(await _bindLocalPlexConnection(profile: profile, conn: conn, pc: pc)); + expected.addAll(conn.servers.map((server) => server.clientIdentifier)); + futures.add(_bindLocalPlexConnection(profile: profile, conn: conn, pc: pc)); case JellyfinConnection(): - final id = await _bindJellyfin(conn); - if (id != null) visible.add(id); + expected.add(conn.serverMachineId); + futures.add(_bindJellyfin(conn)); } } - return visible; + final results = await Future.wait(futures); + for (final result in results) { + visible.addAll(result.visibleServerIds); + expected.addAll(result.expectedServerIds); + } + return _ProfileBindResult(visibleServerIds: visible, expectedServerIds: expected); } - Future> _bindLocalPlexConnection({ + Future<_ProfileBindResult> _bindLocalPlexConnection({ required Profile profile, required PlexAccountConnection conn, required ProfileConnection pc, @@ -414,38 +473,43 @@ class ActiveProfileBinder { appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e); if (e.isTransient) { final ids = await _connectFromCachedServers(conn, userToken, profile.displayName, error: e); - if (ids.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id); + if (ids.visibleServerIds.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id); return ids; } - return const {}; + return const _ProfileBindResult.empty(); } } catch (e, st) { appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st); - return const {}; + return const _ProfileBindResult.empty(); } } if (userToken == null || userToken.isEmpty) { if (pc.userIdentifier.isEmpty) { appLogger.w('ActiveProfileBinder: ${profile.displayName} has no Plex Home user identifier'); - return const {}; + return const _ProfileBindResult.empty(); } final minted = await _mintLocalPlexToken(auth: auth, profile: profile, conn: conn, pc: pc); - if (minted == null) return const {}; + if (minted == null) return const _ProfileBindResult.empty(); userToken = minted; try { servers = await auth.fetchServers(userToken); } on MediaServerHttpException catch (e) { appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e); + if (e.statusCode == 401 || e.statusCode == 403) { + serverManager.markPlexConnectionAuthError(conn); + final ids = conn.servers.map((server) => server.clientIdentifier).toSet(); + return _ProfileBindResult.visible(ids); + } if (e.isTransient) { final ids = await _connectFromCachedServers(conn, userToken, profile.displayName, error: e); - if (ids.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id); + if (ids.visibleServerIds.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id); return ids; } - return const {}; + return const _ProfileBindResult.empty(); } catch (e, st) { appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st); - return const {}; + return const _ProfileBindResult.empty(); } } @@ -476,32 +540,41 @@ class ActiveProfileBinder { return userToken; } - Future> _connectPlexServers(PlexAccountConnection account, String userToken, String profileLabel) async { + Future<_ProfileBindResult> _connectPlexServers( + PlexAccountConnection account, + String userToken, + String profileLabel, + ) async { final auth = await _ensureAuth(); final List servers; try { servers = await auth.fetchServers(userToken); } on MediaServerHttpException catch (e, st) { appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st); + if (e.statusCode == 401 || e.statusCode == 403) { + serverManager.markPlexConnectionAuthError(account); + final ids = account.servers.map((server) => server.clientIdentifier).toSet(); + return _ProfileBindResult.visible(ids); + } if (e.isTransient) { return _connectFromCachedServers(account, userToken, profileLabel, error: e, stackTrace: st); } - return const {}; + return const _ProfileBindResult.empty(); } catch (e, st) { appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st); - return const {}; + return const _ProfileBindResult.empty(); } return _connectFromServers(account, userToken, servers, profileLabel); } - Future> _connectFromCachedServers( + Future<_ProfileBindResult> _connectFromCachedServers( PlexAccountConnection account, String userToken, String profileLabel, { Object? error, StackTrace? stackTrace, }) async { - if (account.servers.isEmpty) return const {}; + if (account.servers.isEmpty) return const _ProfileBindResult.empty(); appLogger.w( 'ActiveProfileBinder: using cached Plex server metadata for $profileLabel after resource refresh failed', error: error, @@ -511,7 +584,7 @@ class ActiveProfileBinder { return _connectFromServers(account, userToken, servers, profileLabel); } - Future> _connectFromServers( + Future<_ProfileBindResult> _connectFromServers( PlexAccountConnection account, String userToken, List servers, @@ -519,7 +592,7 @@ class ActiveProfileBinder { ) async { if (servers.isEmpty) { appLogger.w('ActiveProfileBinder: no servers for $profileLabel on ${account.accountLabel}'); - return const {}; + return const _ProfileBindResult.empty(); } final updatedConn = account.copyWith(servers: servers); final boundIds = await serverManager.refreshTokensForProfile(updatedConn); @@ -527,19 +600,22 @@ class ActiveProfileBinder { // Return only the ids that actually connected — the visibility filter // pushed downstream must not include unreachable servers, otherwise // the UI lists them and downstream calls 404/timeout per interaction. - return boundIds; + return _ProfileBindResult( + visibleServerIds: boundIds, + expectedServerIds: servers.map((server) => server.clientIdentifier).toSet(), + ); } - Future _bindJellyfin(JellyfinConnection conn) async { + Future<_ProfileBindResult> _bindJellyfin(JellyfinConnection conn) async { final ok = await serverManager.addJellyfinConnection(conn); // `addJellyfinConnection` registers the client even when the health probe // returns authError. Keep that server in the active profile's visibility // filter so the re-auth banner can surface it instead of hiding it as if // the profile had no server. if (ok || serverManager.authErrorServerIds.contains(conn.serverMachineId)) { - return conn.serverMachineId; + return _ProfileBindResult.visible({conn.serverMachineId}); } - return null; + return _ProfileBindResult(visibleServerIds: const {}, expectedServerIds: {conn.serverMachineId}); } Future _ensureAuth() async { @@ -561,6 +637,7 @@ class ActiveProfileBinder { for (final serverId in serverManager.serverIds.toList()) { serverManager.removeServer(serverId); } + multiServerProvider.setExpectedVisibleServerIds({}); multiServerProvider.setVisibleServerIds({}); } diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 388be775..6f014cb8 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -45,6 +45,12 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// ids in the set surface through [serverIds] / [onlineServerIds]. Set? _visibleServerIds; + /// Server ids the active profile is expected to have access to, including + /// unreachable servers that do not have a live client in [MultiServerManager]. + /// This is intentionally separate from [_visibleServerIds]: visible ids drive + /// UI/API surfaces, expected ids drive offline/auth decisions. + Set? _expectedVisibleServerIds; + /// Replace the active visibility filter and notify listeners. Pass `null` /// to clear the filter (all servers visible). Idempotent — does nothing /// when [ids] equals the current filter. @@ -62,6 +68,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi _refreshLiveTvAvailabilitySoon(); } + /// Replace the expected active-profile server ids. Pass `null` to fall back + /// to the live visible ids when no profile-scoped expectation is known. + void setExpectedVisibleServerIds(Set? ids) { + if (_expectedVisibleServerIds == null && ids == null) return; + if (_expectedVisibleServerIds != null && + ids != null && + _expectedVisibleServerIds!.length == ids.length && + _expectedVisibleServerIds!.containsAll(ids)) { + return; + } + _expectedVisibleServerIds = ids; + safeNotifyListeners(); + } + /// Add [serverId] to the active visibility filter. Used after adding a /// connection inline (without a profile switch), so the new server /// becomes visible without the binder having to re-run. Initializes the @@ -70,12 +90,14 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi final current = _visibleServerIds; if (current == null) { _visibleServerIds = {serverId}; + _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; safeNotifyListeners(); _refreshLiveTvAvailabilitySoon(); return; } if (current.contains(serverId)) return; _visibleServerIds = {...current, serverId}; + _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; safeNotifyListeners(); _refreshLiveTvAvailabilitySoon(); } @@ -151,6 +173,14 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi return all.where(filter.contains).toList(); } + /// Server ids the active profile is expected to have, including unreachable + /// Plex servers that have no live client yet. + List get expectedServerIds { + final expected = _expectedVisibleServerIds; + if (expected != null) return expected.toList(growable: false); + return serverIds; + } + /// Check if a server is online (and visible under the active profile). bool isServerOnline(String serverId) { final filter = _visibleServerIds; @@ -177,7 +207,7 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// "Sign in again" banner distinct from generic "Server offline". List get authErrorServerIds { final all = _serverManager.authErrorServerIds; - final filter = _visibleServerIds; + final filter = _expectedVisibleServerIds ?? _visibleServerIds; if (filter == null) return all.toList(); return all.where(filter.contains).toList(); } @@ -188,14 +218,14 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Display names for the visible auth-errored servers, in stable order. /// Falls back to the server id when the client doesn't expose a name. List<({String serverId, String displayName})> get authErrorServers { - return authErrorServerIds - .map((id) => (serverId: id, displayName: _serverManager.getClient(id)?.serverName ?? id)) - .toList(); + return authErrorServerIds.map((id) => (serverId: id, displayName: _serverManager.serverDisplayName(id))).toList(); } /// Clear all server connections void clearAllConnections() { _serverManager.disconnectAll(); + _visibleServerIds = null; + _expectedVisibleServerIds = null; appLogger.d('MultiServerProvider: All connections cleared'); safeNotifyListeners(); } diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index 9edb0ecb..1905b8dd 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -2,18 +2,21 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import '../mixins/disposable_change_notifier_mixin.dart'; +import 'multi_server_provider.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; /// Tracks offline mode status based on network connectivity and server reachability. class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMixin implements OfflineModeSource { final MultiServerManager _serverManager; + MultiServerProvider? _multiServerProvider; StreamSubscription>? _connectivitySubscription; StreamSubscription>? _serverStatusSubscription; bool _hasNetworkConnection = true; late bool _hasServerConnection; + bool _lastOfflineState = false; bool _isInitialized = false; /// True once [MultiServerManager] has emitted its first server-status @@ -24,11 +27,15 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi /// trust the real flag. bool _hasReceivedServerStatus = false; - OfflineModeProvider(this._serverManager) : _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty { + OfflineModeProvider(this._serverManager, {MultiServerProvider? multiServerProvider}) + : _multiServerProvider = multiServerProvider, + _hasServerConnection = (multiServerProvider?.hasConnectedServers ?? _serverManager.onlineServerIds.isNotEmpty) { // Pre-seed the "received status" flag if there are already online // servers (e.g. provider rebuilt mid-session) — otherwise we'd // incorrectly say "online" after the manager already emitted. if (_hasServerConnection) _hasReceivedServerStatus = true; + _lastOfflineState = isOffline; + _multiServerProvider?.addListener(_handleMultiServerProviderChanged); } /// Whether the app is currently in offline mode @@ -37,6 +44,8 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi bool get isOffline { if (!_hasNetworkConnection) return true; if (!_hasReceivedServerStatus) return false; + if (!_hasKnownVisibleServers) return false; + if (_hasOnlyAuthErrorServers) return false; return !_hasServerConnection; } @@ -46,6 +55,29 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi /// Whether at least one media server (Plex or Jellyfin) is reachable bool get hasServerConnection => _hasServerConnection; + bool get _hasKnownVisibleServers => + (_multiServerProvider?.expectedServerIds.length ?? _serverManager.serverIds.length) > 0; + + bool get _hasOnlyAuthErrorServers { + final provider = _multiServerProvider; + if (provider == null) return false; + final serverCount = provider.expectedServerIds.length; + return serverCount > 0 && provider.authErrorServerIds.length == serverCount; + } + + /// Attach the profile-visible server provider. Offline state is evaluated + /// against visible servers, not global manager state, so another profile's + /// online server does not keep the active profile out of offline mode. + void updateMultiServerProvider(MultiServerProvider provider) { + if (identical(_multiServerProvider, provider)) return; + _multiServerProvider?.removeListener(_handleMultiServerProviderChanged); + _multiServerProvider = provider; + _multiServerProvider?.addListener(_handleMultiServerProviderChanged); + _hasServerConnection = provider.hasConnectedServers; + if (_hasServerConnection) _hasReceivedServerStatus = true; + _notifyIfOfflineChanged(); + } + /// Updates network and server connection flags Future _updateConnectionFlags() async { try { @@ -58,7 +90,20 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi // connectivity_plus can throw PlatformException on Windows (NetworkManager::StartListen) _hasNetworkConnection = true; } - _hasServerConnection = _serverManager.onlineServerIds.isNotEmpty; + _hasServerConnection = _multiServerProvider?.hasConnectedServers ?? _serverManager.onlineServerIds.isNotEmpty; + } + + void _handleMultiServerProviderChanged() { + _hasServerConnection = _multiServerProvider?.hasConnectedServers ?? _serverManager.onlineServerIds.isNotEmpty; + if (_hasServerConnection) _hasReceivedServerStatus = true; + _notifyIfOfflineChanged(); + } + + void _notifyIfOfflineChanged() { + final offline = isOffline; + if (_lastOfflineState == offline) return; + _lastOfflineState = offline; + safeNotifyListeners(); } /// Initialize the provider and start monitoring @@ -75,12 +120,8 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi () { _connectivitySubscription = Connectivity().onConnectivityChanged.listen( (results) { - final wasOffline = isOffline; _hasNetworkConnection = !results.contains(ConnectivityResult.none); - - if (wasOffline != isOffline) { - safeNotifyListeners(); - } + _notifyIfOfflineChanged(); }, onError: (e) { _hasNetworkConnection = true; @@ -95,26 +136,25 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi // Monitor server status from MultiServerManager _serverStatusSubscription = _serverManager.statusStream.listen((statusMap) { - final wasOffline = isOffline; - _hasServerConnection = statusMap.values.any((isOnline) => isOnline); + _hasServerConnection = _multiServerProvider?.hasConnectedServers ?? statusMap.values.any((isOnline) => isOnline); _hasReceivedServerStatus = true; - - if (wasOffline != isOffline) { - safeNotifyListeners(); - } + _notifyIfOfflineChanged(); }); + _lastOfflineState = isOffline; safeNotifyListeners(); } /// Force a refresh of connectivity status Future refresh() async { await _updateConnectionFlags(); + _lastOfflineState = isOffline; safeNotifyListeners(); } @override void dispose() { + _multiServerProvider?.removeListener(_handleMultiServerProviderChanged); _connectivitySubscription?.cancel(); _serverStatusSubscription?.cancel(); super.dispose(); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 2a146323..a25a61fa 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -139,8 +139,8 @@ class SideNavigationBleedBuilder extends StatelessWidget { tween: Tween(end: targetBleed), duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, - child: child, builder: builder, + child: child, ); } } @@ -167,6 +167,15 @@ bool shouldRetryActiveProfileBindAfterReconnect({ return hasActiveProfile && !hasVisibleConnectedServers && (hasManagerOnlineServers || !hasKnownOfflineServers); } +@visibleForTesting +bool shouldRenderMainScreenOffline({ + required bool providerOffline, + required bool startupOfflineUntilConnected, + required bool hasVisibleConnectedServers, +}) { + return providerOffline || (startupOfflineUntilConnected && !hasVisibleConnectedServers); +} + class MainScreen extends StatefulWidget { final bool isOfflineMode; @@ -210,6 +219,11 @@ class _MainScreenState extends State /// Whether a reconnection attempt is in progress bool _isReconnecting = false; + /// Startup routed here explicitly offline. Keep the offline shell until a + /// visible server actually connects; provider warmup can be optimistic when + /// failed Plex servers have no live client yet. + bool _offlineUntilConnected = false; + /// Prevents double-pushing the profile selection screen bool _isShowingProfileSelection = false; @@ -273,6 +287,7 @@ class _MainScreenState extends State void initState() { super.initState(); _isOffline = widget.isOfflineMode; + _offlineUntilConnected = widget.isOfflineMode; WidgetsBinding.instance.addObserver(this); @@ -281,10 +296,6 @@ class _MainScreenState extends State windowManager.setPreventClose(true); } - _currentTab = _isOffline ? NavigationTabId.downloads : NavigationTabId.discover; - _lastOnlineTabId = _isOffline ? null : NavigationTabId.discover; - _autoSwitchedToDownloads = _isOffline; - // Synchronize _lastHasLiveTv with provider before building screens // so _buildScreens and _hasLiveTv getter agree from the start. try { @@ -292,6 +303,9 @@ class _MainScreenState extends State } catch (_) { _lastHasLiveTv = false; } + _currentTab = _defaultTabForMode(_isOffline); + _lastOnlineTabId = _isOffline ? null : NavigationTabId.discover; + _autoSwitchedToDownloads = _isOffline && _currentTab == NavigationTabId.downloads; _screens = _buildScreens(_isOffline); // Set up Watch Together callbacks immediately (must be synchronous to catch early messages) @@ -311,8 +325,10 @@ class _MainScreenState extends State unawaited(_plexHomeService!.start()); final manager = context.read().serverManager; // Read the binder so the Provider's `lazy: false` create has fired - // for sure; it manages its own lifecycle and disposal. - context.read(); + // for sure; start only in online mode so explicit startup offline does + // not immediately kick off the same connection attempts it skipped. + final binder = context.read(); + if (!_isOffline) binder.start(); _runStartupOnFirstOnlineServer(manager); } if (!_isOffline) { @@ -381,45 +397,7 @@ class _MainScreenState extends State _startupSettleTimeout?.cancel(); _startupSettleTimeout = null; - // Mirror `_invalidateAllScreens`: await the libraries fetch BEFORE - // calling `fullRefresh` on the tab screens. Without the await the - // libraries screen's `_initializeWithLibraries` runs against an - // empty provider, returns early, and never sets a selected library - // — so the tab renders nothing even though libraries arrive moments - // later. The Plex auth path goes through `_invalidateAllScreens` - // (active-profile id changes) and was unaffected; fresh-install - // Jellyfin sign-in is bound to the pre-existing placeholder Owner - // profile, so only this prime path runs. - unawaited(() async { - if (manager.onlineServerIds.isNotEmpty) { - if (!mounted) return; - final mp = context.read(); - final lp = context.read(); - lp.initialize(mp.aggregationService); - await lp.loadLibraries(); - if (!mounted) return; - context.read().onServersConnected(); - // DownloadProvider's initial load can race with [Connections] - // table inserts done by [ActiveProfileBinder]. Now that servers - // are connected the per-backend caches resolve, so retry. - unawaited(context.read().refreshMetadataFromCache()); - } - - // The tab screens called their initial load in `initState` — well - // before the binder finished its first connect — and stayed in - // their loading state. Re-trigger so they reload (or, if no - // servers came online, render their proper error state). - if (!mounted) return; - if (_discoverKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_librariesKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_searchKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - }()); + unawaited(_primeOnlineServices(manager)); } void tryDownloadResume() { @@ -463,6 +441,52 @@ class _MainScreenState extends State _serverStatusSub = manager.statusStream.listen((_) => tryDownloadResume()); } + /// Shared online-entry hook for cold startup and reconnect-from-offline. + /// It mirrors `_invalidateAllScreens`: libraries load before tab refreshes + /// so screens don't initialize against an empty provider and remain blank. + Future _primeOnlineServices(MultiServerManager manager) async { + if (manager.onlineServerIds.isNotEmpty) { + if (!mounted) return; + final mp = context.read(); + if (mp.hasConnectedServers) { + final lp = context.read(); + lp.initialize(mp.aggregationService); + await lp.loadLibraries(); + if (!mounted) return; + context.read().onServersConnected(); + unawaited(context.read().refreshMetadataFromCache()); + _resumeQueuedDownloadsIfPossible(mp); + } + } + + if (!mounted) return; + if (_discoverKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); + } + if (_librariesKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); + } + if (_searchKey.currentState case final FullRefreshable refreshable) { + refreshable.fullRefresh(); + } + } + + void _resumeQueuedDownloadsIfPossible(MultiServerProvider mp) { + if (_downloadResumeFired || !mounted) return; + for (final serverId in mp.onlineServerIds) { + final onlineClient = mp.getClientForServer(serverId); + if (onlineClient == null) continue; + _downloadResumeFired = true; + unawaited( + context.read().ensureInitialized().then((_) { + if (!mounted) return; + context.read().resumeQueuedDownloads(onlineClient); + }), + ); + return; + } + } + void _onActiveProfileChanged() { final activeProfile = _activeProfileForListener; if (activeProfile == null) return; @@ -662,9 +686,8 @@ class _MainScreenState extends State super.didChangeDependencies(); // 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. + // 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); @@ -853,6 +876,15 @@ class _MainScreenState extends State NavigationTabId _normalizeTabForMode(NavigationTabId currentTab, bool isOffline) { final tabs = _getVisibleTabs(isOffline); if (tabs.any((t) => t.id == currentTab)) return currentTab; + return _defaultTabForMode(isOffline); + } + + NavigationTabId _defaultTabForMode(bool isOffline) { + final tabs = _getVisibleTabs(isOffline); + if (isOffline) { + final downloads = tabs.where((t) => t.id == NavigationTabId.downloads).firstOrNull; + if (downloads != null) return downloads.id; + } return tabs.first.id; } @@ -866,6 +898,7 @@ class _MainScreenState extends State final binder = context.read(); unawaited(() async { try { + binder.start(); // Health check first so stale "online" servers get marked offline before // we snapshot the offline list for reconnection. await serverManager.checkServerHealth(); @@ -879,6 +912,11 @@ class _MainScreenState extends State )) { await binder.rebindActive(); } + if (!mounted) return; + if (multiServerProvider.hasConnectedServers) { + _offlineUntilConnected = false; + _handleOfflineStatusChanged(); + } } finally { setStateIfMounted(() => _isReconnecting = false); } @@ -897,7 +935,14 @@ class _MainScreenState extends State } void _handleOfflineStatusChanged() { - final newOffline = _offlineModeProvider?.isOffline ?? widget.isOfflineMode; + final hasVisibleConnectedServers = context.read().hasConnectedServers; + if (hasVisibleConnectedServers) _offlineUntilConnected = false; + final providerOffline = _offlineModeProvider?.isOffline ?? false; + final newOffline = shouldRenderMainScreenOffline( + providerOffline: providerOffline, + startupOfflineUntilConnected: _offlineUntilConnected, + hasVisibleConnectedServers: hasVisibleConnectedServers, + ); if (newOffline == _isOffline) return; @@ -940,7 +985,18 @@ class _MainScreenState extends State // Ensure profile settings are warmed when coming back online if (!_isOffline) { - unawaited(context.userProfile.initialize()); + unawaited(() async { + final mp = context.read(); + final binder = context.read(); + binder.start(); + if (!mp.hasConnectedServers && context.read().active != null) { + await binder.rebindActive(); + if (!mounted) return; + } + await context.userProfile.initialize(); + if (!mounted) return; + await _primeOnlineServices(mp.serverManager); + }()); } } diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 1ed6a53d..24346adc 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -134,12 +134,29 @@ class MultiServerManager { _statusController.add(Map.from(_serverStatus)); } + /// Mark every cached Plex server on [connection] as auth-rejected without + /// requiring a live client. Startup auth failures happen before a client can + /// exist, but the UI still needs a server id/name for the re-auth banner. + void markPlexConnectionAuthError(PlexAccountConnection connection) { + for (final server in connection.servers) { + final id = server.clientIdentifier; + _clientIdByServer[id] = connection.clientIdentifier; + _plexServers[id] = server; + _serverStatus[id] = false; + _authErrorServers.add(id); + } + _statusController.add(Map.from(_serverStatus)); + } + /// Plex-specific server config (name, machineId, connection candidates, /// `owned` flag). Returns `null` for Jellyfin server ids — Jellyfin has no /// `PlexServer` analogue. For "is this server registered?" use /// [getClient] (works for both backends). PlexServer? getPlexServer(String serverId) => _plexServers[serverId]; + String serverDisplayName(String serverId) => + _clients[serverId]?.serverName ?? _plexServers[serverId]?.name ?? serverId; + /// Backend-neutral "is this user an owner/admin on [serverId]?" probe used /// by UI gates that hide destructive admin entries (delete, edit metadata, /// match/unmatch). Returns: diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index 0e945cee..1c232c6b 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; @@ -144,6 +147,127 @@ void main() { expect(binder.consumeUserInitiatedActivation(profile.id), isFalse); }); + test('tracks expected Plex server ids when bind cannot create a live client', () async { + binder.dispose(); + multiServerProvider.dispose(); + + final failingManager = _FailingPlexMultiServerManager(); + manager = failingManager; + multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + binder = ActiveProfileBinder( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + serverManager: manager, + multiServerProvider: multiServerProvider, + pinPrompt: (_, {String? errorMessage}) async => null, + shouldDeferInitialBind: (_) async => false, + plexAuth: PlexAuthService.forTesting( + http: MediaServerHttpClient( + client: MockClient( + (_) async => http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}), + ), + ), + ), + ); + + final profile = await createActiveLocalProfile('local-plex-offline'); + final account = PlexAccountConnection( + id: 'plex.account', + accountToken: 'account-token', + clientIdentifier: 'client-id', + accountLabel: 'Owner', + servers: [_server(accessToken: 'account-server-token')], + createdAt: DateTime(2026, 1, 1), + ); + await connections.upsert(account); + await profileConnections.upsert( + ProfileConnection( + profileId: profile.id, + connectionId: account.id, + userToken: 'home-user-token', + userIdentifier: 'home-user-uuid', + tokenAcquiredAt: DateTime(2026, 1, 1), + ), + ); + + await binder.rebindActive(); + + expect(activeProfile.lastBindingSucceeded, isFalse); + expect(failingManager.refreshCalls, 1); + expect(multiServerProvider.serverIds, isEmpty); + expect(multiServerProvider.expectedServerIds, ['srv-1']); + }); + + test('binds Plex and Jellyfin join rows in parallel', () async { + binder.dispose(); + multiServerProvider.dispose(); + + final mixedManager = _BlockingMixedMultiServerManager(); + manager = mixedManager; + multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + binder = ActiveProfileBinder( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + serverManager: manager, + multiServerProvider: multiServerProvider, + pinPrompt: (_, {String? errorMessage}) async => null, + shouldDeferInitialBind: (_) async => false, + plexAuth: PlexAuthService.forTesting( + http: MediaServerHttpClient( + client: MockClient( + (_) async => http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'}), + ), + ), + ), + ); + + final profile = await createActiveLocalProfile('local-mixed'); + final plexAccount = PlexAccountConnection( + id: 'plex.account', + accountToken: 'account-token', + clientIdentifier: 'client-id', + accountLabel: 'Owner', + servers: [_server(accessToken: 'account-server-token')], + createdAt: DateTime(2026, 1, 1), + ); + final jellyfin = _jellyfinConnection(); + await connections.upsert(plexAccount); + await connections.upsert(jellyfin); + await profileConnections.upsert( + ProfileConnection( + profileId: profile.id, + connectionId: plexAccount.id, + userToken: 'home-user-token', + userIdentifier: 'home-user-uuid', + tokenAcquiredAt: DateTime(2026, 1, 1), + ), + ); + await profileConnections.upsert( + ProfileConnection( + profileId: profile.id, + connectionId: jellyfin.id, + userIdentifier: jellyfin.userId, + tokenAcquiredAt: DateTime(2026, 1, 1), + ), + ); + + final bind = binder.rebindActive(); + await mixedManager.plexStarted.future; + await Future.delayed(Duration.zero); + + expect(mixedManager.jellyfinStarted.isCompleted, isTrue); + expect(activeProfile.isBinding, isTrue); + + mixedManager.releasePlex.complete(); + await bind; + + expect(activeProfile.lastBindingSucceeded, isTrue); + expect(multiServerProvider.onlineServerIds, ['jf-machine']); + expect(multiServerProvider.expectedServerIds.toSet(), {'srv-1', 'jf-machine'}); + }); + group('Plex Home token cache policy', () { test('cold start uses cached token instead of forcing PIN revalidation', () { expect(shouldUsePlexHomeTokenCache(preVerified: false, hasBoundOnce: false), isTrue); @@ -289,6 +413,39 @@ PlexServer _server({required String accessToken}) { ); } +Map _serverJson() => { + 'name': 'Home Server', + 'clientIdentifier': 'srv-1', + 'accessToken': 'server-token', + 'owned': true, + 'provides': 'server', + 'connections': [ + { + 'protocol': 'https', + 'address': '192.168.1.3', + 'port': 32400, + 'uri': 'https://192-168-1-3.machine.plex.direct:32400', + 'local': true, + 'relay': false, + 'IPv6': false, + }, + ], +}; + +JellyfinConnection _jellyfinConnection() { + return JellyfinConnection( + id: 'jf-machine/user-a', + baseUrl: 'https://jellyfin.example', + serverName: 'Jellyfin', + serverMachineId: 'jf-machine', + userId: 'user-a', + userName: 'User A', + accessToken: 'token', + deviceId: 'device', + createdAt: DateTime(2026, 1, 1), + ); +} + class _CapturingMultiServerManager extends MultiServerManager { int refreshCalls = 0; PlexAccountConnection? lastConnection; @@ -303,3 +460,45 @@ class _CapturingMultiServerManager extends MultiServerManager { return connection.servers.map((server) => server.clientIdentifier).toSet(); } } + +class _FailingPlexMultiServerManager extends MultiServerManager { + int refreshCalls = 0; + + @override + Future> refreshTokensForProfile( + PlexAccountConnection connection, { + Duration timeout = MediaServerTimeouts.perServerConnect, + }) async { + refreshCalls++; + for (final server in connection.servers) { + updateServerStatus(server.clientIdentifier, false); + } + return const {}; + } +} + +class _BlockingMixedMultiServerManager extends MultiServerManager { + final plexStarted = Completer(); + final releasePlex = Completer(); + final jellyfinStarted = Completer(); + + @override + Future> refreshTokensForProfile( + PlexAccountConnection connection, { + Duration timeout = MediaServerTimeouts.perServerConnect, + }) async { + if (!plexStarted.isCompleted) plexStarted.complete(); + await releasePlex.future; + for (final server in connection.servers) { + updateServerStatus(server.clientIdentifier, false); + } + return const {}; + } + + @override + Future addJellyfinConnection(JellyfinConnection connection) async { + if (!jellyfinStarted.isCompleted) jellyfinStarted.complete(); + updateServerStatus(connection.serverMachineId, true); + return true; + } +} diff --git a/test/providers/offline_mode_provider_test.dart b/test/providers/offline_mode_provider_test.dart index 60dfde8c..47902df2 100644 --- a/test/providers/offline_mode_provider_test.dart +++ b/test/providers/offline_mode_provider_test.dart @@ -1,6 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/connection/connection.dart'; import 'package:plezy/providers/offline_mode_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_auth_service.dart'; import '../test_helpers/prefs.dart'; @@ -112,5 +119,113 @@ void main() { p.dispose(); manager.dispose(); }); + + test('auth-error-only visible servers do not collapse to generic offline', () async { + final manager = MultiServerManager(); + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection(), + httpClient: MockClient((_) async => http.Response('', 401)), + ); + manager.debugRegisterJellyfinClientForTesting(client, online: false); + final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final p = OfflineModeProvider(manager, multiServerProvider: multi); + await p.initialize(); + + manager.debugMarkAuthErrorForTesting('jf-machine'); + await Future.delayed(Duration.zero); + + expect(multi.authErrorServerIds, contains('jf-machine')); + expect(p.isOffline, isFalse); + + p.dispose(); + multi.dispose(); + manager.dispose(); + }); + + test('expected but unreachable visible servers enter offline without live clients', () async { + final manager = MultiServerManager(); + final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final p = OfflineModeProvider(manager, multiServerProvider: multi); + await p.initialize(); + manager.updateServerStatus('plex-server', false); + await Future.delayed(Duration.zero); + expect(p.isOffline, isFalse); + + var notifications = 0; + p.addListener(() => notifications++); + + multi.setExpectedVisibleServerIds({'plex-server'}); + multi.setVisibleServerIds({}); + await Future.delayed(Duration.zero); + + expect(p.isOffline, isTrue); + expect(notifications, 1); + + p.dispose(); + multi.dispose(); + manager.dispose(); + }); + + test('Plex auth errors without live clients stay out of generic offline', () async { + final manager = MultiServerManager(); + final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final p = OfflineModeProvider(manager, multiServerProvider: multi); + await p.initialize(); + + multi.setExpectedVisibleServerIds({'plex-server'}); + manager.markPlexConnectionAuthError(_plexConnection()); + await Future.delayed(Duration.zero); + + expect(multi.authErrorServerIds, ['plex-server']); + expect(multi.authErrorServers.single.displayName, 'Plex'); + expect(p.isOffline, isFalse); + + p.dispose(); + multi.dispose(); + manager.dispose(); + }); }); } + +PlexAccountConnection _plexConnection() { + return PlexAccountConnection( + id: 'plex-account', + accountToken: 'account-token', + clientIdentifier: 'client-id', + accountLabel: 'Plex Account', + servers: [ + PlexServer( + name: 'Plex', + clientIdentifier: 'plex-server', + accessToken: 'server-token', + connections: [ + PlexConnection( + protocol: 'https', + address: 'plex.example', + port: 32400, + uri: 'https://plex.example:32400', + local: true, + relay: false, + ipv6: false, + ), + ], + owned: true, + ), + ], + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); +} + +JellyfinConnection _jellyfinConnection() { + return JellyfinConnection( + id: 'jf-machine/user-a', + baseUrl: 'https://jellyfin.example', + serverName: 'Jellyfin', + serverMachineId: 'jf-machine', + userId: 'user-a', + userName: 'User A', + accessToken: 'token', + deviceId: 'device', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); +} diff --git a/test/screens/startup_bind_recovery_test.dart b/test/screens/startup_bind_recovery_test.dart index 36f128a8..71b5d7e1 100644 --- a/test/screens/startup_bind_recovery_test.dart +++ b/test/screens/startup_bind_recovery_test.dart @@ -57,5 +57,32 @@ void main() { isFalse, ); }); + + test('explicit offline startup stays offline until a visible server connects', () { + expect( + shouldRenderMainScreenOffline( + providerOffline: false, + startupOfflineUntilConnected: true, + hasVisibleConnectedServers: false, + ), + isTrue, + ); + expect( + shouldRenderMainScreenOffline( + providerOffline: false, + startupOfflineUntilConnected: true, + hasVisibleConnectedServers: true, + ), + isFalse, + ); + expect( + shouldRenderMainScreenOffline( + providerOffline: true, + startupOfflineUntilConnected: false, + hasVisibleConnectedServers: true, + ), + isTrue, + ); + }); }); }