refactor: extract shared mixins and helpers

This commit is contained in:
edde746
2026-02-04 11:11:01 +01:00
parent 16884a7bca
commit a7ac681fdc
12 changed files with 313 additions and 337 deletions
+64 -116
View File
@@ -8,7 +8,7 @@ import '../../focus/dpad_navigator.dart';
import '../../focus/focus_theme.dart';
import '../../focus/input_mode_tracker.dart';
import '../../focus/key_event_utils.dart';
import '../../services/gamepad_service.dart';
import '../../mixins/tab_navigation_mixin.dart';
import '../../../services/plex_client.dart';
import '../../models/plex_library.dart';
import '../../models/plex_metadata.dart';
@@ -23,7 +23,6 @@ import '../../utils/snackbar_helper.dart';
import '../../utils/content_utils.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../widgets/focusable_tab_chip.dart';
import '../main_screen.dart';
import '../../services/storage_service.dart';
import '../../mixins/refreshable.dart';
import '../../mixins/item_updatable.dart';
@@ -66,7 +65,14 @@ class LibrariesScreen extends StatefulWidget {
}
class _LibrariesScreenState extends State<LibrariesScreen>
with Refreshable, FullRefreshable, FocusableTab, LibraryLoadable, ItemUpdatable, SingleTickerProviderStateMixin {
with
Refreshable,
FullRefreshable,
FocusableTab,
LibraryLoadable,
ItemUpdatable,
SingleTickerProviderStateMixin,
TabNavigationMixin {
@override
PlexClient get client {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
@@ -76,8 +82,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return context.getClientForServer(multiServerProvider.onlineServerIds.first);
}
late TabController _tabController;
// GlobalKeys for tabs to enable refresh
final _recommendedTabKey = GlobalKey<State<LibraryRecommendedTab>>();
final _browseTabKey = GlobalKey<State<LibraryBrowseTab>>();
@@ -88,9 +92,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
String? _selectedLibraryGlobalKey;
bool _isInitialLoad = true;
/// When true, suppress auto-focus in tabs (used when navigating via tab bar)
bool _suppressAutoFocus = false;
Map<String, String> _selectedFilters = {};
PlexSort? _selectedSort;
bool _isSortDescending = false;
@@ -101,7 +102,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
int _requestId = 0;
static const int _pageSize = 1000;
/// Flag to prevent _onTabChanged from focusing when we're programmatically changing tabs
/// Flag to prevent onTabChanged from focusing when we're programmatically changing tabs
bool _isRestoringTab = false;
/// Track which tabs have loaded data (used to trigger focus after tab restore)
@@ -116,6 +117,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections');
final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists');
@override
List<FocusNode> get tabChipFocusNodes => [
_recommendedTabChipFocusNode,
_browseTabChipFocusNode,
_collectionsTabChipFocusNode,
_playlistsTabChipFocusNode,
];
// App bar action button focus
late FocusNode _editButtonFocusNode;
late FocusNode _refreshButtonFocusNode;
@@ -128,8 +137,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_tabController.addListener(_onTabChanged);
initTabNavigation();
// Initialize action button focus nodes
_editButtonFocusNode = FocusNode(debugLabel: 'EditButton');
@@ -141,10 +149,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeWithLibraries();
});
// Register L1/R1 callbacks for tab navigation
GamepadService.onL1Pressed = _goToPreviousTab;
GamepadService.onR1Pressed = _goToNextTab;
}
/// Initialize the screen with libraries from the provider.
@@ -191,44 +195,25 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
}
void _goToPreviousTab() {
if (_tabController.index > 0) {
setState(() {
_suppressAutoFocus = true;
_tabController.index = _tabController.index - 1;
});
_getTabChipFocusNode(_tabController.index).requestFocus();
}
}
void _goToNextTab() {
if (_tabController.index < _tabController.length - 1) {
setState(() {
_suppressAutoFocus = true;
_tabController.index = _tabController.index + 1;
});
_getTabChipFocusNode(_tabController.index).requestFocus();
}
}
void _onTabChanged() {
@override
void onTabChanged() {
// Save tab index when changed (but not when restoring from storage)
if (_selectedLibraryGlobalKey != null && !_tabController.indexIsChanging) {
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
// Only save if this was a user-initiated tab change, not a restore
if (!_isRestoringTab) {
StorageService.getInstance().then((storage) {
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _tabController.index);
storage.saveLibraryTab(_selectedLibraryGlobalKey!, tabController.index);
});
// Focus first item in the current tab (only for user-initiated changes)
// But not when navigating via tab bar (suppressAutoFocus is true)
if (!_suppressAutoFocus) {
if (!suppressAutoFocus) {
_focusCurrentTab();
}
}
}
// Rebuild to update chip selection state
setState(() {});
super.onTabChanged();
}
/// Focus the first item in the currently active tab.
@@ -236,22 +221,22 @@ class _LibrariesScreenState extends State<LibrariesScreen>
void _focusCurrentTab() {
// Don't focus during tab animations - wait for animation to complete
// This prevents race conditions during focus restoration
if (_tabController.indexIsChanging) {
if (tabController.indexIsChanging) {
return;
}
// Re-enable auto-focus since user is navigating into tab content
// Only call setState if the value actually changes to avoid unnecessary rebuilds
if (_suppressAutoFocus) {
if (suppressAutoFocus) {
setState(() {
_suppressAutoFocus = false;
suppressAutoFocus = false;
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final tabState = _getTabState(_tabController.index);
final tabState = _getTabState(tabController.index);
if (tabState != null) {
(tabState as dynamic).focusFirstItem();
} else {
@@ -266,7 +251,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Focus without additional frame delay (used for retry)
void _focusCurrentTabImmediate() {
final tabState = _getTabState(_tabController.index);
final tabState = _getTabState(tabController.index);
if (tabState != null) {
(tabState as dynamic).focusFirstItem();
}
@@ -276,13 +261,13 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// For browse tab, this focuses the chips bar first so DOWN navigates to grid.
/// For other tabs, focuses the first item directly.
void _focusCurrentTabFromTabBar() {
if (_tabController.indexIsChanging) {
if (tabController.indexIsChanging) {
return;
}
if (_suppressAutoFocus) {
if (suppressAutoFocus) {
setState(() {
_suppressAutoFocus = false;
suppressAutoFocus = false;
});
}
@@ -294,10 +279,10 @@ class _LibrariesScreenState extends State<LibrariesScreen>
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final tabState = _getTabState(_tabController.index);
final tabState = _getTabState(tabController.index);
if (tabState != null) {
// Browse tab has a chips bar - focus that first so DOWN navigates to grid
if (_tabController.index == 1) {
if (tabController.index == 1) {
(tabState as dynamic).focusChipsBar();
} else {
(tabState as dynamic).focusFirstItem();
@@ -328,13 +313,13 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_loadedTabs.add(tabIndex);
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (_suppressAutoFocus) return;
if (suppressAutoFocus) return;
// Only focus if this is the currently active tab
if (_tabController.index == tabIndex && mounted) {
if (tabController.index == tabIndex && mounted) {
// Use post-frame callback to ensure the widget tree is fully built
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _tabController.index == tabIndex && !_suppressAutoFocus) {
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
_focusCurrentTab();
}
});
@@ -351,38 +336,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_focusCurrentTab();
}
/// Focus the currently selected tab chip in the tab bar.
/// Called when BACK is pressed in tab content.
void focusTabBar() {
setState(() {
_suppressAutoFocus = true;
});
final focusNode = _getTabChipFocusNode(_tabController.index);
focusNode.requestFocus();
}
/// Get the focus node for a tab chip by index
FocusNode _getTabChipFocusNode(int index) {
switch (index) {
case 0:
return _recommendedTabChipFocusNode;
case 1:
return _browseTabChipFocusNode;
case 2:
return _collectionsTabChipFocusNode;
case 3:
return _playlistsTabChipFocusNode;
default:
return _recommendedTabChipFocusNode;
}
}
/// Handle BACK from tab bar - navigate to sidenav
void _onTabBarBack() {
final focusScope = MainScreenFocusScope.of(context);
focusScope?.focusSidebar();
}
void _onEditFocusChange() {
if (mounted) {
setState(() => _isEditFocused = _editButtonFocusNode.hasFocus);
@@ -402,7 +355,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (key.isLeftKey) {
// Navigate back to last tab (Playlists)
_getTabChipFocusNode(3).requestFocus();
getTabChipFocusNode(3).requestFocus();
return KeyEventResult.handled;
}
if (key.isRightKey) {
@@ -434,7 +387,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (librariesProvider.libraries.isNotEmpty) {
_editButtonFocusNode.requestFocus();
} else {
_getTabChipFocusNode(3).requestFocus();
getTabChipFocusNode(3).requestFocus();
}
return KeyEventResult.handled;
}
@@ -454,8 +407,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
_cancelToken?.cancel();
_outerScrollController.dispose();
_recommendedTabChipFocusNode.dispose();
@@ -466,9 +417,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
_editButtonFocusNode.dispose();
_refreshButtonFocusNode.removeListener(_onRefreshFocusChange);
_refreshButtonFocusNode.dispose();
// Clear L1/R1 callbacks
GamepadService.onL1Pressed = null;
GamepadService.onR1Pressed = null;
disposeTabNavigation();
super.dispose();
}
@@ -550,7 +499,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Set flag to prevent _onTabChanged from triggering focus
_isRestoringTab = true;
// Use animateTo with zero duration for instant switch without animation race conditions
_tabController.animateTo(savedTabIndex, duration: Duration.zero);
tabController.animateTo(savedTabIndex, duration: Duration.zero);
// Clear flag synchronously - animateTo with zero duration completes immediately
_isRestoringTab = false;
}
@@ -559,7 +508,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// However, on first load the tab might finish loading before the tab index
// is restored. Check if the current tab has already loaded and focus if so.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _selectedLibraryGlobalKey == libraryGlobalKey && _loadedTabs.contains(_tabController.index)) {
if (mounted && _selectedLibraryGlobalKey == libraryGlobalKey && _loadedTabs.contains(tabController.index)) {
_focusCurrentTab();
}
});
@@ -711,7 +660,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
// Refresh the currently active tab
void _refreshCurrentTab() {
switch (_tabController.index) {
switch (tabController.index) {
case 0: // Recommended tab
final refreshable = _recommendedTabKey.currentState;
if (refreshable is Refreshable) {
@@ -1004,13 +953,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
Widget _buildTabChip(String label, int index) {
final isSelected = _tabController.index == index;
const tabCount = 4; // Recommended, Browse, Collections, Playlists
final isSelected = tabController.index == index;
return FocusableTabChip(
label: label,
isSelected: isSelected,
focusNode: _getTabChipFocusNode(index),
focusNode: getTabChipFocusNode(index),
onSelect: () {
if (isSelected) {
// Already selected - navigate to tab content
@@ -1018,7 +966,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
} else {
// Switch to this tab
setState(() {
_tabController.index = index;
tabController.index = index;
});
}
},
@@ -1026,20 +974,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
? () {
final newIndex = index - 1;
setState(() {
_suppressAutoFocus = true;
_tabController.index = newIndex;
suppressAutoFocus = true;
tabController.index = newIndex;
});
_getTabChipFocusNode(newIndex).requestFocus();
getTabChipFocusNode(newIndex).requestFocus();
}
: _onTabBarBack,
: onTabBarBack,
onNavigateRight: index < tabCount - 1
? () {
final newIndex = index + 1;
setState(() {
_suppressAutoFocus = true;
_tabController.index = newIndex;
suppressAutoFocus = true;
tabController.index = newIndex;
});
_getTabChipFocusNode(newIndex).requestFocus();
getTabChipFocusNode(newIndex).requestFocus();
}
: () {
// Navigate to first action button (edit if libraries exist, else refresh)
@@ -1051,7 +999,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
}
},
onNavigateDown: _focusCurrentTabFromTabBar,
onBack: _onTabBarBack,
onBack: onTabBarBack,
);
}
@@ -1231,7 +1179,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
SliverFillRemaining(
child: TabBarView(
key: ValueKey(_selectedLibraryGlobalKey),
controller: _tabController,
controller: tabController,
// Disable swipe on desktop - trackpad scrolling triggers accidental tab switches
// See: https://github.com/flutter/flutter/issues/11132
physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null,
@@ -1239,32 +1187,32 @@ class _LibrariesScreenState extends State<LibrariesScreen>
LibraryRecommendedTab(
key: _recommendedTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: _tabController.index == 0,
suppressAutoFocus: _suppressAutoFocus,
isActive: tabController.index == 0,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(0),
onBack: focusTabBar,
),
LibraryBrowseTab(
key: _browseTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: _tabController.index == 1,
suppressAutoFocus: _suppressAutoFocus,
isActive: tabController.index == 1,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(1),
onBack: focusTabBar,
),
LibraryCollectionsTab(
key: _collectionsTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: _tabController.index == 2,
suppressAutoFocus: _suppressAutoFocus,
isActive: tabController.index == 2,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(2),
onBack: focusTabBar,
),
LibraryPlaylistsTab(
key: _playlistsTabKey,
library: allLibraries.firstWhere((lib) => lib.globalKey == _selectedLibraryGlobalKey),
isActive: _tabController.index == 3,
suppressAutoFocus: _suppressAutoFocus,
isActive: tabController.index == 3,
suppressAutoFocus: suppressAutoFocus,
onDataLoaded: () => _handleTabDataLoaded(3),
onBack: focusTabBar,
),