From 0e84ce916654cf7adeed1442e72eba4f6e0cae4a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:00:59 +0100 Subject: [PATCH] fix: desktop context menu overflow --- lib/screens/video_player_screen.dart | 2 +- lib/widgets/focusable_list_tile.dart | 43 +++++---- lib/widgets/media_context_menu.dart | 135 +++++++++++++++------------ 3 files changed, 103 insertions(+), 77 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 65a714f8..6d2c0e90 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -2927,7 +2927,7 @@ String _getHwdecValue(bool enabled) { if (Platform.isMacOS || Platform.isIOS) { return 'videotoolbox'; } else if (Platform.isAndroid) { - return 'mediacodec,mediacodec-copy'; + return 'auto-safe'; } else { return 'auto'; // Windows, Linux } diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 985b675a..8c77bb3a 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -80,25 +80,36 @@ class FocusableListTile extends StatefulWidget { class _FocusableListTileState extends State { bool _suppressionConsumed = false; + bool _isHoveredOrFocused = false; @override Widget build(BuildContext context) { - final tile = ListTile( - title: widget.title, - subtitle: widget.subtitle, - leading: widget.leading, - trailing: widget.trailing, - onTap: widget.onTap, - onLongPress: widget.onLongPress, - dense: widget.dense, - enabled: widget.enabled, - selected: widget.selected, - contentPadding: widget.contentPadding, - focusNode: widget.suppressInitialSelect ? null : widget.focusNode, - autofocus: widget.suppressInitialSelect ? false : widget.autofocus, - hoverColor: widget.hoverColor, - textColor: widget.textColor, - iconColor: widget.iconColor, + // When hovered/focused with a custom hoverColor, use onError-style foreground + // to keep text readable against the colored background. + final needsContrastSwap = _isHoveredOrFocused && widget.hoverColor != null && widget.textColor != null; + final textColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.textColor; + final iconColor = needsContrastSwap ? Theme.of(context).colorScheme.onError : widget.iconColor; + + Widget tile = MouseRegion( + onEnter: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = true) : null, + onExit: widget.hoverColor != null ? (_) => setState(() => _isHoveredOrFocused = false) : null, + child: ListTile( + title: widget.title, + subtitle: widget.subtitle, + leading: widget.leading, + trailing: widget.trailing, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + dense: widget.dense, + enabled: widget.enabled, + selected: widget.selected, + contentPadding: widget.contentPadding, + focusNode: widget.suppressInitialSelect ? null : widget.focusNode, + autofocus: widget.suppressInitialSelect ? false : widget.autofocus, + hoverColor: widget.hoverColor, + textColor: textColor, + iconColor: iconColor, + ), ); if (!widget.suppressInitialSelect) { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 3077bc43..beab908a 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1381,74 +1381,89 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { final screenSize = MediaQuery.of(context).size; const menuWidth = 220.0; - // Calculate menu position, keeping it on screen - double left = widget.position.dx; - double top = widget.position.dy; + // Clamp menu position to stay within screen bounds + const edgePadding = 8.0; + final left = widget.position.dx.clamp(edgePadding, screenSize.width - menuWidth - edgePadding); - // Adjust if menu would go off right edge - if (left + menuWidth > screenSize.width) { - left = screenSize.width - menuWidth - 8; - } - - // Estimate menu height and adjust if would go off bottom final estimatedHeight = widget.actions.length * 48.0 + 16; - if (top + estimatedHeight > screenSize.height) { - top = screenSize.height - estimatedHeight - 8; + final spaceBelow = screenSize.height - widget.position.dy - edgePadding; + final spaceAbove = widget.position.dy - edgePadding; + + // Place menu above the click point if it doesn't fit below and there's more room above + 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 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: [ - // Barrier to close menu when clicking outside - Positioned.fill( - child: GestureDetector( - onTap: () => Navigator.pop(context), - behavior: HitTestBehavior.opaque, - child: Container(color: Colors.transparent), + return FocusScope( + // When opened via mouse, don't autofocus any item — let hover handle highlights. + // When opened via keyboard/dpad, autofocus is handled by _initialFocusNode. + 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: [ + // Barrier to close menu when clicking outside + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), ), - ), - // Menu - Positioned( - left: left, - top: top, - child: Material( - elevation: 8, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: menuWidth, maxWidth: menuWidth), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.actions.asMap().entries.map((entry) { - final index = entry.key; - final action = entry.value; - return FocusableListTile( - focusNode: index == 0 ? _initialFocusNode : null, - leading: AppIcon(action.icon, fill: 1, size: 20), - title: Text(action.label), - onTap: () => Navigator.pop(context, action.value), - hoverColor: action.hoverColor, - textColor: action.foregroundColor, - iconColor: action.foregroundColor, - ); - }).toList(), + // Menu + Positioned( + left: left, + top: top, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + 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.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + return FocusableListTile( + focusNode: index == 0 && widget.focusFirstItem ? _initialFocusNode : null, + leading: AppIcon(action.icon, fill: 1, size: 20), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + hoverColor: action.hoverColor, + textColor: action.foregroundColor, + iconColor: action.foregroundColor, + ); + }).toList(), + ), + ), ), ), ), - ), - ], + ], + ), ), ); }