+39
-29
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>{});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user