Album-Ansicht, mitlaufender Songtext, Cover-Cache, Server-Favoriten

Die letzten vier Punkte aus dem Review-Bericht.

G2 — Server-Bereich: Ein Tipp auf ein Album spielte sofort alles ab;
hineinsehen ging nicht. Jetzt Album- und Kuenstler-Ansicht mit Cover,
Kuenstler, Titelzahl, Gesamtdauer, "Abspielen"/"Zufaellig" und der
Titelliste; Antippen startet an dieser Stelle. Dazu "Mehr Alben laden" —
die Liste hoerte bei 50 auf, ohne dass etwas darauf hinwies.
NICHT umgesetzt: den Server-Bereich aus dem Download-Tab loesen (das
widerspricht Commit 34d6d8e, "Auf Wunsch von Dustin") und eine
Server-Suche (die Suche bleibt ausdruecklich rein lokal).

G8 — Songtext: Traegt der Text Zeitmarken, laeuft er mit; die aktive Zeile
ist hervorgehoben, Antippen springt an die Stelle. Ohne Zeitmarken bleibt
es eine Textwand — die App schaut nach, statt zu raten.

G10 — Cover bleiben liegen. Eigener Ordner, einmal geholt. Der Schluessel
haengt an der Album-Kennung, nicht an der Adresse: die traegt bei jedem
App-Start ein neues Token, und der Ordner waere sonst gewachsen, ohne je
einen Treffer zu liefern. Keine neue Abhaengigkeit.

G3 — Das Herz ist bei Server-Titeln nicht mehr ausgegraut, sondern
favorisiert am Server (star/unstar) — dort, wo der Favoriten-Abgleich sie
auch wieder abholt. Offen bleibt der schwere Teil: Wiedergabezaehler,
Historie und Kategorien braeuchten fuer Server-Titel eine Zeile in der
Bibliothek.

Aus dem Code-Review nachgebessert (sieben Punkte): das Herz behielt beim
Titelwechsel seinen Zustand; recycelte Listenzeilen zeigten kurz das Cover
der vorherigen; der neue Songtext bekam bei Server-Titeln die
Abspiel-Adresse statt der Server-Kennung und fand deshalb nie einen Text;
ein einmal fehlgeschlagenes Cover blieb die ganze Sitzung grau; "Server
nicht erreichbar" sah aus wie "Album hat keine Titel", womit der
Wiederholen-Knopf unerreichbar war; die Kuenstler-Ansicht waere immer leer
geblieben, weil getArtist die Titel gar nicht mitliefert; und zwei
gleichzeitige Cover-Abrufe konnten ein zerschnittenes Bild hinterlassen.

Selbst gefunden: das Haekchen "schon geladen" fragte pro Zeile die Platte
ab, bei jedem Positionswechsel neu. Jetzt einmal je Bildschirm.

457 Tests gruen (vorher 433), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
Hermes (Server)
2026-08-23 21:53:31 +02:00
co-authored by Claude Opus 5
parent b42e045e67
commit 0d76d8cc58
16 changed files with 1194 additions and 142 deletions
+63
View File
@@ -0,0 +1,63 @@
/// Eine Zeile eines mitlaufenden Songtexts.
class LyricZeile {
const LyricZeile({required this.zeit, required this.text});
final Duration zeit;
final String text;
}
/// Zeitmarke am Zeilenanfang: `[mm:ss]`, `[mm:ss.x]`, `[mm:ss.xx]` oder
/// `[mm:ss.xxx]`. Mehrere Marken vor derselben Zeile sind erlaubt und im
/// Refrain üblich.
final _marke = RegExp(r'\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]');
/// Ob [text] ein LRC-Text ist — also überhaupt Zeitmarken trägt.
///
/// Der Songtext kann aus dem Tag der Datei oder vom Server kommen, und beides
/// liefert mal die eine, mal die andere Form. Statt zu raten, wird geschaut.
bool istSynchron(String text) => _marke.hasMatch(text);
/// Zerlegt einen LRC-Text in Zeilen mit Zeitmarke, nach Zeit sortiert.
///
/// Gibt eine leere Liste zurück, wenn keine Marken vorkommen — dann ist es
/// eine gewöhnliche Textwand und wird auch so angezeigt.
List<LyricZeile> parseLrc(String text) {
final zeilen = <LyricZeile>[];
for (final roh in text.split('\n')) {
final marken = _marke.allMatches(roh).toList();
if (marken.isEmpty) continue;
// Alles hinter der letzten Marke ist der Text. Kopfzeilen wie `[ar:Neoni]`
// passen nicht auf das Muster und fallen schon oben heraus.
final inhalt = roh.substring(marken.last.end).trim();
for (final marke in marken) {
zeilen.add(LyricZeile(zeit: _zeitAus(marke), text: inhalt));
}
}
zeilen.sort((a, b) => a.zeit.compareTo(b.zeit));
return zeilen;
}
Duration _zeitAus(RegExpMatch marke) {
final minuten = int.parse(marke.group(1)!);
final sekunden = int.parse(marke.group(2)!);
final bruch = marke.group(3);
// `.5` sind fünf Zehntel, `.05` fünf Hundertstel, `.050` fünfzig
// Tausendstel — die Stellenzahl entscheidet, nicht der Wert.
final millis = bruch == null
? 0
: (int.parse(bruch) * (bruch.length == 1 ? 100 : bruch.length == 2 ? 10 : 1));
return Duration(minutes: minuten, seconds: sekunden, milliseconds: millis);
}
/// Index der Zeile, die bei [position] gilt — die letzte, deren Marke bereits
/// erreicht ist. Vor der ersten Marke gilt die erste Zeile, damit nie eine
/// leere Anzeige entsteht.
int? aktiveZeile(List<LyricZeile> zeilen, Duration position) {
if (zeilen.isEmpty) return null;
var index = 0;
for (var i = 0; i < zeilen.length; i++) {
if (zeilen[i].zeit > position) break;
index = i;
}
return index;
}