Cover-Farbe, Teilen aus YouTube, Android Auto, geschärfte Typografie
P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus YouTube trägt die Adresse in den Downloader ein, Android Auto blättert durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../services/cover_cache.dart';
|
||||
import '../shared/theme.dart';
|
||||
|
||||
/// Wie viele der acht Bits je Farbkanal beim Einsortieren wegfallen —
|
||||
/// es bleiben also 5, das sind 32 Stufen je Kanal.
|
||||
///
|
||||
/// Ohne dieses Zusammenfassen hätte fast jeder Bildpunkt seine eigene Farbe
|
||||
/// und „die häufigste" wäre bedeutungslos.
|
||||
const _verworfeneBits = 3;
|
||||
|
||||
/// Bildpunkte unter dieser Deckkraft zählen nicht mit.
|
||||
const _mindestDeckkraft = 128;
|
||||
|
||||
/// Die prägende Farbe eines Coverbilds, aus rohen RGBA-Daten.
|
||||
///
|
||||
/// Nicht einfach die häufigste: Cover sind oft überwiegend dunkel oder grau,
|
||||
/// und dann sähe der Player für jeden Titel gleich aus. Häufigkeit **und**
|
||||
/// Farbigkeit entscheiden zusammen.
|
||||
///
|
||||
/// Rein und ohne Bild-Dekodierung — der Teil, der schiefgehen kann, lässt
|
||||
/// sich so für sich prüfen. Fällt auf [MeloTheme.red] zurück, wenn nichts
|
||||
/// Farbiges übrig bleibt (Schwarz-Weiß-Cover, leere Daten).
|
||||
Color dominanteFarbe(Uint8List rgba) {
|
||||
final anzahl = <int, int>{};
|
||||
// Vier Bytes je Bildpunkt; abgeschnittene Daten am Ende werden übergangen.
|
||||
for (var i = 0; i + 3 < rgba.length; i += 4) {
|
||||
if (rgba[i + 3] < _mindestDeckkraft) continue;
|
||||
final topf = (rgba[i] >> _verworfeneBits) << 16 |
|
||||
(rgba[i + 1] >> _verworfeneBits) << 8 |
|
||||
(rgba[i + 2] >> _verworfeneBits);
|
||||
anzahl[topf] = (anzahl[topf] ?? 0) + 1;
|
||||
}
|
||||
if (anzahl.isEmpty) return MeloTheme.red;
|
||||
|
||||
Color? beste;
|
||||
var bestesGewicht = 0.0;
|
||||
anzahl.forEach((topf, haeufigkeit) {
|
||||
final farbe = _ausTopf(topf);
|
||||
final hsv = HSVColor.fromColor(farbe);
|
||||
// Fast-Schwarz und Fast-Weiß tragen keine Aussage über den Charakter
|
||||
// eines Covers und fallen ganz heraus.
|
||||
if (hsv.value < 0.15 || (hsv.saturation < 0.15 && hsv.value > 0.9)) return;
|
||||
// Farbigkeit zählt doppelt: ein kleiner kräftiger Fleck sagt mehr über
|
||||
// ein Cover aus als eine große graue Fläche.
|
||||
final gewicht = haeufigkeit * (0.2 + hsv.saturation * 2);
|
||||
if (gewicht > bestesGewicht) {
|
||||
bestesGewicht = gewicht;
|
||||
beste = farbe;
|
||||
}
|
||||
});
|
||||
return beste ?? MeloTheme.red;
|
||||
}
|
||||
|
||||
Color _ausTopf(int topf) {
|
||||
// Die Mitte des Topfes statt seiner unteren Ecke — sonst wirkt jede Farbe
|
||||
// etwas zu dunkel.
|
||||
const mitte = 1 << (_verworfeneBits - 1);
|
||||
return Color.fromARGB(
|
||||
255,
|
||||
(((topf >> 16) & 0x1F) << _verworfeneBits) + mitte,
|
||||
(((topf >> 8) & 0x1F) << _verworfeneBits) + mitte,
|
||||
((topf & 0x1F) << _verworfeneBits) + mitte,
|
||||
);
|
||||
}
|
||||
|
||||
/// [farbe] so weit abgedunkelt, dass weiße Schrift darauf lesbar bleibt.
|
||||
///
|
||||
/// Ein knalliges Gelb als Hintergrund würde die Titelzeile verschlucken; die
|
||||
/// Markenfarben der App sind darauf ausgelegt, auf Dunklem zu stehen.
|
||||
Color lesbarAuf(Color farbe) {
|
||||
var hsl = HSLColor.fromColor(farbe);
|
||||
// Sättigung leicht zurücknehmen: ein voll gesättigter Hintergrund flimmert
|
||||
// hinter Text.
|
||||
hsl = hsl.withSaturation((hsl.saturation * 0.8).clamp(0.0, 1.0));
|
||||
var ergebnis = hsl.toColor();
|
||||
var versuche = 0;
|
||||
while (ergebnis.computeLuminance() > 0.12 && versuche < 12) {
|
||||
hsl = hsl.withLightness((hsl.lightness * 0.85).clamp(0.0, 1.0));
|
||||
ergebnis = hsl.toColor();
|
||||
versuche++;
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/// Holt die prägende Farbe eines Coverbilds.
|
||||
///
|
||||
/// Das Bild wird klein dekodiert (32 × 32): für die Farbwahl reicht das
|
||||
/// vollkommen, und ein volles Albumcover in Originalgröße zu dekodieren
|
||||
/// würde bei jedem Titelwechsel spürbar bremsen.
|
||||
Future<Color> farbeAusBild(ImageProvider bild) async {
|
||||
try {
|
||||
final daten = await _rohDaten(bild);
|
||||
return daten == null ? MeloTheme.red : dominanteFarbe(daten);
|
||||
} catch (e) {
|
||||
debugPrint('Cover-Farbe nicht ermittelbar: $e');
|
||||
return MeloTheme.red;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List?> _rohDaten(ImageProvider bild) async {
|
||||
final vervollstaendigt = Completer<ui.Image>();
|
||||
final strom = bild.resolve(const ImageConfiguration(
|
||||
size: Size(32, 32),
|
||||
));
|
||||
late final ImageStreamListener zuhoerer;
|
||||
zuhoerer = ImageStreamListener(
|
||||
(info, _) {
|
||||
if (!vervollstaendigt.isCompleted) vervollstaendigt.complete(info.image);
|
||||
strom.removeListener(zuhoerer);
|
||||
},
|
||||
onError: (fehler, spur) {
|
||||
if (!vervollstaendigt.isCompleted) vervollstaendigt.completeError(fehler);
|
||||
strom.removeListener(zuhoerer);
|
||||
},
|
||||
);
|
||||
strom.addListener(zuhoerer);
|
||||
final bilddaten = await vervollstaendigt.future;
|
||||
final bytes =
|
||||
await bilddaten.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
return bytes?.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// Die Bildquelle hinter einer Cover-Adresse.
|
||||
///
|
||||
/// Server-Cover kommen über den Ordner (siehe [CoverCache]) und nicht direkt
|
||||
/// aus dem Netz: sonst würde die Farbwahl bei jedem Titelwechsel ein zweites
|
||||
/// Mal dasselbe Bild laden.
|
||||
Future<ImageProvider?> coverBildQuelle(Uri adresse) async {
|
||||
if (adresse.scheme == 'file') {
|
||||
final datei = File(adresse.toFilePath());
|
||||
return await datei.exists() ? FileImage(datei) : null;
|
||||
}
|
||||
if (adresse.scheme == 'http' || adresse.scheme == 'https') {
|
||||
final datei = await CoverCache.hole(adresse);
|
||||
return datei == null ? null : FileImage(datei);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user