perf(mornye): adapt glass and player effects to device capabilities

This commit is contained in:
zarzet committed 2026-09-30 23:42:00 +07:00
1 parent b11e925ee4
commit 9ebf98ff6c
20 files changed
+500 -81

No files matched your search

+5
View File
@@ -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,
@@ -10,9 +10,47 @@ final lowEndDeviceProvider = Provider<bool>((ref) => false);
/// in main.dart). Only the `high` tier enables blur by default.
final deviceSupportsBackdropBlurProvider = Provider<bool>((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<bool>((ref) => true);
/// Whether backdrop blur effects should render: the device default, or the
/// user's manual override from appearance settings.
final backdropBlurEnabledProvider = Provider<bool>((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<MornyeGlassLevel>((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<bool>(
(ref) => ref.watch(mornyeGlassLevelProvider) != MornyeGlassLevel.flat,
);
/// Shader glass and continuously re-filtered blur effects (liquid only).
final mornyeLiquidGlassProvider = Provider<bool>(
(ref) => ref.watch(mornyeGlassLevelProvider) == MornyeGlassLevel.liquid,
);
+1 -3
View File
@@ -1078,9 +1078,7 @@ class _MainShellState extends ConsumerState<MainShell>
onSelected: _onNavTap,
onHome: () => _onNavTap(0),
onSearch: ShellNavigationService.requestSearch,
blurEnabled:
!ref.watch(lowEndDeviceProvider) ||
ref.watch(backdropBlurEnabledProvider),
blurEnabled: ref.watch(mornyeBlurEnabledProvider),
),
),
),
+2 -2
View File
@@ -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);
+1 -3
View File
@@ -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(
+1 -3
View File
@@ -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(
+1 -3
View File
@@ -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,
+1 -3
View File
@@ -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,
+2 -2
View File
@@ -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);
+1 -3
View File
@@ -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),
),
);
+10 -4
View File
@@ -63,7 +63,13 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
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<MiniPlayer> {
children: [
OverflowMarquee(
resetKey: (mediaItem.id, mediaItem.title),
maxCycles: marqueeCycles,
child: ExplicitTrackTitle(
title: mediaItem.title,
explicit:
@@ -171,6 +178,7 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
),
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<MiniPlayer> {
child: MornyeGlass.navigation(
strongTint: true,
tintOpacity: MornyeTheme.chromeOpacity(context),
blurEnabled:
!ref.watch(lowEndDeviceProvider) ||
ref.watch(backdropBlurEnabledProvider),
blurEnabled: ref.watch(mornyeBlurEnabledProvider),
child: player,
),
)
+2 -2
View File
@@ -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(
+4
View File
@@ -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 {},
+119 -48
View File
@@ -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<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
@@ -498,6 +568,7 @@ class MornyeTabBar extends StatelessWidget {
);
final inactiveIconColor = scheme.onSurface;
if (blurEnabled &&
liquidGlass &&
!MediaQuery.disableAnimationsOf(context) &&
!MediaQuery.highContrastOf(context)) {
return LayoutBuilder(
+2 -2
View File
@@ -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<double>(
+1 -2
View File
@@ -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(
+12 -1
View File
@@ -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<OverflowMarquee> createState() => _OverflowMarqueeState();
}
@@ -25,6 +30,7 @@ class _OverflowMarqueeState extends State<OverflowMarquee>
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<OverflowMarquee>
@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<OverflowMarquee>
void _queueRestart() {
_pause?.cancel();
_generation++;
_cycles = 0;
if (_restartQueued) return;
_restartQueued = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -113,6 +121,9 @@ class _OverflowMarqueeState extends State<OverflowMarquee>
// 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);
}
}