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
@@ -25,9 +25,20 @@ erscheint auf Android 13+ keine Wiedergabe-Benachrichtigung.
**Gefundene Punkte:** 26 → 5 kritisch · 10 groß · 7 klein · 4 Vorschläge **Gefundene Punkte:** 26 → 5 kritisch · 10 groß · 7 klein · 4 Vorschläge
> **Stand 2026-08-23, nach der Umsetzung:** Die fünf P0-Punkte (K1K5) sind > **Stand 2026-08-23, nach der Umsetzung.** Erledigt: alle fünf P0-Punkte
> behoben, siehe CHANGELOG. G9 und G10 kamen bei der Umsetzung dazu und > (K1K5) sowie G1G10. Details im CHANGELOG.
> sind offen. >
> Zwei Teilforderungen aus **G2** sind bewusst **nicht** umgesetzt: den
> Server-Bereich aus dem Download-Tab zu lösen widerspricht Commit
> `34d6d8e` („Auf Wunsch von Dustin"), und eine Server-Suche widerspricht
> der Vorgabe, dass die Suche rein lokal bleibt.
>
> Aus **G3** bleibt der schwere Teil offen: Wiedergabezähler, Historie und
> Kategorien für reine Server-Titel bräuchten eine Zeile in der Bibliothek.
>
> Offen sind damit noch die P2- und P3-Listen unten sowie **K4** in seiner
> korrigierten Fassung (Album-Detailansicht **lokal**, mit Tracknummer und
> Schema-Erweiterung — die Server-Seite davon ist mit G2 erledigt).
--- ---
+88
View File
@@ -5,6 +5,94 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [Unreleased]
### 🧩 Die letzten vier Punkte aus dem Review (2026-08-23)
**G2 — Server-Bereich.** Ein Tipp auf ein Album spielte sofort das ganze
Album ab; hineinsehen ging nicht.
- 💿 **Album- und Künstler-Ansicht.** Großes Cover, Künstler, Titelzahl,
Gesamtdauer, „Abspielen" und „Zufällig" — darunter die Titelliste mit
Nummer und Dauer. Antippen startet **an dieser Stelle**, nicht am Anfang.
Der laufende Titel ist auch hier markiert, und schon geladene Titel tragen
ein Häkchen.
- 📄 **Mehr als 50 Alben.** Die Liste hörte bei 50 auf, ohne dass etwas
darauf hinwies. Jetzt gibt es „Mehr Alben laden", und wenn der Server
weniger als eine volle Seite liefert, verschwindet der Knopf von selbst.
- 🚫 **Nicht umgesetzt:** „Server-Bereich aus dem Download-Tab lösen" stand
auch im Befund — das widerspricht aber Commit `34d6d8e` („Auf Wunsch von
Dustin"), mit dem der Tab bewusst wieder „Download" heißt. Die
Tab-Struktur bleibt unangetastet. Ebenso weggelassen: eine Suche über den
Server, weil die Suche ausdrücklich rein lokal bleiben soll.
**G8 — Songtext läuft mit.** Bisher eine Textwand ohne Bezug zur Wiedergabe.
- 🎤 Trägt der Text Zeitmarken (LRC), läuft er jetzt **mit**: die aktuelle
Zeile ist hell und größer, der Rest gedämpft, und die Ansicht schiebt sie
in die Mitte. **Antippen springt an die Stelle im Lied.**
- 📃 Ein Text ohne Zeitmarken bleibt eine Textwand — beides kommt vor, je
nach Tag und Server, und die App schaut nach, statt zu raten.
**G10 — Cover bleiben jetzt da.** `Image.network` merkt sich Bilder nur im
Arbeitsspeicher; nach jedem Neustart fragte jede Zeile ihr Cover erneut beim
Server an, und ohne Netz gab es gar keins.
- 🖼️ Cover landen in einem eigenen Ordner und werden nur einmal geholt. Der
Schlüssel hängt an der Album-Kennung, **nicht** an der Adresse — die trägt
bei jedem App-Start ein neues Token, und der Ordner wäre sonst gewachsen,
ohne je einen Treffer zu liefern.
- 🧹 Bewusst im Cache-Ordner: ein Cover ist ersetzbar. Räumt Android auf,
lädt es sich beim nächsten Mal neu — anders als die Musik-Downloads.
- ⚠️ Nur echte Bilder werden abgelegt: Subsonic meldet Fehler mit HTTP 200
und JSON, und das läge sonst dauerhaft als „Cover" im Ordner.
**Aus dem Code-Review nachgebessert — sieben Punkte:**
- 🤍 **Das Herz behielt beim Titelwechsel seinen Zustand.** Einen Server-Titel
favorisieren, der Player geht zum nächsten — und der zeigte sich als
Favorit, obwohl er nie einer war. Der nächste Tipp hätte ihn dann
„entfavorisiert".
- 🖼️ **Beim Scrollen zeigten Zeilen kurz das Cover der vorherigen.** Listen
verwenden ihre Zeilen wieder; ohne Schlüssel behielt die Bildabfrage das
alte Ergebnis, bis das neue da war — bei ungecachten Covern sekundenlang.
Ein Rückschritt gegenüber dem vorherigen Stand, deshalb hier mitbehoben.
- 🎤 **Der neue mitlaufende Songtext funktionierte für Server-Titel gar
nicht.** Ihm wurde die Abspiel-Adresse statt der Server-Kennung
übergeben; die App fragte den Server nach einem Titel namens „https://…"
und bekam nie einen Text.
- 🎨 **Ein Cover, das einmal nicht lud, blieb die ganze Sitzung grau.** Startet
die App ohne Netz, waren alle Cover bis zum Neustart weg — auch wenn die
Verbindung längst wieder stand. Fehlschläge werden nicht mehr gemerkt.
- 🚨 **„Server nicht erreichbar" sah aus wie „Album hat keine Titel".** Beide
Abfragen verschluckten ihre Fehler und lieferten eine leere Liste; der
Wiederholen-Knopf, den ich gebaut hatte, war damit unerreichbar.
- 🎸 **Die Künstler-Ansicht wäre immer leer geblieben.** Die App las die Titel
aus `getArtist`, wo die Subsonic-Schnittstelle sie gar nicht mitliefert —
Alben kommen dort ohne ihre Titel. Jetzt wird je Album nachgefragt.
(Vorbestehend, aber ich habe einen ganzen Bildschirm darauf gebaut.)
- 🧩 Dazu ein seltener, aber dauerhafter Fall: zwei gleichzeitige Abrufe
desselben Covers schrieben in dieselbe Zwischendatei und konnten ein
zerschnittenes Bild hinterlassen, das nie wieder verschwindet.
- 🧹 **Selbst gefunden:** Das Häkchen „schon geladen" fragte pro Zeile die
Platte ab — und die Zeile zeichnet sich bei jedem Positionswechsel neu.
Das wäre eine Dateiprüfung pro Sekunde und Titel gewesen. Jetzt einmal je
Bildschirm aus der Datenbank.
-**Getestet:** 457 Tests grün (vorher 433), `flutter analyze` ohne Befund,
Release-APK gebaut. Neu: 16 Tests für den LRC-Parser, 5 für den
Cover-Schlüssel und 3 für die Album-Daten.
**G3 — Server-Titel nicht länger zweiter Klasse.**
- 🤍 Das Herz ist bei Server-Titeln nicht mehr ausgegraut, sondern
favorisiert **am Server** (`star`/`unstar`) — genau dort, wo der
Favoriten-Abgleich in den Einstellungen sie auch wieder abholt. Ein Titel
der Bibliothek wird weiterhin lokal favorisiert.
- ️ Weiterhin offen: Wiedergabezähler, Historie und Kategorien gibt es für
reine Server-Titel nicht — dafür bräuchten sie eine Zeile in der
Bibliothek, und das wäre ein eigener Umbau.
### 📥 Alben und Künstler offline mitnehmen — und der Zwischenspeicher hat endlich eine Grenze (2026-08-23) ### 📥 Alben und Künstler offline mitnehmen — und der Zwischenspeicher hat endlich eine Grenze (2026-08-23)
Bisher landete Musik nur zufällig auf dem Gerät: was man abspielte, blieb Bisher landete Musik nur zufällig auf dem Gerät: was man abspielte, blieb
+67 -71
View File
@@ -7,7 +7,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../library/category_service.dart'; import '../library/category_service.dart';
import '../library/database.dart'; import '../library/database.dart';
import '../library/library_service.dart'; import '../library/library_service.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../services/baka_auth.dart'; import '../services/baka_auth.dart';
import '../services/media_store.dart'; import '../services/media_store.dart';
@@ -17,6 +16,7 @@ import '../services/server_neuheiten.dart';
import '../services/yt_download_service.dart'; import '../services/yt_download_service.dart';
import '../shared/sub_tabs.dart'; import '../shared/sub_tabs.dart';
import 'download_einordnung.dart'; import 'download_einordnung.dart';
import 'server_titel_screen.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
/// Download-Tab: YouTube-Downloader und der verbundene Navidrome-Server /// Download-Tab: YouTube-Downloader und der verbundene Navidrome-Server
@@ -74,9 +74,14 @@ class _ServerBrowserState extends State<_ServerBrowser> {
// Die Alben sind beim Prüfen schon geladen — kein zweiter Serveraufruf. // Die Alben sind beim Prüfen schon geladen — kein zweiter Serveraufruf.
late final ServerNeuheiten _neuheiten = late final ServerNeuheiten _neuheiten =
ServerNeuheiten(holeAlben: () async => _albums); ServerNeuheiten(holeAlben: () async => _albums);
static const _seite = 50;
List<SubsonicAlbum> _albums = []; List<SubsonicAlbum> _albums = [];
List<SubsonicArtist> _artists = []; List<SubsonicArtist> _artists = [];
bool _loading = false; bool _loading = false;
bool _laedtNach = false;
/// Der Server hat weniger als eine volle Seite geliefert — dann ist Schluss.
bool _alleAlbenDa = false;
bool _showArtists = false; bool _showArtists = false;
String? _error; String? _error;
@@ -99,10 +104,11 @@ class _ServerBrowserState extends State<_ServerBrowser> {
_error = null; _error = null;
}); });
try { try {
final alben = await _nav.getAlben(anzahl: 50); final alben = await _nav.getAlben(anzahl: _seite);
if (mounted) { if (mounted) {
setState(() { setState(() {
_albums = alben; _albums = alben;
_alleAlbenDa = alben.length < _seite;
_loading = false; _loading = false;
}); });
// Erst jetzt prüfen — _albums ist gefüllt. // Erst jetzt prüfen — _albums ist gefüllt.
@@ -120,6 +126,29 @@ class _ServerBrowserState extends State<_ServerBrowser> {
} }
} }
/// Holt die nächste Seite Alben. Vorher blieb die Liste bei 50 stehen,
/// ohne dass irgendetwas darauf hinwies.
Future<void> _mehrAlben() async {
if (_laedtNach || _alleAlbenDa) return;
setState(() => _laedtNach = true);
try {
final weitere =
await _nav.getAlben(anzahl: _seite, ab: _albums.length);
if (!mounted) return;
setState(() {
_albums = [..._albums, ...weitere];
_alleAlbenDa = weitere.length < _seite;
_laedtNach = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _laedtNach = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Nachladen fehlgeschlagen: $e')),
);
}
}
Future<void> _loadArtists() async { Future<void> _loadArtists() async {
if (!_nav.istVerbunden) return; if (!_nav.istVerbunden) return;
setState(() { setState(() {
@@ -270,15 +299,30 @@ class _ServerBrowserState extends State<_ServerBrowser> {
tooltip: 'Alles von ${artist.name} herunterladen', tooltip: 'Alles von ${artist.name} herunterladen',
holeTitel: () => _nav.getArtistSongs(artist.id), holeTitel: () => _nav.getArtistSongs(artist.id),
), ),
onTap: () => _playArtist(artist), onTap: () => _oeffneKuenstler(artist),
), ),
); );
}, },
) )
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: _albums.length, itemCount: _albums.length + (_alleAlbenDa ? 0 : 1),
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i == _albums.length) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: MeloSpace.md),
child: Center(
child: _laedtNach
? const CircularProgressIndicator(
color: MeloTheme.red)
: OutlinedButton(
onPressed: _mehrAlben,
child: const Text('Mehr Alben laden'),
),
),
);
}
final album = _albums[i]; final album = _albums[i];
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -294,7 +338,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
tooltip: 'Album herunterladen', tooltip: 'Album herunterladen',
holeTitel: () => _nav.getSongs(album.id), holeTitel: () => _nav.getSongs(album.id),
), ),
onTap: () => _playAlbum(album), onTap: () => _oeffneAlbum(album),
), ),
); );
}, },
@@ -304,74 +348,26 @@ class _ServerBrowserState extends State<_ServerBrowser> {
); );
} }
Future<void> _playAlbum(SubsonicAlbum album) async { void _oeffneAlbum(SubsonicAlbum album) {
final handler = context.read<MeloAudioHandler>(); Navigator.of(context).push(MaterialPageRoute(
try { builder: (_) => ServerTitelScreen(
final songs = await _nav.getSongs(album.id); titel: album.name,
if (songs.isEmpty) { untertitel: album.kuenstler,
if (!mounted) { coverId: album.coverId,
return; navidrome: _nav,
} holeTitel: () => _nav.getSongs(album.id),
final messenger = ScaffoldMessenger.of(context); ),
messenger.showSnackBar( ));
const SnackBar(content: Text('Album hat keine Songs')),
);
return;
}
final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
final uebersprungen = await handler.loadPlaylist(items);
if (mounted && uebersprungen > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} on NichtsOfflineVerfuegbar catch (e) {
// Kein Fehler, sondern die gewählte Einstellung — deshalb ohne
// "Fehler:" davor, sonst sucht der Nutzer eine Störung.
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$e')),
);
} catch (e) {
if (!mounted) {
return;
}
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
}
} }
Future<void> _playArtist(SubsonicArtist artist) async { void _oeffneKuenstler(SubsonicArtist artist) {
final handler = context.read<MeloAudioHandler>(); Navigator.of(context).push(MaterialPageRoute(
try { builder: (_) => ServerTitelScreen(
final songs = await _nav.getArtistSongs(artist.id); titel: artist.name,
if (songs.isEmpty) { navidrome: _nav,
if (!mounted) return; holeTitel: () => _nav.getArtistSongs(artist.id),
ScaffoldMessenger.of(context).showSnackBar( ),
const SnackBar(content: Text('Künstler hat keine Songs')), ));
);
return;
}
final items = [for (final s in songs) subsonicToMediaItem(s, _nav)];
final uebersprungen = await handler.loadPlaylist(items);
if (mounted && uebersprungen > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} on NichtsOfflineVerfuegbar catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$e')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
}
} }
} }
+333
View File
@@ -0,0 +1,333 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../library/database.dart';
import '../library/song_media.dart';
import '../player/audio_handler.dart';
import '../services/navidrome_service.dart';
import '../shared/cover.dart';
import '../shared/lauf_balken.dart';
import '../shared/theme.dart';
/// Die Titel eines Albums oder Künstlers vom Server.
///
/// Vorher spielte ein Tipp auf ein Album sofort das ganze Album ab — es gab
/// keine Möglichkeit, hineinzusehen oder einen einzelnen Titel zu wählen.
/// Das ist der Unterschied zwischen „abspielen" und „öffnen", und jede andere
/// Musik-App trennt ihn.
class ServerTitelScreen extends StatefulWidget {
const ServerTitelScreen({
super.key,
required this.titel,
required this.navidrome,
required this.holeTitel,
this.untertitel,
this.coverId,
});
final String titel;
final String? untertitel;
final String? coverId;
final NavidromeService navidrome;
final Future<List<SubsonicSong>> Function() holeTitel;
@override
State<ServerTitelScreen> createState() => _ServerTitelScreenState();
}
class _ServerTitelScreenState extends State<ServerTitelScreen> {
List<SubsonicSong>? _songs;
Set<String> _geladen = const {};
String? _fehler;
@override
void initState() {
super.initState();
_laden();
}
Future<void> _laden() async {
final db = context.read<MeloDb>();
setState(() => _fehler = null);
try {
final songs = await widget.holeTitel();
// Einmal für den ganzen Bildschirm, nicht je Zeile.
final geladen = await db.downloadIds();
if (mounted) {
setState(() {
_songs = songs;
_geladen = geladen;
});
}
} catch (e) {
if (mounted) {
setState(() => _fehler = e is NavidromeException
? e.message
: 'Titel nicht abrufbar');
}
}
}
Future<void> _spiele(List<SubsonicSong> songs, int ab) async {
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
final items = [
for (final s in songs) subsonicToMediaItem(s, widget.navidrome)
];
try {
final uebersprungen = await handler.loadPlaylist(items, startIndex: ab);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} on NichtsOfflineVerfuegbar catch (e) {
messenger.showSnackBar(SnackBar(content: Text('$e')));
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
}
Future<void> _zufaellig(List<SubsonicSong> songs) {
final gemischt = [...songs]..shuffle();
return _spiele(gemischt, 0);
}
@override
Widget build(BuildContext context) {
final songs = _songs;
return Scaffold(
appBar: AppBar(title: Text(widget.titel)),
body: _fehler != null
? _Fehler(text: _fehler!, onNochmal: _laden)
: songs == null
? const Center(
child: CircularProgressIndicator(color: MeloTheme.red))
: songs.isEmpty
? const Center(
child: Text('Keine Titel',
style: TextStyle(color: MeloTheme.text2)))
: ListView.builder(
itemCount: songs.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return _Kopf(
titel: widget.titel,
untertitel: widget.untertitel,
coverId: widget.coverId,
navidrome: widget.navidrome,
songs: songs,
onAbspielen: () => _spiele(songs, 0),
onZufaellig: () => _zufaellig(songs),
);
}
final song = songs[i - 1];
return _Zeile(
song: song,
nummer: i,
geladen: _geladen.contains(song.id),
onTap: () => _spiele(songs, i - 1),
);
},
),
);
}
}
/// Cover, Anzahl, Gesamtdauer und die beiden Wiedergabe-Knöpfe.
class _Kopf extends StatelessWidget {
const _Kopf({
required this.titel,
required this.untertitel,
required this.coverId,
required this.navidrome,
required this.songs,
required this.onAbspielen,
required this.onZufaellig,
});
final String titel;
final String? untertitel;
final String? coverId;
final NavidromeService navidrome;
final List<SubsonicSong> songs;
final VoidCallback onAbspielen;
final VoidCallback onZufaellig;
static String _dauer(int sekunden) {
final stunden = sekunden ~/ 3600;
final minuten = (sekunden % 3600) ~/ 60;
if (stunden > 0) return '$stunden Std. $minuten Min.';
return '$minuten Min.';
}
@override
Widget build(BuildContext context) {
final gesamt = songs.fold<int>(0, (s, e) => s + e.dauerSekunden);
final bild = coverId;
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (bild != null)
CoverImage(
artUri: navidrome.coverUrl(bild),
size: 180,
radius: MeloRadius.card,
),
const SizedBox(height: MeloSpace.sm + MeloSpace.xs),
Text(titel,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center),
const SizedBox(height: MeloSpace.xs),
Text(
[
?untertitel,
'${songs.length} Titel',
_dauer(gesamt),
].join(' · '),
style: const TextStyle(color: MeloTheme.text2, fontSize: 13),
textAlign: TextAlign.center,
),
const SizedBox(height: MeloSpace.md),
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.play_arrow),
label: const Text('Abspielen'),
onPressed: onAbspielen,
),
),
const SizedBox(width: MeloSpace.sm),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.shuffle),
label: const Text('Zufällig'),
onPressed: onZufaellig,
),
),
],
),
const SizedBox(height: MeloSpace.sm),
const Divider(height: 1),
],
),
);
}
}
/// Eine Titelzeile: Nummer, Name, Dauer — und die Lauf-Markierung, wenn
/// dieser Titel gerade spielt.
class _Zeile extends StatelessWidget {
const _Zeile({
required this.song,
required this.nummer,
required this.geladen,
required this.onTap,
});
final SubsonicSong song;
final int nummer;
final bool geladen;
final VoidCallback onTap;
static String _dauer(int sekunden) {
final m = (sekunden ~/ 60).toString();
final s = (sekunden % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return StreamBuilder<MediaItem?>(
stream: handler.mediaItem,
initialData: handler.mediaItem.valueOrNull,
builder: (context, laufSnap) {
// Server-Titel tragen keine Bibliotheks-UUID; erkannt werden sie an
// der Navidrome-ID.
final aktuell = laufSnap.data;
final markiert =
aktuell != null && navidromeIdOf(aktuell) == song.id;
return StreamBuilder<PlaybackState>(
stream: markiert ? handler.playbackState : null,
initialData: handler.playbackState.valueOrNull,
builder: (context, zustandSnap) => ListTile(
leading: SizedBox(
width: 32,
child: markiert
? LaufBalken(
laeuft: zustandSnap.data?.playing ?? false,
farbe: MeloTheme.red,
)
: Text('$nummer',
textAlign: TextAlign.center,
style: const TextStyle(color: MeloTheme.text3)),
),
title: Text(
song.titel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: markiert
? const TextStyle(
color: MeloTheme.red, fontWeight: FontWeight.w600)
: null,
),
subtitle: song.kuenstler.isEmpty
? null
: Text(song.kuenstler,
maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (geladen)
const Padding(
padding: EdgeInsets.only(right: MeloSpace.sm),
child: Icon(Icons.download_done,
size: 16, color: MeloTheme.text3),
),
Text(_dauer(song.dauerSekunden),
style: const TextStyle(color: MeloTheme.text3)),
],
),
onTap: onTap,
),
);
},
);
}
}
class _Fehler extends StatelessWidget {
const _Fehler({required this.text, required this.onNochmal});
final String text;
final VoidCallback onNochmal;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: MeloTheme.red),
const SizedBox(height: MeloSpace.sm + MeloSpace.xs),
Text(text, style: const TextStyle(color: MeloTheme.text2)),
const SizedBox(height: MeloSpace.md),
FilledButton.icon(
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
onPressed: onNochmal,
),
],
),
);
}
}
+12
View File
@@ -561,6 +561,18 @@ class MeloDb extends _$MeloDb {
Future<void> vergissAlleDownloads() => delete(downloads).go(); Future<void> vergissAlleDownloads() => delete(downloads).go();
/// Server-IDs aller heruntergeladenen Titel.
///
/// Einmal je Bildschirm statt einmal je Zeile: die Titelliste eines Albums
/// zeichnet sich bei jedem Positionswechsel neu, und eine Datei-Abfrage pro
/// Zeile und Neuzeichnen wäre eine Prüfung pro Sekunde und Titel.
Future<Set<String>> downloadIds() async {
final zeilen = await (selectOnly(downloads)
..addColumns([downloads.navidromeId]))
.get();
return {for (final z in zeilen) z.read(downloads.navidromeId)!};
}
Future<int?> lastPosition(String songId) async { Future<int?> lastPosition(String songId) async {
final row = await (select(playbackHistory) final row = await (select(playbackHistory)
+5
View File
@@ -150,6 +150,11 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen. /// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen.
final Set<String> _benutzteCacheDateien = {}; final Set<String> _benutzteCacheDateien = {};
final NavidromeService _nav = NavidromeService(); final NavidromeService _nav = NavidromeService();
/// Die Server-Verbindung des Players — hier bereits mit geladenen
/// Zugangsdaten. Die Oberfläche nutzt sie mit, statt eine zweite
/// Verbindung aufzubauen, die davon nichts wüsste.
NavidromeService get navidrome => _nav;
int? _lastCountedIndex; int? _lastCountedIndex;
/// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden. /// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden.
+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;
}
+164 -32
View File
@@ -6,8 +6,10 @@ import '../library/database.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/favorite_button.dart'; import '../shared/favorite_button.dart';
import '../shared/server_favorite_button.dart';
import '../shared/theme.dart'; import '../shared/theme.dart';
import 'audio_handler.dart'; import 'audio_handler.dart';
import 'lrc.dart';
import 'queue_screen.dart'; import 'queue_screen.dart';
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls. /// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
@@ -15,6 +17,7 @@ class NowPlayingScreen extends StatelessWidget {
const NowPlayingScreen({super.key}); const NowPlayingScreen({super.key});
void _showLyrics(BuildContext context, String songId) { void _showLyrics(BuildContext context, String songId) {
if (songId.isEmpty) return;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (ctx) => _LyricsSheet(songId: songId), builder: (ctx) => _LyricsSheet(songId: songId),
@@ -39,10 +42,16 @@ class NowPlayingScreen extends StatelessWidget {
IconButton( IconButton(
tooltip: 'Songtext', tooltip: 'Songtext',
icon: const Icon(Icons.lyrics), icon: const Icon(Icons.lyrics),
// Bei Server-Titeln die Navidrome-ID, nicht die
// Abspiel-Adresse: mit der fragte die App den Server nach
// einem Titel namens "https://…" und bekam nie einen Text.
onPressed: () => _showLyrics( onPressed: () => _showLyrics(
context, item.extras?['songId'] as String? ?? item.id), context, songIdOf(item) ?? navidromeIdOf(item) ?? ''),
), ),
FavoriteButton(songId: item.extras?['songId'] as String? ?? ''), // Ein Titel der Bibliothek wird lokal favorisiert, einer
// vom Server am Server — beide bekommen dasselbe Herz,
// statt dass eines davon ausgegraut bleibt.
_Herz(item: item),
], ],
); );
}, },
@@ -98,6 +107,30 @@ class NowPlayingScreen extends StatelessWidget {
} }
} }
/// Das passende Herz für den laufenden Titel.
class _Herz extends StatelessWidget {
const _Herz({required this.item});
final MediaItem item;
@override
Widget build(BuildContext context) {
final songId = songIdOf(item);
if (songId != null && songId.isNotEmpty) {
return FavoriteButton(songId: songId);
}
final navId = navidromeIdOf(item);
if (navId == null) return const SizedBox.shrink();
return ServerFavoriteButton(
// Ohne Schlüssel behielte das Herz beim Titelwechsel seinen Zustand:
// Flutter würde dasselbe Element weiterverwenden, und der nächste Titel
// erschiene als Favorit, obwohl er nie einer war.
key: ValueKey(navId),
navidromeId: navId,
navidrome: context.read<MeloAudioHandler>().navidrome,
);
}
}
class _Cover extends StatelessWidget { class _Cover extends StatelessWidget {
const _Cover({required this.item}); const _Cover({required this.item});
final MediaItem item; final MediaItem item;
@@ -317,7 +350,10 @@ class _LyricsSheet extends StatefulWidget {
class _LyricsSheetState extends State<_LyricsSheet> { class _LyricsSheetState extends State<_LyricsSheet> {
late final NavidromeService _nav = NavidromeService(); late final NavidromeService _nav = NavidromeService();
String? _text; String? _text;
List<LyricZeile> _zeilen = const [];
bool _loading = true; bool _loading = true;
final _scroll = ScrollController();
int? _gezeigteZeile;
@override @override
void initState() { void initState() {
@@ -325,29 +361,57 @@ class _LyricsSheetState extends State<_LyricsSheet> {
_load(); _load();
} }
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
/// Zuerst der Songtext aus dem Tag der Datei — der ist sofort da und /// Zuerst der Songtext aus dem Tag der Datei — der ist sofort da und
/// funktioniert offline. Nur wenn keiner drinsteht, wird der Server gefragt. /// funktioniert offline. Nur wenn keiner drinsteht, wird der Server gefragt.
Future<void> _load() async { Future<void> _load() async {
final db = context.read<MeloDb>(); final db = context.read<MeloDb>();
final lokal = await db.lyricsOf(widget.songId); final lokal = await db.lyricsOf(widget.songId);
if (lokal != null) { if (lokal != null) {
if (mounted) setState(() { _text = lokal; _loading = false; }); if (mounted) setState(() => _uebernimm(lokal));
return; return;
} }
await _nav.ladeGespeicherteZugangsdaten(); await _nav.ladeGespeicherteZugangsdaten();
if (_nav.istVerbunden) { if (_nav.istVerbunden) {
final lyrics = await _nav.getLyrics(widget.songId); final lyrics = await _nav.getLyrics(widget.songId);
if (mounted) { if (mounted) {
setState(() { setState(() => _uebernimm(lyrics.isEmpty ? null : lyrics.text));
_text = lyrics.isEmpty ? null : lyrics.text;
_loading = false;
});
} }
return; return;
} }
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
void _uebernimm(String? text) {
_text = text;
// Trägt der Text Zeitmarken, läuft er mit; sonst bleibt es eine
// Textwand — beides kommt vor, je nach Tag und Server.
_zeilen = text == null ? const [] : parseLrc(text);
_loading = false;
}
/// Schiebt die aktive Zeile in die Mitte. Nur beim Zeilenwechsel, nicht bei
/// jeder Positionsmeldung — sonst ruckelt die Ansicht permanent.
void _folge(int index) {
if (_gezeigteZeile == index || !_scroll.hasClients) return;
_gezeigteZeile = index;
final ziel = (index * _zeilenHoehe) -
(_scroll.position.viewportDimension / 2) +
(_zeilenHoehe / 2);
_scroll.animateTo(
ziel.clamp(0, _scroll.position.maxScrollExtent),
duration: MeloMotion.normal,
curve: MeloMotion.curve,
);
}
static const _zeilenHoehe = 40.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -355,12 +419,15 @@ class _LyricsSheetState extends State<_LyricsSheet> {
child: Column( child: Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(MeloSpace.md),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('📝 Songtext', Text(
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), _zeilen.isEmpty ? 'Songtext' : 'Songtext · läuft mit',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -368,30 +435,95 @@ class _LyricsSheetState extends State<_LyricsSheet> {
], ],
), ),
), ),
Expanded( Expanded(child: _inhalt()),
child: _loading
? const Center(
child: CircularProgressIndicator(color: MeloTheme.red),
)
: _text == null
? const Center(
child: Text('Kein Songtext verfügbar',
style: TextStyle(color: MeloTheme.text2)),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Text(
_text!,
style: const TextStyle(
fontSize: 14,
height: 1.6,
color: Colors.white,
),
),
),
),
], ],
), ),
); );
} }
Widget _inhalt() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(color: MeloTheme.red),
);
}
final text = _text;
if (text == null) {
return const Center(
child: Text('Kein Songtext verfügbar',
style: TextStyle(color: MeloTheme.text2)),
);
}
if (_zeilen.isEmpty) {
return SingleChildScrollView(
padding: const EdgeInsets.all(MeloSpace.md),
child: Text(
text,
style: const TextStyle(fontSize: 14, height: 1.6, color: Colors.white),
),
);
}
return _Mitlaufend(
zeilen: _zeilen,
scroll: _scroll,
zeilenHoehe: _zeilenHoehe,
onZeile: _folge,
);
}
}
/// Die mitlaufende Darstellung: aktive Zeile hell und hervorgehoben, der
/// Rest gedämpft. Antippen springt an die Stelle im Titel.
class _Mitlaufend extends StatelessWidget {
const _Mitlaufend({
required this.zeilen,
required this.scroll,
required this.zeilenHoehe,
required this.onZeile,
});
final List<LyricZeile> zeilen;
final ScrollController scroll;
final double zeilenHoehe;
final ValueChanged<int> onZeile;
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
return StreamBuilder<Duration>(
stream: handler.positionStream,
builder: (context, snapshot) {
final aktiv = aktiveZeile(zeilen, snapshot.data ?? Duration.zero);
if (aktiv != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => onZeile(aktiv));
}
return ListView.builder(
controller: scroll,
itemExtent: zeilenHoehe,
padding: const EdgeInsets.symmetric(horizontal: MeloSpace.md),
itemCount: zeilen.length,
itemBuilder: (context, i) {
final istAktiv = i == aktiv;
return InkWell(
onTap: () => handler.seek(zeilen[i].zeit),
child: Align(
alignment: Alignment.centerLeft,
child: AnimatedDefaultTextStyle(
duration: MeloMotion.fast,
style: TextStyle(
fontSize: istAktiv ? 17 : 15,
height: 1.3,
fontWeight: istAktiv ? FontWeight.w700 : FontWeight.w400,
color: istAktiv ? Colors.white : MeloTheme.text3,
),
child: Text(zeilen[i].text,
maxLines: 1, overflow: TextOverflow.ellipsis),
),
),
);
},
);
},
);
}
} }
+106
View File
@@ -0,0 +1,106 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// Legt Coverbilder vom Server auf der Platte ab.
///
/// `Image.network` merkt sich Bilder nur im Arbeitsspeicher. Beim nächsten
/// Kaltstart fragte deshalb jede sichtbare Zeile ihr Cover erneut beim Server
/// an — und ohne Netz gab es gar keins, obwohl das Bild schon dreimal geladen
/// worden war.
///
/// Absichtlich im Cache-Ordner und nicht bei den Downloads: ein Cover ist
/// ersetzbar. Räumt Android den Ordner, lädt es sich beim nächsten Mal neu.
class CoverCache {
static const ordnerName = 'melo_covers';
/// Höchstzahl gemerkter Abfragen. Ohne Deckel wüchse die Tabelle mit jedem
/// je gesehenen Cover; die Dateien bleiben davon unberührt.
static const _merkGrenze = 500;
/// Laufende und fertige Abfragen je Adresse.
///
/// Der **Future** wird gemerkt, nicht erst das Ergebnis: `CoverImage` ist
/// zustandslos und fragt bei jedem Neuzeichnen. Ohne diese Tabelle liefe
/// derselbe Download mehrfach parallel.
static final Map<String, Future<File?>> _abfragen = {};
static Directory? _ordner;
/// Macht den Namen der Zwischendatei je Abruf eindeutig.
static int _laufendeNummer = 0;
static Future<Directory> _ordnerHolen() async {
final vorhanden = _ordner;
if (vorhanden != null) return vorhanden;
final basis = await getApplicationCacheDirectory();
final ordner = Directory(p.join(basis.path, ordnerName));
if (!await ordner.exists()) await ordner.create(recursive: true);
_ordner = ordner;
return ordner;
}
/// Dateiname einer Cover-Adresse.
///
/// Ohne die Zugangsdaten: die Adresse trägt bei jedem App-Start ein neues
/// Token und Salt. Als Schlüssel wäre dasselbe Bild sonst jedes Mal ein
/// anderes, und der Ordner wüchse, ohne je einen Treffer zu liefern.
static String dateiName(Uri adresse) {
final id = adresse.queryParameters['id'] ?? adresse.path;
return '${md5.convert(id.codeUnits)}.img';
}
/// Die Bilddatei zu [adresse] — aus dem Ordner, sonst frisch geladen.
/// `null`, wenn es sie nicht gibt und auch nicht zu holen ist.
static Future<File?> hole(Uri adresse) {
final schluessel = dateiName(adresse);
final bekannt = _abfragen[schluessel];
if (bekannt != null) return bekannt;
if (_abfragen.length >= _merkGrenze) _abfragen.clear();
// Einen Fehlschlag nicht dauerhaft merken: startet die App ohne Netz,
// blieben sonst alle Cover bis zum Neustart grau, auch wenn die
// Verbindung längst wieder steht.
final abfrage = _hole(adresse, schluessel).then((datei) {
if (datei == null) _abfragen.remove(schluessel);
return datei;
});
_abfragen[schluessel] = abfrage;
return abfrage;
}
static Future<File?> _hole(Uri adresse, String schluessel) async {
try {
final datei = File(p.join((await _ordnerHolen()).path, schluessel));
if (await datei.exists()) return datei;
final antwort =
await http.get(adresse).timeout(const Duration(seconds: 15));
if (antwort.statusCode != 200 || antwort.bodyBytes.isEmpty) return null;
// Subsonic meldet Fehler mit HTTP 200 und JSON — das wäre als Bild
// unbrauchbar und läge dann dauerhaft im Ordner.
final typ = antwort.headers['content-type'] ?? '';
if (!typ.startsWith('image/')) return null;
// Erst vollständig schreiben, dann umbenennen: ein Abbruch hinterlässt
// sonst eine halbe Datei, die als gültiger Treffer gälte. Der
// Zwischenname ist je Abruf eindeutig — zwei gleichzeitige Abrufe
// desselben Covers schrieben sonst in dieselbe Datei und könnten ein
// zerschnittenes Bild hinterlassen, das dauerhaft liegen bliebe.
final teil = File('${datei.path}.${_laufendeNummer++}.part');
await teil.writeAsBytes(antwort.bodyBytes, flush: true);
await teil.rename(datei.path);
return datei;
} catch (e) {
debugPrint('Cover nicht ladbar: $e');
return null;
}
}
/// Nur für Tests: vergisst die gemerkten Abfragen.
@visibleForTesting
static void vergissAbfragen() => _abfragen.clear();
}
+39 -18
View File
@@ -42,12 +42,14 @@ class SubsonicAlbum {
final String name; final String name;
final String? coverId; final String? coverId;
final int songCount; final int songCount;
final String? kuenstler;
const SubsonicAlbum({ const SubsonicAlbum({
required this.id, required this.id,
required this.name, required this.name,
this.coverId, this.coverId,
this.songCount = 0, this.songCount = 0,
this.kuenstler,
}); });
factory SubsonicAlbum.fromJson(Map<String, dynamic> j) => SubsonicAlbum( factory SubsonicAlbum.fromJson(Map<String, dynamic> j) => SubsonicAlbum(
@@ -55,6 +57,7 @@ class SubsonicAlbum {
name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt', name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt',
coverId: j['coverArt'] as String?, coverId: j['coverArt'] as String?,
songCount: (j['songCount'] as int?) ?? 0, songCount: (j['songCount'] as int?) ?? 0,
kuenstler: j['artist'] as String?,
); );
} }
@@ -229,12 +232,15 @@ class NavidromeService {
} }
} }
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50}) async { /// [ab] überspringt die ersten Alben — damit lässt sich über die erste
/// Seite hinaus nachladen, statt bei 50 stehenzubleiben.
Future<List<SubsonicAlbum>> getAlben({int anzahl = 50, int ab = 0}) async {
try { try {
// getAlbumList2 = ID3-basiert (wie getArtists) — der Folder-Endpoint // getAlbumList2 = ID3-basiert (wie getArtists) — der Folder-Endpoint
// getAlbumList liefert bei tag-organisierten Navidrome-Libraries leer. // getAlbumList liefert bei tag-organisierten Navidrome-Libraries leer.
final r = await http final r = await http
.get(_uri('getAlbumList2.view', {'type': 'newest', 'size': '$anzahl'})) .get(_uri('getAlbumList2.view',
{'type': 'newest', 'size': '$anzahl', 'offset': '$ab'}))
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}'); if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
return parseAlben(parseSubsonic(r.body)); return parseAlben(parseSubsonic(r.body));
@@ -244,16 +250,23 @@ class NavidromeService {
} }
} }
/// Die Titel eines Albums.
///
/// Wirft bei Fehlern weiter, statt eine leere Liste zu liefern: „keine
/// Titel" und „Server nicht erreichbar" sehen sonst gleich aus, und der
/// Nutzer bekäme keinen Wiederholen-Knopf zu sehen.
Future<List<SubsonicSong>> getSongs(String albumId) async { Future<List<SubsonicSong>> getSongs(String albumId) async {
try { try {
final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15)); final r = await http
if (r.statusCode != 200) return []; .get(_uri('getAlbum.view', {'id': albumId}))
final data = jsonDecode(r.body); .timeout(const Duration(seconds: 15));
final songs = data['subsonic-response']?['album']?['song'] as List? ?? []; if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
final data = parseSubsonic(r.body);
final songs = data['album']?['song'] as List? ?? [];
return songs.map((j) => SubsonicSong.fromJson(j)).toList(); return songs.map((j) => SubsonicSong.fromJson(j)).toList();
} catch (e) { } catch (e) {
await logger.error('Navidrome getSongs Fehler: $e', e, StackTrace.current); await logger.error('Navidrome getSongs Fehler: $e', e, StackTrace.current);
return []; rethrow;
} }
} }
@@ -318,23 +331,31 @@ class NavidromeService {
} }
} }
/// Alle Titel eines Künstlers, Album für Album.
///
/// `getArtist` liefert die Alben **ohne** ihre Titel — das steht so in der
/// Subsonic-API. Vorher las diese Methode `album[].song`, was es dort nie
/// gibt: die Liste war immer leer, und der Künstler wirkte titellos.
/// Deshalb je Album ein `getAlbum` hinterher.
Future<List<SubsonicSong>> getArtistSongs(String artistId) async { Future<List<SubsonicSong>> getArtistSongs(String artistId) async {
try { try {
final r = await http.get(_uri('getArtist.view', {'id': artistId})).timeout(const Duration(seconds: 15)); final r = await http
if (r.statusCode != 200) return []; .get(_uri('getArtist.view', {'id': artistId}))
final data = jsonDecode(r.body); .timeout(const Duration(seconds: 15));
final albums = data['subsonic-response']?['artist']?['album'] as List? ?? []; if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}');
final data = parseSubsonic(r.body);
final alben = data['artist']?['album'] as List? ?? [];
final songs = <SubsonicSong>[]; final songs = <SubsonicSong>[];
for (final album in albums) { for (final album in alben) {
final albumSongs = album['song'] as List? ?? []; final id = (album as Map)['id'] as String?;
for (final song in albumSongs) { if (id == null) continue;
songs.add(SubsonicSong.fromJson(song)); songs.addAll(await getSongs(id));
}
} }
return songs; return songs;
} catch (e) { } catch (e) {
await logger.error('Navidrome getArtistSongs Fehler: $e', e, StackTrace.current); await logger.error(
return []; 'Navidrome getArtistSongs Fehler: $e', e, StackTrace.current);
rethrow;
} }
} }
+23 -10
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/cover_cache.dart';
import 'theme.dart'; import 'theme.dart';
/// Cover-Bild aus einer Datei- oder Server-Adresse, mit Noten-Platzhalter /// Cover-Bild aus einer Datei- oder Server-Adresse, mit Noten-Platzhalter
@@ -50,16 +51,28 @@ class CoverImage extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder, errorBuilder: (_, _, _) => placeholder,
), ),
// Solange das Bild vom Server unterwegs ist, steht der Platzhalter da — // Über den Ordner statt direkt: einmal geladen, bleibt das Cover auch
// sonst springt die Zeile in der Liste, sobald es ankommt. // nach einem Neustart und ohne Netz da (siehe [CoverCache]). Solange
'http' || 'https' => Image.network( // es unterwegs ist, steht der Platzhalter — sonst springt die Zeile in
artUri!.toString(), // der Liste, sobald das Bild ankommt.
width: size, 'http' || 'https' => FutureBuilder<File?>(
height: size, // Schlüssel je Adresse: in einer Liste werden Zeilen samt ihrem
fit: BoxFit.cover, // Cover-Widget wiederverwendet. Ohne ihn behielte der FutureBuilder
errorBuilder: (_, _, _) => placeholder, // das Ergebnis der vorherigen Zeile und zeigte deren Bild, bis das
frameBuilder: (_, kind, frame, warAusCache) => // neue geladen ist.
frame == null && !warAusCache ? placeholder : kind, key: ValueKey(artUri),
future: CoverCache.hole(artUri!),
builder: (context, snapshot) {
final datei = snapshot.data;
if (datei == null) return placeholder;
return Image.file(
datei,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => placeholder,
);
},
), ),
_ => placeholder, _ => placeholder,
}; };
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../services/navidrome_service.dart';
import 'theme.dart';
/// Herz für einen Titel, der nur am Server liegt.
///
/// Solche Titel haben keine Zeile in der Bibliothek und damit keine UUID —
/// das gewöhnliche [FavoriteButton] muss sie deshalb ablehnen. Der Server
/// führt aber eine eigene Favoritenliste (`star`/`unstar`), und die ist
/// genau der richtige Ort dafür: von dort holt sie auch der
/// Favoriten-Abgleich in den Einstellungen.
///
/// Der Zustand wird bewusst nur lokal gehalten und nicht vom Server
/// nachgeladen: für ein einzelnes Herz wäre eine eigene Abfrage bei jedem
/// Titelwechsel zu teuer, und die Antwort träfe ohnehin erst nach dem
/// nächsten Tippen ein.
class ServerFavoriteButton extends StatefulWidget {
const ServerFavoriteButton({
super.key,
required this.navidromeId,
required this.navidrome,
});
final String navidromeId;
final NavidromeService navidrome;
@override
State<ServerFavoriteButton> createState() => _ServerFavoriteButtonState();
}
class _ServerFavoriteButtonState extends State<ServerFavoriteButton> {
bool? _favorit;
bool _laeuft = false;
Future<void> _umschalten() async {
if (_laeuft) return;
final neu = !(_favorit ?? false);
setState(() => _laeuft = true);
final messenger = ScaffoldMessenger.of(context);
final ok = neu
? await widget.navidrome.setFavorite(widget.navidromeId)
: await widget.navidrome.removeFavorite(widget.navidromeId);
if (!mounted) return;
setState(() {
_laeuft = false;
// Nur bei Erfolg umschalten — sonst zeigte das Herz einen Zustand, den
// der Server nicht kennt.
if (ok) _favorit = neu;
});
if (!ok) {
messenger.showSnackBar(
const SnackBar(content: Text('Der Server hat es nicht übernommen')),
);
}
}
@override
Widget build(BuildContext context) {
final favorit = _favorit ?? false;
return IconButton(
tooltip: favorit
? 'Favorit am Server entfernen'
: 'Am Server zu Favoriten hinzufügen',
icon: Icon(
favorit ? Icons.favorite : Icons.favorite_border,
color: favorit ? MeloTheme.red : MeloTheme.text2,
),
onPressed: _laeuft ? null : _umschalten,
);
}
}
+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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:melo/shared/cover.dart'; import 'package:melo/shared/cover.dart';
@@ -26,17 +28,22 @@ void main() {
expect(find.byType(Image), findsOneWidget); expect(find.byType(Image), findsOneWidget);
}); });
testWidgets('eine http-Adresse wird als Bild geladen', (tester) async { testWidgets('eine Server-Adresse geht über den Cover-Ordner', (tester) async {
await pumpe(tester, Uri.parse('http://musik.example/getCoverArt.view?id=1')); // Server-Cover werden nicht direkt geladen, sondern erst auf die Platte
// gelegt (siehe CoverCache) — sonst wären sie nach jedem Neustart weg.
expect(find.byType(Image), findsOneWidget);
});
testWidgets('eine https-Adresse wird als Bild geladen', (tester) async {
await pumpe( await pumpe(
tester, Uri.parse('https://musik.example/getCoverArt.view?id=1')); 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', testWidgets('ein unbekanntes Schema fällt auf den Platzhalter zurück',