From ca9b6a9ecdabac993e8ffbb6c9de34234191d8de Mon Sep 17 00:00:00 2001 From: dwk001 Date: Sun, 18 Jan 2026 14:21:37 -0500 Subject: [PATCH 1/2] Fix: Android TV-only Manage Libraries dialog with focus scroll support - Use Dialog + Scaffold for TV to improve library visibility and keyboard navigation - Added _ensureFocusedVisible() to scroll focused library into view on D-pad movement - Maintained DraggableScrollableSheet for non-TV platforms (unchanged behavior) - Only affects Android TV; Windows/desktop/mobile use original modal bottomsheet - Preserves all existing functionality (reordering, visibility toggle, options menu) --- lib/screens/libraries/libraries_screen.dart | 142 +++++++++++++++++--- 1 file changed, 124 insertions(+), 18 deletions(-) diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 7c38735a..1aaf8f3c 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -776,23 +776,43 @@ class _LibrariesScreenState extends State void _showLibraryManagementSheet() { final hiddenLibrariesProvider = Provider.of(context, listen: false); - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => _LibraryManagementSheet( - allLibraries: List.from(_allLibraries), - hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - onReorder: (reorderedLibraries) { - setState(() { - _allLibraries = reorderedLibraries; - }); - _saveLibraryOrder(); - }, - onToggleVisibility: _toggleLibraryVisibility, - getLibraryMenuItems: _getLibraryMenuItems, - onLibraryMenuAction: _handleLibraryMenuAction, - ), - ); + if (PlatformDetector.isTV()) { + showDialog( + context: context, + builder: (context) => _LibraryManagementSheet( + isDialog: true, + allLibraries: List.from(_allLibraries), + hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, + onReorder: (reorderedLibraries) { + setState(() { + _allLibraries = reorderedLibraries; + }); + _saveLibraryOrder(); + }, + onToggleVisibility: _toggleLibraryVisibility, + getLibraryMenuItems: _getLibraryMenuItems, + onLibraryMenuAction: _handleLibraryMenuAction, + ), + ); + } else { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _LibraryManagementSheet( + allLibraries: List.from(_allLibraries), + hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, + onReorder: (reorderedLibraries) { + setState(() { + _allLibraries = reorderedLibraries; + }); + _saveLibraryOrder(); + }, + onToggleVisibility: _toggleLibraryVisibility, + getLibraryMenuItems: _getLibraryMenuItems, + onLibraryMenuAction: _handleLibraryMenuAction, + ), + ); + } } Future _performLibraryAction({ @@ -1162,6 +1182,7 @@ class _LibrariesScreenState extends State } class _LibraryManagementSheet extends StatefulWidget { + final bool isDialog; final List allLibraries; final Set hiddenLibraryKeys; final Function(List) onReorder; @@ -1170,6 +1191,7 @@ class _LibraryManagementSheet extends StatefulWidget { final void Function(String action, PlexLibrary library) onLibraryMenuAction; const _LibraryManagementSheet({ + this.isDialog = false, required this.allLibraries, required this.hiddenLibraryKeys, required this.onReorder, @@ -1192,6 +1214,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { int? _originalIndex; // Original position before move (for cancel) List? _originalOrder; // Original order before move (for cancel) final FocusNode _listFocusNode = FocusNode(); + final Map _tileKeys = {}; // For dialog mode scroll-into-view @override void initState() { @@ -1205,6 +1228,22 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { super.dispose(); } + void _ensureFocusedVisible() { + if (!widget.isDialog) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final key = _tileKeys[_focusedIndex]; + final context = key?.currentContext; + if (context != null) { + Scrollable.ensureVisible( + context, + alignment: 0.25, + duration: const Duration(milliseconds: 200), + ); + } + }); + } + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; @@ -1260,6 +1299,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { _focusedIndex--; _focusedColumn = 0; // Reset to row when changing rows }); + _ensureFocusedVisible(); return KeyEventResult.handled; } if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) { @@ -1267,6 +1307,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { _focusedIndex++; _focusedColumn = 0; // Reset to row when changing rows }); + _ensureFocusedVisible(); return KeyEventResult.handled; } if (key.isLeftKey && _focusedColumn > 0) { @@ -1312,6 +1353,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { } final library = _tempLibraries.removeAt(oldIndex); _tempLibraries.insert(newIndex, library); + if (widget.isDialog) { + _tileKeys.clear(); + } }); // Apply immediately widget.onReorder(_tempLibraries); @@ -1385,6 +1429,35 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final hiddenLibrariesProvider = context.watch(); final hiddenLibraryKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + if (widget.isDialog) { + return Dialog( + child: Scaffold( + appBar: AppBar( + title: const Row( + children: [ + AppIcon(Symbols.edit_rounded, fill: 1), + SizedBox(width: 12), + Text('Manage Libraries'), + ], + ), + automaticallyImplyLeading: false, + actions: [ + IconButton( + icon: const AppIcon(Symbols.close_rounded, fill: 1), + onPressed: () => Navigator.pop(context), + ), + ], + ), + body: Focus( + focusNode: _listFocusNode, + autofocus: InputModeTracker.isKeyboardMode(context), + onKeyEvent: _handleKeyEvent, + child: _buildFlatLibraryListDialog(hiddenLibraryKeys), + ), + ), + ); + } + return DraggableScrollableSheet( initialChildSize: 0.7, minChildSize: 0.5, @@ -1432,6 +1505,38 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ); } + /// Build library list for dialog (TV) using ListView with scroll-into-view support + Widget _buildFlatLibraryListDialog(Set hiddenLibraryKeys) { + final nonUniqueNames = _getNonUniqueLibraryNames(); + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + return ReorderableListView.builder( + onReorder: _reorderLibraries, + itemCount: _tempLibraries.length, + padding: const EdgeInsets.symmetric(vertical: 8), + buildDefaultDragHandles: false, + itemBuilder: (context, index) { + final library = _tempLibraries[index]; + final showServerName = nonUniqueNames.contains(library.title) && library.serverName != null; + final isFocused = isKeyboardMode && index == _focusedIndex; + final isMoving = index == _movingIndex; + + _tileKeys.putIfAbsent(index, () => GlobalKey()); + + return _buildLibraryTile( + library, + index, + hiddenLibraryKeys, + showServerName: showServerName, + isFocused: isFocused, + isMoving: isMoving, + focusedColumn: isFocused ? _focusedColumn : null, + tileKey: _tileKeys[index], + ); + }, + ); + } + /// Build flat library list with server subtitle for non-unique names Widget _buildFlatLibraryList(ScrollController scrollController, Set hiddenLibraryKeys) { final nonUniqueNames = _getNonUniqueLibraryNames(); @@ -1470,6 +1575,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { bool isFocused = false, bool isMoving = false, int? focusedColumn, + Key? tileKey, }) { final isHidden = hiddenLibraryKeys.contains(library.globalKey); final colorScheme = Theme.of(context).colorScheme; @@ -1488,7 +1594,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final isOptionsButtonFocused = isFocused && focusedColumn == 2; return Opacity( - key: ValueKey(library.globalKey), + key: tileKey ?? ValueKey(library.globalKey), opacity: isHidden ? 0.5 : 1.0, child: Container( decoration: BoxDecoration(color: tileColor), From 91b11a51375d67358b3b68a883c75d58f03fa301 Mon Sep 17 00:00:00 2001 From: dwk001 Date: Sun, 18 Jan 2026 15:14:42 -0500 Subject: [PATCH 2/2] fix: Localize 'Manage Libraries' dialog title Use t.libraries.manageLibraries translation key instead of hardcoded English string. --- lib/screens/libraries/libraries_screen.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 1aaf8f3c..2c685fa2 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1433,11 +1433,11 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { return Dialog( child: Scaffold( appBar: AppBar( - title: const Row( + title: Row( children: [ - AppIcon(Symbols.edit_rounded, fill: 1), - SizedBox(width: 12), - Text('Manage Libraries'), + const AppIcon(Symbols.edit_rounded, fill: 1), + const SizedBox(width: 12), + Text(t.libraries.manageLibraries), ], ), automaticallyImplyLeading: false,