Einzelne Server-Titel offline nehmen und wieder entfernen

This commit is contained in:
Hermes (Server)
2026-08-27 12:24:56 +02:00
parent fcf53dcb6c
commit 8e3c986d13
5 changed files with 248 additions and 6 deletions
+20
View File
@@ -5,6 +5,26 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## [Unreleased] ## [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) ### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26)
- 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht - 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht
+61 -6
View File
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
import '../library/database.dart'; import '../library/database.dart';
import '../library/song_media.dart'; import '../library/song_media.dart';
import '../player/audio_handler.dart'; import '../player/audio_handler.dart';
import '../services/download_service.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import '../shared/cover.dart'; import '../shared/cover.dart';
import '../shared/lauf_balken.dart'; import '../shared/lauf_balken.dart';
@@ -69,6 +70,45 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
} }
} }
/// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet
/// — hier wird er nachgezogen.
Future<void> _aktualisiereGeladen() async {
final geladen = await context.read<MeloDb>().downloadIds();
if (mounted) setState(() => _geladen = geladen);
}
Future<void> _offlineNehmen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
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<void> _offlineEntfernen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
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<void> _spiele(List<SubsonicSong> songs, int ab) async { Future<void> _spiele(List<SubsonicSong> songs, int ab) async {
final handler = context.read<MeloAudioHandler>(); final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
@@ -130,6 +170,8 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
nummer: i, nummer: i,
geladen: _geladen.contains(song.id), geladen: _geladen.contains(song.id),
onTap: () => _spiele(songs, i - 1), onTap: () => _spiele(songs, i - 1),
onOffline: () => _offlineNehmen(song),
onEntfernen: () => _offlineEntfernen(song),
); );
}, },
), ),
@@ -230,12 +272,16 @@ class _Zeile extends StatelessWidget {
required this.nummer, required this.nummer,
required this.geladen, required this.geladen,
required this.onTap, required this.onTap,
required this.onOffline,
required this.onEntfernen,
}); });
final SubsonicSong song; final SubsonicSong song;
final int nummer; final int nummer;
final bool geladen; final bool geladen;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onOffline;
final VoidCallback onEntfernen;
static String _dauer(int sekunden) { static String _dauer(int sekunden) {
final m = (sekunden ~/ 60).toString(); final m = (sekunden ~/ 60).toString();
@@ -286,12 +332,21 @@ class _Zeile extends StatelessWidget {
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (geladen) // Bisher gab es den Lade-Knopf nur je Album — einen einzelnen
const Padding( // Titel mitzunehmen ging gar nicht.
padding: EdgeInsets.only(right: MeloSpace.sm), geladen
child: Icon(Icons.download_done, ? IconButton(
size: 16, color: MeloTheme.text3), 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), Text(_dauer(song.dauerSekunden),
style: const TextStyle(color: MeloTheme.text3)), style: const TextStyle(color: MeloTheme.text3)),
], ],
+14
View File
@@ -110,6 +110,20 @@ class DownloadService extends ChangeNotifier {
return neu; 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<bool> ladeEinzelnenTitel(SubsonicSong song) async =>
await lade([song]) > 0;
Future<_Ergebnis> _ladeEinen(SubsonicSong song) async { Future<_Ergebnis> _ladeEinen(SubsonicSong song) async {
try { try {
// Datei und Buchführung entstehen nacheinander. Bricht die App // Datei und Buchführung entstehen nacheinander. Bricht die App
@@ -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 = <String>[];
final entfernt = <String>[];
@override
bool get laeuft => false;
@override
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async {
geladen.add(song.id);
return true;
}
@override
Future<bool> 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<MeloDb>.value(value: db),
ChangeNotifierProvider<DownloadService>.value(value: dienst),
Provider<MeloAudioHandler>.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();
});
}
+24
View File
@@ -1,6 +1,9 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.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_service.dart';
import 'package:melo/services/download_speicher.dart'; import 'package:melo/services/download_speicher.dart';
import 'package:melo/services/navidrome_service.dart';
void main() { void main() {
group('brauchtRueckfrage', () { group('brauchtRueckfrage', () {
@@ -54,4 +57,25 @@ void main() {
expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3')); 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);
});
});
} }