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
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/player/lrc.dart';
/// Der Songtext war eine Textwand ohne Bezug zur Wiedergabe. Diese Regeln
/// machen aus einem LRC-Text mitlaufende Zeilen.
void main() {
group('parseLrc', () {
test('liest Zeitmarke und Text', () {
final zeilen = parseLrc('[00:12.50]Erste Zeile');
expect(zeilen, hasLength(1));
expect(zeilen.single.zeit, const Duration(seconds: 12, milliseconds: 500));
expect(zeilen.single.text, 'Erste Zeile');
});
test('versteht Hundertstel und Tausendstel', () {
expect(parseLrc('[00:01.5]A').single.zeit,
const Duration(seconds: 1, milliseconds: 500));
expect(parseLrc('[00:01.05]A').single.zeit,
const Duration(seconds: 1, milliseconds: 50));
expect(parseLrc('[00:01.050]A').single.zeit,
const Duration(seconds: 1, milliseconds: 50));
});
test('kommt auch ohne Bruchteile aus', () {
expect(parseLrc('[01:30]A').single.zeit, const Duration(minutes: 1, seconds: 30));
});
test('sortiert nach Zeit, auch wenn die Datei es nicht tut', () {
final zeilen = parseLrc('[00:20.00]Zwei\n[00:10.00]Eins');
expect(zeilen.map((z) => z.text), ['Eins', 'Zwei']);
});
test('eine Zeile mit mehreren Marken erscheint mehrfach', () {
// Im Refrain üblich: dieselbe Zeile für mehrere Stellen.
final zeilen = parseLrc('[00:10.00][01:10.00]Refrain');
expect(zeilen, hasLength(2));
expect(zeilen.first.zeit, const Duration(seconds: 10));
expect(zeilen.last.zeit, const Duration(minutes: 1, seconds: 10));
});
test('überspringt Kopfzeilen wie [ar:] und [ti:]', () {
final zeilen = parseLrc('[ar:Neoni]\n[ti:Roses]\n[00:05.00]Los');
expect(zeilen.map((z) => z.text), ['Los']);
});
test('leere Textzeilen bleiben erhalten — sie sind Pausen', () {
final zeilen = parseLrc('[00:05.00]A\n[00:08.00]\n[00:12.00]B');
expect(zeilen, hasLength(3));
expect(zeilen[1].text, isEmpty);
});
test('gewöhnlicher Text ohne Marken ergibt nichts', () {
expect(parseLrc('Nur eine Textwand\nohne Zeitangaben'), isEmpty);
});
test('leerer Text ergibt nichts', () {
expect(parseLrc(''), isEmpty);
});
});
group('istSynchron', () {
test('erkennt einen LRC-Text', () {
expect(istSynchron('[00:12.50]Zeile'), isTrue);
});
test('eine Textwand ist nicht synchron', () {
expect(istSynchron('Roses are red\nViolets are blue'), isFalse);
});
});
group('aktiveZeile', () {
final zeilen = parseLrc('[00:00.00]Null\n[00:10.00]Zehn\n[00:20.00]Zwanzig');
test('vor der ersten Marke gilt die erste Zeile', () {
expect(aktiveZeile(zeilen, Duration.zero), 0);
});
test('genau auf einer Marke gilt diese Zeile', () {
expect(aktiveZeile(zeilen, const Duration(seconds: 10)), 1);
});
test('zwischen zwei Marken gilt die frühere', () {
expect(aktiveZeile(zeilen, const Duration(seconds: 15)), 1);
});
test('nach der letzten Marke gilt die letzte Zeile', () {
expect(aktiveZeile(zeilen, const Duration(minutes: 5)), 2);
});
test('ohne Zeilen gibt es keine aktive', () {
expect(aktiveZeile(const [], Duration.zero), isNull);
});
});
}
+52
View File
@@ -0,0 +1,52 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/services/navidrome_service.dart';
/// Der Kopf der Album-Detailansicht nennt den Künstler. Der stand bisher in
/// der Antwort des Servers, wurde aber nicht ausgelesen.
void main() {
Map<String, dynamic> antwort(List<Map<String, dynamic>> alben) =>
NavidromeService.parseSubsonic(jsonEncode({
'subsonic-response': {
'status': 'ok',
'albumList2': {'album': alben},
}
}));
test('liest den Album-Künstler mit', () {
final alben = NavidromeService.parseAlben(antwort([
{'id': '1', 'name': 'Trailerpark', 'artist': 'Neoni', 'songCount': 12},
]));
expect(alben.single.kuenstler, 'Neoni');
expect(alben.single.name, 'Trailerpark');
expect(alben.single.songCount, 12);
});
test('ein Album ohne Künstler-Feld bleibt lesbar', () {
final alben = NavidromeService.parseAlben(antwort([
{'id': '1', 'name': 'Ohne'},
]));
expect(alben.single.kuenstler, isNull);
expect(alben.single.name, 'Ohne');
});
group('parseSubsonic bei Fehlern', () {
test('getSongs und getArtistSongs duerfen nicht "leer" melden', () {
// Beide verschluckten Fehler und lieferten []. Damit sah "Server nicht
// erreichbar" genauso aus wie "Album hat keine Titel", und der
// Wiederholen-Knopf erschien nie.
expect(
() => NavidromeService.parseSubsonic(jsonEncode({
'subsonic-response': {
'status': 'failed',
'error': {'message': 'Wrong username or password'},
}
})),
throwsA(isA<NavidromeException>()),
);
});
});
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/services/cover_cache.dart';
/// Der Schlüssel entscheidet, ob der Cover-Ordner je einen Treffer liefert.
void main() {
Uri cover(String id, {String token = 'abc', String salt = 'xyz'}) =>
Uri.parse('https://musik.example/rest/getCoverArt.view'
'?id=$id&u=dustin&t=$token&s=$salt&v=1.16.1&c=Melo&f=json');
group('dateiName', () {
test('dasselbe Cover ergibt denselben Namen', () {
expect(CoverCache.dateiName(cover('al-7')),
CoverCache.dateiName(cover('al-7')));
});
test('Token und Salt ändern den Namen nicht', () {
// Beides wird bei jedem App-Start neu gewürfelt. Flösse es ein, wäre
// dasselbe Bild jedes Mal ein anderer Eintrag — der Ordner wüchse,
// ohne je einen Treffer zu liefern.
expect(
CoverCache.dateiName(cover('al-7', token: 'aaa', salt: 'bbb')),
CoverCache.dateiName(cover('al-7', token: 'ccc', salt: 'ddd')),
);
});
test('verschiedene Cover ergeben verschiedene Namen', () {
expect(CoverCache.dateiName(cover('al-7')),
isNot(CoverCache.dateiName(cover('al-8'))));
});
test('eine Adresse ohne id fällt auf den Pfad zurück', () {
expect(
CoverCache.dateiName(Uri.parse('https://musik.example/bild.jpg')),
isNot(CoverCache.dateiName(Uri.parse('https://musik.example/x.jpg'))),
);
});
test('endet auf .img', () {
expect(CoverCache.dateiName(cover('al-7')), endsWith('.img'));
});
});
}
+15 -8
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:melo/shared/cover.dart';
@@ -26,17 +28,22 @@ void main() {
expect(find.byType(Image), findsOneWidget);
});
testWidgets('eine http-Adresse wird als Bild geladen', (tester) async {
await pumpe(tester, Uri.parse('http://musik.example/getCoverArt.view?id=1'));
expect(find.byType(Image), findsOneWidget);
});
testWidgets('eine https-Adresse wird als Bild geladen', (tester) async {
testWidgets('eine Server-Adresse geht über den Cover-Ordner', (tester) async {
// Server-Cover werden nicht direkt geladen, sondern erst auf die Platte
// gelegt (siehe CoverCache) — sonst wären sie nach jedem Neustart weg.
await pumpe(
tester, Uri.parse('https://musik.example/getCoverArt.view?id=1'));
expect(find.byType(Image), findsOneWidget);
expect(find.byType(FutureBuilder<File?>), findsOneWidget);
});
testWidgets('solange das Cover unterwegs ist, steht der Platzhalter',
(tester) async {
// Ohne Netz kommt nichts an. Die Zeile muss trotzdem stehen, statt
// leer zu bleiben oder zu springen.
await pumpe(tester, Uri.parse('http://musik.example/getCoverArt.view?id=1'));
expect(find.byIcon(Icons.music_note), findsOneWidget);
});
testWidgets('ein unbekanntes Schema fällt auf den Platzhalter zurück',