fix(startup): speed offline fallback

close #1108
This commit is contained in:
edde746
2026-05-23 16:00:00 +02:00
parent 3a042d79f6
commit b6d23ed089
9 changed files with 709 additions and 138 deletions
+39 -29
View File
@@ -711,10 +711,29 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return MultiServerProvider(_serverManager, _aggregationService);
},
),
ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
create: (context) {
final provider = OfflineModeProvider(
_serverManager,
multiServerProvider: context.read<MultiServerProvider>(),
);
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<ActiveProfileBinder>(
lazy: false,
create: (context) {
@@ -731,22 +750,10 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return settings.read(SettingsService.requireProfileSelectionOnOpen) &&
activeProfile.hasMultipleProfiles;
},
)..start();
);
},
dispose: (_, binder) => binder.dispose(),
),
ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
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<ActiveProfileProvider, DownloadProvider>(
@@ -1054,6 +1061,7 @@ class SetupScreen extends StatefulWidget {
class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
String _statusMessage = '';
bool _enteringOffline = false;
// Per-server connection status: serverId -> (name, connected?)
final Map<String, (String name, bool? connected)> _serverStatus = {};
@@ -1068,6 +1076,15 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
setStateIfMounted(() => _statusMessage = message);
}
Future<void> _enterOfflineMode() async {
if (_enteringOffline) return;
_enteringOffline = true;
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
}
Future<void> _loadSavedCredentials() async {
_setStatus(t.common.checkingNetwork);
@@ -1153,10 +1170,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
// No network — skip connection attempts and go straight to offline mode
if (!hasNetwork) {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().ensureInitialized();
if (!mounted) return;
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
await _enterOfflineMode();
return;
}
@@ -1188,9 +1202,8 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
// Snapshot Provider refs before further awaits.
final activeProfile = context.read<ActiveProfileProvider>();
// 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<ActiveProfileBinder>();
final downloadProvider = context.read<DownloadProvider>();
@@ -1211,6 +1224,11 @@ class _SetupScreenState extends State<SetupScreen> 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<SetupScreen> 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<SetupScreen> 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;
}
+118 -41
View File
@@ -22,6 +22,19 @@ typedef PlexHomePinPrompt = Future<String?> Function(Profile profile, {String? e
typedef ShouldDeferInitialBind = FutureOr<bool> Function(Profile profile);
class _ProfileBindResult {
const _ProfileBindResult({required this.visibleServerIds, required this.expectedServerIds});
const _ProfileBindResult.empty() : visibleServerIds = const {}, expectedServerIds = const {};
_ProfileBindResult.visible(Set<String> ids)
: visibleServerIds = Set.unmodifiable(ids),
expectedServerIds = Set.unmodifiable(ids);
final Set<String> visibleServerIds;
final Set<String> 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 = <String>{};
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 = <String>{};
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<Set<String>> _bindPlexHome(Profile profile) async {
Future<Set<String>> _expectedServerIdsForProfile(Profile profile) async {
final expected = <String>{};
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<Set<String>> _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 = <String>{};
final expected = <String>{};
final futures = <Future<_ProfileBindResult>>[];
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<Set<String>> _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<Set<String>> _connectPlexServers(PlexAccountConnection account, String userToken, String profileLabel) async {
Future<_ProfileBindResult> _connectPlexServers(
PlexAccountConnection account,
String userToken,
String profileLabel,
) async {
final auth = await _ensureAuth();
final List<PlexServer> 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<Set<String>> _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<Set<String>> _connectFromServers(
Future<_ProfileBindResult> _connectFromServers(
PlexAccountConnection account,
String userToken,
List<PlexServer> 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<String?> _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<PlexAuthService> _ensureAuth() async {
@@ -561,6 +637,7 @@ class ActiveProfileBinder {
for (final serverId in serverManager.serverIds.toList()) {
serverManager.removeServer(serverId);
}
multiServerProvider.setExpectedVisibleServerIds(<String>{});
multiServerProvider.setVisibleServerIds(<String>{});
}
+34 -4
View File
@@ -45,6 +45,12 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
/// ids in the set surface through [serverIds] / [onlineServerIds].
Set<String>? _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<String>? _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<String>? 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<String> 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<String> 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();
}
+53 -13
View File
@@ -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<List<ConnectivityResult>>? _connectivitySubscription;
StreamSubscription<Map<String, bool>>? _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<void> _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<void> refresh() async {
await _updateConnectionFlags();
_lastOfflineState = isOffline;
safeNotifyListeners();
}
@override
void dispose() {
_multiServerProvider?.removeListener(_handleMultiServerProviderChanged);
_connectivitySubscription?.cancel();
_serverStatusSubscription?.cancel();
super.dispose();
+107 -51
View File
@@ -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<MainScreen>
/// 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<MainScreen>
void initState() {
super.initState();
_isOffline = widget.isOfflineMode;
_offlineUntilConnected = widget.isOfflineMode;
WidgetsBinding.instance.addObserver(this);
@@ -281,10 +296,6 @@ class _MainScreenState extends State<MainScreen>
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<MainScreen>
} 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<MainScreen>
unawaited(_plexHomeService!.start());
final manager = context.read<MultiServerProvider>().serverManager;
// Read the binder so the Provider's `lazy: false` create has fired
// for sure; it manages its own lifecycle and disposal.
context.read<ActiveProfileBinder>();
// 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<ActiveProfileBinder>();
if (!_isOffline) binder.start();
_runStartupOnFirstOnlineServer(manager);
}
if (!_isOffline) {
@@ -381,45 +397,7 @@ class _MainScreenState extends State<MainScreen>
_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<MultiServerProvider>();
final lp = context.read<LibrariesProvider>();
lp.initialize(mp.aggregationService);
await lp.loadLibraries();
if (!mounted) return;
context.read<OfflineWatchSyncService>().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<DownloadProvider>().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<MainScreen>
_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<void> _primeOnlineServices(MultiServerManager manager) async {
if (manager.onlineServerIds.isNotEmpty) {
if (!mounted) return;
final mp = context.read<MultiServerProvider>();
if (mp.hasConnectedServers) {
final lp = context.read<LibrariesProvider>();
lp.initialize(mp.aggregationService);
await lp.loadLibraries();
if (!mounted) return;
context.read<OfflineWatchSyncService>().onServersConnected();
unawaited(context.read<DownloadProvider>().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<DownloadProvider>().ensureInitialized().then((_) {
if (!mounted) return;
context.read<DownloadProvider>().resumeQueuedDownloads(onlineClient);
}),
);
return;
}
}
void _onActiveProfileChanged() {
final activeProfile = _activeProfileForListener;
if (activeProfile == null) return;
@@ -662,9 +686,8 @@ class _MainScreenState extends State<MainScreen>
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<OfflineModeProvider?>();
if (provider != null && provider != _offlineModeProvider) {
_offlineModeProvider?.removeListener(_handleOfflineStatusChanged);
@@ -853,6 +876,15 @@ class _MainScreenState extends State<MainScreen>
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<MainScreen>
final binder = context.read<ActiveProfileBinder>();
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<MainScreen>
)) {
await binder.rebindActive();
}
if (!mounted) return;
if (multiServerProvider.hasConnectedServers) {
_offlineUntilConnected = false;
_handleOfflineStatusChanged();
}
} finally {
setStateIfMounted(() => _isReconnecting = false);
}
@@ -897,7 +935,14 @@ class _MainScreenState extends State<MainScreen>
}
void _handleOfflineStatusChanged() {
final newOffline = _offlineModeProvider?.isOffline ?? widget.isOfflineMode;
final hasVisibleConnectedServers = context.read<MultiServerProvider>().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<MainScreen>
// Ensure profile settings are warmed when coming back online
if (!_isOffline) {
unawaited(context.userProfile.initialize());
unawaited(() async {
final mp = context.read<MultiServerProvider>();
final binder = context.read<ActiveProfileBinder>();
binder.start();
if (!mp.hasConnectedServers && context.read<ActiveProfileProvider>().active != null) {
await binder.rebindActive();
if (!mounted) return;
}
await context.userProfile.initialize();
if (!mounted) return;
await _primeOnlineServices(mp.serverManager);
}());
}
}
+17
View File
@@ -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:
@@ -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<void>.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<String, dynamic> _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<Set<String>> 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<void>();
final releasePlex = Completer<void>();
final jellyfinStarted = Completer<void>();
@override
Future<Set<String>> 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<bool> addJellyfinConnection(JellyfinConnection connection) async {
if (!jellyfinStarted.isCompleted) jellyfinStarted.complete();
updateServerStatus(connection.serverMachineId, true);
return true;
}
}
@@ -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<void>.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<void>.delayed(Duration.zero);
expect(p.isOffline, isFalse);
var notifications = 0;
p.addListener(() => notifications++);
multi.setExpectedVisibleServerIds({'plex-server'});
multi.setVisibleServerIds(<String>{});
await Future<void>.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<void>.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),
);
}
@@ -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,
);
});
});
}