diff --git a/lib/main.dart b/lib/main.dart index 26e2e45e..43f37a29 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -92,6 +92,11 @@ void main() { deviceSupportsBackdropBlurProvider.overrideWithValue( runtimeProfile.enableBackdropBlur, ), + // The RAM tiers describe Android hardware. Other platforms keep + // the existing full material by default. + deviceSupportsLiquidGlassProvider.overrideWithValue( + !Platform.isAndroid || runtimeProfile.enableBackdropBlur, + ), initialSettingsProvider.overrideWithValue(bootstrapSettings), initialSafAccessLostProvider.overrideWithValue( initialSafAccessLost, diff --git a/lib/providers/runtime_profile_provider.dart b/lib/providers/runtime_profile_provider.dart index 1bfde69c..d1fc312d 100644 --- a/lib/providers/runtime_profile_provider.dart +++ b/lib/providers/runtime_profile_provider.dart @@ -10,9 +10,47 @@ 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. +final deviceSupportsLiquidGlassProvider = Provider((ref) => true); + /// Whether backdrop blur effects should render: the device default, or the /// user's manual override from appearance settings. final backdropBlurEnabledProvider = Provider((ref) { return ref.watch(deviceSupportsBackdropBlurProvider) || ref.watch(settingsProvider.select((s) => s.forceBackdropBlur)); }); + +/// How much of Mornye's glass the device renders. +enum MornyeGlassLevel { + /// Opaque surfaces; no backdrop sampling. + flat, + + /// Translucent surfaces over one backdrop blur; no shader passes. + frosted, + + /// Frosted surfaces plus refractive shader lenses and blur-driven motion. + liquid, +} + +final mornyeGlassLevelProvider = Provider((ref) { + final lowEnd = ref.watch(lowEndDeviceProvider); + if (!lowEnd && ref.watch(deviceSupportsLiquidGlassProvider)) { + return MornyeGlassLevel.liquid; + } + // The user's "always use blur effects" override restores the full material. + if (ref.watch(backdropBlurEnabledProvider)) return MornyeGlassLevel.liquid; + return lowEnd ? MornyeGlassLevel.flat : MornyeGlassLevel.frosted; +}); + +/// Backdrop blur for Mornye surfaces (frosted or liquid). +final mornyeBlurEnabledProvider = Provider( + (ref) => ref.watch(mornyeGlassLevelProvider) != MornyeGlassLevel.flat, +); + +/// Shader glass and continuously re-filtered blur effects (liquid only). +final mornyeLiquidGlassProvider = Provider( + (ref) => ref.watch(mornyeGlassLevelProvider) == MornyeGlassLevel.liquid, +); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index adfba991..d631e895 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -1078,9 +1078,7 @@ class _MainShellState extends ConsumerState onSelected: _onNavTap, onHome: () => _onNavTap(0), onSearch: ShellNavigationService.requestSearch, - blurEnabled: - !ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider), + blurEnabled: ref.watch(mornyeBlurEnabledProvider), ), ), ), diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 35e32eb2..7ac8bab3 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -3496,8 +3496,8 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> final blurLyrics = mornye && !highContrast && - (!ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)); + // Every defocused line is filtered again on each playback frame. + ref.watch(mornyeLiquidGlassProvider); final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 280); diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index 190debe6..08957199 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -106,9 +106,7 @@ class _FilterChip extends ConsumerWidget { onTap: onTap, child: MornyeGlass( radius: 22, - blurEnabled: - !ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider), + blurEnabled: ref.watch(mornyeBlurEnabledProvider), child: Material( color: Colors.transparent, child: InkWell( diff --git a/lib/screens/repo_tab.dart b/lib/screens/repo_tab.dart index 3cc2ed9f..3c70ae0a 100644 --- a/lib/screens/repo_tab.dart +++ b/lib/screens/repo_tab.dart @@ -659,9 +659,7 @@ class _CategoryChip extends ConsumerWidget { excludeSemantics: true, child: MornyeGlass.navigation( radius: 24, - blurEnabled: - !ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider), + blurEnabled: ref.watch(mornyeBlurEnabledProvider), child: Material( color: Colors.transparent, child: InkWell( diff --git a/lib/widgets/album_detail_header.dart b/lib/widgets/album_detail_header.dart index fc11310f..a52c4d23 100644 --- a/lib/widgets/album_detail_header.dart +++ b/lib/widgets/album_detail_header.dart @@ -701,9 +701,7 @@ class HeaderCircleButton extends ConsumerWidget { ), ); } - final blurEnabled = - !ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider); + final blurEnabled = ref.watch(mornyeBlurEnabledProvider); final button = IconButton( onPressed: onPressed, tooltip: tooltip, diff --git a/lib/widgets/app_search_field.dart b/lib/widgets/app_search_field.dart index 6a23ff14..3860ca78 100644 --- a/lib/widgets/app_search_field.dart +++ b/lib/widgets/app_search_field.dart @@ -106,9 +106,7 @@ class AppSearchField extends StatelessWidget { return Consumer( builder: (context, ref, child) => MornyeGlass.navigation( radius: 28, - blurEnabled: - !ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider), + blurEnabled: ref.watch(mornyeBlurEnabledProvider), child: child!, ), child: field, diff --git a/lib/widgets/app_switch.dart b/lib/widgets/app_switch.dart index 07f2a50b..6b0c7ba7 100644 --- a/lib/widgets/app_switch.dart +++ b/lib/widgets/app_switch.dart @@ -50,8 +50,8 @@ class AppSwitch extends StatelessWidget { enabled && !MediaQuery.disableAnimationsOf(context) && !MediaQuery.highContrastOf(context) && - (!ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)); + // One shader lens per switch; settings pages show many at once. + ref.watch(mornyeLiquidGlassProvider); final inactive = scheme.brightness == Brightness.dark ? const Color(0xff39393d) : const Color(0xff8e8e93); diff --git a/lib/widgets/extension_repo_card.dart b/lib/widgets/extension_repo_card.dart index 6fbab2c3..aceea6fb 100644 --- a/lib/widgets/extension_repo_card.dart +++ b/lib/widgets/extension_repo_card.dart @@ -36,9 +36,7 @@ class ExtensionRepoCard extends ConsumerWidget { padding: margin, child: MornyeGlass.navigation( radius: 24, - blurEnabled: - !ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider), + blurEnabled: ref.watch(mornyeBlurEnabledProvider), child: Material(color: Colors.transparent, child: child), ), ); diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index 43de1a2f..f9d7d19c 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -63,7 +63,13 @@ class _MiniPlayerState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final mornye = context.isMornye; final compact = mornye && widget.compact; - if (mornye && !MediaQuery.disableAnimationsOf(context)) { + // Frosted and flat glass levels run on constrained Android hardware. They + // skip keeping a paused video decoder for the full player's motion cover, + // and stop the title marquee after a few passes: every marquee frame + // re-filters the blurred chrome around it. + final fullEffects = !mornye || ref.watch(mornyeLiquidGlassProvider); + final marqueeCycles = fullEffects ? null : 2; + if (mornye && fullEffects && !MediaQuery.disableAnimationsOf(context)) { final artwork = ref .watch( playerMotionArtworkProvider(( @@ -156,6 +162,7 @@ class _MiniPlayerState extends ConsumerState { children: [ OverflowMarquee( resetKey: (mediaItem.id, mediaItem.title), + maxCycles: marqueeCycles, child: ExplicitTrackTitle( title: mediaItem.title, explicit: @@ -171,6 +178,7 @@ class _MiniPlayerState extends ConsumerState { ), OverflowMarquee( resetKey: (mediaItem.id, mediaItem.artist), + maxCycles: marqueeCycles, child: Text( mediaItem.artist ?? '', style: Theme.of(context).textTheme.bodySmall @@ -244,9 +252,7 @@ class _MiniPlayerState extends ConsumerState { child: MornyeGlass.navigation( strongTint: true, tintOpacity: MornyeTheme.chromeOpacity(context), - blurEnabled: - !ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider), + blurEnabled: ref.watch(mornyeBlurEnabledProvider), child: player, ), ) diff --git a/lib/widgets/mornye_artist_header.dart b/lib/widgets/mornye_artist_header.dart index b6954330..a3efb295 100644 --- a/lib/widgets/mornye_artist_header.dart +++ b/lib/widgets/mornye_artist_header.dart @@ -359,8 +359,8 @@ class _ArtistCollapsingArtwork extends ConsumerWidget { final blur = 18 * Curves.easeOut.transform(collapse); final blurEnabled = !MediaQuery.disableAnimationsOf(context) && - (!ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)); + // The collapse blur and masked backdrop re-filter on every scroll frame. + ref.watch(mornyeLiquidGlassProvider); return ClipRect( child: Stack( diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index 78b13985..26124408 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; import 'package:spotiflac_android/widgets/mornye_chrome.dart'; @@ -85,9 +86,11 @@ class MornyeBottomBar extends ConsumerWidget { final hasPlayer = ref.watch( currentMediaItemProvider.select((item) => item.value != null), ); + final liquidGlass = ref.watch(mornyeLiquidGlassProvider); // Animated glass tabs already reserve 8px above their visible capsule. final glassTabs = blurEnabled && + liquidGlass && !MediaQuery.disableAnimationsOf(context) && !MediaQuery.highContrastOf(context); final tabGap = glassTabs ? 0.0 : 8.0; @@ -118,6 +121,7 @@ class MornyeBottomBar extends ConsumerWidget { onSelected: (index) => index == destinations.length - 1 ? onSearch() : onSelected(index), blurEnabled: blurEnabled, + liquidGlass: liquidGlass, hiddenIconIndices: hideMovingIcons ? {leadingIndex, destinations.length - 1} : const {}, diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 4a018045..8c493667 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -25,10 +25,14 @@ class MornyeSegmentedControl extends ConsumerWidget { final scheme = theme.colorScheme; final blur = !MediaQuery.highContrastOf(context) && - (!ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)); + ref.watch(mornyeBlurEnabledProvider); final animate = !MediaQuery.disableAnimationsOf(context); final height = MediaQuery.textScalerOf(context).scale(15) + 36; + if (!blur || !animate || !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; @@ -74,11 +78,9 @@ class MornyeSegmentedControl extends ConsumerWidget { chromaticAberration: 0, ), ), - pillStyle: LiquidGlassTabPillStyle( - mode: blur && animate - ? LiquidGlassPillMode.impellerOnly - : LiquidGlassPillMode.none, - animated: animate, + pillStyle: const LiquidGlassTabPillStyle( + mode: LiquidGlassPillMode.impellerOnly, + animated: true, ), itemStyle: LiquidGlassTabItemStyle( selectedColor: scheme.onSurface, @@ -117,6 +119,66 @@ class MornyeSegmentedControl extends ConsumerWidget { ), ); } + + 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, + 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, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } } /// The bounded, translucent chrome used by Mornye's tab accessory. Blur is @@ -165,8 +227,7 @@ class MornyeGlassPanel extends ConsumerWidget { final blur = blurEnabled && !MediaQuery.highContrastOf(context) && - (!ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)); + ref.watch(mornyeBlurEnabledProvider); return MornyeGlass.navigation( radius: radius, firstInGroup: firstInGroup, @@ -231,10 +292,14 @@ class MornyeGlass extends StatelessWidget { @override Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final dark = scheme.brightness == Brightness.dark; final useGlass = _useLens && blurEnabled && !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, @@ -244,41 +309,45 @@ class MornyeGlass extends StatelessWidget { tintOpacity: tintOpacity, tintColor: tintColor, backdropFilter: backdropFilter, - child: useGlass - ? NativeGlassMetrics( - child: LiquidGlassLens( - style: LiquidGlassStyle( - shape: LiquidGlassShape.continuousRoundedRectangle( - cornerRadius: radius, - borderWidth: dark && tintOpacity == null ? 0 : 0.6, - lightIntensity: dark && tintOpacity == null ? 0 : 0.18, - ), - appearance: LiquidGlassAppearance( - color: tintOpacity != null - ? Colors.transparent - : dark - ? scheme.surfaceContainerHigh.withValues(alpha: 0.28) - : Colors.white.withValues( - alpha: strongTint ? 0.20 : 0.55, - ), - // The surface already blurs the backdrop. Refracting that - // frosted result needs no second Gaussian blur pass. - blur: const 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), - ), - ) - : child, + child: useGlass ? lens(child) : child, ); } + + Widget _liquidLens(Widget child) => Builder( + builder: (context) { + final scheme = Theme.of(context).colorScheme; + final dark = scheme.brightness == Brightness.dark; + return NativeGlassMetrics( + child: LiquidGlassLens( + style: LiquidGlassStyle( + shape: LiquidGlassShape.continuousRoundedRectangle( + cornerRadius: radius, + borderWidth: dark && tintOpacity == null ? 0 : 0.6, + lightIntensity: dark && tintOpacity == null ? 0 : 0.18, + ), + appearance: LiquidGlassAppearance( + color: tintOpacity != null + ? Colors.transparent + : dark + ? scheme.surfaceContainerHigh.withValues(alpha: 0.28) + : Colors.white.withValues(alpha: strongTint ? 0.20 : 0.55), + // The surface already blurs the backdrop. Refracting that + // frosted result needs no second Gaussian blur pass. + blur: const 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 @@ -464,10 +533,7 @@ class MornyeFilterChip extends ConsumerWidget { } return MornyeGlass.navigation( radius: 24, - blurEnabled: - blurEnabled && - (!ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)), + blurEnabled: blurEnabled && ref.watch(mornyeBlurEnabledProvider), child: content, ); } @@ -480,9 +546,13 @@ class MornyeTabBar extends StatelessWidget { required this.selectedIndex, required this.onSelected, required this.blurEnabled, + this.liquidGlass = true, this.hiddenIconIndices = const {}, }); + /// Shader capsule and travelling pill; otherwise the frosted row below. + final bool liquidGlass; + final List destinations; final int selectedIndex; final ValueChanged onSelected; @@ -498,6 +568,7 @@ class MornyeTabBar extends StatelessWidget { ); final inactiveIconColor = scheme.onSurface; if (blurEnabled && + liquidGlass && !MediaQuery.disableAnimationsOf(context) && !MediaQuery.highContrastOf(context)) { return LayoutBuilder( diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart index ee31d562..024589c3 100644 --- a/lib/widgets/mornye_landscape_player.dart +++ b/lib/widgets/mornye_landscape_player.dart @@ -316,10 +316,10 @@ 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(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)); + ref.watch(mornyeLiquidGlassProvider); return ClipRect( key: const ValueKey('landscape-lyrics-viewport'), child: TweenAnimationBuilder( diff --git a/lib/widgets/mornye_player_details_sheet.dart b/lib/widgets/mornye_player_details_sheet.dart index b68da365..c330b231 100644 --- a/lib/widgets/mornye_player_details_sheet.dart +++ b/lib/widgets/mornye_player_details_sheet.dart @@ -29,8 +29,7 @@ class MornyePlayerDetailsSheet extends ConsumerWidget { final item = mediaItem; final blur = !MediaQuery.highContrastOf(context) && - (!ref.watch(lowEndDeviceProvider) || - ref.watch(backdropBlurEnabledProvider)); + ref.watch(mornyeBlurEnabledProvider); return MornyeGlass.navigation( blurEnabled: blur, child: SafeArea( diff --git a/lib/widgets/overflow_marquee.dart b/lib/widgets/overflow_marquee.dart index 36238d53..95dd5772 100644 --- a/lib/widgets/overflow_marquee.dart +++ b/lib/widgets/overflow_marquee.dart @@ -8,11 +8,16 @@ class OverflowMarquee extends StatefulWidget { super.key, required this.resetKey, required this.child, + this.maxCycles, }); final Object resetKey; final Widget child; + /// Stops at the start after this many passes; null scrolls indefinitely. + /// A new [resetKey], width or lifecycle resume starts a fresh count. + final int? maxCycles; + @override State createState() => _OverflowMarqueeState(); } @@ -25,6 +30,7 @@ class _OverflowMarqueeState extends State final _contentKey = GlobalKey(); Timer? _pause; int _generation = 0; + int _cycles = 0; bool _restartQueued = false; bool _motionEnabled = false; bool _appActive = true; @@ -53,7 +59,8 @@ class _OverflowMarqueeState extends State @override void didUpdateWidget(OverflowMarquee oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.resetKey != oldWidget.resetKey) { + if (widget.resetKey != oldWidget.resetKey || + widget.maxCycles != oldWidget.maxCycles) { _queueRestart(); } } @@ -67,6 +74,7 @@ class _OverflowMarqueeState extends State void _queueRestart() { _pause?.cancel(); _generation++; + _cycles = 0; if (_restartQueued) return; _restartQueued = true; WidgetsBinding.instance.addPostFrameCallback((_) { @@ -113,6 +121,9 @@ class _OverflowMarqueeState extends State // The next copy is now exactly where the first started, so resetting // the offset changes neither the visible text nor its direction. _scroll.jumpTo(0); + _cycles++; + final maxCycles = widget.maxCycles; + if (maxCycles != null && _cycles >= maxCycles) return; _scheduleCycle(generation); } } diff --git a/test/design_system_test.dart b/test/design_system_test.dart index b3f5d107..b71981a5 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -8,6 +8,8 @@ import 'package:analyzer/source/line_info.dart'; 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' + show LiquidGlassTabBar; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/theme/app_theme.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; @@ -263,6 +265,41 @@ void main() { ); } + testWidgets('theme selector skips the glass tab bar with reduced motion', ( + tester, + ) async { + var selected = 0; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => MornyeSegmentedControl( + labels: const ['System', 'Light', 'Dark'], + selectedIndex: selected, + onChanged: (index) => setState(() => selected = index), + ), + ), + ), + ), + ), + ), + ); + expect(find.byType(LiquidGlassTabBar), findsNothing); + expect(find.text('Dark'), findsOneWidget); + await tester.tap(find.text('Dark')); + await tester.pump(); + expect(selected, 2); + expect( + tester.getCenter(find.text('System')).dx < + tester.getCenter(find.text('Dark')).dx, + isTrue, + ); + expect(tester.takeException(), isNull); + }); testWidgets('glass search keeps text editing, submit and clear usable', ( tester, ) async { diff --git a/test/mornye_glass_level_test.dart b/test/mornye_glass_level_test.dart new file mode 100644 index 00000000..d0ab0031 --- /dev/null +++ b/test/mornye_glass_level_test.dart @@ -0,0 +1,142 @@ +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/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/mornye_chrome.dart'; + +MornyeGlassLevel _levelFor({ + required bool lowEnd, + required bool blur, + required bool liquidCapable, +}) { + final container = ProviderContainer( + overrides: [ + lowEndDeviceProvider.overrideWithValue(lowEnd), + // Device default or the user's "always use blur effects" override. + backdropBlurEnabledProvider.overrideWithValue(blur), + deviceSupportsLiquidGlassProvider.overrideWithValue(liquidCapable), + ], + ); + addTearDown(container.dispose); + return container.read(mornyeGlassLevelProvider); +} + +void main() { + test('runtime tiers map to Mornye glass levels', () { + // Low tier: arm32-only or low-RAM Android. + expect( + _levelFor(lowEnd: true, blur: false, liquidCapable: false), + MornyeGlassLevel.flat, + ); + // Standard Android: frosted blur without shader passes. + expect( + _levelFor(lowEnd: false, blur: false, liquidCapable: false), + MornyeGlassLevel.frosted, + ); + // iOS runs the standard tier but keeps the full material. + expect( + _levelFor(lowEnd: false, blur: false, liquidCapable: true), + MornyeGlassLevel.liquid, + ); + // High-tier Android. + expect( + _levelFor(lowEnd: false, blur: true, liquidCapable: true), + MornyeGlassLevel.liquid, + ); + // "Always use blur effects" restores the full material on any tier. + for (final lowEnd in [true, false]) { + expect( + _levelFor(lowEnd: lowEnd, blur: true, liquidCapable: false), + MornyeGlassLevel.liquid, + ); + } + }); + + testWidgets('frosted level keeps blur but drops every shader surface', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + deviceSupportsLiquidGlassProvider.overrideWithValue(false), + backdropBlurEnabledProvider.overrideWithValue(false), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Scaffold( + body: Column( + children: [ + const MornyeGlass( + blurEnabled: true, + child: SizedBox(width: 200, height: 50), + ), + MornyeSegmentedControl( + labels: const ['One', 'Two'], + selectedIndex: 0, + onChanged: (_) {}, + ), + AppSwitch(value: true, onChanged: (_) {}), + Consumer( + builder: (context, ref, _) => MornyeTabBar( + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.music_note), + label: 'Library', + ), + ], + selectedIndex: 0, + onSelected: (_) {}, + blurEnabled: ref.watch(mornyeBlurEnabledProvider), + liquidGlass: ref.watch(mornyeLiquidGlassProvider), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(LiquidGlassLens), findsNothing); + expect(find.byType(LiquidGlassTabBar), findsNothing); + expect(find.byType(LiquidGlassSwitch), 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('liquid level keeps the shader glass', (tester) async { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Scaffold( + body: Column( + children: [ + const MornyeGlass( + blurEnabled: true, + child: SizedBox(width: 200, height: 50), + ), + AppSwitch(value: true, onChanged: (_) {}), + ], + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(LiquidGlassLens), findsWidgets); + expect(find.byType(LiquidGlassSwitch), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/overflow_marquee_test.dart b/test/overflow_marquee_test.dart index 76bc3ea2..98c6de14 100644 --- a/test/overflow_marquee_test.dart +++ b/test/overflow_marquee_test.dart @@ -1,14 +1,20 @@ +import 'dart:async'; + import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/player_artwork_video_provider.dart'; +import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart'; import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/services/motion_artwork_store.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/mini_player.dart'; import 'package:spotiflac_android/widgets/overflow_marquee.dart'; +import 'package:video_player/video_player.dart'; void main() { const longTitle = 'A long song title with enough words to leave the viewport'; @@ -168,6 +174,66 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + testWidgets('bounded marquee rests at the start after its passes', ( + tester, + ) async { + Future pumpBounded(String title, {int? maxCycles = 2}) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 160, + child: OverflowMarquee( + resetKey: title, + maxCycles: maxCycles, + child: Text( + title, + maxLines: 1, + style: const TextStyle(fontSize: 16), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + } + + await pumpBounded(longTitle); + final scroll = controller(tester); + final distance = scroll.position.maxScrollExtent; + expect(distance, greaterThan(0)); + // Two passes of pause + travel, then nothing else is scheduled. + var passes = 0; + var moving = false; + for (var i = 0; i < 600; i++) { + await tester.pump(const Duration(milliseconds: 250)); + if (scroll.offset > 0) { + moving = true; + } else if (moving) { + moving = false; + passes++; + } + } + expect(passes, 2); + expect(controller(tester).offset, 0); + expect(tester.binding.transientCallbackCount, 0); + await tester.pump(const Duration(seconds: 10)); + expect(controller(tester).offset, 0); + + // Restoring full effects restarts a resting title without changing tracks. + await pumpBounded(longTitle, maxCycles: null); + await startScrolling(tester); + expect(controller(tester).offset, greaterThan(0)); + + // A new track starts a fresh count. + await pumpBounded('$longTitle again'); + await startScrolling(tester); + expect(controller(tester).offset, greaterThan(0)); + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('RTL titles scroll from the reading direction', (tester) async { await pumpTitle(tester, direction: TextDirection.rtl); expect(controller(tester).position.axisDirection, AxisDirection.left); @@ -177,6 +243,58 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + for (final liquid in [false, true]) { + testWidgets( + 'Mornye mini player prepares the motion cover only with liquid glass ' + '(liquid: $liquid)', + (tester) async { + final prepared = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentMediaItemProvider.overrideWith( + (ref) => Stream.value( + const MediaItem( + id: 'track', + title: 'Song', + album: 'Album', + artist: 'Artist', + ), + ), + ), + playbackStateProvider.overrideWith((ref) => const Stream.empty()), + deviceSupportsLiquidGlassProvider.overrideWithValue(liquid), + backdropBlurEnabledProvider.overrideWithValue(false), + playerMotionArtworkProvider.overrideWith( + (ref, album) async => + const MotionArtwork('file:///cover.mp4', aspectRatio: 1), + ), + playerArtworkVideoProvider.overrideWith((ref, source) { + prepared.add(source); + return Completer().future; + }), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: Center(child: SizedBox(width: 320, child: MiniPlayer())), + ), + ), + ), + ); + for (var i = 0; i < 4; i++) { + await tester.pump(); + } + expect(find.text('Song'), findsWidgets); + expect(prepared, liquid ? ['file:///cover.mp4'] : isEmpty); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } + for (final mornye in [false, true]) { for (final compact in [false, true]) { testWidgets(