From 0914e7d9788b229eb0b6fc570894ba5d206c035b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:24:54 +0700 Subject: [PATCH] feat(mornye): restore liquid glass with continuous draggable navigation --- .../mornye_liquid_glass_test.dart | 280 ++++++ lib/l10n/arb/app_en.arb | 1 + lib/l10n/arb/app_id.arb | 1 + lib/providers/runtime_profile_provider.dart | 11 +- lib/screens/main_shell.dart | 51 +- lib/screens/now_playing_screen.dart | 2 + .../settings/appearance_settings_page.dart | 23 +- lib/theme/mornye_theme.dart | 2 +- lib/widgets/app_snack_bar.dart | 2 +- lib/widgets/mornye_artist_header.dart | 29 - lib/widgets/mornye_bottom_bar.dart | 95 +- lib/widgets/mornye_chrome.dart | 840 ++++++------------ lib/widgets/mornye_context_menu.dart | 126 +-- lib/widgets/mornye_landscape_player.dart | 35 +- lib/widgets/mornye_liquid_backdrop.dart | 57 ++ lib/widgets/mornye_player_actions_sheet.dart | 224 ++--- lib/widgets/mornye_selection_pill.dart | 438 +++++++++ lib/widgets/native_glass_metrics.dart | 25 - pubspec.lock | 4 +- pubspec.yaml | 2 +- test/app_snack_bar_test.dart | 7 +- test/appearance_settings_test.dart | 12 + test/mornye_bottom_bar_test.dart | 24 +- test/mornye_context_menu_test.dart | 78 +- test/mornye_glass_interaction_test.dart | 270 ++++++ ...est.dart => mornye_glass_layout_test.dart} | 15 +- test/mornye_glass_level_test.dart | 126 +-- test/mornye_landscape_player_test.dart | 6 +- test/now_playing_lyrics_transition_test.dart | 54 +- 29 files changed, 1818 insertions(+), 1022 deletions(-) create mode 100644 integration_test/mornye_liquid_glass_test.dart create mode 100644 lib/widgets/mornye_liquid_backdrop.dart create mode 100644 lib/widgets/mornye_selection_pill.dart delete mode 100644 lib/widgets/native_glass_metrics.dart create mode 100644 test/mornye_glass_interaction_test.dart rename test/{native_glass_metrics_test.dart => mornye_glass_layout_test.dart} (88%) diff --git a/integration_test/mornye_liquid_glass_test.dart b/integration_test/mornye_liquid_glass_test.dart new file mode 100644 index 00000000..a7af63bf --- /dev/null +++ b/integration_test/mornye_liquid_glass_test.dart @@ -0,0 +1,280 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/widgets/mornye_liquid_backdrop.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_selection_pill.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'Impeller lens refracts its own bounds and preserves foreground', + (tester) async { + expect(ui.ImageFilter.isShaderFilterSupported, isTrue); + final key = GlobalKey(); + final movement = ValueNotifier(0); + addTearDown(movement.dispose); + await tester.pumpWidget( + MaterialApp( + home: RepaintBoundary( + key: key, + child: LiquidGlassBatch( + child: Stack( + children: [ + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.black, Colors.white], + ), + ), + ), + ), + for (final top in [160.0, 320.0]) + Positioned( + left: 40, + top: top, + width: 300, + height: 80, + child: ValueListenableBuilder( + valueListenable: movement, + builder: (context, value, child) => Transform.translate( + offset: Offset(value, 0), + child: Transform.scale( + scale: top == 320 ? 0.8 : 1, + child: child, + ), + ), + child: RepaintBoundary( + child: ClipRRect( + borderRadius: BorderRadius.circular(40), + child: MornyeLiquidBackdrop( + borderRadius: BorderRadius.circular(40), + clarity: 1, + blurSigma: 2, + child: const Center( + child: SizedBox( + width: 16, + height: 16, + child: ColoredBox(color: Color(0xff00ff00)), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + // Asset compilation completes outside the fake widget-test clock. + await tester.runAsync(() => LiquidGlassShaders.ensureLoaded()); + await tester.pumpAndSettle(); + expect(tester.binding.hasScheduledFrame, isFalse); + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final pixelRatio = tester.view.devicePixelRatio; + final image = await boundary.toImage(pixelRatio: pixelRatio); + final bytes = (await image.toByteData())!; + int channel(int x, int y, int c) => bytes.getUint8( + ((y * pixelRatio).round() * image.width + (x * pixelRatio).round()) * + 4 + + c, + ); + final png = (await image.toByteData(format: ui.ImageByteFormat.png))!; + final directory = await getTemporaryDirectory(); + await File('${directory.path}/mornye-lens-check.png').writeAsBytes( + png.buffer.asUint8List(png.offsetInBytes, png.lengthInBytes), + ); + debugPrint('Glass diagnostic: ${directory.path}/mornye-lens-check.png'); + for (final y in [200, 360]) { + // An inward lens shifts the left edge into the brighter background. + final edgeX = y == 200 ? 42 : 72; + expect(channel(edgeX, y, 0), greaterThan(channel(edgeX, 100, 0) + 4)); + // The middle of the backdrop remains at its original screen position. + expect(channel(150, y, 0), closeTo(channel(150, 100, 0), 2)); + if (y == 200) { + expect(channel(190, y, 0), 0); + expect(channel(190, y, 1), 255); + expect(channel(190, y, 2), 0); + } + } + expect(channel(20, 200, 0), channel(20, 100, 0)); + image.dispose(); + movement.value = 20; + await tester.pumpAndSettle(); + final moved = await boundary.toImage(pixelRatio: pixelRatio); + final movedBytes = (await moved.toByteData())!; + int red(int x, int y) => movedBytes.getUint8( + ((y * pixelRatio).round() * moved.width + (x * pixelRatio).round()) * 4, + ); + expect(red(62, 200), greaterThan(red(62, 100) + 4)); + expect(red(92, 360), greaterThan(red(92, 100) + 4)); + moved.dispose(); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('held lens follows a drag and releases without changing taps', ( + tester, + ) async { + var selected = 0; + final changes = []; + final theme = MornyeTheme.build(Brightness.dark, glassClarity: 1); + final lens = find.byWidgetPredicate( + (widget) => widget is MornyeLiquidBackdrop && widget.interaction, + ); + await tester.pumpWidget( + ProviderScope( + overrides: [deviceSupportsLiquidGlassProvider.overrideWithValue(true)], + child: MaterialApp( + theme: theme, + home: Scaffold( + body: Stack( + children: [ + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xffb35c28), + Color(0xff3f276d), + Color(0xff264e75), + ], + ), + ), + ), + ), + Positioned.fill( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (var i = 0; i < 10; i++) + Text( + 'Music • Library • Albums', + style: theme.textTheme.headlineMedium, + ), + ], + ), + ), + Positioned( + left: 16, + right: 16, + bottom: 40, + child: StatefulBuilder( + builder: (context, setState) => MornyeTabBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.library_music), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.search), + label: 'Search', + ), + ], + selectedIndex: selected, + blurEnabled: true, + onSelected: (value) { + changes.add(value); + setState(() => selected = value); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 500)), + ); + await tester.pumpAndSettle(); + expect(find.byType(LiquidGlassLens), findsOneWidget); + final gesture = await tester.startGesture( + tester.getCenter(find.text('Home')), + ); + await tester.pumpAndSettle(); + expect(tester.widget(lens).progress, 1); + expect(find.byType(LiquidGlassLens), findsNWidgets(2)); + final home = tester.getCenter(find.text('Home')); + final library = tester.getCenter(find.text('Library')); + final pill = find.descendant( + of: find + .descendant( + of: find.byType(MornyeSelectionPill), + matching: find.byType(AnimatedAlign), + ) + .first, + matching: find.byType(FractionallySizedBox), + ); + final heldWidth = + tester.getBottomRight(lens).dx - tester.getTopLeft(lens).dx; + expect(heldWidth, greaterThan(tester.getSize(pill).width * 1.2)); + await tester.pump(const Duration(seconds: 1)); + expect(tester.widget(lens).progress, 1); + expect(changes, isEmpty); + for (final fraction in [0.35, 0.45, 0.85, 1.2, 1.1, 0.9, 1.5]) { + final position = Offset( + home.dx + (library.dx - home.dx) * fraction, + home.dy, + ); + await gesture.moveTo(position); + await tester.pump(); + expect(tester.getCenter(pill).dx, closeTo(position.dx, 0.1)); + expect(tester.getCenter(lens).dx, closeTo(position.dx, 0.1)); + expect(changes, isEmpty); + } + if (const bool.fromEnvironment('GLASS_PREVIEW')) { + debugPrint('GLASS_PREVIEW: holding Home'); + await tester.runAsync( + () => Future.delayed(const Duration(seconds: 25)), + ); + } + await gesture.moveTo(tester.getCenter(find.text('Search'))); + await tester.pumpAndSettle(); + expect(changes, isEmpty); + expect( + tester.getCenter(pill).dx, + closeTo(tester.getCenter(find.text('Search')).dx, 0.1), + ); + await gesture.up(); + await tester.pumpAndSettle(); + expect(changes, [2]); + expect(tester.widget(lens).progress, 0); + expect(find.byType(LiquidGlassLens), findsOneWidget); + final cancel = await tester.startGesture( + tester.getCenter(find.text('Search')), + ); + await cancel.moveTo(tester.getCenter(find.text('Home'))); + await tester.pumpAndSettle(); + await cancel.cancel(); + await tester.pumpAndSettle(); + expect(changes, [2]); + expect( + tester.getCenter(pill).dx, + closeTo(tester.getCenter(find.text('Search')).dx, 0.1), + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 06894180..c136fa55 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -79,6 +79,7 @@ "appearanceGlassClaritySubtitle": "Adjust transparency in Mornye menus and navigation. Text contrast is preserved.", "appearanceGlassTinted": "Tinted", "appearanceGlassClear": "Clear", + "appearanceGlassClearPerformance": "Very clear glass may use more power and feel less smooth on older devices. Lower clarity if needed.", "appearanceGlassClarityBlurOff": "Glass is opaque on this device. Turn on “{setting}” to adjust clarity.", "@appearanceGlassClarityBlurOff": { "description": "Shown under the disabled glass clarity slider when the device renders Mornye glass without blur. The placeholder is the name of the force-blur toggle.", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index dbb33753..abfe9e7c 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -79,6 +79,7 @@ "appearanceGlassClaritySubtitle": "Atur transparansi menu dan navigasi Mornye. Kontras tulisan tetap dijaga.", "appearanceGlassTinted": "Pekat", "appearanceGlassClear": "Bening", + "appearanceGlassClearPerformance": "Kaca sangat bening dapat memakai lebih banyak daya dan terasa kurang lancar di perangkat lama. Kurangi kebeningan jika perlu.", "appearanceGlassClarityBlurOff": "Kaca dibuat pekat di perangkat ini. Aktifkan “{setting}” untuk mengatur kebeningan.", "appearanceGlassClarityHighContrast": "Kaca tetap pekat selama pengaturan kontras tinggi sistem aktif.", "appearanceUseSystemFont": "Gunakan font sistem", diff --git a/lib/providers/runtime_profile_provider.dart b/lib/providers/runtime_profile_provider.dart index d1fc312d..5f9fced0 100644 --- a/lib/providers/runtime_profile_provider.dart +++ b/lib/providers/runtime_profile_provider.dart @@ -10,10 +10,9 @@ final lowEndDeviceProvider = Provider((ref) => false); /// in main.dart). Only the `high` tier enables blur by default. final deviceSupportsBackdropBlurProvider = Provider((ref) => false); -/// Whether the device's GPU is expected to sustain Mornye's shader glass -/// (refractive lenses, the travelling tab pill, liquid switches and per-line -/// lyric defocus) on top of plain backdrop blur. Overridden in main.dart: -/// mid-range Android keeps frosted blur without the shader passes. +/// Whether the GPU supports the higher blur budget and animated artwork / +/// lyric defocus. Glass controls themselves use the same lightweight renderer +/// on every tier. Overridden in main.dart. final deviceSupportsLiquidGlassProvider = Provider((ref) => true); /// Whether backdrop blur effects should render: the device default, or the @@ -31,7 +30,7 @@ enum MornyeGlassLevel { /// Translucent surfaces over one backdrop blur; no shader passes. frosted, - /// Frosted surfaces plus refractive shader lenses and blur-driven motion. + /// Impeller backdrop refraction, with a blur fallback on other renderers. liquid, } @@ -50,7 +49,7 @@ final mornyeBlurEnabledProvider = Provider( (ref) => ref.watch(mornyeGlassLevelProvider) != MornyeGlassLevel.flat, ); -/// Shader glass and continuously re-filtered blur effects (liquid only). +/// Higher-quality glass and continuously filtered artwork / lyric effects. final mornyeLiquidGlassProvider = Provider( (ref) => ref.watch(mornyeGlassLevelProvider) == MornyeGlassLevel.liquid, ); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 792b4c14..6d649e58 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -41,6 +41,7 @@ import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; import 'package:spotiflac_android/widgets/expressive_navigation_bar.dart'; import 'package:spotiflac_android/widgets/mornye_bottom_bar.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/selection_bottom_bar.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -890,18 +891,19 @@ class _MainShellState extends ConsumerState label: l10n.navHome, ), NavigationDestination( - icon: AnimatedBadge( - count: queueState, - child: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: Icon( - context.isMornye - ? CupertinoIcons.square_stack_fill - : Icons.library_music_outlined, - ), - ), - ), + icon: context.isMornye + ? MornyeNavigationIcon( + icon: CupertinoIcons.square_stack_fill, + badgeCount: queueState, + ) + : AnimatedBadge( + count: queueState, + child: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.library_music_outlined), + ), + ), selectedIcon: SlidingIcon( child: AnimatedBadge( count: queueState, @@ -916,18 +918,19 @@ class _MainShellState extends ConsumerState ), if (showStore) NavigationDestination( - icon: AnimatedBadge( - count: repoUpdatesCount, - child: Badge( - isLabelVisible: repoUpdatesCount > 0, - label: Text('$repoUpdatesCount'), - child: Icon( - context.isMornye - ? CupertinoIcons.square_grid_2x2 - : Icons.extension_outlined, - ), - ), - ), + icon: context.isMornye + ? MornyeNavigationIcon( + icon: CupertinoIcons.square_grid_2x2, + badgeCount: repoUpdatesCount, + ) + : AnimatedBadge( + count: repoUpdatesCount, + child: Badge( + isLabelVisible: repoUpdatesCount > 0, + label: Text('$repoUpdatesCount'), + child: const Icon(Icons.extension_outlined), + ), + ), selectedIcon: BouncingIcon( child: AnimatedBadge( count: repoUpdatesCount, diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 36e77eb2..e3ee7cab 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -2090,6 +2090,7 @@ class _NowPlayingScreenState extends ConsumerState { final action = await showMornyeContextMenu( context: titleContext, preferAbove: true, + maxWidth: 280, builder: (_) => MornyePlayerNavigationMenu(mediaItem: mediaItem), ); if (!mounted) return; @@ -2160,6 +2161,7 @@ class _NowPlayingScreenState extends ConsumerState { context: context, anchor: anchor, preferAbove: true, + maxWidth: 280, builder: (_) => MornyePlayerActionsSheet( mediaItem: mediaItem, sleepTimerSubtitle: sleepTimerSubtitle, diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index dd3b821a..1b96df0c 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -531,9 +531,16 @@ class _MornyeGlassClaritySlider extends ConsumerWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - theme.colorScheme.primary, - const Color(0xff277f94), - const Color(0xffc69756), + for (final color in [ + theme.colorScheme.primary, + const Color(0xff277f94), + const Color(0xffc69756), + ]) + // Preview light glass over a light backdrop, as in the + // app. Dense sample colors make white glass look gray. + theme.brightness == Brightness.light + ? Color.lerp(Colors.white, color, 0.4)! + : color, ], ), ), @@ -589,6 +596,16 @@ class _MornyeGlassClaritySlider extends ConsumerWidget { Text(l10n.appearanceGlassClear, style: theme.textTheme.bodySmall), ], ), + if (adjustable && clarity >= 0.9) ...[ + const SizedBox(height: 8), + Text( + l10n.appearanceGlassClearPerformance, + key: const ValueKey('mornye-glass-clear-performance'), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ], ), ); diff --git a/lib/theme/mornye_theme.dart b/lib/theme/mornye_theme.dart index dc996434..be4f29aa 100644 --- a/lib/theme/mornye_theme.dart +++ b/lib/theme/mornye_theme.dart @@ -55,7 +55,7 @@ class MornyeTheme extends ThemeExtension { static double navigationOpacity(BuildContext context) { final theme = Theme.of(context); if (theme.extension()?.chromeSurface != null) return 0.24; - return theme.brightness == Brightness.dark ? 0.44 : 0.54; + return theme.brightness == Brightness.dark ? 0.36 : 0.49; } static const lightAccent = Color.fromRGBO(204, 46, 51, 1); diff --git a/lib/widgets/app_snack_bar.dart b/lib/widgets/app_snack_bar.dart index f2231507..9477031b 100644 --- a/lib/widgets/app_snack_bar.dart +++ b/lib/widgets/app_snack_bar.dart @@ -110,7 +110,7 @@ ScaffoldFeatureController showAppSnackBar( child: MornyeGlassPanel.overlay( radius: 24, // Messages have no modal scrim; let more of the page show through. - tintOpacity: 0.60, + tintOpacity: 0.48, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: DefaultTextStyle( diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart index a3efb295..5b9be7b9 100644 --- a/lib/widgets/mornye_artist_header.dart +++ b/lib/widgets/mornye_artist_header.dart @@ -401,35 +401,6 @@ class _ArtistCollapsingArtwork extends ConsumerWidget { ), ), ), - if (blurEnabled && fade < 1) - Positioned.fill( - child: IgnorePointer( - child: Align( - alignment: Alignment.bottomCenter, - child: FractionallySizedBox( - heightFactor: 0.48, - widthFactor: 1, - child: ClipRect( - child: ShaderMask( - blendMode: BlendMode.dstIn, - shaderCallback: (bounds) => const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.white], - stops: [0, 0.7], - ).createShader(bounds), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: ColoredBox( - color: surface.withValues(alpha: 0.08), - ), - ), - ), - ), - ), - ), - ), - ), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index 15ad5e0f..d99194b2 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; @@ -207,7 +208,7 @@ class _MornyeBottomBarState extends ConsumerState final labelHeight = labelPainter.height; labelPainter.dispose(); final fullIconBottom = glassTabs - ? 40 + (labelHeight + 2) / 2 + ? 8 + math.max(64, 37 + labelHeight) / 2 + (labelHeight + 2) / 2 : math.max(64.0, 49 + labelHeight) - 23.5; final tabInset = glassTabs ? 6.0 : 5.0; double fullIconStart(int index) => @@ -242,7 +243,12 @@ class _MornyeBottomBarState extends ConsumerState child: Stack( alignment: Alignment.center, children: [ - Positioned.fill(child: surface), + Positioned.fill( + child: Offstage( + offstage: amount == 0, + child: surface, + ), + ), Material( color: Colors.transparent, child: IconButton( @@ -286,51 +292,54 @@ class _MornyeBottomBarState extends ConsumerState // 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, + LiquidGlassBatch( + child: 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( + clipBehavior: amount == 0 ? Clip.none : Clip.hardEdge, + 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), diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 22daf648..3a6315e8 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -2,10 +2,11 @@ import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; -import 'package:spotiflac_android/widgets/native_glass_metrics.dart'; +import 'package:spotiflac_android/widgets/mornye_selection_pill.dart'; +import 'package:spotiflac_android/widgets/mornye_liquid_backdrop.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class MornyeSegmentedControl extends ConsumerWidget { const MornyeSegmentedControl({ @@ -22,160 +23,31 @@ class MornyeSegmentedControl extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final scheme = theme.colorScheme; - final blur = - !MediaQuery.highContrastOf(context) && - ref.watch(mornyeBlurEnabledProvider); - final animate = !MediaQuery.disableAnimationsOf(context); - // At 0% clarity the material is opaque; drop the shader pill as - // MornyeGlass drops its lens. - final clear = MornyeTheme.glassClarityOf(context) > 0; final height = MediaQuery.textScalerOf(context).scale(15) + 36; - if (!blur || !animate || !clear || !ref.watch(mornyeLiquidGlassProvider)) { - // The Impeller tab bar ignores its pill mode and always runs its shader - // passes. Without blur or motion, draw the resting segments directly. - return _plainSegments(context, blur: blur, height: height); - } - final rtl = Directionality.of(context) == TextDirection.rtl; - final visualLabels = rtl ? labels.reversed.toList() : labels; - int logicalIndex(int index) => rtl ? labels.length - 1 - index : index; - return LayoutBuilder( - builder: (context, constraints) => SizedBox( - height: height + 16, - child: MediaQuery.removePadding( - context: context, - removeBottom: true, - child: Directionality( - textDirection: TextDirection.ltr, - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned( - left: 0, - right: 0, - top: 8, - height: height, - child: _MornyeGlassSurface( - blurEnabled: blur, - radius: height / 2, - child: const SizedBox.expand(), - ), - ), - NativeGlassMetrics( - child: LiquidGlassTabBar.withImpeller( - width: constraints.maxWidth, - height: height, - margin: const EdgeInsets.only(bottom: 8), - itemPadding: 4, - selectedIndex: logicalIndex(selectedIndex), - onChanged: (index) => onChanged(logicalIndex(index)), - style: const LiquidGlassStyle( - shape: LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: 32, - borderWidth: 0, - lightIntensity: 0, - ), - appearance: LiquidGlassAppearance(), - refraction: LiquidGlassRefraction( - distortion: 0, - chromaticAberration: 0, - ), - ), - pillStyle: const LiquidGlassTabPillStyle( - mode: LiquidGlassPillMode.impellerOnly, - animated: true, - ), - itemStyle: LiquidGlassTabItemStyle( - selectedColor: scheme.onSurface, - unselectedColor: scheme.onSurface, - iconSize: 0, - iconLabelGap: 0, - labelFontSize: 15, - ), - items: [ - for (final label in visualLabels) - LiquidGlassTabBarItem( - label: label, - iconBuilder: (_, _) => const SizedBox.shrink(), - labelBuilder: (context, state) => Text( - label, - textDirection: rtl - ? TextDirection.rtl - : TextDirection.ltr, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: scheme.onSurface, - fontWeight: state.selected - ? FontWeight.w600 - : FontWeight.w400, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _plainSegments( - BuildContext context, { - required bool blur, - required double height, - }) { - final theme = Theme.of(context); - final scheme = theme.colorScheme; - final selectionFill = scheme.onSurface.withValues( - alpha: scheme.brightness == Brightness.dark ? 0.12 : 0.08, - ); - final radius = BorderRadius.circular(height / 2); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: SizedBox( height: height, - child: _MornyeGlassSurface( - blurEnabled: blur, + child: MornyeGlass.navigation( + blurEnabled: ref.watch(mornyeBlurEnabledProvider), radius: height / 2, - child: Padding( - padding: const EdgeInsets.all(4), - child: Row( - children: [ - for (var index = 0; index < labels.length; index++) - Expanded( - child: Semantics( - button: true, - selected: index == selectedIndex, - child: Material( - color: index == selectedIndex - ? selectionFill - : Colors.transparent, - borderRadius: radius, - child: InkWell( - borderRadius: radius, - onTap: () => onChanged(index), - child: Center( - child: Text( - labels[index], - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyMedium?.copyWith( - color: scheme.onSurface, - fontWeight: index == selectedIndex - ? FontWeight.w600 - : FontWeight.w400, - ), - ), - ), - ), - ), - ), - ), - ], + child: MornyeSelectionPill( + liquidInteraction: + ref.watch(mornyeLiquidGlassProvider) && + MornyeTheme.glassClarityOf(context) > 0.2, + labels: labels, + selectedIndex: selectedIndex, + onChanged: onChanged, + itemBuilder: (context, index, selected) => Center( + child: Text( + labels[index], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: selected ? FontWeight.w600 : FontWeight.w400, + ), + ), ), ), ), @@ -184,8 +56,7 @@ class MornyeSegmentedControl extends ConsumerWidget { } } -/// The bounded, translucent chrome used by Mornye's tab accessory. Blur is -/// limited to the control itself and follows the app's device performance gate. +/// Shared material for panels, menus, dialogs and floating sheets. class MornyeGlassPanel extends ConsumerWidget { const MornyeGlassPanel({ super.key, @@ -196,11 +67,10 @@ class MornyeGlassPanel extends ConsumerWidget { this.strongTint = false, this.tintOpacity, this.tintColor, - this.backdropFilter, this.blurEnabled = true, + this.highlightBorder = false, }); - /// Shared glass material for confirmation dialogs and floating sheets. const MornyeGlassPanel.overlay({ super.key, required this.child, @@ -209,9 +79,9 @@ class MornyeGlassPanel extends ConsumerWidget { this.lastInGroup = true, this.tintOpacity = 0.78, this.tintColor, - this.backdropFilter, this.blurEnabled = true, - }) : strongTint = false; + this.highlightBorder = false, + }) : strongTint = true; final Widget child; final double radius; @@ -220,32 +90,26 @@ class MornyeGlassPanel extends ConsumerWidget { final bool strongTint; final double? tintOpacity; final Color? tintColor; - final ImageFilter? backdropFilter; - - /// Disable backdrop sampling for surfaces that scroll over a plain page. final bool blurEnabled; + final bool highlightBorder; @override - Widget build(BuildContext context, WidgetRef ref) { - final blur = - blurEnabled && - !MediaQuery.highContrastOf(context) && - ref.watch(mornyeBlurEnabledProvider); - return MornyeGlass.navigation( - radius: radius, - firstInGroup: firstInGroup, - lastInGroup: lastInGroup, - strongTint: strongTint, - tintOpacity: tintOpacity, - tintColor: tintColor, - backdropFilter: backdropFilter, - blurEnabled: blur, - child: Material(color: Colors.transparent, child: child), - ); - } + Widget build(BuildContext context, WidgetRef ref) => MornyeGlass.navigation( + radius: radius, + firstInGroup: firstInGroup, + lastInGroup: lastInGroup, + strongTint: strongTint, + tintOpacity: tintOpacity, + tintColor: tintColor, + blurEnabled: blurEnabled && ref.watch(mornyeBlurEnabledProvider), + highlightBorder: highlightBorder, + child: child, + ); } -class MornyeGlass extends StatelessWidget { +/// Bounded glass: Impeller lenses refract a blurred backdrop, while frosted +/// surfaces share ordinary blur passes. Foreground content stays above glass. +class MornyeGlass extends ConsumerWidget { const MornyeGlass({ super.key, required this.child, @@ -254,13 +118,11 @@ class MornyeGlass extends StatelessWidget { this.strongTint = false, this.tintOpacity, this.tintColor, - this.backdropFilter, - }) : _useLens = true, - firstInGroup = true, + this.highlightBorder = false, + }) : firstInGroup = true, lastInGroup = true, samplesBackdrop = true; - /// Shares the navigation bar's tint, blur and subtle edge reflections. const MornyeGlass.navigation({ super.key, required this.child, @@ -271,142 +133,18 @@ class MornyeGlass extends StatelessWidget { this.strongTint = false, this.tintOpacity, this.tintColor, - this.backdropFilter, - this.samplesBackdrop = true, - }) : _useLens = false; - - final Widget child; - final bool blurEnabled; - - /// False for panels that only ever scroll over the plain page background. - /// Blurring a uniform backdrop changes nothing visible but re-filters the - /// panel's whole area on every scroll frame; the translucent tint, rim and - /// clarity preference stay exactly as with sampling. - final bool samplesBackdrop; - final double radius; - final bool _useLens; - final bool firstInGroup; - final bool lastInGroup; - - /// Keeps floating controls readable over arbitrary album artwork. - final bool strongTint; - - /// Base tint before applying the user's glass clarity preference. - final double? tintOpacity; - final Color? tintColor; - final ImageFilter? backdropFilter; - - @override - Widget build(BuildContext context) { - final clarity = MornyeTheme.glassClarityOf(context); - final useGlass = - _useLens && - blurEnabled && - clarity > 0 && - !MediaQuery.highContrastOf(context); - Widget lens(Widget child) => Consumer( - // Mid-range Android keeps the frosted surface but not the shader lens. - builder: (context, ref, child) => - ref.watch(mornyeLiquidGlassProvider) ? _liquidLens(child!) : child!, - child: child, - ); - return _MornyeGlassSurface( - blurEnabled: blurEnabled, - radius: radius, - firstInGroup: firstInGroup, - lastInGroup: lastInGroup, - strongTint: strongTint, - tintOpacity: tintOpacity, - tintColor: tintColor, - backdropFilter: backdropFilter, - samplesBackdrop: samplesBackdrop, - child: useGlass ? lens(child) : child, - ); - } - - Widget _liquidLens(Widget child) => Builder( - builder: (context) => NativeGlassMetrics( - child: LiquidGlassLens( - style: LiquidGlassStyle( - shape: LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: radius, - // The shared rim owns directional highlights, including - // surfaces without a lens. Avoid a second specular border. - borderWidth: 0, - lightIntensity: 0, - ), - appearance: const LiquidGlassAppearance( - color: Colors.transparent, - // The surface already blurs the backdrop. Refracting that - // frosted result needs no second Gaussian blur pass. - blur: LiquidGlassBlur(), - ), - refraction: const LiquidGlassRefraction( - distortion: 0.02, - distortionWidth: 8, - chromaticAberration: 0, - ), - ), - // Only the shader uses window metrics; responsive content and - // decoded artwork retain the surrounding tablet layout scale. - child: MediaQuery(data: MediaQuery.of(context), child: child), - ), - ), - ); -} - -/// 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 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 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, - required this.blurEnabled, - this.radius = 32, - this.firstInGroup = true, - this.lastInGroup = true, - this.strongTint = false, - this.tintOpacity, - this.tintColor, - this.backdropFilter, this.samplesBackdrop = true, + this.highlightBorder = false, }); + // Quantized and shared across surfaces. Avoid composed color filters and + // rebuilding a Gaussian filter for each clarity-slider animation frame. + static const _blurSigmas = [2.0, 3.0, 6.0, 16.0]; + static final _blurs = [ + for (final sigma in _blurSigmas) + ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + ]; + final Widget child; final bool blurEnabled; final double radius; @@ -415,47 +153,104 @@ class _MornyeGlassSurface extends ConsumerWidget { final bool strongTint; final double? tintOpacity; final Color? tintColor; - final ImageFilter? backdropFilter; + + /// Paint the edge above the fill so tinted popovers retain a visible rim. + final bool highlightBorder; + + /// Plain-page surfaces keep their tint without filtering a uniform backdrop. final bool samplesBackdrop; @override 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 level = ref.watch(mornyeGlassLevelProvider); + final translucent = + blurEnabled && + level != MornyeGlassLevel.flat && + 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, ); - // A translucent white tint must not become solid white behind light - // text when accessibility or the device profile disables blur. - final baseTint = (tintColor ?? scheme.surfaceContainerHigh).withValues( - alpha: (tintOpacity ?? (strongTint ? 0.80 : 0.60)) * (dark ? 0.82 : 0.88), + final artworkTint = Theme.of( + context, + ).extension()?.chromeSurface; + var tintSource = + tintColor ?? (dark ? scheme.surfaceContainerHigh : Colors.white); + var opacity = tintOpacity ?? (strongTint ? 0.80 : 0.60); + if (tintColor == null && artworkTint != null) { + // The artwork color is already known; retain its hue with a richer tint + // instead of a saturation filter over changing page content. + final hsl = HSLColor.fromColor(scheme.surfaceContainerHigh); + final coverTint = hsl + .withSaturation((hsl.saturation * 1.6).clamp(0, 1)) + .toColor(); + tintSource = dark + ? coverTint + : Color.lerp(Colors.white, coverTint, 0.15)!; + opacity = opacity.clamp(0.60, 1.0); + } + // Light glass needs a white veil over saturated or dark artwork. + // Keep this in the existing fill so whitening adds no backdrop pass. + var baseTint = tintSource.withValues( + alpha: dark ? opacity * 0.82 : (opacity * 0.88).clamp(0.50, 1.0), ); - final tint = useBlur + if (dark) { + // Bound bright artwork with paint instead of filtering backdrop luma. + baseTint = Color.alphaBlend( + baseTint, + Colors.black.withValues(alpha: 0.35), + ); + } + // Let the upper end of the clarity slider reveal more of the page. Keep + // the tinted endpoint opaque and the single-pass blur budget unchanged. + final tintStrength = (1 - clarity) * (1 - clarity); + final tint = translucent ? Color.alphaBlend( - scheme.surfaceContainerHigh.withValues(alpha: 1 - clarity), + scheme.surfaceContainerHigh.withValues(alpha: tintStrength), 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 insideGlass = + context.dependOnInheritedWidgetOfExactType<_GlassBackdropScope>() != + null; + // At low clarity the tint obscures almost all detail. In particular, 10% + // must not suddenly enable backdrop sampling on a mid-range GPU. + final blurThreshold = level == MornyeGlassLevel.frosted ? 0.5 : 0.2; final sampleBackdrop = - useBlur && samplesBackdrop && (!frosted || tint.a < 0.95); + translucent && + samplesBackdrop && + !insideGlass && + clarity > blurThreshold; + final useLens = + sampleBackdrop && + firstInGroup && + lastInGroup && + level == MornyeGlassLevel.liquid && + ImageFilter.isShaderFilterSupported; + // Clear glass softens the backdrop less. Keep the mid-range fallback at + // its small blur budget, and reuse these filters in the lens renderer too. + final filterIndex = level == MornyeGlassLevel.frosted + ? 1 + : clarity >= 0.9 + ? 0 + : clarity >= 0.6 + ? 2 + : 3; + final clearFill = (dark ? const Color(0xff999999) : Colors.white) + .withValues(alpha: dark ? 0.17 : 0.46); final surface = DecoratedBox( - decoration: BoxDecoration(color: tint, borderRadius: shape), - child: child, + decoration: BoxDecoration( + color: useLens && !strongTint + ? Color.lerp(tint, clearFill, ((clarity - 0.2) / 0.8).clamp(0, 1)) + : tint, + borderRadius: shape, + ), + // Child controls paint on this surface; never re-blur their parent. + child: _GlassBackdropScope(child: child), ); return DecoratedBox( decoration: BoxDecoration( @@ -464,11 +259,9 @@ class _MornyeGlassSurface extends ConsumerWidget { ? [ BoxShadow( color: Colors.black.withValues(alpha: dark ? 0.14 : 0.08), - blurRadius: 12, - // Clear glass keeps its tint; the shadow belongs outside - // the panel, not underneath its translucent center. + blurRadius: 8, blurStyle: BlurStyle.outer, - offset: const Offset(0, 3), + offset: const Offset(0, 2), ), ] : null, @@ -478,17 +271,21 @@ class _MornyeGlassSurface extends ConsumerWidget { shape: shape, firstInGroup: firstInGroup, lastInGroup: lastInGroup, - illuminated: useBlur, + illuminated: translucent && (!useLens || highlightBorder), + emphasized: highlightBorder, ), child: ClipRRect( borderRadius: shape, - child: sampleBackdrop + child: useLens + ? MornyeLiquidBackdrop( + borderRadius: shape, + clarity: clarity, + blurSigma: _blurSigmas[filterIndex], + child: surface, + ) + : sampleBackdrop ? BackdropFilter.grouped( - filter: - backdropFilter ?? - (dark - ? _darkBackdrops[filterIndex] - : _backdropBlurs[filterIndex]), + filter: _blurs[filterIndex], child: surface, ) : surface, @@ -498,20 +295,28 @@ class _MornyeGlassSurface extends ConsumerWidget { } } -/// Faint edge reflections define the glass without a bright capsule outline. -/// No offscreen layer, extra blur, or per-frame readback. +class _GlassBackdropScope extends InheritedWidget { + const _GlassBackdropScope({required super.child}); + + @override + bool updateShouldNotify(_GlassBackdropScope oldWidget) => false; +} + +/// Subtle edge definition without another offscreen layer or backdrop read. class _MornyeGlassRim extends CustomPainter { const _MornyeGlassRim({ required this.shape, required this.firstInGroup, required this.lastInGroup, required this.illuminated, + required this.emphasized, }); final BorderRadius shape; final bool firstInGroup; final bool lastInGroup; final bool illuminated; + final bool emphasized; @override void paint(Canvas canvas, Size size) { @@ -532,24 +337,29 @@ class _MornyeGlassRim extends CustomPainter { Paint() ..style = PaintingStyle.stroke ..strokeWidth = 0.5 - ..color = Colors.black.withValues(alpha: illuminated ? 0.10 : 0.22), + // A thin contact edge must not paint a shadow under the whole glass. + ..color = Colors.black.withValues(alpha: 0.5), ); if (illuminated) { canvas.drawRRect( outline.deflate(0.8), Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 0.6 + ..strokeWidth = emphasized ? 1 : 0.6 ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0, 0.14, 0.5, 0.86, 1], colors: [ - firstInGroup ? const Color(0x2effffff) : Colors.transparent, - const Color(0x08ffffff), + firstInGroup + ? Color(emphasized ? 0x88ffffff : 0x40ffffff) + : Colors.transparent, + Color(emphasized ? 0x26ffffff : 0x0dffffff), const Color(0x0a000000), - const Color(0x08ffffff), - lastInGroup ? const Color(0x1fffffff) : Colors.transparent, + Color(emphasized ? 0x20ffffff : 0x0dffffff), + lastInGroup + ? Color(emphasized ? 0x55ffffff : 0x26ffffff) + : Colors.transparent, ], ).createShader(bounds), ); @@ -562,7 +372,8 @@ class _MornyeGlassRim extends CustomPainter { shape != oldDelegate.shape || firstInGroup != oldDelegate.firstInGroup || lastInGroup != oldDelegate.lastInGroup || - illuminated != oldDelegate.illuminated; + illuminated != oldDelegate.illuminated || + emphasized != oldDelegate.emphasized; } /// A searchable category uses the same material as the navigation capsule. @@ -636,6 +447,27 @@ class MornyeFilterChip extends ConsumerWidget { } } +class MornyeNavigationIcon extends StatelessWidget { + const MornyeNavigationIcon({ + super.key, + required this.icon, + required this.badgeCount, + }); + + final IconData icon; + final int badgeCount; + + @override + Widget build(BuildContext context) => AnimatedBadge( + count: badgeCount, + child: Badge( + isLabelVisible: badgeCount > 0, + label: Text('$badgeCount'), + child: MornyeSelectionForeground(child: Icon(icon)), + ), + ); +} + class MornyeTabBar extends StatelessWidget { const MornyeTabBar({ super.key, @@ -648,12 +480,17 @@ class MornyeTabBar extends StatelessWidget { this.contentOpacity = const AlwaysStoppedAnimation(1), }); - /// Shader capsule and travelling pill; otherwise the frosted row below. + final List destinations; + final int selectedIndex; + final ValueChanged onSelected; + final bool blurEnabled; final bool liquidGlass; + final Set hiddenIconIndices; - /// Whether the bar renders the shader capsule. Overlays aligned with its - /// icons must use the same decision. At 0% clarity the material is opaque, - /// so the shader pill is dropped as MornyeGlass drops its lens. + /// Fade only the foreground so the backdrop can keep sampling the page. + final Animation contentOpacity; + + /// The floating layout also supplies the coordinates for collapsing icons. static bool usesLiquidGlass( BuildContext context, { required bool blurEnabled, @@ -665,242 +502,85 @@ class MornyeTabBar extends StatelessWidget { !MediaQuery.disableAnimationsOf(context) && !MediaQuery.highContrastOf(context); - final List destinations; - final int selectedIndex; - final ValueChanged onSelected; - final bool blurEnabled; - // The active and Search icons move independently while the capsule folds. - final Set hiddenIconIndices; - - /// Fade icons and labels while the capsule folds. Its backdrop must stay - /// outside the fade layer so it can still sample the page during motion. - final Animation contentOpacity; - @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; - final selectionFill = scheme.onSurface.withValues( - alpha: scheme.brightness == Brightness.dark ? 0.12 : 0.08, - ); - final inactiveIconColor = scheme.onSurface; - if (usesLiquidGlass( + final floating = usesLiquidGlass( context, blurEnabled: blurEnabled, liquidGlass: liquidGlass, - )) { - return LayoutBuilder( - builder: (context, constraints) => SizedBox( - // Leave room above and below for the travelling pill to lift and - // stretch; its shader must not be clipped to the resting capsule. - height: 80, - child: MediaQuery.removePadding( - context: context, - removeBottom: true, - child: Stack( - clipBehavior: Clip.none, + ); + final content = FadeTransition( + opacity: contentOpacity, + child: MornyeSelectionPill( + selectionColor: scheme.primary, + maskItemForeground: false, + liquidInteraction: + floating && MornyeTheme.glassClarityOf(context) > 0.2, + labels: [for (final destination in destinations) destination.label], + selectedIndex: selectedIndex, + onChanged: onSelected, + padding: EdgeInsets.symmetric( + horizontal: floating ? 6 : 5, + vertical: 5, + ), + itemBuilder: (context, index, selected) => ConstrainedBox( + constraints: const BoxConstraints(minHeight: 54), + child: Padding( + padding: EdgeInsets.symmetric(vertical: floating ? 0 : 6), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ - Positioned( - left: 0, - right: 0, - bottom: 8, - height: 64, - child: _MornyeGlassSurface( - blurEnabled: blurEnabled, - strongTint: true, - tintOpacity: MornyeTheme.navigationOpacity(context), - child: const SizedBox.expand(), + IconTheme( + data: IconThemeData( + size: 25, + color: selected ? scheme.primary : scheme.onSurface, + ), + child: Opacity( + opacity: hiddenIconIndices.contains(index) ? 0 : 1, + child: destinations[index].icon is MornyeNavigationIcon + ? destinations[index].icon + : MornyeSelectionForeground( + child: destinations[index].icon, + ), ), ), - FadeTransition( - opacity: contentOpacity, - child: NativeGlassMetrics( - child: LiquidGlassTabBar.withImpeller( - width: constraints.maxWidth, - height: 64, - margin: const EdgeInsets.only(bottom: 8), - selectedIndex: selectedIndex < 0 ? 0 : selectedIndex, - onChanged: onSelected, - style: LiquidGlassTabBar.defaultStyle.copyWith( - // The frosted base owns the subtle outline. Disable the - // package's default specular rim around the whole capsule. - shape: - const LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: 32, - borderWidth: 0, - lightIntensity: 0, - ), - // The sibling surface already supplies tint and blur. Keep - // the lens clear so the moving pill can refract the icons. - appearance: const LiquidGlassAppearance(), - refraction: const LiquidGlassRefraction( - distortion: 0, - chromaticAberration: 0, - ), - ), - itemStyle: LiquidGlassTabItemStyle( - selectedColor: scheme.primary, - unselectedColor: scheme.onSurface, - iconSize: 25, - labelFontSize: 11, - ), - pillStyle: LiquidGlassTabPillStyle( - mode: LiquidGlassPillMode.impellerOnly, - show: selectedIndex >= 0, - color: selectionFill, - animated: true, - // Keep the moving refractive pill, without stacking the - // package's second magnifier lens beneath it. - magnifierPill: const LiquidGlassTabMagnifierPillStyle( - enabled: false, - ), - ), - items: [ - for (final (index, destination) in destinations.indexed) - LiquidGlassTabBarItem( - label: destination.label, - iconBuilder: (context, icon) => IconTheme( - data: IconThemeData( - size: icon.size, - color: icon.selected && selectedIndex >= 0 - ? scheme.primary - : inactiveIconColor, - ), - child: Opacity( - opacity: hiddenIconIndices.contains(index) - ? 0 - : 1, - child: destination.icon, - ), - ), - labelBuilder: (context, label) => Text( - destination.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - fontSize: label.textStyle.fontSize, - fontWeight: FontWeight.w600, - color: selectedIndex < 0 - ? scheme.onSurface - : label.textStyle.color, - ), - ), - ), - ], + const SizedBox(height: 2), + MornyeSelectionForeground( + child: Text( + destinations[index].label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontSize: floating ? 11 : null, + fontWeight: FontWeight.w600, + color: selected ? scheme.primary : scheme.onSurface, ), ), ), - // With no selected tab the package still needs an internal - // index; handle taps here so - // returning to that index (Home) is not swallowed as a re-tap. - if (selectedIndex < 0) - Positioned( - left: 6, - right: 6, - bottom: 8, - height: 64, - child: Row( - children: [ - for ( - var index = 0; - index < destinations.length; - index++ - ) - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onSelected(index), - child: const SizedBox.expand(), - ), - ), - ], - ), - ), ], ), ), ), - ); - } - return MornyeGlass.navigation( - blurEnabled: blurEnabled, - strongTint: true, - tintOpacity: MornyeTheme.navigationOpacity(context), - child: FadeTransition( - opacity: contentOpacity, - child: Padding( - padding: const EdgeInsets.all(5), - child: Row( - children: [ - for (var index = 0; index < destinations.length; index++) - Expanded( - child: Semantics( - selected: index == selectedIndex, - button: true, - label: destinations[index].label, - excludeSemantics: true, - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(28), - onTap: () => onSelected(index), - child: AnimatedContainer( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 220), - constraints: const BoxConstraints(minHeight: 54), - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - decoration: BoxDecoration( - color: index == selectedIndex - ? selectionFill - : Colors.transparent, - borderRadius: BorderRadius.circular(28), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconTheme( - data: IconThemeData( - size: 25, - color: index == selectedIndex - ? scheme.primary - : inactiveIconColor, - ), - // Keep tab selection quiet, as in Mornye. Badges - // stay live without the Material bounce/spin. - child: Opacity( - opacity: hiddenIconIndices.contains(index) - ? 0 - : 1, - child: destinations[index].icon, - ), - ), - const SizedBox(height: 2), - Text( - destinations[index].label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - fontWeight: FontWeight.w600, - color: index == selectedIndex - ? scheme.primary - : scheme.onSurface, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ], + ), + ); + return Padding( + padding: EdgeInsets.symmetric(vertical: floating ? 8 : 0), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: MornyeGlass.navigation( + blurEnabled: blurEnabled, + strongTint: true, + tintOpacity: MornyeTheme.navigationOpacity(context), + child: const SizedBox.expand(), + ), ), - ), + // Keep the held lens above the bar's clip so it can swell outward. + content, + ], ), ); } diff --git a/lib/widgets/mornye_context_menu.dart b/lib/widgets/mornye_context_menu.dart index f8a3b8af..af2456a2 100644 --- a/lib/widgets/mornye_context_menu.dart +++ b/lib/widgets/mornye_context_menu.dart @@ -1,5 +1,4 @@ import 'dart:math' as math; -import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -30,7 +29,7 @@ Future showMornyeContextMenu({ barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, transitionDuration: MediaQuery.disableAnimationsOf(context) ? Duration.zero - : const Duration(milliseconds: 160), + : const Duration(milliseconds: 220), // Opacity layers isolate a BackdropFilter from the underlying artwork. transitionBuilder: (_, _, _, child) => child, pageBuilder: (context, animation, _) { @@ -50,8 +49,15 @@ Future showMornyeContextMenu({ ), ), child: ScaleTransition( - alignment: preferAbove ? Alignment.bottomRight : Alignment.center, - scale: animation.drive(Tween(begin: 0.96, end: 1)), + alignment: preferAbove + ? Alignment.bottomRight + : Alignment.topRight, + scale: animation.drive( + Tween( + begin: 0.94, + end: 1, + ).chain(CurveTween(curve: Curves.easeOutCubic)), + ), child: themes.wrap(Builder(builder: builder)), ), ), @@ -154,67 +160,6 @@ 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 [], @@ -238,7 +183,10 @@ class MornyeContextMenu extends StatelessWidget { final shortcuts = quickActions.isEmpty ? null : Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: dense ? 4 : 8, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -248,14 +196,20 @@ class MornyeContextMenu extends StatelessWidget { ), ); final actions = Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: EdgeInsets.symmetric(vertical: dense ? 4 : 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < visibleGroups.length; i++) ...[ if (i > 0 || quickActions.isNotEmpty) Padding( - padding: EdgeInsets.symmetric(vertical: i == 0 ? 0 : 8), + padding: EdgeInsets.symmetric( + vertical: i == 0 + ? 0 + : dense + ? 4 + : 8, + ), child: Divider( height: 0.5, thickness: 0.5, @@ -274,14 +228,18 @@ class MornyeContextMenu extends StatelessWidget { return Theme( data: theme, child: MornyeGlassPanel.overlay( - 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: dark ? 0.025 : 0.72, + radius: dense ? 24 : 28, + highlightBorder: true, + // Player menus carry the cover's hue. The overlay fill still protects + // text contrast over bright artwork at the clear end of the slider. + tintColor: theme.extension()?.chromeSurface != null + ? null + : dark + ? const Color(0xff414141) + : Colors.white, + tintOpacity: dark + ? 0.80 - 0.20 * (theme.extension()?.glassClarity ?? 0) + : 0.80, child: LayoutBuilder( builder: (context, constraints) { // Let the entire menu scroll when pinning the shortcuts would @@ -335,19 +293,27 @@ class MornyeContextMenu extends StatelessWidget { return Semantics( selected: action.selected, child: CupertinoButton( + minimumSize: const Size(48, 48), padding: compact - ? const EdgeInsets.symmetric(horizontal: 4, vertical: 10) - : EdgeInsets.symmetric(horizontal: 20, vertical: dense ? 10 : 13), + ? EdgeInsets.symmetric(horizontal: 4, vertical: dense ? 6 : 10) + : EdgeInsets.symmetric( + horizontal: dense ? 16 : 20, + vertical: dense ? 8 : 13, + ), onPressed: action.onPressed, child: compact ? Column( mainAxisSize: MainAxisSize.min, - children: [icon, const SizedBox(height: 7), label], + children: [ + icon, + SizedBox(height: dense ? 4 : 7), + label, + ], ) : Row( children: [ icon, - const SizedBox(width: 16), + SizedBox(width: dense ? 12 : 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart index 024589c3..25f27d82 100644 --- a/lib/widgets/mornye_landscape_player.dart +++ b/lib/widgets/mornye_landscape_player.dart @@ -1,11 +1,9 @@ import 'dart:async'; -import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/widgets/audio_output_button.dart'; /// Landscape opens on the player. Lyrics can hide their bottom actions until @@ -304,7 +302,7 @@ class _MornyeLandscapePlayerState extends State { } /// Let lyrics pass behind the footer without leaving readable text underneath -/// its controls. The mask and bounded blur recede when those controls hide. +/// its controls. A single opacity mask recedes when those controls hide. class _LandscapeLyricsViewport extends ConsumerWidget { const _LandscapeLyricsViewport({ required this.controlsVisible, @@ -316,10 +314,6 @@ class _LandscapeLyricsViewport extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Three stacked backdrop passes re-filter the moving lyrics every frame. - final blur = - !MediaQuery.highContrastOf(context) && - ref.watch(mornyeLiquidGlassProvider); return ClipRect( key: const ValueKey('landscape-lyrics-viewport'), child: TweenAnimationBuilder( @@ -344,38 +338,13 @@ class _LandscapeLyricsViewport extends ConsumerWidget { ], stops: [ 0, - (1 - 96 / bounds.height).clamp(0, 1), + (1 - 112 / bounds.height).clamp(0, 1), (1 - 48 / bounds.height).clamp(0, 1), 1, ], ).createShader(bounds), child: child, ), - if (blur && progress > 0) - // Increasing, clipped passes soften the edge without placing a - // mask/opacity layer between the filters and their backdrop. - for (final (height, sigma) in const [ - (96.0, 2.0), - (80.0, 4.0), - (64.0, 8.0), - ]) - Positioned( - left: 0, - right: 0, - bottom: 0, - height: height, - child: IgnorePointer( - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: sigma * progress, - sigmaY: sigma * progress, - ), - child: const SizedBox.expand(), - ), - ), - ), - ), ], ), ), diff --git a/lib/widgets/mornye_liquid_backdrop.dart b/lib/widgets/mornye_liquid_backdrop.dart new file mode 100644 index 00000000..4afcccb5 --- /dev/null +++ b/lib/widgets/mornye_liquid_backdrop.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; + +/// A bounded package lens without a captured-screen view or a touch ticker. +class MornyeLiquidBackdrop extends StatelessWidget { + const MornyeLiquidBackdrop({ + super.key, + required this.borderRadius, + required this.clarity, + required this.blurSigma, + required this.child, + this.interaction = false, + this.progress = 1, + }); + + final BorderRadius borderRadius; + final double clarity; + final double blurSigma; + final Widget child; + final bool interaction; + final double progress; + + @override + Widget build(BuildContext context) { + if (interaction && progress <= 0) return child; + final effect = interaction ? progress : 1.0; + return LiquidGlassLens( + style: LiquidGlassStyle( + shape: LiquidGlassShape.roundedRectangle( + cornerRadius: borderRadius.topLeft.x, + borderWidth: 0.75, + lightDirection: 0, + lightIntensity: 0.25 * effect, + lightColor: Colors.white, + borderType: const OpticalBorder( + ambientIntensity: 0.05, + lightSpread: 0.2, + ), + ), + appearance: LiquidGlassAppearance( + blur: LiquidGlassBlur(sigmaX: blurSigma, sigmaY: blurSigma), + ), + // Translate Figma percentages into this package's optical controls. + // Keep color separation subtle instead of copying its contrast demo. + refraction: LiquidGlassRefraction( + refractionType: OpticalRefraction( + refraction: 1.7, + depth: 0.30 * effect, + refractionWidth: 20, + ), + chromaticAberration: 0.002 * clarity * effect, + ), + ), + child: child, + ); + } +} diff --git a/lib/widgets/mornye_player_actions_sheet.dart b/lib/widgets/mornye_player_actions_sheet.dart index ab75fd0c..04de251b 100644 --- a/lib/widgets/mornye_player_actions_sheet.dart +++ b/lib/widgets/mornye_player_actions_sheet.dart @@ -17,56 +17,29 @@ class MornyePlayerNavigationMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = MornyeTheme.fromContext(context, brightness: Brightness.dark); - final art = mediaItem.artUri; - final source = art?.scheme == 'file' ? art!.toFilePath() : art?.toString(); - return Theme( - data: theme.copyWith( - colorScheme: theme.colorScheme.copyWith(primary: Colors.grey), - ), - child: CoverPaletteBuilder( - imageSource: source, - builder: (context, _) { - final dominant = HSLColor.fromColor( - source == null - ? Colors.grey - : CoverPalette.sourceColor(source, Brightness.dark) ?? - Colors.grey, - ); - final surface = dominant - .withSaturation(dominant.saturation.clamp(0.0, 0.28)) - .withLightness(0.28) - .toColor(); - return Theme( - data: theme.copyWith( - colorScheme: theme.colorScheme.copyWith( - surfaceContainerHigh: surface, + return _MornyePlayerMenuSurface( + mediaItem: mediaItem, + child: MornyeContextMenu( + inheritSurface: true, + dense: true, + groups: [ + [ + if ((mediaItem.artist ?? '').trim().isNotEmpty) + MornyeMenuAction( + icon: CupertinoIcons.mic, + label: context.l10n.mornyeGoToArtist, + subtitle: mediaItem.artist, + onPressed: () => Navigator.of(context).pop('artist'), ), - ), - child: MornyeContextMenu( - inheritSurface: true, - dense: true, - groups: [ - [ - if ((mediaItem.artist ?? '').trim().isNotEmpty) - MornyeMenuAction( - icon: CupertinoIcons.mic, - label: context.l10n.mornyeGoToArtist, - subtitle: mediaItem.artist, - onPressed: () => Navigator.of(context).pop('artist'), - ), - if ((mediaItem.album ?? '').trim().isNotEmpty) - MornyeMenuAction( - icon: CupertinoIcons.square_stack, - label: context.l10n.homeGoToAlbum, - subtitle: mediaItem.album, - onPressed: () => Navigator.of(context).pop('album'), - ), - ], - ], - ), - ); - }, + if ((mediaItem.album ?? '').trim().isNotEmpty) + MornyeMenuAction( + icon: CupertinoIcons.square_stack, + label: context.l10n.homeGoToAlbum, + subtitle: mediaItem.album, + onPressed: () => Navigator.of(context).pop('album'), + ), + ], + ], ), ); } @@ -91,71 +64,76 @@ class MornyePlayerActionsSheet extends ConsumerWidget { (state) => track != null && state.isLoved(track), ), ); - return MornyeContextMenu( - quickActions: [ - _action( - context, - 'favorite', - loved ? context.l10n.mornyeFavorited : context.l10n.mornyeFavorite, - CupertinoIcons.star_fill, - selected: loved, - ), - _action( - context, - 'share', - context.l10n.trackMetadataShare, - CupertinoIcons.share_solid, - ), - ], - groups: [ - [ + return _MornyePlayerMenuSurface( + mediaItem: mediaItem, + child: MornyeContextMenu( + inheritSurface: true, + dense: true, + quickActions: [ _action( context, - 'playlist', - context.l10n.collectionAddToPlaylist, - CupertinoIcons.text_badge_plus, + 'favorite', + loved ? context.l10n.mornyeFavorited : context.l10n.mornyeFavorite, + CupertinoIcons.star_fill, + selected: loved, + ), + _action( + context, + 'share', + context.l10n.trackMetadataShare, + CupertinoIcons.share_solid, ), ], - [ - if ((mediaItem.album ?? '').trim().isNotEmpty) + groups: [ + [ _action( context, - 'album', - context.l10n.homeGoToAlbum, - CupertinoIcons.square_stack, - subtitle: mediaItem.album, + 'playlist', + context.l10n.collectionAddToPlaylist, + CupertinoIcons.text_badge_plus, ), - if ((mediaItem.artist ?? '').trim().isNotEmpty) + ], + [ + if ((mediaItem.album ?? '').trim().isNotEmpty) + _action( + context, + 'album', + context.l10n.homeGoToAlbum, + CupertinoIcons.square_stack, + subtitle: mediaItem.album, + ), + if ((mediaItem.artist ?? '').trim().isNotEmpty) + _action( + context, + 'artist', + context.l10n.mornyeGoToArtist, + CupertinoIcons.mic, + subtitle: mediaItem.artist, + ), _action( context, - 'artist', - context.l10n.mornyeGoToArtist, - CupertinoIcons.mic, - subtitle: mediaItem.artist, + 'details', + context.l10n.nowPlayingDetails, + CupertinoIcons.info, ), - _action( - context, - 'details', - context.l10n.nowPlayingDetails, - CupertinoIcons.info, - ), + ], + [ + _action( + context, + 'sleepTimer', + context.l10n.nowPlayingSleepTimer, + CupertinoIcons.moon_zzz, + subtitle: sleepTimerSubtitle, + ), + _action( + context, + 'external', + context.l10n.nowPlayingOpenInExternalPlayer, + CupertinoIcons.arrow_up_right_square, + ), + ], ], - [ - _action( - context, - 'sleepTimer', - context.l10n.nowPlayingSleepTimer, - CupertinoIcons.moon_zzz, - subtitle: sleepTimerSubtitle, - ), - _action( - context, - 'external', - context.l10n.nowPlayingOpenInExternalPlayer, - CupertinoIcons.arrow_up_right_square, - ), - ], - ], + ), ); } @@ -174,3 +152,43 @@ class MornyePlayerActionsSheet extends ConsumerWidget { onPressed: () => Navigator.of(context).pop(value), ); } + +/// Reuse the cached cover palette; no per-frame background capture is needed. +class _MornyePlayerMenuSurface extends StatelessWidget { + const _MornyePlayerMenuSurface({ + required this.mediaItem, + required this.child, + }); + + final MediaItem mediaItem; + final Widget child; + + @override + Widget build(BuildContext context) { + final art = mediaItem.artUri; + final source = art?.scheme == 'file' ? art!.toFilePath() : art?.toString(); + return CoverPaletteBuilder( + imageSource: source, + builder: (context, _) { + final dominant = HSLColor.fromColor( + source == null + ? Colors.grey + : CoverPalette.sourceColor(source, Brightness.dark) ?? + Colors.grey, + ); + final surface = dominant + .withSaturation(dominant.saturation.clamp(0.0, 0.34)) + .withLightness(0.28) + .toColor(); + return Theme( + data: MornyeTheme.fromContext( + context, + brightness: Brightness.dark, + chromeSurface: surface, + ), + child: child, + ); + }, + ); + } +} diff --git a/lib/widgets/mornye_selection_pill.dart b/lib/widgets/mornye_selection_pill.dart new file mode 100644 index 00000000..eb83d06f --- /dev/null +++ b/lib/widgets/mornye_selection_pill.dart @@ -0,0 +1,438 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/widgets/mornye_liquid_backdrop.dart'; + +/// Selection with a small, transient refractive lens while held. Labels are +/// rendered once above the lens; it samples the bar's backdrop on the GPU. +class MornyeSelectionPill extends StatefulWidget { + const MornyeSelectionPill({ + super.key, + required this.labels, + required this.selectedIndex, + required this.onChanged, + required this.itemBuilder, + this.padding = const EdgeInsets.all(4), + this.liquidInteraction = false, + this.selectionColor, + this.maskItemForeground = true, + }); + + final List labels; + final int selectedIndex; + final ValueChanged onChanged; + final Widget Function(BuildContext context, int index, bool selected) + itemBuilder; + final EdgeInsets padding; + final bool liquidInteraction; + + /// Recolors the foreground under the moving pill instead of switching an + /// entire item at a tab boundary. Builders receive `selected: false` in this + /// mode so the unmasked foreground retains its normal color. + final Color? selectionColor; + final bool maskItemForeground; + + @override + State createState() => _MornyeSelectionPillState(); +} + +class _MornyeSelectionPillState extends State { + int? _dragIndex; + bool _dragCancelled = false; + bool _pressed = false; + int? _pressedIndex; + double? _dragAlignment; + int? _activePointer; + final _foregroundKey = GlobalKey(); + + @override + void didUpdateWidget(MornyeSelectionPill oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.labels.length != widget.labels.length || + oldWidget.selectedIndex != widget.selectedIndex) { + _dragIndex = null; + _dragAlignment = null; + _pressedIndex = null; + } + } + + void _preview(double x, double width) { + if (width <= 0 || widget.labels.isEmpty) return; + final visual = (x / width * widget.labels.length).floor().clamp( + 0, + widget.labels.length - 1, + ); + final logical = Directionality.of(context) == TextDirection.rtl + ? widget.labels.length - 1 - visual + : visual; + final halfItem = width / widget.labels.length / 2; + final position = width <= halfItem * 2 + ? 0.0 + : ((x.clamp(halfItem, width - halfItem) - halfItem) / + (width - 2 * halfItem)) * + 2 - + 1; + setState(() { + _dragIndex = logical; + _dragAlignment = Directionality.of(context) == TextDirection.rtl + ? -position + : position; + }); + } + + @override + Widget build(BuildContext context) { + final count = widget.labels.length; + if (count == 0) return const SizedBox.shrink(); + final selected = _dragIndex ?? _pressedIndex ?? widget.selectedIndex; + final scheme = Theme.of(context).colorScheme; + final lensEnabled = + widget.liquidInteraction && + ui.ImageFilter.isShaderFilterSupported && + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context); + return Listener( + // An accepted drag can end with a PointerCancelEvent. Do not navigate + // when the OS interrupts the gesture (e.g. opening system controls). + onPointerDown: (event) { + if (_activePointer != null) return; + _activePointer = event.pointer; + _dragCancelled = false; + if (!lensEnabled || _pressed) return; + final box = context.findRenderObject()! as RenderBox; + final width = box.size.width - widget.padding.horizontal; + if (width <= 0) return; + final visual = + ((event.localPosition.dx - widget.padding.left) / width * count) + .floor() + .clamp(0, count - 1); + setState(() { + _pressed = true; + _pressedIndex = Directionality.of(context) == TextDirection.rtl + ? count - visual - 1 + : visual; + }); + }, + onPointerUp: (event) { + if (event.pointer != _activePointer) return; + _activePointer = null; + setState(() { + _pressed = false; + _pressedIndex = null; + _dragAlignment = null; + }); + }, + onPointerCancel: (event) { + if (event.pointer != _activePointer) return; + _activePointer = null; + setState(() { + _dragCancelled = true; + _pressed = false; + _pressedIndex = null; + _dragAlignment = null; + }); + }, + child: Padding( + padding: widget.padding, + child: LayoutBuilder( + builder: (context, constraints) => GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragStart: (event) => + _preview(event.localPosition.dx, constraints.maxWidth), + onHorizontalDragUpdate: (event) => + _preview(event.localPosition.dx, constraints.maxWidth), + onHorizontalDragEnd: (_) { + final index = _dragIndex; + setState(() { + _dragIndex = null; + _dragAlignment = null; + }); + if (index != null && !_dragCancelled) widget.onChanged(index); + }, + onHorizontalDragCancel: () => setState(() { + _dragIndex = null; + _dragAlignment = null; + }), + child: Stack( + fit: StackFit.passthrough, + clipBehavior: Clip.none, + children: [ + if (selected >= 0 && selected < count) + Positioned.fill( + child: IgnorePointer( + child: AnimatedAlign( + duration: + _dragAlignment != null || + MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: AlignmentDirectional( + _dragAlignment ?? + (count == 1 + ? 0 + : -1 + 2 * selected / (count - 1)), + 0, + ), + child: FractionallySizedBox( + widthFactor: 1 / count, + heightFactor: 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: scheme.onSurface.withValues( + alpha: lensEnabled && _pressed + ? 0.025 + : scheme.brightness == Brightness.dark + ? 0.12 + : 0.08, + ), + borderRadius: BorderRadius.circular(32), + border: Border.all( + color: scheme.onSurface.withValues(alpha: 0.05), + width: 0.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 1, + offset: const Offset(0, 0.5), + ), + ], + ), + ), + ), + ), + ), + ), + if (lensEnabled && selected >= 0 && selected < count) + _interactionLens(selected, count), + _foreground( + selected, + count, + Row( + key: _foregroundKey, + children: [ + for (var index = 0; index < count; index++) + Expanded( + child: Semantics( + button: true, + selected: index == widget.selectedIndex, + label: widget.labels[index], + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(32), + // The moving pill supplies press feedback. An + // ink highlight would leave a colored patch on + // the tab where the drag started. + splashFactory: widget.selectionColor == null + ? null + : NoSplash.splashFactory, + highlightColor: widget.selectionColor == null + ? null + : Colors.transparent, + onTap: () => widget.onChanged(index), + child: ExcludeSemantics( + child: AnimatedScale( + scale: + lensEnabled && _pressed && selected >= 0 + ? 1 + + 0.18 * + _coverage( + index, + selected, + count, + ) + : 1, + duration: + _dragAlignment != null || + MediaQuery.disableAnimationsOf( + context, + ) + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: _item(context, index, selected), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _item(BuildContext context, int index, int selected) { + final child = widget.itemBuilder( + context, + index, + widget.selectionColor == null && index == selected, + ); + return widget.maskItemForeground + ? MornyeSelectionForeground(child: child) + : child; + } + + double _coverage(int index, int selected, int count) { + final position = _dragAlignment == null + ? selected.toDouble() + : (_dragAlignment! + 1) * (count - 1) / 2; + return (1 - (index - position).abs()).clamp(0.0, 1.0); + } + + Widget _foreground(int selected, int count, Widget child) { + final color = widget.selectionColor; + if (color == null) return child; + return TweenAnimationBuilder( + tween: Tween( + end: + _dragAlignment ?? + (count == 1 ? 0 : -1 + 2 * selected / (count - 1)), + ), + duration: + _dragAlignment != null || MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + builder: (context, alignment, child) => _SelectionForegroundScope( + rowKey: _foregroundKey, + alignment: Directionality.of(context) == TextDirection.rtl + ? -alignment + : alignment, + count: count, + color: selected >= 0 && selected < count + ? color + : color.withValues(alpha: 0), + child: child!, + ), + child: child, + ); + } + + Widget _interactionLens(int selected, int count) => Positioned.fill( + child: IgnorePointer( + child: AnimatedAlign( + duration: _dragAlignment != null + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: AlignmentDirectional( + _dragAlignment ?? (count == 1 ? 0 : -1 + 2 * selected / (count - 1)), + 0, + ), + child: TweenAnimationBuilder( + tween: Tween(end: _pressed ? 1 : 0), + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + builder: (context, progress, child) => FractionallySizedBox( + widthFactor: 1 / count, + heightFactor: 1, + child: Transform.scale( + scaleX: 1 + 0.28 * progress, + scaleY: 1 + 0.24 * progress, + child: ClipRRect( + borderRadius: BorderRadius.circular(32), + child: LiquidGlassBatch.exclude( + child: MornyeLiquidBackdrop( + borderRadius: BorderRadius.circular(32), + clarity: 1, + interaction: true, + progress: progress, + blurSigma: 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32), + color: Colors.white.withValues( + alpha: + progress * + (Theme.of(context).brightness == Brightness.light + ? 0.24 + : 0.08), + ), + border: Border.all( + color: Colors.white.withValues(alpha: 0.6 * progress), + width: 0.75, + ), + ), + child: const SizedBox.expand(), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); +} + +/// Paint only icons and labels with the sliding selection color. Badge fills +/// and counters can remain outside this widget and retain their own contrast. +class MornyeSelectionForeground extends StatelessWidget { + const MornyeSelectionForeground({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_SelectionForegroundScope>(); + if (scope == null) return child; + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) { + final row = + scope.rowKey.currentContext!.findRenderObject()! as RenderBox; + final box = context.findRenderObject()! as RenderBox; + final width = row.size.width / scope.count; + final left = (scope.alignment + 1) * (row.size.width - width) / 2; + // Convert from the bar so scaled icons and nested badges still share + // exactly the same moving selection boundary. + final start = box.globalToLocal(row.localToGlobal(Offset(left, 0))); + final end = box.globalToLocal( + row.localToGlobal(Offset(left + width, 0)), + ); + return ui.Gradient.linear( + Offset(start.dx, 0), + Offset(end.dx, 0), + [scope.color, scope.color], + null, + TileMode.decal, + ); + }, + child: child, + ); + } +} + +class _SelectionForegroundScope extends InheritedWidget { + const _SelectionForegroundScope({ + required this.rowKey, + required this.alignment, + required this.count, + required this.color, + required super.child, + }); + + final GlobalKey rowKey; + final double alignment; + final int count; + final Color color; + + @override + bool updateShouldNotify(_SelectionForegroundScope oldWidget) => + alignment != oldWidget.alignment || + count != oldWidget.count || + color != oldWidget.color || + rowKey != oldWidget.rowKey; +} diff --git a/lib/widgets/native_glass_metrics.dart b/lib/widgets/native_glass_metrics.dart deleted file mode 100644 index 95ed3551..00000000 --- a/lib/widgets/native_glass_metrics.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// Gives glass shaders the actual window's coordinate system. -/// -/// AdaptiveUiScaler changes MediaQuery's size and density for layout and image -/// decoding. The glass renderer already includes that ancestor transform when -/// positioning its shader, so using the scaled density again displaces the -/// effect from its clip (especially near the bottom of a tablet screen). -class NativeGlassMetrics extends StatelessWidget { - const NativeGlassMetrics({super.key, required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - final view = View.of(context); - return MediaQuery( - data: MediaQuery.of(context).copyWith( - size: view.physicalSize / view.devicePixelRatio, - devicePixelRatio: view.devicePixelRatio, - ), - child: child, - ); - } -} diff --git a/pubspec.lock b/pubspec.lock index 8bac56db..c94cb6c4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -830,10 +830,10 @@ packages: dependency: "direct main" description: name: liquid_glass_easy - sha256: "0900af82971da23f1f2a28ea038c13d28fab6bd1e57c2492c005a8dae3ca8f90" + sha256: "48f2147e8b662402ccef9304cf8c216082b7913ad985ea2ac3d4f402b16ab417" url: "https://pub.dev" source: hosted - version: "4.3.2" + version: "4.3.4" listen: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index aba76214..b460e870 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: # UI Components cached_network_image: ^4.0.2 flutter_cache_manager: ^3.4.5 - liquid_glass_easy: ^4.3.2 + liquid_glass_easy: 4.3.4 cupertino_icons: ^1.0.9 volume_controller: ^3.7.1 diff --git a/test/app_snack_bar_test.dart b/test/app_snack_bar_test.dart index 6ddfe658..4b564fb7 100644 --- a/test/app_snack_bar_test.dart +++ b/test/app_snack_bar_test.dart @@ -86,9 +86,10 @@ void main() { image.dispose(); return colors; }); - // Retain a visible backdrop hue even beneath the glass shadow. - expect(colors![0].b - colors[0].r, greaterThan(0.20)); - expect(colors[1].r - colors[1].b, greaterThan(0.20)); + // The painted dark tint attenuates chroma along with luminance. Both + // backdrop hues must remain visibly distinct throughout the transition. + expect(colors![0].b - colors[0].r, greaterThan(0.16)); + expect(colors[1].r - colors[1].b, greaterThan(0.16)); final foreground = MornyeTheme.build(brightness).colorScheme.onSurface; for (final background in colors) { final luminances = [ diff --git a/test/appearance_settings_test.dart b/test/appearance_settings_test.dart index 4fb422e5..c250f9b9 100644 --- a/test/appearance_settings_test.dart +++ b/test/appearance_settings_test.dart @@ -108,6 +108,10 @@ void main() { expect(prefs.getDouble(kMornyeGlassClarityKey), 1); final context = tester.element(slider); expect(MornyeTheme.glassClarityOf(context), 1); + expect( + find.byKey(const ValueKey('mornye-glass-clear-performance')), + findsOneWidget, + ); expect( MornyeTheme.fromContext( context, @@ -124,6 +128,10 @@ void main() { await tester.pumpAndSettle(); expect(tester.widget(slider).value, 0); expect(prefs.getDouble(kMornyeGlassClarityKey), 0); + expect( + find.byKey(const ValueKey('mornye-glass-clear-performance')), + findsNothing, + ); expect(tester.takeException(), isNull); }); @@ -143,6 +151,10 @@ void main() { await tester.ensureVisible(slider); await tester.pumpAndSettle(); expect(tester.widget(slider).onChanged, isNull); + expect( + find.byKey(const ValueKey('mornye-glass-clear-performance')), + findsNothing, + ); expect( tester.widget(hint).data, 'Glass is opaque on this device. Turn on ' diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index bd02297f..69d50b64 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/widgets/mornye_selection_pill.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; @@ -141,7 +141,7 @@ void main() { 'player and navbar share a backdrop while moving capsules remain isolated', (tester) async { await pumpShell(tester, blur: true, liquidGlass: false); - void expectSharedBackdrop() { + void expectSharedBackdrop({int movingSurfaces = 2}) { final filters = find.descendant( of: find.byType(MornyeBottomBar), matching: find.byType(BackdropFilter), @@ -160,11 +160,12 @@ void main() { // Side capsules overlap the folding tabs and must not join their group. expect( renderers.where((filter) => filter.backdropKey == null), - hasLength(2), + hasLength(movingSurfaces), ); } - expectSharedBackdrop(); + // Invisible side capsules must not paint additional backdrop passes. + expectSharedBackdrop(movingSurfaces: 0); chrome.value = true; await tester.pump(); await tester.pump(const Duration(milliseconds: 190)); @@ -537,7 +538,6 @@ void main() { chromeSurface: backdrop == albumBlue ? albumBlue : null, ); final artist = find.text('Artist'); - // The glass renderer also builds a copy for the refractive pill. final libraryIcon = find .descendant( of: find.byType(MornyeTabBar), @@ -812,16 +812,16 @@ void main() { 'glass bar stays mounted and pauses its tickers while collapsed', (tester) async { await pumpShell(tester, blur: true); - final glass = tester.element(find.byType(LiquidGlassTabBar)); + final glass = tester.element(find.byType(MornyeSelectionPill)); final player = tester.state(find.byType(MiniPlayer)); expect(TickerMode.valuesOf(glass).enabled, isTrue); chrome.value = true; await tester.pumpAndSettle(); - expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass)); + expect(tester.element(find.byType(MornyeSelectionPill)), same(glass)); expect(TickerMode.valuesOf(glass).enabled, isFalse); chrome.expand(); await tester.pumpAndSettle(); - expect(tester.element(find.byType(LiquidGlassTabBar)), same(glass)); + expect(tester.element(find.byType(MornyeSelectionPill)), same(glass)); expect(tester.state(find.byType(MiniPlayer)), same(player)); expect(TickerMode.valuesOf(glass).enabled, isTrue); expect(tester.takeException(), isNull); @@ -850,9 +850,11 @@ class _StripedBackdrop extends CustomPainter { @override void paint(Canvas canvas, Size size) { final paint = Paint(); - for (var x = 0.0; x < size.width; x += 12) { - paint.color = (x / 12).round().isEven ? Colors.white : Colors.black; - canvas.drawRect(Rect.fromLTWH(x, 0, 12, size.height), paint); + // Clear glass uses a small blur; fine stripes still reveal a lost backdrop + // during transforms without requiring the old heavily frosted appearance. + for (var x = 0.0; x < size.width; x += 2) { + paint.color = (x / 2).round().isEven ? Colors.white : Colors.black; + canvas.drawRect(Rect.fromLTWH(x, 0, 2, size.height), paint); } } diff --git a/test/mornye_context_menu_test.dart b/test/mornye_context_menu_test.dart index e856b79f..248f0300 100644 --- a/test/mornye_context_menu_test.dart +++ b/test/mornye_context_menu_test.dart @@ -28,6 +28,8 @@ void main() { Color? backgroundColor, GlobalKey? capture, double glassClarity = kDefaultMornyeGlassClarity, + bool dense = false, + Color? chromeSurface, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -39,7 +41,11 @@ void main() { backdropBlurEnabledProvider.overrideWithValue(false), ], child: MaterialApp( - theme: MornyeTheme.build(brightness, glassClarity: glassClarity), + theme: MornyeTheme.build( + brightness, + glassClarity: glassClarity, + chromeSurface: chromeSurface, + ), builder: (context, child) => RepaintBoundary( key: capture, child: MediaQuery( @@ -71,6 +77,8 @@ void main() { Navigator.of(menuContext).pop(label), ); return MornyeContextMenu( + dense: dense, + inheritSurface: chromeSurface != null, quickActions: [ action( 'Download', @@ -251,6 +259,74 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('compact menus save space while keeping 48 dp touch targets', ( + tester, + ) async { + await openMenu( + tester, + anchor: const Rect.fromLTWH(330, 144, 44, 44), + onResult: (_) {}, + ); + final originalHeight = tester + .getSize(find.byType(MornyeContextMenu)) + .height; + await tester.pumpWidget(const SizedBox()); + await openMenu( + tester, + anchor: const Rect.fromLTWH(330, 144, 44, 44), + dense: true, + onResult: (_) {}, + ); + expect( + tester.getSize(find.byType(MornyeContextMenu)).height, + lessThan(originalHeight - 30), + ); + for (final button in find.byType(CupertinoButton).evaluate()) { + final bounds = tester.getSize( + find.byElementPredicate((e) => e == button), + ); + expect(bounds.width, greaterThanOrEqualTo(48)); + expect(bounds.height, greaterThanOrEqualTo(48)); + } + expect(find.text('Share').hitTestable(), findsOneWidget); + expect(find.text('Remove').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('cover-tinted menus preserve their hue and clear text contrast', ( + tester, + ) async { + final colors = []; + for (final cover in [const Color(0xff5f3a2d), const Color(0xff2d3a5f)]) { + final capture = GlobalKey(); + await openMenu( + tester, + anchor: const Rect.fromLTWH(330, 144, 44, 44), + brightness: Brightness.dark, + glassClarity: 1, + backgroundColor: Colors.white, + chromeSurface: cover, + capture: capture, + dense: true, + onResult: (_) {}, + ); + final bounds = tester.getRect(find.byType(MornyeContextMenu)); + final pixels = await samplePixels(tester, capture, [ + Offset(bounds.left + 8, bounds.center.dy), + ]); + final color = pixels.single; + colors.add(color); + expect( + 1.05 / (color.computeLuminance() + 0.05), + greaterThanOrEqualTo(4.5), + ); + await tester.pumpWidget(const SizedBox()); + } + expect(colors.first.r, greaterThan(colors.first.b + 0.03)); + expect(colors.last.b, greaterThan(colors.last.r + 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/mornye_glass_interaction_test.dart b/test/mornye_glass_interaction_test.dart new file mode 100644 index 00000000..561e34d3 --- /dev/null +++ b/test/mornye_glass_interaction_test.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_switch.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_selection_pill.dart'; + +void main() { + for (final direction in TextDirection.values) { + testWidgets('tab dragging previews, commits and cancels ($direction)', ( + tester, + ) async { + var selected = -1; + final changes = []; + final capture = GlobalKey(); + final theme = MornyeTheme.build(Brightness.dark); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: theme, + home: Directionality( + textDirection: direction, + child: Scaffold( + body: Center( + child: SizedBox( + width: 360, + child: StatefulBuilder( + builder: (context, setState) => RepaintBoundary( + key: capture, + child: MornyeTabBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: MornyeNavigationIcon( + icon: Icons.music_note, + badgeCount: 7, + ), + label: 'Library', + ), + NavigationDestination( + icon: Icon(Icons.search), + label: 'Search', + ), + ], + selectedIndex: selected, + onSelected: (next) { + changes.add(next); + setState(() => selected = next); + }, + blurEnabled: true, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // No selection must still allow activating the first destination. + await tester.tap(find.text('Home')); + await tester.pumpAndSettle(); + expect(changes, [0]); + final home = tester.getCenter(find.text('Home')); + final library = tester.getCenter(find.text('Library')); + final pill = find.descendant( + of: find.byType(MornyeSelectionPill), + matching: find.byType(FractionallySizedBox), + ); + final gesture = await tester.startGesture(home); + // Move within tabs, across their boundaries, and reverse direction. + // One frame must put the pill under the finger without a settling delay. + for (final fraction in [0.35, 0.45, 0.5, 0.85, 1.2, 1.1, 0.9, 1.5]) { + final position = Offset( + home.dx + (library.dx - home.dx) * fraction, + home.dy, + ); + await gesture.moveTo(position); + await tester.pump(); + expect(tester.getCenter(pill).dx, closeTo(position.dx, 0.1)); + expect(changes, [0]); + if (fraction == 0.5) { + // Halfway between destinations, both icons and both labels contain + // active and inactive pixels. A nearest-index color swap cannot pass. + for (final finder in [ + find.byIcon(Icons.home), + find.byIcon(Icons.music_note), + find.text('Home'), + find.text('Library'), + ]) { + final colors = await _foregroundPixels(tester, capture, finder, [ + theme.colorScheme.primary, + theme.colorScheme.onSurface, + ]); + expect(colors, everyElement(greaterThan(3))); + } + final badgePixels = await _foregroundPixels( + tester, + capture, + find.text('7'), + [theme.colorScheme.onError], + ); + expect(badgePixels.single, greaterThan(3)); + } + } + await gesture.moveTo(tester.getCenter(find.text('Search'))); + await tester.pumpAndSettle(); + expect(changes, [0]); + await gesture.up(); + await tester.pumpAndSettle(); + expect(changes, [0, 2]); + expect( + tester.getCenter(pill).dx, + closeTo(tester.getCenter(find.text('Search')).dx, 0.1), + ); + final cancelled = await tester.startGesture( + tester.getCenter(find.text('Search')), + ); + await cancelled.moveTo(tester.getCenter(find.text('Home'))); + await tester.pump(); + await cancelled.cancel(); + await tester.pumpAndSettle(); + expect(changes, [0, 2]); + expect( + tester.getCenter(pill).dx, + closeTo(tester.getCenter(find.text('Search')).dx, 0.1), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(changes.length, 3); + expect(find.text('Home'), findsOneWidget); + expect(find.text('Search'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('switch drag toggles once with no filter ($direction)', ( + tester, + ) async { + var value = false; + final changes = []; + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Directionality( + textDirection: direction, + child: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) => AppSwitch( + value: value, + onChanged: (next) { + changes.add(next); + setState(() => value = next); + }, + ), + ), + ), + ), + ), + ), + ); + final sign = direction == TextDirection.ltr ? 1.0 : -1.0; + final gesture = await tester.startGesture( + tester.getCenter(find.byType(AppSwitch)), + ); + await gesture.moveBy(Offset(25 * sign, 0)); + await gesture.moveBy(Offset(25 * sign, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + expect(changes, [true]); + expect(find.byType(BackdropFilter), findsNothing); + expect(find.byType(ImageFiltered), findsNothing); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('nested glass uses one bounded filter and large text fits', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(3)), + child: Scaffold( + body: Column( + children: [ + MornyeGlassPanel( + child: MornyeSegmentedControl( + labels: const ['A', 'B'], + selectedIndex: 0, + onChanged: (_) {}, + ), + ), + MornyeTabBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + ], + selectedIndex: 0, + onSelected: (_) {}, + blurEnabled: true, + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(MornyeGlassPanel), + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); + expect(tester.getSize(find.byType(MornyeTabBar)).height, greaterThan(80)); + expect(tester.takeException(), isNull); + }); +} + +Future> _foregroundPixels( + WidgetTester tester, + GlobalKey capture, + Finder finder, + List colors, +) async { + final boundary = + capture.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final rect = tester + .getRect(finder) + .shift(-boundary.localToGlobal(Offset.zero)); + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + try { + final bytes = (await image.toByteData())!; + final counts = List.filled(colors.length, 0); + for (var y = rect.top.ceil(); y < rect.bottom.floor(); y++) { + for (var x = rect.left.ceil(); x < rect.right.floor(); x++) { + final offset = (y * image.width + x) * 4; + for (var i = 0; i < colors.length; i++) { + final argb = colors[i].toARGB32(); + if ((bytes.getUint8(offset) - ((argb >> 16) & 255)).abs() <= 2 && + (bytes.getUint8(offset + 1) - ((argb >> 8) & 255)).abs() <= 2 && + (bytes.getUint8(offset + 2) - (argb & 255)).abs() <= 2) { + counts[i]++; + } + } + } + } + return counts; + } finally { + image.dispose(); + } + }))!; +} diff --git a/test/native_glass_metrics_test.dart b/test/mornye_glass_layout_test.dart similarity index 88% rename from test/native_glass_metrics_test.dart rename to test/mornye_glass_layout_test.dart index 05ffc170..c6e2d1c8 100644 --- a/test/native_glass_metrics_test.dart +++ b/test/mornye_glass_layout_test.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:liquid_glass_easy/liquid_glass_easy.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; void main() { - testWidgets('glass uses window pixels while tablet content keeps its scale', ( + testWidgets('glass and controls share tablet layout coordinates', ( tester, ) async { tester.view.devicePixelRatio = 2; @@ -68,7 +67,7 @@ void main() { } // Full-screen tablet in both orientations, compact tablet, then a pop-up - // window and back. Resizing must update the shader without disabling glass. + // window and back. Glass follows normal layout, without window-pixel hacks. for (final size in [ const Size(1400, 876), const Size(876, 1400), @@ -81,17 +80,13 @@ void main() { expect(contentMetrics!.size, size / scale); expect(contentMetrics!.devicePixelRatio, 2 * scale); expect(contentMetrics!.padding.bottom, closeTo(20 / scale, 0.01)); - for (final type in [ - LiquidGlassLens, - LiquidGlassTabBar, - LiquidGlassSwitch, - ]) { + for (final type in [MornyeGlass, MornyeTabBar, AppSwitch]) { final glass = find.byType(type); expect(glass, findsWidgets); for (final element in glass.evaluate()) { final metrics = MediaQuery.of(element); - expect(metrics.size, size, reason: '$type at $size'); - expect(metrics.devicePixelRatio, 2, reason: '$type at $size'); + expect(metrics.size, size / scale, reason: '$type at $size'); + expect(metrics.devicePixelRatio, 2 * scale, reason: '$type at $size'); expect(metrics.textScaler, contentMetrics!.textScaler); expect(metrics.highContrast, contentMetrics!.highContrast); expect(metrics.disableAnimations, contentMetrics!.disableAnimations); diff --git a/test/mornye_glass_level_test.dart b/test/mornye_glass_level_test.dart index dfad5990..301ef0ba 100644 --- a/test/mornye_glass_level_test.dart +++ b/test/mornye_glass_level_test.dart @@ -3,12 +3,13 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:liquid_glass_easy/liquid_glass_easy.dart'; +import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_switch.dart'; import 'package:spotiflac_android/widgets/extension_repo_card.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; +import 'package:spotiflac_android/widgets/mornye_selection_pill.dart'; MornyeGlassLevel _levelFor({ required bool lowEnd, @@ -108,62 +109,61 @@ void main() { ); await tester.pump(); - expect(find.byType(LiquidGlassLens), findsNothing); - expect(find.byType(LiquidGlassTabBar), findsNothing); - expect(find.byType(LiquidGlassSwitch), findsNothing); + expect(find.byType(MornyeSelectionPill), findsNWidgets(2)); + expect(find.byType(ImageFiltered), findsNothing); // The frosted material still samples the page behind it. expect(find.byType(BackdropFilter), findsWidgets); expect(find.text('Library'), findsOneWidget); 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), - ), + testWidgets('clear glass reduces 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; - } + ), + ); + 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), - ); - }, - ); + expect(await filterFor(0, false), isNull); + expect(await filterFor(0.1, false), isNull); + expect(await filterFor(0.25, false), isNull); + expect(await filterFor(0.5, false), isNull); + expect(await filterFor(0.1, true), isNull); + expect(await filterFor(0.2, true), isNull); + expect( + await filterFor(0.25, true), + ui.ImageFilter.blur(sigmaX: 16, sigmaY: 16), + ); + expect( + await filterFor(kDefaultMornyeGlassClarity, true), + ui.ImageFilter.blur(sigmaX: 6, sigmaY: 6), + ); + expect( + await filterFor(1, false), + ui.ImageFilter.blur(sigmaX: 3, sigmaY: 3), + ); + expect(await filterFor(1, true), ui.ImageFilter.blur(sigmaX: 2, sigmaY: 2)); + }); for (final clarity in [0.0, 0.5]) { testWidgets('liquid tab and segment pills follow clarity ($clarity)', ( @@ -220,16 +220,11 @@ void main() { ), liquid, ); - // At 0% the opaque material drops the shader pills, like MornyeGlass - // drops its lens; the resting segments and tabs remain usable. - expect(find.byType(LiquidGlassTabBar), findsNWidgets(liquid ? 2 : 0)); - expect( - find.byType(LiquidGlassLens), - liquid ? findsWidgets : findsNothing, - ); - // The shader bar paints its labels in more than one layer. - expect(find.text('Two'), findsWidgets); - expect(find.text('Library'), findsWidgets); + // Every tier draws just one copy of each label and a painted pill. + expect(find.byType(MornyeSelectionPill), findsNWidgets(2)); + expect(find.byType(BackdropFilter), findsNWidgets(liquid ? 2 : 0)); + expect(find.text('Two'), findsOneWidget); + expect(find.text('Library'), findsOneWidget); expect(tester.takeException(), isNull); }); } @@ -298,7 +293,9 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('liquid level keeps the shader glass', (tester) async { + testWidgets('full-quality glass has one blur; switches have none', ( + tester, + ) async { await tester.pumpWidget( ProviderScope( child: MaterialApp( @@ -319,8 +316,15 @@ void main() { ); await tester.pump(); - expect(find.byType(LiquidGlassLens), findsWidgets); - expect(find.byType(LiquidGlassSwitch), findsOneWidget); + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byType(ImageFiltered), findsNothing); + expect( + find.descendant( + of: find.byType(AppSwitch), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); expect(tester.takeException(), isNull); }); } diff --git a/test/mornye_landscape_player_test.dart b/test/mornye_landscape_player_test.dart index 32da361e..ba012e02 100644 --- a/test/mornye_landscape_player_test.dart +++ b/test/mornye_landscape_player_test.dart @@ -117,8 +117,10 @@ void main() { expect(await contrast(10), lessThan(5)); final transitionContrast = (await contrast(74))!; - expect(transitionContrast, lessThan(blur ? 110 : 170)); - if (!blur) expect(transitionContrast, greaterThan(120)); + // A longer opacity fade clears the footer on every GPU, without three + // overlapping backdrop filters over the moving lyric content. + expect(transitionContrast, lessThan(110)); + expect(find.byType(BackdropFilter), findsNothing); await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 560a026e..edc2139b 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -19,6 +19,8 @@ import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; import 'package:spotiflac_android/providers/player_artwork_video_provider.dart'; import 'package:spotiflac_android/services/motion_artwork_store.dart'; +import 'package:spotiflac_android/services/network_metadata_service.dart'; +import 'package:spotiflac_android/services/network_storage_service.dart'; import 'package:video_player/video_player.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -1269,7 +1271,7 @@ void main() { await tester.pumpAndSettle(); expect(tester.getSize(panel).width, enteringWidth); expect(tester.getRect(panel).bottom, lessThan(anchor.top)); - expect(tester.getRect(panel).width, 320); + expect(tester.getRect(panel).width, 280); expect(find.byType(BottomSheet), findsNothing); expect(Theme.of(tester.element(panel)).brightness, Brightness.dark); expect( @@ -1288,7 +1290,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, 17); + expect(tester.widget(find.text('Go to Album')).style?.fontSize, 16); await tester.ensureVisible(find.text('Sleep timer')); await tester.tap(find.text('Sleep timer')); await tester.pumpAndSettle(); @@ -2864,6 +2866,46 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('network lyrics use shared metadata instead of the audio URL', ( + tester, + ) async { + final storage = NetworkStorageService.instance; + final source = NetworkStorageService.source('lyrics-test', 'song.flac'); + await tester.runAsync(() async { + await storage.save( + const NetworkConnection( + id: 'lyrics-test', + name: 'Lyrics test', + protocol: NetworkProtocol.http, + address: 'https://nas.test/music/', + ), + ); + await NetworkMetadataService.instance.read(source); + }); + addTearDown(() async { + await storage.remove('lyrics-test'); + await storage.dispose(); + }); + expect(metadataReads, hasLength(1)); + await pumpNowPlaying(tester); + mediaItems.add( + MediaItem( + id: source, + title: 'Network song', + extras: { + 'source': source, + 'resolvedSource': 'http://127.0.0.1:9999/playback-only', + }, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('material-lyrics-toggle'))); + await tester.pumpAndSettle(); + expect(find.text('First lyric'), findsOneWidget); + expect(metadataReads, hasLength(1)); + expect(tester.takeException(), isNull); + }); + testWidgets( 'automatic SAF track change refreshes lyrics while Lyrics page is active', (tester) async { @@ -2988,7 +3030,13 @@ void main() { final glass = tester.widget( find.byType(MornyeGlassPanel).last, ); - expect(glass.backdropFilter, isNotNull); + expect( + find.descendant( + of: find.byType(MornyeGlassPanel).last, + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); expect(glass.tintOpacity, inExclusiveRange(0, 1)); expect( find.text('Hide Pronunciation'),