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
186 lines
6.2 KiB
Dart
186 lines
6.2 KiB
Dart
import 'dart:io';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
/// Ein Eintrag des Zwischenspeichers, wie ihn die Verdrängung sieht.
|
|
/// Ohne Datei-Zugriff, damit [zuVerdraengen] rein bleibt.
|
|
class CacheEintrag {
|
|
const CacheEintrag({
|
|
required this.pfad,
|
|
required this.groesse,
|
|
required this.geaendertMs,
|
|
});
|
|
|
|
final String pfad;
|
|
final int groesse;
|
|
|
|
/// Wann die Datei zuletzt angefasst wurde. Bewusst „geändert" und nicht
|
|
/// „zugegriffen": Android hängt Datenträger meist mit `noatime` ein, die
|
|
/// Zugriffszeit steht dort still. Der Cache-Treffer stempelt die Datei
|
|
/// deshalb selbst um (siehe [CacheManager.getCachedFile]).
|
|
final int geaendertMs;
|
|
}
|
|
|
|
/// Welche Dateien weichen müssen, damit der Zwischenspeicher [maxBytes] nicht
|
|
/// überschreitet — am längsten nicht gehört zuerst.
|
|
///
|
|
/// Rein und ohne Dateisystem: die Reihenfolge ist der Teil, der schiefgehen
|
|
/// kann, und der lässt sich so für sich prüfen.
|
|
/// [geschuetzt] sind Pfade, die gerade als Quelle in der Warteschlange
|
|
/// stehen. Sie bleiben liegen, auch wenn die Grenze dadurch überschritten
|
|
/// bleibt: die Wiedergabe hat die Pfade schon fest eingetragen, und eine
|
|
/// gelöschte Datei ließe sie mitten im Album abbrechen — ohne Rückfall aufs
|
|
/// Streamen. Die Grenze wird beim nächsten Mal wieder eingehalten.
|
|
List<String> zuVerdraengen(
|
|
List<CacheEintrag> eintraege,
|
|
int maxBytes, {
|
|
Set<String> geschuetzt = const {},
|
|
}) {
|
|
final grenze = maxBytes < 0 ? 0 : maxBytes;
|
|
var belegt = eintraege.fold<int>(0, (summe, e) => summe + e.groesse);
|
|
if (belegt <= grenze) return const [];
|
|
|
|
final sortiert = [
|
|
for (final e in eintraege)
|
|
if (!geschuetzt.contains(e.pfad)) e,
|
|
]..sort((a, b) {
|
|
final zeit = a.geaendertMs.compareTo(b.geaendertMs);
|
|
// Gleicher Zeitstempel: nach Pfad, damit dasselbe Eingabebild immer
|
|
// dieselbe Antwort ergibt.
|
|
return zeit != 0 ? zeit : a.pfad.compareTo(b.pfad);
|
|
});
|
|
|
|
final weg = <String>[];
|
|
for (final eintrag in sortiert) {
|
|
if (belegt <= grenze) break;
|
|
weg.add(eintrag.pfad);
|
|
belegt -= eintrag.groesse;
|
|
}
|
|
return weg;
|
|
}
|
|
|
|
class CacheManager {
|
|
static const _cacheDir = 'melo_cache';
|
|
late Directory _cacheFolder;
|
|
|
|
Future<void> init() async {
|
|
final appDir = await getApplicationCacheDirectory();
|
|
_cacheFolder = Directory('${appDir.path}/$_cacheDir');
|
|
if (!await _cacheFolder.exists()) {
|
|
await _cacheFolder.create(recursive: true);
|
|
}
|
|
}
|
|
|
|
Future<int> getCacheSize() async {
|
|
int size = 0;
|
|
final files = _cacheFolder.listSync(recursive: true);
|
|
for (final file in files) {
|
|
if (file is File) {
|
|
size += await file.length();
|
|
}
|
|
}
|
|
return size;
|
|
}
|
|
|
|
Future<void> clearCache() async {
|
|
try {
|
|
if (await _cacheFolder.exists()) {
|
|
await _cacheFolder.delete(recursive: true);
|
|
}
|
|
await _cacheFolder.create(recursive: true);
|
|
debugPrint('Cache gelöscht');
|
|
} catch (e) {
|
|
debugPrint('Fehler beim Löschen des Cache: $e');
|
|
}
|
|
}
|
|
|
|
Future<File?> getCachedFile(String streamUrl) async {
|
|
try {
|
|
final hash = md5.convert(streamUrl.codeUnits).toString();
|
|
final file = File('${_cacheFolder.path}/$hash.mp3');
|
|
if (await file.exists()) return file;
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('Fehler beim Abrufen der Cache-Datei: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Stempelt die Datei auf jetzt um — erst dadurch weiß die Verdrängung,
|
|
/// was zuletzt **gehört** wurde. Die Zugriffszeit des Dateisystems taugt
|
|
/// dafür nicht (siehe [CacheEintrag.geaendertMs]).
|
|
///
|
|
/// Bewusst getrennt vom Nachschlagen: beim Aufbau einer Warteschlange wird
|
|
/// jede Datei einmal nachgeschlagen. Würde dabei umgestempelt, hätten alle
|
|
/// fast denselben Zeitstempel und die Verdrängung wüsste nicht mehr, was
|
|
/// wirklich gehört wurde.
|
|
Future<void> beruehre(String streamUrl) async {
|
|
try {
|
|
final hash = md5.convert(streamUrl.codeUnits).toString();
|
|
final file = File('${_cacheFolder.path}/$hash.mp3');
|
|
if (await file.exists()) await file.setLastModified(DateTime.now());
|
|
} catch (e) {
|
|
debugPrint('Cache-Zeitstempel nicht setzbar: $e');
|
|
}
|
|
}
|
|
|
|
/// Alle Einträge mit Größe und Zeitstempel — Grundlage für [zuVerdraengen].
|
|
Future<List<CacheEintrag>> eintraege() async {
|
|
if (!await _cacheFolder.exists()) return const [];
|
|
final gefunden = <CacheEintrag>[];
|
|
for (final eintrag in _cacheFolder.listSync()) {
|
|
if (eintrag is! File) continue;
|
|
// Halbe Downloads zählen nicht mit; sie räumt der Downloader selbst weg.
|
|
if (eintrag.path.endsWith('.part')) continue;
|
|
try {
|
|
final status = eintrag.statSync();
|
|
gefunden.add(CacheEintrag(
|
|
pfad: eintrag.path,
|
|
groesse: status.size,
|
|
geaendertMs: status.modified.millisecondsSinceEpoch,
|
|
));
|
|
} catch (e) {
|
|
debugPrint('Cache-Eintrag nicht lesbar: $e');
|
|
}
|
|
}
|
|
return gefunden;
|
|
}
|
|
|
|
/// Hält den Zwischenspeicher unter [maxBytes] und meldet, wie viele Dateien
|
|
/// dafür weichen mussten. Die bewusst heruntergeladenen Titel liegen in
|
|
/// einem anderen Ordner und sind davon nie betroffen.
|
|
Future<int> begrenzeAuf(int maxBytes, {Set<String> geschuetzt = const {}}) async {
|
|
final weg =
|
|
zuVerdraengen(await eintraege(), maxBytes, geschuetzt: geschuetzt);
|
|
var geloescht = 0;
|
|
for (final pfad in weg) {
|
|
try {
|
|
await File(pfad).delete();
|
|
geloescht++;
|
|
} catch (e) {
|
|
debugPrint('Cache-Eintrag nicht löschbar: $e');
|
|
}
|
|
}
|
|
if (geloescht > 0) debugPrint('Zwischenspeicher: $geloescht Dateien verdrängt');
|
|
return geloescht;
|
|
}
|
|
|
|
Future<File> getCacheFile(String streamUrl) async {
|
|
final hash = md5.convert(streamUrl.codeUnits).toString();
|
|
return File('${_cacheFolder.path}/$hash.mp3');
|
|
}
|
|
|
|
Stream<File> streamAndCache(Stream<List<int>> sourceStream, String streamUrl) async* {
|
|
final cacheFile = await getCacheFile(streamUrl);
|
|
final sink = cacheFile.openWrite();
|
|
|
|
await for (final chunk in sourceStream) {
|
|
sink.add(chunk);
|
|
yield cacheFile;
|
|
}
|
|
|
|
await sink.close();
|
|
}
|
|
}
|