diff --git a/lib/mixins/grid_focus_node_mixin.dart b/lib/mixins/grid_focus_node_mixin.dart new file mode 100644 index 00000000..249c4dd6 --- /dev/null +++ b/lib/mixins/grid_focus_node_mixin.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +/// Manages a map of grid-item [FocusNode]s with focus-tracking and restoration. +/// +/// Provides: +/// - Lazy creation of per-index focus nodes via [getGridItemFocusNode]. +/// - Focus tracking ([lastFocusedGridIndex], [gridContentVersion]) so callers +/// can restore focus after rebuilds. +/// - [cleanupGridFocusNodes] to prune nodes for indices beyond the current count. +/// - [disposeGridFocusNodes] for full teardown. +mixin GridFocusNodeMixin on State { + final Map gridItemFocusNodes = {}; + int? lastFocusedGridIndex; + int gridContentVersion = 0; + int lastFocusedGridContentVersion = 0; + + /// Get or create a focus node for a grid item at [index]. + FocusNode getGridItemFocusNode(int index, {String prefix = 'grid_item'}) { + return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: '${prefix}_$index')); + } + + /// Record that the item at [index] received focus. + void trackGridItemFocus(int index, bool hasFocus) { + if (hasFocus) { + lastFocusedGridIndex = index; + lastFocusedGridContentVersion = gridContentVersion; + } + } + + /// Whether the last-focused index is still valid for restoration. + bool get shouldRestoreGridFocus => + lastFocusedGridIndex != null && lastFocusedGridContentVersion == gridContentVersion && lastFocusedGridIndex! >= 0; + + /// Remove focus nodes for indices >= [itemCount]. + void cleanupGridFocusNodes(int itemCount) { + final keysToRemove = gridItemFocusNodes.keys.where((i) => i >= itemCount).toList(); + for (final key in keysToRemove) { + gridItemFocusNodes[key]?.dispose(); + gridItemFocusNodes.remove(key); + } + } + + /// Dispose all grid-item focus nodes. + void disposeGridFocusNodes() { + for (final node in gridItemFocusNodes.values) { + node.dispose(); + } + gridItemFocusNodes.clear(); + } +} diff --git a/lib/mixins/tab_navigation_mixin.dart b/lib/mixins/tab_navigation_mixin.dart new file mode 100644 index 00000000..59df4516 --- /dev/null +++ b/lib/mixins/tab_navigation_mixin.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import '../services/gamepad_service.dart'; +import '../screens/main_screen.dart'; + +/// Mixin that provides common tab navigation infrastructure. +/// +/// Handles: +/// - [TabController] creation and disposal +/// - L1/R1 gamepad registration for tab switching +/// - [suppressAutoFocus] flag management +/// - Tab chip focus node lookup +/// - Tab bar back navigation to sidebar +/// +/// Subclasses must provide [tabChipFocusNodes] — one [FocusNode] per tab. +mixin TabNavigationMixin on State, SingleTickerProviderStateMixin { + late TabController tabController; + + /// When true, suppress auto-focus in tabs (used when navigating via tab bar). + bool suppressAutoFocus = false; + + /// Subclasses provide focus nodes as a list indexed by tab position. + List get tabChipFocusNodes; + + /// Number of tabs — derived from [tabChipFocusNodes]. + int get tabCount => tabChipFocusNodes.length; + + /// Initialise the [TabController] and register gamepad callbacks. + /// Call from [initState]. + void initTabNavigation() { + tabController = TabController(length: tabCount, vsync: this); + tabController.addListener(onTabChanged); + GamepadService.onL1Pressed = goToPreviousTab; + GamepadService.onR1Pressed = goToNextTab; + } + + /// Dispose the [TabController] and clear gamepad callbacks. + /// Call from [dispose]. + void disposeTabNavigation() { + tabController.removeListener(onTabChanged); + tabController.dispose(); + GamepadService.onL1Pressed = null; + GamepadService.onR1Pressed = null; + } + + 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(); + } + } + + /// Called when the tab index changes. Override to add custom behaviour + /// (e.g. persisting the tab index), then call `super.onTabChanged()`. + void onTabChanged() { + setState(() {}); + } + + FocusNode getTabChipFocusNode(int index) => tabChipFocusNodes[index]; + + /// Focus the currently selected tab chip. + void focusTabBar() { + setState(() { + suppressAutoFocus = true; + }); + getTabChipFocusNode(tabController.index).requestFocus(); + } + + /// Navigate back from the tab bar to the sidebar. + void onTabBarBack() { + MainScreenFocusScope.of(context)?.focusSidebar(); + } +} diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index fb9d6dce..059eb2ba 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -11,7 +11,7 @@ import '../services/download_storage_service.dart'; import '../services/plex_api_cache.dart'; import '../services/plex_client.dart'; import '../utils/app_logger.dart'; -import '../utils/plex_cache_parser.dart'; +import '../utils/global_key_utils.dart'; /// Holds Plex thumb path reference for downloaded artwork. /// The actual file path is computed from the hash of serverId + thumb path. @@ -98,10 +98,8 @@ class DownloadProvider extends ChangeNotifier { _artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath); // Load metadata from API cache (base endpoint - chapters/markers included in data) - final cached = await apiCache.get(item.serverId, '/library/metadata/${item.ratingKey}'); - final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); - if (firstMetadata != null) { - final metadata = PlexMetadata.fromJson(firstMetadata).copyWith(serverId: item.serverId); + final metadata = await apiCache.getMetadata(item.serverId, item.ratingKey); + if (metadata != null) { _metadata[item.globalKey] = metadata; // For episodes, also load parent (show and season) metadata @@ -166,10 +164,8 @@ class DownloadProvider extends ChangeNotifier { if (showRatingKey != null) { final showGlobalKey = '$serverId:$showRatingKey'; if (!_metadata.containsKey(showGlobalKey)) { - final cached = await apiCache.get(serverId, '/library/metadata/$showRatingKey'); - final showJson = PlexCacheParser.extractFirstMetadata(cached); - if (showJson != null) { - final showMetadata = PlexMetadata.fromJson(showJson).copyWith(serverId: serverId); + final showMetadata = await apiCache.getMetadata(serverId, showRatingKey); + if (showMetadata != null) { _metadata[showGlobalKey] = showMetadata; // Store artwork reference for offline display if (showMetadata.thumb != null) { @@ -184,10 +180,8 @@ class DownloadProvider extends ChangeNotifier { if (seasonRatingKey != null) { final seasonGlobalKey = '$serverId:$seasonRatingKey'; if (!_metadata.containsKey(seasonGlobalKey)) { - final cached = await apiCache.get(serverId, '/library/metadata/$seasonRatingKey'); - final seasonJson = PlexCacheParser.extractFirstMetadata(cached); - if (seasonJson != null) { - final seasonMetadata = PlexMetadata.fromJson(seasonJson).copyWith(serverId: serverId); + final seasonMetadata = await apiCache.getMetadata(serverId, seasonRatingKey); + if (seasonMetadata != null) { _metadata[seasonGlobalKey] = seasonMetadata; // Store artwork reference for offline display if (seasonMetadata.thumb != null) { @@ -498,11 +492,11 @@ class DownloadProvider extends ChangeNotifier { // If no direct progress, check if this is a show or season // and calculate aggregate progress from episodes - final parts = globalKey.split(':'); - if (parts.length != 2) return null; + final parsed = parseGlobalKey(globalKey); + if (parsed == null) return null; - final serverId = parts[0]; - final ratingKey = parts[1]; + final serverId = parsed.serverId; + final ratingKey = parsed.ratingKey; // Try to get metadata to determine type final meta = _metadata[globalKey]; @@ -1002,19 +996,16 @@ class DownloadProvider extends ChangeNotifier { int updatedCount = 0; for (final globalKey in _metadata.keys.toList()) { - final parts = globalKey.split(':'); - if (parts.length != 2) continue; + final parsed = parseGlobalKey(globalKey); + if (parsed == null) continue; - final serverId = parts[0]; - final ratingKey = parts[1]; + final serverId = parsed.serverId; + final ratingKey = parsed.ratingKey; try { - final cached = await apiCache.get(serverId, '/library/metadata/$ratingKey'); - - final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); - if (firstMetadata != null) { - final metadata = PlexMetadata.fromJson(firstMetadata); - _metadata[globalKey] = metadata.copyWith(serverId: serverId); + final metadata = await apiCache.getMetadata(serverId, ratingKey); + if (metadata != null) { + _metadata[globalKey] = metadata; updatedCount++; } } catch (e) { diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index deed8ad4..8c1415cb 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -8,6 +8,7 @@ import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import 'base_media_list_detail_screen.dart'; import 'focusable_detail_screen_mixin.dart'; +import '../mixins/grid_focus_node_mixin.dart'; import '../focus/key_event_utils.dart'; /// Screen to display the contents of a collection @@ -21,7 +22,10 @@ class CollectionDetailScreen extends StatefulWidget { } class _CollectionDetailScreenState extends BaseMediaListDetailScreen - with StandardItemLoader, FocusableDetailScreenMixin { + with + StandardItemLoader, + GridFocusNodeMixin, + FocusableDetailScreenMixin { @override PlexMetadata get mediaItem => widget.collection; diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index b66e0fd6..4ef43028 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -5,7 +5,8 @@ import '../../models/plex_metadata.dart'; import '../../providers/download_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../providers/settings_provider.dart'; -import '../../services/gamepad_service.dart'; +import '../../utils/global_key_utils.dart'; +import '../../mixins/tab_navigation_mixin.dart'; import '../../utils/grid_size_calculator.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; @@ -24,103 +25,43 @@ class DownloadsScreen extends StatefulWidget { State createState() => DownloadsScreenState(); } -class DownloadsScreenState extends State with SingleTickerProviderStateMixin { - late TabController _tabController; - +class DownloadsScreenState extends State with SingleTickerProviderStateMixin, TabNavigationMixin { // Focus nodes for tab chips final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue'); final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows'); final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies'); - /// When true, suppress auto-focus in tabs (used when navigating via tab bar) - bool _suppressAutoFocus = true; + @override + List get tabChipFocusNodes => [_queueTabChipFocusNode, _tvShowsTabChipFocusNode, _moviesTabChipFocusNode]; @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); - _tabController.addListener(_onTabChanged); - - // Register L1/R1 callbacks for tab navigation - GamepadService.onL1Pressed = _goToPreviousTab; - GamepadService.onR1Pressed = _goToNextTab; + suppressAutoFocus = true; // Start suppressed + initTabNavigation(); } @override void dispose() { - _tabController.removeListener(_onTabChanged); - _tabController.dispose(); _queueTabChipFocusNode.dispose(); _tvShowsTabChipFocusNode.dispose(); _moviesTabChipFocusNode.dispose(); - // Clear L1/R1 callbacks - GamepadService.onL1Pressed = null; - GamepadService.onR1Pressed = null; + disposeTabNavigation(); super.dispose(); } - void _goToPreviousTab() { - if (_tabController.index > 0) { - setState(() { - _suppressAutoFocus = true; - _tabController.index = _tabController.index - 1; - }); - _getTabChipFocusNode(_tabController.index).requestFocus(); + @override + void onTabChanged() { + if (!tabController.indexIsChanging) { + super.onTabChanged(); } } - void _goToNextTab() { - if (_tabController.index < _tabController.length - 1) { - setState(() { - _suppressAutoFocus = true; - _tabController.index = _tabController.index + 1; - }); - _getTabChipFocusNode(_tabController.index).requestFocus(); - } - } - - void _onTabChanged() { - if (!_tabController.indexIsChanging) { - // Rebuild to update chip selection state - setState(() {}); - } - } - - /// Get the focus node for a tab chip by index - FocusNode _getTabChipFocusNode(int index) { - switch (index) { - case 0: - return _queueTabChipFocusNode; - case 1: - return _tvShowsTabChipFocusNode; - case 2: - return _moviesTabChipFocusNode; - default: - return _queueTabChipFocusNode; - } - } - - /// 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(); - } - - /// Handle BACK from tab bar - navigate to sidenav - void _onTabBarBack() { - final focusScope = MainScreenFocusScope.of(context); - focusScope?.focusSidebar(); - } - /// Focus the first item in the currently active tab void _focusCurrentTab() { // Re-enable auto-focus since user is navigating into tab content setState(() { - _suppressAutoFocus = false; + suppressAutoFocus = false; }); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -130,13 +71,12 @@ class DownloadsScreenState extends State with SingleTickerProvi } Widget _buildTabChip(String label, int index) { - final isSelected = _tabController.index == index; - const tabCount = 3; // Queue, TV Shows, Movies + 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 @@ -144,7 +84,7 @@ class DownloadsScreenState extends State with SingleTickerProvi } else { // Switch to this tab setState(() { - _tabController.index = index; + tabController.index = index; }); } }, @@ -152,24 +92,24 @@ class DownloadsScreenState extends State with SingleTickerProvi ? () { 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(); } : null, onNavigateDown: _focusCurrentTab, - onBack: _onTabBarBack, + onBack: onTabBarBack, ); } @@ -230,13 +170,13 @@ class DownloadsScreenState extends State with SingleTickerProvi // Tab content Expanded( child: TabBarView( - controller: _tabController, + controller: tabController, children: [ Consumer2( builder: (context, downloadProvider, serverProvider, _) { // Helper to get client from globalKey (serverId:ratingKey) getClient(String globalKey) { - final serverId = globalKey.split(':').first; + final serverId = parseGlobalKey(globalKey)?.serverId ?? globalKey; return serverProvider.serverManager.getClient(serverId); } @@ -260,18 +200,18 @@ class DownloadsScreenState extends State with SingleTickerProvi onDelete: downloadProvider.deleteDownload, onNavigateLeft: () => MainScreenFocusScope.of(context)?.focusSidebar(), onBack: focusTabBar, - suppressAutoFocus: _suppressAutoFocus, + suppressAutoFocus: suppressAutoFocus, ); }, ), _DownloadsGridContent( type: DownloadType.tvShows, - suppressAutoFocus: _suppressAutoFocus, + suppressAutoFocus: suppressAutoFocus, onBack: focusTabBar, ), _DownloadsGridContent( type: DownloadType.movies, - suppressAutoFocus: _suppressAutoFocus, + suppressAutoFocus: suppressAutoFocus, onBack: focusTabBar, ), ], diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 6fe843a0..57152e74 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../focus/dpad_navigator.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; +import '../mixins/grid_focus_node_mixin.dart'; import '../providers/settings_provider.dart'; import '../utils/grid_size_calculator.dart'; import '../widgets/app_icon.dart'; @@ -22,7 +23,9 @@ class AppBarButtonConfig { /// Mixin that provides common focus navigation functionality for detail screens. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. -mixin FocusableDetailScreenMixin on State { +/// +/// Classes using this mixin must also use [GridFocusNodeMixin]. +mixin FocusableDetailScreenMixin on State, GridFocusNodeMixin { // Scroll controller for scrolling to top when app bar is focused final ScrollController scrollController = ScrollController(); @@ -33,12 +36,6 @@ mixin FocusableDetailScreenMixin on State { // Grid item focus final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item'); - final Map gridItemFocusNodes = {}; - - // Focus restoration - int? lastFocusedIndex; - int contentVersion = 0; - int lastFocusedContentVersion = 0; // App bar focus state bool isAppBarFocused = false; @@ -63,15 +60,7 @@ mixin FocusableDetailScreenMixin on State { shuffleButtonFocusNode.dispose(); deleteButtonFocusNode.dispose(); firstItemFocusNode.dispose(); - for (final node in gridItemFocusNodes.values) { - node.dispose(); - } - gridItemFocusNodes.clear(); - } - - /// Get or create a focus node for a grid item at the given index - FocusNode getGridItemFocusNode(int index) { - return gridItemFocusNodes.putIfAbsent(index, () => FocusNode(debugLabel: 'detail_grid_item_$index')); + disposeGridFocusNodes(); } /// Navigate from content to app bar @@ -95,11 +84,7 @@ mixin FocusableDetailScreenMixin on State { void navigateToGrid() { if (!hasItems) return; - // Check if we should restore focus to the last focused item - final shouldRestoreFocus = - lastFocusedIndex != null && lastFocusedContentVersion == contentVersion && lastFocusedIndex! >= 0; - - final targetIndex = shouldRestoreFocus ? lastFocusedIndex! : 0; + final targetIndex = shouldRestoreGridFocus ? lastFocusedGridIndex! : 0; setState(() { isAppBarFocused = false; @@ -108,7 +93,7 @@ mixin FocusableDetailScreenMixin on State { if (targetIndex == 0) { firstItemFocusNode.requestFocus(); } else { - getGridItemFocusNode(targetIndex).requestFocus(); + getGridItemFocusNode(targetIndex, prefix: 'detail_grid_item').requestFocus(); } } @@ -242,14 +227,6 @@ mixin FocusableDetailScreenMixin on State { } } - /// Track focus on a grid item. Call from onFocusChange of grid items. - void trackGridItemFocus(int index, bool hasFocus) { - if (hasFocus) { - lastFocusedIndex = index; - lastFocusedContentVersion = contentVersion; - } - } - /// Build a standard focusable grid sliver for media items. /// Used by collection and smart playlist detail screens. Widget buildFocusableGrid({ @@ -275,7 +252,9 @@ mixin FocusableDetailScreenMixin on State { itemBuilder: (context, index) { final item = items[index]; final inFirstRow = GridSizeCalculator.isFirstRow(index, columnCount); - final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index); + final focusNode = index == 0 + ? firstItemFocusNode + : getGridItemFocusNode(index, prefix: 'detail_grid_item'); return FocusableMediaCard( key: Key(item.ratingKey), diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index e8ee99c2..1f5e8b8b 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -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 - with Refreshable, FullRefreshable, FocusableTab, LibraryLoadable, ItemUpdatable, SingleTickerProviderStateMixin { + with + Refreshable, + FullRefreshable, + FocusableTab, + LibraryLoadable, + ItemUpdatable, + SingleTickerProviderStateMixin, + TabNavigationMixin { @override PlexClient get client { final multiServerProvider = Provider.of(context, listen: false); @@ -76,8 +82,6 @@ class _LibrariesScreenState extends State return context.getClientForServer(multiServerProvider.onlineServerIds.first); } - late TabController _tabController; - // GlobalKeys for tabs to enable refresh final _recommendedTabKey = GlobalKey>(); final _browseTabKey = GlobalKey>(); @@ -88,9 +92,6 @@ class _LibrariesScreenState extends State String? _selectedLibraryGlobalKey; bool _isInitialLoad = true; - /// When true, suppress auto-focus in tabs (used when navigating via tab bar) - bool _suppressAutoFocus = false; - Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; @@ -101,7 +102,7 @@ class _LibrariesScreenState extends State 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 final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections'); final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists'); + @override + List 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 @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 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 } } - 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 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 /// 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 /// 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 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 _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 _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 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 if (librariesProvider.libraries.isNotEmpty) { _editButtonFocusNode.requestFocus(); } else { - _getTabChipFocusNode(3).requestFocus(); + getTabChipFocusNode(3).requestFocus(); } return KeyEventResult.handled; } @@ -454,8 +407,6 @@ class _LibrariesScreenState extends State @override void dispose() { - _tabController.removeListener(_onTabChanged); - _tabController.dispose(); _cancelToken?.cancel(); _outerScrollController.dispose(); _recommendedTabChipFocusNode.dispose(); @@ -466,9 +417,7 @@ class _LibrariesScreenState extends State _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 // 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 // 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 // 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 } 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 } else { // Switch to this tab setState(() { - _tabController.index = index; + tabController.index = index; }); } }, @@ -1026,20 +974,20 @@ class _LibrariesScreenState extends State ? () { 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 } }, onNavigateDown: _focusCurrentTabFromTabBar, - onBack: _onTabBarBack, + onBack: onTabBarBack, ); } @@ -1231,7 +1179,7 @@ class _LibrariesScreenState extends State 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 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, ), diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 4cd1480c..8ededceb 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -20,6 +20,7 @@ import '../sort_bottom_sheet.dart'; import '../state_messages.dart'; import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart' show ViewMode, EpisodePosterMode; +import '../../../mixins/grid_focus_node_mixin.dart'; import '../../../mixins/item_updatable.dart'; import '../../../i18n/strings.g.dart'; import '../../main_screen.dart'; @@ -44,7 +45,7 @@ class LibraryBrowseTab extends BaseLibraryTab { } class _LibraryBrowseTabState extends BaseLibraryTabState - with ItemUpdatable, LibraryTabFocusMixin { + with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin { @override PlexClient get client => getClientForLibrary(); @@ -84,12 +85,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _gridItemFocusNodes = {}; - // Scroll controller for the CustomScrollView final ScrollController _scrollController = ScrollController(); @@ -100,28 +95,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState FocusNode(debugLabel: 'browse_grid_item_$index')); - } - - /// Clean up focus nodes for items that no longer exist - void _cleanupFocusNodes() { - final keysToRemove = _gridItemFocusNodes.keys.where((index) => index >= items.length).toList(); - for (final key in keysToRemove) { - _gridItemFocusNodes[key]?.dispose(); - _gridItemFocusNodes.remove(key); - } - } - // Override loadData to use our custom _loadContent @override Future> loadData() async { @@ -269,8 +246,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState trackGridItemFocus(index, hasFocus), onListRefresh: _loadItems, ); } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 3360e23c..93b8c274 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -18,6 +18,7 @@ import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; +import '../../mixins/grid_focus_node_mixin.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { @@ -30,7 +31,10 @@ class PlaylistDetailScreen extends StatefulWidget { } class _PlaylistDetailScreenState extends BaseMediaListDetailScreen - with StandardItemLoader, FocusableDetailScreenMixin { + with + StandardItemLoader, + GridFocusNodeMixin, + FocusableDetailScreenMixin { @override dynamic get mediaItem => widget.playlist; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index ac489bef..52c60ea1 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -15,6 +15,7 @@ import '../services/download_storage_service.dart'; import '../services/plex_api_cache.dart'; import '../utils/app_logger.dart'; import '../utils/codec_utils.dart'; +import '../utils/global_key_utils.dart'; import '../utils/plex_cache_parser.dart'; /// Extension methods on AppDatabase for download operations @@ -354,23 +355,19 @@ class DownloadManagerService { appLogger.d('Status updated to downloading'); // Parse globalKey to get serverId and ratingKey - final parts = globalKey.split(':'); - final serverId = parts[0]; - final ratingKey = parts[1]; + final parsed = parseGlobalKey(globalKey); + if (parsed == null) { + throw Exception('Invalid globalKey format: $globalKey'); + } + final serverId = parsed.serverId; + final ratingKey = parsed.ratingKey; // Get metadata from cache - final cachedResponse = await _apiCache.get(serverId, '/library/metadata/$ratingKey'); - if (cachedResponse == null) { + final metadata = await _apiCache.getMetadata(serverId, ratingKey); + if (metadata == null) { throw Exception('Metadata not found in cache for $globalKey'); } - // Parse metadata from cached response - final firstMetadata = PlexCacheParser.extractFirstMetadata(cachedResponse); - if (firstMetadata == null) { - throw Exception('Invalid cached metadata for $globalKey'); - } - final metadata = PlexMetadata.fromJson(firstMetadata).copyWith(serverId: serverId); - // Get video playback data (includes URL, streams, markers, etc.) // This also caches the metadata with chapters/markers for offline use final playbackData = await client.getVideoPlaybackData(metadata.ratingKey); @@ -860,15 +857,15 @@ class DownloadManagerService { } // Delete files from storage - final parts = globalKey.split(':'); - if (parts.length != 2) { + final parsed = parseGlobalKey(globalKey); + if (parsed == null) { await _database.deleteDownload(globalKey); return; } - final serverId = parts[0]; - final ratingKey = parts[1]; - final metadata = await _getMetadataFromCache(serverId, ratingKey); + final serverId = parsed.serverId; + final ratingKey = parsed.ratingKey; + final metadata = await _apiCache.getMetadata(serverId, ratingKey); if (metadata == null) { // Fallback deletion without progress @@ -935,7 +932,7 @@ class DownloadManagerService { Future _deleteMediaFilesWithMetadata(String serverId, String ratingKey) async { try { // Get metadata from API cache - final metadata = await _getMetadataFromCache(serverId, ratingKey); + final metadata = await _apiCache.getMetadata(serverId, ratingKey); if (metadata == null) { // Fallback: Try database record @@ -970,16 +967,6 @@ class DownloadManagerService { } } - /// Get metadata from API cache - Future _getMetadataFromCache(String serverId, String ratingKey) async { - final cachedData = await _apiCache.get(serverId, '/library/metadata/$ratingKey'); - final metadataJson = PlexCacheParser.extractFirstMetadata(cachedData); - if (metadataJson != null) { - return PlexMetadata.fromJson(metadataJson).copyWith(serverId: serverId); - } - return null; - } - /// Get chapter thumb paths from cached metadata Future> _getChapterThumbPaths(String serverId, String ratingKey) async { try { @@ -1076,7 +1063,7 @@ class DownloadManagerService { Future _deleteEpisodeFiles(PlexMetadata episode, String serverId) async { try { final parentMetadata = episode.grandparentRatingKey != null - ? await _getMetadataFromCache(serverId, episode.grandparentRatingKey!) + ? await _apiCache.getMetadata(serverId, episode.grandparentRatingKey!) : null; final showYear = parentMetadata?.year; @@ -1113,7 +1100,7 @@ class DownloadManagerService { Future _deleteSeasonFiles(PlexMetadata season, String serverId) async { try { final parentMetadata = season.parentRatingKey != null - ? await _getMetadataFromCache(serverId, season.parentRatingKey!) + ? await _apiCache.getMetadata(serverId, season.parentRatingKey!) : null; final showYear = parentMetadata?.year; diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index 37fd39fd..b3799966 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:drift/drift.dart'; import '../database/app_database.dart'; +import '../models/plex_metadata.dart'; +import '../utils/plex_cache_parser.dart'; /// Key-value cache for Plex API responses using Drift/SQLite. /// Stores raw JSON responses keyed by serverId:endpoint format. @@ -110,6 +112,16 @@ class PlexApiCache { return keys; } + /// Fetch and parse a [PlexMetadata] item from cache. + /// + /// Returns `null` when the endpoint is not cached or contains no metadata. + Future getMetadata(String serverId, String ratingKey) async { + final cached = await get(serverId, '/library/metadata/$ratingKey'); + final json = PlexCacheParser.extractFirstMetadata(cached); + if (json == null) return null; + return PlexMetadata.fromJson(json).copyWith(serverId: serverId); + } + /// Clear all cached data (useful for debugging/testing) Future clearAll() async { await _db.delete(_db.apiCache).go(); diff --git a/lib/utils/global_key_utils.dart b/lib/utils/global_key_utils.dart new file mode 100644 index 00000000..2010fc45 --- /dev/null +++ b/lib/utils/global_key_utils.dart @@ -0,0 +1,9 @@ +/// Parses a globalKey string (format: "serverId:ratingKey") into its components. +/// +/// Returns `null` if the key does not contain a colon separator. +/// Uses [indexOf] so ratingKeys containing colons are handled correctly. +({String serverId, String ratingKey})? parseGlobalKey(String globalKey) { + final idx = globalKey.indexOf(':'); + if (idx < 0) return null; + return (serverId: globalKey.substring(0, idx), ratingKey: globalKey.substring(idx + 1)); +}