import 'database.dart'; /// Trennzeichen, mit denen Genre-Tags mehrere Werte in einem Feld ablegen. final _separators = RegExp(r'[;,|/\n]'); /// Zerlegt einen Genre-Tag in einzelne Kategorien. Doppelte Einträge fallen /// weg (ohne Rücksicht auf Groß-/Kleinschreibung), die erste Schreibweise /// gewinnt. List parseCategories(String? raw) { if (raw == null) return const []; return _dedupe(raw.split(_separators).map((e) => e.trim())); } /// Wie [parseCategories], aber für Tag-Formate, die bereits mehrere Felder /// liefern (z. B. ID3v2 mit mehreren GENRE-Frames). List parseCategoryList(Iterable raw) { return _dedupe(raw.expand((e) => e.split(_separators)).map((e) => e.trim())); } /// Kategorien eines Titels aus seinen Tags. /// /// Der **Album-Titel zählt als Kategorie** und steht vorn: viele Sammlungen /// sind über das Album-Feld sortiert, nicht über das Genre — Alben und /// Kategorien sind in Melo dieselbe Sache. Die vordere Position ist wichtig, /// weil die erste Kategorie das Coverbild bestimmt. List kategorienAusTags({ String? album, Iterable genres = const [], }) { final ausGenre = parseCategoryList(genres); final albumName = album?.trim() ?? ''; if (albumName.isEmpty) return ausGenre; return _dedupe([albumName, ...ausGenre]); } List _dedupe(Iterable candidates) { final seen = {}; final result = []; for (final candidate in candidates) { if (candidate.isEmpty) continue; if (seen.add(candidate.toLowerCase())) result.add(candidate); } return result; } /// Name für Titel, denen keine Kategorie zugeordnet ist. const ohneKategorie = 'Ohne Kategorie'; /// Bündelt [songs] nach Kategorie, alphabetisch nach Kategoriename. Ein Titel /// erscheint unter **jeder** seiner Kategorien; Titel ganz ohne Kategorie /// sammeln sich unter [ohneKategorie] (immer am Ende, damit die benannten /// Kategorien oben stehen). Map> groupByCategory( List songs, Map> categoriesBySong, ) { final grouped = >{}; final ohne = []; for (final song in songs) { final names = categoriesBySong[song.id] ?? const []; if (names.isEmpty) { ohne.add(song); continue; } for (final name in names) { grouped.putIfAbsent(name, () => []).add(song); } } final keys = grouped.keys.toList() ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); return { for (final key in keys) key: grouped[key]!, if (ohne.isNotEmpty) ohneKategorie: ohne, }; } /// Ermittelt je Kategorie ein Coverbild: das erste vorhandene Cover unter den /// Songs der Kategorie, nach Titel geordnet — damit dieselbe Bibliothek immer /// dasselbe Bild ergibt. Kategorien ohne jedes Cover fehlen im Ergebnis. Map categoryCovers( List songs, Map> categoriesBySong, ) { final ordered = [...songs] ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); final covers = {}; for (final song in ordered) { final cover = song.coverPath; if (cover == null) continue; for (final category in categoriesBySong[song.id] ?? const []) { covers.putIfAbsent(category, () => cover); } } return covers; } /// Das anzuzeigende Cover eines Songs: bei aktiver Einstellung "Gleiche /// Kategorie = gleiches Coverbild" das Bild seiner **ersten** Kategorie, /// sonst (oder wenn die Kategorie kein Bild hat) sein eigenes. String? effectiveCover( Song song, List categories, Map covers, { required bool groupByCategory, }) { if (!groupByCategory || categories.isEmpty) return song.coverPath; return covers[categories.first] ?? song.coverPath; }