refactor: extract shared mixins and helpers
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user