Alben und Kuenstler offline mitnehmen; Zwischenspeicher bekommt eine Grenze

Bisher landete Musik nur zufaellig auf dem Geraet: was man abspielte, blieb
nebenbei liegen. Gezielt "dieses Album fuer die Zugfahrt" gab es nicht. Und
dieser Zwischenspeicher wuchs unbegrenzt — es gab nur "alles loeschen".

Zwei getrennte Ablagen. Bewusste Downloads liegen NICHT im Cache-Ordner:
den darf Android bei Speichernot jederzeit selbst leeren, und genau das
darf einem mitgenommenen Album nicht passieren. Sie liegen deshalb neben
der Datenbank im Support-Ordner. Die Grenze gilt nur fuer den
Zufalls-Cache; Downloads sind unbegrenzt und werden nie verdraengt.

- Download-Pfeil je Album und Kuenstler, mit Fortschritt und Abbruch
- Rueckfrage ab 30 Titeln samt Groessenschaetzung
- Obergrenze wahlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB), Standard 2 GB;
  am laengsten nicht Gehoertes weicht zuerst
- Liste "Heruntergeladen" mit Titel, Kuenstler, Groesse, einzeln oder alle
  entfernbar (Schema 10)
- Wiedergabe nimmt erst den Download, dann den Zwischenspeicher

Aus dem Code-Review nachgebessert, vier ernste Punkte: "aus" (0 MB)
schaltete das Aufraeumen ab statt das Zwischenspeichern und liess den
Cache unbegrenzt wachsen; wer sich erst waehrend der Sitzung am Server
anmeldete, konnte gar nichts laden; die Verdraengung konnte Dateien
loeschen, die die laufende Warteschlange schon als Quelle eingetragen
hatte (Abbruch mitten im Album, ohne Rueckfall aufs Streamen); und ein
heruntergeladener Titel wurde beim Abspielen ein zweites Mal in den Cache
geholt. Dazu sechs kleinere und der "zuletzt gehoert"-Stempel, der beim
Aufbau der Warteschlange statt beim Abspielen gesetzt wurde.

Nebenbei aufgeraeumt, weil die Dateien ohnehin offen waren: keine Emoji
als Knoepfe mehr, kein Colors.redAccent und kein grey.shade mehr im
ganzen Projekt, und die Einstellungen heissen nicht mehr "Settings".

433 Tests gruen (vorher 411), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
Hermes (Server)
2026-08-23 21:31:53 +02:00
co-authored by Claude Opus 5
parent 80948f1ad5
commit b42e045e67
15 changed files with 1784 additions and 32 deletions
+45 -3
View File
@@ -5,6 +5,7 @@ import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import '../library/database.dart';
import '../services/cache_manager.dart';
import '../services/download_speicher.dart';
import '../services/navidrome_service.dart';
import 'headphone_autoplay.dart';
import 'replay_gain.dart';
@@ -142,6 +143,12 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
late final SleepTimer sleepTimer;
late final Timer _positionRecordTimer;
late final CacheManager _cache;
final DownloadSpeicher _downloads = DownloadSpeicher();
/// Cache-Dateien, die gerade als Quelle in der Warteschlange stehen. Sie
/// dürfen nicht verdrängt werden — die Pfade sind im Player schon fest
/// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen.
final Set<String> _benutzteCacheDateien = {};
final NavidromeService _nav = NavidromeService();
int? _lastCountedIndex;
@@ -157,6 +164,16 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// Einstellungen gesetzt; siehe [loadPlaylist].
bool offlineOnly = false;
/// Obergrenze des Zwischenspeichers in Bytes. Wird von den Einstellungen
/// gesetzt; bewusst heruntergeladene Titel liegen woanders und zählen nicht
/// mit (siehe [DownloadSpeicher]).
///
/// **0 heißt „nichts behalten"**, nicht „unbegrenzt" — die Einstellung
/// bietet genau diese Wahl an. Der Startwert entspricht der Voreinstellung
/// der App, damit ein noch nicht übernommener Wert nie versehentlich den
/// ganzen Zwischenspeicher leert.
int cacheGrenzeBytes = 2048 * 1024 * 1024;
MeloAudioHandler({required this.db}) {
_cache = CacheManager();
_cache.init();
@@ -190,6 +207,12 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
if (index != null && index < q.length) {
mediaItem.add(q[index].copyWith(duration: _player.duration));
_applyGain(q[index]);
// Jetzt läuft er wirklich — erst das macht ihn im Sinne der
// Verdrängung zu einem „zuletzt gehörten" Titel.
final navId = navidromeIdOf(q[index]);
if (navId != null) {
unawaited(_cache.beruehre(NavidromeService.cacheSchluessel(navId)));
}
if (shouldCountPlay(index, _lastCountedIndex)) {
_lastCountedIndex = index;
final songId = songIdOf(q[index]);
@@ -254,6 +277,9 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
// unten "alle übersprungen" und meldete fälschlich den Offline-Modus.
if (items.isEmpty) return 0;
// Die alte Warteschlange ist gleich weg; ihre Dateien dürfen wieder
// verdrängt werden.
_benutzteCacheDateien.clear();
final quellen = [for (final item in items) await _quelle(item)];
final behalten = [for (final quelle in quellen) quelle != null];
final uebersprungen = behalten.where((b) => !b).length;
@@ -288,8 +314,14 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
// Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten.
final navId = navidromeIdOf(item);
if (navId != null) {
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache));
// Wer den Titel bewusst heruntergeladen hat, braucht keine zweite Kopie
// im Zwischenspeicher — das wäre doppelter Platz und doppeltes Datenvolumen.
if (navId != null && await _downloads.treffer(navId) == null) {
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache).then((_) {
// Direkt nach dem Ablegen aufräumen — sonst wächst der
// Zwischenspeicher weiter unbegrenzt.
raeumeZwischenspeicherAuf();
}));
}
return uebersprungen;
}
@@ -302,19 +334,29 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
/// gälte der Offline-Modus beim Starten, aber nicht beim Anhängen.
Future<AudioSource?> _quelle(MediaItem item) async {
final navId = navidromeIdOf(item);
// Erst die bewussten Downloads, dann der Zwischenspeicher: was der Nutzer
// mitgenommen hat, soll nie am Netz hängen — auch nicht, wenn zufällig
// eine Kopie im Cache liegt, die morgen verdrängt sein kann.
final treffer = navId == null
? null
: await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
: await _downloads.treffer(navId) ??
await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
if (offlineOnly &&
!istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) {
return null;
}
if (treffer != null) _benutzteCacheDateien.add(treffer.path);
return AudioSource.uri(
quelleFuer(item, cacheTreffer: treffer?.path),
tag: item,
);
}
/// Hält den Zwischenspeicher unter der eingestellten Grenze, ohne dabei
/// Dateien der laufenden Warteschlange anzufassen.
Future<void> raeumeZwischenspeicherAuf() =>
_cache.begrenzeAuf(cacheGrenzeBytes, geschuetzt: _benutzteCacheDateien);
/// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang.
/// Der Server zählt nur bei Server-Titeln — für Titel vom Gerät gilt
/// allein die lokale Historie.