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 = {}; // 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 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 _rohDaten(ImageProvider bild) async { final vervollstaendigt = Completer(); 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 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; }