mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
fix(theme): retain dominant cover colors in player backgrounds
This commit is contained in:
1 parent
161a216bea
commit
6debfccba2
3 files changed
+124
-37
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user