diff --git a/lib/mixins/tab_navigation_mixin.dart b/lib/mixins/tab_navigation_mixin.dart index abcff056..57e52aae 100644 --- a/lib/mixins/tab_navigation_mixin.dart +++ b/lib/mixins/tab_navigation_mixin.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../services/gamepad_service.dart'; import '../screens/main_screen.dart'; +import '../widgets/focusable_tab_chip.dart'; /// Mixin that provides common tab navigation infrastructure. /// @@ -83,4 +84,43 @@ mixin TabNavigationMixin on State, TickerProviderSt void onTabBarBack() { MainScreenFocusScope.of(context)?.focusSidebar(); } + + /// Shared tab chip builder — eliminates duplication between screens. + Widget buildTabChip( + String label, + int index, { + required VoidCallback onSelectWhenActive, + required VoidCallback onNavigateDown, + VoidCallback? onNavigateRightFromLast, + }) { + final isSelected = tabController.index == index; + return FocusableTabChip( + label: label, + isSelected: isSelected, + focusNode: getTabChipFocusNode(index), + onSelect: () { + if (isSelected) { + onSelectWhenActive(); + } else { + setState(() { tabController.index = index; }); + } + }, + onNavigateLeft: index > 0 + ? () { + final newIndex = index - 1; + setState(() { suppressAutoFocus = true; tabController.index = newIndex; }); + getTabChipFocusNode(newIndex).requestFocus(); + } + : onTabBarBack, + onNavigateRight: index < tabCount - 1 + ? () { + final newIndex = index + 1; + setState(() { suppressAutoFocus = true; tabController.index = newIndex; }); + getTabChipFocusNode(newIndex).requestFocus(); + } + : onNavigateRightFromLast, + onNavigateDown: onNavigateDown, + onBack: onTabBarBack, + ); + } } diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart index 4ea37b93..a631187e 100644 --- a/lib/navigation/navigation_tabs.dart +++ b/lib/navigation/navigation_tabs.dart @@ -35,10 +35,6 @@ class NavigationTab { }).toList(); } - /// Check if a visual index corresponds to a specific tab ID - static bool isTabAtIndex(NavigationTabId id, int index, {required bool isOffline, bool hasLiveTv = false}) { - return indexFor(id, isOffline: isOffline, hasLiveTv: hasLiveTv) == index; - } } // Label getters (must be top-level for const constructor) diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 5f8cbfdf..84b3c85b 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -23,7 +23,6 @@ import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/content_utils.dart'; import '../../widgets/desktop_app_bar.dart'; -import '../../widgets/focusable_tab_chip.dart'; import '../../widgets/overlay_sheet.dart'; import '../../services/storage_service.dart'; import '../../mixins/refreshable.dart'; @@ -768,51 +767,6 @@ class _LibrariesScreenState extends State }).toList(); } - Widget _buildTabChip(String label, int index) { - final isSelected = tabController.index == index; - - return FocusableTabChip( - label: label, - isSelected: isSelected, - focusNode: getTabChipFocusNode(index), - onSelect: () { - if (isSelected) { - // Already selected - navigate to tab content - _focusCurrentTab(); - } else { - // Switch to this tab - setState(() { - tabController.index = index; - }); - } - }, - onNavigateLeft: index > 0 - ? () { - final newIndex = index - 1; - setState(() { - suppressAutoFocus = true; - tabController.index = newIndex; - }); - getTabChipFocusNode(newIndex).requestFocus(); - } - : onTabBarBack, - onNavigateRight: index < tabCount - 1 - ? () { - final newIndex = index + 1; - setState(() { - suppressAutoFocus = true; - tabController.index = newIndex; - }); - getTabChipFocusNode(newIndex).requestFocus(); - } - : () { - _actionBarKey.currentState?.getFocusNode(0).requestFocus(); - }, - onNavigateDown: _focusCurrentTabFromTabBar, - onBack: onTabBarBack, - ); - } - /// Build the app bar title - either dropdown on mobile or simple title on desktop Widget _buildAppBarTitle(List visibleLibraries) { // No libraries or no selection @@ -827,7 +781,13 @@ class _LibrariesScreenState extends State children: [ for (int i = 0; i < _visibleTabs.length; i++) ...[ if (i > 0) const SizedBox(width: 8), - _buildTabChip(_getTabLabel(_visibleTabs[i]), i), + buildTabChip( + _getTabLabel(_visibleTabs[i]), + i, + onSelectWhenActive: _focusCurrentTab, + onNavigateDown: _focusCurrentTabFromTabBar, + onNavigateRightFromLast: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), + ), ], ], ); @@ -959,7 +919,13 @@ class _LibrariesScreenState extends State children: [ for (int i = 0; i < _visibleTabs.length; i++) ...[ if (i > 0) const SizedBox(width: 8), - _buildTabChip(_getTabLabel(_visibleTabs[i]), i), + buildTabChip( + _getTabLabel(_visibleTabs[i]), + i, + onSelectWhenActive: _focusCurrentTab, + onNavigateDown: _focusCurrentTabFromTabBar, + onNavigateRightFromLast: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), + ), ], ], ), diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index eaf6a83c..bdc12da4 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -14,10 +14,11 @@ import '../../utils/app_logger.dart'; import '../../utils/desktop_window_padding.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; -import '../../widgets/focusable_tab_chip.dart'; import 'tabs/guide_tab.dart'; import 'tabs/whats_on_tab.dart'; +enum LiveTvTab { guide, whatsOn } + class LiveTvScreen extends StatefulWidget { const LiveTvScreen({super.key}); @@ -78,12 +79,13 @@ class _LiveTvScreenState extends State if (!tabController.indexIsChanging) { super.onTabChanged(); // Pause/resume timers based on active tab - if (tabController.index == 0) { - _whatsOnTabKey.currentState?.pauseRefresh(); - _guideTabKey.currentState?.resumeRefresh(); - } else { - _guideTabKey.currentState?.pauseRefresh(); - _whatsOnTabKey.currentState?.resumeRefresh(); + switch (LiveTvTab.values[tabController.index]) { + case LiveTvTab.guide: + _whatsOnTabKey.currentState?.pauseRefresh(); + _guideTabKey.currentState?.resumeRefresh(); + case LiveTvTab.whatsOn: + _guideTabKey.currentState?.pauseRefresh(); + _whatsOnTabKey.currentState?.resumeRefresh(); } } } @@ -255,10 +257,11 @@ class _LiveTvScreenState extends State } void _focusCurrentTab() { - if (tabController.index == 0) { - _guideTabKey.currentState?.focusContent(); - } else if (tabController.index == 1) { - _whatsOnTabKey.currentState?.focusFirstHub(); + switch (LiveTvTab.values[tabController.index]) { + case LiveTvTab.guide: + _guideTabKey.currentState?.focusContent(); + case LiveTvTab.whatsOn: + _whatsOnTabKey.currentState?.focusFirstHub(); } setState(() { suppressAutoFocus = false; @@ -273,45 +276,11 @@ class _LiveTvScreenState extends State // Tab chips // --------------------------------------------------------------------------- - Widget _buildTabChip(String label, int index) { - final isSelected = tabController.index == index; - - return FocusableTabChip( - label: label, - isSelected: isSelected, - focusNode: getTabChipFocusNode(index), - onSelect: () { - if (isSelected) { - _focusCurrentTab(); - } else { - setState(() { - tabController.index = index; - }); - } - }, - onNavigateLeft: index > 0 - ? () { - final newIndex = index - 1; - setState(() { - suppressAutoFocus = true; - tabController.index = newIndex; - }); - getTabChipFocusNode(newIndex).requestFocus(); - } - : onTabBarBack, - onNavigateRight: index < tabCount - 1 - ? () { - final newIndex = index + 1; - setState(() { - suppressAutoFocus = true; - tabController.index = newIndex; - }); - getTabChipFocusNode(newIndex).requestFocus(); - } - : () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), - onNavigateDown: _focusCurrentTab, - onBack: onTabBarBack, - ); + String _getTabLabel(LiveTvTab tab) { + return switch (tab) { + LiveTvTab.guide => t.liveTv.guide, + LiveTvTab.whatsOn => t.liveTv.whatsOn, + }; } // --------------------------------------------------------------------------- @@ -328,9 +297,16 @@ class _LiveTvScreenState extends State title: useSideNav ? Row( children: [ - _buildTabChip(t.liveTv.guide, 0), - const SizedBox(width: 8), - _buildTabChip(t.liveTv.whatsOn, 1), + for (int i = 0; i < LiveTvTab.values.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + buildTabChip( + _getTabLabel(LiveTvTab.values[i]), + i, + onSelectWhenActive: _focusCurrentTab, + onNavigateDown: _focusCurrentTab, + onNavigateRightFromLast: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), + ), + ], ], ) : Text(t.liveTv.title), @@ -397,9 +373,16 @@ class _LiveTvScreenState extends State scrollDirection: Axis.horizontal, child: Row( children: [ - _buildTabChip(t.liveTv.guide, 0), - const SizedBox(width: 8), - _buildTabChip(t.liveTv.whatsOn, 1), + for (int i = 0; i < LiveTvTab.values.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + buildTabChip( + _getTabLabel(LiveTvTab.values[i]), + i, + onSelectWhenActive: _focusCurrentTab, + onNavigateDown: _focusCurrentTab, + onNavigateRightFromLast: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), + ), + ], ], ), ), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index d0bb6b2a..199f83f1 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -84,12 +84,20 @@ class MainScreen extends StatefulWidget { } class _MainScreenState extends State with RouteAware, WindowListener, WidgetsBindingObserver { - late int _currentIndex; + 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; @@ -132,10 +140,7 @@ class _MainScreenState extends State with RouteAware, WindowListener windowManager.setPreventClose(true); } - // Start on Downloads tab when in offline mode - // In offline mode: visual index 0 = Downloads (screen 3), 1 = Settings (screen 4) - // In online mode: indices match directly - _currentIndex = 0; + _currentTab = _isOffline ? NavigationTabId.downloads : NavigationTabId.discover; _lastOnlineTabId = _isOffline ? null : NavigationTabId.discover; _autoSwitchedToDownloads = _isOffline; @@ -434,48 +439,29 @@ class _MainScreenState extends State with RouteAware, WindowListener final receiver = CompanionRemoteReceiver.instance; receiver.onTabNext = () { - final tabCount = _getVisibleTabs(_isOffline).length; - _selectTab((_currentIndex + 1) % tabCount); + 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 tabCount = _getVisibleTabs(_isOffline).length; - _selectTab((_currentIndex - 1 + tabCount) % tabCount); - }; - receiver.onTabDiscover = () { - final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - if (idx >= 0) _selectTab(idx); - }; - receiver.onTabLibraries = () { - final idx = NavigationTab.indexFor(NavigationTabId.libraries, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - if (idx >= 0) _selectTab(idx); - }; - receiver.onTabSearch = () { - final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - if (idx >= 0) _selectTab(idx); - }; - receiver.onTabDownloads = () { - final idx = NavigationTab.indexFor(NavigationTabId.downloads, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - if (idx >= 0) _selectTab(idx); - }; - receiver.onTabSettings = () { - final idx = NavigationTab.indexFor(NavigationTabId.settings, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - if (idx >= 0) _selectTab(idx); - }; - receiver.onHome = () { - final idx = NavigationTab.indexFor(NavigationTabId.discover, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - if (idx >= 0) _selectTab(idx); + 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 = () => _selectTab(NavigationTabId.discover); receiver.onSearchAction = (query) { - final idx = NavigationTab.indexFor(NavigationTabId.search, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - if (idx >= 0) { - _selectTab(idx); - if (query != null && query.isNotEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.setSearchQuery(query); - } - }); - } + _selectTab(NavigationTabId.search); + if (query != null && query.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_searchKey.currentState case final SearchInputFocusable searchable) { + searchable.setSearchQuery(query); + } + }); } }; } @@ -543,41 +529,25 @@ class _MainScreenState extends State with RouteAware, WindowListener } List _buildScreens(bool offline) { - // In offline mode, only show Downloads and Settings - if (offline) { - return [DownloadsScreen(key: _downloadsKey), SettingsScreen(key: _settingsKey)]; - } - - // Use _lastHasLiveTv (the value synchronized with _handleLiveTvChanged) - // so screens and nav bar always agree on whether LiveTV is included. - final hasLiveTv = _lastHasLiveTv; - return [ - DiscoverScreen(key: _discoverKey, onBecameVisible: _onDiscoverBecameVisible), - LibrariesScreen(key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged), - if (hasLiveTv) LiveTvScreen(key: _liveTvKey), - SearchScreen(key: _searchKey), - DownloadsScreen(key: _downloadsKey), - SettingsScreen(key: _settingsKey), + for (final tab in _getVisibleTabs(offline)) + switch (tab.id) { + NavigationTabId.discover => DiscoverScreen(key: _discoverKey, onBecameVisible: _onDiscoverBecameVisible), + NavigationTabId.libraries => LibrariesScreen(key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged), + NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey), + NavigationTabId.search => SearchScreen(key: _searchKey), + NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey), + NavigationTabId.settings => SettingsScreen(key: _settingsKey), + }, ]; } - /// Normalize tab index when switching between offline/online modes. + /// 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. - int _normalizeIndexForMode(int currentIndex, bool wasOffline, bool isOffline) { - if (wasOffline == isOffline) return currentIndex; - - final oldTabs = _getVisibleTabs(wasOffline); - final newTabs = _getVisibleTabs(isOffline); - - // Get the tab ID at the current index (or first tab if out of bounds) - final currentTabId = currentIndex >= 0 && currentIndex < oldTabs.length - ? oldTabs[currentIndex].id - : oldTabs.first.id; - - // Find the same tab in the new mode's tab list - final newIndex = newTabs.indexWhere((tab) => tab.id == currentTabId); - return newIndex >= 0 ? newIndex : 0; + NavigationTabId _normalizeTabForMode(NavigationTabId currentTab, bool isOffline) { + final tabs = _getVisibleTabs(isOffline); + if (tabs.any((t) => t.id == currentTab)) return currentTab; + return tabs.first.id; } void _triggerReconnect() { @@ -600,11 +570,8 @@ class _MainScreenState extends State with RouteAware, WindowListener _lastHasLiveTv = hasLiveTv; setState(() { - final currentTabId = _tabIdForIndex(_isOffline, _currentIndex); _screens = _buildScreens(_isOffline); - // Restore the correct tab index after rebuilding - final newIndex = NavigationTab.indexFor(currentTabId, isOffline: _isOffline, hasLiveTv: hasLiveTv); - _currentIndex = newIndex >= 0 ? newIndex : 0; + _currentTab = _normalizeTabForMode(_currentTab, _isOffline); }); } @@ -613,7 +580,7 @@ class _MainScreenState extends State with RouteAware, WindowListener if (newOffline == _isOffline) return; - final previousTabId = _tabIdForIndex(_isOffline, _currentIndex); + final previousTab = _currentTab; final wasOffline = _isOffline; setState(() { _isReconnecting = false; @@ -624,23 +591,23 @@ class _MainScreenState extends State with RouteAware, WindowListener if (_isOffline) { // Remember the online tab so we can restore it when reconnecting. if (!wasOffline) { - _lastOnlineTabId = previousTabId; + _lastOnlineTabId = previousTab; } - _currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline); + final normalizedTab = _normalizeTabForMode(_currentTab, _isOffline); + _currentTab = normalizedTab; // Track if we auto-switched to Downloads because the previous tab was unavailable. _autoSwitchedToDownloads = - previousTabId != NavigationTabId.downloads && - _tabIdForIndex(true, _currentIndex) == NavigationTabId.downloads; + 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; - final restoredIndex = NavigationTab.indexFor(restoredTab, isOffline: _isOffline, hasLiveTv: _hasLiveTv); - _currentIndex = restoredIndex >= 0 ? restoredIndex : 0; + _currentTab = _normalizeTabForMode(restoredTab, _isOffline); } else { - _currentIndex = _normalizeIndexForMode(_currentIndex, wasOffline, _isOffline); + _currentTab = _normalizeTabForMode(_currentTab, _isOffline); } _autoSwitchedToDownloads = false; } @@ -679,7 +646,7 @@ class _MainScreenState extends State with RouteAware, WindowListener // This preserves the user's focus position when returning from sidebar. WidgetsBinding.instance.addPostFrameCallback((_) { if (_contentFocusScope.focusedChild == null) { - if (_screenKeyForIndex(_currentIndex)?.currentState case final FocusableTab focusable) { + if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) { focusable.focusActiveTabIfReady(); } } @@ -738,14 +705,7 @@ class _MainScreenState extends State with RouteAware, WindowListener if (!isMacShortcut && !isOtherShortcut) return KeyEventResult.ignored; if (_isOffline) return KeyEventResult.handled; - final searchIndex = NavigationTab.indexFor( - NavigationTabId.search, - isOffline: _isOffline, - hasLiveTv: _hasLiveTv, - ); - if (searchIndex < 0) return KeyEventResult.handled; - - _selectTab(searchIndex); + _selectTab(NavigationTabId.search); if (_isSidebarFocused) _focusContent(); // Schedule focus after the frame so the search screen is visible in the IndexedStack WidgetsBinding.instance.addPostFrameCallback((_) { @@ -759,7 +719,7 @@ class _MainScreenState extends State with RouteAware, WindowListener @override void didPush() { // Called when this route has been pushed (initial navigation) - if (_currentIndex == 0 && !_isOffline) { + if (_currentTab == NavigationTabId.discover) { _onDiscoverBecameVisible(); } } @@ -767,7 +727,7 @@ class _MainScreenState extends State with RouteAware, WindowListener @override void didPushNext() { // Called when a child route is pushed on top (e.g., video player) - if (_currentIndex == 0 && !_isOffline) { + if (_currentTab == NavigationTabId.discover) { if (_discoverKey.currentState case final TabVisibilityAware aware) { aware.onTabHidden(); } @@ -786,7 +746,7 @@ class _MainScreenState extends State with RouteAware, WindowListener }); // Called when returning to this route from a child route (e.g., from video player) - if (_currentIndex == 0 && !_isOffline) { + if (_currentTab == NavigationTabId.discover) { if (_discoverKey.currentState case final TabVisibilityAware aware) { aware.onTabShown(); } @@ -864,25 +824,28 @@ class _MainScreenState extends State with RouteAware, WindowListener // Sidebar automatically updates since it watches LibrariesProvider } - void _selectTab(int index) { - final previousIndex = _currentIndex; + void _selectTab(NavigationTabId tab) { + // Guard: ignore if tab isn't available in current mode + if (!_getVisibleTabs(_isOffline).any((t) => t.id == tab)) return; + + final previousTab = _currentTab; setState(() { - _currentIndex = index; + _currentTab = tab; if (!_isOffline) { - _lastOnlineTabId = _tabIdForIndex(false, index); - } else if (previousIndex != index) { + _lastOnlineTabId = tab; + } else if (previousTab != tab) { // User made an explicit offline selection, so don't auto-restore later. _autoSwitchedToDownloads = false; } }); - if (previousIndex != index) { + if (previousTab != tab) { // Notify previous screen it's being hidden - if (_screenKeyForIndex(previousIndex)?.currentState case final TabVisibilityAware aware) { + if (_screenKeyFor(previousTab)?.currentState case final TabVisibilityAware aware) { aware.onTabHidden(); } // Notify and focus new screen - final newState = _screenKeyForIndex(index)?.currentState; + final newState = _screenKeyFor(tab)?.currentState; if (newState case final TabVisibilityAware aware) { aware.onTabShown(); } @@ -892,21 +855,16 @@ class _MainScreenState extends State with RouteAware, WindowListener } // Discover: always refresh content (even on re-selection) - if (!_isOffline && _tabIdForIndex(_isOffline, index) == NavigationTabId.discover) { + if (!_isOffline && tab == NavigationTabId.discover) { _onDiscoverBecameVisible(); } } /// Handle library selection from side navigation rail void _selectLibrary(String libraryGlobalKey) { - setState(() { - _selectedLibraryGlobalKey = libraryGlobalKey; - _currentIndex = 1; // Switch to Libraries tab - if (!_isOffline) { - _lastOnlineTabId = NavigationTabId.libraries; - } - }); - // Tell LibrariesScreen to load this library + _selectedLibraryGlobalKey = libraryGlobalKey; + _selectTab(NavigationTabId.libraries); + // Tell LibrariesScreen to load this library after tab switch if (_librariesKey.currentState case final LibraryLoadable loadable) { loadable.loadLibraryByKey(libraryGlobalKey); } @@ -925,17 +883,9 @@ class _MainScreenState extends State with RouteAware, WindowListener return NavigationTab.getVisibleTabs(isOffline: isOffline, hasLiveTv: _hasLiveTv); } - /// Get the tab ID for a given index, clamping to the available range. - NavigationTabId _tabIdForIndex(bool isOffline, int index) { - final tabs = _getVisibleTabs(isOffline); - if (tabs.isEmpty) return NavigationTabId.discover; - final safeIndex = index.clamp(0, tabs.length - 1).toInt(); - return tabs[safeIndex].id; - } - - /// Get the GlobalKey for the screen at the given tab index. - GlobalKey? _screenKeyForIndex(int index) { - return switch (_tabIdForIndex(_isOffline, index)) { + /// Get the GlobalKey for a given tab. + GlobalKey? _screenKeyFor(NavigationTabId tab) { + return switch (tab) { NavigationTabId.discover => _discoverKey, NavigationTabId.libraries => _librariesKey, NavigationTabId.liveTv => _liveTvKey, @@ -1008,14 +958,14 @@ class _MainScreenState extends State with RouteAware, WindowListener node: _sidebarFocusScope, child: SideNavigationRail( key: _sideNavKey, - selectedIndex: _currentIndex, + selectedTab: _currentTab, selectedLibraryKey: _selectedLibraryGlobalKey, isOfflineMode: _isOffline, isSidebarFocused: _isSidebarFocused, alwaysExpanded: alwaysExpanded, isReconnecting: _isReconnecting, - onDestinationSelected: (index) { - _selectTab(index); + onDestinationSelected: (tab) { + _selectTab(tab); _focusContent(); }, onLibrarySelected: (key) { @@ -1087,7 +1037,10 @@ class _MainScreenState extends State with RouteAware, WindowListener data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null), child: NavigationBar( selectedIndex: _currentIndex, - onDestinationSelected: _selectTab, + onDestinationSelected: (i) { + final tabs = _getVisibleTabs(_isOffline); + if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id); + }, labelBehavior: hideLabels ? NavigationDestinationLabelBehavior.alwaysHide : NavigationDestinationLabelBehavior.alwaysShow, diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index faf7b68b..b65bd5a9 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -128,13 +128,13 @@ class NavigationRailItem extends StatelessWidget { /// Side navigation rail for Desktop and Android TV platforms class SideNavigationRail extends StatefulWidget { - final int selectedIndex; + final NavigationTabId selectedTab; final String? selectedLibraryKey; final bool isOfflineMode; final bool isSidebarFocused; final bool alwaysExpanded; final bool isReconnecting; - final ValueChanged onDestinationSelected; + final ValueChanged onDestinationSelected; final ValueChanged onLibrarySelected; /// Called when RIGHT arrow is pressed to navigate to content without selecting. @@ -145,7 +145,7 @@ class SideNavigationRail extends StatefulWidget { const SideNavigationRail({ super.key, - required this.selectedIndex, + required this.selectedTab, this.selectedLibraryKey, this.isOfflineMode = false, this.isSidebarFocused = false, @@ -209,7 +209,7 @@ class SideNavigationRailState extends State { void didUpdateWidget(covariant SideNavigationRail oldWidget) { super.didUpdateWidget(oldWidget); // Auto-collapse after navigation (selection changed) - if (oldWidget.selectedIndex != widget.selectedIndex || oldWidget.selectedLibraryKey != widget.selectedLibraryKey) { + if (oldWidget.selectedTab != widget.selectedTab || oldWidget.selectedLibraryKey != widget.selectedLibraryKey) { _isTouchExpanded = false; } } @@ -427,9 +427,9 @@ class SideNavigationRailState extends State { icon: Symbols.home_rounded, selectedIcon: Symbols.home_rounded, label: Translations.of(context).common.home, - isSelected: widget.selectedIndex == 0, + isSelected: widget.selectedTab == NavigationTabId.discover, isFocused: _focusTracker.isFocused(_kHome), - onTap: () => widget.onDestinationSelected(0), + onTap: () => widget.onDestinationSelected(NavigationTabId.discover), focusNode: _focusTracker.get(_kHome), isCollapsed: isCollapsed, ), @@ -447,20 +447,9 @@ class SideNavigationRailState extends State { icon: Symbols.live_tv_rounded, selectedIcon: Symbols.live_tv_rounded, label: Translations.of(context).navigation.liveTv, - isSelected: NavigationTab.isTabAtIndex( - NavigationTabId.liveTv, - widget.selectedIndex, - isOffline: widget.isOfflineMode, - hasLiveTv: true, - ), + isSelected: widget.selectedTab == NavigationTabId.liveTv, isFocused: _focusTracker.isFocused('liveTv'), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor( - NavigationTabId.liveTv, - isOffline: widget.isOfflineMode, - hasLiveTv: true, - ), - ), + onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv), focusNode: _focusTracker.get('liveTv'), isCollapsed: isCollapsed, ), @@ -473,20 +462,9 @@ class SideNavigationRailState extends State { icon: Symbols.search_rounded, selectedIcon: Symbols.search_rounded, label: Translations.of(context).common.search, - isSelected: NavigationTab.isTabAtIndex( - NavigationTabId.search, - widget.selectedIndex, - isOffline: widget.isOfflineMode, - hasLiveTv: context.read().hasLiveTv, - ), + isSelected: widget.selectedTab == NavigationTabId.search, isFocused: _focusTracker.isFocused(_kSearch), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor( - NavigationTabId.search, - isOffline: widget.isOfflineMode, - hasLiveTv: context.read().hasLiveTv, - ), - ), + onTap: () => widget.onDestinationSelected(NavigationTabId.search), focusNode: _focusTracker.get(_kSearch), isCollapsed: isCollapsed, ), @@ -495,61 +473,29 @@ class SideNavigationRailState extends State { ], // Downloads - Builder( - builder: (context) { - final hasLiveTv = context.read().hasLiveTv; - return _buildNavItem( - icon: Symbols.download_rounded, - selectedIcon: Symbols.download_rounded, - label: Translations.of(context).navigation.downloads, - isSelected: NavigationTab.isTabAtIndex( - NavigationTabId.downloads, - widget.selectedIndex, - isOffline: widget.isOfflineMode, - hasLiveTv: hasLiveTv, - ), - isFocused: _focusTracker.isFocused(_kDownloads), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor( - NavigationTabId.downloads, - isOffline: widget.isOfflineMode, - hasLiveTv: hasLiveTv, - ), - ), - focusNode: _focusTracker.get(_kDownloads), - isCollapsed: isCollapsed, - ); - }, + _buildNavItem( + icon: Symbols.download_rounded, + selectedIcon: Symbols.download_rounded, + label: Translations.of(context).navigation.downloads, + isSelected: widget.selectedTab == NavigationTabId.downloads, + isFocused: _focusTracker.isFocused(_kDownloads), + onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), + focusNode: _focusTracker.get(_kDownloads), + isCollapsed: isCollapsed, ), const SizedBox(height: 8), // Settings - Builder( - builder: (context) { - final hasLiveTv = context.read().hasLiveTv; - return _buildNavItem( - icon: Symbols.settings_rounded, - selectedIcon: Symbols.settings_rounded, - label: Translations.of(context).common.settings, - isSelected: NavigationTab.isTabAtIndex( - NavigationTabId.settings, - widget.selectedIndex, - isOffline: widget.isOfflineMode, - hasLiveTv: hasLiveTv, - ), - isFocused: _focusTracker.isFocused(_kSettings), - onTap: () => widget.onDestinationSelected( - NavigationTab.indexFor( - NavigationTabId.settings, - isOffline: widget.isOfflineMode, - hasLiveTv: hasLiveTv, - ), - ), - focusNode: _focusTracker.get(_kSettings), - isCollapsed: isCollapsed, - ); - }, + _buildNavItem( + icon: Symbols.settings_rounded, + selectedIcon: Symbols.settings_rounded, + label: Translations.of(context).common.settings, + isSelected: widget.selectedTab == NavigationTabId.settings, + isFocused: _focusTracker.isFocused(_kSettings), + onTap: () => widget.onDestinationSelected(NavigationTabId.settings), + focusNode: _focusTracker.get(_kSettings), + isCollapsed: isCollapsed, ), ], ), @@ -629,7 +575,7 @@ class SideNavigationRailState extends State { Widget _buildLibrariesSection(List visibleLibraries, dynamic t, {bool isCollapsed = false}) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; - final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null; + final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null; final isLibrariesFocused = _focusTracker.isFocused(_kLibraries); return Column( @@ -687,7 +633,7 @@ class SideNavigationRailState extends State { Symbols.video_library_rounded, fill: 1, size: 22, - color: widget.selectedIndex == 1 ? t.text : t.textMuted, + color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, ), const SizedBox(width: 11), Expanded( @@ -698,8 +644,8 @@ class SideNavigationRailState extends State { Translations.of(context).navigation.libraries, style: TextStyle( fontSize: 14, - fontWeight: widget.selectedIndex == 1 ? FontWeight.w600 : FontWeight.w400, - color: widget.selectedIndex == 1 ? t.text : t.textMuted, + fontWeight: widget.selectedTab == NavigationTabId.libraries ? FontWeight.w600 : FontWeight.w400, + color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, ), ), ), @@ -793,7 +739,7 @@ class SideNavigationRailState extends State { } Widget _buildLibraryItem(PlexLibrary library, dynamic t, {bool showServerName = false}) { - final isSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == library.globalKey; + final isSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == library.globalKey; final isFocused = _focusTracker.isFocused(library.globalKey); final focusNode = _focusTracker.get(library.globalKey);