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
+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);
}());
}
}