fix(theme): retain dominant cover colors in player backgrounds

This commit is contained in:
zarzet committed 2026-10-17 16:18:39 +07:00
1 parent 161a216bea
commit 6debfccba2
3 files changed
+124 -37

No files matched your search

+86 -36
View File
@@ -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<double>.filled(bins, 0);
final binRed = List<double>.filled(bins, 0);
final binGreen = List<double>.filled(bins, 0);
final binBlue = List<double>.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
+1 -1
View File
@@ -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
+37
View File
@@ -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 {