diff --git a/CHANGELOG.md b/CHANGELOG.md index d46775d..ceb17e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,77 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### đŸ“„ 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 +nebenbei liegen. Gezielt „dieses Album fĂŒr die Zugfahrt" gab es nicht. Und +dieser Zwischenspeicher wuchs **unbegrenzt** — es gab nur „alles löschen". + +- đŸ“„ **Download-Pfeil neben jedem Album und KĂŒnstler** im Bereich + Download → Server. WĂ€hrend es lĂ€uft, steht oben ein Fortschritt mit + Abbruch-Knopf. +- ⚠ **RĂŒckfrage bei großen Mengen.** Ein Album geht ohne Nachfrage durch; + ab 30 Titeln fragt die App erst („347 Titel, geschĂ€tzt etwa 2100 MB"). + Ein ganzer KĂŒnstler soll niemandem aus Versehen das Datenvolumen kosten. +- đŸ—‚ïž **Zwei getrennte Töpfe.** Bewusste Downloads liegen **nicht** im + Cache-Ordner. Der Grund ist keine Geschmacksfrage: den Cache-Ordner darf + Android bei Speichernot jederzeit selbst leeren — genau das darf einem + Album, das man mitgenommen hat, nicht passieren. Downloads liegen deshalb + neben der Datenbank, wo das System nichts anrĂŒhrt. +- 🚧 **Obergrenze fĂŒr den Zwischenspeicher**, voreingestellt 2 GB, in den + Einstellungen wĂ€hlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB). Ist sie erreicht, + fliegt das am lĂ€ngsten nicht Gehörte zuerst raus. **Heruntergeladene Titel + sind davon nie betroffen** — wer ein Album mitnimmt, hat das entschieden, + und die App nimmt es ihm nicht wieder weg. +- 📋 **Liste „Heruntergeladen"** in den Einstellungen: Titel, KĂŒnstler, + GrĂ¶ĂŸe, einzeln oder alle entfernbar. Sie kommt aus der Datenbank und nicht + aus dem Ordner — die Dateien heißen nach einem Hash und stĂŒnden dort sonst + als „a3f9
mp3". +- ▶ **Die Wiedergabe schaut erst in die Downloads, dann in den + Zwischenspeicher.** Ein mitgenommener Titel hĂ€ngt nie am Netz, auch wenn + zufĂ€llig eine Cache-Kopie existiert, die morgen verdrĂ€ngt sein kann. +- đŸ§č **Nebenbei aufgerĂ€umt** (die Dateien waren ohnehin offen): die + Emoji-Knöpfe „📀 Alben" und â€žđŸŽ€ KĂŒnstler" heißen jetzt schlicht „Alben" + und „KĂŒnstler", das grelle `Colors.redAccent` ist ĂŒberall dem Melo-Rot + gewichen, und die Einstellungen heißen nicht mehr „Settings". +- 🔍 **Aus dem Code-Review nachgebessert — zehn Punkte, vier davon ernst:** + **(1)** „Aus" bewirkte das Gegenteil. Bei 0 MB ĂŒbersprang die App das + AufrĂ€umen ganz, legte aber weiter jeden Titel ab — genau der unbegrenzt + wachsende Speicher, den diese Änderung beheben soll. „Aus" heißt jetzt + wirklich: nichts behalten. + **(2)** Wer sich erst **in** der Sitzung am Musikserver anmeldete, konnte + nichts herunterladen. Der Dienst hielt eine eigene Verbindung, die nur + beim App-Start eingerichtet wurde; die Titelliste kam, der Download lief + ins Leere. Erst ein Neustart half. + **(3)** Die AufrĂ€um-Aktion konnte Dateien löschen, die die **laufende** + Warteschlange schon als Quelle eingetragen hatte — die Wiedergabe wĂ€re + mitten im Album abgebrochen, ohne auf Streamen zurĂŒckzufallen. Benutzte + Dateien sind jetzt geschĂŒtzt; im Zweifel bleibt die App lieber kurz ĂŒber + der Grenze, als die Musik abzuwĂŒrgen. + **(4)** Ein bewusst heruntergeladener Titel wurde beim Abspielen **noch + einmal** in den Zwischenspeicher geladen — doppelter Platz, doppeltes + Datenvolumen. + Dazu sechs kleinere: ein einzelner Fehlschlag verdeckte nicht mehr den + Erfolg der ĂŒbrigen Titel („18 heruntergeladen · 2 fehlgeschlagen"); eine + Datei ohne BuchfĂŒhrungs-Zeile (App mittendrin beendet) wird jetzt + nachgetragen statt fĂŒr immer unsichtbar zu bleiben; zweimal antippen meldet + nicht mehr fĂ€lschlich Erfolg; „LĂ€dt 21 von 20" gibt es nicht mehr; eine + gesenkte Grenze wirkt sofort statt erst beim nĂ€chsten gestreamten Titel; + und drei ungenutzte Methoden sind raus. + +- 🕐 **Auch aus dem Review:** Der „zuletzt gehört"-Stempel wurde beim + **Aufbau** der Warteschlange gesetzt — damit hatten nach einem Albumstart + fĂŒnfzig Dateien fast denselben Zeitstempel und die VerdrĂ€ngung wusste nicht + mehr, was wirklich gehört wurde. Gestempelt wird jetzt, wenn ein Titel + tatsĂ€chlich losspielt. + +- ✅ **Getestet:** 433 Tests grĂŒn (vorher 411), `flutter analyze` ohne + Befund, Release-APK gebaut. Neu: 13 Tests fĂŒr die VerdrĂ€ngungs-Regel + (welche Datei weicht wann, bei gleichem Zeitstempel nie zufĂ€llig, und + benutzte Dateien nie) und 9 fĂŒr RĂŒckfrage-Schwelle, GrĂ¶ĂŸenschĂ€tzung und + Dateinamen. + + ### ▶ Man sieht jetzt, welcher Titel gerade lĂ€uft (2026-08-23) Bisher scrollte man durch die Bibliothek und musste raten, wo man gerade diff --git a/lib/downloads/downloads_liste_screen.dart b/lib/downloads/downloads_liste_screen.dart new file mode 100644 index 0000000..e8e6499 --- /dev/null +++ b/lib/downloads/downloads_liste_screen.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../library/database.dart'; +import '../services/download_service.dart'; +import '../shared/theme.dart'; + +/// Übersicht der bewusst heruntergeladenen Titel, mit der Möglichkeit, sie +/// einzeln oder alle wieder vom GerĂ€t zu nehmen. +/// +/// Die Liste kommt aus der Datenbank, nicht aus dem Ordner: die Dateien +/// heißen nach einem Hash und wĂŒrden hier sonst als „a3f9
mp3" stehen. +class DownloadsListeScreen extends StatelessWidget { + const DownloadsListeScreen({super.key}); + + static String formatiereBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + Future _alleEntfernen(BuildContext context, int anzahl) async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ja = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Alle Downloads entfernen?'), + content: Text('$anzahl Titel werden vom GerĂ€t genommen. ' + 'Am Server bleiben sie unverĂ€ndert.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Entfernen'), + ), + ], + ), + ); + if (ja != true) return; + await dienst.entferneAlle(); + messenger.showSnackBar( + const SnackBar(content: Text('Downloads entfernt')), + ); + } + + @override + Widget build(BuildContext context) { + final db = context.read(); + return Scaffold( + appBar: AppBar(title: const Text('Heruntergeladen')), + body: StreamBuilder>( + stream: db.watchDownloads(), + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Text('Fehler: ${snapshot.error}', + style: const TextStyle(color: MeloTheme.text2)), + ); + } + final eintraege = snapshot.data ?? const []; + if (eintraege.isEmpty) return const _Leer(); + final gesamt = + eintraege.fold(0, (summe, e) => summe + e.groesseBytes); + return Column( + children: [ + ListTile( + title: Text('${eintraege.length} Titel · ' + '${formatiereBytes(gesamt)}'), + subtitle: const Text( + 'Diese Titel laufen ohne Netz und werden nie von selbst ' + 'gelöscht.'), + trailing: TextButton( + onPressed: () => _alleEntfernen(context, eintraege.length), + child: const Text('Alle entfernen'), + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: eintraege.length, + itemBuilder: (context, i) { + final eintrag = eintraege[i]; + return ListTile( + leading: const Icon(Icons.download_done), + title: Text(eintrag.titel, + maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + [ + eintrag.kuenstler ?? 'Unbekannt', + formatiereBytes(eintrag.groesseBytes), + ].join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + tooltip: 'Vom GerĂ€t nehmen', + icon: const Icon(Icons.delete_outline), + onPressed: () => context + .read() + .entferne(eintrag.navidromeId), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ); + } +} + +class _Leer extends StatelessWidget { + const _Leer(); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: MeloSpace.xl), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.download_for_offline_outlined, + size: 64, color: Colors.white24), + SizedBox(height: MeloSpace.sm + MeloSpace.xs), + Text('Noch nichts heruntergeladen', + style: TextStyle(color: MeloTheme.text2)), + SizedBox(height: MeloSpace.xs + 2), + Text( + 'Im Bereich Download → Server steht neben jedem Album und ' + 'KĂŒnstler ein Pfeil.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white30, fontSize: 12), + ), + ], + ), + ), + ); + } +} diff --git a/lib/downloads/downloads_screen.dart b/lib/downloads/downloads_screen.dart index 136c923..9920950 100644 --- a/lib/downloads/downloads_screen.dart +++ b/lib/downloads/downloads_screen.dart @@ -11,6 +11,7 @@ import '../library/song_media.dart'; import '../player/audio_handler.dart'; import '../services/baka_auth.dart'; import '../services/media_store.dart'; +import '../services/download_service.dart'; import '../services/navidrome_service.dart'; import '../services/server_neuheiten.dart'; import '../services/yt_download_service.dart'; @@ -165,7 +166,7 @@ class _ServerBrowserState extends State<_ServerBrowser> { if (_loading) { return const Center( - child: CircularProgressIndicator(color: Colors.redAccent), + child: CircularProgressIndicator(color: MeloTheme.red), ); } @@ -179,7 +180,7 @@ class _ServerBrowserState extends State<_ServerBrowser> { children: [ Icon(hatFehler ? Icons.error_outline : Icons.album, size: 64, - color: hatFehler ? Colors.redAccent : Colors.white24), + color: hatFehler ? MeloTheme.red : Colors.white24), const SizedBox(height: 12), Text( hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen', @@ -200,6 +201,7 @@ class _ServerBrowserState extends State<_ServerBrowser> { return Column( children: [ + const _LadeFortschritt(), if (_neuheiten.neue.isNotEmpty) MaterialBanner( backgroundColor: MeloTheme.surfaceHigh, @@ -229,9 +231,10 @@ class _ServerBrowserState extends State<_ServerBrowser> { if (_albums.isEmpty) _loadAlbums(); }), style: FilledButton.styleFrom( - backgroundColor: !_showArtists ? Colors.redAccent : Colors.grey.shade700, + backgroundColor: + !_showArtists ? MeloTheme.red : MeloTheme.surfaceHigh, ), - child: const Text('📀 Alben'), + child: const Text('Alben'), ), ), const SizedBox(width: 8), @@ -242,9 +245,10 @@ class _ServerBrowserState extends State<_ServerBrowser> { if (_artists.isEmpty) _loadArtists(); }), style: FilledButton.styleFrom( - backgroundColor: _showArtists ? Colors.redAccent : Colors.grey.shade700, + backgroundColor: + _showArtists ? MeloTheme.red : MeloTheme.surfaceHigh, ), - child: const Text('đŸŽ€ KĂŒnstler'), + child: const Text('KĂŒnstler'), ), ), ], @@ -262,6 +266,10 @@ class _ServerBrowserState extends State<_ServerBrowser> { child: ListTile( leading: const Icon(Icons.person, size: 48), title: Text(artist.name), + trailing: _LadeKnopf( + tooltip: 'Alles von ${artist.name} herunterladen', + holeTitel: () => _nav.getArtistSongs(artist.id), + ), onTap: () => _playArtist(artist), ), ); @@ -278,10 +286,14 @@ class _ServerBrowserState extends State<_ServerBrowser> { leading: Icon( Icons.album, size: 48, - color: Colors.redAccent.withValues(alpha: 0.5), + color: MeloTheme.red.withValues(alpha: 0.5), ), title: Text(album.name), - subtitle: Text('${album.songCount} Songs'), + subtitle: Text('${album.songCount} Titel'), + trailing: _LadeKnopf( + tooltip: 'Album herunterladen', + holeTitel: () => _nav.getSongs(album.id), + ), onTap: () => _playAlbum(album), ), ); @@ -363,6 +375,171 @@ class _ServerBrowserState extends State<_ServerBrowser> { } } +/// Knopf, der die Titel hinter einem Album oder KĂŒnstler herunterlĂ€dt. +/// +/// [holeTitel] wird erst beim Antippen aufgerufen — die Titelliste eines +/// Albums kostet einen Serveraufruf, und den fĂŒr jede sichtbare Zeile im +/// Voraus zu machen wĂ€re Unfug. +class _LadeKnopf extends StatefulWidget { + const _LadeKnopf({required this.tooltip, required this.holeTitel}); + + final String tooltip; + final Future> Function() holeTitel; + + @override + State<_LadeKnopf> createState() => _LadeKnopfState(); +} + +class _LadeKnopfState extends State<_LadeKnopf> { + bool _holt = false; + + Future _antippen() async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + if (dienst.laeuft) { + messenger.showSnackBar( + const SnackBar(content: Text('Es lĂ€uft schon ein Download')), + ); + return; + } + + setState(() => _holt = true); + final List titel; + try { + titel = await widget.holeTitel(); + } catch (e) { + if (mounted) setState(() => _holt = false); + messenger.showSnackBar(SnackBar(content: Text('Fehler: $e'))); + return; + } + if (!mounted) return; + setState(() => _holt = false); + + if (titel.isEmpty) { + messenger.showSnackBar(const SnackBar(content: Text('Nichts zu laden'))); + return; + } + // Ein Album geht ohne Nachfrage durch; ein ganzer KĂŒnstler kann + // mehrere hundert Titel und einige Gigabyte sein. + if (brauchtRueckfrage(titel.length) && !await _bestaetigt(titel)) return; + + // Zweite PrĂŒfung: zwischen der ersten und jetzt lagen ein Serveraufruf + // und womöglich ein Dialog. In der Zeit kann ein anderer Knopf einen + // Download gestartet haben — `lade` liefert dann sofort 0, und ohne diese + // PrĂŒfung meldete die App „War schon heruntergeladen". + if (dienst.laeuft) { + messenger.showSnackBar( + const SnackBar(content: Text('Es lĂ€uft schon ein Download')), + ); + return; + } + + final neu = await dienst.lade(titel); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(_meldung(dienst, neu)))); + } + + /// Sagt beides: was ankam und was nicht. Ein einzelner Fehlschlag darf + /// nicht den Erfolg der ĂŒbrigen neunzehn Titel verdecken. + static String _meldung(DownloadService dienst, int neu) { + final teile = []; + if (neu > 0) teile.add('$neu Titel heruntergeladen'); + if (dienst.fehlgeschlagen > 0) { + teile.add('${dienst.fehlgeschlagen} fehlgeschlagen'); + } + if (teile.isEmpty) return 'War schon heruntergeladen'; + return teile.join(' · '); + } + + Future _bestaetigt(List titel) async { + final mb = geschaetzteBytes([for (final t in titel) t.dauerSekunden]) ~/ + (1024 * 1024); + final ja = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Wirklich herunterladen?'), + content: Text('${titel.length} Titel, geschĂ€tzt etwa $mb MB.\n' + 'Die SchĂ€tzung ist grob — der Server nennt die genaue GrĂ¶ĂŸe ' + 'erst beim Abruf.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Abbrechen'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Herunterladen'), + ), + ], + ), + ); + return ja ?? false; + } + + @override + Widget build(BuildContext context) { + if (_holt) { + return const SizedBox( + width: MeloTheme.minTouchTarget, + height: MeloTheme.minTouchTarget, + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + return IconButton( + tooltip: widget.tooltip, + icon: const Icon(Icons.download_for_offline_outlined), + onPressed: _antippen, + ); + } +} + +/// Zeigt den laufenden Download ĂŒber der Liste, mit Abbruch. +class _LadeFortschritt extends StatelessWidget { + const _LadeFortschritt(); + + @override + Widget build(BuildContext context) { + final dienst = context.watch(); + if (!dienst.laeuft) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB( + MeloSpace.md, MeloSpace.sm, MeloSpace.sm, 0), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'LĂ€dt ${(dienst.fertig + 1).clamp(1, dienst.gesamt)} ' + 'von ${dienst.gesamt}' + '${dienst.aktuell == null ? '' : ' — ${dienst.aktuell}'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: MeloTheme.text2, fontSize: 12), + ), + const SizedBox(height: MeloSpace.xs), + LinearProgressIndicator(value: dienst.anteil), + ], + ), + ), + TextButton( + onPressed: dienst.abbrechen, + child: const Text('Abbrechen'), + ), + ], + ), + ); + } +} + /// Merkt sich, ob der Proxy seine YouTube-Cookies benutzen soll. const _cookiesSchluessel = 'yt_cookies'; diff --git a/lib/library/database.dart b/lib/library/database.dart index 5a5f21f..81ffa07 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -117,6 +117,23 @@ class PlaybackHistory extends Table { Set get primaryKey => {songId, playedAtMs}; } +/// Bewusst heruntergeladene Server-Titel. +/// +/// Die Dateien allein wĂŒrden nicht reichen: sie heißen nach einem Hash, und +/// ohne diese Zeilen könnte die App nur „37 Dateien, 412 MB" zeigen statt +/// einer Liste mit Titeln und KĂŒnstlern. +class Downloads extends Table { + TextColumn get navidromeId => text()(); + TextColumn get titel => text()(); + TextColumn get kuenstler => text().nullable()(); + TextColumn get album => text().nullable()(); + IntColumn get groesseBytes => integer()(); + IntColumn get geladenAmMs => integer()(); + + @override + Set get primaryKey => {navidromeId}; +} + @DriftDatabase(tables: [ Songs, Folders, @@ -125,12 +142,13 @@ class PlaybackHistory extends Table { Favorites, PlaybackHistory, SongCategories, + Downloads, ]) class MeloDb extends _$MeloDb { MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); @override - int get schemaVersion => 9; + int get schemaVersion => 10; @override MigrationStrategy get migration => MigrationStrategy( @@ -169,6 +187,9 @@ class MeloDb extends _$MeloDb { // Dateien Ă€ndert. await _ergaenzeAlbumKategorien(); } + if (from < 10) { + await m.createTable(downloads); + } }, ); @@ -524,6 +545,23 @@ class MeloDb extends _$MeloDb { .get(); } + // === Downloads === + Stream> watchDownloads() => (select(downloads) + ..orderBy([ + (d) => OrderingTerm(expression: d.geladenAmMs, mode: OrderingMode.desc) + ])) + .watch(); + + Future merkeDownload(DownloadsCompanion eintrag) => + into(downloads).insert(eintrag, mode: InsertMode.insertOrReplace); + + Future vergissDownload(String navidromeId) => (delete(downloads) + ..where((d) => d.navidromeId.equals(navidromeId))) + .go(); + + Future vergissAlleDownloads() => delete(downloads).go(); + + Future lastPosition(String songId) async { final row = await (select(playbackHistory) ..where((h) => h.songId.equals(songId)) diff --git a/lib/library/database.g.dart b/lib/library/database.g.dart index 1ee0346..f3edabd 100644 --- a/lib/library/database.g.dart +++ b/lib/library/database.g.dart @@ -2767,6 +2767,440 @@ class SongCategoriesCompanion extends UpdateCompanion { } } +class $DownloadsTable extends Downloads + with TableInfo<$DownloadsTable, Download> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $DownloadsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _navidromeIdMeta = const VerificationMeta( + 'navidromeId', + ); + @override + late final GeneratedColumn navidromeId = GeneratedColumn( + 'navidrome_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _titelMeta = const VerificationMeta('titel'); + @override + late final GeneratedColumn titel = GeneratedColumn( + 'titel', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _kuenstlerMeta = const VerificationMeta( + 'kuenstler', + ); + @override + late final GeneratedColumn kuenstler = GeneratedColumn( + 'kuenstler', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _albumMeta = const VerificationMeta('album'); + @override + late final GeneratedColumn album = GeneratedColumn( + 'album', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _groesseBytesMeta = const VerificationMeta( + 'groesseBytes', + ); + @override + late final GeneratedColumn groesseBytes = GeneratedColumn( + 'groesse_bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _geladenAmMsMeta = const VerificationMeta( + 'geladenAmMs', + ); + @override + late final GeneratedColumn geladenAmMs = GeneratedColumn( + 'geladen_am_ms', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + navidromeId, + titel, + kuenstler, + album, + groesseBytes, + geladenAmMs, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'downloads'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('navidrome_id')) { + context.handle( + _navidromeIdMeta, + navidromeId.isAcceptableOrUnknown( + data['navidrome_id']!, + _navidromeIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_navidromeIdMeta); + } + if (data.containsKey('titel')) { + context.handle( + _titelMeta, + titel.isAcceptableOrUnknown(data['titel']!, _titelMeta), + ); + } else if (isInserting) { + context.missing(_titelMeta); + } + if (data.containsKey('kuenstler')) { + context.handle( + _kuenstlerMeta, + kuenstler.isAcceptableOrUnknown(data['kuenstler']!, _kuenstlerMeta), + ); + } + if (data.containsKey('album')) { + context.handle( + _albumMeta, + album.isAcceptableOrUnknown(data['album']!, _albumMeta), + ); + } + if (data.containsKey('groesse_bytes')) { + context.handle( + _groesseBytesMeta, + groesseBytes.isAcceptableOrUnknown( + data['groesse_bytes']!, + _groesseBytesMeta, + ), + ); + } else if (isInserting) { + context.missing(_groesseBytesMeta); + } + if (data.containsKey('geladen_am_ms')) { + context.handle( + _geladenAmMsMeta, + geladenAmMs.isAcceptableOrUnknown( + data['geladen_am_ms']!, + _geladenAmMsMeta, + ), + ); + } else if (isInserting) { + context.missing(_geladenAmMsMeta); + } + return context; + } + + @override + Set get $primaryKey => {navidromeId}; + @override + Download map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return Download( + navidromeId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}navidrome_id'], + )!, + titel: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}titel'], + )!, + kuenstler: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}kuenstler'], + ), + album: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album'], + ), + groesseBytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}groesse_bytes'], + )!, + geladenAmMs: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}geladen_am_ms'], + )!, + ); + } + + @override + $DownloadsTable createAlias(String alias) { + return $DownloadsTable(attachedDatabase, alias); + } +} + +class Download extends DataClass implements Insertable { + final String navidromeId; + final String titel; + final String? kuenstler; + final String? album; + final int groesseBytes; + final int geladenAmMs; + const Download({ + required this.navidromeId, + required this.titel, + this.kuenstler, + this.album, + required this.groesseBytes, + required this.geladenAmMs, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['navidrome_id'] = Variable(navidromeId); + map['titel'] = Variable(titel); + if (!nullToAbsent || kuenstler != null) { + map['kuenstler'] = Variable(kuenstler); + } + if (!nullToAbsent || album != null) { + map['album'] = Variable(album); + } + map['groesse_bytes'] = Variable(groesseBytes); + map['geladen_am_ms'] = Variable(geladenAmMs); + return map; + } + + DownloadsCompanion toCompanion(bool nullToAbsent) { + return DownloadsCompanion( + navidromeId: Value(navidromeId), + titel: Value(titel), + kuenstler: kuenstler == null && nullToAbsent + ? const Value.absent() + : Value(kuenstler), + album: album == null && nullToAbsent + ? const Value.absent() + : Value(album), + groesseBytes: Value(groesseBytes), + geladenAmMs: Value(geladenAmMs), + ); + } + + factory Download.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return Download( + navidromeId: serializer.fromJson(json['navidromeId']), + titel: serializer.fromJson(json['titel']), + kuenstler: serializer.fromJson(json['kuenstler']), + album: serializer.fromJson(json['album']), + groesseBytes: serializer.fromJson(json['groesseBytes']), + geladenAmMs: serializer.fromJson(json['geladenAmMs']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'navidromeId': serializer.toJson(navidromeId), + 'titel': serializer.toJson(titel), + 'kuenstler': serializer.toJson(kuenstler), + 'album': serializer.toJson(album), + 'groesseBytes': serializer.toJson(groesseBytes), + 'geladenAmMs': serializer.toJson(geladenAmMs), + }; + } + + Download copyWith({ + String? navidromeId, + String? titel, + Value kuenstler = const Value.absent(), + Value album = const Value.absent(), + int? groesseBytes, + int? geladenAmMs, + }) => Download( + navidromeId: navidromeId ?? this.navidromeId, + titel: titel ?? this.titel, + kuenstler: kuenstler.present ? kuenstler.value : this.kuenstler, + album: album.present ? album.value : this.album, + groesseBytes: groesseBytes ?? this.groesseBytes, + geladenAmMs: geladenAmMs ?? this.geladenAmMs, + ); + Download copyWithCompanion(DownloadsCompanion data) { + return Download( + navidromeId: data.navidromeId.present + ? data.navidromeId.value + : this.navidromeId, + titel: data.titel.present ? data.titel.value : this.titel, + kuenstler: data.kuenstler.present ? data.kuenstler.value : this.kuenstler, + album: data.album.present ? data.album.value : this.album, + groesseBytes: data.groesseBytes.present + ? data.groesseBytes.value + : this.groesseBytes, + geladenAmMs: data.geladenAmMs.present + ? data.geladenAmMs.value + : this.geladenAmMs, + ); + } + + @override + String toString() { + return (StringBuffer('Download(') + ..write('navidromeId: $navidromeId, ') + ..write('titel: $titel, ') + ..write('kuenstler: $kuenstler, ') + ..write('album: $album, ') + ..write('groesseBytes: $groesseBytes, ') + ..write('geladenAmMs: $geladenAmMs') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + navidromeId, + titel, + kuenstler, + album, + groesseBytes, + geladenAmMs, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is Download && + other.navidromeId == this.navidromeId && + other.titel == this.titel && + other.kuenstler == this.kuenstler && + other.album == this.album && + other.groesseBytes == this.groesseBytes && + other.geladenAmMs == this.geladenAmMs); +} + +class DownloadsCompanion extends UpdateCompanion { + final Value navidromeId; + final Value titel; + final Value kuenstler; + final Value album; + final Value groesseBytes; + final Value geladenAmMs; + final Value rowid; + const DownloadsCompanion({ + this.navidromeId = const Value.absent(), + this.titel = const Value.absent(), + this.kuenstler = const Value.absent(), + this.album = const Value.absent(), + this.groesseBytes = const Value.absent(), + this.geladenAmMs = const Value.absent(), + this.rowid = const Value.absent(), + }); + DownloadsCompanion.insert({ + required String navidromeId, + required String titel, + this.kuenstler = const Value.absent(), + this.album = const Value.absent(), + required int groesseBytes, + required int geladenAmMs, + this.rowid = const Value.absent(), + }) : navidromeId = Value(navidromeId), + titel = Value(titel), + groesseBytes = Value(groesseBytes), + geladenAmMs = Value(geladenAmMs); + static Insertable custom({ + Expression? navidromeId, + Expression? titel, + Expression? kuenstler, + Expression? album, + Expression? groesseBytes, + Expression? geladenAmMs, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (navidromeId != null) 'navidrome_id': navidromeId, + if (titel != null) 'titel': titel, + if (kuenstler != null) 'kuenstler': kuenstler, + if (album != null) 'album': album, + if (groesseBytes != null) 'groesse_bytes': groesseBytes, + if (geladenAmMs != null) 'geladen_am_ms': geladenAmMs, + if (rowid != null) 'rowid': rowid, + }); + } + + DownloadsCompanion copyWith({ + Value? navidromeId, + Value? titel, + Value? kuenstler, + Value? album, + Value? groesseBytes, + Value? geladenAmMs, + Value? rowid, + }) { + return DownloadsCompanion( + navidromeId: navidromeId ?? this.navidromeId, + titel: titel ?? this.titel, + kuenstler: kuenstler ?? this.kuenstler, + album: album ?? this.album, + groesseBytes: groesseBytes ?? this.groesseBytes, + geladenAmMs: geladenAmMs ?? this.geladenAmMs, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (navidromeId.present) { + map['navidrome_id'] = Variable(navidromeId.value); + } + if (titel.present) { + map['titel'] = Variable(titel.value); + } + if (kuenstler.present) { + map['kuenstler'] = Variable(kuenstler.value); + } + if (album.present) { + map['album'] = Variable(album.value); + } + if (groesseBytes.present) { + map['groesse_bytes'] = Variable(groesseBytes.value); + } + if (geladenAmMs.present) { + map['geladen_am_ms'] = Variable(geladenAmMs.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('DownloadsCompanion(') + ..write('navidromeId: $navidromeId, ') + ..write('titel: $titel, ') + ..write('kuenstler: $kuenstler, ') + ..write('album: $album, ') + ..write('groesseBytes: $groesseBytes, ') + ..write('geladenAmMs: $geladenAmMs, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$MeloDb extends GeneratedDatabase { _$MeloDb(QueryExecutor e) : super(e); $MeloDbManager get managers => $MeloDbManager(this); @@ -2779,6 +3213,7 @@ abstract class _$MeloDb extends GeneratedDatabase { this, ); late final $SongCategoriesTable songCategories = $SongCategoriesTable(this); + late final $DownloadsTable downloads = $DownloadsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -2791,6 +3226,7 @@ abstract class _$MeloDb extends GeneratedDatabase { favorites, playbackHistory, songCategories, + downloads, ]; } @@ -5324,6 +5760,225 @@ typedef $$SongCategoriesTableProcessedTableManager = SongCategory, PrefetchHooks Function({bool songId}) >; +typedef $$DownloadsTableCreateCompanionBuilder = + DownloadsCompanion Function({ + required String navidromeId, + required String titel, + Value kuenstler, + Value album, + required int groesseBytes, + required int geladenAmMs, + Value rowid, + }); +typedef $$DownloadsTableUpdateCompanionBuilder = + DownloadsCompanion Function({ + Value navidromeId, + Value titel, + Value kuenstler, + Value album, + Value groesseBytes, + Value geladenAmMs, + Value rowid, + }); + +class $$DownloadsTableFilterComposer + extends Composer<_$MeloDb, $DownloadsTable> { + $$DownloadsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get navidromeId => $composableBuilder( + column: $table.navidromeId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get titel => $composableBuilder( + column: $table.titel, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get kuenstler => $composableBuilder( + column: $table.kuenstler, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get album => $composableBuilder( + column: $table.album, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get groesseBytes => $composableBuilder( + column: $table.groesseBytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get geladenAmMs => $composableBuilder( + column: $table.geladenAmMs, + builder: (column) => ColumnFilters(column), + ); +} + +class $$DownloadsTableOrderingComposer + extends Composer<_$MeloDb, $DownloadsTable> { + $$DownloadsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get navidromeId => $composableBuilder( + column: $table.navidromeId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get titel => $composableBuilder( + column: $table.titel, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get kuenstler => $composableBuilder( + column: $table.kuenstler, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get album => $composableBuilder( + column: $table.album, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get groesseBytes => $composableBuilder( + column: $table.groesseBytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get geladenAmMs => $composableBuilder( + column: $table.geladenAmMs, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$DownloadsTableAnnotationComposer + extends Composer<_$MeloDb, $DownloadsTable> { + $$DownloadsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get navidromeId => $composableBuilder( + column: $table.navidromeId, + builder: (column) => column, + ); + + GeneratedColumn get titel => + $composableBuilder(column: $table.titel, builder: (column) => column); + + GeneratedColumn get kuenstler => + $composableBuilder(column: $table.kuenstler, builder: (column) => column); + + GeneratedColumn get album => + $composableBuilder(column: $table.album, builder: (column) => column); + + GeneratedColumn get groesseBytes => $composableBuilder( + column: $table.groesseBytes, + builder: (column) => column, + ); + + GeneratedColumn get geladenAmMs => $composableBuilder( + column: $table.geladenAmMs, + builder: (column) => column, + ); +} + +class $$DownloadsTableTableManager + extends + RootTableManager< + _$MeloDb, + $DownloadsTable, + Download, + $$DownloadsTableFilterComposer, + $$DownloadsTableOrderingComposer, + $$DownloadsTableAnnotationComposer, + $$DownloadsTableCreateCompanionBuilder, + $$DownloadsTableUpdateCompanionBuilder, + (Download, BaseReferences<_$MeloDb, $DownloadsTable, Download>), + Download, + PrefetchHooks Function() + > { + $$DownloadsTableTableManager(_$MeloDb db, $DownloadsTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$DownloadsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$DownloadsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$DownloadsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value navidromeId = const Value.absent(), + Value titel = const Value.absent(), + Value kuenstler = const Value.absent(), + Value album = const Value.absent(), + Value groesseBytes = const Value.absent(), + Value geladenAmMs = const Value.absent(), + Value rowid = const Value.absent(), + }) => DownloadsCompanion( + navidromeId: navidromeId, + titel: titel, + kuenstler: kuenstler, + album: album, + groesseBytes: groesseBytes, + geladenAmMs: geladenAmMs, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String navidromeId, + required String titel, + Value kuenstler = const Value.absent(), + Value album = const Value.absent(), + required int groesseBytes, + required int geladenAmMs, + Value rowid = const Value.absent(), + }) => DownloadsCompanion.insert( + navidromeId: navidromeId, + titel: titel, + kuenstler: kuenstler, + album: album, + groesseBytes: groesseBytes, + geladenAmMs: geladenAmMs, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$DownloadsTableProcessedTableManager = + ProcessedTableManager< + _$MeloDb, + $DownloadsTable, + Download, + $$DownloadsTableFilterComposer, + $$DownloadsTableOrderingComposer, + $$DownloadsTableAnnotationComposer, + $$DownloadsTableCreateCompanionBuilder, + $$DownloadsTableUpdateCompanionBuilder, + (Download, BaseReferences<_$MeloDb, $DownloadsTable, Download>), + Download, + PrefetchHooks Function() + >; class $MeloDbManager { final _$MeloDb _db; @@ -5342,4 +5997,6 @@ class $MeloDbManager { $$PlaybackHistoryTableTableManager(_db, _db.playbackHistory); $$SongCategoriesTableTableManager get songCategories => $$SongCategoriesTableTableManager(_db, _db.songCategories); + $$DownloadsTableTableManager get downloads => + $$DownloadsTableTableManager(_db, _db.downloads); } diff --git a/lib/main.dart b/lib/main.dart index beadad8..9974be0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,8 +21,10 @@ import 'player/audio_handler.dart'; import 'player/mini_player.dart'; import 'services/baka_auth.dart'; import 'services/logger_service.dart'; +import 'services/download_service.dart'; import 'services/melo_cloud_service.dart'; import 'services/offline_mode.dart'; +import 'services/navidrome_service.dart'; import 'services/sync_service.dart'; import 'services/yt_download_service.dart'; import 'settings/app_settings.dart'; @@ -39,6 +41,7 @@ late final AudioEffects _effects; late final BakaAuth _bakaAuth; late final YtDownloadService _ytDownload; late final SyncService _sync; +late final DownloadService _downloads; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -70,6 +73,9 @@ Future main() async { cloud: MeloCloudService(auth: _bakaAuth), ); await _sync.laden(); + final navidrome = NavidromeService(); + await navidrome.ladeGespeicherteZugangsdaten(); + _downloads = DownloadService(db: _db, navidrome: navidrome); // Die Audio-Session steht erst beim Abspielen fest und kann wechseln — // die Klangeffekte mĂŒssen dann neu daran gehĂ€ngt werden. _handler.androidAudioSessionIdStream.listen((id) { @@ -78,6 +84,10 @@ Future main() async { void uebernehmeEinstellungen() { _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones; _handler.normalizeVolume = _settings.normalizeVolume; + _handler.cacheGrenzeBytes = _settings.cacheGrenzeBytes; + // Sofort anwenden: wer die Grenze senkt, will nicht warten, bis zufĂ€llig + // der nĂ€chste Titel gestreamt wird. + unawaited(_handler.raeumeZwischenspeicherAuf()); } uebernehmeEinstellungen(); @@ -110,6 +120,7 @@ class MeloApp extends StatelessWidget { ChangeNotifierProvider.value(value: _bakaAuth), ChangeNotifierProvider.value(value: _ytDownload), ChangeNotifierProvider.value(value: _sync), + ChangeNotifierProvider.value(value: _downloads), ], child: MaterialApp( title: 'Melo', diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index 1ac4b91..ed78495 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -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 _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 _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 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. diff --git a/lib/services/cache_manager.dart b/lib/services/cache_manager.dart index 48e1ee3..9be1965 100644 --- a/lib/services/cache_manager.dart +++ b/lib/services/cache_manager.dart @@ -3,6 +3,63 @@ 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 zuVerdraengen( + List eintraege, + int maxBytes, { + Set geschuetzt = const {}, +}) { + final grenze = maxBytes < 0 ? 0 : maxBytes; + var belegt = eintraege.fold(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 = []; + 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; @@ -42,9 +99,7 @@ class CacheManager { try { final hash = md5.convert(streamUrl.codeUnits).toString(); final file = File('${_cacheFolder.path}/$hash.mp3'); - if (await file.exists()) { - return file; - } + if (await file.exists()) return file; return null; } catch (e) { debugPrint('Fehler beim Abrufen der Cache-Datei: $e'); @@ -52,6 +107,65 @@ class CacheManager { } } + /// 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 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> eintraege() async { + if (!await _cacheFolder.exists()) return const []; + final gefunden = []; + 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 begrenzeAuf(int maxBytes, {Set 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 getCacheFile(String streamUrl) async { final hash = md5.convert(streamUrl.codeUnits).toString(); return File('${_cacheFolder.path}/$hash.mp3'); diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart new file mode 100644 index 0000000..c87b781 --- /dev/null +++ b/lib/services/download_service.dart @@ -0,0 +1,173 @@ +import 'package:drift/drift.dart' show Value; +import 'package:flutter/foundation.dart'; + +import '../library/database.dart'; +import 'download_speicher.dart'; +import 'navidrome_service.dart'; + +/// Ab wie vielen Titeln vor dem Herunterladen nachgefragt wird. +/// +/// Ein Album sind zehn bis zwanzig Titel — da wĂ€re eine RĂŒckfrage nur im Weg. +/// Ein KĂŒnstler können mehrere hundert und einige Gigabyte sein, und das +/// sollte niemand aus Versehen antippen. +const rueckfrageAb = 30; + +bool brauchtRueckfrage(int anzahl) => anzahl >= rueckfrageAb; + +/// Grobe GrĂ¶ĂŸenschĂ€tzung fĂŒr die RĂŒckfrage. Genau geht nicht — der Server +/// verrĂ€t die DateigrĂ¶ĂŸe erst beim Abruf; diese Zahl beruht auf der Dauer +/// bei rund 256 kbit/s und ist als Hausnummer gedacht. +int geschaetzteBytes(Iterable dauernSekunden) => + dauernSekunden.fold(0, (summe, s) => summe + s * 32000); + +enum _Ergebnis { neu, schonDa, fehler } + +/// LĂ€dt Server-Titel bewusst auf das GerĂ€t und fĂŒhrt Buch darĂŒber. +/// +/// Getrennt vom Zwischenspeicher, der beim Abspielen nebenbei entsteht: was +/// hier landet, hat der Nutzer gewollt und wird nie von selbst verdrĂ€ngt. +class DownloadService extends ChangeNotifier { + DownloadService({ + required this.db, + required this.navidrome, + DownloadSpeicher? speicher, + }) : speicher = speicher ?? DownloadSpeicher(); + + final MeloDb db; + final NavidromeService navidrome; + final DownloadSpeicher speicher; + + bool _laeuft = false; + int _fertig = 0; + int _gesamt = 0; + int _fehlgeschlagen = 0; + String? _aktuell; + String? _fehler; + bool _abbruchGewuenscht = false; + + bool get laeuft => _laeuft; + int get fertig => _fertig; + int get gesamt => _gesamt; + + /// Titel, der gerade geladen wird. + String? get aktuell => _aktuell; + + /// Meldung des letzten Fehlschlags. Sagt **nicht**, dass der ganze Lauf + /// gescheitert ist — siehe [fehlgeschlagen]. + String? get fehler => _fehler; + + /// Wie viele Titel im letzten Lauf nicht geladen werden konnten. + int get fehlgeschlagen => _fehlgeschlagen; + + /// Fortschritt zwischen 0 und 1, oder `null`, solange nichts lĂ€uft. + double? get anteil => + _laeuft && _gesamt > 0 ? (_fertig / _gesamt).clamp(0.0, 1.0) : null; + + void abbrechen() { + if (_laeuft) _abbruchGewuenscht = true; + } + + /// LĂ€dt [songs] herunter. Bereits vorhandene Titel werden ĂŒbersprungen. + /// Gibt zurĂŒck, wie viele neu dazugekommen sind. + Future lade(List songs) async { + if (_laeuft) return 0; + // Die Zugangsdaten können sich seit dem App-Start geĂ€ndert haben: wer + // sich erst in den Einstellungen anmeldet, hĂ€tte hier sonst eine leere + // Verbindung und jeder Download liefe ins Leere. + if (!navidrome.istVerbunden) { + await navidrome.ladeGespeicherteZugangsdaten(); + } + if (!navidrome.istVerbunden) { + _fehler = 'Nicht mit dem Musikserver verbunden'; + notifyListeners(); + return 0; + } + _laeuft = true; + _abbruchGewuenscht = false; + _fehler = null; + _fehlgeschlagen = 0; + _fertig = 0; + _gesamt = songs.length; + notifyListeners(); + + var neu = 0; + try { + for (final song in songs) { + if (_abbruchGewuenscht) break; + _aktuell = song.titel; + notifyListeners(); + final ergebnis = await _ladeEinen(song); + if (ergebnis == _Ergebnis.neu) neu++; + if (ergebnis == _Ergebnis.fehler) _fehlgeschlagen++; + _fertig++; + notifyListeners(); + } + } finally { + _laeuft = false; + _aktuell = null; + notifyListeners(); + } + return neu; + } + + Future<_Ergebnis> _ladeEinen(SubsonicSong song) async { + try { + // Datei und BuchfĂŒhrung entstehen nacheinander. Bricht die App + // dazwischen ab, liegt eine Datei ohne Zeile herum — sie tauchte dann + // in keiner Liste auf und ließe sich nie wieder loswerden. Deshalb hier + // beides prĂŒfen und die fehlende Zeile nachtragen, statt nur „ist schon + // da" zu melden. + final vorhanden = await speicher.treffer(song.id); + if (vorhanden != null) { + await _buchen(song, await vorhanden.length()); + return _Ergebnis.schonDa; + } + final ziel = await speicher.ziel(song.id); + final datei = + await navidrome.ladeDateiHerunter(navidrome.streamUrl(song.id), ziel); + if (datei == null) { + _fehler = 'Nicht ladbar: ${song.titel}'; + return _Ergebnis.fehler; + } + await _buchen(song, await datei.length()); + return _Ergebnis.neu; + } catch (e) { + _fehler = 'Fehler bei ${song.titel}: $e'; + debugPrint('Download fehlgeschlagen: $e'); + return _Ergebnis.fehler; + } + } + + Future _buchen(SubsonicSong song, int bytes) => + db.merkeDownload(DownloadsCompanion.insert( + navidromeId: song.id, + titel: song.titel, + kuenstler: Value(song.kuenstler.isEmpty ? null : song.kuenstler), + album: Value(song.album.isEmpty ? null : song.album), + groesseBytes: bytes, + geladenAmMs: DateTime.now().millisecondsSinceEpoch, + )); + + /// Nimmt einen heruntergeladenen Titel wieder vom GerĂ€t. + /// Nimmt einen heruntergeladenen Titel wieder vom GerĂ€t. + /// + /// Die Zeile fĂ€llt nur, wenn die Datei wirklich weg ist — sonst bliebe eine + /// Datei ohne BuchfĂŒhrung liegen, die in keiner Liste mehr auftaucht. + Future entferne(String navidromeId) async { + await speicher.loesche(navidromeId); + if (await speicher.treffer(navidromeId) != null) { + _fehler = 'Datei ließ sich nicht entfernen'; + notifyListeners(); + return false; + } + await db.vergissDownload(navidromeId); + notifyListeners(); + return true; + } + + Future entferneAlle() async { + await speicher.loescheAlle(); + await db.vergissAlleDownloads(); + notifyListeners(); + } +} diff --git a/lib/services/download_speicher.dart b/lib/services/download_speicher.dart new file mode 100644 index 0000000..2cdfb1a --- /dev/null +++ b/lib/services/download_speicher.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Ablage fĂŒr bewusst heruntergeladene Titel. +/// +/// Bewusst **nicht** im Cache-Ordner: den darf Android bei Speichernot ohne +/// Vorwarnung leeren — das ist sein Zweck. Einem Album, das jemand fĂŒr die +/// Zugfahrt mitgenommen hat, darf genau das nicht passieren. Deshalb liegt +/// es neben der Datenbank im Support-Ordner, den das System in Ruhe lĂ€sst. +/// +/// Die Trennung ist auch die Grundlage der Speicher-Rechnung: die Grenze fĂŒr +/// den Zwischenspeicher gilt nur dort, Downloads sind unbegrenzt. +class DownloadSpeicher { + static const ordnerName = 'melo_downloads'; + + Directory? _ordner; + + Future _ordnerHolen() async { + final vorhanden = _ordner; + if (vorhanden != null) return vorhanden; + final basis = await getApplicationSupportDirectory(); + final ordner = Directory(p.join(basis.path, ordnerName)); + if (!await ordner.exists()) await ordner.create(recursive: true); + _ordner = ordner; + return ordner; + } + + /// Dateiname eines Titels. Wie im Zwischenspeicher ĂŒber die Server-ID und + /// nicht ĂŒber die Stream-Adresse: die trĂ€gt bei jedem App-Start ein neues + /// Token und wĂ€re als SchlĂŒssel wertlos. + static String dateiName(String navidromeId) => + '${md5.convert('navidrome:$navidromeId'.codeUnits)}.mp3'; + + /// Wohin der Titel geschrieben wird — die Datei muss es noch nicht geben. + Future ziel(String navidromeId) async => + File(p.join((await _ordnerHolen()).path, dateiName(navidromeId))); + + /// Die fertige Datei, oder `null`, wenn der Titel nicht heruntergeladen ist. + Future treffer(String navidromeId) async { + final datei = await ziel(navidromeId); + return await datei.exists() ? datei : null; + } + + + Future loesche(String navidromeId) async { + try { + final datei = await ziel(navidromeId); + if (await datei.exists()) await datei.delete(); + } catch (e) { + debugPrint('Download nicht löschbar: $e'); + } + } + + Future loescheAlle() async { + try { + final ordner = await _ordnerHolen(); + if (await ordner.exists()) await ordner.delete(recursive: true); + _ordner = null; + await _ordnerHolen(); + } catch (e) { + debugPrint('Downloads nicht löschbar: $e'); + } + } +} diff --git a/lib/services/navidrome_service.dart b/lib/services/navidrome_service.dart index b20e5f7..0ed2a58 100644 --- a/lib/services/navidrome_service.dart +++ b/lib/services/navidrome_service.dart @@ -379,7 +379,18 @@ class NavidromeService { Future ladeInCache(String songId, Uri streamUri, CacheManager cache) async { final ziel = await cache.getCacheFile(cacheSchluessel(songId)); if (await ziel.exists()) return ziel; + return ladeDateiHerunter(streamUri, ziel); + } + /// LĂ€dt [streamUri] vollstĂ€ndig nach [ziel] und gibt die fertige Datei + /// zurĂŒck, oder `null`, wenn dabei etwas schiefging. + /// + /// Dieselbe Logik fĂŒr den Zwischenspeicher wie fĂŒr einen bewussten + /// Download — nur das Ziel unterscheidet sich. Geschrieben wird zuerst in + /// eine `.part`-Datei; erst der vollstĂ€ndige, als Audio bestĂ€tigte + /// Download wird umbenannt. Ein Abbruch hinterlĂ€sst damit keine halbe + /// Datei, die spĂ€ter als gĂŒltiger Treffer gĂ€lte. + Future ladeDateiHerunter(Uri streamUri, File ziel) async { final teil = File('${ziel.path}.part'); final klient = http.Client(); try { @@ -387,16 +398,15 @@ class NavidromeService { .send(http.Request('GET', streamUri)) .timeout(const Duration(seconds: 30)); if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) { - await logger.error('Cache abgebrochen: HTTP ${antwort.statusCode}, ' + await logger.error('Download abgebrochen: HTTP ${antwort.statusCode}, ' 'Typ ${antwort.headers['content-type'] ?? '-'}'); return null; } await antwort.stream.pipe(teil.openWrite()); await teil.rename(ziel.path); - debugPrint('Im Cache abgelegt: ${ziel.path}'); return ziel; } catch (e) { - await logger.error('Cache-Download fehlgeschlagen: $e', e, StackTrace.current); + await logger.error('Download fehlgeschlagen: $e', e, StackTrace.current); if (await teil.exists()) await teil.delete(); return null; } finally { diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart index 7137d44..6540bd1 100644 --- a/lib/settings/app_settings.dart +++ b/lib/settings/app_settings.dart @@ -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 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 setCacheGrenzeMb(int mb) async { + if (_cacheGrenzeMb == mb) return; + _cacheGrenzeMb = mb; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_keyCacheGrenze, mb); + } + Future setGroupCoversByCategory(bool value) async { if (_groupCoversByCategory == value) return; _groupCoversByCategory = value; diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 61b52c2..d211c06 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -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 { Widget build(BuildContext context) { final db = context.read(); 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 { }, ), ), + const _SectionLabel('Speicher'), + StreamBuilder>( + stream: db.watchDownloads(), + builder: (context, snapshot) { + final eintraege = snapshot.data ?? const []; + final bytes = + eintraege.fold(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().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( + value: context.watch().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().setCacheGrenzeMb(mb); + }, + ), + ), ListTile( leading: const Icon(Icons.favorite), title: const Text('Favoriten-Sync'), @@ -268,9 +312,9 @@ class _SettingsScreenState extends State { 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 { 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 { 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 { 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 { width: 20, height: 20, child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.redAccent), + strokeWidth: 2, color: MeloTheme.red), ), ), ], @@ -401,7 +445,7 @@ class _SettingsScreenState extends State { } }, child: const Text('Login', - style: TextStyle(color: Colors.redAccent)), + style: TextStyle(color: MeloTheme.red)), ), ], ), diff --git a/test/services/cache_verdraengung_test.dart b/test/services/cache_verdraengung_test.dart new file mode 100644 index 0000000..0b36f11 --- /dev/null +++ b/test/services/cache_verdraengung_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/cache_manager.dart'; + +CacheEintrag _e(String pfad, int groesse, int geaendertMs) => + CacheEintrag(pfad: pfad, groesse: groesse, geaendertMs: geaendertMs); + +/// Der Zwischenspeicher wuchs bisher unbegrenzt — es gab nur „alles löschen". +/// Diese Regel entscheidet, was wegmuss: am lĂ€ngsten nicht gehört zuerst. +void main() { + group('zuVerdraengen', () { + test('unter der Grenze fĂ€llt nichts weg', () { + final weg = zuVerdraengen([_e('a', 100, 1), _e('b', 100, 2)], 500); + + expect(weg, isEmpty); + }); + + test('genau auf der Grenze fĂ€llt nichts weg', () { + final weg = zuVerdraengen([_e('a', 200, 1), _e('b', 300, 2)], 500); + + expect(weg, isEmpty); + }); + + test('der Ă€lteste Eintrag geht zuerst', () { + // 3 × 200 = 600 bei Grenze 500: einer muss weg, der mit dem + // kleinsten Zeitstempel. + final weg = zuVerdraengen( + [_e('neu', 200, 30), _e('alt', 200, 10), _e('mittel', 200, 20)], + 500, + ); + + expect(weg, ['alt']); + }); + + test('es fĂ€llt so viel weg, bis die Grenze eingehalten ist', () { + // 4 × 200 = 800 bei Grenze 300: zwei reichen nicht, drei mĂŒssen weg. + final weg = zuVerdraengen( + [_e('a', 200, 1), _e('b', 200, 2), _e('c', 200, 3), _e('d', 200, 4)], + 300, + ); + + expect(weg, ['a', 'b', 'c']); + }); + + test('Grenze 0 rĂ€umt alles weg', () { + final weg = zuVerdraengen([_e('a', 1, 1), _e('b', 1, 2)], 0); + + expect(weg, ['a', 'b']); + }); + + test('eine negative Grenze wird wie 0 behandelt', () { + expect(zuVerdraengen([_e('a', 1, 1)], -5), ['a']); + }); + + test('bei gleichem Zeitstempel entscheidet der Pfad — nie zufĂ€llig', () { + final weg = zuVerdraengen([_e('b', 200, 7), _e('a', 200, 7)], 200); + + expect(weg, ['a']); + }); + + test('ein leerer Zwischenspeicher ergibt nichts zu tun', () { + expect(zuVerdraengen(const [], 100), isEmpty); + }); + + test('lĂ€sst die Ausgangsliste unverĂ€ndert', () { + final eintraege = [_e('b', 200, 2), _e('a', 200, 1)]; + + zuVerdraengen(eintraege, 100); + + expect(eintraege.map((e) => e.pfad), ['b', 'a']); + }); + }); + + group('geschĂŒtzte Dateien', () { + test('eine benutzte Datei bleibt liegen, auch wenn sie die Ă€lteste ist', () { + // Der schwerste Fall: die Warteschlange hat den Pfad schon fest als + // Quelle eingetragen. Wird die Datei gelöscht, bricht die Wiedergabe + // mitten im Album ab — ohne RĂŒckfall aufs Streamen. + final weg = zuVerdraengen( + [_e('laeuft', 200, 1), _e('alt', 200, 2), _e('neu', 200, 3)], + 200, + geschuetzt: {'laeuft'}, + ); + + expect(weg, isNot(contains('laeuft'))); + expect(weg, ['alt', 'neu']); + }); + + test('lieber ĂŒber der Grenze bleiben als die Wiedergabe abwĂŒrgen', () { + // Alles geschĂŒtzt: dann wird eben nichts frei. Die Grenze greift beim + // nĂ€chsten Mal wieder. + final weg = zuVerdraengen( + [_e('a', 500, 1), _e('b', 500, 2)], + 100, + geschuetzt: {'a', 'b'}, + ); + + expect(weg, isEmpty); + }); + + test('ohne Schutzliste verhĂ€lt es sich wie zuvor', () { + final weg = zuVerdraengen([_e('a', 200, 1), _e('b', 200, 2)], 200); + + expect(weg, ['a']); + }); + + test('ein geschĂŒtzter Pfad, den es gar nicht gibt, stört nicht', () { + final weg = zuVerdraengen( + [_e('a', 200, 1), _e('b', 200, 2)], + 200, + geschuetzt: {'gibtesnicht'}, + ); + + expect(weg, ['a']); + }); + }); +} diff --git a/test/services/download_service_test.dart b/test/services/download_service_test.dart new file mode 100644 index 0000000..db09e72 --- /dev/null +++ b/test/services/download_service_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/download_speicher.dart'; + +void main() { + group('brauchtRueckfrage', () { + test('ein Album geht ohne Nachfrage durch', () { + // Zehn bis zwanzig Titel — da wĂ€re eine RĂŒckfrage nur im Weg. + expect(brauchtRueckfrage(12), isFalse); + expect(brauchtRueckfrage(29), isFalse); + }); + + test('ab der Schwelle wird gefragt', () { + expect(brauchtRueckfrage(rueckfrageAb), isTrue); + }); + + test('ein ganzer KĂŒnstler wird immer erfragt', () { + // Mehrere hundert Titel und einige Gigabyte darf niemand aus Versehen + // antippen. + expect(brauchtRueckfrage(347), isTrue); + }); + + test('nichts auszuwĂ€hlen fragt nicht', () { + expect(brauchtRueckfrage(0), isFalse); + }); + }); + + group('geschaetzteBytes', () { + test('rechnet die Dauer in eine Hausnummer um', () { + // 200 s bei rund 256 kbit/s ≈ 6,4 MB. + expect(geschaetzteBytes([200]), 6400000); + }); + + test('zĂ€hlt mehrere Titel zusammen', () { + expect(geschaetzteBytes([100, 100]), geschaetzteBytes([200])); + }); + + test('ohne Titel ist die SchĂ€tzung null', () { + expect(geschaetzteBytes(const []), 0); + }); + }); + + group('DownloadSpeicher.dateiName', () { + test('hĂ€ngt nur an der Server-ID, nicht an der Stream-Adresse', () { + // Die Adresse trĂ€gt bei jedem App-Start ein neues Token — als + // SchlĂŒssel wĂ€re die Datei nie wiederzufinden. + expect(DownloadSpeicher.dateiName('abc'), + DownloadSpeicher.dateiName('abc')); + expect(DownloadSpeicher.dateiName('abc'), + isNot(DownloadSpeicher.dateiName('xyz'))); + }); + + test('endet auf .mp3, damit der Player den Typ erkennt', () { + expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3')); + }); + }); +}