import 'dart:async'; import '../media/ids.dart'; import '../media/media_server_client.dart'; import '../navigation/main_screen_scope.dart'; import 'dart:io' show Platform, exit; export '../navigation/main_screen_scope.dart' show MainScreenFocusScope, MainScreenScopeAspect, SideNavigationBleedBuilder; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show HardwareKeyboard, KeyDownEvent, KeyRepeatEvent, KeyUpEvent, LogicalKeyboardKey; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:window_manager/window_manager.dart'; import '../i18n/strings.g.dart'; import '../services/app_exit_service.dart'; import '../services/tvos_system_navigation_service.dart'; import '../services/update_service.dart'; import '../utils/app_logger.dart'; import '../widgets/auth_error_banner.dart'; import '../widgets/app_icon.dart'; import '../utils/provider_extensions.dart'; import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../utils/update_dialog.dart'; import '../utils/video_player_navigation.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/refreshable.dart'; import '../widgets/overlay_sheet.dart'; import '../mixins/tab_visibility_aware.dart'; import '../navigation/navigation_tabs.dart'; import '../navigation/profile_navigation_scope.dart'; import '../profiles/active_profile_binder.dart'; import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; import '../profiles/profile_selection_policy.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; import '../providers/playback_state_provider.dart'; import '../widgets/settings_builder.dart'; import '../widgets/tv_virtual_keyboard.dart'; import '../services/api_cache.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/settings_service.dart'; import '../providers/offline_mode_provider.dart'; import '../services/companion_remote/companion_remote_host_controller.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; import '../providers/companion_remote_provider.dart'; import '../utils/desktop_window_padding.dart'; import '../widgets/music/mini_player.dart'; import '../widgets/side_navigation_rail.dart'; import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; import 'discover_screen.dart'; import 'explore_screen.dart'; import 'libraries/library_quick_picker_sheet.dart'; import 'libraries/libraries_screen.dart'; import 'livetv/live_tv_screen.dart'; import 'search_screen.dart'; import 'downloads/downloads_screen.dart'; import 'settings/settings_screen.dart'; import 'profile/profile_switch_screen.dart'; import 'profile/profile_teardown.dart'; import '../services/system_shelf_service.dart'; import '../watch_together/watch_together.dart'; /// Provides access to the main screen's focus control. // MainScreenFocusScope and SideNavigationBleedBuilder live in // navigation/main_screen_scope.dart (re-exported above) so widgets like the // browse rail can import the scope without an import cycle through this file. @visibleForTesting bool shouldHandleMacOsRootEscape({ required bool isMacOS, required bool isPhysicalKeyboardEvent, required LogicalKeyboardKey logicalKey, required bool isCurrentRoute, required bool isHomeTab, }) { return isMacOS && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab; } @visibleForTesting ({double left, double width}) mainScreenSideNavigationContentLayout({ required double viewportWidth, required double currentSideNavigationWidth, required double reservedSideNavigationWidth, }) { return ( left: currentSideNavigationWidth, width: (viewportWidth - reservedSideNavigationWidth).clamp(0.0, double.infinity).toDouble(), ); } @visibleForTesting bool shouldRetryActiveProfileBindAfterReconnect({ required bool hasActiveProfile, required bool hasVisibleConnectedServers, required bool hasManagerOnlineServers, required bool hasKnownOfflineServers, }) { return hasActiveProfile && !hasVisibleConnectedServers && (hasManagerOnlineServers || !hasKnownOfflineServers); } @visibleForTesting bool shouldRenderMainScreenOffline({ required bool providerOffline, required bool startupOfflineUntilConnected, required bool hasVisibleConnectedServers, }) { return providerOffline || (startupOfflineUntilConnected && !hasVisibleConnectedServers); } @visibleForTesting List mainScreenBottomNavigationTabs({ required List visibleTabs, required bool isMobile, required bool isOffline, required NavigationTabId currentTab, }) { if (!isMobile) return visibleTabs; return visibleTabs.where((tab) { if (tab.id != NavigationTabId.settings) return true; return isOffline || currentTab == NavigationTabId.settings; }).toList(); } @visibleForTesting bool shouldPassTvosMenuToSystem({ required bool isAppleTV, required bool isShowingProfileSelection, required bool isOverlaySheetOpen, required bool isRouteCurrent, required bool isSidebarFocused, required bool hasVisibleTabs, required bool isCurrentTabRoot, }) { return isAppleTV && isSidebarFocused && !isShowingProfileSelection && !isOverlaySheetOpen && isRouteCurrent && hasVisibleTabs && isCurrentTabRoot; } @visibleForTesting enum ProfileInvalidationAction { none, invalidateNow } @visibleForTesting ProfileInvalidationAction profileInvalidationAction({ required String? previousProfileId, required String? currentProfileId, required bool wasBindingPreviously, required bool isBindingNow, }) { // An active-id change remounts the whole session subtree // ([ProfileSessionScreen] keys on the id), recreating this screen and // every session-scoped provider — nothing to invalidate from here. The // app-global pieces (ApiCache volatile rows) are cleared at that remount // seam, where the unmount can't outrun the work. if (currentProfileId != previousProfileId) { return ProfileInvalidationAction.none; } if (wasBindingPreviously && !isBindingNow) { return ProfileInvalidationAction.invalidateNow; } return ProfileInvalidationAction.none; } class MainScreen extends StatefulWidget { final bool isOfflineMode; /// When `true`, the previous screen (typically [SetupScreen]) already /// resolved the launch profile prompt — skip the postFrame prompt that /// would otherwise re-fire it. final bool initialPromptHandled; const MainScreen({super.key, this.isOfflineMode = false, this.initialPromptHandled = false}); @override State createState() => _MainScreenState(); } class _MainScreenState extends State with RouteAware, WindowListener, WidgetsBindingObserver, MountedSetStateMixin { NavigationTabId _currentTab = NavigationTabId.discover; String? _selectedLibraryGlobalKey; /// Whether the app is in offline mode (no server connection) bool _isOffline = false; /// Computed index — searches the same _getVisibleTabs() that _buildScreens iterates, /// so _screens[_currentIndex] is always the widget for _currentTab. int get _currentIndex { final tabs = _getVisibleTabs(_isOffline); final idx = tabs.indexWhere((t) => t.id == _currentTab); return (idx >= 0 ? idx : 0).clamp(0, _screens.length - 1); } /// Last selected online tab (restored when coming back online after an offline fallback) NavigationTabId? _lastOnlineTabId; /// A preferred startup section (e.g. Live TV) that wasn't visible yet at cold /// start because servers bind asynchronously. Applied once it becomes /// available (see [_handleLiveTvChanged]); cleared on any explicit selection. NavigationTabId? _pendingStartupTab; /// Whether we auto-switched to Downloads because the previous tab was unavailable offline bool _autoSwitchedToDownloads = false; OfflineModeProvider? _offlineModeProvider; MultiServerProvider? _multiServerProvider; CatalogSourcesProvider? _catalogSourcesProvider; RouteObserver>? _profileRouteObserver; bool _lastHasLiveTv = false; bool _lastHasExplore = false; /// 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; late List _screens; /// One [GlobalKey] per tab, so a tab's live [State] can be reached from /// anywhere in this class via [_onScreen]. Deliberately untyped: every /// consumer discards the concrete `State` type and pattern-matches on a /// capability mixin (Refreshable, FocusableTab, …) instead. final Map _screenKeys = {for (final id in NavigationTabId.values) id: GlobalKey()}; final GlobalKey _sideNavKey = GlobalKey(); /// Measures the mobile bottom navigation area for the music mini-player. final GlobalKey _bottomBarKey = GlobalKey(); MiniPlayerInsetController? _miniPlayerInsets; // Focus management for sidebar/content switching final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); bool _isSidebarFocused = false; bool _isSidebarInteractionExpanded = false; bool _isOverlaySheetOpen = false; /// The binder is now owned by a top-level [Provider] (see main.dart) so /// the splash can await its first settle before navigating here. We just /// observe its [ActiveProfileProvider.isBinding] state for the once-only /// priming below. PlexHomeService? _plexHomeService; ActiveProfileProvider? _activeProfileForListener; String? _lastSeenProfileId; // Tracks ActiveProfileProvider.isBinding from the previous notification // so we can detect a binding-just-settled transition for the *same* // active profile id (e.g. after a borrow/remove rebind). Without this // we only invalidate on id change and the libraries sidebar keeps // stale entries until the user switches profiles. bool _wasBindingPrev = false; bool _hadProfiles = false; /// Subscription to MultiServerManager status changes. Used to resume any /// queued downloads as soon as a Plex client comes online for the first /// time after launch (legacy main.dart used to do this from SetupScreen /// before navigating). StreamSubscription>? _serverStatusSub; bool _downloadResumeFired = false; /// Listener that fires when [ActiveProfileBinder] settles (Plex *and* /// Jellyfin both bound). Drives the once-per-launch priming of /// LibrariesProvider + watch sync + tab fullRefresh — wiring this off /// the first online-server emission instead would prime before /// Jellyfin gets added, leaving its libraries out of the navbar. VoidCallback? _bindingSettleListener; bool _startupServicesPrimed = false; Timer? _startupSettleTimeout; /// Hard ceiling on how long we wait for [ActiveProfileBinder] to settle /// before priming the UI anyway. The binder always calls /// `markBindingFinished` in its `finally`, but this is a defence in depth: /// if a transient bug or hung HTTP path keeps `isBinding` true, the user /// would otherwise see an empty Discover screen forever. After the /// fallback fires the screens render their normal "no servers" state and /// the user can pull-to-refresh / open settings. static const _startupSettleFallback = Duration(seconds: 15); static const _backExitWindow = Duration(seconds: 3); DateTime? _lastBackPressAt; @override void initState() { super.initState(); _isOffline = widget.isOfflineMode; _offlineUntilConnected = widget.isOfflineMode; WidgetsBinding.instance.addObserver(this); _contentFocusScope.addListener(_syncSidebarFocusWithContent); if (PlatformDetector.isDesktopOS()) { windowManager.addListener(this); windowManager.setPreventClose(true); } // Synchronize _lastHasLiveTv with provider before building screens // so _buildScreens and _hasLiveTv getter agree from the start. try { _lastHasLiveTv = context.read().hasLiveTv; } catch (_) { _lastHasLiveTv = false; } try { _lastHasExplore = context.read().hasAnySource; } catch (_) { _lastHasExplore = false; } _currentTab = _defaultTabForMode(_isOffline); _lastOnlineTabId = _isOffline ? null : NavigationTabId.discover; _autoSwitchedToDownloads = _isOffline && _currentTab == NavigationTabId.downloads; // If the preferred startup section isn't visible yet (e.g. Live TV before // servers finish binding), remember it and switch once it becomes available. final preferredStartup = SettingsService.instanceOrNull?.read(SettingsService.startupSection); _pendingStartupTab = (!_isOffline && preferredStartup != null && preferredStartup != _currentTab) ? preferredStartup : null; _screens = _buildScreens(_isOffline); // Warm the TV keyboard's text-layout caches off the first real open // (measured ~315ms first-open frame on low-end boxes, mostly cold font // shaping). Delayed past the startup burst; no-op off TV. if (PlatformDetector.isTV()) { WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(seconds: 3), () { if (mounted) warmUpTvVirtualKeyboardText(context); }); }); } // Set up Watch Together callbacks immediately (must be synchronous to catch early messages) if (!_isOffline) { _setupWatchTogetherCallback(); _setupSystemShelfDeepLink(); } // Wire profile binder + tracker bootstrap (skip in offline mode) WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; final activeProfile = context.read(); _activeProfileForListener = activeProfile; _lastSeenProfileId = activeProfile.activeId; activeProfile.addListener(_onActiveProfileChanged); _plexHomeService = context.read(); unawaited(_plexHomeService!.start()); final manager = context.read().serverManager; // Read the binder so the Provider's `lazy: false` create has fired // for sure; start only in online mode so explicit startup offline does // not immediately kick off the same connection attempts it skipped. final binder = context.read(); if (!_isOffline) binder.start(); _runStartupOnFirstOnlineServer(manager); if (!_isOffline) { // Settings-only initialization — profile identity is managed by // ActiveProfileProvider + ActiveProfileBinder. final userProfileProvider = context.userProfile; await userProfileProvider.initialize(); if (!mounted) return; // Ensure first login (or any unset profile state) requires explicit selection. await _promptForInitialProfileSelection(); if (!mounted) return; // Auto-start companion remote server once the active profile is known. if (_companionRemoteSetup) { unawaited(_autoStartCompanionRemoteServer()); } } // Focus content initially (replaces autofocus which caused focus stealing issues) // Skip if profile selection is on top — it manages its own focus. if (!_isSidebarFocused && !_isShowingProfileSelection) { _contentFocusScope.requestFocus(); } _updateTvosMenuPassthrough(); // Check for updates on startup unawaited(_checkForUpdatesOnStartup()); }); } /// Run startup tasks that depend on having at least one online server: /// initialize and load the libraries provider, kick off the initial /// watch-state sync, and (for Plex) resume any queued downloads. The /// legacy [SetupScreen] path used to do all this before navigating to /// MainScreen; with the binder taking over for the connect, we hook /// into [ActiveProfileProvider.isBinding] (for the once-only priming, /// which must wait for *all* connections — Plex *and* Jellyfin — to /// land so the navbar shows libraries from both backends) and /// [MultiServerManager.statusStream] (for download resume, which only /// cares about the first online Plex client). Fires at most once per /// MainScreen lifetime. void _runStartupOnFirstOnlineServer(MultiServerManager manager) { if (_isOffline || _downloadResumeFired) return; final activeProfile = context.read(); void primeServicesOnBindingSettle({bool fromTimeout = false}) { if (_startupServicesPrimed || !mounted) return; // Wait for the binder to finish — `_rebind` only flips `isBinding` // false after both `_bindPlexHome` AND `_bindJoinRows` (where // Jellyfin gets added) complete. Priming on the first Plex status // emit instead would load libraries before Jellyfin is registered. // // The `fromTimeout` escape hatch lets the [_startupSettleTimeout] // bypass this gate if the binder has somehow not flipped the flag // within [_startupSettleFallback]. Logs a warning so the silent // path is still surfaced in diagnostics. if (activeProfile.isBinding && !fromTimeout) return; if (fromTimeout) { appLogger.w( 'ActiveProfileBinder still binding after ${_startupSettleFallback.inSeconds}s ' '— priming UI anyway so the user is not stuck on an empty screen.', ); } // Set the guard before the await so re-entrant listener fires can't // race a second prime. _startupServicesPrimed = true; _startupSettleTimeout?.cancel(); _startupSettleTimeout = null; unawaited(_primeOnlineServices(manager)); } void tryDownloadResume() { // Wait for any online client before firing the resume — the download // pipeline is backend-neutral (resumeQueuedDownloads accepts a // MediaServerClient and per-item resolution picks up the right // backend), so a Jellyfin-only setup can resume too. _resumeQueuedDownloadsOnce(manager.onlineClients.values.firstOrNull); } // Listen for binding-settle so the once-only priming runs after both // Plex and Jellyfin are wired up. _bindingSettleListener = () => primeServicesOnBindingSettle(); activeProfile.addListener(_bindingSettleListener!); // Defence in depth: bypass the binder gate after a hard ceiling so a // hung bind path can't strand the user on an empty screen. _startupSettleTimeout?.cancel(); _startupSettleTimeout = Timer(_startupSettleFallback, () { primeServicesOnBindingSettle(fromTimeout: true); }); // Fast paths: binder may have already settled / first Plex server may // already be online (binder finished before this microtask). primeServicesOnBindingSettle(); tryDownloadResume(); if (_downloadResumeFired) return; _serverStatusSub = manager.statusStream.listen((_) => tryDownloadResume()); } /// Shared online-entry hook for cold startup and reconnect-from-offline. /// It mirrors `_invalidateAllScreens`: libraries load before tab refreshes /// so screens don't initialize against an empty provider and remain blank. Future _primeOnlineServices(MultiServerManager manager) async { if (manager.onlineServerIds.isNotEmpty) { if (!mounted) return; final mp = context.read(); if (mp.hasConnectedServers) { final lp = context.read(); lp.initialize(mp.aggregationService); await lp.loadLibraries(); if (!mounted) return; context.read().onServersConnected(); unawaited(context.read().refreshMetadataFromCache()); _resumeQueuedDownloadsOnce( mp.onlineServerIds.map((id) => mp.getClientForServer(ServerId(id))).nonNulls.firstOrNull, ); } } if (!mounted) return; _fullRefreshContentTabs(); } /// Single-shot "resume queued downloads once any client is online" rule, /// shared by the startup status-stream path and [_primeOnlineServices] — /// each caller resolves its own candidate client (unfiltered manager view /// vs the visibility-filtered provider) and hands it here. No-op once the /// resume has fired, or while no client is online yet. void _resumeQueuedDownloadsOnce(MediaServerClient? onlineClient) { if (_downloadResumeFired || !mounted) return; if (onlineClient == null) return; _downloadResumeFired = true; // The status subscription exists only to drive this one-shot. _serverStatusSub?.cancel(); _serverStatusSub = null; final downloadProvider = context.read(); unawaited( downloadProvider.ensureInitialized().then((_) { if (!mounted) return; downloadProvider.resumeQueuedDownloads(onlineClient); }), ); } void _onActiveProfileChanged() { final activeProfile = _activeProfileForListener; if (activeProfile == null) return; final id = activeProfile.activeId; final isBindingNow = activeProfile.isBinding; final action = profileInvalidationAction( previousProfileId: _lastSeenProfileId, currentProfileId: id, wasBindingPreviously: _wasBindingPrev, isBindingNow: isBindingNow, ); _lastSeenProfileId = id; _wasBindingPrev = isBindingNow; // Re-arm the initial-profile prompt when profiles arrive late (e.g. a // slow home-user fetch landing after the empty first snapshot) while // nothing is active — otherwise the one-shot post-frame prompt has // already passed and the user is stuck in a session with no picker. final hasProfilesNow = activeProfile.profiles.isNotEmpty; if (!_hadProfiles && hasProfilesNow && id == null && !_isShowingProfileSelection) { unawaited(_promptForInitialProfileSelection()); } _hadProfiles = hasProfilesNow; // Same active id, but a rebind cycle for that profile just settled // (true → false transition). Fires after borrow / connection-removal // flows trigger ActiveProfileBinder.rebindIfActive, so the libraries // sidebar reflects the new server set without an app restart. if (action == ProfileInvalidationAction.invalidateNow) { unawaited(_invalidateAllScreens()); } } Future _promptForInitialProfileSelection() async { if (!mounted || _isShowingProfileSelection) return; if (widget.initialPromptHandled) return; final activeProfile = context.read(); final connections = context.read(); // The provider's initialize() is fire-and-forget from MultiProvider — // wait for it to settle so `active` and `profiles` reflect storage // before we decide whether to prompt. await activeProfile.initialize(); if (!mounted) return; final settingsService = await SettingsService.getInstance(); if (!mounted) return; // Connections but ZERO resolvable profiles (e.g. the home-user fetch // failed at sign-in): a session with nothing to select and no picker is // a dead end. Mirror the boot guard — prune orphans and route to auth // when nothing selectable remains. if (activeProfile.active == null && activeProfile.profiles.isEmpty) { // Offline, "unresolvable" may just be an unreachable plex.tv — don't // kick the user to auth over it. if (!widget.isOfflineMode && (await connections.list()).isNotEmpty && mounted) { appLogger.w('MainScreen: connections exist but no profiles resolved — settling session'); await settleSessionAfterRemoval(SessionTeardownScope.of(context)); } return; } // Always prompt when there's no active profile but profiles exist // (fresh sign-in with multiple Plex Home users): otherwise the binder // has no profile to bind, and the user lands on an empty screen with // no way back to the picker. final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return; await _pushProfileSelection(); } /// Push the picker in "must choose" mode, suppressing the tvOS menu-button /// passthrough for as long as it is up. Future _pushProfileSelection() async { _isShowingProfileSelection = true; _setTvosMenuPassthrough(false); await Navigator.of( context, rootNavigator: true, ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); if (!mounted) return; _isShowingProfileSelection = false; _updateTvosMenuPassthrough(); } Future _checkForUpdatesOnStartup() async { if (!mounted) return; final settingsService = await SettingsService.getInstance(); if (!settingsService.read(SettingsService.autoCheckUpdatesOnStartup)) return; // Native updater (Sparkle/WinSparkle) handles everything — skip Flutter dialog if (UpdateService.useNativeUpdater) { await UpdateService.checkForUpdatesNative(inBackground: true); return; } try { final updateInfo = await UpdateService.checkForUpdatesOnStartup(); if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) { await _showUpdateDialog(updateInfo); } } catch (e) { appLogger.e('Error checking for updates', error: e); } } Future _showUpdateDialog(Map updateInfo) => showUpdateAvailableDialog( context, updateInfo, title: t.update.available, dismissLabel: t.common.later, showSkipVersion: true, ); /// Set up the Watch Together navigation callback for guests void _setupWatchTogetherCallback() { try { final watchTogether = context.read(); watchTogether.onMediaSwitched = (ratingKey, serverId, mediaTitle) { appLogger.d('WatchTogether: Media switch received - navigating to $mediaTitle'); return _navigateToWatchTogetherMedia(ratingKey, serverId); }; watchTogether.onHostExitedPlayer = () { appLogger.d('WatchTogether: Host exited player - exiting player for guest'); // Watch Together playback lives in the profile navigator; root-level // dialogs/profile picker must not be affected. if (!mounted) return; final navigator = Navigator.of(context); bool isVideoPlayerOnTop = false; navigator.popUntil((route) { if (route.isCurrent) { isVideoPlayerOnTop = route.settings.name == kVideoPlayerRouteName; } return true; }); if (isVideoPlayerOnTop && navigator.canPop()) { navigator.pop(); } }; } catch (e) { appLogger.w('Could not set up Watch Together callback', error: e); } } /// Set up launcher shelf deep link handling for Android TV and tvOS taps. void _setupSystemShelfDeepLink() { if (!Platform.isAndroid && !PlatformDetector.isAppleTV()) return; final systemShelf = SystemShelfService(); // Listen for deep links when app is already running (warm start) systemShelf.onShelfItemTap = (contentId) { appLogger.d('System shelf tap: $contentId'); _handleShelfContentId(contentId); }; _systemShelfTapCallback = systemShelf.onShelfItemTap; // Check for pending deep link from cold start WidgetsBinding.instance.addPostFrameCallback((_) async { final contentId = await systemShelf.getInitialDeepLink(); if (contentId != null && mounted) { appLogger.d('System shelf initial deep link: $contentId'); unawaited(_handleShelfContentId(contentId)); } }); } /// Handle a launcher shelf content ID by fetching metadata and starting playback. Future _handleShelfContentId(String contentId) async { if (!mounted) return; final parsed = SystemShelfService.parseContentId(contentId); if (parsed == null) { appLogger.w('System shelf: invalid content ID: $contentId'); return; } final (serverId, ratingKey) = parsed; try { final multiServer = context.read(); final client = multiServer.getClientForServer(serverId); if (client == null) { appLogger.w('System shelf: server $serverId not available'); return; } final metadata = await client.fetchItem(ratingKey); if (metadata == null || !mounted) return; unawaited(navigateToVideoPlayer(context, metadata: metadata)); } catch (e) { appLogger.e('System shelf: failed to navigate to media', error: e); } } /// Navigate to media when host switches content in Watch Together session. /// Returns whether navigation was initiated; failures are re-dispatched on /// the host's next state heartbeat. Future _navigateToWatchTogetherMedia(String ratingKey, ServerId serverId) async { if (!mounted) return false; // Check before any context usage try { return await navigateToWatchTogetherPlayback(context, ratingKey: ratingKey, serverId: serverId); } catch (e) { appLogger.e('WatchTogether: Failed to navigate to media', error: e); return false; } } bool _companionRemoteSetup = false; ValueChanged? _systemShelfTapCallback; @override void didChangeDependencies() { super.didChangeDependencies(); // Listen for offline/online transitions to refresh navigation & screens. // If the provider already observed a failed bind before this listener // attached, mirror that missed state after build. final provider = context.read(); if (provider != null && provider != _offlineModeProvider) { _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); _offlineModeProvider = provider; _offlineModeProvider!.addListener(_handleOfflineStatusChanged); if (!widget.isOfflineMode && !_isOffline && provider.isOffline) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _handleOfflineStatusChanged(); }); } } // Listen for Live TV / DVR availability changes final multiServer = context.read(); if (multiServer != _multiServerProvider) { _multiServerProvider?.removeListener(_handleLiveTvChanged); _multiServerProvider = multiServer; _multiServerProvider!.addListener(_handleLiveTvChanged); } // Listen for catalog sources (Explore tab) appearing/disappearing when a // provider like Trakt is connected or disconnected mid-session. final catalogSources = context.read(); if (catalogSources != _catalogSourcesProvider) { _catalogSourcesProvider?.removeListener(_handleCatalogSourcesChanged); _catalogSourcesProvider = catalogSources; _catalogSourcesProvider!.addListener(_handleCatalogSourcesChanged); } // Wire up Companion Remote command routing (host devices only, once) if (!_companionRemoteSetup && PlatformDetector.shouldActAsRemoteHost(context)) { _companionRemoteSetup = true; _setupCompanionRemote(); } _miniPlayerInsets = context.read(); final scopedRouteObserver = ProfileNavigationScope.of(context).routeObserver; if (scopedRouteObserver != _profileRouteObserver) { _profileRouteObserver?.unsubscribe(this); _profileRouteObserver = scopedRouteObserver; final route = ModalRoute.of(context); if (route is PageRoute) { scopedRouteObserver.subscribe(this, route); } } } void _setupCompanionRemote() { final companionRemote = context.read(); companionRemote.onCommandReceived = (command) { if (mounted) { CompanionRemoteReceiver.instance.handleCommand(command, context); } }; final receiver = CompanionRemoteReceiver.instance; receiver.navigationOwner = this; receiver.onTabNext = () { final tabs = _getVisibleTabs(_isOffline); final idx = tabs.indexWhere((t) => t.id == _currentTab); if (idx >= 0) _selectTab(tabs[(idx + 1) % tabs.length].id); }; receiver.onTabPrevious = () { final tabs = _getVisibleTabs(_isOffline); final idx = tabs.indexWhere((t) => t.id == _currentTab); if (idx >= 0) _selectTab(tabs[(idx - 1 + tabs.length) % tabs.length].id); }; receiver.onTabDiscover = () => _selectTab(NavigationTabId.discover); receiver.onTabLibraries = () => _selectTab(NavigationTabId.libraries); receiver.onTabSearch = () => _selectTab(NavigationTabId.search); receiver.onTabDownloads = () => _selectTab(NavigationTabId.downloads); receiver.onTabSettings = () => _selectTab(NavigationTabId.settings); receiver.onHome = () { final tabs = _getVisibleTabs(_isOffline); if (tabs.isEmpty) return; _selectTab(tabs.first.id); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _sideNavKey.currentState?.focusHomeItem(); }); }; receiver.onSearchAction = (query) { final trimmed = query?.trim() ?? ''; final hasQuery = trimmed.isNotEmpty; // With a query, don't focus the input (which would auto-open the TV // keyboard); submitSearchQuery runs the search and focuses results. _selectTab(NavigationTabId.search, focusSearchInput: !hasQuery); if (hasQuery) { WidgetsBinding.instance.addPostFrameCallback((_) { _onScreen(NavigationTabId.search, (screen) => screen.submitSearchQuery(trimmed)); }); } }; } Future _autoStartCompanionRemoteServer() async { try { final settings = await SettingsService.getInstance(); if (!settings.read(SettingsService.enableCompanionRemoteServer)) return; if (!mounted) return; await startCompanionRemoteHost(context); } catch (e) { appLogger.e('CompanionRemote: Failed to auto-start server', error: e); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _profileRouteObserver?.unsubscribe(this); if (PlatformDetector.isDesktopOS()) { windowManager.removeListener(this); windowManager.setPreventClose(false); } _offlineModeProvider?.removeListener(_handleOfflineStatusChanged); _multiServerProvider?.removeListener(_handleLiveTvChanged); _catalogSourcesProvider?.removeListener(_handleCatalogSourcesChanged); if (_bindingSettleListener != null) { _activeProfileForListener?.removeListener(_bindingSettleListener!); } _activeProfileForListener?.removeListener(_onActiveProfileChanged); _serverStatusSub?.cancel(); _startupSettleTimeout?.cancel(); _startupSettleTimeout = null; _sidebarFocusScope.dispose(); _contentFocusScope.removeListener(_syncSidebarFocusWithContent); _contentFocusScope.dispose(); _setTvosMenuPassthrough(false); // Clean up only callbacks still owned by this screen. A replacement // MainScreen may already have installed its callbacks this frame. if (_companionRemoteSetup) { final receiver = CompanionRemoteReceiver.instance; if (identical(receiver.navigationOwner, this)) { receiver.onTabNext = null; receiver.onTabPrevious = null; receiver.onTabDiscover = null; receiver.onTabLibraries = null; receiver.onTabSearch = null; receiver.onTabDownloads = null; receiver.onTabSettings = null; receiver.onHome = null; receiver.onSearchAction = null; receiver.navigationOwner = null; } } final shelfCallback = _systemShelfTapCallback; final systemShelf = SystemShelfService(); if (shelfCallback != null && identical(systemShelf.onShelfItemTap, shelfCallback)) { systemShelf.onShelfItemTap = null; } super.dispose(); } @override void onWindowClose() { exit(0); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && !_isOffline && !_isShowingProfileSelection) { // Only show profile selection on resume for mobile platforms. // On desktop, "resumed" fires on every window focus gain (alt-tab, click), // which is too frequent — the initial prompt on startup is sufficient. if (Platform.isAndroid || Platform.isIOS) { _showProfileSelectionOnResume(); } } } Future _showProfileSelectionOnResume() async { final settingsService = await SettingsService.getInstance(); if (!mounted) return; if (!context.read().requiresSelectionOnOpen(settingsService)) return; await _pushProfileSelection(); } /// IndexedStack that disables tickers for offscreen children to prevent /// animation controllers on non-visible tabs from scheduling frames. Widget _buildTickerAwareStack() { return Column( children: [ const AuthErrorBanner(), Expanded( child: IndexedStack( index: _currentIndex, clipBehavior: Clip.none, children: [ for (var i = 0; i < _screens.length; i++) TickerMode(enabled: i == _currentIndex, child: _screens[i]), ], ), ), ], ); } List _buildScreens(bool offline) { return [ for (final tab in _getVisibleTabs(offline)) switch (tab.id) { NavigationTabId.discover => DiscoverScreen(key: _screenKeys[tab.id]), NavigationTabId.explore => ExploreScreen(key: _screenKeys[tab.id]), NavigationTabId.libraries => LibrariesScreen( key: _screenKeys[tab.id], onLibraryOrderChanged: _onLibraryOrderChanged, onLibrarySelected: _handleLibrariesScreenSelected, ), NavigationTabId.liveTv => LiveTvScreen(key: _screenKeys[tab.id]), NavigationTabId.search => SearchScreen(key: _screenKeys[tab.id]), NavigationTabId.downloads => DownloadsScreen(key: _screenKeys[tab.id]), NavigationTabId.settings => SettingsScreen(key: _screenKeys[tab.id]), }, ]; } /// Normalize tab ID when switching between offline/online modes. /// Preserves the current tab if it exists in the new mode, otherwise defaults to first tab. 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) => NavigationTab.resolveDefaultTab( isOffline: isOffline, hasLiveTv: _hasLiveTv, hasExplore: _lastHasExplore, preferredStartup: SettingsService.instanceOrNull?.read(SettingsService.startupSection), ); void _triggerReconnect() { if (_isReconnecting) return; setState(() => _isReconnecting = true); final multiServerProvider = context.read(); final serverManager = multiServerProvider.serverManager; final activeProfile = context.read(); final binder = context.read(); unawaited(() async { try { binder.start(); // Health check first so stale "online" servers get marked offline before // we snapshot the offline list for reconnection. await serverManager.checkServerHealth(); await serverManager.reconnectOfflineServers(forceRediscovery: true); if (!mounted) return; if (shouldRetryActiveProfileBindAfterReconnect( hasActiveProfile: activeProfile.active != null, hasVisibleConnectedServers: multiServerProvider.hasConnectedServers, hasManagerOnlineServers: serverManager.onlineServerIds.isNotEmpty, hasKnownOfflineServers: serverManager.offlineServerIds.isNotEmpty, )) { await binder.rebindActive(); } if (!mounted) return; if (multiServerProvider.hasConnectedServers) { _offlineUntilConnected = false; _handleOfflineStatusChanged(); } } finally { setStateIfMounted(() => _isReconnecting = false); } }()); } /// Rebuilds navigation after a tab's availability flipped: _currentTab may /// need normalizing, and passthrough depends on it being the first tab. void _handleTabAvailabilityChanged() { setState(() { _screens = _buildScreens(_isOffline); _currentTab = _normalizeTabForMode(_currentTab, _isOffline); }); _updateTvosMenuPassthrough(); } void _handleLiveTvChanged() { final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; if (hasLiveTv == _lastHasLiveTv) return; _lastHasLiveTv = hasLiveTv; _handleTabAvailabilityChanged(); // A preferred startup section (only Live TV can be deferred) just became // available — switch to it via _selectTab so it gets the usual visibility // and focus handling. _selectTab clears _pendingStartupTab. final pending = _pendingStartupTab; if (pending != null && _getVisibleTabs(_isOffline).any((t) => t.id == pending)) { _selectTab(pending); } } void _handleCatalogSourcesChanged() { final hasExplore = _catalogSourcesProvider?.hasAnySource ?? false; if (hasExplore == _lastHasExplore) return; _lastHasExplore = hasExplore; _handleTabAvailabilityChanged(); } void _handleOfflineStatusChanged() { final hasVisibleConnectedServers = context.read().hasConnectedServers; if (hasVisibleConnectedServers) _offlineUntilConnected = false; final providerOffline = _offlineModeProvider?.isOffline ?? false; final newOffline = shouldRenderMainScreenOffline( providerOffline: providerOffline, startupOfflineUntilConnected: _offlineUntilConnected, hasVisibleConnectedServers: hasVisibleConnectedServers, ); if (newOffline == _isOffline) return; final previousTab = _currentTab; final wasOffline = _isOffline; setState(() { _isReconnecting = false; _isOffline = newOffline; _screens = _buildScreens(_isOffline); _selectedLibraryGlobalKey = _isOffline ? null : _selectedLibraryGlobalKey; if (_isOffline) { // Remember the online tab so we can restore it when reconnecting. if (!wasOffline) { _lastOnlineTabId = previousTab; } final normalizedTab = _normalizeTabForMode(_currentTab, _isOffline); _currentTab = normalizedTab; // Track if we auto-switched to Downloads because the previous tab was unavailable. _autoSwitchedToDownloads = previousTab != NavigationTabId.downloads && normalizedTab == NavigationTabId.downloads; } else { // Coming back online: restore the last online tab if we forced a switch to Downloads. if (_autoSwitchedToDownloads) { final restoredTab = _lastOnlineTabId ?? NavigationTabId.discover; _currentTab = _normalizeTabForMode(restoredTab, _isOffline); } else { _currentTab = _normalizeTabForMode(_currentTab, _isOffline); } _autoSwitchedToDownloads = false; } }); _updateTvosMenuPassthrough(); // Refresh sidebar focus after rebuilding navigation WidgetsBinding.instance.addPostFrameCallback((_) { _sideNavKey.currentState?.focusActiveItem(); }); // Ensure profile settings are warmed when coming back online if (!_isOffline) { unawaited(() async { final mp = context.read(); final binder = context.read(); binder.start(); if (!mp.hasConnectedServers && context.read().active != null) { await binder.rebindActive(); if (!mounted) return; } await context.userProfile.initialize(); if (!mounted) return; await _primeOnlineServices(mp.serverManager); }()); } } void _focusSidebar() { // Capture target before requestFocus() auto-focuses a sidebar descendant // and overwrites lastFocusedKey (e.g. to the Libraries toggle button). final targetKey = _sideNavKey.currentState?.lastFocusedKey; setState(() => _isSidebarFocused = true); _updateTvosMenuPassthrough(); _sidebarFocusScope.requestFocus(); // Focus the active item after the focus scope has focus WidgetsBinding.instance.addPostFrameCallback((_) { _sideNavKey.currentState?.focusActiveItem(targetKey: targetKey); }); } void _focusContent({bool restorePreviousFocus = true}) { setState(() => _isSidebarFocused = false); _updateTvosMenuPassthrough(); if (restorePreviousFocus) { _contentFocusScope.requestFocus(); } // Only programmatically focus if the scope didn't auto-restore a child. // This preserves the user's focus position when returning from sidebar. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (restorePreviousFocus && _contentFocusScope.focusedChild != null) return; _onScreen(_currentTab, (screen) => screen.focusActiveTabIfReady()); }); } /// _isSidebarFocused is hand-toggled; if anything moves real focus into the /// content scope without going through _focusContent (e.g. a deferred /// focusActiveTabIfReady), collapse the rail to match reality (#1411). void _syncSidebarFocusWithContent() { if (!mounted || !_isSidebarFocused || !_contentFocusScope.hasFocus) return; setState(() => _isSidebarFocused = false); _updateTvosMenuPassthrough(); } void _handleSidebarInteractionExpandedChanged(bool expanded) { if (_isSidebarInteractionExpanded == expanded) return; setState(() => _isSidebarInteractionExpanded = expanded); } void _handleOverlaySheetOpenChanged(bool open) { if (_isOverlaySheetOpen == open) return; _isOverlaySheetOpen = open; _updateTvosMenuPassthrough(); } double _sideNavigationWidth(BuildContext context, {required bool alwaysExpanded}) { final isExpanded = alwaysExpanded || _isSidebarFocused || _isSidebarInteractionExpanded; return isExpanded ? SideNavigationRailState.expandedWidth : SideNavigationRailState.collapsedWidthForContext(context); } bool get _shouldPassTvosMenuToSystem { final tabs = _getVisibleTabs(_isOffline); return shouldPassTvosMenuToSystem( isAppleTV: PlatformDetector.isAppleTV(), isShowingProfileSelection: _isShowingProfileSelection, isOverlaySheetOpen: _isOverlaySheetOpen, isRouteCurrent: ModalRoute.of(context)?.isCurrent == true, isSidebarFocused: _isSidebarFocused, hasVisibleTabs: tabs.isNotEmpty, isCurrentTabRoot: tabs.isNotEmpty && _currentTab == tabs.first.id, ); } void _setTvosMenuPassthrough(bool enabled) { if (!PlatformDetector.isAppleTV()) return; unawaited(TvosSystemNavigationService.setMenuPassthroughEnabled(enabled)); } void _updateTvosMenuPassthrough() { if (!mounted) return; _setTvosMenuPassthrough(_shouldPassTvosMenuToSystem); } /// Suppress stray back events after a child route pops. /// On Android TV the platform popRoute can arrive before the key events, /// so BackKeySuppressorObserver misses them and they leak into _handleBackKey. bool _suppressBackAfterPop = false; KeyEventResult _handleMainBack() { final tabs = _getVisibleTabs(_isOffline); if (tabs.isEmpty) return KeyEventResult.handled; final homeTab = tabs.first.id; if (_currentTab != homeTab) { _selectTab(homeTab); // Keep the focus ring in step with the new selection; the sidebar scope // already has focus, so no post-frame deferral is needed. _sideNavKey.currentState?.focusHomeItem(); _lastBackPressAt = null; return KeyEventResult.handled; } // The tvOS engine normally passes root Menu presses through to UIKit. If a // stale event still reaches Flutter, avoid showing an exit prompt that // cannot be honored app-side. if (PlatformDetector.isAppleTV()) { _lastBackPressAt = null; return KeyEventResult.handled; } final now = DateTime.now(); final lastBackPressAt = _lastBackPressAt; if (lastBackPressAt != null && now.difference(lastBackPressAt) < _backExitWindow) { _lastBackPressAt = null; unawaited(AppExitService.requestExit()); return KeyEventResult.handled; } _lastBackPressAt = now; showMainSnackBar(t.common.pressBackAgainToExit, duration: _backExitWindow); return KeyEventResult.handled; } KeyEventResult _handleMainBackKeyAction(KeyEvent event) { if (!event.logicalKey.isBackKey) return KeyEventResult.ignored; if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } // AppleTV: KeyDown does the work, KeyUp is consumed silently. See the // matching comment in handleBackKeyAction for why the suppressor pattern // doesn't fit here. if (PlatformDetector.isAppleTV() && event is KeyDownEvent) { final result = _handleMainBack(); if (result == KeyEventResult.handled) { BackKeyCoordinator.markHandled(); } return result; } if (PlatformDetector.isAppleTV() && event is KeyUpEvent) { return KeyEventResult.handled; } if (event is KeyUpEvent) { final result = _handleMainBack(); if (result == KeyEventResult.handled) { BackKeyCoordinator.markHandled(); } return result; } if (event is KeyDownEvent || event is KeyRepeatEvent) { return KeyEventResult.handled; } return KeyEventResult.ignored; } KeyEventResult _handleBackKey(KeyEvent event) { if (ModalRoute.of(context)?.isCurrent != true) { return KeyEventResult.ignored; } if (_suppressBackAfterPop && event.logicalKey.isBackKey) { if (event is KeyUpEvent) _suppressBackAfterPop = false; return KeyEventResult.handled; } if (!_isSidebarFocused) { // Content focused → move to sidebar return handleBackKeyAction(event, _focusSidebar); } return _handleMainBackKeyAction(event); } /// F11 toggles OS fullscreen from anywhere in the main UI. The in-player /// hotkey (default `f`) only works while the player is mounted; this is /// the escape hatch when fullscreen persists after the player closes. KeyEventResult _handleFullscreenShortcut(KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey != LogicalKeyboardKey.f11) return KeyEventResult.ignored; if (!PlatformDetector.isDesktopOS()) return KeyEventResult.ignored; unawaited(FullscreenStateManager().toggleFullscreen()); return KeyEventResult.handled; } /// On macOS, native fullscreen is window state shared by every route. /// Player Escape therefore leaves it alone; only root Home owns the /// conventional Escape-to-leave-fullscreen behavior. KeyEventResult _handleMacOsRootEscape(KeyEvent event) { final tabs = _getVisibleTabs(_isOffline); final shouldHandle = shouldHandleMacOsRootEscape( isMacOS: Platform.isMacOS, isPhysicalKeyboardEvent: event.isPhysicalKeyboardEvent, logicalKey: event.logicalKey, isCurrentRoute: ModalRoute.of(context)?.isCurrent == true, isHomeTab: tabs.isNotEmpty && _currentTab == tabs.first.id, ); if (!shouldHandle) return KeyEventResult.ignored; if (event is KeyUpEvent) { BackKeyCoordinator.markHandled(); unawaited(FullscreenStateManager().exitFullscreenIfActive()); } return event is KeyDownEvent || event is KeyRepeatEvent || event is KeyUpEvent ? KeyEventResult.handled : KeyEventResult.ignored; } /// Handle Cmd+F (macOS) / Ctrl+F (Windows/Linux) to navigate to search. KeyEventResult _handleSearchShortcut(KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; if (event.logicalKey != LogicalKeyboardKey.keyF) return KeyEventResult.ignored; final isMetaPressed = HardwareKeyboard.instance.isMetaPressed; final isControlPressed = HardwareKeyboard.instance.isControlPressed; final isMacShortcut = Platform.isMacOS && isMetaPressed && !isControlPressed; final isOtherShortcut = !Platform.isMacOS && isControlPressed && !isMetaPressed; if (!isMacShortcut && !isOtherShortcut) return KeyEventResult.ignored; if (_isOffline) return KeyEventResult.handled; _selectTab(NavigationTabId.search); if (_isSidebarFocused) _focusContent(); // Schedule focus after the frame so the search screen is visible in the IndexedStack WidgetsBinding.instance.addPostFrameCallback((_) { _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); return KeyEventResult.handled; } @override void didPush() { // Called when this route has been pushed (initial navigation) if (_currentTab == NavigationTabId.discover) { _onDiscoverBecameVisible(); } } @override void didPushNext() { _setTvosMenuPassthrough(false); // A pushed detail route covers the bottom bar — drop the mini-player to // the true (safe-area) bottom while it's hidden. _miniPlayerInsets?.setNavBarSuspended(true); // Called when a child route is pushed on top (e.g., video player) if (_currentTab == NavigationTabId.discover) { _onScreen(NavigationTabId.discover, (screen) => screen.onTabHidden()); } } @override void didPopNext() { // Suppress stray back key events from the pop that just returned us here _suppressBackAfterPop = true; // Auto-clear after 2 frames in case no back event arrives WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) { _suppressBackAfterPop = false; }); }); // Called when returning to this route from a child route (e.g., from video player) _updateTvosMenuPassthrough(); _miniPlayerInsets?.setNavBarSuspended(false); if (_currentTab == NavigationTabId.discover) { _onScreen(NavigationTabId.discover, (screen) => screen.onTabShown()); _onDiscoverBecameVisible(); } } void _onDiscoverBecameVisible() { appLogger.d('Navigated to home'); // Refresh content when returning to discover page _onScreen(NavigationTabId.discover, (screen) => screen.refresh()); } void _onLibraryOrderChanged() { // Refresh side navigation when library order changes _sideNavKey.currentState?.reloadLibraries(); } /// Invalidate cached data across screens after a profile switch. /// The [ActiveProfileBinder] has already pushed fresh per-server tokens /// into [MultiServerManager], so this just clears UI caches and refreshes /// the visible screens. Future _invalidateAllScreens() async { appLogger.d('Invalidating screen data after profile switch'); final multiServerProvider = context.read(); final hiddenLibrariesProvider = context.read(); final librariesProvider = context.read(); final playbackStateProvider = context.read(); // Drop volatile API cache rows before screens kick off their refetch. // Pinned rows back offline downloads and must survive profile switches. try { await ApiCache.clearRegisteredVolatile(); } catch (e, st) { appLogger.w('Failed to clear ApiCache on profile switch', error: e, stackTrace: st); } await hiddenLibrariesProvider.refresh(); if (!mounted) return; librariesProvider.clear(); if (multiServerProvider.serverManager.serverIds.isNotEmpty) { if (!mounted) return; context.read().onServersConnected(); // Profile switches re-bind connections — give DownloadProvider a chance // to repopulate metadata that the per-backend caches now resolve. unawaited(context.read().refreshMetadataFromCache()); librariesProvider.initialize(multiServerProvider.aggregationService); await librariesProvider.refresh(); } playbackStateProvider.clearShuffle(); _fullRefreshContentTabs(); // Refresh user-level settings (audio/sub defaults) for the new identity. if (mounted) { unawaited(context.userProfile.refreshProfileSettings()); } } void _selectTab(NavigationTabId tab, {bool focusSearchInput = true}) { // Guard: ignore if tab isn't available in current mode if (!_getVisibleTabs(_isOffline).any((t) => t.id == tab)) return; final previousTab = _currentTab; setState(() { _currentTab = tab; // An explicit selection cancels any deferred startup-section switch. _pendingStartupTab = null; if (!_isOffline) { _lastOnlineTabId = tab; } else if (previousTab != tab) { // User made an explicit offline selection, so don't auto-restore later. _autoSwitchedToDownloads = false; } }); _updateTvosMenuPassthrough(); if (previousTab != tab) { // Notify previous screen it's being hidden _onScreen(previousTab, (screen) => screen.onTabHidden()); // Notify and focus new screen _onScreen(tab, (screen) => screen.onTabShown()); // Back-to-home keeps the sidebar focused (chain: content → sidebar → // home → exit); stealing focus here left _isSidebarFocused stuck true // while real focus sat on a content card (#1411). // A companion-remote search (focusSearchInput: false) must NOT focus the // search input, since focusing it auto-opens the on-screen keyboard; the // query submit focuses results instead. if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) { _onScreen(tab, (screen) => screen.focusActiveTabIfReady()); } } // Discover: always refresh content (even on re-selection) if (!_isOffline && tab == NavigationTabId.discover) { _onDiscoverBecameVisible(); } // Focus search input after rebuild so IndexedStack has made it visible. // Skipped for a companion-remote search (focusSearchInput: false), whose // submit runs the search and focuses results without opening the keyboard. if (tab == NavigationTabId.search && focusSearchInput) { WidgetsBinding.instance.addPostFrameCallback((_) { _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); } } /// Handle library selection from side navigation rail void _selectLibrary(String libraryGlobalKey) { _selectedLibraryGlobalKey = libraryGlobalKey; _selectTab(NavigationTabId.libraries); // Tell LibrariesScreen to load this library after tab switch _onScreen(NavigationTabId.libraries, (screen) => screen.loadLibraryByKey(libraryGlobalKey)); _onScreen(NavigationTabId.libraries, (screen) => screen.focusActiveTabIfReady()); } void _openSettings() { if (PlatformDetector.shouldUseSideNavigation(context)) { _selectTab(NavigationTabId.settings); _focusContent(restorePreviousFocus: false); return; } Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())); } void _handleLibrariesScreenSelected(String libraryGlobalKey) { if (_selectedLibraryGlobalKey == libraryGlobalKey) return; setState(() => _selectedLibraryGlobalKey = libraryGlobalKey); } void _showLibraryQuickPicker(BuildContext context) { if (_isOffline) return; final controller = OverlaySheetController.of(context); final groupByServer = SettingsService.instanceOrNull?.read(SettingsService.groupLibrariesByServer) ?? false; final maxHeight = MediaQuery.sizeOf(context).height * 0.62; controller .show( showDragHandle: true, constraints: BoxConstraints(maxHeight: maxHeight), builder: (sheetContext) { return Consumer2( builder: (context, librariesProvider, hiddenLibrariesProvider, _) { if (!hiddenLibrariesProvider.isInitialized) { return LibraryQuickPickerSheet( libraries: const [], selectedLibraryKey: _selectedLibraryGlobalKey, isLoading: true, groupByServer: groupByServer, emptyMessage: t.libraries.noLibrariesFound, onSelected: (libraryGlobalKey) => controller.close(libraryGlobalKey), ); } final allLibraries = librariesProvider.libraries; final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; final visibleLibraries = allLibraries .where((library) => !hiddenKeys.contains(library.globalKey)) .toList(); final emptyMessage = allLibraries.isEmpty ? t.libraries.noLibrariesFound : t.libraries.allLibrariesHidden; return LibraryQuickPickerSheet( libraries: visibleLibraries, selectedLibraryKey: _selectedLibraryGlobalKey, isLoading: librariesProvider.isLoading, groupByServer: groupByServer, emptyMessage: emptyMessage, onSelected: (libraryGlobalKey) => controller.close(libraryGlobalKey), ); }, ); }, ) .then((libraryGlobalKey) { if (!mounted || libraryGlobalKey == null) return; _selectLibrary(libraryGlobalKey); }); } /// Whether the Live TV tab is currently visible /// Use the synchronized value so screens list and nav bar always agree. /// Updated by _handleLiveTvChanged when the provider notifies. bool get _hasLiveTv => _lastHasLiveTv; /// Get navigation tabs filtered by offline mode List _getVisibleTabs(bool isOffline) { return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv, hasExplore: _lastHasExplore); } List _getBottomNavigationTabs(BuildContext context) { return mainScreenBottomNavigationTabs( visibleTabs: _getVisibleTabs(_isOffline), isMobile: PlatformDetector.isMobile(context), isOffline: _isOffline, currentTab: _currentTab, ); } /// Invoke [fn] on the tab's current [State] when it exists and implements /// the capability [T]. Screens are only built for visible tabs and mount a /// frame later, so a missing key or a non-matching state is a no-op. void _onScreen(NavigationTabId tab, void Function(T state) fn) { if (_screenKeys[tab]?.currentState case final T state) fn(state); } /// Full-refresh the primary content tabs. Shared by the online-entry hook /// ([_primeOnlineServices]) and the profile-switch invalidation /// ([_invalidateAllScreens]), which refresh the same set. void _fullRefreshContentTabs() { for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) { _onScreen(tab, (screen) => screen.fullRefresh()); } } Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) { final tabs = _getBottomNavigationTabs(context); final selectedIndex = tabs.indexWhere((tab) => tab.id == _currentTab); final navigationBar = NavigationBar( selectedIndex: selectedIndex >= 0 ? selectedIndex : 0, onDestinationSelected: (i) { if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id); }, labelBehavior: hideLabels ? NavigationDestinationLabelBehavior.alwaysHide : NavigationDestinationLabelBehavior.alwaysShow, destinations: tabs.map((tab) => tab.toDestination()).toList(), ); final librariesIndex = tabs.indexWhere((tab) => tab.id == NavigationTabId.libraries); if (librariesIndex < 0 || tabs.isEmpty) return navigationBar; return LayoutBuilder( builder: (context, constraints) { if (!constraints.hasBoundedWidth) return navigationBar; final itemWidth = constraints.maxWidth / tabs.length; final isRtl = Directionality.of(context) == TextDirection.rtl; final left = isRtl ? constraints.maxWidth - (itemWidth * (librariesIndex + 1)) : itemWidth * librariesIndex; return Stack( children: [ navigationBar, Positioned( left: left, top: 0, bottom: 0, width: itemWidth, child: GestureDetector( behavior: HitTestBehavior.translucent, excludeFromSemantics: true, onLongPress: () { Feedback.forLongPress(context); _showLibraryQuickPicker(context); }, child: const SizedBox.expand(), ), ), ], ); }, ); } /// Report the mobile bottom bar's rendered height to the mini-player inset /// controller after this frame (the bar mixes NavigationBar, optional /// offline banner, and label modes — measuring beats re-deriving). void _scheduleBottomBarMeasure() { final controller = _miniPlayerInsets; if (controller == null) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final box = _bottomBarKey.currentContext?.findRenderObject() as RenderBox?; if (box != null && box.hasSize) controller.setNavBarInset(box.size.height); }); } @override Widget build(BuildContext context) { final useSideNav = PlatformDetector.shouldUseSideNavigation(context); return _buildContent(context, useSideNav); } Widget _buildContent(BuildContext context, bool useSideNav) { if (useSideNav) { return SettingValueBuilder( pref: SettingsService.alwaysKeepSidebarOpen, builder: (context, alwaysExpanded, _) { final targetContentOffset = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded); final reservedContentOffset = alwaysExpanded ? SideNavigationRailState.expandedWidth : SideNavigationRailState.collapsedWidthForContext(context); return OverlaySheetHost( onOpenChanged: _handleOverlaySheetOpenChanged, // canPop:false blocks the system route-pop (matching the old inert // PopScope). The dpad/key back chain (content → top tabs → sidebar → // home) is owned entirely by the key path below; there is NO // onSystemBack because a pure popRoute must not short-circuit that // chain to home. The host still closes an open sheet on system back. canPop: false, child: Focus( onKeyEvent: (node, event) { final rootEscapeResult = _handleMacOsRootEscape(event); if (rootEscapeResult == KeyEventResult.handled) return rootEscapeResult; final fullscreenResult = _handleFullscreenShortcut(event); if (fullscreenResult == KeyEventResult.handled) return fullscreenResult; final searchResult = _handleSearchShortcut(event); if (searchResult == KeyEventResult.handled) return searchResult; return _handleBackKey(event); }, child: TweenAnimationBuilder( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, tween: Tween(end: targetContentOffset), child: FocusScope( node: _contentFocusScope, // No autofocus - we control focus programmatically to prevent // autofocus from stealing focus back after setState() rebuilds child: _buildTickerAwareStack(), ), builder: (context, contentLeftPadding, contentChild) { return LayoutBuilder( builder: (context, constraints) { final viewportWidth = constraints.maxWidth; // Layout from the tween END value: deriving it from the // animated value changed MainScreenFocusScope every tick // of the sidebar expansion, rebuilding every dependent // (the whole TV content tree) per frame. The slide is a // paint-only translate on the content below instead. final contentLayout = mainScreenSideNavigationContentLayout( viewportWidth: viewportWidth, currentSideNavigationWidth: targetContentOffset, reservedSideNavigationWidth: reservedContentOffset, ); return MainScreenFocusScope( focusSidebar: _focusSidebar, focusContent: _focusContent, isSidebarFocused: _isSidebarFocused, sideNavigationWidth: targetContentOffset, reservedSideNavigationWidth: reservedContentOffset, foregroundLeft: contentLayout.left, foregroundWidth: contentLayout.width, viewportWidth: viewportWidth, selectLibrary: _selectLibrary, openSettings: _openSettings, child: SideNavigationScope( child: Stack( clipBehavior: Clip.hardEdge, children: [ Positioned.fill(child: ColoredBox(color: Theme.of(context).scaffoldBackgroundColor)), Positioned( top: 0, bottom: 0, left: contentLayout.left, width: contentLayout.width, // Duration/curve of this tween must stay in // sync with SideNavigationBleedBuilder, which // counter-animates viewport-pinned overlays. child: Transform.translate( offset: Offset(contentLeftPadding - targetContentOffset, 0), child: contentChild!, ), ), Positioned( top: 0, bottom: 0, left: 0, child: FocusScope( node: _sidebarFocusScope, child: SideNavigationRail( key: _sideNavKey, selectedTab: _currentTab, selectedLibraryKey: _selectedLibraryGlobalKey, isOfflineMode: _isOffline, isSidebarFocused: _isSidebarFocused, alwaysExpanded: alwaysExpanded, isReconnecting: _isReconnecting, onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged, onDestinationSelected: (tab) { final restorePreviousFocus = tab == _currentTab; _selectTab(tab); _focusContent(restorePreviousFocus: restorePreviousFocus); }, onLibrarySelected: (key) { _selectLibrary(key); _focusContent(restorePreviousFocus: false); }, onNavigateToContent: _focusContent, onReconnect: _triggerReconnect, ), ), ), ], ), ), ); }, ); }, ), ), ); }, ); } return OverlaySheetHost( onOpenChanged: _handleOverlaySheetOpenChanged, // Host owns sheet + system back; onSystemBack mirrors the old PopScope // (go to home tab, then press-back-twice to exit). canPop: false, onSystemBack: () { if (BackKeyCoordinator.consumeIfHandled()) return; _handleMainBack(); }, child: ScaffoldMessenger( key: ProfileNavigationScope.of(context).mainScaffoldMessengerKey, child: Scaffold( body: _buildTickerAwareStack(), bottomNavigationBar: Column( key: _bottomBarKey, mainAxisSize: .min, children: [ // Reconnect bar when offline if (_isOffline) Material( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: InkWell( onTap: _isReconnecting ? null : _triggerReconnect, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( mainAxisAlignment: .center, children: [ if (_isReconnecting) SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Theme.of(context).colorScheme.primary, ), ) else AppIcon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary), const SizedBox(width: 8), Text( t.common.reconnect, style: TextStyle( fontSize: 14, fontWeight: .w500, color: Theme.of(context).colorScheme.primary, ), ), ], ), ), ), ), SettingValueBuilder( pref: SettingsService.showNavBarLabels, builder: (context, showNavBarLabels, _) { final hideLabels = !showNavBarLabels; // Re-measure whenever the bar's composition can change: // this builder reruns on label toggles AND on every // MainScreen rebuild (offline bar appearing/disappearing). _scheduleBottomBarMeasure(); return NavigationBarTheme( data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null), child: _buildBottomNavigationBar(context, hideLabels: hideLabels), ); }, ), ], ), ), ), ); } }