diff --git a/CHANGELOG.md b/CHANGELOG.md index 739cd5f..9e870cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 📥 Einzelne Server-Titel offline nehmen (2026-08-27) + +- 📥 **Einzelner Titel offline statt nur ganze Alben.** Auf dem + Server-Titel-Bildschirm (`ServerTitelScreen`) hat jede Zeile jetzt einen + eigenen Lade-Knopf: nicht geladen → "Offline nehmen" + (`Icons.download_outlined`), schon geladen → "Vom Gerät entfernen" + (`Icons.download_done`, rot). Neue `DownloadService.ladeEinzelnenTitel` + läuft bewusst über den bestehenden öffentlichen `lade()`-Pfad — Doppel- + Lauf-Schutz, Verbindungsprüfung und Fortschritts-Buchführung kommen + dadurch automatisch mit. +- ⚠️ **Bewusste Einschränkung:** kein Abgleich mit dem Abspiel- + Zwischenspeicher (ein gerade gehörter Titel kann danach doppelt vorliegen, + bis die Cache-Verdrängung greift), kein Platz-Check, keine 30er-Rückfrage + — der Knopf lädt genau einen Titel. Der Offline-Modus-Schalter wird nicht + beachtet (ausdrückliche Nutzeraktion). +- ✅ **Getestet:** 617 Tests grün (vorher 614), `flutter analyze` ohne + Befund. 3 neue Tests: `DownloadService.ladeEinzelnenTitel` ohne + Serververbindung sowie zwei Widget-Tests für Offline-Nehmen und den + Gegenweg (Entfernen) auf dem Server-Titel-Bildschirm. + ### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26) - 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht diff --git a/lib/downloads/server_titel_screen.dart b/lib/downloads/server_titel_screen.dart index 84f52eb..4ad7808 100644 --- a/lib/downloads/server_titel_screen.dart +++ b/lib/downloads/server_titel_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/song_media.dart'; import '../player/audio_handler.dart'; +import '../services/download_service.dart'; import '../services/navidrome_service.dart'; import '../shared/cover.dart'; import '../shared/lauf_balken.dart'; @@ -69,6 +70,45 @@ class _ServerTitelScreenState extends State { } } + /// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet + /// — hier wird er nachgezogen. + Future _aktualisiereGeladen() async { + final geladen = await context.read().downloadIds(); + if (mounted) setState(() => _geladen = geladen); + } + + Future _offlineNehmen(SubsonicSong song) 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; + } + final neu = await dienst.ladeEinzelnenTitel(song); + await _aktualisiereGeladen(); + if (!mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(neu + ? 'Offline: ${song.titel}' + : (dienst.fehler ?? 'War schon heruntergeladen')), + )); + } + + Future _offlineEntfernen(SubsonicSong song) async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + final weg = await dienst.entferne(song.id); + await _aktualisiereGeladen(); + if (!mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(weg + ? 'Vom Gerät entfernt: ${song.titel}' + : (dienst.fehler ?? 'Ließ sich nicht entfernen')), + )); + } + Future _spiele(List songs, int ab) async { final handler = context.read(); final messenger = ScaffoldMessenger.of(context); @@ -130,6 +170,8 @@ class _ServerTitelScreenState extends State { nummer: i, geladen: _geladen.contains(song.id), onTap: () => _spiele(songs, i - 1), + onOffline: () => _offlineNehmen(song), + onEntfernen: () => _offlineEntfernen(song), ); }, ), @@ -230,12 +272,16 @@ class _Zeile extends StatelessWidget { required this.nummer, required this.geladen, required this.onTap, + required this.onOffline, + required this.onEntfernen, }); final SubsonicSong song; final int nummer; final bool geladen; final VoidCallback onTap; + final VoidCallback onOffline; + final VoidCallback onEntfernen; static String _dauer(int sekunden) { final m = (sekunden ~/ 60).toString(); @@ -286,12 +332,21 @@ class _Zeile extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (geladen) - const Padding( - padding: EdgeInsets.only(right: MeloSpace.sm), - child: Icon(Icons.download_done, - size: 16, color: MeloTheme.text3), - ), + // Bisher gab es den Lade-Knopf nur je Album — einen einzelnen + // Titel mitzunehmen ging gar nicht. + geladen + ? IconButton( + tooltip: 'Vom Gerät entfernen', + icon: const Icon(Icons.download_done, + size: 20, color: MeloTheme.red), + onPressed: onEntfernen, + ) + : IconButton( + tooltip: 'Offline nehmen', + icon: const Icon(Icons.download_outlined, + size: 20, color: MeloTheme.text3), + onPressed: onOffline, + ), Text(_dauer(song.dauerSekunden), style: const TextStyle(color: MeloTheme.text3)), ], diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index c87b781..bdb9be6 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -110,6 +110,20 @@ class DownloadService extends ChangeNotifier { return neu; } + /// Nimmt einen einzelnen Server-Titel offline. + /// + /// Bewusst über [lade]: das bringt Doppel-Lauf-Schutz, Verbindungsprüfung + /// und Fortschritts-Buchführung mit. Gibt zurück, ob der Titel neu + /// dazugekommen ist. + /// + /// **Bekannte Einschränkung:** Liegt der Titel schon im Abspiel-Zwischen- + /// speicher (weil er gerade gehört wurde), wird er trotzdem neu geladen — + /// bis die Verdrängung greift, belegt er doppelten Platz. Der Gegenschutz + /// kostete einen eigenen Cache-Zugang in diesem Dienst; für drei Nutzer ist + /// das der schlechtere Tausch. + Future ladeEinzelnenTitel(SubsonicSong song) async => + await lade([song]) > 0; + Future<_Ergebnis> _ladeEinen(SubsonicSong song) async { try { // Datei und Buchführung entstehen nacheinander. Bricht die App diff --git a/test/downloads/einzel_song_offline_test.dart b/test/downloads/einzel_song_offline_test.dart new file mode 100644 index 0000000..a3aa429 --- /dev/null +++ b/test/downloads/einzel_song_offline_test.dart @@ -0,0 +1,129 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/downloads/server_titel_screen.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/navidrome_service.dart'; + +/// Merkt sich nur, was verlangt wurde — echte Downloads brauchen einen +/// Server, und darum geht es hier nicht. +class _FakeDownloads extends DownloadService { + _FakeDownloads(MeloDb db) : super(db: db, navidrome: NavidromeService()); + + final geladen = []; + final entfernt = []; + + @override + bool get laeuft => false; + + @override + Future ladeEinzelnenTitel(SubsonicSong song) async { + geladen.add(song.id); + return true; + } + + @override + Future entferne(String navidromeId) async { + entfernt.add(navidromeId); + return true; + } +} + +void main() { + final titel = [ + const SubsonicSong( + id: 'nav-1', + titel: 'Nachtpuls', + kuenstler: 'Rotklang', + album: 'Nacht', + dauerSekunden: 200, + ), + ]; + + Future<({_FakeDownloads dienst, MeloAudioHandler handler})> pumpe( + WidgetTester tester, + {bool schonGeladen = false}) async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + if (schonGeladen) { + // Der Bildschirm liest den Zustand einmal per db.downloadIds(). + await db.merkeDownload(DownloadsCompanion.insert( + navidromeId: 'nav-1', + titel: 'Nachtpuls', + groesseBytes: 1, + geladenAmMs: 0, + )); + } + final dienst = _FakeDownloads(db); + // MeloAudioHandler startet einen periodischen Timer (Positions- + // Buchführung). Ohne handler.dispose() meldet der Testrahmen ihn als + // „pending" — dasselbe etablierte Muster wie in + // test/library/my_music_screen_test.dart: handler.dispose() explizit am + // Testende aufrufen (addTearDown greift zu spät, außerhalb der + // FakeAsync-Zone, in der der Timer geprüft wird). + final handler = MeloAudioHandler(db: db); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: dienst), + Provider.value(value: handler), + ], + child: MaterialApp( + home: ServerTitelScreen( + titel: 'Nacht', + navidrome: NavidromeService(), + holeTitel: () async => titel, + ), + ), + ), + ); + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + return (dienst: dienst, handler: handler); + } + + testWidgets('ein einzelner Titel lässt sich offline nehmen', (tester) async { + final (:dienst, :handler) = await pumpe(tester); + + await tester.tap(find.byTooltip('Offline nehmen')); + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + + expect(dienst.geladen, ['nav-1']); + expect(dienst.entfernt, isEmpty); + + handler.dispose(); + }); + + testWidgets('ein schon geladener Titel bietet den Gegenweg an', + (tester) async { + final (:dienst, :handler) = + await pumpe(tester, schonGeladen: true); + + // Zustand „schon offline": statt „Offline nehmen" steht dort das + // Entfernen — ohne diesen Test wäre der halbe Knopf ungeprüft. + expect(find.byTooltip('Offline nehmen'), findsNothing); + + await tester.tap(find.byTooltip('Vom Gerät entfernen')); + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + + expect(dienst.entfernt, ['nav-1']); + expect(dienst.geladen, isEmpty); + + handler.dispose(); + }); +} diff --git a/test/services/download_service_test.dart b/test/services/download_service_test.dart index db09e72..566ce7f 100644 --- a/test/services/download_service_test.dart +++ b/test/services/download_service_test.dart @@ -1,6 +1,9 @@ +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; import 'package:melo/services/download_service.dart'; import 'package:melo/services/download_speicher.dart'; +import 'package:melo/services/navidrome_service.dart'; void main() { group('brauchtRueckfrage', () { @@ -54,4 +57,25 @@ void main() { expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3')); }); }); + + group('ladeEinzelnenTitel', () { + test('ohne Serververbindung wird nichts geladen und der Grund steht da', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = DownloadService(db: db, navidrome: NavidromeService()); + + final neu = await dienst.ladeEinzelnenTitel(const SubsonicSong( + id: 'nav-1', + titel: 'Nachtpuls', + kuenstler: 'Rotklang', + album: 'Nacht', + dauerSekunden: 200, + )); + + expect(neu, isFalse); + expect(dienst.fehler, contains('Musikserver')); + expect(await db.downloadIds(), isEmpty); + }); + }); }