diff --git a/.ui-design/reviews/melo_vollwertigkeit_20260823.md b/.ui-design/reviews/melo_vollwertigkeit_20260823.md index 2108314..a01c14d 100644 --- a/.ui-design/reviews/melo_vollwertigkeit_20260823.md +++ b/.ui-design/reviews/melo_vollwertigkeit_20260823.md @@ -25,9 +25,20 @@ erscheint auf Android 13+ keine Wiedergabe-Benachrichtigung. **Gefundene Punkte:** 26 → 5 kritisch · 10 groß · 7 klein · 4 Vorschläge -> **Stand 2026-08-23, nach der Umsetzung:** Die fünf P0-Punkte (K1–K5) sind -> behoben, siehe CHANGELOG. G9 und G10 kamen bei der Umsetzung dazu und -> sind offen. +> **Stand 2026-08-23, nach der Umsetzung.** Erledigt: alle fünf P0-Punkte +> (K1–K5) sowie G1–G10. Details im CHANGELOG. +> +> 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). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb17e4..86e20b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,94 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [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) Bisher landete Musik nur zufällig auf dem Gerät: was man abspielte, blieb diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 9920950..15fb815 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -7,7 +7,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../library/category_service.dart'; import '../library/database.dart'; import '../library/library_service.dart'; -import '../library/song_media.dart'; import '../player/audio_handler.dart'; import '../services/baka_auth.dart'; import '../services/media_store.dart'; @@ -17,6 +16,7 @@ import '../services/server_neuheiten.dart'; import '../services/yt_download_service.dart'; import '../shared/sub_tabs.dart'; import 'download_einordnung.dart'; +import 'server_titel_screen.dart'; import '../shared/theme.dart'; /// 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. late final ServerNeuheiten _neuheiten = ServerNeuheiten(holeAlben: () async => _albums); + static const _seite = 50; + List _albums = []; List _artists = []; bool _loading = false; + bool _laedtNach = false; + /// Der Server hat weniger als eine volle Seite geliefert — dann ist Schluss. + bool _alleAlbenDa = false; bool _showArtists = false; String? _error; @@ -99,10 +104,11 @@ class _ServerBrowserState extends State<_ServerBrowser> { _error = null; }); try { - final alben = await _nav.getAlben(anzahl: 50); + final alben = await _nav.getAlben(anzahl: _seite); if (mounted) { setState(() { _albums = alben; + _alleAlbenDa = alben.length < _seite; _loading = false; }); // 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 _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 _loadArtists() async { if (!_nav.istVerbunden) return; setState(() { @@ -270,15 +299,30 @@ class _ServerBrowserState extends State<_ServerBrowser> { tooltip: 'Alles von ${artist.name} herunterladen', holeTitel: () => _nav.getArtistSongs(artist.id), ), - onTap: () => _playArtist(artist), + onTap: () => _oeffneKuenstler(artist), ), ); }, ) : ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: _albums.length, + itemCount: _albums.length + (_alleAlbenDa ? 0 : 1), 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]; return Card( margin: const EdgeInsets.only(bottom: 8), @@ -294,7 +338,7 @@ class _ServerBrowserState extends State<_ServerBrowser> { tooltip: 'Album herunterladen', holeTitel: () => _nav.getSongs(album.id), ), - onTap: () => _playAlbum(album), + onTap: () => _oeffneAlbum(album), ), ); }, @@ -304,74 +348,26 @@ class _ServerBrowserState extends State<_ServerBrowser> { ); } - Future _playAlbum(SubsonicAlbum album) async { - final handler = context.read(); - try { - final songs = await _nav.getSongs(album.id); - if (songs.isEmpty) { - if (!mounted) { - return; - } - 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')), - ); - } + void _oeffneAlbum(SubsonicAlbum album) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ServerTitelScreen( + titel: album.name, + untertitel: album.kuenstler, + coverId: album.coverId, + navidrome: _nav, + holeTitel: () => _nav.getSongs(album.id), + ), + )); } - Future _playArtist(SubsonicArtist artist) async { - final handler = context.read(); - try { - final songs = await _nav.getArtistSongs(artist.id); - if (songs.isEmpty) { - if (!mounted) return; - 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')), - ); - } + void _oeffneKuenstler(SubsonicArtist artist) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => ServerTitelScreen( + titel: artist.name, + navidrome: _nav, + holeTitel: () => _nav.getArtistSongs(artist.id), + ), + )); } } diff --git a/lib/downloads/server_titel_screen.dart b/lib/downloads/server_titel_screen.dart new file mode 100644 index 0000000..84f52eb --- /dev/null +++ b/lib/downloads/server_titel_screen.dart @@ -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> Function() holeTitel; + + @override + State createState() => _ServerTitelScreenState(); +} + +class _ServerTitelScreenState extends State { + List? _songs; + Set _geladen = const {}; + String? _fehler; + + @override + void initState() { + super.initState(); + _laden(); + } + + Future _laden() async { + final db = context.read(); + 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 _spiele(List songs, int ab) async { + final handler = context.read(); + 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 _zufaellig(List 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 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(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(); + return StreamBuilder( + 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( + 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, + ), + ], + ), + ); + } +} diff --git a/lib/library/database.dart b/lib/library/database.dart index 81ffa07..c0f69ce 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -561,6 +561,18 @@ class MeloDb extends _$MeloDb { Future 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> downloadIds() async { + final zeilen = await (selectOnly(downloads) + ..addColumns([downloads.navidromeId])) + .get(); + return {for (final z in zeilen) z.read(downloads.navidromeId)!}; + } + Future lastPosition(String songId) async { final row = await (select(playbackHistory) diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index ed78495..6e43e1b 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -150,6 +150,11 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { /// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen. final Set _benutzteCacheDateien = {}; 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; /// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden. diff --git a/lib/player/lrc.dart b/lib/player/lrc.dart new file mode 100644 index 0000000..b3cab9c --- /dev/null +++ b/lib/player/lrc.dart @@ -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 parseLrc(String text) { + final zeilen = []; + 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 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; +} diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index 55de957..e19c2bb 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -6,8 +6,10 @@ import '../library/database.dart'; import '../services/navidrome_service.dart'; import '../shared/cover.dart'; import '../shared/favorite_button.dart'; +import '../shared/server_favorite_button.dart'; import '../shared/theme.dart'; import 'audio_handler.dart'; +import 'lrc.dart'; import 'queue_screen.dart'; /// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls. @@ -15,6 +17,7 @@ class NowPlayingScreen extends StatelessWidget { const NowPlayingScreen({super.key}); void _showLyrics(BuildContext context, String songId) { + if (songId.isEmpty) return; showModalBottomSheet( context: context, builder: (ctx) => _LyricsSheet(songId: songId), @@ -39,10 +42,16 @@ class NowPlayingScreen extends StatelessWidget { IconButton( tooltip: 'Songtext', 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( - 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().navidrome, + ); + } +} + class _Cover extends StatelessWidget { const _Cover({required this.item}); final MediaItem item; @@ -317,7 +350,10 @@ class _LyricsSheet extends StatefulWidget { class _LyricsSheetState extends State<_LyricsSheet> { late final NavidromeService _nav = NavidromeService(); String? _text; + List _zeilen = const []; bool _loading = true; + final _scroll = ScrollController(); + int? _gezeigteZeile; @override void initState() { @@ -325,29 +361,57 @@ class _LyricsSheetState extends State<_LyricsSheet> { _load(); } + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + /// Zuerst der Songtext aus dem Tag der Datei — der ist sofort da und /// funktioniert offline. Nur wenn keiner drinsteht, wird der Server gefragt. Future _load() async { final db = context.read(); final lokal = await db.lyricsOf(widget.songId); if (lokal != null) { - if (mounted) setState(() { _text = lokal; _loading = false; }); + if (mounted) setState(() => _uebernimm(lokal)); return; } await _nav.ladeGespeicherteZugangsdaten(); if (_nav.istVerbunden) { final lyrics = await _nav.getLyrics(widget.songId); if (mounted) { - setState(() { - _text = lyrics.isEmpty ? null : lyrics.text; - _loading = false; - }); + setState(() => _uebernimm(lyrics.isEmpty ? null : lyrics.text)); } return; } 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 Widget build(BuildContext context) { return Container( @@ -355,12 +419,15 @@ class _LyricsSheetState extends State<_LyricsSheet> { child: Column( children: [ Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(MeloSpace.md), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('📝 Songtext', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + Text( + _zeilen.isEmpty ? 'Songtext' : 'Songtext · läuft mit', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w600), + ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), @@ -368,30 +435,95 @@ class _LyricsSheetState extends State<_LyricsSheet> { ], ), ), - Expanded( - 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, - ), - ), - ), - ), + Expanded(child: _inhalt()), ], ), ); } + + 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 zeilen; + final ScrollController scroll; + final double zeilenHoehe; + final ValueChanged onZeile; + + @override + Widget build(BuildContext context) { + final handler = context.read(); + return StreamBuilder( + 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), + ), + ), + ); + }, + ); + }, + ); + } } diff --git a/lib/services/cover_cache.dart b/lib/services/cover_cache.dart new file mode 100644 index 0000000..318edd1 --- /dev/null +++ b/lib/services/cover_cache.dart @@ -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> _abfragen = {}; + + static Directory? _ordner; + + /// Macht den Namen der Zwischendatei je Abruf eindeutig. + static int _laufendeNummer = 0; + + static Future _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 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 _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(); +} diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index 0ed2a58..b1573e1 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -42,12 +42,14 @@ class SubsonicAlbum { final String name; final String? coverId; final int songCount; + final String? kuenstler; const SubsonicAlbum({ required this.id, required this.name, this.coverId, this.songCount = 0, + this.kuenstler, }); factory SubsonicAlbum.fromJson(Map j) => SubsonicAlbum( @@ -55,6 +57,7 @@ class SubsonicAlbum { name: j['name'] as String? ?? j['title'] as String? ?? 'Unbekannt', coverId: j['coverArt'] as String?, songCount: (j['songCount'] as int?) ?? 0, + kuenstler: j['artist'] as String?, ); } @@ -229,12 +232,15 @@ class NavidromeService { } } - Future> 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> getAlben({int anzahl = 50, int ab = 0}) async { try { // getAlbumList2 = ID3-basiert (wie getArtists) — der Folder-Endpoint // getAlbumList liefert bei tag-organisierten Navidrome-Libraries leer. 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)); if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}'); 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> getSongs(String albumId) async { try { - final r = await http.get(_uri('getAlbum.view', {'id': albumId})).timeout(const Duration(seconds: 15)); - if (r.statusCode != 200) return []; - final data = jsonDecode(r.body); - final songs = data['subsonic-response']?['album']?['song'] as List? ?? []; + final r = await http + .get(_uri('getAlbum.view', {'id': albumId})) + .timeout(const Duration(seconds: 15)); + 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(); } catch (e) { 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> getArtistSongs(String artistId) async { try { - final r = await http.get(_uri('getArtist.view', {'id': artistId})).timeout(const Duration(seconds: 15)); - if (r.statusCode != 200) return []; - final data = jsonDecode(r.body); - final albums = data['subsonic-response']?['artist']?['album'] as List? ?? []; + final r = await http + .get(_uri('getArtist.view', {'id': artistId})) + .timeout(const Duration(seconds: 15)); + if (r.statusCode != 200) throw NavidromeException('HTTP ${r.statusCode}'); + final data = parseSubsonic(r.body); + final alben = data['artist']?['album'] as List? ?? []; final songs = []; - for (final album in albums) { - final albumSongs = album['song'] as List? ?? []; - for (final song in albumSongs) { - songs.add(SubsonicSong.fromJson(song)); - } + for (final album in alben) { + final id = (album as Map)['id'] as String?; + if (id == null) continue; + songs.addAll(await getSongs(id)); } return songs; } catch (e) { - await logger.error('Navidrome getArtistSongs Fehler: $e', e, StackTrace.current); - return []; + await logger.error( + 'Navidrome getArtistSongs Fehler: $e', e, StackTrace.current); + rethrow; } } diff --git a/lib/shared/cover.dart b/lib/shared/cover.dart index 1429773..c039047 100644 --- a/lib/shared/cover.dart +++ b/lib/shared/cover.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../services/cover_cache.dart'; import 'theme.dart'; /// Cover-Bild aus einer Datei- oder Server-Adresse, mit Noten-Platzhalter @@ -50,16 +51,28 @@ class CoverImage extends StatelessWidget { fit: BoxFit.cover, errorBuilder: (_, _, _) => placeholder, ), - // Solange das Bild vom Server unterwegs ist, steht der Platzhalter da — - // sonst springt die Zeile in der Liste, sobald es ankommt. - 'http' || 'https' => Image.network( - artUri!.toString(), - width: size, - height: size, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => placeholder, - frameBuilder: (_, kind, frame, warAusCache) => - frame == null && !warAusCache ? placeholder : kind, + // Über den Ordner statt direkt: einmal geladen, bleibt das Cover auch + // nach einem Neustart und ohne Netz da (siehe [CoverCache]). Solange + // es unterwegs ist, steht der Platzhalter — sonst springt die Zeile in + // der Liste, sobald das Bild ankommt. + 'http' || 'https' => FutureBuilder( + // Schlüssel je Adresse: in einer Liste werden Zeilen samt ihrem + // Cover-Widget wiederverwendet. Ohne ihn behielte der FutureBuilder + // das Ergebnis der vorherigen Zeile und zeigte deren Bild, bis das + // neue geladen ist. + 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, }; diff --git a/lib/shared/server_favorite_button.dart b/lib/shared/server_favorite_button.dart new file mode 100644 index 0000000..99da5f4 --- /dev/null +++ b/lib/shared/server_favorite_button.dart @@ -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 createState() => _ServerFavoriteButtonState(); +} + +class _ServerFavoriteButtonState extends State { + bool? _favorit; + bool _laeuft = false; + + Future _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, + ); + } +} diff --git a/test/player/lrc_test.dart b/test/player/lrc_test.dart new file mode 100644 index 0000000..7a06883 --- /dev/null +++ b/test/player/lrc_test.dart @@ -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); + }); + }); +} diff --git a/test/services/album_kuenstler_test.dart b/test/services/album_kuenstler_test.dart new file mode 100644 index 0000000..2fcc9aa --- /dev/null +++ b/test/services/album_kuenstler_test.dart @@ -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 antwort(List> 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()), + ); + }); + }); +} diff --git a/test/services/cover_cache_test.dart b/test/services/cover_cache_test.dart new file mode 100644 index 0000000..96f7f5e --- /dev/null +++ b/test/services/cover_cache_test.dart @@ -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')); + }); + }); +} diff --git a/test/shared/cover_test.dart b/test/shared/cover_test.dart index 618497d..5382096 100644 --- a/test/shared/cover_test.dart +++ b/test/shared/cover_test.dart @@ -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), 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',