refactor: replace numeric tab indexes with NavigationTabId

This commit is contained in:
edde746
2026-03-15 22:31:54 +01:00
parent af0de5c0a9
commit db6161d84e
6 changed files with 208 additions and 324 deletions
+40
View File
@@ -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<T extends StatefulWidget> on State<T>, 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,
);
}
}
-4
View File
@@ -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)
+14 -48
View File
@@ -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<LibrariesScreen>
}).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<PlexLibrary> visibleLibraries) {
// No libraries or no selection
@@ -827,7 +781,13 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
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(),
),
],
],
),
+39 -56
View File
@@ -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<LiveTvScreen>
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<LiveTvScreen>
}
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<LiveTvScreen>
// 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<LiveTvScreen>
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<LiveTvScreen>
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(),
),
],
],
),
),
+82 -129
View File
@@ -84,12 +84,20 @@ class MainScreen extends StatefulWidget {
}
class _MainScreenState extends State<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> with RouteAware, WindowListener
}
List<Widget> _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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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<MainScreen> 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,
+33 -87
View File
@@ -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<int> onDestinationSelected;
final ValueChanged<NavigationTabId> onDestinationSelected;
final ValueChanged<String> 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<SideNavigationRail> {
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<SideNavigationRail> {
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<SideNavigationRail> {
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<SideNavigationRail> {
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<MultiServerProvider>().hasLiveTv,
),
isSelected: widget.selectedTab == NavigationTabId.search,
isFocused: _focusTracker.isFocused(_kSearch),
onTap: () => widget.onDestinationSelected(
NavigationTab.indexFor(
NavigationTabId.search,
isOffline: widget.isOfflineMode,
hasLiveTv: context.read<MultiServerProvider>().hasLiveTv,
),
),
onTap: () => widget.onDestinationSelected(NavigationTabId.search),
focusNode: _focusTracker.get(_kSearch),
isCollapsed: isCollapsed,
),
@@ -495,61 +473,29 @@ class SideNavigationRailState extends State<SideNavigationRail> {
],
// Downloads
Builder(
builder: (context) {
final hasLiveTv = context.read<MultiServerProvider>().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<MultiServerProvider>().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<SideNavigationRail> {
Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t, {bool isCollapsed = false}) {
final librariesProvider = context.watch<LibrariesProvider>();
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<SideNavigationRail> {
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<SideNavigationRail> {
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<SideNavigationRail> {
}
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);