feat(mornye): restore liquid glass with continuous draggable navigation

This commit is contained in:
zarzet committed 2026-10-03 04:24:54 +07:00
1 parent 9247b99ab0
commit 0914e7d978
29 files changed
+1818 -1022

No files matched your search

@@ -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<double>(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<double>(
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 = <int>[];
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<void>.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<MornyeLiquidBackdrop>(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<MornyeLiquidBackdrop>(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<void>.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<MornyeLiquidBackdrop>(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);
});
}
+1
View File
@@ -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.",
+1
View File
@@ -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",
+5 -6
View File
@@ -10,10 +10,9 @@ 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.
/// 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<bool>((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<bool>(
(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<bool>(
(ref) => ref.watch(mornyeGlassLevelProvider) == MornyeGlassLevel.liquid,
);
+27 -24
View File
@@ -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<MainShell>
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<MainShell>
),
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,
+2
View File
@@ -2090,6 +2090,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final action = await showMornyeContextMenu<String>(
context: titleContext,
preferAbove: true,
maxWidth: 280,
builder: (_) => MornyePlayerNavigationMenu(mediaItem: mediaItem),
);
if (!mounted) return;
@@ -2160,6 +2161,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
context: context,
anchor: anchor,
preferAbove: true,
maxWidth: 280,
builder: (_) => MornyePlayerActionsSheet(
mediaItem: mediaItem,
sleepTimerSubtitle: sleepTimerSubtitle,
@@ -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,
),
),
],
],
),
);
+1 -1
View File
@@ -55,7 +55,7 @@ class MornyeTheme extends ThemeExtension<MornyeTheme> {
static double navigationOpacity(BuildContext context) {
final theme = Theme.of(context);
if (theme.extension<MornyeTheme>()?.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);
+1 -1
View File
@@ -110,7 +110,7 @@ ScaffoldFeatureController<SnackBar, SnackBarClosedReason> 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(
-29
View File
@@ -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(
+52 -43
View File
@@ -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<MornyeBottomBar>
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<MornyeBottomBar>
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<MornyeBottomBar>
// 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),
File diff suppressed because it is too large. Load diff
+46 -80
View File
@@ -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<T?> showMornyeContextMenu<T>({
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<T?> showMornyeContextMenu<T>({
),
),
child: ScaleTransition(
alignment: preferAbove ? Alignment.bottomRight : Alignment.center,
scale: animation.drive(Tween<double>(begin: 0.96, end: 1)),
alignment: preferAbove
? Alignment.bottomRight
: Alignment.topRight,
scale: animation.drive(
Tween<double>(
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<MornyeTheme>()?.chromeSurface != null
? null
: dark
? const Color(0xff414141)
: Colors.white,
tintOpacity: dark
? 0.80 - 0.20 * (theme.extension<MornyeTheme>()?.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,
+2 -33
View File
@@ -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<MornyeLandscapePlayer> {
}
/// 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<double>(
@@ -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(),
),
),
),
),
],
),
),
+57
View File
@@ -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,
);
}
}
+121 -103
View File
@@ -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,
);
},
);
}
}
+438
View File
@@ -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<String> labels;
final int selectedIndex;
final ValueChanged<int> 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<MornyeSelectionPill> createState() => _MornyeSelectionPillState();
}
class _MornyeSelectionPillState extends State<MornyeSelectionPill> {
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<double>(
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<double>(
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;
}
-25
View File
@@ -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,
);
}
}
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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 = [
+12
View File
@@ -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>(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>(slider).onChanged, isNull);
expect(
find.byKey(const ValueKey('mornye-glass-clear-performance')),
findsNothing,
);
expect(
tester.widget<Text>(hint).data,
'Glass is opaque on this device. Turn on '
+13 -11
View File
@@ -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);
}
}
+77 -1
View File
@@ -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 = <Color>[];
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,
+270
View File
@@ -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 = <int>[];
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 = <bool>[];
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<List<int>> _foregroundPixels(
WidgetTester tester,
GlobalKey capture,
Finder finder,
List<Color> 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();
}
}))!;
}
@@ -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);
+65 -61
View File
@@ -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<ui.ImageFilter?> 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<ui.ImageFilter?> 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<BackdropFilter>(
find.byType(BackdropFilter),
);
return filters.isEmpty ? null : filters.single.filter;
}
),
);
final filters = tester.widgetList<BackdropFilter>(
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);
});
}
+4 -2
View File
@@ -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();
+51 -3
View File
@@ -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<Text>(find.text('Go to Album')).style?.fontSize, 17);
expect(tester.widget<Text>(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<MornyeGlassPanel>(
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'),