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]
|
## [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
|
||||||
|
|||||||
@@ -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)),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user