fix(artwork): preserve cover colors and readable player contrast

This commit is contained in:
zarzet
2026-09-25 01:20:11 +07:00
parent a470ed8d7c
commit 2adcdf0768
5 changed files with 186 additions and 20 deletions
+84 -9
View File
@@ -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<String, ColorScheme> _cache = <String, ColorScheme>{};
static final Map<String, Color> _sourceColors = {};
static final Map<String, Future<ColorScheme?>> _pending = {};
static final List<String> _cacheOrder = <String>[];
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<Color?> _sampleSourceColor(ImageProvider provider) {
final result = Completer<Color?>();
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.
+4 -2
View File
@@ -130,8 +130,10 @@ class _MornyeArtworkContrastState extends State<MornyeArtworkContrast> {
}
}
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;
+34 -9
View File
@@ -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(
+44
View File
@@ -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);
}
});
});
}
+20
View File
@@ -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());
});
}