mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-03 14:46:57 +02:00
perf(mornye): adapt glass and player effects to device capabilities
This commit is contained in:
1 parent
b11e925ee4
commit
9ebf98ff6c
20 files changed
+500
-81
No files matched your search
@@ -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,
|
||||
);
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,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
@@ -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(
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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<void> 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 = <String>[];
|
||||
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<VideoPlayerController>().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(
|
||||
|
||||
Reference in new issue
Block a user