From ca7665d805c92388210bd50f5ceb4abce100ee77 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:30:44 +0700 Subject: [PATCH] fix(lyrics): soften landscape footer and use neutral tab colors --- lib/widgets/mornye_landscape_player.dart | 105 +++++++++++++++- test/mornye_landscape_player_test.dart | 146 +++++++++++++++++++++++ 2 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 test/mornye_landscape_player_test.dart diff --git a/lib/widgets/mornye_landscape_player.dart b/lib/widgets/mornye_landscape_player.dart index aebee887..fb6f5991 100644 --- a/lib/widgets/mornye_landscape_player.dart +++ b/lib/widgets/mornye_landscape_player.dart @@ -1,8 +1,11 @@ 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 @@ -166,7 +169,10 @@ class _MornyeLandscapePlayerState extends State { : Stack( fit: StackFit.expand, children: [ - widget.lyrics, + _LandscapeLyricsViewport( + controlsVisible: _actionsVisible, + child: widget.lyrics, + ), if (widget.lyricsOptions != null) Positioned( left: 20, @@ -228,6 +234,14 @@ class _MornyeLandscapePlayerState extends State { .l10n .nowPlayingTabLyrics, isSelected: widget.page == 1, + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: widget.page == 1 + ? Colors.white.withValues( + alpha: 0.16, + ) + : Colors.transparent, + ), icon: const Icon( CupertinoIcons.quote_bubble, ), @@ -236,6 +250,7 @@ class _MornyeLandscapePlayerState extends State { ), ), AudioOutputButton( + color: Colors.white, onPickerChanged: (open) { if (!mounted) return; setState( @@ -251,6 +266,14 @@ class _MornyeLandscapePlayerState extends State { CupertinoIcons.list_bullet, ), isSelected: widget.page == 2, + color: Colors.white, + style: IconButton.styleFrom( + backgroundColor: widget.page == 2 + ? Colors.white.withValues( + alpha: 0.16, + ) + : Colors.transparent, + ), onPressed: () => widget.onPageChanged( widget.page == 2 ? 0 : 2, ), @@ -275,3 +298,83 @@ class _MornyeLandscapePlayerState extends State { ), ); } + +/// Let lyrics pass behind the footer without leaving readable text underneath +/// its controls. The mask and bounded blur recede when those controls hide. +class _LandscapeLyricsViewport extends ConsumerWidget { + const _LandscapeLyricsViewport({ + required this.controlsVisible, + required this.child, + }); + + final bool controlsVisible; + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final blur = + !MediaQuery.highContrastOf(context) && + (!ref.watch(lowEndDeviceProvider) || + ref.watch(backdropBlurEnabledProvider)); + return ClipRect( + key: const ValueKey('landscape-lyrics-viewport'), + child: TweenAnimationBuilder( + tween: Tween(end: controlsVisible ? 1 : 0), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + child: child, + builder: (context, progress, child) => Stack( + fit: StackFit.expand, + children: [ + ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white, + Colors.white, + Colors.white.withValues(alpha: 1 - progress), + Colors.white.withValues(alpha: 1 - progress), + ], + stops: [ + 0, + (1 - 96 / 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(), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/test/mornye_landscape_player_test.dart b/test/mornye_landscape_player_test.dart new file mode 100644 index 00000000..32da361e --- /dev/null +++ b/test/mornye_landscape_player_test.dart @@ -0,0 +1,146 @@ +import 'dart:ui' as ui; + +import 'package:flutter/cupertino.dart'; +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:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/mornye_landscape_player.dart'; + +void main() { + Future pumpPlayer( + WidgetTester tester, { + GlobalKey? capture, + bool blur = true, + }) async { + tester.view.physicalSize = const Size(852, 393); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var page = 1; + await tester.pumpWidget( + ProviderScope( + overrides: [ + lowEndDeviceProvider.overrideWithValue(true), + backdropBlurEnabledProvider.overrideWithValue(blur), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + backgroundColor: const Color(0xFF303030), + body: RepaintBoundary( + key: capture, + child: StatefulBuilder( + builder: (context, setState) => MornyeLandscapePlayer( + page: page, + isPlaying: true, + onPageChanged: (value) => setState(() => page = value), + artwork: const SizedBox.expand(), + header: const SizedBox(height: 48), + lyrics: const CustomPaint(painter: _Stripes()), + queue: const SizedBox.expand(), + controls: const SizedBox(height: 68), + volume: const SizedBox(height: 48), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('landscape selected tabs use the neutral portrait colors', ( + tester, + ) async { + await pumpPlayer(tester); + final lyrics = find.byIcon(CupertinoIcons.quote_bubble); + final queue = find.byIcon(CupertinoIcons.list_bullet); + void expectColors(Finder icon, {required bool selected}) { + expect(IconTheme.of(tester.element(icon)).color, Colors.white); + final surface = tester.widget( + find.ancestor(of: icon, matching: find.byType(Material)).first, + ); + expect( + surface.color, + selected ? Colors.white.withValues(alpha: 0.16) : Colors.transparent, + ); + } + + expectColors(lyrics, selected: true); + expectColors(queue, selected: false); + await tester.tap(queue); + await tester.pumpAndSettle(); + expectColors(lyrics, selected: false); + expectColors(queue, selected: true); + expect(tester.takeException(), isNull); + }); + + for (final blur in [false, true]) { + testWidgets('landscape footer clears text behind controls (blur: $blur)', ( + tester, + ) async { + final capture = GlobalKey(); + await pumpPlayer(tester, capture: capture, blur: blur); + final viewport = tester.getRect( + find.byKey(const ValueKey('landscape-lyrics-viewport')), + ); + Future contrast(double aboveBottom) => tester.runAsync(() async { + final boundary = tester.renderObject( + find.byKey(capture), + ); + final image = await boundary.toImage(); + try { + final pixels = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final origin = boundary.globalToLocal( + Offset(viewport.left + 100, viewport.bottom - aboveBottom), + ); + final samples = List.generate( + 64, + (x) => pixels.getUint8( + ((origin.dy.floor() * image.width) + origin.dx.floor() + x) * 4, + ), + )..sort(); + return samples.last - samples.first; + } finally { + image.dispose(); + } + }); + + expect(await contrast(10), lessThan(5)); + final transitionContrast = (await contrast(74))!; + expect(transitionContrast, lessThan(blur ? 110 : 170)); + if (!blur) expect(transitionContrast, greaterThan(120)); + + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(await contrast(10), greaterThan(240)); + expect(tester.takeException(), isNull); + }); + } +} + +class _Stripes extends CustomPainter { + const _Stripes(); + + @override + void paint(Canvas canvas, Size size) { + for (var x = 0.0; x < size.width; x += 8) { + canvas.drawRect( + Rect.fromLTWH(x, 0, 8, size.height), + Paint()..color = (x ~/ 8).isEven ? Colors.white : Colors.black, + ); + } + } + + @override + bool shouldRepaint(_Stripes oldDelegate) => false; +}