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
@@ -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? 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<String, dynamic> 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<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 {
|
||||
// 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<List<SubsonicSong>> 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<List<SubsonicSong>> 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 = <SubsonicSong>[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user