From 6daf77c890daa2b2bb0b5a5beeffe42038996346 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:18:33 +0100 Subject: [PATCH] feat: convert settings dialogs to platform-adaptive menus Replace AlertDialog-based selection dialogs with popup dropdowns on desktop/TV and bottom sheets on mobile, matching the context menu pattern. Fix popup background blending into cards by tinting surface. --- lib/screens/settings/settings_screen.dart | 859 +++++++++++++--------- lib/widgets/media_context_menu.dart | 4 + 2 files changed, 497 insertions(+), 366 deletions(-) diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 350ed194..acc15144 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -10,10 +10,11 @@ import '../../models/hotkey_model.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; - +import '../../focus/dpad_navigator.dart'; import '../../focus/focus_memory_tracker.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; +import '../../utils/focus_utils.dart'; import '../main_screen.dart'; import '../../mixins/refreshable.dart'; import '../../services/discord_rpc_service.dart'; @@ -29,7 +30,9 @@ import '../../services/update_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focusable_list_tile.dart'; import '../../widgets/focused_scroll_scaffold.dart'; +import '../../widgets/overlay_sheet.dart'; import '../../widgets/tv_number_spinner.dart'; import 'hotkey_recorder_widget.dart'; import '../../providers/companion_remote_provider.dart'; @@ -268,59 +271,179 @@ class _SettingsScreenState extends State with FocusableTab { ), Consumer( builder: (context, themeProvider, child) { - return ListTile( - focusNode: _focusTracker.get(_kTheme), - leading: AppIcon(themeProvider.themeModeIcon, fill: 1), - title: Text(t.settings.theme), - subtitle: Text(themeProvider.themeModeDisplayName), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showThemeDialog(themeProvider), + return Builder( + builder: (tileContext) => ListTile( + focusNode: _focusTracker.get(_kTheme), + leading: AppIcon(themeProvider.themeModeIcon, fill: 1), + title: Text(t.settings.theme), + subtitle: Text(themeProvider.themeModeDisplayName), + trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), + onTap: () async { + final value = await _showSettingsMenu( + tileContext: tileContext, + title: t.settings.theme, + options: [ + _DialogOption( + value: settings.ThemeMode.system, + title: t.settings.systemTheme, + subtitle: t.settings.systemThemeDescription, + ), + _DialogOption(value: settings.ThemeMode.light, title: t.settings.lightTheme), + _DialogOption(value: settings.ThemeMode.dark, title: t.settings.darkTheme), + _DialogOption( + value: settings.ThemeMode.oled, + title: t.settings.oledTheme, + subtitle: t.settings.oledThemeDescription, + ), + ], + currentValue: themeProvider.themeMode, + ); + if (value != null) { + themeProvider.setThemeMode(value); + } + }, + ), ); }, ), - ListTile( - focusNode: _focusTracker.get(_kLanguage), - leading: const AppIcon(Symbols.language_rounded, fill: 1), - title: Text(t.settings.language), - subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showLanguageDialog(), + Builder( + builder: (tileContext) => ListTile( + focusNode: _focusTracker.get(_kLanguage), + leading: const AppIcon(Symbols.language_rounded, fill: 1), + title: Text(t.settings.language), + subtitle: Text(_getLanguageDisplayName(LocaleSettings.currentLocale)), + trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), + onTap: () async { + final value = await _showSettingsMenu( + tileContext: tileContext, + title: t.settings.language, + options: AppLocale.values + .map((locale) => _DialogOption(value: locale, title: _getLanguageDisplayName(locale))) + .toList(), + currentValue: LocaleSettings.currentLocale, + ); + if (value != null) { + await _settingsService.setAppLocale(value); + LocaleSettings.setLocale(value); + _restartApp(); + } + }, + ), ), Consumer( builder: (context, settingsProvider, child) { - return ListTile( - focusNode: _focusTracker.get(_kLibraryDensity), - leading: const AppIcon(Symbols.grid_view_rounded, fill: 1), - title: Text(t.settings.libraryDensity), - subtitle: Text(settingsProvider.libraryDensityDisplayName), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showLibraryDensityDialog(), - ); - }, - ), - Consumer( - builder: (context, settingsProvider, child) { - return ListTile( - focusNode: _focusTracker.get(_kViewMode), - leading: const AppIcon(Symbols.view_list_rounded, fill: 1), - title: Text(t.settings.viewMode), - subtitle: Text( - settingsProvider.viewMode == settings.ViewMode.grid ? t.settings.gridView : t.settings.listView, + return Builder( + builder: (tileContext) => ListTile( + focusNode: _focusTracker.get(_kLibraryDensity), + leading: const AppIcon(Symbols.grid_view_rounded, fill: 1), + title: Text(t.settings.libraryDensity), + subtitle: Text(settingsProvider.libraryDensityDisplayName), + trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), + onTap: () async { + final value = await _showSettingsMenu( + tileContext: tileContext, + title: t.settings.libraryDensity, + options: [ + _DialogOption( + value: settings.LibraryDensity.compact, + title: t.settings.compact, + subtitle: t.settings.compactDescription, + ), + _DialogOption( + value: settings.LibraryDensity.normal, + title: t.settings.normal, + subtitle: t.settings.normalDescription, + ), + _DialogOption( + value: settings.LibraryDensity.comfortable, + title: t.settings.comfortable, + subtitle: t.settings.comfortableDescription, + ), + ], + currentValue: settingsProvider.libraryDensity, + ); + if (value != null) { + settingsProvider.setLibraryDensity(value); + } + }, ), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showViewModeDialog(), ); }, ), Consumer( builder: (context, settingsProvider, child) { - return ListTile( - focusNode: _focusTracker.get(_kEpisodePosterMode), - leading: const AppIcon(Symbols.image_rounded, fill: 1), - title: Text(t.settings.episodePosterMode), - subtitle: Text(settingsProvider.episodePosterModeDisplayName), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showEpisodePosterModeDialog(), + return Builder( + builder: (tileContext) => ListTile( + focusNode: _focusTracker.get(_kViewMode), + leading: const AppIcon(Symbols.view_list_rounded, fill: 1), + title: Text(t.settings.viewMode), + subtitle: Text( + settingsProvider.viewMode == settings.ViewMode.grid ? t.settings.gridView : t.settings.listView, + ), + trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), + onTap: () async { + final value = await _showSettingsMenu( + tileContext: tileContext, + title: t.settings.viewMode, + options: [ + _DialogOption( + value: settings.ViewMode.grid, + title: t.settings.gridView, + subtitle: t.settings.gridViewDescription, + ), + _DialogOption( + value: settings.ViewMode.list, + title: t.settings.listView, + subtitle: t.settings.listViewDescription, + ), + ], + currentValue: settingsProvider.viewMode, + ); + if (value != null) { + settingsProvider.setViewMode(value); + } + }, + ), + ); + }, + ), + Consumer( + builder: (context, settingsProvider, child) { + return Builder( + builder: (tileContext) => ListTile( + focusNode: _focusTracker.get(_kEpisodePosterMode), + leading: const AppIcon(Symbols.image_rounded, fill: 1), + title: Text(t.settings.episodePosterMode), + subtitle: Text(settingsProvider.episodePosterModeDisplayName), + trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), + onTap: () async { + final value = await _showSettingsMenu( + tileContext: tileContext, + title: t.settings.episodePosterMode, + options: [ + _DialogOption( + value: settings.EpisodePosterMode.seriesPoster, + title: t.settings.seriesPoster, + subtitle: t.settings.seriesPosterDescription, + ), + _DialogOption( + value: settings.EpisodePosterMode.seasonPoster, + title: t.settings.seasonPoster, + subtitle: t.settings.seasonPosterDescription, + ), + _DialogOption( + value: settings.EpisodePosterMode.episodeThumbnail, + title: t.settings.episodeThumbnail, + subtitle: t.settings.episodeThumbnailDescription, + ), + ], + currentValue: settingsProvider.episodePosterMode, + ); + if (value != null) { + settingsProvider.setEpisodePosterMode(value); + } + }, + ), ); }, ), @@ -472,13 +595,33 @@ class _SettingsScreenState extends State with FocusableTab { ), ), if (Platform.isAndroid) - ListTile( - focusNode: _focusTracker.get(_kPlayerBackend), - leading: const AppIcon(Symbols.play_circle_rounded, fill: 1), - title: Text(t.settings.playerBackend), - subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showPlayerBackendDialog(), + Builder( + builder: (tileContext) => ListTile( + focusNode: _focusTracker.get(_kPlayerBackend), + leading: const AppIcon(Symbols.play_circle_rounded, fill: 1), + title: Text(t.settings.playerBackend), + subtitle: Text(_useExoPlayer ? t.settings.exoPlayerDescription : t.settings.mpvDescription), + trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), + onTap: () async { + final value = await _showSettingsMenu( + tileContext: tileContext, + title: t.settings.playerBackend, + options: [ + _DialogOption( + value: true, + title: t.settings.exoPlayer, + subtitle: t.settings.exoPlayerDescription, + ), + _DialogOption(value: false, title: t.settings.mpv, subtitle: t.settings.mpvDescription), + ], + currentValue: _useExoPlayer, + ); + if (value != null) { + setState(() => _useExoPlayer = value); + await _settingsService.setUseExoPlayer(value); + } + }, + ), ), ListTile( focusNode: _focusTracker.get(_kExternalPlayer), @@ -552,13 +695,45 @@ class _SettingsScreenState extends State with FocusableTab { await _settingsService.setTunneledPlayback(value); }, ), - ListTile( - focusNode: _focusTracker.get(_kBufferSize), - leading: const AppIcon(Symbols.memory_rounded, fill: 1), - title: Text(t.settings.bufferSize), - subtitle: Text(_bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString())), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - onTap: () => _showBufferSizeDialog(), + Builder( + builder: (tileContext) => ListTile( + focusNode: _focusTracker.get(_kBufferSize), + leading: const AppIcon(Symbols.memory_rounded, fill: 1), + title: Text(t.settings.bufferSize), + subtitle: Text( + _bufferSize == 0 ? t.settings.bufferSizeAuto : t.settings.bufferSizeMB(size: _bufferSize.toString()), + ), + trailing: const AppIcon(Symbols.arrow_drop_down_rounded, fill: 1), + onTap: () async { + final bufferOptions = [0, 64, 128, 256, 512, 1024]; + final value = await _showSettingsMenu( + tileContext: tileContext, + title: t.settings.bufferSize, + options: bufferOptions + .map( + (size) => + _DialogOption(value: size, title: size == 0 ? t.settings.bufferSizeAuto : '${size}MB'), + ) + .toList(), + currentValue: _bufferSize, + ); + if (value != null) { + setState(() { + _bufferSize = value; + _settingsService.setBufferSize(value); + }); + if (Platform.isAndroid && value > 0) { + final heapMB = await PlayerAndroid.getHeapSize(); + if (heapMB > 0 && value > heapMB ~/ 4 && mounted) { + showAppSnackBar( + context, + t.settings.bufferSizeWarning(heap: heapMB.toString(), size: value.toString()), + ); + } + } + } + }, + ), ), ListTile( focusNode: _focusTracker.get(_kSubtitleStyling), @@ -1115,113 +1290,73 @@ class _SettingsScreenState extends State with FocusableTab { ); } - void _showThemeDialog(ThemeProvider themeProvider) { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.settings.theme), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: AppIcon( - themeProvider.themeMode == settings.ThemeMode.system - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.systemTheme), - subtitle: Text(t.settings.systemThemeDescription), - onTap: () { - themeProvider.setThemeMode(settings.ThemeMode.system); - Navigator.pop(context); - }, - ), - ListTile( - leading: AppIcon( - themeProvider.themeMode == settings.ThemeMode.light - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.lightTheme), - onTap: () { - themeProvider.setThemeMode(settings.ThemeMode.light); - Navigator.pop(context); - }, - ), - ListTile( - leading: AppIcon( - themeProvider.themeMode == settings.ThemeMode.dark - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.darkTheme), - onTap: () { - themeProvider.setThemeMode(settings.ThemeMode.dark); - Navigator.pop(context); - }, - ), - ListTile( - leading: AppIcon( - themeProvider.themeMode == settings.ThemeMode.oled - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.oledTheme), - subtitle: Text(t.settings.oledThemeDescription), - onTap: () { - themeProvider.setThemeMode(settings.ThemeMode.oled); - Navigator.pop(context); - }, - ), - ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], - ); - }, + /// Shows a platform-adaptive settings menu: popup dropdown on desktop/TV, + /// bottom sheet on mobile. + Future _showSettingsMenu({ + required BuildContext tileContext, + required String title, + required List<_DialogOption> options, + required T currentValue, + }) { + final useBottomSheet = Platform.isIOS || Platform.isAndroid; + final focusFirstItem = InputModeTracker.isKeyboardMode(context); + + if (useBottomSheet) { + return _showSettingsMenuSheet( + title: title, + options: options, + currentValue: currentValue, + focusFirstItem: focusFirstItem, + ); + } else { + return _showSettingsMenuPopup( + tileContext: tileContext, + options: options, + currentValue: currentValue, + focusFirstItem: focusFirstItem, + ); + } + } + + /// Mobile path: bottom sheet via OverlaySheetController.showAdaptive + Future _showSettingsMenuSheet({ + required String title, + required List<_DialogOption> options, + required T currentValue, + required bool focusFirstItem, + }) { + return OverlaySheetController.showAdaptive( + context, + showDragHandle: true, + builder: (context) => _SettingsMenuSheet( + title: title, + options: options, + currentValue: currentValue, + focusFirstItem: focusFirstItem, + ), ); } - void _showBufferSizeDialog() { - final options = [0, 64, 128, 256, 512, 1024]; + /// Desktop/TV path: positioned popup dropdown + Future _showSettingsMenuPopup({ + required BuildContext tileContext, + required List<_DialogOption> options, + required T currentValue, + required bool focusFirstItem, + }) { + final renderBox = tileContext.findRenderObject() as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + final tileSize = renderBox.size; - showDialog( + return showDialog( context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.settings.bufferSize), - content: Column( - mainAxisSize: MainAxisSize.min, - children: options.map((size) { - return ListTile( - leading: AppIcon( - _bufferSize == size ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(size == 0 ? t.settings.bufferSizeAuto : '${size}MB'), - onTap: () async { - setState(() { - _bufferSize = size; - _settingsService.setBufferSize(size); - }); - Navigator.pop(context); - if (Platform.isAndroid && size > 0) { - final heapMB = await PlayerAndroid.getHeapSize(); - if (heapMB > 0 && size > heapMB ~/ 4 && mounted) { - showAppSnackBar(this.context, t.settings.bufferSizeWarning(heap: heapMB.toString(), size: size.toString())); - } - } - }, - ); - }).toList(), - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], - ); - }, + barrierColor: Colors.transparent, + builder: (context) => _SettingsMenuPopup( + position: Offset(position.dx + tileSize.width, position.dy + tileSize.height), + options: options, + currentValue: currentValue, + focusFirstItem: focusFirstItem, + ), ); } @@ -1482,53 +1617,6 @@ class _SettingsScreenState extends State with FocusableTab { ); } - void _showPlayerBackendDialog() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.settings.playerBackend), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: AppIcon( - _useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.exoPlayer), - subtitle: Text(t.settings.exoPlayerDescription), - onTap: () async { - setState(() { - _useExoPlayer = true; - }); - await _settingsService.setUseExoPlayer(true); - if (context.mounted) Navigator.pop(context); - }, - ), - ListTile( - leading: AppIcon( - !_useExoPlayer ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, - fill: 1, - ), - title: Text(t.settings.mpv), - subtitle: Text(t.settings.mpvDescription), - onTap: () async { - setState(() { - _useExoPlayer = false; - }); - await _settingsService.setUseExoPlayer(false); - if (context.mounted) Navigator.pop(context); - }, - ), - ], - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], - ); - }, - ); - } - void _showKeyboardShortcutsDialog() { if (_keyboardService == null) return; @@ -1546,11 +1634,7 @@ class _SettingsScreenState extends State with FocusableTab { title: Text(t.settings.clearCache), content: Text(t.settings.clearCacheDescription), actions: [ - TextButton( - autofocus: true, - onPressed: () => Navigator.pop(context), - child: Text(t.common.cancel), - ), + TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), TextButton( onPressed: () async { final navigator = Navigator.of(context); @@ -1576,11 +1660,7 @@ class _SettingsScreenState extends State with FocusableTab { title: Text(t.settings.resetSettings), content: Text(t.settings.resetSettingsDescription), actions: [ - TextButton( - autofocus: true, - onPressed: () => Navigator.pop(context), - child: Text(t.common.cancel), - ), + TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), TextButton( onPressed: () async { final navigator = Navigator.of(context); @@ -1635,50 +1715,6 @@ class _SettingsScreenState extends State with FocusableTab { } } - void _showLanguageDialog() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.settings.language), - content: Column( - mainAxisSize: MainAxisSize.min, - children: AppLocale.values.map((locale) { - final isSelected = LocaleSettings.currentLocale == locale; - return ListTile( - title: Text(_getLanguageDisplayName(locale)), - leading: AppIcon( - isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, - fill: 1, - color: isSelected ? Theme.of(context).colorScheme.primary : null, - ), - tileColor: isSelected ? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.3) : null, - onTap: () async { - // Save the locale to settings - await _settingsService.setAppLocale(locale); - - // Set the locale immediately - LocaleSettings.setLocale(locale); - - // Close dialog - if (context.mounted) { - Navigator.pop(context); - } - - // Trigger app-wide rebuild by restarting the app - if (context.mounted) { - _restartApp(); - } - }, - ); - }).toList(), - ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], - ); - }, - ); - } - void _restartApp() { // Navigate to the root and remove all previous routes Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); @@ -1738,11 +1774,7 @@ class _SettingsScreenState extends State with FocusableTab { ], ), actions: [ - TextButton( - autofocus: true, - onPressed: () => Navigator.pop(context), - child: Text(t.common.close), - ), + TextButton(autofocus: true, onPressed: () => Navigator.pop(context), child: Text(t.common.close)), FilledButton( onPressed: () async { final url = Uri.parse(_updateInfo!['releaseUrl']); @@ -1758,117 +1790,212 @@ class _SettingsScreenState extends State with FocusableTab { }, ); } +} - /// Generic option selection dialog for settings with SettingsProvider - void _showOptionSelectionDialog({ - required String title, - required List<_DialogOption> options, - required T Function(SettingsProvider) getCurrentValue, - required Future Function(T value, SettingsProvider provider) onSelect, - }) { - final settingsProvider = context.read(); - showDialog( - context: context, - builder: (BuildContext context) { - return Consumer( - builder: (context, provider, child) { - final currentValue = getCurrentValue(provider); - return AlertDialog( - title: Text(title), - content: Column( - mainAxisSize: MainAxisSize.min, - children: options.map((option) { - return ListTile( +/// Bottom sheet content for settings menu (mobile) +class _SettingsMenuSheet extends StatefulWidget { + final String title; + final List<_DialogOption> options; + final T currentValue; + final bool focusFirstItem; + + const _SettingsMenuSheet({ + required this.title, + required this.options, + required this.currentValue, + this.focusFirstItem = false, + }); + + @override + State<_SettingsMenuSheet> createState() => _SettingsMenuSheetState(); +} + +class _SettingsMenuSheetState extends State<_SettingsMenuSheet> { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'SettingsMenuSheetInitialFocus'); + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.options.asMap().entries.map((entry) { + final index = entry.key; + final option = entry.value; + final isSelected = widget.currentValue == option.value; + return FocusableListTile( + focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, + dense: true, leading: AppIcon( - currentValue == option.value - ? Symbols.radio_button_checked_rounded - : Symbols.radio_button_unchecked_rounded, + isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, fill: 1, ), title: Text(option.title), subtitle: option.subtitle != null ? Text(option.subtitle!) : null, - onTap: () async { - await onSelect(option.value, settingsProvider); - if (context.mounted) Navigator.pop(context); - }, + onTap: () => OverlaySheetController.closeAdaptive(context, option.value), ); - }).toList(), + }), + ], + ), + ), + ), + ], + ); + } +} + +/// Positioned popup menu for settings menu (desktop/TV) +class _SettingsMenuPopup extends StatefulWidget { + final Offset position; + final List<_DialogOption> options; + final T currentValue; + final bool focusFirstItem; + + const _SettingsMenuPopup({ + required this.position, + required this.options, + required this.currentValue, + this.focusFirstItem = false, + }); + + @override + State<_SettingsMenuPopup> createState() => _SettingsMenuPopupState(); +} + +class _SettingsMenuPopupState extends State<_SettingsMenuPopup> { + late final FocusNode _initialFocusNode; + + @override + void initState() { + super.initState(); + _initialFocusNode = FocusNode(debugLabel: 'SettingsMenuPopupInitialFocus'); + if (widget.focusFirstItem) { + FocusUtils.requestFocusAfterBuild(this, _initialFocusNode); + } + } + + @override + void dispose() { + _initialFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + const menuWidth = 280.0; + const edgePadding = 8.0; + + // Right-align: position.dx is the tile's right edge + final left = (widget.position.dx - menuWidth).clamp(edgePadding, screenSize.width - menuWidth - edgePadding); + + final estimatedHeight = widget.options.length * 56.0 + 16; + final spaceBelow = screenSize.height - widget.position.dy - edgePadding; + final spaceAbove = widget.position.dy - edgePadding; + + final double top; + final double maxHeight; + if (estimatedHeight <= spaceBelow) { + top = widget.position.dy; + maxHeight = spaceBelow; + } else if (spaceAbove > spaceBelow) { + final menuHeight = estimatedHeight.clamp(0.0, spaceAbove); + top = widget.position.dy - menuHeight; + maxHeight = menuHeight; + } else { + top = widget.position.dy; + maxHeight = spaceBelow; + } + + return FocusScope( + autofocus: false, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], - ); - }, - ); - }, - ); - } - - void _showLibraryDensityDialog() { - _showOptionSelectionDialog( - title: t.settings.libraryDensity, - options: [ - _DialogOption( - value: settings.LibraryDensity.compact, - title: t.settings.compact, - subtitle: t.settings.compactDescription, + ), + Positioned( + left: left, + top: top, + child: Material( + elevation: 8, + color: Color.alphaBlend( + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), + Theme.of(context).colorScheme.surface, + ), + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth, maxHeight: maxHeight), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.options.asMap().entries.map((entry) { + final index = entry.key; + final option = entry.value; + final isSelected = widget.currentValue == option.value; + return FocusableListTile( + focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, + dense: true, + leading: AppIcon( + isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded, + fill: 1, + ), + title: Text(option.title), + subtitle: option.subtitle != null ? Text(option.subtitle!) : null, + onTap: () => Navigator.pop(context, option.value), + ); + }).toList(), + ), + ), + ), + ), + ), + ], ), - _DialogOption( - value: settings.LibraryDensity.normal, - title: t.settings.normal, - subtitle: t.settings.normalDescription, - ), - _DialogOption( - value: settings.LibraryDensity.comfortable, - title: t.settings.comfortable, - subtitle: t.settings.comfortableDescription, - ), - ], - getCurrentValue: (p) => p.libraryDensity, - onSelect: (value, provider) => provider.setLibraryDensity(value), - ); - } - - void _showViewModeDialog() { - _showOptionSelectionDialog( - title: t.settings.viewMode, - options: [ - _DialogOption( - value: settings.ViewMode.grid, - title: t.settings.gridView, - subtitle: t.settings.gridViewDescription, - ), - _DialogOption( - value: settings.ViewMode.list, - title: t.settings.listView, - subtitle: t.settings.listViewDescription, - ), - ], - getCurrentValue: (p) => p.viewMode, - onSelect: (value, provider) => provider.setViewMode(value), - ); - } - - void _showEpisodePosterModeDialog() { - _showOptionSelectionDialog( - title: t.settings.episodePosterMode, - options: [ - _DialogOption( - value: settings.EpisodePosterMode.seriesPoster, - title: t.settings.seriesPoster, - subtitle: t.settings.seriesPosterDescription, - ), - _DialogOption( - value: settings.EpisodePosterMode.seasonPoster, - title: t.settings.seasonPoster, - subtitle: t.settings.seasonPosterDescription, - ), - _DialogOption( - value: settings.EpisodePosterMode.episodeThumbnail, - title: t.settings.episodeThumbnail, - subtitle: t.settings.episodeThumbnailDescription, - ), - ], - getCurrentValue: (p) => p.episodePosterMode, - onSelect: (value, provider) => provider.setEpisodePosterMode(value), + ), ); } } diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 98309a36..32aad93e 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1424,6 +1424,10 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { top: top, child: Material( elevation: 8, + color: Color.alphaBlend( + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), + Theme.of(context).colorScheme.surface, + ), borderRadius: BorderRadius.circular(tokens(context).radiusSm), clipBehavior: Clip.antiAlias, child: ConstrainedBox(