refactor: replace numeric tab indexes with NavigationTabId
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user