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
+25
View File
@@ -7,10 +7,19 @@ class AppSettings extends ChangeNotifier {
static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover';
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
static const _keyNormalizeVolume = 'gleiche_lautstaerke';
static const _keyCacheGrenze = 'cache_grenze_mb';
/// Voreinstellung für den Zwischenspeicher: 2 GB. Groß genug für einige
/// hundert Titel, klein genug, um ein Telefon nicht zuzumüllen.
static const cacheGrenzeStandardMb = 2048;
/// Zur Auswahl stehende Grenzen in MB. 0 heißt: gar nicht zwischenspeichern.
static const cacheGrenzenMb = [0, 512, 1024, 2048, 4096, 8192];
bool _groupCoversByCategory = true;
bool _autoPlayOnHeadphones = false;
bool _normalizeVolume = true;
int _cacheGrenzeMb = cacheGrenzeStandardMb;
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
@@ -23,14 +32,30 @@ class AppSettings extends ChangeNotifier {
/// auf das Niveau der leiseren abgesenkt.
bool get normalizeVolume => _normalizeVolume;
/// Obergrenze des Zwischenspeichers in MB. Gilt nur für das, was beim
/// Abspielen nebenbei anfällt — bewusst heruntergeladene Titel liegen in
/// einem eigenen Ordner und werden nie verdrängt.
int get cacheGrenzeMb => _cacheGrenzeMb;
int get cacheGrenzeBytes => _cacheGrenzeMb * 1024 * 1024;
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
_cacheGrenzeMb = prefs.getInt(_keyCacheGrenze) ?? cacheGrenzeStandardMb;
notifyListeners();
}
Future<void> setCacheGrenzeMb(int mb) async {
if (_cacheGrenzeMb == mb) return;
_cacheGrenzeMb = mb;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyCacheGrenze, mb);
}
Future<void> setGroupCoversByCategory(bool value) async {
if (_groupCoversByCategory == value) return;
_groupCoversByCategory = value;
+58 -14
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../downloads/downloads_liste_screen.dart';
import '../library/database.dart';
import '../library/library_service.dart';
import '../library/permissions.dart';
@@ -62,7 +63,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
appBar: AppBar(title: const Text('Einstellungen')),
body: ListView(
children: [
const _SectionLabel('Bibliothek'),
@@ -199,18 +200,61 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
),
const _SectionLabel('Speicher'),
StreamBuilder<List<Download>>(
stream: db.watchDownloads(),
builder: (context, snapshot) {
final eintraege = snapshot.data ?? const <Download>[];
final bytes =
eintraege.fold<int>(0, (summe, e) => summe + e.groesseBytes);
return ListTile(
leading: const Icon(Icons.download_for_offline_outlined),
title: const Text('Heruntergeladen'),
subtitle: Text(eintraege.isEmpty
? 'Noch nichts heruntergeladen'
: '${eintraege.length} Titel · '
'${DownloadsListeScreen.formatiereBytes(bytes)}'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const DownloadsListeScreen(),
)),
);
},
),
ListTile(
leading: const Icon(Icons.storage),
title: const Text('Cache-Speicher'),
subtitle: Text(_formatBytes(_cacheSize)),
title: const Text('Zwischenspeicher'),
// Zwei Zahlen, weil es zwei Töpfe sind: was hier steht, ist beim
// Abspielen nebenbei angefallen und darf verdrängt werden.
subtitle: Text('${_formatBytes(_cacheSize)} von '
'${context.watch<AppSettings>().cacheGrenzeMb} MB'),
trailing: _cacheSize > 0
? IconButton(
tooltip: 'Cache löschen',
tooltip: 'Zwischenspeicher leeren',
icon: const Icon(Icons.delete),
onPressed: () => _clearCache(),
)
: null,
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Obergrenze des Zwischenspeichers'),
subtitle: const Text(
'Gilt nicht für heruntergeladene Titel — die bleiben.'),
trailing: DropdownButton<int>(
value: context.watch<AppSettings>().cacheGrenzeMb,
items: [
for (final mb in AppSettings.cacheGrenzenMb)
DropdownMenuItem(
value: mb,
child: Text(mb == 0 ? 'aus' : '$mb MB'),
),
],
onChanged: (mb) {
if (mb != null) context.read<AppSettings>().setCacheGrenzeMb(mb);
},
),
),
ListTile(
leading: const Icon(Icons.favorite),
title: const Text('Favoriten-Sync'),
@@ -268,9 +312,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: Colors.grey.shade900,
backgroundColor: MeloTheme.surface,
title: const Text('Navidrome-Anmeldung',
style: TextStyle(color: Colors.white, fontSize: 16)),
style: TextStyle(color: MeloTheme.text1, fontSize: 16)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -294,8 +338,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => setDialogState(() => selectedUser = 'Baka'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Baka'
? Colors.redAccent
: Colors.grey.shade700,
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Baka'),
),
@@ -306,8 +350,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => setDialogState(() => selectedUser = 'Tinker'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Tinker'
? Colors.redAccent
: Colors.grey.shade700,
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Tinker'),
),
@@ -318,8 +362,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: () => setDialogState(() => selectedUser = 'Dustin'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Dustin'
? Colors.redAccent
: Colors.grey.shade700,
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Dustin'),
),
@@ -358,7 +402,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.redAccent),
strokeWidth: 2, color: MeloTheme.red),
),
),
],
@@ -401,7 +445,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
},
child: const Text('Login',
style: TextStyle(color: Colors.redAccent)),
style: TextStyle(color: MeloTheme.red)),
),
],
),