diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 35ed58d1..0799ee60 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -131,6 +131,7 @@ class MornyeGlassPanel extends ConsumerWidget { this.strongTint = false, this.tintOpacity, this.tintColor, + this.backdropFilter, this.liquidGlass = false, this.blurEnabled = true, }); @@ -144,6 +145,7 @@ class MornyeGlassPanel extends ConsumerWidget { this.lastInGroup = true, this.tintOpacity = 0.78, this.tintColor, + this.backdropFilter, this.liquidGlass = false, this.blurEnabled = true, }) : strongTint = false; @@ -155,6 +157,7 @@ class MornyeGlassPanel extends ConsumerWidget { final bool strongTint; final double? tintOpacity; final Color? tintColor; + final ImageFilter? backdropFilter; final bool liquidGlass; /// Disable backdrop sampling for surfaces that scroll over a plain page. @@ -172,6 +175,7 @@ class MornyeGlassPanel extends ConsumerWidget { radius: radius, tintOpacity: tintOpacity, tintColor: tintColor, + backdropFilter: backdropFilter, blurEnabled: blur, child: Material(color: Colors.transparent, child: child), ); @@ -183,6 +187,7 @@ class MornyeGlassPanel extends ConsumerWidget { strongTint: strongTint, tintOpacity: tintOpacity, tintColor: tintColor, + backdropFilter: backdropFilter, blurEnabled: blur, child: Material( color: strongTint @@ -203,6 +208,7 @@ class MornyeGlass extends StatelessWidget { this.strongTint = false, this.tintOpacity, this.tintColor, + this.backdropFilter, }) : _useLens = true, firstInGroup = true, lastInGroup = true; @@ -218,6 +224,7 @@ class MornyeGlass extends StatelessWidget { this.strongTint = false, this.tintOpacity, this.tintColor, + this.backdropFilter, }) : _useLens = false; final Widget child; @@ -233,6 +240,7 @@ class MornyeGlass extends StatelessWidget { /// Uses a single tint instead of layered highlights for translucent surfaces. final double? tintOpacity; final Color? tintColor; + final ImageFilter? backdropFilter; @override Widget build(BuildContext context) { @@ -248,6 +256,7 @@ class MornyeGlass extends StatelessWidget { strongTint: strongTint, tintOpacity: tintOpacity, tintColor: tintColor, + backdropFilter: backdropFilter, child: useGlass ? NativeGlassMetrics( child: LiquidGlassLens( @@ -299,6 +308,7 @@ class _MornyeGlassSurface extends StatelessWidget { this.strongTint = false, this.tintOpacity, this.tintColor, + this.backdropFilter, }); final Widget child; @@ -309,6 +319,7 @@ class _MornyeGlassSurface extends StatelessWidget { final bool strongTint; final double? tintOpacity; final Color? tintColor; + final ImageFilter? backdropFilter; @override Widget build(BuildContext context) { @@ -321,7 +332,7 @@ class _MornyeGlassSurface extends StatelessWidget { ); final rim = BorderSide( color: dark - ? Colors.white.withValues(alpha: 0.16) + ? Colors.white.withValues(alpha: tintColor == null ? 0.16 : 0.28) : Colors.black.withValues(alpha: 0.17), width: 0.75, ); @@ -390,7 +401,10 @@ class _MornyeGlassSurface extends StatelessWidget { child: ClipRRect( borderRadius: shape, child: useBlur - ? BackdropFilter(filter: _backdropBlur, child: surface) + ? BackdropFilter( + filter: backdropFilter ?? _backdropBlur, + child: surface, + ) : surface, ), ), diff --git a/lib/widgets/mornye_context_menu.dart b/lib/widgets/mornye_context_menu.dart index 98521f8e..f8a3b8af 100644 --- a/lib/widgets/mornye_context_menu.dart +++ b/lib/widgets/mornye_context_menu.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -153,6 +154,67 @@ class MornyeMenuAction { /// One frosted popover, with compact shortcuts and separated action groups. class MornyeContextMenu extends StatelessWidget { + // Compress bright artwork after blurring it, preserving more backdrop + // detail in the midtones than a thick, opaque gray fill would allow. + static final _darkBackdrop = ImageFilter.compose( + outer: const ColorFilter.matrix([ + 0.22, + 0, + 0, + 0, + 54, + 0, + 0.22, + 0, + 0, + 54, + 0, + 0, + 0.22, + 0, + 56, + 0, + 0, + 0, + 1, + 0, + ]), + inner: _frostedBackdrop(1.8), + ); + static final _lightBackdrop = _frostedBackdrop(1); + + static ImageFilter _frostedBackdrop(double brightness) { + const saturation = 0.45; + const r = 0.2126 * (1 - saturation); + const g = 0.7152 * (1 - saturation); + const b = 0.0722 * (1 - saturation); + return ImageFilter.compose( + outer: ColorFilter.matrix([ + (r + saturation) * brightness, + g * brightness, + b * brightness, + 0, + 0, + r * brightness, + (g + saturation) * brightness, + b * brightness, + 0, + 0, + r * brightness, + g * brightness, + (b + saturation) * brightness, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]), + inner: ImageFilter.blur(sigmaX: 32, sigmaY: 32), + ); + } + const MornyeContextMenu({ super.key, this.quickActions = const [], @@ -171,6 +233,7 @@ class MornyeContextMenu extends StatelessWidget { final theme = inheritSurface ? Theme.of(context) : MornyeTheme.fromContext(context); + final dark = theme.brightness == Brightness.dark; final visibleGroups = groups.where((group) => group.isNotEmpty).toList(); final shortcuts = quickActions.isEmpty ? null @@ -193,11 +256,14 @@ class MornyeContextMenu extends StatelessWidget { if (i > 0 || quickActions.isNotEmpty) Padding( padding: EdgeInsets.symmetric(vertical: i == 0 ? 0 : 8), - child: const Divider( + child: Divider( height: 0.5, thickness: 0.5, indent: 20, endIndent: 20, + color: theme.colorScheme.onSurface.withValues( + alpha: dark ? 0.18 : 0.12, + ), ), ), for (final action in visibleGroups[i]) _action(theme, action), @@ -211,8 +277,11 @@ class MornyeContextMenu extends StatelessWidget { radius: 28, // Keep one local, clipped backdrop. A second screen-space lens pass // can shift its outline inside an offset/scaled popover on Impeller. + // Soften artwork colors and bright lyrics before the neutral tint, + // keeping white menu text readable even over a near-white cover. + backdropFilter: dark ? _darkBackdrop : _lightBackdrop, tintColor: Colors.white, - tintOpacity: theme.brightness == Brightness.dark ? 0.12 : 0.50, + tintOpacity: dark ? 0.025 : 0.72, child: LayoutBuilder( builder: (context, constraints) { // Let the entire menu scroll when pinning the shortcuts would @@ -249,16 +318,16 @@ class MornyeContextMenu extends StatelessWidget { : action.destructive || action.selected ? theme.colorScheme.primary : theme.colorScheme.onSurface; - final icon = Icon(action.icon, size: compact ? 23 : 21, color: color); + final icon = Icon(action.icon, size: compact ? 24 : 22, color: color); final label = Text( action.label, textAlign: compact ? TextAlign.center : TextAlign.start, style: theme.textTheme.bodyLarge?.copyWith( color: color, fontSize: compact - ? 13 + ? 14 : dense - ? 15 + ? 16 : 17, fontWeight: compact ? FontWeight.w600 : FontWeight.w400, ), @@ -289,8 +358,9 @@ class MornyeContextMenu extends StatelessWidget { Text( action.subtitle!, style: theme.textTheme.bodySmall?.copyWith( + fontSize: 14, color: theme.colorScheme.onSurface.withValues( - alpha: 0.78, + alpha: 0.96, ), ), ), diff --git a/lib/widgets/mornye_player_actions_sheet.dart b/lib/widgets/mornye_player_actions_sheet.dart index 64268344..ab75fd0c 100644 --- a/lib/widgets/mornye_player_actions_sheet.dart +++ b/lib/widgets/mornye_player_actions_sheet.dart @@ -92,7 +92,6 @@ class MornyePlayerActionsSheet extends ConsumerWidget { ), ); return MornyeContextMenu( - dense: true, quickActions: [ _action( context, diff --git a/test/mornye_context_menu_test.dart b/test/mornye_context_menu_test.dart index 97e6590e..238481e7 100644 --- a/test/mornye_context_menu_test.dart +++ b/test/mornye_context_menu_test.dart @@ -137,46 +137,60 @@ void main() { }))!; for (final brightness in Brightness.values) { - testWidgets('menu glass lightens its backdrop evenly ($brightness)', ( - tester, - ) async { - final originalDisableShadows = debugDisableShadows; - debugDisableShadows = false; - try { - final capture = GlobalKey(); - await openMenu( - tester, - anchor: const Rect.fromLTWH(330, 144, 44, 44), - brightness: brightness, - backgroundColor: const Color(0xff6c3a22), - capture: capture, - onResult: (_) {}, - ); - final menu = tester.getRect(find.byType(MornyeContextMenu)); - final pixels = await samplePixels(tester, capture, [ - const Offset(8, 80), - for (final x in [menu.left + 8, menu.right - 8]) - for (final fraction in [0.25, 0.5, 0.75]) - Offset(x, menu.top + menu.height * fraction), - ]); - final outside = pixels.first; - for (final inside in pixels.skip(1)) { - // The white wash lightens all channels while retaining the backdrop's - // warm hue. Sampling both sides catches an extra, offset glass panel. - expect(inside.r, greaterThan(outside.r + 0.04)); - expect(inside.g, greaterThan(outside.g + 0.04)); - expect(inside.b, greaterThan(outside.b + 0.04)); - expect(inside.r, greaterThan(inside.g + 0.04)); - expect(inside.g, greaterThan(inside.b + 0.02)); - expect(inside.r, closeTo(pixels[1].r, 0.015)); - expect(inside.g, closeTo(pixels[1].g, 0.015)); - expect(inside.b, closeTo(pixels[1].b, 0.015)); + for (final background in [ + Colors.white, + Colors.black, + const Color(0xff6c3a22), + ]) { + testWidgets('menu labels stay readable over $background ($brightness)', ( + tester, + ) async { + final originalDisableShadows = debugDisableShadows; + debugDisableShadows = false; + try { + final capture = GlobalKey(); + await openMenu( + tester, + anchor: const Rect.fromLTWH(330, 144, 44, 44), + brightness: brightness, + backgroundColor: background, + capture: capture, + onResult: (_) {}, + ); + final menu = tester.getRect(find.byType(MornyeContextMenu)); + final pixels = await samplePixels(tester, capture, [ + for (final x in [menu.left + 8, menu.right - 8]) + for (final fraction in [0.25, 0.5, 0.75]) + Offset(x, menu.top + menu.height * fraction), + ]); + final foreground = MornyeTheme.build( + brightness, + ).colorScheme.onSurface; + for (final inside in pixels) { + // Busy lyrics and bright artwork must not wash out either label. + for (final textColor in [ + foreground, + Color.alphaBlend(foreground.withValues(alpha: 0.96), inside), + ]) { + final luminances = [ + inside.computeLuminance(), + textColor.computeLuminance(), + ]..sort(); + final contrast = + (luminances.last + 0.05) / (luminances.first + 0.05); + expect(contrast, greaterThanOrEqualTo(4.5)); + } + // The panel remains even at both edges without an offset lens. + expect(inside.r, closeTo(pixels.first.r, 0.015)); + expect(inside.g, closeTo(pixels.first.g, 0.015)); + expect(inside.b, closeTo(pixels.first.b, 0.015)); + } + expect(tester.takeException(), isNull); + } finally { + debugDisableShadows = originalDisableShadows; } - expect(tester.takeException(), isNull); - } finally { - debugDisableShadows = originalDisableShadows; - } - }); + }); + } testWidgets('menu stays on screen near a bottom edge ($brightness)', ( tester, @@ -203,6 +217,34 @@ void main() { }); } + testWidgets('frosted glass still carries the artwork color', (tester) async { + final colors = []; + for (final background in [ + const Color(0xff804020), + const Color(0xff204080), + ]) { + final capture = GlobalKey(); + await openMenu( + tester, + anchor: const Rect.fromLTWH(330, 144, 44, 44), + brightness: Brightness.dark, + backgroundColor: background, + capture: capture, + onResult: (_) {}, + ); + final rect = tester.getRect(find.byType(MornyeContextMenu)); + colors.addAll( + await samplePixels(tester, capture, [ + Offset(rect.left + 8, rect.center.dy), + ]), + ); + await tester.pumpWidget(const SizedBox.shrink()); + } + expect(colors.first.r, greaterThan(colors.last.r + 0.03)); + expect(colors.last.b, greaterThan(colors.first.b + 0.03)); + expect(tester.takeException(), isNull); + }); + for (final highContrast in [false, true]) { testWidgets('menu stays readable without blur (contrast: $highContrast)', ( tester, diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index f2aed50f..862c3f24 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -1096,7 +1096,7 @@ void main() { expect(find.text('Go to Artist'), findsOneWidget); expect(find.text('Favorite'), findsOneWidget); expect(find.text('Share'), findsOneWidget); - expect(tester.widget(find.text('Go to Album')).style?.fontSize, 15); + expect(tester.widget(find.text('Go to Album')).style?.fontSize, 17); await tester.ensureVisible(find.text('Sleep timer')); await tester.tap(find.text('Sleep timer')); await tester.pumpAndSettle(); @@ -2608,8 +2608,8 @@ void main() { final glass = tester.widget( find.byType(MornyeGlassPanel).last, ); - expect(glass.tintColor, Colors.white); - expect(glass.tintOpacity, lessThan(0.5)); + expect(glass.backdropFilter, isNotNull); + expect(glass.tintOpacity, inExclusiveRange(0, 1)); expect( find.text('Hide Pronunciation'), supplement == 'pronunciation' ? findsOneWidget : findsNothing,