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
+50
View File
@@ -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<T extends StatefulWidget> on State<T> {
final Map<int, FocusNode> 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();
}
}
+85
View File
@@ -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<T extends StatefulWidget> on State<T>, SingleTickerProviderStateMixin<T> {
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<FocusNode> 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();
}
}
+18 -27
View File
@@ -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) {
+5 -1
View File
@@ -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<CollectionDetailScreen>
with StandardItemLoader<CollectionDetailScreen>, FocusableDetailScreenMixin<CollectionDetailScreen> {
with
StandardItemLoader<CollectionDetailScreen>,
GridFocusNodeMixin<CollectionDetailScreen>,
FocusableDetailScreenMixin<CollectionDetailScreen> {
@override
PlexMetadata get mediaItem => widget.collection;
+29 -89
View File
@@ -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<DownloadsScreen> createState() => DownloadsScreenState();
}
class DownloadsScreenState extends State<DownloadsScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
class DownloadsScreenState extends State<DownloadsScreen> 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<FocusNode> 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<DownloadsScreen> 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<DownloadsScreen> with SingleTickerProvi
} else {
// Switch to this tab
setState(() {
_tabController.index = index;
tabController.index = index;
});
}
},
@@ -152,24 +92,24 @@ class DownloadsScreenState extends State<DownloadsScreen> 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<DownloadsScreen> with SingleTickerProvi
// Tab content
Expanded(
child: TabBarView(
controller: _tabController,
controller: tabController,
children: [
Consumer2<DownloadProvider, MultiServerProvider>(
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<DownloadsScreen> 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,
),
],
+10 -31
View File
@@ -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<T extends StatefulWidget> on State<T> {
///
/// Classes using this mixin must also use [GridFocusNodeMixin].
mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocusNodeMixin<T> {
// Scroll controller for scrolling to top when app bar is focused
final ScrollController scrollController = ScrollController();
@@ -33,12 +36,6 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
// Grid item focus
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
final Map<int, FocusNode> gridItemFocusNodes = {};
// Focus restoration
int? lastFocusedIndex;
int contentVersion = 0;
int lastFocusedContentVersion = 0;
// App bar focus state
bool isAppBarFocused = false;
@@ -63,15 +60,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
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<T extends StatefulWidget> on State<T> {
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<T extends StatefulWidget> on State<T> {
if (targetIndex == 0) {
firstItemFocusNode.requestFocus();
} else {
getGridItemFocusNode(targetIndex).requestFocus();
getGridItemFocusNode(targetIndex, prefix: 'detail_grid_item').requestFocus();
}
}
@@ -242,14 +227,6 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
}
}
/// 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<T extends StatefulWidget> on State<T> {
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),
+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,
),
@@ -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<PlexMetadata> {
}
class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
with ItemUpdatable, LibraryTabFocusMixin {
with ItemUpdatable, LibraryTabFocusMixin, GridFocusNodeMixin {
@override
PlexClient get client => getClientForLibrary();
@@ -84,12 +85,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
final FocusNode _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip');
final FocusNode _sortChipFocusNode = FocusNode(debugLabel: 'sort_chip');
// Focus tracking for grid items
int? _lastFocusedIndex;
int _contentVersion = 0;
int _lastFocusedContentVersion = 0;
final Map<int, FocusNode> _gridItemFocusNodes = {};
// Scroll controller for the CustomScrollView
final ScrollController _scrollController = ScrollController();
@@ -100,28 +95,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_groupingChipFocusNode.dispose();
_filtersChipFocusNode.dispose();
_sortChipFocusNode.dispose();
// Dispose all grid item focus nodes
for (final node in _gridItemFocusNodes.values) {
node.dispose();
}
_gridItemFocusNodes.clear();
disposeGridFocusNodes();
super.dispose();
}
/// Get or create a focus node for a grid item at the given index
FocusNode _getGridItemFocusNode(int index) {
return _gridItemFocusNodes.putIfAbsent(index, () => 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<List<PlexMetadata>> loadData() async {
@@ -269,8 +246,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
items = [];
// Increment content version when loading fresh content
// This invalidates the last focused index
_contentVersion++;
_cleanupFocusNodes();
gridContentVersion++;
cleanupGridFocusNodes(items.length);
}
});
@@ -522,17 +499,13 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
void _navigateToGrid() {
if (items.isEmpty) return;
// Check if we should restore focus to the last focused item
final shouldRestoreFocus =
_lastFocusedIndex != null && _lastFocusedContentVersion == _contentVersion && _lastFocusedIndex! < items.length;
final targetIndex = shouldRestoreFocus ? _lastFocusedIndex! : 0;
final targetIndex = shouldRestoreGridFocus && lastFocusedGridIndex! < items.length ? lastFocusedGridIndex! : 0;
// Use firstItemFocusNode for index 0 (matches _buildMediaCardItem)
if (targetIndex == 0) {
firstItemFocusNode.requestFocus();
} else {
_getGridItemFocusNode(targetIndex).requestFocus();
getGridItemFocusNode(targetIndex, prefix: 'browse_grid_item').requestFocus();
}
}
@@ -762,7 +735,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
// Use firstItemFocusNode for index 0 to maintain compatibility with base class
// All other items get managed focus nodes for restoration
final focusNode = index == 0 ? firstItemFocusNode : _getGridItemFocusNode(index);
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'browse_grid_item');
return FocusableMediaCard(
key: Key(item.ratingKey),
@@ -772,13 +745,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
onNavigateUp: isFirstRow ? _navigateToChips : null,
onNavigateLeft: isFirstColumn ? _navigateToSidebar : null,
onBack: widget.onBack,
onFocusChange: (hasFocus) {
if (hasFocus) {
// Track the focused index and current content version
_lastFocusedIndex = index;
_lastFocusedContentVersion = _contentVersion;
}
},
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
onListRefresh: _loadItems,
);
}
@@ -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<PlaylistDetailScreen>
with StandardItemLoader<PlaylistDetailScreen>, FocusableDetailScreenMixin<PlaylistDetailScreen> {
with
StandardItemLoader<PlaylistDetailScreen>,
GridFocusNodeMixin<PlaylistDetailScreen>,
FocusableDetailScreenMixin<PlaylistDetailScreen> {
@override
dynamic get mediaItem => widget.playlist;
+17 -30
View File
@@ -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<void> _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<PlexMetadata?> _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<List<String>> _getChapterThumbPaths(String serverId, String ratingKey) async {
try {
@@ -1076,7 +1063,7 @@ class DownloadManagerService {
Future<void> _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<void> _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;
+12
View File
@@ -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<PlexMetadata?> 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<void> clearAll() async {
await _db.delete(_db.apiCache).go();
+9
View File
@@ -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));
}