mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
fix(artwork): preserve cover colors and readable player contrast
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user