Alben und Kuenstler offline mitnehmen; Zwischenspeicher bekommt eine Grenze
Bisher landete Musik nur zufaellig auf dem Geraet: was man abspielte, blieb nebenbei liegen. Gezielt "dieses Album fuer die Zugfahrt" gab es nicht. Und dieser Zwischenspeicher wuchs unbegrenzt — es gab nur "alles loeschen". Zwei getrennte Ablagen. Bewusste Downloads liegen NICHT im Cache-Ordner: den darf Android bei Speichernot jederzeit selbst leeren, und genau das darf einem mitgenommenen Album nicht passieren. Sie liegen deshalb neben der Datenbank im Support-Ordner. Die Grenze gilt nur fuer den Zufalls-Cache; Downloads sind unbegrenzt und werden nie verdraengt. - Download-Pfeil je Album und Kuenstler, mit Fortschritt und Abbruch - Rueckfrage ab 30 Titeln samt Groessenschaetzung - Obergrenze wahlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB), Standard 2 GB; am laengsten nicht Gehoertes weicht zuerst - Liste "Heruntergeladen" mit Titel, Kuenstler, Groesse, einzeln oder alle entfernbar (Schema 10) - Wiedergabe nimmt erst den Download, dann den Zwischenspeicher Aus dem Code-Review nachgebessert, vier ernste Punkte: "aus" (0 MB) schaltete das Aufraeumen ab statt das Zwischenspeichern und liess den Cache unbegrenzt wachsen; wer sich erst waehrend der Sitzung am Server anmeldete, konnte gar nichts laden; die Verdraengung konnte Dateien loeschen, die die laufende Warteschlange schon als Quelle eingetragen hatte (Abbruch mitten im Album, ohne Rueckfall aufs Streamen); und ein heruntergeladener Titel wurde beim Abspielen ein zweites Mal in den Cache geholt. Dazu sechs kleinere und der "zuletzt gehoert"-Stempel, der beim Aufbau der Warteschlange statt beim Abspielen gesetzt wurde. Nebenbei aufgeraeumt, weil die Dateien ohnehin offen waren: keine Emoji als Knoepfe mehr, kein Colors.redAccent und kein grey.shade mehr im ganzen Projekt, und die Einstellungen heissen nicht mehr "Settings". 433 Tests gruen (vorher 411), flutter analyze ohne Befund, Release-APK gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
co-authored by
Claude Opus 5
parent
80948f1ad5
commit
b42e045e67
@@ -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<void> _alleEntfernen(BuildContext context, int anzahl) async {
|
||||
final dienst = context.read<DownloadService>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final ja = await showDialog<bool>(
|
||||
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<MeloDb>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Heruntergeladen')),
|
||||
body: StreamBuilder<List<Download>>(
|
||||
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 <Download>[];
|
||||
if (eintraege.isEmpty) return const _Leer();
|
||||
final gesamt =
|
||||
eintraege.fold<int>(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<DownloadService>()
|
||||
.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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<List<SubsonicSong>> Function() holeTitel;
|
||||
|
||||
@override
|
||||
State<_LadeKnopf> createState() => _LadeKnopfState();
|
||||
}
|
||||
|
||||
class _LadeKnopfState extends State<_LadeKnopf> {
|
||||
bool _holt = false;
|
||||
|
||||
Future<void> _antippen() 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;
|
||||
}
|
||||
|
||||
setState(() => _holt = true);
|
||||
final List<SubsonicSong> 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 = <String>[];
|
||||
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<bool> _bestaetigt(List<SubsonicSong> titel) async {
|
||||
final mb = geschaetzteBytes([for (final t in titel) t.dauerSekunden]) ~/
|
||||
(1024 * 1024);
|
||||
final ja = await showDialog<bool>(
|
||||
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<DownloadService>();
|
||||
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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user