mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
fix(lyrics): soften landscape footer and use neutral tab colors
This commit is contained in:
@@ -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<MornyeLandscapePlayer> {
|
||||
: 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<MornyeLandscapePlayer> {
|
||||
.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<MornyeLandscapePlayer> {
|
||||
),
|
||||
),
|
||||
AudioOutputButton(
|
||||
color: Colors.white,
|
||||
onPickerChanged: (open) {
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
@@ -251,6 +266,14 @@ class _MornyeLandscapePlayerState extends State<MornyeLandscapePlayer> {
|
||||
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<MornyeLandscapePlayer> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 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<double>(
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> 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<Material>(
|
||||
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<int?> contrast(double aboveBottom) => tester.runAsync(() async {
|
||||
final boundary = tester.renderObject<RenderRepaintBoundary>(
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user