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:
co-authored by
Claude Opus 5
parent
b42e045e67
commit
0d76d8cc58
@@ -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 (K1–K5) 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
|
> (K1–K5) sowie G1–G10. 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-6
@@ -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.
|
||||||
|
'http' || 'https' => FutureBuilder<File?>(
|
||||||
|
// 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,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, _, _) => placeholder,
|
errorBuilder: (_, _, _) => placeholder,
|
||||||
frameBuilder: (_, kind, frame, warAusCache) =>
|
);
|
||||||
frame == null && !warAusCache ? placeholder : kind,
|
},
|
||||||
),
|
),
|
||||||
_ => placeholder,
|
_ => placeholder,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user