From 2adcdf0768ec505c3d59fb69b3b220fca2b8f195 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:20:11 +0700 Subject: [PATCH] fix(artwork): preserve cover colors and readable player contrast --- lib/theme/cover_palette.dart | 93 ++++++++++++++++++++--- lib/widgets/mornye_artwork_contrast.dart | 6 +- lib/widgets/mornye_player_background.dart | 43 ++++++++--- test/cover_palette_test.dart | 44 +++++++++++ test/mornye_artwork_contrast_test.dart | 20 +++++ 5 files changed, 186 insertions(+), 20 deletions(-) diff --git a/lib/theme/cover_palette.dart b/lib/theme/cover_palette.dart index ccb33cb1..09dcbb4f 100644 --- a/lib/theme/cover_palette.dart +++ b/lib/theme/cover_palette.dart @@ -1,4 +1,7 @@ +import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; @@ -14,6 +17,7 @@ class CoverPalette { /// URL+brightness. Bounded because a long library-browsing session would /// otherwise keep every visited album's scheme alive. static final Map _cache = {}; + static final Map _sourceColors = {}; static final Map> _pending = {}; static final List _cacheOrder = []; static const int _maxEntries = 32; @@ -43,6 +47,11 @@ class CoverPalette { static ColorScheme? peek(String source, Brightness brightness) => _cache[cacheKeyFor(source, brightness)]; + /// Average cover colour before Material's accent selection or tonal mapping. + /// Near-monochrome covers stay neutral instead of acquiring a seed hue. + static Color? sourceColor(String source, Brightness brightness) => + _sourceColors[cacheKeyFor(source, brightness)]; + static ColorScheme? _peekByKey(String key) => _cache[key]; /// Resolves the scheme for [source] (a network URL or a local file path). @@ -78,21 +87,25 @@ class CoverPalette { } try { + // Both samplers share this bounded decode through Flutter's image cache. + final sample = ResizeImage( + provider, + width: 112, + height: 112, + policy: ResizeImagePolicy.fit, + ); final scheme = await ColorScheme.fromImageProvider( - // Palette extraction only samples a small image. Bound decoding too, - // instead of decoding the original before Flutter downsamples it. - provider: ResizeImage( - provider, - width: 112, - height: 112, - policy: ResizeImagePolicy.fit, - ), + provider: sample, brightness: brightness, ); + final sourceColor = await _sampleSourceColor(sample); + if (sourceColor != null) _sourceColors[key] = sourceColor; _cache[key] = scheme; _cacheOrder.add(key); while (_cacheOrder.length > _maxEntries) { - _cache.remove(_cacheOrder.removeAt(0)); + final oldest = _cacheOrder.removeAt(0); + _cache.remove(oldest); + _sourceColors.remove(oldest); } return scheme; } catch (_) { @@ -101,6 +114,68 @@ class CoverPalette { return null; } } + + static Future _sampleSourceColor(ImageProvider provider) { + final result = Completer(); + final stream = provider.resolve(ImageConfiguration.empty); + late final ImageStreamListener listener; + listener = ImageStreamListener( + (info, _) async { + stream.removeListener(listener); + try { + final data = await info.image.toByteData( + format: ui.ImageByteFormat.rawStraightRgba, + ); + if (data == null) { + result.complete(null); + return; + } + var red = 0.0; + var green = 0.0; + var blue = 0.0; + var weight = 0.0; + for (var index = 0; index < data.lengthInBytes; index += 4) { + final alpha = data.getUint8(index + 3) / 255; + red += data.getUint8(index) * alpha; + green += data.getUint8(index + 1) * alpha; + blue += data.getUint8(index + 2) * alpha; + weight += alpha; + } + if (weight == 0) { + result.complete(null); + return; + } + red /= weight; + green /= weight; + blue /= weight; + // A very dark navy pixel can have high HSL saturation despite being + // visually black. Compare channel differences before using its hue. + final spread = + math.max(red, math.max(green, blue)) - + math.min(red, math.min(green, blue)); + if (spread < 16) { + final gray = (red * 0.2126 + green * 0.7152 + blue * 0.0722) + .round(); + result.complete(Color.fromARGB(255, gray, gray, gray)); + } else { + result.complete( + Color.fromARGB(255, red.round(), green.round(), blue.round()), + ); + } + } catch (_) { + result.complete(null); + } finally { + info.dispose(); + } + }, + onError: (Object error, StackTrace? stack) { + stream.removeListener(listener); + result.complete(null); + }, + ); + stream.addListener(listener); + return result.future; + } } /// Exposes the header's effective [ColorScheme] to descendants. diff --git a/lib/widgets/mornye_artwork_contrast.dart b/lib/widgets/mornye_artwork_contrast.dart index af453239..741216bf 100644 --- a/lib/widgets/mornye_artwork_contrast.dart +++ b/lib/widgets/mornye_artwork_contrast.dart @@ -130,8 +130,10 @@ class _MornyeArtworkContrastState extends State { } } if (count == 0) continue; - // Hysteresis avoids flicker when frames hover around the crossover. - final threshold = _colors[entry.key] == Colors.black ? 0.16 : 0.20; + // Prefer the player's white labels/icons while they retain 3:1 + // contrast. Pastel frames need not switch to black merely because + // black has a higher contrast ratio. Hysteresis prevents flicker. + final threshold = _colors[entry.key] == Colors.black ? 0.28 : 0.30; next[entry.key] = luminance / count > threshold ? Colors.black : Colors.white; diff --git a/lib/widgets/mornye_player_background.dart b/lib/widgets/mornye_player_background.dart index 6d00521a..c18df9e4 100644 --- a/lib/widgets/mornye_player_background.dart +++ b/lib/widgets/mornye_player_background.dart @@ -34,10 +34,20 @@ class MornyePlayerBackground extends StatelessWidget { ), child: CoverPaletteBuilder( imageSource: source, - builder: (context, scheme) { - final dominant = HSLColor.fromColor(scheme.primary); + builder: (context, _) { + final dominant = HSLColor.fromColor( + source == null + ? const Color(0xff808080) + : CoverPalette.sourceColor(source, Brightness.dark) ?? + const Color(0xff808080), + ); final muted = dominant.withSaturation( - dominant.saturation.clamp(0.0, 0.28), + dominant.saturation.clamp(0.0, 0.34), + ); + // Carry the cover's muted colour through the controls instead of + // fading bright artwork into a nearly black, flat surface. + final backdrop = dominant.withSaturation( + dominant.saturation.clamp(0.0, 0.12), ); final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero @@ -47,12 +57,27 @@ class MornyePlayerBackground extends StatelessWidget { curve: Curves.easeInOutCubic, decoration: BoxDecoration( gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - muted.withLightness(artwork == null ? 0.28 : 0.10).toColor(), - muted.withLightness(artwork == null ? 0.15 : 0.10).toColor(), - ], + begin: artwork == null + ? Alignment.topLeft + : Alignment.topCenter, + end: artwork == null + ? Alignment.bottomRight + : Alignment.bottomCenter, + stops: artwork == null ? null : const [0, 0.5, 1], + colors: artwork == null + ? [ + muted + .withLightness(0.28 + dominant.lightness * 0.24) + .toColor(), + muted + .withLightness(0.14 + dominant.lightness * 0.20) + .toColor(), + ] + : [ + backdrop.withLightness(0.62).toColor(), + backdrop.withLightness(0.54).toColor(), + backdrop.withLightness(0.34).toColor(), + ], ), ), child: Stack( diff --git a/test/cover_palette_test.dart b/test/cover_palette_test.dart index 934fba89..df8e3a1a 100644 --- a/test/cover_palette_test.dart +++ b/test/cover_palette_test.dart @@ -33,6 +33,10 @@ void main() { expect(identical(first, second), isTrue); final scheme = await first; expect(scheme, isNotNull); + expect( + CoverPalette.sourceColor(file.path, Brightness.light)?.toARGB32(), + Colors.blue.toARGB32(), + ); // The original is 512 x 256. Its cached decode fits inside 112 x 112 // while preserving the 2:1 aspect ratio, instead of retaining 512 KiB. expect(cache.currentSizeBytes, 112 * 56 * 4); @@ -47,4 +51,44 @@ void main() { } }); }); + + testWidgets('white and near-black artwork keeps a neutral source colour', ( + tester, + ) async { + await tester.runAsync(() async { + final directory = await Directory.systemTemp.createTemp('neutral-cover-'); + try { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(0, 0, 100, 100), + Paint()..color = Colors.white, + ); + canvas.drawRect( + const Rect.fromLTWH(50, 0, 50, 100), + Paint()..color = const Color(0xff02020f), + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(100, 100); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + picture.dispose(); + final file = File('${directory.path}/monochrome.png'); + await file.writeAsBytes(bytes!.buffer.asUint8List()); + + expect( + await CoverPalette.resolve(file.path, Brightness.dark), + isNotNull, + ); + final color = CoverPalette.sourceColor(file.path, Brightness.dark)!; + expect(color.r, color.g); + expect(color.g, color.b); + expect(color.r, inInclusiveRange(0.49, 0.52)); + } finally { + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + await directory.delete(recursive: true); + } + }); + }); } diff --git a/test/mornye_artwork_contrast_test.dart b/test/mornye_artwork_contrast_test.dart index 9806993b..b2404042 100644 --- a/test/mornye_artwork_contrast_test.dart +++ b/test/mornye_artwork_contrast_test.dart @@ -95,6 +95,26 @@ void main() { await tester.pump(); await sample(); expect(colors, {'header': Colors.black, 'controls': Colors.white}); + + // A muted pink frame still supports white player labels, including after + // a bright frame temporarily required black labels. + const pastel = Color(0xffa08398); + await tester.pumpWidget(app(pastel, pastel)); + await sample(); + expect(colors, {'header': Colors.white, 'controls': Colors.white}); + final pastelChanges = changes; + await tester.pumpWidget(app(const Color(0xff939393), pastel)); + await sample(); + expect(changes, pastelChanges); + await tester.pumpWidget(app(Colors.white, pastel)); + await sample(); + expect(colors, {'header': Colors.black, 'controls': Colors.white}); + await tester.pumpWidget(app(const Color(0xff939393), pastel)); + await sample(); + expect(colors, {'header': Colors.black, 'controls': Colors.white}); + await tester.pumpWidget(app(pastel, pastel)); + await sample(); + expect(colors, {'header': Colors.white, 'controls': Colors.white}); await tester.pumpWidget(const SizedBox()); }); }