From 6debfccba2adcbccf4d2481be119f4eb72b8b6b0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:18:39 +0700 Subject: [PATCH] fix(theme): retain dominant cover colors in player backgrounds --- lib/theme/cover_palette.dart | 122 +++++++++++++++------- lib/widgets/mornye_player_background.dart | 2 +- test/cover_palette_test.dart | 37 +++++++ 3 files changed, 124 insertions(+), 37 deletions(-) diff --git a/lib/theme/cover_palette.dart b/lib/theme/cover_palette.dart index 072cef04..2b741304 100644 --- a/lib/theme/cover_palette.dart +++ b/lib/theme/cover_palette.dart @@ -263,42 +263,7 @@ class CoverPalette { 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()), - ); - } + result.complete(data == null ? null : coverSourceColor(data)); } catch (_) { result.complete(null); } finally { @@ -315,6 +280,91 @@ class CoverPalette { } } +/// The colour a cover reads as, from straight RGBA pixels. +/// +/// A plain average turns a dark cover with one clear colour into gray: black +/// pixels outweigh a pale avocado strip covering a third of the art. When at +/// least [minimumCoverage] of the pixels share a visible hue, that hue's +/// average wins, weighted by colourfulness. Otherwise the average is used, +/// and near-monochrome art stays neutral instead of acquiring a seed hue. +@visibleForTesting +Color? coverSourceColor(ByteData data, {double minimumCoverage = 0.12}) { + const bins = 24; + final binWeight = List.filled(bins, 0); + final binRed = List.filled(bins, 0); + final binGreen = List.filled(bins, 0); + final binBlue = List.filled(bins, 0); + var red = 0.0; + var green = 0.0; + var blue = 0.0; + var weight = 0.0; + var chromatic = 0.0; + for (var index = 0; index + 3 < data.lengthInBytes; index += 4) { + final alpha = data.getUint8(index + 3) / 255; + if (alpha == 0) continue; + final r = data.getUint8(index); + final g = data.getUint8(index + 1); + final b = data.getUint8(index + 2); + red += r * alpha; + green += g * alpha; + blue += b * alpha; + weight += alpha; + final high = math.max(r, math.max(g, b)); + final chroma = high - math.min(r, math.min(g, b)); + // Dark pixels have unstable hues, as in the navy-black case below. + if (chroma < 20 || high < 48) continue; + chromatic += alpha; + final hue = HSVColor.fromColor(Color.fromARGB(255, r, g, b)).hue; + final bin = (hue / (360 / bins)).floor() % bins; + final w = alpha * chroma; + binWeight[bin] += w; + binRed[bin] += r * w; + binGreen[bin] += g * w; + binBlue[bin] += b * w; + } + if (weight == 0) return null; + if (chromatic / weight >= minimumCoverage) { + var best = 0; + var bestWeight = -1.0; + for (var bin = 0; bin < bins; bin++) { + final total = + binWeight[(bin + bins - 1) % bins] + + binWeight[bin] + + binWeight[(bin + 1) % bins]; + if (total > bestWeight) { + best = bin; + bestWeight = total; + } + } + var r = 0.0, g = 0.0, b = 0.0, w = 0.0; + for (final bin in [(best + bins - 1) % bins, best, (best + 1) % bins]) { + r += binRed[bin]; + g += binGreen[bin]; + b += binBlue[bin]; + w += binWeight[bin]; + } + return Color.fromARGB( + 255, + (r / w).round(), + (g / w).round(), + (b / w).round(), + ); + } + 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(); + return Color.fromARGB(255, gray, gray, gray); + } + return Color.fromARGB(255, red.round(), green.round(), blue.round()); +} + /// Exposes the header's effective [ColorScheme] to descendants. /// /// Header sub-widgets (meta rows, circle buttons, play actions) live in the diff --git a/lib/widgets/mornye_player_background.dart b/lib/widgets/mornye_player_background.dart index c18df9e4..a31919c2 100644 --- a/lib/widgets/mornye_player_background.dart +++ b/lib/widgets/mornye_player_background.dart @@ -47,7 +47,7 @@ class MornyePlayerBackground extends StatelessWidget { // 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), + dominant.saturation.clamp(0.0, 0.2), ); final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero diff --git a/test/cover_palette_test.dart b/test/cover_palette_test.dart index ff1835ee..44bdb797 100644 --- a/test/cover_palette_test.dart +++ b/test/cover_palette_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -6,6 +7,42 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/theme/cover_palette.dart'; void main() { + ByteData pixels(List<(Color, int)> runs) { + final data = ByteData(runs.fold(0, (sum, run) => sum + run.$2) * 4); + var offset = 0; + for (final (color, count) in runs) { + for (var i = 0; i < count; i++, offset += 4) { + data + ..setUint8(offset, (color.r * 255).round()) + ..setUint8(offset + 1, (color.g * 255).round()) + ..setUint8(offset + 2, (color.b * 255).round()) + ..setUint8(offset + 3, 255); + } + } + return data; + } + + test('a dark cover with one clear colour keeps that colour, not gray', () { + // Mostly black art with a pale avocado strip, as in issue #658. + final color = coverSourceColor( + pixels([ + (const Color(0xff050505), 65), + (const Color(0xffd4ddac), 30), + (const Color(0xff6a6e72), 5), + ]), + )!; + final hsl = HSLColor.fromColor(color); + expect(hsl.hue, inInclusiveRange(60, 80)); + expect(hsl.saturation, greaterThan(0.3)); + }); + + test('a small colourful detail does not tint a neutral cover', () { + final color = coverSourceColor( + pixels([(const Color(0xff808080), 95), (const Color(0xffff0000), 5)]), + )!; + expect(HSLColor.fromColor(color).saturation, lessThan(0.1)); + }); + testWidgets( 'same-path cover replacement refreshes palette and decoded image', (tester) async {