diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 4d63ead4..1b3d6a97 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -255,6 +255,7 @@ "traktDescription": "Sync watch history with Trakt", "trackers": "Trackers", "trackersDescription": "Sync progress to Trakt, MyAnimeList, AniList, and Simkl", + "manageLibrariesDescription": "Reorder and hide libraries", "companionRemoteServer": "Companion Remote Server", "companionRemoteServerDescription": "Allow mobile devices on your network to control this app", "autoPip": "Auto Picture-in-Picture", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 10be4474..8f4e6c1d 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 16 -/// Strings: 20718 (1294 per locale) +/// Strings: 20719 (1294 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 075572f2..899496ea 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -876,6 +876,9 @@ class TranslationsSettingsEn { /// en: 'Sync progress to Trakt, MyAnimeList, AniList, and Simkl' String get trackersDescription => 'Sync progress to Trakt, MyAnimeList, AniList, and Simkl'; + /// en: 'Reorder and hide libraries' + String get manageLibrariesDescription => 'Reorder and hide libraries'; + /// en: 'Companion Remote Server' String get companionRemoteServer => 'Companion Remote Server'; @@ -4824,6 +4827,7 @@ extension on Translations { 'settings.traktDescription' => 'Sync watch history with Trakt', 'settings.trackers' => 'Trackers', 'settings.trackersDescription' => 'Sync progress to Trakt, MyAnimeList, AniList, and Simkl', + 'settings.manageLibrariesDescription' => 'Reorder and hide libraries', 'settings.companionRemoteServer' => 'Companion Remote Server', 'settings.companionRemoteServerDescription' => 'Allow mobile devices on your network to control this app', 'settings.autoPip' => 'Auto Picture-in-Picture', @@ -5090,9 +5094,9 @@ extension on Translations { 'messages.unableToDetermineLibrarySection' => 'Unable to determine library section for this item', 'messages.logsCleared' => 'Logs cleared', 'messages.logsCopied' => 'Logs copied to clipboard', - 'messages.noLogsAvailable' => 'No logs available', _ => null, } ?? switch (path) { + 'messages.noLogsAvailable' => 'No logs available', 'messages.libraryScanning' => ({required Object title}) => 'Scanning "${title}"...', 'messages.libraryScanStarted' => ({required Object title}) => 'Library scan started for "${title}"', 'messages.libraryScanFailed' => ({required Object error}) => 'Failed to scan library: ${error}', @@ -5604,9 +5608,9 @@ extension on Translations { 'shaders.shaderImported' => 'Shader imported', 'shaders.shaderImportFailed' => 'Failed to import shader', 'shaders.deleteShader' => 'Delete Shader', - 'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?', _ => null, } ?? switch (path) { + 'shaders.deleteShaderConfirm' => ({required Object name}) => 'Delete "${name}"?', 'companionRemote.title' => 'Companion Remote', 'companionRemote.connectedTo' => ({required Object name}) => 'Connected to ${name}', 'companionRemote.unknownDevice' => 'Unknown Device', diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 3e6bb8b9..52209e54 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -4,36 +4,27 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../../focus/focus_theme.dart'; import '../../focus/focusable_action_bar.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/input_mode_tracker.dart'; -import '../../focus/key_event_utils.dart'; import '../../mixins/tab_navigation_mixin.dart'; -import '../../../services/plex_client.dart'; import '../../media/ids.dart'; -import '../../media/media_backend.dart'; import '../../media/media_item.dart'; import '../../media/media_library.dart'; -import '../../media/media_server_client.dart'; import '../../providers/hidden_libraries_provider.dart'; import '../../providers/libraries_provider.dart'; import '../../providers/multi_server_provider.dart'; import '../../services/settings_service.dart'; import '../../widgets/settings_builder.dart'; import '../../utils/app_logger.dart'; -import '../../utils/dialogs.dart'; import '../../utils/library_grouping.dart'; import '../../utils/platform_detector.dart'; -import '../../utils/provider_extensions.dart'; -import '../../utils/snackbar_helper.dart'; import '../../utils/content_utils.dart'; import '../../widgets/app_menu.dart'; import '../../widgets/backend_badge.dart'; import '../../widgets/desktop_app_bar.dart'; -import '../../widgets/overlay_sheet.dart'; +import '../../widgets/library_management_sheet.dart'; import '../../services/storage_service.dart'; import '../../mixins/refreshable.dart'; import '../../mixins/item_updatable.dart'; @@ -51,27 +42,6 @@ List _getVisibleTabs(MediaLibrary library) { return LibraryTabType.values; } -/// A menu action item for context menus -class ContextMenuItem { - final String value; - final IconData icon; - final String label; - final bool requiresConfirmation; - final String? confirmationTitle; - final String? confirmationMessage; - final bool isDestructive; - - const ContextMenuItem({ - required this.value, - required this.icon, - required this.label, - this.requiresConfirmation = false, - this.confirmationTitle, - this.confirmationMessage, - this.isDestructive = false, - }); -} - class LibrariesScreen extends StatefulWidget { final VoidCallback? onLibraryOrderChanged; final ValueChanged? onLibrarySelected; @@ -604,221 +574,11 @@ class _LibrariesScreenState extends State } } - List _getLibraryMenuItems(MediaLibrary library) { - // Refresh metadata is the only admin action both backends support — Plex - // hits `/library/sections/{id}/refresh?force=1`, Jellyfin posts to - // `/Items/{id}/Refresh` (the library view is itself an item). - final refresh = ContextMenuItem( - value: 'refresh', - icon: Symbols.sync_rounded, - label: t.libraries.refreshMetadata, - requiresConfirmation: true, - confirmationTitle: t.libraries.refreshMetadata, - confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title), - isDestructive: true, - ); - // Scan / analyze / empty trash hit Plex-only endpoints. Gating them keeps - // [getPlexClientForLibrary] from falling back through `_resolveClient` to - // the first online Plex server and firing the action against the wrong - // backend. - if (library.backend != MediaBackend.plex) return [refresh]; - return [ - ContextMenuItem( - value: 'scan', - icon: Symbols.refresh_rounded, - label: t.libraries.scanLibraryFiles, - requiresConfirmation: true, - confirmationTitle: t.libraries.scanLibrary, - confirmationMessage: t.libraries.scanLibraryConfirm(title: library.title), - ), - ContextMenuItem( - value: 'analyze', - icon: Symbols.analytics_rounded, - label: t.libraries.analyze, - requiresConfirmation: true, - confirmationTitle: t.libraries.analyzeLibrary, - confirmationMessage: t.libraries.analyzeLibraryConfirm(title: library.title), - ), - refresh, - ContextMenuItem( - value: 'empty_trash', - icon: Symbols.delete_outline_rounded, - label: t.libraries.emptyTrash, - requiresConfirmation: true, - confirmationTitle: t.libraries.emptyTrash, - confirmationMessage: t.libraries.emptyTrashConfirm(title: library.title), - isDestructive: true, - ), - ]; - } - - Future _handleLibraryMenuAction(String action, MediaLibrary library) async { - // Find the menu item for confirmation details - final menuItems = _getLibraryMenuItems(library); - final item = menuItems.where((i) => i.value == action).firstOrNull; - if (item == null) return; - - if (item.requiresConfirmation) { - final confirmed = await showConfirmDialog( - context, - title: item.confirmationTitle ?? t.dialog.confirmAction, - message: item.confirmationMessage ?? t.libraries.confirmActionMessage, - confirmText: t.common.confirm, - isDestructive: item.isDestructive, - ); - if (!confirmed) return; - } - - switch (action) { - case 'scan': - unawaited(_scanLibrary(library)); - break; - case 'analyze': - unawaited(_analyzeLibrary(library)); - break; - case 'refresh': - unawaited(_refreshLibraryMetadata(library)); - break; - case 'empty_trash': - unawaited(_emptyLibraryTrash(library)); - break; - } - } - void _showLibraryManagementSheet() { - final librariesProvider = context.read(); - final hiddenLibrariesProvider = Provider.of(context, listen: false); - final allLibraries = librariesProvider.libraries; - - if (PlatformDetector.isTV()) { - showScopedDialog( - context: context, - builder: (context) => _LibraryManagementSheet( - isDialog: true, - allLibraries: List.from(allLibraries), - hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - onReorder: (reorderedLibraries) { - librariesProvider.updateLibraryOrder(reorderedLibraries); - _notifyLibraryOrderChanged(); - }, - onToggleVisibility: _toggleLibraryVisibility, - getLibraryMenuItems: _getLibraryMenuItems, - onLibraryMenuAction: _handleLibraryMenuAction, - ), - ); - } else { - OverlaySheetController.of(context).show( - showDragHandle: true, - builder: (context) => _LibraryManagementSheet( - allLibraries: List.from(allLibraries), - hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - onReorder: (reorderedLibraries) { - librariesProvider.updateLibraryOrder(reorderedLibraries); - _notifyLibraryOrderChanged(); - }, - onToggleVisibility: _toggleLibraryVisibility, - getLibraryMenuItems: _getLibraryMenuItems, - onLibraryMenuAction: _handleLibraryMenuAction, - ), - ); - } - } - - Future _performLibraryAction({ - required MediaLibrary library, - required Future Function(PlexClient client) action, - required String progressMessage, - required String successMessage, - required String Function(Object error) failureMessage, - }) async { - try { - final client = context.getPlexClientForLibrary(library); - - if (mounted) { - showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); - } - - await action(client); - - if (mounted) { - showSuccessSnackBar(context, successMessage); - } - } catch (e) { - appLogger.e('Library action failed', error: e); - if (mounted) { - showErrorSnackBar(context, failureMessage(e)); - } - } - } - - /// Backend-neutral counterpart to [_performLibraryAction] for ops that exist - /// on the [MediaServerClient] interface (currently just refresh metadata). - /// Resolves the client through `getMediaClientForLibrary` so a Jellyfin - /// library is routed to its own server, not a fallback Plex one. - Future _performMediaLibraryAction({ - required MediaLibrary library, - required Future Function(MediaServerClient client) action, - required String progressMessage, - required String successMessage, - required String Function(Object error) failureMessage, - }) async { - try { - final client = context.getMediaClientForLibrary(library); - - if (mounted) { - showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); - } - - await action(client); - - if (mounted) { - showSuccessSnackBar(context, successMessage); - } - } catch (e) { - appLogger.e('Library action failed', error: e); - if (mounted) { - showErrorSnackBar(context, failureMessage(e)); - } - } - } - - Future _scanLibrary(MediaLibrary library) { - return _performLibraryAction( - library: library, - action: (client) => client.scanLibrary(library.id), - progressMessage: t.messages.libraryScanning(title: library.title), - successMessage: t.messages.libraryScanStarted(title: library.title), - failureMessage: (error) => t.messages.libraryScanFailed(error: error.toString()), - ); - } - - Future _refreshLibraryMetadata(MediaLibrary library) { - return _performMediaLibraryAction( - library: library, - action: (client) => client.refreshLibraryMetadata(library.id), - progressMessage: t.messages.metadataRefreshing(title: library.title), - successMessage: t.messages.metadataRefreshStarted(title: library.title), - failureMessage: (error) => t.messages.metadataRefreshFailed(error: error.toString()), - ); - } - - Future _emptyLibraryTrash(MediaLibrary library) { - return _performLibraryAction( - library: library, - action: (client) => client.emptyLibraryTrash(library.id), - progressMessage: t.libraries.emptyingTrash(title: library.title), - successMessage: t.libraries.trashEmptied(title: library.title), - failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error), - ); - } - - Future _analyzeLibrary(MediaLibrary library) { - return _performLibraryAction( - library: library, - action: (client) => client.analyzeLibrary(library.id), - progressMessage: t.libraries.analyzing(title: library.title), - successMessage: t.libraries.analysisStarted(title: library.title), - failureMessage: (error) => t.libraries.failedToAnalyze(error: error), + showLibraryManagementSheet( + context, + onOrderChanged: _notifyLibraryOrderChanged, + onToggleVisibility: _toggleLibraryVisibility, ); } @@ -1246,458 +1006,3 @@ class _LibrariesScreenState extends State return Scaffold(body: scrollBody); } } - -class _LibraryManagementSheet extends StatefulWidget { - final bool isDialog; - final List allLibraries; - final Set hiddenLibraryKeys; - final Function(List) onReorder; - final Function(MediaLibrary) onToggleVisibility; - final List Function(MediaLibrary) getLibraryMenuItems; - final void Function(String action, MediaLibrary library) onLibraryMenuAction; - - const _LibraryManagementSheet({ - this.isDialog = false, - required this.allLibraries, - required this.hiddenLibraryKeys, - required this.onReorder, - required this.onToggleVisibility, - required this.getLibraryMenuItems, - required this.onLibraryMenuAction, - }); - - @override - State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState(); -} - -class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { - late List _tempLibraries; - - // Keyboard navigation state - int _focusedIndex = 0; - int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button - int? _movingIndex; // Non-null when in move mode - int? _originalIndex; // Original position before move (for cancel) - List? _originalOrder; // Original order before move (for cancel) - final FocusNode _listFocusNode = FocusNode(); - final ScrollController _dialogScrollController = ScrollController(); - bool _backKeyDownSeen = false; - - @override - void initState() { - super.initState(); - _tempLibraries = List.from(widget.allLibraries); - } - - @override - void dispose() { - _listFocusNode.dispose(); - _dialogScrollController.dispose(); - super.dispose(); - } - - void _ensureFocusedVisible() { - if (!widget.isDialog) return; - if (!_dialogScrollController.hasClients) return; - - const double itemHeight = 72.0; // Material ListTile with subtitle - const double listTopPadding = 8.0; - final double targetTop = listTopPadding + (_focusedIndex * itemHeight); - final double targetBottom = targetTop + itemHeight; - - final double viewportTop = _dialogScrollController.offset; - final double viewportHeight = _dialogScrollController.position.viewportDimension; - final double viewportBottom = viewportTop + viewportHeight; - - // Already fully visible — skip - if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; - - // Place item at ~25% from top of viewport - final double destination = (targetTop - viewportHeight * 0.25).clamp( - 0.0, - _dialogScrollController.position.maxScrollExtent, - ); - - _dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - // Track back key down/up pairing. If focus was elsewhere during KeyDown - // (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray - // KeyUp that would incorrectly pop the dialog. Consume it instead. - if (key.isBackKey) { - if (event is KeyDownEvent) { - _backKeyDownSeen = true; - } else if (event is KeyUpEvent && !_backKeyDownSeen) { - return KeyEventResult.handled; - } - if (event is KeyUpEvent) { - _backKeyDownSeen = false; - } - } - - final backResult = handleBackKeyAction(event, () { - if (_movingIndex != null) { - // Cancel move - restore original position - setState(() { - if (_originalOrder != null) { - _tempLibraries = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - } else { - OverlaySheetController.popAdaptive(context); - } - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (!event.isActionable) return KeyEventResult.ignored; - - if (_movingIndex != null) { - // Move mode - arrows reorder the item - if (key.isUpKey && _movingIndex! > 0) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! - 1, item); - _movingIndex = _movingIndex! - 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! + 1, item); - _movingIndex = _movingIndex! + 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - // Confirm move - apply the reorder - widget.onReorder(_tempLibraries); - setState(() { - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - return KeyEventResult.handled; - } - } else { - // Navigation mode - if (key.isUpKey && _focusedIndex > 0) { - setState(() { - _focusedIndex--; - _focusedColumn = 0; // Reset to row when changing rows - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) { - setState(() { - _focusedIndex++; - _focusedColumn = 0; // Reset to row when changing rows - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isLeftKey && _focusedColumn > 0) { - setState(() => _focusedColumn--); - return KeyEventResult.handled; - } - if (key.isRightKey && _focusedColumn < 2) { - setState(() => _focusedColumn++); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - if (_focusedColumn == 0) { - // Enter move mode - setState(() { - _movingIndex = _focusedIndex; - _originalIndex = _focusedIndex; - _originalOrder = List.from(_tempLibraries); - }); - } else if (_focusedColumn == 1) { - // Toggle visibility - final library = _tempLibraries[_focusedIndex]; - widget.onToggleVisibility(library); - } else if (_focusedColumn == 2) { - // Show options menu - final library = _tempLibraries[_focusedIndex]; - _showLibraryMenuBottomSheet(context, library); - } - return KeyEventResult.handled; - } - } - - // Block d-pad keys at boundaries so focus doesn't escape the dialog - if (key.isDpadDirection) { - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - - void _reorderLibraries(int oldIndex, int newIndex) { - setState(() { - final library = _tempLibraries.removeAt(oldIndex); - _tempLibraries.insert(newIndex, library); - }); - // Apply immediately - widget.onReorder(_tempLibraries); - } - - void _showLibraryMenuBottomSheet(BuildContext outerContext, MediaLibrary library) { - final menuItems = widget.getLibraryMenuItems(library); - OverlaySheetController.pushAdaptive( - outerContext, - builder: (context) => AppMenuSheet( - title: library.title, - entries: [ - for (final item in menuItems) - AppMenuItem(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive), - ], - onSelected: (value) => widget.onLibraryMenuAction(value, library), - ), - ); - } - - /// Whether the libraries span more than one connected server. - bool _hasMultipleServers() { - final serverIds = _tempLibraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet(); - return serverIds.length > 1; - } - - @override - Widget build(BuildContext context) { - // Watch provider to rebuild when hidden libraries change - final hiddenLibrariesProvider = context.watch(); - final hiddenLibraryKeys = hiddenLibrariesProvider.hiddenLibraryKeys; - - if (widget.isDialog) { - return Dialog( - child: PopScope( - canPop: false, // Prevent system back from double-popping; handled by _handleKeyEvent - // ignore: no-empty-block - required callback, blocks system back on Android TV - onPopInvokedWithResult: (didPop, result) {}, - child: Scaffold( - appBar: AppBar( - title: Row( - children: [ - const AppIcon(Symbols.edit_rounded, fill: 1), - const SizedBox(width: 12), - Text(t.libraries.manageLibraries), - ], - ), - 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, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), - ), - child: Row( - children: [ - const AppIcon(Symbols.edit_rounded, fill: 1), - const SizedBox(width: 12), - Expanded( - child: Text(t.libraries.manageLibraries, style: const TextStyle(fontSize: 20, fontWeight: .bold)), - ), - IconButton( - icon: const AppIcon(Symbols.close_rounded, fill: 1), - onPressed: () => OverlaySheetController.popAdaptive(context), - ), - ], - ), - ), - - // Library list (grouped by server if multiple servers) - Expanded( - child: Focus( - focusNode: _listFocusNode, - autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, - child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys), - ), - ), - ], - ); - }, - ); - } - - /// Build library list for dialog (TV) using ListView with scroll-into-view support - Widget _buildFlatLibraryListDialog(Set hiddenLibraryKeys) { - final showServerNames = _hasMultipleServers(); - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return ReorderableListView.builder( - scrollController: _dialogScrollController, - onReorderItem: _reorderLibraries, - itemCount: _tempLibraries.length, - padding: const EdgeInsets.symmetric(vertical: 8), - buildDefaultDragHandles: false, - itemBuilder: (context, index) { - final library = _tempLibraries[index]; - final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; - - return _buildLibraryTile( - library, - index, - hiddenLibraryKeys, - showServerName: showServerName, - isFocused: isFocused, - isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, - ); - }, - ); - } - - /// Build flat library list with a server subtitle when multiple servers are connected - Widget _buildFlatLibraryList(ScrollController scrollController, Set hiddenLibraryKeys) { - final showServerNames = _hasMultipleServers(); - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return ReorderableListView.builder( - scrollController: scrollController, - onReorderItem: _reorderLibraries, - itemCount: _tempLibraries.length, - padding: const EdgeInsets.symmetric(vertical: 8), - buildDefaultDragHandles: false, - itemBuilder: (context, index) { - final library = _tempLibraries[index]; - final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; - return _buildLibraryTile( - library, - index, - hiddenLibraryKeys, - showServerName: showServerName, - isFocused: isFocused, - isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, - ); - }, - ); - } - - /// Build a single library tile - Widget _buildLibraryTile( - MediaLibrary library, - int index, - Set hiddenLibraryKeys, { - bool showServerName = false, - bool isFocused = false, - bool isMoving = false, - int? focusedColumn, - }) { - final isHidden = hiddenLibraryKeys.contains(library.globalKey); - final colorScheme = Theme.of(context).colorScheme; - - // Determine background color based on state - Color? tileColor; - if (isMoving) { - tileColor = colorScheme.primaryContainer; - } else if (isFocused && focusedColumn == 0) { - // Only highlight row when row itself is focused (column 0) - tileColor = colorScheme.surfaceContainerHighest; - } - - // Button focus states - final isVisibilityButtonFocused = isFocused && focusedColumn == 1; - final isOptionsButtonFocused = isFocused && focusedColumn == 2; - - return Opacity( - key: ValueKey(library.globalKey), - opacity: isHidden ? 0.5 : 1.0, - child: ListTile( - tileColor: tileColor, - leading: Row( - mainAxisSize: .min, - children: [ - ReorderableDragStartListener( - index: index, - child: AppIcon( - isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded, - fill: 1, - color: isMoving ? colorScheme.primary : IconTheme.of(context).color?.withValues(alpha: 0.5), - ), - ), - const SizedBox(width: 8), - AppIcon(ContentTypeHelper.getLibraryIcon(library.kind.id), fill: 1), - ], - ), - title: Text(library.title), - subtitle: showServerName - ? Text( - library.serverName!, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - ) - : null, - trailing: Row( - mainAxisSize: .min, - children: [ - Container( - decoration: FocusTheme.focusBackgroundDecoration(isFocused: isVisibilityButtonFocused, borderRadius: 20), - child: IconButton( - icon: AppIcon(isHidden ? Symbols.visibility_off_rounded : Symbols.visibility_rounded, fill: 1), - tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary, - onPressed: () => widget.onToggleVisibility(library), - ), - ), - Container( - decoration: FocusTheme.focusBackgroundDecoration(isFocused: isOptionsButtonFocused, borderRadius: 20), - child: IconButton( - icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), - tooltip: t.libraries.libraryOptions, - onPressed: () => _showLibraryMenuBottomSheet(context, library), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index ab826a69..1c7556a9 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -35,6 +35,7 @@ import '../../utils/platform_detector.dart'; import '../../utils/update_dialog.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/dialog_action_button.dart'; +import '../../widgets/library_management_sheet.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; @@ -64,6 +65,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun static const _kDonate = 'donate'; static const _kAppearance = 'appearance'; static const _kPlayback = 'playback'; + static const _kManageLibraries = 'manage_libraries'; static const _kTrackers = 'trackers'; static const _kDownloadLocation = 'download_location'; static const _kDownloadOnWifiOnly = 'download_on_wifi_only'; @@ -135,6 +137,8 @@ class _SettingsScreenState extends State with FocusableTab, Moun @override Widget build(BuildContext context) { + final hasLibraries = context.select((p) => p.libraries.isNotEmpty); + return Scaffold( body: Focus( onKeyEvent: _handleKeyEvent, @@ -150,6 +154,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun if (DonationService.isEnabled) _buildDonateTile(), _buildAppearanceTile(), _buildPlaybackTile(), + if (hasLibraries) _buildManageLibrariesTile(), _buildTrackersTile(), ], ), @@ -233,6 +238,16 @@ class _SettingsScreenState extends State with FocusableTab, Moun ); } + Widget _buildManageLibrariesTile() { + return SettingNavigationTile( + focusNode: _focusTracker.get(_kManageLibraries), + icon: Symbols.video_library_rounded, + title: t.libraries.manageLibraries, + subtitle: t.settings.manageLibrariesDescription, + onTap: () => showLibraryManagementSheet(context), + ); + } + Widget _buildTrackersTile() { return Consumer2( builder: (context, trakt, trackers, _) { diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart new file mode 100644 index 00000000..7c396ca5 --- /dev/null +++ b/lib/widgets/library_management_sheet.dart @@ -0,0 +1,742 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/focus_theme.dart'; +import '../focus/input_mode_tracker.dart'; +import '../focus/key_event_utils.dart'; +import '../i18n/strings.g.dart'; +import '../media/media_backend.dart'; +import '../media/media_library.dart'; +import '../media/media_server_client.dart'; +import '../providers/hidden_libraries_provider.dart'; +import '../providers/libraries_provider.dart'; +import '../services/plex_client.dart'; +import '../utils/app_logger.dart'; +import '../utils/content_utils.dart'; +import '../utils/dialogs.dart'; +import '../utils/platform_detector.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/snackbar_helper.dart'; +import 'app_icon.dart'; +import 'app_menu.dart'; +import 'overlay_sheet.dart'; + +/// A menu action item for context menus +class ContextMenuItem { + final String value; + final IconData icon; + final String label; + final bool requiresConfirmation; + final String? confirmationTitle; + final String? confirmationMessage; + final bool isDestructive; + + const ContextMenuItem({ + required this.value, + required this.icon, + required this.label, + this.requiresConfirmation = false, + this.confirmationTitle, + this.confirmationMessage, + this.isDestructive = false, + }); +} + +/// Shows the manage/reorder-libraries sheet (dialog on TV, overlay sheet +/// otherwise). Reorder and hide/unhide are provider-backed, so any screen can +/// open it. +/// +/// [onOrderChanged] runs after the new order is written to +/// [LibrariesProvider] (the libraries screen uses it to poke MainScreen's +/// side nav). [onToggleVisibility] overrides the default plain hide/unhide +/// (the libraries screen adds "re-select first visible library" logic). +Future showLibraryManagementSheet( + BuildContext context, { + VoidCallback? onOrderChanged, + Future Function(MediaLibrary library)? onToggleVisibility, +}) { + final librariesProvider = context.read(); + final hiddenLibrariesProvider = context.read(); + final allLibraries = librariesProvider.libraries; + + Future defaultToggleVisibility(MediaLibrary library) async { + final isHidden = hiddenLibrariesProvider.hiddenLibraryKeys.contains(library.globalKey); + if (isHidden) { + await hiddenLibrariesProvider.unhideLibrary(library.globalKey); + } else { + await hiddenLibrariesProvider.hideLibrary(library.globalKey); + } + } + + Widget buildSheet({required bool isDialog}) => _LibraryManagementSheet( + isDialog: isDialog, + allLibraries: List.from(allLibraries), + hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, + onReorder: (reorderedLibraries) { + librariesProvider.updateLibraryOrder(reorderedLibraries); + onOrderChanged?.call(); + }, + onToggleVisibility: onToggleVisibility ?? defaultToggleVisibility, + getLibraryMenuItems: _getLibraryMenuItems, + onLibraryMenuAction: (action, library) => _handleLibraryMenuAction(context, action, library), + ); + + if (PlatformDetector.isTV()) { + return showScopedDialog(context: context, builder: (context) => buildSheet(isDialog: true)); + } + // showAdaptive rather than of(context).show: on mobile, settings is a pushed + // route outside the OverlaySheetHost subtree, so only the fallback path is + // available there. isScrollControlled keeps the fallback modal from capping + // the sheet at ~9/16 of the screen. + return OverlaySheetController.showAdaptive( + context, + showDragHandle: true, + isScrollControlled: true, + builder: (context) => buildSheet(isDialog: false), + ); +} + +List _getLibraryMenuItems(MediaLibrary library) { + // Refresh metadata is the only admin action both backends support — Plex + // hits `/library/sections/{id}/refresh?force=1`, Jellyfin posts to + // `/Items/{id}/Refresh` (the library view is itself an item). + final refresh = ContextMenuItem( + value: 'refresh', + icon: Symbols.sync_rounded, + label: t.libraries.refreshMetadata, + requiresConfirmation: true, + confirmationTitle: t.libraries.refreshMetadata, + confirmationMessage: t.libraries.refreshMetadataConfirm(title: library.title), + isDestructive: true, + ); + // Scan / analyze / empty trash hit Plex-only endpoints. Gating them keeps + // [getPlexClientForLibrary] from falling back through `_resolveClient` to + // the first online Plex server and firing the action against the wrong + // backend. + if (library.backend != MediaBackend.plex) return [refresh]; + return [ + ContextMenuItem( + value: 'scan', + icon: Symbols.refresh_rounded, + label: t.libraries.scanLibraryFiles, + requiresConfirmation: true, + confirmationTitle: t.libraries.scanLibrary, + confirmationMessage: t.libraries.scanLibraryConfirm(title: library.title), + ), + ContextMenuItem( + value: 'analyze', + icon: Symbols.analytics_rounded, + label: t.libraries.analyze, + requiresConfirmation: true, + confirmationTitle: t.libraries.analyzeLibrary, + confirmationMessage: t.libraries.analyzeLibraryConfirm(title: library.title), + ), + refresh, + ContextMenuItem( + value: 'empty_trash', + icon: Symbols.delete_outline_rounded, + label: t.libraries.emptyTrash, + requiresConfirmation: true, + confirmationTitle: t.libraries.emptyTrash, + confirmationMessage: t.libraries.emptyTrashConfirm(title: library.title), + isDestructive: true, + ), + ]; +} + +Future _handleLibraryMenuAction(BuildContext context, String action, MediaLibrary library) async { + // Find the menu item for confirmation details + final menuItems = _getLibraryMenuItems(library); + final item = menuItems.where((i) => i.value == action).firstOrNull; + if (item == null) return; + + if (item.requiresConfirmation) { + final confirmed = await showConfirmDialog( + context, + title: item.confirmationTitle ?? t.dialog.confirmAction, + message: item.confirmationMessage ?? t.libraries.confirmActionMessage, + confirmText: t.common.confirm, + isDestructive: item.isDestructive, + ); + if (!confirmed || !context.mounted) return; + } + + switch (action) { + case 'scan': + unawaited(_scanLibrary(context, library)); + break; + case 'analyze': + unawaited(_analyzeLibrary(context, library)); + break; + case 'refresh': + unawaited(_refreshLibraryMetadata(context, library)); + break; + case 'empty_trash': + unawaited(_emptyLibraryTrash(context, library)); + break; + } +} + +Future _performLibraryAction( + BuildContext context, { + required MediaLibrary library, + required Future Function(PlexClient client) action, + required String progressMessage, + required String successMessage, + required String Function(Object error) failureMessage, +}) async { + try { + final client = context.getPlexClientForLibrary(library); + + if (context.mounted) { + showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); + } + + await action(client); + + if (context.mounted) { + showSuccessSnackBar(context, successMessage); + } + } catch (e) { + appLogger.e('Library action failed', error: e); + if (context.mounted) { + showErrorSnackBar(context, failureMessage(e)); + } + } +} + +/// Backend-neutral counterpart to [_performLibraryAction] for ops that exist +/// on the [MediaServerClient] interface (currently just refresh metadata). +/// Resolves the client through `getMediaClientForLibrary` so a Jellyfin +/// library is routed to its own server, not a fallback Plex one. +Future _performMediaLibraryAction( + BuildContext context, { + required MediaLibrary library, + required Future Function(MediaServerClient client) action, + required String progressMessage, + required String successMessage, + required String Function(Object error) failureMessage, +}) async { + try { + final client = context.getMediaClientForLibrary(library); + + if (context.mounted) { + showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); + } + + await action(client); + + if (context.mounted) { + showSuccessSnackBar(context, successMessage); + } + } catch (e) { + appLogger.e('Library action failed', error: e); + if (context.mounted) { + showErrorSnackBar(context, failureMessage(e)); + } + } +} + +Future _scanLibrary(BuildContext context, MediaLibrary library) { + return _performLibraryAction( + context, + library: library, + action: (client) => client.scanLibrary(library.id), + progressMessage: t.messages.libraryScanning(title: library.title), + successMessage: t.messages.libraryScanStarted(title: library.title), + failureMessage: (error) => t.messages.libraryScanFailed(error: error.toString()), + ); +} + +Future _refreshLibraryMetadata(BuildContext context, MediaLibrary library) { + return _performMediaLibraryAction( + context, + library: library, + action: (client) => client.refreshLibraryMetadata(library.id), + progressMessage: t.messages.metadataRefreshing(title: library.title), + successMessage: t.messages.metadataRefreshStarted(title: library.title), + failureMessage: (error) => t.messages.metadataRefreshFailed(error: error.toString()), + ); +} + +Future _emptyLibraryTrash(BuildContext context, MediaLibrary library) { + return _performLibraryAction( + context, + library: library, + action: (client) => client.emptyLibraryTrash(library.id), + progressMessage: t.libraries.emptyingTrash(title: library.title), + successMessage: t.libraries.trashEmptied(title: library.title), + failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error), + ); +} + +Future _analyzeLibrary(BuildContext context, MediaLibrary library) { + return _performLibraryAction( + context, + library: library, + action: (client) => client.analyzeLibrary(library.id), + progressMessage: t.libraries.analyzing(title: library.title), + successMessage: t.libraries.analysisStarted(title: library.title), + failureMessage: (error) => t.libraries.failedToAnalyze(error: error), + ); +} + +class _LibraryManagementSheet extends StatefulWidget { + final bool isDialog; + final List allLibraries; + final Set hiddenLibraryKeys; + final Function(List) onReorder; + final Function(MediaLibrary) onToggleVisibility; + final List Function(MediaLibrary) getLibraryMenuItems; + final void Function(String action, MediaLibrary library) onLibraryMenuAction; + + const _LibraryManagementSheet({ + this.isDialog = false, + required this.allLibraries, + required this.hiddenLibraryKeys, + required this.onReorder, + required this.onToggleVisibility, + required this.getLibraryMenuItems, + required this.onLibraryMenuAction, + }); + + @override + State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState(); +} + +class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { + late List _tempLibraries; + + // Keyboard navigation state + int _focusedIndex = 0; + int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button + int? _movingIndex; // Non-null when in move mode + int? _originalIndex; // Original position before move (for cancel) + List? _originalOrder; // Original order before move (for cancel) + final FocusNode _listFocusNode = FocusNode(); + final ScrollController _dialogScrollController = ScrollController(); + bool _backKeyDownSeen = false; + + @override + void initState() { + super.initState(); + _tempLibraries = List.from(widget.allLibraries); + } + + @override + void dispose() { + _listFocusNode.dispose(); + _dialogScrollController.dispose(); + super.dispose(); + } + + void _ensureFocusedVisible() { + if (!widget.isDialog) return; + if (!_dialogScrollController.hasClients) return; + + const double itemHeight = 72.0; // Material ListTile with subtitle + const double listTopPadding = 8.0; + final double targetTop = listTopPadding + (_focusedIndex * itemHeight); + final double targetBottom = targetTop + itemHeight; + + final double viewportTop = _dialogScrollController.offset; + final double viewportHeight = _dialogScrollController.position.viewportDimension; + final double viewportBottom = viewportTop + viewportHeight; + + // Already fully visible — skip + if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; + + // Place item at ~25% from top of viewport + final double destination = (targetTop - viewportHeight * 0.25).clamp( + 0.0, + _dialogScrollController.position.maxScrollExtent, + ); + + _dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } + + KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + + // Track back key down/up pairing. If focus was elsewhere during KeyDown + // (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray + // KeyUp that would incorrectly pop the dialog. Consume it instead. + if (key.isBackKey) { + if (event is KeyDownEvent) { + _backKeyDownSeen = true; + } else if (event is KeyUpEvent && !_backKeyDownSeen) { + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { + _backKeyDownSeen = false; + } + } + + final backResult = handleBackKeyAction(event, () { + if (_movingIndex != null) { + // Cancel move - restore original position + setState(() { + if (_originalOrder != null) { + _tempLibraries = List.from(_originalOrder!); + } + _focusedIndex = _originalIndex ?? 0; + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + } else { + OverlaySheetController.popAdaptive(context); + } + }); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + if (!event.isActionable) return KeyEventResult.ignored; + + if (_movingIndex != null) { + // Move mode - arrows reorder the item + if (key.isUpKey && _movingIndex! > 0) { + setState(() { + final item = _tempLibraries.removeAt(_movingIndex!); + _tempLibraries.insert(_movingIndex! - 1, item); + _movingIndex = _movingIndex! - 1; + _focusedIndex = _movingIndex!; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) { + setState(() { + final item = _tempLibraries.removeAt(_movingIndex!); + _tempLibraries.insert(_movingIndex! + 1, item); + _movingIndex = _movingIndex! + 1; + _focusedIndex = _movingIndex!; + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + // Confirm move - apply the reorder + widget.onReorder(_tempLibraries); + setState(() { + _movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + return KeyEventResult.handled; + } + } else { + // Navigation mode + if (key.isUpKey && _focusedIndex > 0) { + setState(() { + _focusedIndex--; + _focusedColumn = 0; // Reset to row when changing rows + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) { + setState(() { + _focusedIndex++; + _focusedColumn = 0; // Reset to row when changing rows + }); + _ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isLeftKey && _focusedColumn > 0) { + setState(() => _focusedColumn--); + return KeyEventResult.handled; + } + if (key.isRightKey && _focusedColumn < 2) { + setState(() => _focusedColumn++); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (_focusedColumn == 0) { + // Enter move mode + setState(() { + _movingIndex = _focusedIndex; + _originalIndex = _focusedIndex; + _originalOrder = List.from(_tempLibraries); + }); + } else if (_focusedColumn == 1) { + // Toggle visibility + final library = _tempLibraries[_focusedIndex]; + widget.onToggleVisibility(library); + } else if (_focusedColumn == 2) { + // Show options menu + final library = _tempLibraries[_focusedIndex]; + _showLibraryMenuBottomSheet(context, library); + } + return KeyEventResult.handled; + } + } + + // Block d-pad keys at boundaries so focus doesn't escape the dialog + if (key.isDpadDirection) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _reorderLibraries(int oldIndex, int newIndex) { + setState(() { + final library = _tempLibraries.removeAt(oldIndex); + _tempLibraries.insert(newIndex, library); + }); + // Apply immediately + widget.onReorder(_tempLibraries); + } + + void _showLibraryMenuBottomSheet(BuildContext outerContext, MediaLibrary library) { + final menuItems = widget.getLibraryMenuItems(library); + OverlaySheetController.pushAdaptive( + outerContext, + builder: (context) => AppMenuSheet( + title: library.title, + entries: [ + for (final item in menuItems) + AppMenuItem(value: item.value, icon: item.icon, label: item.label, destructive: item.isDestructive), + ], + onSelected: (value) => widget.onLibraryMenuAction(value, library), + ), + ); + } + + /// Whether the libraries span more than one connected server. + bool _hasMultipleServers() { + final serverIds = _tempLibraries.where((lib) => lib.serverId != null).map((lib) => lib.serverId).toSet(); + return serverIds.length > 1; + } + + @override + Widget build(BuildContext context) { + // Watch provider to rebuild when hidden libraries change + final hiddenLibrariesProvider = context.watch(); + final hiddenLibraryKeys = hiddenLibrariesProvider.hiddenLibraryKeys; + + if (widget.isDialog) { + return Dialog( + child: PopScope( + canPop: false, // Prevent system back from double-popping; handled by _handleKeyEvent + // ignore: no-empty-block - required callback, blocks system back on Android TV + onPopInvokedWithResult: (didPop, result) {}, + child: Scaffold( + appBar: AppBar( + title: Row( + children: [ + const AppIcon(Symbols.edit_rounded, fill: 1), + const SizedBox(width: 12), + Text(t.libraries.manageLibraries), + ], + ), + 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, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Row( + children: [ + const AppIcon(Symbols.edit_rounded, fill: 1), + const SizedBox(width: 12), + Expanded( + child: Text(t.libraries.manageLibraries, style: const TextStyle(fontSize: 20, fontWeight: .bold)), + ), + IconButton( + icon: const AppIcon(Symbols.close_rounded, fill: 1), + onPressed: () => OverlaySheetController.popAdaptive(context), + ), + ], + ), + ), + + // Library list (grouped by server if multiple servers) + Expanded( + child: Focus( + focusNode: _listFocusNode, + autofocus: InputModeTracker.isKeyboardMode(context), + onKeyEvent: _handleKeyEvent, + child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys), + ), + ), + ], + ); + }, + ); + } + + /// Build library list for dialog (TV) using ListView with scroll-into-view support + Widget _buildFlatLibraryListDialog(Set hiddenLibraryKeys) { + final showServerNames = _hasMultipleServers(); + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + return ReorderableListView.builder( + scrollController: _dialogScrollController, + onReorderItem: _reorderLibraries, + itemCount: _tempLibraries.length, + padding: const EdgeInsets.symmetric(vertical: 8), + buildDefaultDragHandles: false, + itemBuilder: (context, index) { + final library = _tempLibraries[index]; + final showServerName = showServerNames && library.serverName != null; + final isFocused = isKeyboardMode && index == _focusedIndex; + final isMoving = index == _movingIndex; + + return _buildLibraryTile( + library, + index, + hiddenLibraryKeys, + showServerName: showServerName, + isFocused: isFocused, + isMoving: isMoving, + focusedColumn: isFocused ? _focusedColumn : null, + ); + }, + ); + } + + /// Build flat library list with a server subtitle when multiple servers are connected + Widget _buildFlatLibraryList(ScrollController scrollController, Set hiddenLibraryKeys) { + final showServerNames = _hasMultipleServers(); + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + return ReorderableListView.builder( + scrollController: scrollController, + onReorderItem: _reorderLibraries, + itemCount: _tempLibraries.length, + padding: const EdgeInsets.symmetric(vertical: 8), + buildDefaultDragHandles: false, + itemBuilder: (context, index) { + final library = _tempLibraries[index]; + final showServerName = showServerNames && library.serverName != null; + final isFocused = isKeyboardMode && index == _focusedIndex; + final isMoving = index == _movingIndex; + return _buildLibraryTile( + library, + index, + hiddenLibraryKeys, + showServerName: showServerName, + isFocused: isFocused, + isMoving: isMoving, + focusedColumn: isFocused ? _focusedColumn : null, + ); + }, + ); + } + + /// Build a single library tile + Widget _buildLibraryTile( + MediaLibrary library, + int index, + Set hiddenLibraryKeys, { + bool showServerName = false, + bool isFocused = false, + bool isMoving = false, + int? focusedColumn, + }) { + final isHidden = hiddenLibraryKeys.contains(library.globalKey); + final colorScheme = Theme.of(context).colorScheme; + + // Determine background color based on state + Color? tileColor; + if (isMoving) { + tileColor = colorScheme.primaryContainer; + } else if (isFocused && focusedColumn == 0) { + // Only highlight row when row itself is focused (column 0) + tileColor = colorScheme.surfaceContainerHighest; + } + + // Button focus states + final isVisibilityButtonFocused = isFocused && focusedColumn == 1; + final isOptionsButtonFocused = isFocused && focusedColumn == 2; + + return Opacity( + key: ValueKey(library.globalKey), + opacity: isHidden ? 0.5 : 1.0, + child: ListTile( + tileColor: tileColor, + leading: Row( + mainAxisSize: .min, + children: [ + ReorderableDragStartListener( + index: index, + child: AppIcon( + isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded, + fill: 1, + color: isMoving ? colorScheme.primary : IconTheme.of(context).color?.withValues(alpha: 0.5), + ), + ), + const SizedBox(width: 8), + AppIcon(ContentTypeHelper.getLibraryIcon(library.kind.id), fill: 1), + ], + ), + title: Text(library.title), + subtitle: showServerName + ? Text( + library.serverName!, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.6), + ), + ) + : null, + trailing: Row( + mainAxisSize: .min, + children: [ + Container( + decoration: FocusTheme.focusBackgroundDecoration(isFocused: isVisibilityButtonFocused, borderRadius: 20), + child: IconButton( + icon: AppIcon(isHidden ? Symbols.visibility_off_rounded : Symbols.visibility_rounded, fill: 1), + tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary, + onPressed: () => widget.onToggleVisibility(library), + ), + ), + Container( + decoration: FocusTheme.focusBackgroundDecoration(isFocused: isOptionsButtonFocused, borderRadius: 20), + child: IconButton( + icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), + tooltip: t.libraries.libraryOptions, + onPressed: () => _showLibraryMenuBottomSheet(context, library), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 7971ae61..8dbefbba 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -128,10 +128,7 @@ class OverlaySheetController { () { final size = MediaQuery.sizeOf(context); final isDesktop = size.width > 600; - return BoxConstraints( - maxWidth: isDesktop ? 700 : double.infinity, - maxHeight: isDesktop ? 400 : size.height * 0.75, - ); + return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); }(); return showModalBottomSheet( context: context, @@ -567,7 +564,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr final effectiveConstraints = _constraints ?? - BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75); + BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); // Slide direction depends on alignment: bottom sheets slide up, top sheets slide down. // Use a pixel transform instead of FractionalTranslation so mouse-tracker diff --git a/test/widgets/overlay_sheet_test.dart b/test/widgets/overlay_sheet_test.dart index 78ff1439..12005f89 100644 --- a/test/widgets/overlay_sheet_test.dart +++ b/test/widgets/overlay_sheet_test.dart @@ -52,6 +52,46 @@ void main() { expect(find.text('Item 0'), findsOneWidget); }); + testWidgets('desktop default constraints scale with window height', (tester) async { + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(platform: TargetPlatform.android), + home: OverlaySheetHost( + child: Scaffold( + body: Center( + child: Builder( + builder: (context) => ElevatedButton( + onPressed: () { + OverlaySheetController.of(context).show( + builder: (_) => ListView.builder( + itemCount: 100, + itemBuilder: (_, index) => ListTile(title: Text('Item $index')), + ), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Unbounded list content fills the default constraints: 75% of window + // height (previously a fixed 400 on desktop) capped at 700 wide. + final sheetSize = tester.getSize(find.byType(ListView)); + expect(sheetSize.height, 800 * 0.75); + expect(sheetSize.width, 700); + }); + group('opt-in canPop / onSystemBack', () { // Pushes an OverlaySheetHost route on top of a home route so we can observe // whether a simulated system back pops the route. The host's child has an