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