diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index d71cf05c..15ad5e0f 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -283,50 +283,55 @@ class _MornyeBottomBarState extends ConsumerState return Stack( clipBehavior: Clip.none, children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasPlayer || amount > 0) - Align( - alignment: Alignment.bottomCenter, - // With no track, introducing the row at full height would - // make the bar jump taller on the first animation frame. - heightFactor: hasPlayer ? 1 : amount, - child: Padding( - padding: EdgeInsets.only( - bottom: hasPlayer - ? tabGap + (8 - tabGap) * amount - : 8, + // Only the non-overlapping player and tab surfaces share a + // backdrop. Moving side capsules stay outside this group: they + // cross the tab bar while folding and need separate samples. + BackdropGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasPlayer || amount > 0) + Align( + alignment: Alignment.bottomCenter, + // With no track, introducing the row at full height would + // make the bar jump taller on the first animation frame. + heightFactor: hasPlayer ? 1 : amount, + child: Padding( + padding: EdgeInsets.only( + bottom: hasPlayer + ? tabGap + (8 - tabGap) * amount + : 8, + ), + child: Row( + children: [ + SizedBox( + width: 60 * amount, + height: hasPlayer ? 48 + 4 * amount : 52, + ), + Expanded(child: player), + SizedBox( + width: 60 * amount, + height: hasPlayer ? 48 + 4 * amount : 52, + ), + ], + ), ), - child: Row( - children: [ - SizedBox( - width: 60 * amount, - height: hasPlayer ? 48 + 4 * amount : 52, - ), - Expanded(child: player), - SizedBox( - width: 60 * amount, - height: hasPlayer ? 48 + 4 * amount : 52, - ), - ], + ), + ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: 1 - amount, + child: IgnorePointer( + ignoring: amount > 0.5, + child: ExcludeSemantics( + excluding: amount > 0.5, + child: amount == 0 ? fullTabs : foldingTabs, + ), ), ), ), - ClipRect( - child: Align( - alignment: Alignment.bottomCenter, - heightFactor: 1 - amount, - child: IgnorePointer( - ignoring: amount > 0.5, - child: ExcludeSemantics( - excluding: amount > 0.5, - child: amount == 0 ? fullTabs : foldingTabs, - ), - ), - ), - ), - ], + ], + ), ), movingIcon(leading: true, surface: leadingSurface), movingIcon(leading: false, surface: searchSurface), diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index f97e9d4c..22daf648 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -357,36 +357,42 @@ class MornyeGlass extends StatelessWidget { /// Keep the capsule legible independently of the shader renderer. The liquid /// lens adds refraction above this frosted base, never above bare page text. -class _MornyeGlassSurface extends StatelessWidget { - static final _backdropBlur = ImageFilter.blur(sigmaX: 18, sigmaY: 18); +class _MornyeGlassSurface extends ConsumerWidget { + // Reuse a small set of filters rather than constructing a new blur for every + // slider frame. Nearly opaque glass needs a much smaller sampling radius. + static final _backdropBlurs = [ + for (final sigma in [4.0, 8.0, 12.0, 18.0]) + ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + ]; // Reduce luminance without flattening the backdrop's color differences. // Subtract 35% of Rec.709 luma from every channel: white is bounded at 0.65, // while colored artwork stays visible without per-frame pixel readback. - static final _darkBackdrop = ImageFilter.compose( - outer: const ColorFilter.matrix([ - 0.92559, - -0.25032, - -0.02527, - 0, - 0, - -0.07441, - 0.74968, - -0.02527, - 0, - 0, - -0.07441, - -0.25032, - 0.97473, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - ]), - inner: _backdropBlur, - ); + static const _darken = ColorFilter.matrix([ + 0.92559, + -0.25032, + -0.02527, + 0, + 0, + -0.07441, + 0.74968, + -0.02527, + 0, + 0, + -0.07441, + -0.25032, + 0.97473, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]); + static final _darkBackdrops = [ + for (final blur in _backdropBlurs) + ImageFilter.compose(outer: _darken, inner: blur), + ]; const _MornyeGlassSurface({ required this.child, @@ -413,12 +419,21 @@ class _MornyeGlassSurface extends StatelessWidget { final bool samplesBackdrop; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; final clarity = MornyeTheme.glassClarityOf(context); final useBlur = blurEnabled && clarity > 0 && !MediaQuery.highContrastOf(context); final dark = scheme.brightness == Brightness.dark; + final frosted = + ref.watch(mornyeGlassLevelProvider) == MornyeGlassLevel.frosted; + final filterIndex = clarity <= 0.25 + ? 0 + : frosted || clarity <= 0.50 + ? 1 + : clarity <= 0.75 + ? 2 + : 3; final shape = BorderRadius.vertical( top: firstInGroup ? Radius.circular(radius) : Radius.zero, bottom: lastInGroup ? Radius.circular(radius) : Radius.zero, @@ -434,6 +449,10 @@ class _MornyeGlassSurface extends StatelessWidget { baseTint, ) : scheme.surfaceContainerHigh; + // On mid-range GPUs an almost opaque tint hides the blur anyway. Keep its + // translucency and rim without paying for a backdrop pass at low clarity. + final sampleBackdrop = + useBlur && samplesBackdrop && (!frosted || tint.a < 0.95); final surface = DecoratedBox( decoration: BoxDecoration(color: tint, borderRadius: shape), child: child, @@ -463,10 +482,13 @@ class _MornyeGlassSurface extends StatelessWidget { ), child: ClipRRect( borderRadius: shape, - child: useBlur && samplesBackdrop - ? BackdropFilter( + child: sampleBackdrop + ? BackdropFilter.grouped( filter: - backdropFilter ?? (dark ? _darkBackdrop : _backdropBlur), + backdropFilter ?? + (dark + ? _darkBackdrops[filterIndex] + : _backdropBlurs[filterIndex]), child: surface, ) : surface, diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index 7c22c715..bd02297f 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -137,6 +137,44 @@ void main() { const albumBlue = Color(0xff464566); + testWidgets( + 'player and navbar share a backdrop while moving capsules remain isolated', + (tester) async { + await pumpShell(tester, blur: true, liquidGlass: false); + void expectSharedBackdrop() { + final filters = find.descendant( + of: find.byType(MornyeBottomBar), + matching: find.byType(BackdropFilter), + ); + final renderers = filters + .evaluate() + .map( + (element) => element.findRenderObject()! as RenderBackdropFilter, + ) + .toList(); + final shared = renderers + .where((filter) => filter.backdropKey != null) + .toList(); + expect(shared, hasLength(2)); + expect(shared[0].backdropKey, same(shared[1].backdropKey)); + // Side capsules overlap the folding tabs and must not join their group. + expect( + renderers.where((filter) => filter.backdropKey == null), + hasLength(2), + ); + } + + expectSharedBackdrop(); + chrome.value = true; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 190)); + expectSharedBackdrop(); + await tester.pumpAndSettle(); + expectSharedBackdrop(); + expect(tester.takeException(), isNull); + }, + ); + for (final liquidGlass in [false, true]) { testWidgets( 'navbar keeps its backdrop blurred throughout motion (liquid: $liquidGlass)', diff --git a/test/mornye_glass_level_test.dart b/test/mornye_glass_level_test.dart index ab2891db..dfad5990 100644 --- a/test/mornye_glass_level_test.dart +++ b/test/mornye_glass_level_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -115,6 +117,54 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'low clarity uses a small blur and mid-range glass caps its radius', + (tester) async { + Future filterFor(double clarity, bool liquid) async { + await tester.pumpWidget( + ProviderScope( + key: UniqueKey(), + overrides: [ + mornyeGlassLevelProvider.overrideWithValue( + liquid ? MornyeGlassLevel.liquid : MornyeGlassLevel.frosted, + ), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.light, glassClarity: clarity), + home: const MornyeGlass.navigation( + blurEnabled: true, + child: SizedBox(width: 200, height: 50), + ), + ), + ), + ); + final filters = tester.widgetList( + find.byType(BackdropFilter), + ); + return filters.isEmpty ? null : filters.single.filter; + } + + expect(await filterFor(0, false), isNull); + expect(await filterFor(0.1, false), isNull); + expect( + await filterFor(0.25, false), + ui.ImageFilter.blur(sigmaX: 4, sigmaY: 4), + ); + expect( + await filterFor(0.1, true), + ui.ImageFilter.blur(sigmaX: 4, sigmaY: 4), + ); + expect( + await filterFor(1, false), + ui.ImageFilter.blur(sigmaX: 8, sigmaY: 8), + ); + expect( + await filterFor(1, true), + ui.ImageFilter.blur(sigmaX: 18, sigmaY: 18), + ); + }, + ); + for (final clarity in [0.0, 0.5]) { testWidgets('liquid tab and segment pills follow clarity ($clarity)', ( tester,