mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
feat(mornye): restore liquid glass with continuous draggable navigation
This commit is contained in:
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);
|
||||
});
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
+260
-580
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user