diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 68300c60..84110e2f 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,12 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../models/plex_metadata.dart'; -import '../widgets/media_grid_sliver.dart'; -import '../widgets/focused_scroll_scaffold.dart'; +import '../widgets/focusable_media_card.dart'; +import '../widgets/media_grid_delegate.dart'; +import '../utils/grid_size_calculator.dart'; +import '../widgets/desktop_app_bar.dart'; +import '../providers/settings_provider.dart'; import '../i18n/strings.g.dart'; import '../utils/dialogs.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import 'base_media_list_detail_screen.dart'; +import 'focusable_detail_screen_mixin.dart'; /// Screen to display the contents of a collection class CollectionDetailScreen extends StatefulWidget { @@ -19,7 +25,7 @@ class CollectionDetailScreen extends StatefulWidget { } class _CollectionDetailScreenState extends BaseMediaListDetailScreen - with StandardItemLoader { + with StandardItemLoader, FocusableDetailScreenMixin { @override PlexMetadata get mediaItem => widget.collection; @@ -29,11 +35,30 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen t.collections.empty; + @override + bool get hasItems => items.isNotEmpty; + + @override + int get appBarButtonCount => items.isNotEmpty ? 3 : 1; // play, shuffle, delete (or just delete if empty) + + @override + void dispose() { + disposeFocusResources(); + super.dispose(); + } + @override Future> fetchItems() async { return await client.getCollectionItems(widget.collection.ratingKey); } + @override + Future loadItems() async { + contentVersion++; + await super.loadItems(); + autoFocusFirstItemAfterLoad(); + } + @override String getLoadErrorMessage(Object error) { return t.collections.failedToLoadItems(error: error.toString()); @@ -44,11 +69,28 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _deleteCollection() async { - // Get library section ID from the collection or its items - int? sectionId = widget.collection.librarySectionID; + @override + List getAppBarButtons() { + final buttons = []; + if (items.isNotEmpty) { + buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.discover.play, onPressed: playItems)); + buttons.add( + AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), + ); + } + buttons.add( + AppBarButtonConfig( + icon: Symbols.delete_rounded, + tooltip: t.common.delete, + onPressed: _deleteCollection, + color: Colors.red, + ), + ); + return buttons; + } - // If collection doesn't have it, try to get it from loaded items + Future _deleteCollection() async { + int? sectionId = widget.collection.librarySectionID; if (sectionId == null && items.isNotEmpty) { sectionId = items.first.librarySectionID; } @@ -60,7 +102,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen( + builder: (context, settingsProvider, child) { + final columnCount = _getGridColumnCount(context, settingsProvider); + return SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + sliver: SliverGrid.builder( + gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final inFirstRow = isFirstRow(index, columnCount); + final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index); + + return FocusableMediaCard( + key: Key(item.ratingKey), + item: item, + focusNode: focusNode, + onRefresh: updateItem, + collectionId: widget.collection.ratingKey, + onListRefresh: loadItems, + onNavigateUp: inFirstRow ? navigateToAppBar : null, + onBack: handleBackFromContent, + onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), + ); + }, ), - ], + ); + }, ); } } diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart new file mode 100644 index 00000000..f14e208b --- /dev/null +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import '../focus/dpad_navigator.dart'; +import '../focus/input_mode_tracker.dart'; +import '../widgets/app_icon.dart'; +import '../i18n/strings.g.dart'; + +/// Configuration for app bar buttons +class AppBarButtonConfig { + final IconData icon; + final String tooltip; + final VoidCallback onPressed; + final Color? color; + + const AppBarButtonConfig({required this.icon, required this.tooltip, required this.onPressed, this.color}); +} + +/// Mixin that provides common focus navigation functionality for detail screens. +/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. +mixin FocusableDetailScreenMixin on State { + // Scroll controller for scrolling to top when app bar is focused + final ScrollController scrollController = ScrollController(); + + // App bar focus nodes + final FocusNode playButtonFocusNode = FocusNode(debugLabel: 'detail_play'); + final FocusNode shuffleButtonFocusNode = FocusNode(debugLabel: 'detail_shuffle'); + final FocusNode deleteButtonFocusNode = FocusNode(debugLabel: 'detail_delete'); + + // Grid item focus + final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item'); + final Map gridItemFocusNodes = {}; + + // Focus restoration + int? lastFocusedIndex; + int contentVersion = 0; + int lastFocusedContentVersion = 0; + + // App bar focus state + bool isAppBarFocused = false; + int appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete (or less if fewer buttons) + + // Flag to prevent PopScope from exiting when BACK was handled by a key handler + bool backHandledByKeyEvent = false; + + /// Number of app bar buttons (override if different from 3) + int get appBarButtonCount => 3; + + /// Called when items are available and we want to check if focus should be set + bool get hasItems; + + /// Called to get the list of app bar button configurations + List getAppBarButtons(); + + /// Dispose focus-related resources. Call this from your dispose() method. + void disposeFocusResources() { + scrollController.dispose(); + playButtonFocusNode.dispose(); + 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')); + } + + /// Navigate from content to app bar + void navigateToAppBar() { + setState(() { + isAppBarFocused = true; + appBarFocusedButton = 0; + }); + _focusAppBarButton(0); + // Scroll to top to show the app bar + scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + } + + /// Handle BACK key from content - navigate to app bar and set flag to prevent PopScope exit + void handleBackFromContent() { + backHandledByKeyEvent = true; + navigateToAppBar(); + } + + /// Navigate focus from app bar down to the grid + 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; + + setState(() { + isAppBarFocused = false; + }); + + if (targetIndex == 0) { + firstItemFocusNode.requestFocus(); + } else { + getGridItemFocusNode(targetIndex).requestFocus(); + } + } + + /// Handle back navigation for PopScope. Returns true if should pop. + bool handleBackNavigation() { + // If BACK was already handled by a key event, don't pop + if (backHandledByKeyEvent) { + backHandledByKeyEvent = false; + return false; + } + + if (isAppBarFocused) { + // Already on app bar, allow exit + return true; + } else { + // Focus app bar first + navigateToAppBar(); + return false; + } + } + + /// Focus a specific app bar button by index + void _focusAppBarButton(int index) { + switch (index) { + case 0: + playButtonFocusNode.requestFocus(); + break; + case 1: + shuffleButtonFocusNode.requestFocus(); + break; + case 2: + deleteButtonFocusNode.requestFocus(); + break; + } + } + + /// Handle key events when app bar is focused + KeyEventResult handleAppBarKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey; + final maxButton = appBarButtonCount - 1; + + if (key.isLeftKey && appBarFocusedButton > 0) { + setState(() => appBarFocusedButton--); + _focusAppBarButton(appBarFocusedButton); + return KeyEventResult.handled; + } + if (key.isRightKey && appBarFocusedButton < maxButton) { + setState(() => appBarFocusedButton++); + _focusAppBarButton(appBarFocusedButton); + return KeyEventResult.handled; + } + if (key.isDownKey) { + // Return focus to grid + navigateToGrid(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + final buttons = getAppBarButtons(); + if (appBarFocusedButton < buttons.length) { + buttons[appBarFocusedButton].onPressed(); + } + return KeyEventResult.handled; + } + if (key.isBackKey) { + // Already on app bar, exit the screen + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + /// Build focusable app bar action widgets + List buildFocusableAppBarActions() { + final colorScheme = Theme.of(context).colorScheme; + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final buttons = getAppBarButtons(); + + return buttons.asMap().entries.map((entry) { + final index = entry.key; + final config = entry.value; + final isFocused = isKeyboardMode && isAppBarFocused && appBarFocusedButton == index; + + FocusNode focusNode; + switch (index) { + case 0: + focusNode = playButtonFocusNode; + break; + case 1: + focusNode = shuffleButtonFocusNode; + break; + case 2: + focusNode = deleteButtonFocusNode; + break; + default: + focusNode = FocusNode(); + } + + return Focus( + focusNode: focusNode, + onKeyEvent: handleAppBarKeyEvent, + child: Container( + decoration: isFocused + ? BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)) + : null, + child: IconButton( + icon: AppIcon(config.icon, fill: 1), + tooltip: config.tooltip, + onPressed: config.onPressed, + color: config.color, + ), + ), + ); + }).toList(); + } + + /// Auto-focus first item after load if in keyboard mode. + /// Call this from loadItems() after items are loaded. + void autoFocusFirstItemAfterLoad() { + if (mounted && hasItems) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (InputModeTracker.isKeyboardMode(context)) { + setState(() { + isAppBarFocused = false; + }); + firstItemFocusNode.requestFocus(); + } + }); + } + } + + /// Check if the given index is in the first row of a grid with given column count + bool isFirstRow(int index, int columnCount) { + return index < columnCount; + } + + /// Track focus on a grid item. Call from onFocusChange of grid items. + void trackGridItemFocus(int index, bool hasFocus) { + if (hasFocus) { + lastFocusedIndex = index; + lastFocusedContentVersion = contentVersion; + } + } +} diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 0efea6ea..650c9dca 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -52,20 +52,29 @@ class AdaptiveMediaGrid extends StatelessWidget { ); } + // Extra top padding for focus decoration (scale + border extends beyond item bounds) + static const double _focusDecorationPadding = 8.0; + /// Builds either a list or grid view based on the view mode Widget _buildItemsView(BuildContext context, ViewMode viewMode, LibraryDensity density) { - final effectivePadding = padding ?? GridLayoutConstants.gridPadding; + final basePadding = padding ?? GridLayoutConstants.gridPadding; + // Add extra top padding for focus decoration of first row items + final effectivePadding = basePadding.copyWith(top: basePadding.top + _focusDecorationPadding); final effectiveAspectRatio = childAspectRatio ?? GridLayoutConstants.posterAspectRatio; if (viewMode == ViewMode.list) { return ListView.builder( padding: effectivePadding, + // Allow focus decoration to render outside scroll bounds + clipBehavior: Clip.none, itemCount: items.length, itemBuilder: (context, index) => itemBuilder(context, items[index], index), ); } else { return GridView.builder( padding: effectivePadding, + // Allow focus decoration to render outside scroll bounds + clipBehavior: Clip.none, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(context, density), childAspectRatio: effectiveAspectRatio, diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 58e981fe..6efe71a3 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -143,12 +143,17 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState items) { _ensureHubKeys(items.length); return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.fromLTRB(0, 8 + _focusDecorationPadding, 0, 8), + // Allow focus decoration to render outside scroll bounds + clipBehavior: Clip.none, itemCount: items.length, itemBuilder: (context, index) { final hub = items[index]; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index d80ea4eb..343ed91b 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -524,7 +524,7 @@ class _MediaDetailScreenState extends State with WatchStateAw } else { await offlineWatch.markAsWatched(serverId: metadata.serverId!, ratingKey: metadata.ratingKey); } - if (context.mounted) { + if (mounted) { showAppSnackBar( context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline, @@ -542,7 +542,7 @@ class _MediaDetailScreenState extends State with WatchStateAw } else { await client.markAsWatched(metadata.ratingKey); } - if (context.mounted) { + if (mounted) { _watchStateChanged = true; showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched); // Update watch state without full rebuild @@ -550,7 +550,7 @@ class _MediaDetailScreenState extends State with WatchStateAw } } } catch (e) { - if (context.mounted) { + if (mounted) { showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); } } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 9317e2f3..b833b6c6 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; import '../../services/plex_client.dart'; import '../../services/play_queue_launcher.dart'; import '../../models/plex_playlist.dart'; @@ -8,8 +10,14 @@ import '../../models/plex_metadata.dart'; import '../../utils/app_logger.dart'; import '../../utils/provider_extensions.dart'; import '../../widgets/media_grid_sliver.dart'; +import '../../widgets/focusable_media_card.dart'; +import '../../widgets/media_grid_delegate.dart'; +import '../../utils/grid_size_calculator.dart'; +import '../../widgets/desktop_app_bar.dart'; +import '../../providers/settings_provider.dart'; +import '../../focus/dpad_navigator.dart'; +import '../../focus/input_mode_tracker.dart'; import 'playlist_item_card.dart'; -import '../../widgets/focused_scroll_scaffold.dart'; import '../../i18n/strings.g.dart'; import '../../utils/dialogs.dart'; import '../base_media_list_detail_screen.dart'; @@ -38,11 +46,92 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen Symbols.playlist_play_rounded; + // Scroll controller for scrolling to top when app bar is focused + final ScrollController _scrollController = ScrollController(); + + // Focus management + final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list'); + final FocusNode _playButtonFocusNode = FocusNode(debugLabel: 'playlist_play'); + final FocusNode _shuffleButtonFocusNode = FocusNode(debugLabel: 'playlist_shuffle'); + final FocusNode _deleteButtonFocusNode = FocusNode(debugLabel: 'playlist_delete'); + + // Navigation state for regular (non-smart) playlists + int _focusedIndex = 0; + int _focusedColumn = 0; // 0=content, 1=drag handle, 2=remove button + + // Move mode state + int? _movingIndex; + int? _originalIndex; + List? _originalOrder; + + // Scroll-into-view keys + final Map _itemKeys = {}; + + // App bar focus state + bool _isAppBarFocused = false; + int _appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete + + // Flag to prevent PopScope from exiting when BACK was handled by a key handler + bool _backHandledByKeyEvent = false; + + // Grid focus for smart playlists + final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'playlist_first_item'); + final Map _gridItemFocusNodes = {}; + int? _lastFocusedGridIndex; + int _contentVersion = 0; + int _lastFocusedContentVersion = 0; + + @override + void dispose() { + _scrollController.dispose(); + _listFocusNode.dispose(); + _playButtonFocusNode.dispose(); + _shuffleButtonFocusNode.dispose(); + _deleteButtonFocusNode.dispose(); + _firstItemFocusNode.dispose(); + for (final node in _gridItemFocusNodes.values) { + node.dispose(); + } + _gridItemFocusNodes.clear(); + 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: 'playlist_grid_item_$index')); + } + @override Future> fetchItems() async { return await client.getPlaylist(widget.playlist.ratingKey); } + @override + Future loadItems() async { + // Increment content version when loading fresh content + _contentVersion++; + await super.loadItems(); + + // Auto-focus after load if in keyboard mode + if (mounted && items.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (InputModeTracker.isKeyboardMode(context)) { + setState(() { + _isAppBarFocused = false; + _focusedIndex = 0; + _focusedColumn = 0; + }); + if (widget.playlist.smart) { + _firstItemFocusNode.requestFocus(); + } else { + _listFocusNode.requestFocus(); + } + } + }); + } + } + @override String getLoadSuccessMessage(int itemCount) { return 'Loaded $itemCount items for playlist: ${widget.playlist.title}'; @@ -141,6 +230,72 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen _persistMoveToServer(int originalIndex, int newIndex) async { + // Item is already at newIndex in the list + final movedItem = items[newIndex]; + + // Check if item has playlistItemID (required for reordering) + if (movedItem.playlistItemID == null) { + appLogger.e('Cannot persist move: item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); + // Revert the UI change + setState(() { + final item = items.removeAt(newIndex); + items.insert(originalIndex, item); + _focusedIndex = originalIndex; + }); + } + return; + } + + // Determine the "after" item ID based on where the item is now + final int afterPlaylistItemId; + if (newIndex == 0) { + afterPlaylistItemId = 0; // Move to top + } else { + final afterItem = items[newIndex - 1]; + if (afterItem.playlistItemID == null) { + appLogger.e('Cannot persist move: after item missing playlistItemID'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); + // Revert the UI change + setState(() { + final item = items.removeAt(newIndex); + items.insert(originalIndex, item); + _focusedIndex = originalIndex; + }); + } + return; + } + afterPlaylistItemId = afterItem.playlistItemID!; + } + + appLogger.d('Persisting move from $originalIndex to $newIndex (after ID: $afterPlaylistItemId)'); + + // Call API to persist the change (UI is already updated) + final success = await client.movePlaylistItem( + playlistId: widget.playlist.ratingKey, + playlistItemId: movedItem.playlistItemID!, + afterPlaylistItemId: afterPlaylistItemId, + ); + + if (!success) { + // Revert on failure + appLogger.e('Failed to persist move, reverting UI'); + if (mounted) { + setState(() { + final item = items.removeAt(newIndex); + items.insert(originalIndex, item); + _focusedIndex = originalIndex; + }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.playlists.errorReordering))); + } + } + } + Future _removeItem(int index) async { final item = items[index]; @@ -201,57 +356,490 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen 0) { + setState(() { + final item = items.removeAt(_movingIndex!); + items.insert(_movingIndex! - 1, item); + _movingIndex = _movingIndex! - 1; + _focusedIndex = _movingIndex!; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isDownKey && _movingIndex! < items.length - 1) { + setState(() { + final item = items.removeAt(_movingIndex!); + items.insert(_movingIndex! + 1, item); + _movingIndex = _movingIndex! + 1; + _focusedIndex = _movingIndex!; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + // Confirm move - persist to server (UI is already updated during move) + final oldIndex = _originalIndex!; + final newIndex = _movingIndex!; + setState(() { + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + // Keep focus on the moved item at its new position + _focusedIndex = newIndex; + _focusedColumn = 0; + }); + // Persist the change via API (list is already in correct order) + _persistMoveToServer(oldIndex, newIndex); + return KeyEventResult.handled; + } + if (key.isBackKey) { + // Cancel move mode, set flag to prevent PopScope exit + _backHandledByKeyEvent = true; + _cancelMoveMode(); + return KeyEventResult.handled; + } + } else { + // Navigation mode + if (key.isUpKey) { + if (_focusedIndex > 0) { + setState(() { + _focusedIndex--; + _focusedColumn = 0; // Reset to row when changing rows + }); + _ensureFocusedVisible(); + } else { + // First item - navigate to app bar + _navigateToAppBar(); + } + return KeyEventResult.handled; + } + if (key.isDownKey && _focusedIndex < items.length - 1) { + setState(() { + _focusedIndex++; + _focusedColumn = 0; // Reset to row when changing rows + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isLeftKey) { + // Navigate left within columns + if (_focusedColumn == 0 && widget.playlist.smart == false) { + // Go to drag handle (column 1) + setState(() => _focusedColumn = 1); + return KeyEventResult.handled; + } else if (_focusedColumn == 2) { + // Go back to content + setState(() => _focusedColumn = 0); + return KeyEventResult.handled; + } + } + if (key.isRightKey) { + // Navigate right within columns + if (_focusedColumn == 0) { + // Go to remove button (column 2) + setState(() => _focusedColumn = 2); + return KeyEventResult.handled; + } else if (_focusedColumn == 1) { + // Go to content from drag handle + setState(() => _focusedColumn = 0); + return KeyEventResult.handled; + } + } + if (key.isSelectKey) { + if (_focusedColumn == 0) { + // Play from this item + _playFromItem(_focusedIndex); + } else if (_focusedColumn == 1 && !widget.playlist.smart) { + // Enter move mode + setState(() { + _movingIndex = _focusedIndex; + _originalIndex = _focusedIndex; + _originalOrder = List.from(items); + }); + } else if (_focusedColumn == 2) { + // Remove item + _removeItem(_focusedIndex); + } + return KeyEventResult.handled; + } + if (key.isBackKey) { + // Navigate to app bar on BACK, set flag to prevent PopScope exit + _handleBackFromContent(); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + + /// Handle key events when app bar is focused + KeyEventResult _handleAppBarKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final key = event.logicalKey; + final hasDelete = !widget.playlist.smart; + final maxButton = hasDelete ? 2 : 1; + + if (key.isLeftKey && _appBarFocusedButton > 0) { + setState(() => _appBarFocusedButton--); + _focusAppBarButton(_appBarFocusedButton); + return KeyEventResult.handled; + } + if (key.isRightKey && _appBarFocusedButton < maxButton) { + setState(() => _appBarFocusedButton++); + _focusAppBarButton(_appBarFocusedButton); + return KeyEventResult.handled; + } + if (key.isDownKey) { + // Return focus to list/grid + setState(() => _isAppBarFocused = false); + if (items.isNotEmpty) { + if (widget.playlist.smart) { + _navigateToGrid(); + } else { + _listFocusNode.requestFocus(); + } + } + return KeyEventResult.handled; + } + if (key.isSelectKey) { + _triggerAppBarButton(_appBarFocusedButton); + return KeyEventResult.handled; + } + if (key.isBackKey) { + // Already on app bar, exit the screen + Navigator.pop(context); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _focusAppBarButton(int index) { + switch (index) { + case 0: + _playButtonFocusNode.requestFocus(); + break; + case 1: + _shuffleButtonFocusNode.requestFocus(); + break; + case 2: + _deleteButtonFocusNode.requestFocus(); + break; + } + } + + void _triggerAppBarButton(int index) { + switch (index) { + case 0: + playItems(); + break; + case 1: + shufflePlayItems(); + break; + case 2: + if (!widget.playlist.smart) _deletePlaylist(); + break; + } + } + + /// Navigate focus from app bar down to the grid + void _navigateToGrid() { + if (items.isEmpty) return; + + // Check if we should restore focus to the last focused item + final shouldRestoreFocus = + _lastFocusedGridIndex != null && + _lastFocusedContentVersion == _contentVersion && + _lastFocusedGridIndex! < items.length; + + final targetIndex = shouldRestoreFocus ? _lastFocusedGridIndex! : 0; + + if (targetIndex == 0) { + _firstItemFocusNode.requestFocus(); + } else { + _getGridItemFocusNode(targetIndex).requestFocus(); + } + } + + /// Navigate from grid to app bar + void _navigateToAppBar() { + setState(() { + _isAppBarFocused = true; + _appBarFocusedButton = 0; + }); + _playButtonFocusNode.requestFocus(); + // Scroll to top to show the app bar + _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); + } + + /// Handle BACK key from grid/list - navigate to app bar and set flag to prevent PopScope exit + void _handleBackFromContent() { + _backHandledByKeyEvent = true; + _navigateToAppBar(); + } + + /// Handle back navigation for PopScope + bool _handleBackNavigation() { + // If BACK was already handled by a key event, don't pop + if (_backHandledByKeyEvent) { + _backHandledByKeyEvent = false; + return false; + } + + // If in move mode, cancel move instead of navigating + if (_movingIndex != null) { + _cancelMoveMode(); + return false; + } + + if (_isAppBarFocused) { + // Already on app bar, allow exit + return true; + } else { + // Focus app bar first + _navigateToAppBar(); + return false; + } + } + + /// Calculate the number of columns in the current grid + int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) { + final screenWidth = MediaQuery.of(context).size.width - 16; + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + return (screenWidth / maxCrossAxisExtent).floor().clamp(1, 100); + } + + /// Check if the given index is in the first row of the grid + bool _isFirstRow(int index, int columnCount) { + return index < columnCount; + } + + /// Build focusable app bar actions + List _buildFocusableAppBarActions() { + final colorScheme = Theme.of(context).colorScheme; + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + Widget buildFocusableButton({ + required FocusNode focusNode, + required int buttonIndex, + required IconData icon, + required String tooltip, + required VoidCallback onPressed, + Color? color, + }) { + final isFocused = isKeyboardMode && _isAppBarFocused && _appBarFocusedButton == buttonIndex; + return Focus( + focusNode: focusNode, + onKeyEvent: _handleAppBarKeyEvent, + child: Container( + decoration: isFocused + ? BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)) + : null, + child: IconButton(icon: AppIcon(icon, fill: 1), tooltip: tooltip, onPressed: onPressed, color: color), + ), + ); + } + + return [ + if (items.isNotEmpty) + buildFocusableButton( + focusNode: _playButtonFocusNode, + buttonIndex: 0, + icon: Symbols.play_arrow_rounded, + tooltip: t.discover.play, + onPressed: playItems, + ), + if (items.isNotEmpty) + buildFocusableButton( + focusNode: _shuffleButtonFocusNode, + buttonIndex: 1, + icon: Symbols.shuffle_rounded, + tooltip: t.common.shuffle, + onPressed: shufflePlayItems, + ), + if (!widget.playlist.smart) + buildFocusableButton( + focusNode: _deleteButtonFocusNode, + buttonIndex: 2, + icon: Symbols.delete_rounded, + tooltip: t.playlists.delete, + onPressed: _deletePlaylist, + color: Colors.red, + ), + ]; + } + + /// Cancel move mode if active, returns true if cancelled + bool _cancelMoveMode() { + if (_movingIndex != null) { + setState(() { + if (_originalOrder != null) { + items = List.from(_originalOrder!); + } + _focusedIndex = _originalIndex ?? 0; + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + return true; + } + return false; + } + @override Widget build(BuildContext context) { - return FocusedScrollScaffold( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.playlist.title, style: const TextStyle(fontSize: 16)), - if (widget.playlist.smart) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]), - const SizedBox(width: 4), - Text( - t.playlists.smartPlaylist, - style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal), - ), - ], + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + final shouldPop = _handleBackNavigation(); + if (shouldPop && mounted) { + Navigator.pop(context); + } + }, + child: Scaffold( + body: CustomScrollView( + controller: _scrollController, + slivers: [ + CustomAppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.playlist.title, style: const TextStyle(fontSize: 16)), + if (widget.playlist.smart) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(Symbols.auto_awesome_rounded, fill: 1, size: 12, color: Colors.blue[300]), + const SizedBox(width: 4), + Text( + t.playlists.smartPlaylist, + style: TextStyle(fontSize: 11, color: Colors.blue[300], fontWeight: FontWeight.normal), + ), + ], + ), + ], + ), + actions: _buildFocusableAppBarActions(), ), - ], + ...buildStateSlivers(), + if (items.isNotEmpty) + if (widget.playlist.smart) + // Smart playlists: Use focusable grid view (cannot be reordered) + _buildSmartPlaylistGrid(isKeyboardMode) + else + // Regular playlists: Use reorderable list view with focus + _buildReorderableList(isKeyboardMode), + ], + ), ), - actions: buildAppBarActions( - onDelete: widget.playlist.smart ? null : _deletePlaylist, - deleteTooltip: t.playlists.delete, - showDelete: !widget.playlist.smart, + ); + } + + /// Build a focusable grid for smart playlists + Widget _buildSmartPlaylistGrid(bool isKeyboardMode) { + return Consumer( + builder: (context, settingsProvider, child) { + final columnCount = _getGridColumnCount(context, settingsProvider); + return SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), + sliver: SliverGrid.builder( + gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final isFirstRow = _isFirstRow(index, columnCount); + final focusNode = index == 0 ? _firstItemFocusNode : _getGridItemFocusNode(index); + + return FocusableMediaCard( + key: Key(item.ratingKey), + item: item, + focusNode: focusNode, + onRefresh: updateItem, + onNavigateUp: isFirstRow ? _navigateToAppBar : null, + onBack: _handleBackFromContent, + onFocusChange: (hasFocus) { + if (hasFocus) { + _lastFocusedGridIndex = index; + _lastFocusedContentVersion = _contentVersion; + } + }, + ); + }, + ), + ); + }, + ); + } + + /// Build a reorderable list for regular playlists with focus support + Widget _buildReorderableList(bool isKeyboardMode) { + return SliverToBoxAdapter( + child: Focus( + autofocus: isKeyboardMode && items.isNotEmpty && !_isAppBarFocused, + focusNode: _listFocusNode, + onKeyEvent: _handleListKeyEvent, + onFocusChange: (hasFocus) { + // Rebuild when focus changes to update visual indicators + if (hasFocus && mounted) { + setState(() { + _isAppBarFocused = false; + }); + } + }, + child: ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + onReorder: _onReorder, + buildDefaultDragHandles: false, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + // Check keyboard mode directly to ensure we get latest value + final inKeyboardMode = InputModeTracker.isKeyboardMode(context); + final isFocused = inKeyboardMode && index == _focusedIndex && !_isAppBarFocused; + final isMoving = index == _movingIndex; + + _itemKeys.putIfAbsent(index, () => GlobalKey()); + + return PlaylistItemCard( + key: _itemKeys[index] ?? ValueKey(item.playlistItemID ?? item.ratingKey), + item: item, + index: index, + onRemove: () => _removeItem(index), + onTap: () => _playFromItem(index), + onRefresh: updateItem, + canReorder: !widget.playlist.smart, + isFocused: isFocused, + focusedColumn: isFocused ? _focusedColumn : null, + isMoving: isMoving, + ); + }, + ), ), - slivers: [ - ...buildStateSlivers(), - if (items.isNotEmpty) - if (widget.playlist.smart) - // Smart playlists: Use grid view (cannot be reordered) - MediaGridSliver(items: items, onRefresh: updateItem) - else - // Regular playlists: Use reorderable list view - SliverReorderableList( - itemBuilder: (context, index) { - final item = items[index]; - return PlaylistItemCard( - key: ValueKey(item.playlistItemID ?? item.ratingKey), - item: item, - index: index, - onRemove: () => _removeItem(index), - onTap: () => _playFromItem(index), - onRefresh: updateItem, - canReorder: !widget.playlist.smart, - ); - }, - itemCount: items.length, - onReorder: _onReorder, - ), - ], ); } } diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index a15274c9..f879688d 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -20,6 +20,11 @@ class PlaylistItemCard extends StatefulWidget { final void Function(String ratingKey)? onRefresh; final bool canReorder; // Whether drag handle should be shown + // Focus state for keyboard/D-pad navigation + final bool isFocused; + final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button + final bool isMoving; // Whether this item is being moved/reordered + const PlaylistItemCard({ super.key, required this.item, @@ -28,6 +33,9 @@ class PlaylistItemCard extends StatefulWidget { this.onTap, this.onRefresh, this.canReorder = true, + this.isFocused = false, + this.focusedColumn, + this.isMoving = false, }); @override @@ -40,6 +48,29 @@ class _PlaylistItemCardState extends State { @override Widget build(BuildContext context) { final item = widget.item; + final colorScheme = Theme.of(context).colorScheme; + + // Determine if row is focused (main content area) + final isRowFocused = widget.isFocused && widget.focusedColumn == 0; + + // Focus states for individual elements + final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1; + final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2; + + // Determine card styling based on focus/move state + Color? cardColor; + ShapeBorder? cardShape; + if (widget.isMoving) { + cardColor = colorScheme.primaryContainer; + } else if (isRowFocused) { + // Row is focused - use visible border like FocusableWrapper + cardColor = colorScheme.surfaceContainerHighest; + cardShape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: colorScheme.primary, width: 2.5), + ); + } + return MediaContextMenu( key: _contextMenuKey, item: item, @@ -47,6 +78,8 @@ class _PlaylistItemCardState extends State { onTap: widget.onTap, child: Card( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: cardColor, + shape: cardShape, child: InkWell( onTap: widget.onTap, child: Padding( @@ -60,9 +93,20 @@ class _PlaylistItemCardState extends State { onLongPress: () {}, child: ReorderableDragStartListener( index: widget.index, - child: const Padding( - padding: EdgeInsets.only(right: 12), - child: AppIcon(Symbols.drag_indicator_rounded, fill: 1, color: Colors.grey), + child: Container( + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(right: 4), + decoration: isDragHandleFocused + ? BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + ) + : null, + child: AppIcon( + widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded, + fill: 1, + color: (widget.isMoving || isDragHandleFocused) ? colorScheme.primary : Colors.grey, + ), ), ), ), @@ -115,11 +159,19 @@ class _PlaylistItemCardState extends State { const SizedBox(width: 8), // Remove button - IconButton( - icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20), - onPressed: widget.onRemove, - tooltip: t.playlists.removeItem, - color: Colors.grey[400], + Container( + decoration: isRemoveButtonFocused + ? BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ) + : null, + child: IconButton( + icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20), + onPressed: widget.onRemove, + tooltip: t.playlists.removeItem, + color: isRemoveButtonFocused ? colorScheme.primary : Colors.grey[400], + ), ), ], ), diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index b7ac1bbd..c0e10523 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 392fae5d..3f991f16 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1012,6 +1012,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!mounted) return; } } + if (!mounted) return; _isExiting.value = true; Navigator.of(context).pop(true); } @@ -1031,6 +1032,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } // Default behavior for hosts or non-session users + if (!mounted) return; _isExiting.value = true; Navigator.of(context).pop(true); } diff --git a/lib/utils/formatters.dart b/lib/utils/formatters.dart index c071f3b9..a21de477 100644 --- a/lib/utils/formatters.dart +++ b/lib/utils/formatters.dart @@ -130,7 +130,7 @@ String formatDurationTimestamp(Duration duration) { final minutes = absoluteDuration.inMinutes.remainder(60); final seconds = absoluteDuration.inSeconds.remainder(60); - final result; + final String result; if (hours > 0) { result = '$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index b0c0b95f..41909494 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; /// A ListTile that accepts a FocusNode for keyboard/controller navigation. diff --git a/pubspec.lock b/pubspec.lock index 5025f4e0..b5cd4705 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -678,7 +678,7 @@ packages: source: hosted version: "2.0.5" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" diff --git a/pubspec.yaml b/pubspec.yaml index 1f9957ac..d19ef8b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + intl: ^0.20.2 dio: ^5.4.0 json_annotation: ^4.8.1 shared_preferences: ^2.2.2