Einzelne Server-Titel offline nehmen und wieder entfernen
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<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 {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
@@ -130,6 +170,8 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
|
||||
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)),
|
||||
],
|
||||
|
||||
@@ -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<bool> ladeEinzelnenTitel(SubsonicSong song) async =>
|
||||
await lade([song]) > 0;
|
||||
|
||||
Future<_Ergebnis> _ladeEinen(SubsonicSong song) async {
|
||||
try {
|
||||
// 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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user