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:
Hermes (Server)
2026-08-25 09:09:27 +02:00
parent 490e73ce41
commit 60c57577a3
22 changed files with 1040 additions and 40 deletions
+99
View File
@@ -4,9 +4,12 @@ import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import '../library/database.dart';
import '../library/song_grouping.dart';
import '../library/song_media.dart';
import '../services/cache_manager.dart';
import '../services/download_speicher.dart';
import '../services/navidrome_service.dart';
import 'auto_browse.dart';
import 'headphone_autoplay.dart';
import 'replay_gain.dart';
import 'sleep_timer.dart';
@@ -455,6 +458,102 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
await _player.moveAudioSource(oldIndex, newIndex);
}
// === Blättern im Auto und beim Sprachassistenten ===
//
// Der MediaBrowserService war im Manifest zwar angemeldet, lieferte aber
// nie Inhalte: ohne diese Methoden blieb die Bibliothek dort leer.
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
if (parentMediaId == AudioService.browsableRootId) {
return [for (final art in wurzelArten) _ordner(art)];
}
final knoten = knotenAus(parentMediaId);
if (knoten == null) return const [];
switch (knoten.art) {
case AutoArt.favoriten:
return _alsMedien(await db.watchFavorites().first);
case AutoArt.zuletzt:
return _alsMedien(await db.watchRecent(limit: 100).first);
case AutoArt.kategorien:
final namen = await db.allCategoryNames();
return [
for (final name in namen) _ordner(AutoArt.kategorie, name),
];
case AutoArt.kuenstlerListe:
final songs = await db.watchSongs().first;
return [
for (final name in groupByArtist(songs).keys)
_ordner(AutoArt.kuenstler, name),
];
case AutoArt.kategorie:
return _alsMedien(await _titelDerKategorie(knoten.name));
case AutoArt.kuenstler:
final songs = await db.watchSongs().first;
return _alsMedien(groupByArtist(songs)[knoten.name] ?? const []);
case AutoArt.titel:
return const [];
}
}
@override
Future<MediaItem?> getMediaItem(String mediaId) async {
final knoten = knotenAus(mediaId);
if (knoten?.art != AutoArt.titel || knoten?.name == null) return null;
final song = await db.songById(knoten!.name!);
return song == null ? null : songToMediaItem(song);
}
/// Wird aufgerufen, wenn im Auto ein Titel angetippt wird. Es läuft nicht
/// nur dieser eine, sondern der Ordner ab dieser Stelle — sonst hörte die
/// Musik nach einem Lied auf.
@override
Future<void> playFromMediaId(String mediaId,
[Map<String, dynamic>? extras]) async {
final knoten = knotenAus(mediaId);
if (knoten == null) return;
if (knoten.art == AutoArt.titel && knoten.name != null) {
final ordner = extras?['ordner'] as String?;
final geschwister =
ordner == null ? const <MediaItem>[] : await getChildren(ordner);
final index =
geschwister.indexWhere((m) => songIdOf(m) == knoten.name);
if (index != -1) {
await loadPlaylist(geschwister, startIndex: index);
return;
}
final song = await db.songById(knoten.name!);
if (song != null) await loadPlaylist([songToMediaItem(song)]);
return;
}
// Ein Ordner: alles darin von vorn.
final inhalt = await getChildren(mediaId);
final titel = [for (final m in inhalt) if (songIdOf(m) != null) m];
if (titel.isNotEmpty) await loadPlaylist(titel);
}
Future<List<Song>> _titelDerKategorie(String? name) async {
if (name == null) return const [];
final songs = await db.watchSongs().first;
final zuordnung = await db.watchCategoriesBySong().first;
return [
for (final song in songs)
if (zuordnung[song.id]?.contains(name) ?? false) song,
];
}
MediaItem _ordner(AutoArt art, [String? name]) => MediaItem(
id: knotenId(art, name),
title: name ?? art.anzeige,
playable: false,
);
List<MediaItem> _alsMedien(List<Song> songs) => [
for (final song in songs)
songToMediaItem(song).copyWith(id: knotenId(AutoArt.titel, song.id)),
];
@override
Future<void> play() => _player.play();
+59
View File
@@ -0,0 +1,59 @@
/// Was für ein Knoten im Browse-Baum von Android Auto und dem
/// Sprachassistenten gemeint ist.
enum AutoArt {
favoriten('Favoriten'),
zuletzt('Zuletzt hinzugefügt'),
kategorien('Kategorien'),
kuenstlerListe('Künstler'),
kategorie(''),
kuenstler(''),
titel('');
const AutoArt(this.anzeige);
/// Beschriftung eines Ordners der obersten Ebene.
final String anzeige;
}
/// Die Ordner, die im Auto auf der obersten Ebene stehen — dieselben Wege,
/// die auch die App anbietet.
const wurzelArten = [
AutoArt.favoriten,
AutoArt.zuletzt,
AutoArt.kategorien,
AutoArt.kuenstlerListe,
];
/// Ein aufgelöster Knoten: seine Art und, wo nötig, sein Name.
class AutoKnoten {
const AutoKnoten(this.art, this.name);
final AutoArt art;
/// Kategorie- oder Künstlername, bei einem Titel dessen UUID.
final String? name;
}
const _praefix = 'melo:';
/// Adresse eines Knotens.
///
/// Der Name steht **hinter** dem ersten Doppelpunkt nach der Art und wird
/// nicht weiter zerlegt: „Intro: Der Anfang" ist ein normaler Albumtitel,
/// und Schrägstriche kommen in Künstlernamen wie „AC/DC" vor. Würde hier
/// gesplittet, verlöre man beides.
String knotenId(AutoArt art, [String? name]) =>
name == null ? '$_praefix${art.name}' : '$_praefix${art.name}:$name';
/// Löst eine Adresse wieder auf, oder `null`, wenn sie nicht von uns stammt.
AutoKnoten? knotenAus(String mediaId) {
if (!mediaId.startsWith(_praefix)) return null;
final rest = mediaId.substring(_praefix.length);
final trenner = rest.indexOf(':');
final artName = trenner == -1 ? rest : rest.substring(0, trenner);
final name = trenner == -1 ? null : rest.substring(trenner + 1);
for (final art in AutoArt.values) {
if (art.name == artName) return AutoKnoten(art, name);
}
return null;
}
+146
View File
@@ -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;
}
+82 -1
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -9,6 +11,7 @@ import '../shared/favorite_button.dart';
import '../shared/server_favorite_button.dart';
import '../shared/theme.dart';
import 'audio_handler.dart';
import 'cover_farbe.dart';
import 'lrc.dart';
import 'queue_screen.dart';
@@ -27,7 +30,12 @@ class NowPlayingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return Scaffold(
return _CoverGrund(
handler: handler,
child: Scaffold(
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig.
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
actions: [
@@ -127,6 +135,79 @@ class NowPlayingScreen extends StatelessWidget {
},
),
),
),
);
}
}
/// Färbt den Grund des Players nach dem Coverbild ein.
///
/// Der Player sah für jeden Titel gleich aus — schwarz. Ein Verlauf aus der
/// prägenden Farbe des Covers gibt jedem Album sein eigenes Gesicht, ohne
/// dass die Schrift darunter leidet: die Farbe wird vorher so weit
/// abgedunkelt, dass Weiß darauf lesbar bleibt (siehe [lesbarAuf]).
class _CoverGrund extends StatefulWidget {
const _CoverGrund({required this.handler, required this.child});
final MeloAudioHandler handler;
final Widget child;
@override
State<_CoverGrund> createState() => _CoverGrundState();
}
class _CoverGrundState extends State<_CoverGrund> {
Color _farbe = MeloTheme.black;
Uri? _zuletzt;
StreamSubscription<MediaItem?>? _abo;
@override
void initState() {
super.initState();
_abo = widget.handler.mediaItem.listen(_uebernimm);
}
@override
void dispose() {
_abo?.cancel();
super.dispose();
}
Future<void> _uebernimm(MediaItem? item) async {
final art = item?.artUri;
// Nur bei einem echten Bildwechsel neu rechnen: `mediaItem` meldet auch
// nachgetragene Dauern, und jedes Mal ein Bild zu dekodieren wäre Unfug.
if (art == _zuletzt) return;
_zuletzt = art;
if (art == null) {
if (mounted) setState(() => _farbe = MeloTheme.black);
return;
}
final bild = await coverBildQuelle(art);
if (bild == null) {
if (mounted) setState(() => _farbe = MeloTheme.black);
return;
}
final farbe = lesbarAuf(await farbeAusBild(bild));
if (mounted && art == _zuletzt) setState(() => _farbe = farbe);
}
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: MeloMotion.ruhig(context, MeloMotion.normal),
curve: MeloMotion.curve,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
// Nach unten in Schwarz auslaufen: dort sitzen Fortschritt und
// Bedienung, und die stehen auf dem gewohnten Grund am ruhigsten.
colors: [_farbe, MeloTheme.black],
stops: const [0.0, 0.75],
),
),
child: widget.child,
);
}
}