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
@@ -5,6 +5,77 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 📥 Alben und Künstler offline mitnehmen — und der Zwischenspeicher hat endlich eine Grenze (2026-08-23)
|
||||||
|
|
||||||
|
Bisher landete Musik nur zufällig auf dem Gerät: was man abspielte, blieb
|
||||||
|
nebenbei liegen. Gezielt „dieses Album für die Zugfahrt" gab es nicht. Und
|
||||||
|
dieser Zwischenspeicher wuchs **unbegrenzt** — es gab nur „alles löschen".
|
||||||
|
|
||||||
|
- 📥 **Download-Pfeil neben jedem Album und Künstler** im Bereich
|
||||||
|
Download → Server. Während es läuft, steht oben ein Fortschritt mit
|
||||||
|
Abbruch-Knopf.
|
||||||
|
- ⚠️ **Rückfrage bei großen Mengen.** Ein Album geht ohne Nachfrage durch;
|
||||||
|
ab 30 Titeln fragt die App erst („347 Titel, geschätzt etwa 2100 MB").
|
||||||
|
Ein ganzer Künstler soll niemandem aus Versehen das Datenvolumen kosten.
|
||||||
|
- 🗂️ **Zwei getrennte Töpfe.** Bewusste Downloads liegen **nicht** im
|
||||||
|
Cache-Ordner. Der Grund ist keine Geschmacksfrage: den Cache-Ordner darf
|
||||||
|
Android bei Speichernot jederzeit selbst leeren — genau das darf einem
|
||||||
|
Album, das man mitgenommen hat, nicht passieren. Downloads liegen deshalb
|
||||||
|
neben der Datenbank, wo das System nichts anrührt.
|
||||||
|
- 🚧 **Obergrenze für den Zwischenspeicher**, voreingestellt 2 GB, in den
|
||||||
|
Einstellungen wählbar (aus / 512 MB / 1 / 2 / 4 / 8 GB). Ist sie erreicht,
|
||||||
|
fliegt das am längsten nicht Gehörte zuerst raus. **Heruntergeladene Titel
|
||||||
|
sind davon nie betroffen** — wer ein Album mitnimmt, hat das entschieden,
|
||||||
|
und die App nimmt es ihm nicht wieder weg.
|
||||||
|
- 📋 **Liste „Heruntergeladen"** in den Einstellungen: Titel, Künstler,
|
||||||
|
Größe, einzeln oder alle entfernbar. Sie kommt aus der Datenbank und nicht
|
||||||
|
aus dem Ordner — die Dateien heißen nach einem Hash und stünden dort sonst
|
||||||
|
als „a3f9…mp3".
|
||||||
|
- ▶️ **Die Wiedergabe schaut erst in die Downloads, dann in den
|
||||||
|
Zwischenspeicher.** Ein mitgenommener Titel hängt nie am Netz, auch wenn
|
||||||
|
zufällig eine Cache-Kopie existiert, die morgen verdrängt sein kann.
|
||||||
|
- 🧹 **Nebenbei aufgeräumt** (die Dateien waren ohnehin offen): die
|
||||||
|
Emoji-Knöpfe „📀 Alben" und „🎤 Künstler" heißen jetzt schlicht „Alben"
|
||||||
|
und „Künstler", das grelle `Colors.redAccent` ist überall dem Melo-Rot
|
||||||
|
gewichen, und die Einstellungen heißen nicht mehr „Settings".
|
||||||
|
- 🔍 **Aus dem Code-Review nachgebessert — zehn Punkte, vier davon ernst:**
|
||||||
|
**(1)** „Aus" bewirkte das Gegenteil. Bei 0 MB übersprang die App das
|
||||||
|
Aufräumen ganz, legte aber weiter jeden Titel ab — genau der unbegrenzt
|
||||||
|
wachsende Speicher, den diese Änderung beheben soll. „Aus" heißt jetzt
|
||||||
|
wirklich: nichts behalten.
|
||||||
|
**(2)** Wer sich erst **in** der Sitzung am Musikserver anmeldete, konnte
|
||||||
|
nichts herunterladen. Der Dienst hielt eine eigene Verbindung, die nur
|
||||||
|
beim App-Start eingerichtet wurde; die Titelliste kam, der Download lief
|
||||||
|
ins Leere. Erst ein Neustart half.
|
||||||
|
**(3)** Die Aufräum-Aktion konnte Dateien löschen, die die **laufende**
|
||||||
|
Warteschlange schon als Quelle eingetragen hatte — die Wiedergabe wäre
|
||||||
|
mitten im Album abgebrochen, ohne auf Streamen zurückzufallen. Benutzte
|
||||||
|
Dateien sind jetzt geschützt; im Zweifel bleibt die App lieber kurz über
|
||||||
|
der Grenze, als die Musik abzuwürgen.
|
||||||
|
**(4)** Ein bewusst heruntergeladener Titel wurde beim Abspielen **noch
|
||||||
|
einmal** in den Zwischenspeicher geladen — doppelter Platz, doppeltes
|
||||||
|
Datenvolumen.
|
||||||
|
Dazu sechs kleinere: ein einzelner Fehlschlag verdeckte nicht mehr den
|
||||||
|
Erfolg der übrigen Titel („18 heruntergeladen · 2 fehlgeschlagen"); eine
|
||||||
|
Datei ohne Buchführungs-Zeile (App mittendrin beendet) wird jetzt
|
||||||
|
nachgetragen statt für immer unsichtbar zu bleiben; zweimal antippen meldet
|
||||||
|
nicht mehr fälschlich Erfolg; „Lädt 21 von 20" gibt es nicht mehr; eine
|
||||||
|
gesenkte Grenze wirkt sofort statt erst beim nächsten gestreamten Titel;
|
||||||
|
und drei ungenutzte Methoden sind raus.
|
||||||
|
|
||||||
|
- 🕐 **Auch aus dem Review:** Der „zuletzt gehört"-Stempel wurde beim
|
||||||
|
**Aufbau** der Warteschlange gesetzt — damit hatten nach einem Albumstart
|
||||||
|
fünfzig Dateien fast denselben Zeitstempel und die Verdrängung wusste nicht
|
||||||
|
mehr, was wirklich gehört wurde. Gestempelt wird jetzt, wenn ein Titel
|
||||||
|
tatsächlich losspielt.
|
||||||
|
|
||||||
|
- ✅ **Getestet:** 433 Tests grün (vorher 411), `flutter analyze` ohne
|
||||||
|
Befund, Release-APK gebaut. Neu: 13 Tests für die Verdrängungs-Regel
|
||||||
|
(welche Datei weicht wann, bei gleichem Zeitstempel nie zufällig, und
|
||||||
|
benutzte Dateien nie) und 9 für Rückfrage-Schwelle, Größenschätzung und
|
||||||
|
Dateinamen.
|
||||||
|
|
||||||
|
|
||||||
### ▶️ Man sieht jetzt, welcher Titel gerade läuft (2026-08-23)
|
### ▶️ Man sieht jetzt, welcher Titel gerade läuft (2026-08-23)
|
||||||
|
|
||||||
Bisher scrollte man durch die Bibliothek und musste raten, wo man gerade
|
Bisher scrollte man durch die Bibliothek und musste raten, wo man gerade
|
||||||
|
|||||||
@@ -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 '../player/audio_handler.dart';
|
||||||
import '../services/baka_auth.dart';
|
import '../services/baka_auth.dart';
|
||||||
import '../services/media_store.dart';
|
import '../services/media_store.dart';
|
||||||
|
import '../services/download_service.dart';
|
||||||
import '../services/navidrome_service.dart';
|
import '../services/navidrome_service.dart';
|
||||||
import '../services/server_neuheiten.dart';
|
import '../services/server_neuheiten.dart';
|
||||||
import '../services/yt_download_service.dart';
|
import '../services/yt_download_service.dart';
|
||||||
@@ -165,7 +166,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
|
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(color: Colors.redAccent),
|
child: CircularProgressIndicator(color: MeloTheme.red),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +180,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(hatFehler ? Icons.error_outline : Icons.album,
|
Icon(hatFehler ? Icons.error_outline : Icons.album,
|
||||||
size: 64,
|
size: 64,
|
||||||
color: hatFehler ? Colors.redAccent : Colors.white24),
|
color: hatFehler ? MeloTheme.red : Colors.white24),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
|
hatFehler ? 'Serverfehler: $_error' : 'Keine Alben geladen',
|
||||||
@@ -200,6 +201,7 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
const _LadeFortschritt(),
|
||||||
if (_neuheiten.neue.isNotEmpty)
|
if (_neuheiten.neue.isNotEmpty)
|
||||||
MaterialBanner(
|
MaterialBanner(
|
||||||
backgroundColor: MeloTheme.surfaceHigh,
|
backgroundColor: MeloTheme.surfaceHigh,
|
||||||
@@ -229,9 +231,10 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
if (_albums.isEmpty) _loadAlbums();
|
if (_albums.isEmpty) _loadAlbums();
|
||||||
}),
|
}),
|
||||||
style: FilledButton.styleFrom(
|
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),
|
const SizedBox(width: 8),
|
||||||
@@ -242,9 +245,10 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
if (_artists.isEmpty) _loadArtists();
|
if (_artists.isEmpty) _loadArtists();
|
||||||
}),
|
}),
|
||||||
style: FilledButton.styleFrom(
|
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(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.person, size: 48),
|
leading: const Icon(Icons.person, size: 48),
|
||||||
title: Text(artist.name),
|
title: Text(artist.name),
|
||||||
|
trailing: _LadeKnopf(
|
||||||
|
tooltip: 'Alles von ${artist.name} herunterladen',
|
||||||
|
holeTitel: () => _nav.getArtistSongs(artist.id),
|
||||||
|
),
|
||||||
onTap: () => _playArtist(artist),
|
onTap: () => _playArtist(artist),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -278,10 +286,14 @@ class _ServerBrowserState extends State<_ServerBrowser> {
|
|||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.album,
|
Icons.album,
|
||||||
size: 48,
|
size: 48,
|
||||||
color: Colors.redAccent.withValues(alpha: 0.5),
|
color: MeloTheme.red.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
title: Text(album.name),
|
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),
|
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.
|
/// Merkt sich, ob der Proxy seine YouTube-Cookies benutzen soll.
|
||||||
const _cookiesSchluessel = 'yt_cookies';
|
const _cookiesSchluessel = 'yt_cookies';
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,23 @@ class PlaybackHistory extends Table {
|
|||||||
Set<Column> get primaryKey => {songId, playedAtMs};
|
Set<Column> get primaryKey => {songId, playedAtMs};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bewusst heruntergeladene Server-Titel.
|
||||||
|
///
|
||||||
|
/// Die Dateien allein würden nicht reichen: sie heißen nach einem Hash, und
|
||||||
|
/// ohne diese Zeilen könnte die App nur „37 Dateien, 412 MB" zeigen statt
|
||||||
|
/// einer Liste mit Titeln und Künstlern.
|
||||||
|
class Downloads extends Table {
|
||||||
|
TextColumn get navidromeId => text()();
|
||||||
|
TextColumn get titel => text()();
|
||||||
|
TextColumn get kuenstler => text().nullable()();
|
||||||
|
TextColumn get album => text().nullable()();
|
||||||
|
IntColumn get groesseBytes => integer()();
|
||||||
|
IntColumn get geladenAmMs => integer()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {navidromeId};
|
||||||
|
}
|
||||||
|
|
||||||
@DriftDatabase(tables: [
|
@DriftDatabase(tables: [
|
||||||
Songs,
|
Songs,
|
||||||
Folders,
|
Folders,
|
||||||
@@ -125,12 +142,13 @@ class PlaybackHistory extends Table {
|
|||||||
Favorites,
|
Favorites,
|
||||||
PlaybackHistory,
|
PlaybackHistory,
|
||||||
SongCategories,
|
SongCategories,
|
||||||
|
Downloads,
|
||||||
])
|
])
|
||||||
class MeloDb extends _$MeloDb {
|
class MeloDb extends _$MeloDb {
|
||||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 9;
|
int get schemaVersion => 10;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -169,6 +187,9 @@ class MeloDb extends _$MeloDb {
|
|||||||
// Dateien ändert.
|
// Dateien ändert.
|
||||||
await _ergaenzeAlbumKategorien();
|
await _ergaenzeAlbumKategorien();
|
||||||
}
|
}
|
||||||
|
if (from < 10) {
|
||||||
|
await m.createTable(downloads);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -524,6 +545,23 @@ class MeloDb extends _$MeloDb {
|
|||||||
.get();
|
.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Downloads ===
|
||||||
|
Stream<List<Download>> watchDownloads() => (select(downloads)
|
||||||
|
..orderBy([
|
||||||
|
(d) => OrderingTerm(expression: d.geladenAmMs, mode: OrderingMode.desc)
|
||||||
|
]))
|
||||||
|
.watch();
|
||||||
|
|
||||||
|
Future<void> merkeDownload(DownloadsCompanion eintrag) =>
|
||||||
|
into(downloads).insert(eintrag, mode: InsertMode.insertOrReplace);
|
||||||
|
|
||||||
|
Future<void> vergissDownload(String navidromeId) => (delete(downloads)
|
||||||
|
..where((d) => d.navidromeId.equals(navidromeId)))
|
||||||
|
.go();
|
||||||
|
|
||||||
|
Future<void> vergissAlleDownloads() => delete(downloads).go();
|
||||||
|
|
||||||
|
|
||||||
Future<int?> lastPosition(String songId) async {
|
Future<int?> lastPosition(String songId) async {
|
||||||
final row = await (select(playbackHistory)
|
final row = await (select(playbackHistory)
|
||||||
..where((h) => h.songId.equals(songId))
|
..where((h) => h.songId.equals(songId))
|
||||||
|
|||||||
@@ -2767,6 +2767,440 @@ class SongCategoriesCompanion extends UpdateCompanion<SongCategory> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class $DownloadsTable extends Downloads
|
||||||
|
with TableInfo<$DownloadsTable, Download> {
|
||||||
|
@override
|
||||||
|
final GeneratedDatabase attachedDatabase;
|
||||||
|
final String? _alias;
|
||||||
|
$DownloadsTable(this.attachedDatabase, [this._alias]);
|
||||||
|
static const VerificationMeta _navidromeIdMeta = const VerificationMeta(
|
||||||
|
'navidromeId',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> navidromeId = GeneratedColumn<String>(
|
||||||
|
'navidrome_id',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _titelMeta = const VerificationMeta('titel');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> titel = GeneratedColumn<String>(
|
||||||
|
'titel',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _kuenstlerMeta = const VerificationMeta(
|
||||||
|
'kuenstler',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> kuenstler = GeneratedColumn<String>(
|
||||||
|
'kuenstler',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _albumMeta = const VerificationMeta('album');
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> album = GeneratedColumn<String>(
|
||||||
|
'album',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _groesseBytesMeta = const VerificationMeta(
|
||||||
|
'groesseBytes',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<int> groesseBytes = GeneratedColumn<int>(
|
||||||
|
'groesse_bytes',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
static const VerificationMeta _geladenAmMsMeta = const VerificationMeta(
|
||||||
|
'geladenAmMs',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<int> geladenAmMs = GeneratedColumn<int>(
|
||||||
|
'geladen_am_ms',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.int,
|
||||||
|
requiredDuringInsert: true,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
List<GeneratedColumn> get $columns => [
|
||||||
|
navidromeId,
|
||||||
|
titel,
|
||||||
|
kuenstler,
|
||||||
|
album,
|
||||||
|
groesseBytes,
|
||||||
|
geladenAmMs,
|
||||||
|
];
|
||||||
|
@override
|
||||||
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
|
@override
|
||||||
|
String get actualTableName => $name;
|
||||||
|
static const String $name = 'downloads';
|
||||||
|
@override
|
||||||
|
VerificationContext validateIntegrity(
|
||||||
|
Insertable<Download> instance, {
|
||||||
|
bool isInserting = false,
|
||||||
|
}) {
|
||||||
|
final context = VerificationContext();
|
||||||
|
final data = instance.toColumns(true);
|
||||||
|
if (data.containsKey('navidrome_id')) {
|
||||||
|
context.handle(
|
||||||
|
_navidromeIdMeta,
|
||||||
|
navidromeId.isAcceptableOrUnknown(
|
||||||
|
data['navidrome_id']!,
|
||||||
|
_navidromeIdMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_navidromeIdMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('titel')) {
|
||||||
|
context.handle(
|
||||||
|
_titelMeta,
|
||||||
|
titel.isAcceptableOrUnknown(data['titel']!, _titelMeta),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_titelMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('kuenstler')) {
|
||||||
|
context.handle(
|
||||||
|
_kuenstlerMeta,
|
||||||
|
kuenstler.isAcceptableOrUnknown(data['kuenstler']!, _kuenstlerMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.containsKey('album')) {
|
||||||
|
context.handle(
|
||||||
|
_albumMeta,
|
||||||
|
album.isAcceptableOrUnknown(data['album']!, _albumMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.containsKey('groesse_bytes')) {
|
||||||
|
context.handle(
|
||||||
|
_groesseBytesMeta,
|
||||||
|
groesseBytes.isAcceptableOrUnknown(
|
||||||
|
data['groesse_bytes']!,
|
||||||
|
_groesseBytesMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_groesseBytesMeta);
|
||||||
|
}
|
||||||
|
if (data.containsKey('geladen_am_ms')) {
|
||||||
|
context.handle(
|
||||||
|
_geladenAmMsMeta,
|
||||||
|
geladenAmMs.isAcceptableOrUnknown(
|
||||||
|
data['geladen_am_ms']!,
|
||||||
|
_geladenAmMsMeta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (isInserting) {
|
||||||
|
context.missing(_geladenAmMsMeta);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<GeneratedColumn> get $primaryKey => {navidromeId};
|
||||||
|
@override
|
||||||
|
Download map(Map<String, dynamic> data, {String? tablePrefix}) {
|
||||||
|
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
|
||||||
|
return Download(
|
||||||
|
navidromeId: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}navidrome_id'],
|
||||||
|
)!,
|
||||||
|
titel: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}titel'],
|
||||||
|
)!,
|
||||||
|
kuenstler: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}kuenstler'],
|
||||||
|
),
|
||||||
|
album: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}album'],
|
||||||
|
),
|
||||||
|
groesseBytes: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}groesse_bytes'],
|
||||||
|
)!,
|
||||||
|
geladenAmMs: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.int,
|
||||||
|
data['${effectivePrefix}geladen_am_ms'],
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
$DownloadsTable createAlias(String alias) {
|
||||||
|
return $DownloadsTable(attachedDatabase, alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Download extends DataClass implements Insertable<Download> {
|
||||||
|
final String navidromeId;
|
||||||
|
final String titel;
|
||||||
|
final String? kuenstler;
|
||||||
|
final String? album;
|
||||||
|
final int groesseBytes;
|
||||||
|
final int geladenAmMs;
|
||||||
|
const Download({
|
||||||
|
required this.navidromeId,
|
||||||
|
required this.titel,
|
||||||
|
this.kuenstler,
|
||||||
|
this.album,
|
||||||
|
required this.groesseBytes,
|
||||||
|
required this.geladenAmMs,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
map['navidrome_id'] = Variable<String>(navidromeId);
|
||||||
|
map['titel'] = Variable<String>(titel);
|
||||||
|
if (!nullToAbsent || kuenstler != null) {
|
||||||
|
map['kuenstler'] = Variable<String>(kuenstler);
|
||||||
|
}
|
||||||
|
if (!nullToAbsent || album != null) {
|
||||||
|
map['album'] = Variable<String>(album);
|
||||||
|
}
|
||||||
|
map['groesse_bytes'] = Variable<int>(groesseBytes);
|
||||||
|
map['geladen_am_ms'] = Variable<int>(geladenAmMs);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadsCompanion toCompanion(bool nullToAbsent) {
|
||||||
|
return DownloadsCompanion(
|
||||||
|
navidromeId: Value(navidromeId),
|
||||||
|
titel: Value(titel),
|
||||||
|
kuenstler: kuenstler == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(kuenstler),
|
||||||
|
album: album == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(album),
|
||||||
|
groesseBytes: Value(groesseBytes),
|
||||||
|
geladenAmMs: Value(geladenAmMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Download.fromJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
ValueSerializer? serializer,
|
||||||
|
}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return Download(
|
||||||
|
navidromeId: serializer.fromJson<String>(json['navidromeId']),
|
||||||
|
titel: serializer.fromJson<String>(json['titel']),
|
||||||
|
kuenstler: serializer.fromJson<String?>(json['kuenstler']),
|
||||||
|
album: serializer.fromJson<String?>(json['album']),
|
||||||
|
groesseBytes: serializer.fromJson<int>(json['groesseBytes']),
|
||||||
|
geladenAmMs: serializer.fromJson<int>(json['geladenAmMs']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
|
||||||
|
serializer ??= driftRuntimeOptions.defaultSerializer;
|
||||||
|
return <String, dynamic>{
|
||||||
|
'navidromeId': serializer.toJson<String>(navidromeId),
|
||||||
|
'titel': serializer.toJson<String>(titel),
|
||||||
|
'kuenstler': serializer.toJson<String?>(kuenstler),
|
||||||
|
'album': serializer.toJson<String?>(album),
|
||||||
|
'groesseBytes': serializer.toJson<int>(groesseBytes),
|
||||||
|
'geladenAmMs': serializer.toJson<int>(geladenAmMs),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Download copyWith({
|
||||||
|
String? navidromeId,
|
||||||
|
String? titel,
|
||||||
|
Value<String?> kuenstler = const Value.absent(),
|
||||||
|
Value<String?> album = const Value.absent(),
|
||||||
|
int? groesseBytes,
|
||||||
|
int? geladenAmMs,
|
||||||
|
}) => Download(
|
||||||
|
navidromeId: navidromeId ?? this.navidromeId,
|
||||||
|
titel: titel ?? this.titel,
|
||||||
|
kuenstler: kuenstler.present ? kuenstler.value : this.kuenstler,
|
||||||
|
album: album.present ? album.value : this.album,
|
||||||
|
groesseBytes: groesseBytes ?? this.groesseBytes,
|
||||||
|
geladenAmMs: geladenAmMs ?? this.geladenAmMs,
|
||||||
|
);
|
||||||
|
Download copyWithCompanion(DownloadsCompanion data) {
|
||||||
|
return Download(
|
||||||
|
navidromeId: data.navidromeId.present
|
||||||
|
? data.navidromeId.value
|
||||||
|
: this.navidromeId,
|
||||||
|
titel: data.titel.present ? data.titel.value : this.titel,
|
||||||
|
kuenstler: data.kuenstler.present ? data.kuenstler.value : this.kuenstler,
|
||||||
|
album: data.album.present ? data.album.value : this.album,
|
||||||
|
groesseBytes: data.groesseBytes.present
|
||||||
|
? data.groesseBytes.value
|
||||||
|
: this.groesseBytes,
|
||||||
|
geladenAmMs: data.geladenAmMs.present
|
||||||
|
? data.geladenAmMs.value
|
||||||
|
: this.geladenAmMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('Download(')
|
||||||
|
..write('navidromeId: $navidromeId, ')
|
||||||
|
..write('titel: $titel, ')
|
||||||
|
..write('kuenstler: $kuenstler, ')
|
||||||
|
..write('album: $album, ')
|
||||||
|
..write('groesseBytes: $groesseBytes, ')
|
||||||
|
..write('geladenAmMs: $geladenAmMs')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(
|
||||||
|
navidromeId,
|
||||||
|
titel,
|
||||||
|
kuenstler,
|
||||||
|
album,
|
||||||
|
groesseBytes,
|
||||||
|
geladenAmMs,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
(other is Download &&
|
||||||
|
other.navidromeId == this.navidromeId &&
|
||||||
|
other.titel == this.titel &&
|
||||||
|
other.kuenstler == this.kuenstler &&
|
||||||
|
other.album == this.album &&
|
||||||
|
other.groesseBytes == this.groesseBytes &&
|
||||||
|
other.geladenAmMs == this.geladenAmMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadsCompanion extends UpdateCompanion<Download> {
|
||||||
|
final Value<String> navidromeId;
|
||||||
|
final Value<String> titel;
|
||||||
|
final Value<String?> kuenstler;
|
||||||
|
final Value<String?> album;
|
||||||
|
final Value<int> groesseBytes;
|
||||||
|
final Value<int> geladenAmMs;
|
||||||
|
final Value<int> rowid;
|
||||||
|
const DownloadsCompanion({
|
||||||
|
this.navidromeId = const Value.absent(),
|
||||||
|
this.titel = const Value.absent(),
|
||||||
|
this.kuenstler = const Value.absent(),
|
||||||
|
this.album = const Value.absent(),
|
||||||
|
this.groesseBytes = const Value.absent(),
|
||||||
|
this.geladenAmMs = const Value.absent(),
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
});
|
||||||
|
DownloadsCompanion.insert({
|
||||||
|
required String navidromeId,
|
||||||
|
required String titel,
|
||||||
|
this.kuenstler = const Value.absent(),
|
||||||
|
this.album = const Value.absent(),
|
||||||
|
required int groesseBytes,
|
||||||
|
required int geladenAmMs,
|
||||||
|
this.rowid = const Value.absent(),
|
||||||
|
}) : navidromeId = Value(navidromeId),
|
||||||
|
titel = Value(titel),
|
||||||
|
groesseBytes = Value(groesseBytes),
|
||||||
|
geladenAmMs = Value(geladenAmMs);
|
||||||
|
static Insertable<Download> custom({
|
||||||
|
Expression<String>? navidromeId,
|
||||||
|
Expression<String>? titel,
|
||||||
|
Expression<String>? kuenstler,
|
||||||
|
Expression<String>? album,
|
||||||
|
Expression<int>? groesseBytes,
|
||||||
|
Expression<int>? geladenAmMs,
|
||||||
|
Expression<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return RawValuesInsertable({
|
||||||
|
if (navidromeId != null) 'navidrome_id': navidromeId,
|
||||||
|
if (titel != null) 'titel': titel,
|
||||||
|
if (kuenstler != null) 'kuenstler': kuenstler,
|
||||||
|
if (album != null) 'album': album,
|
||||||
|
if (groesseBytes != null) 'groesse_bytes': groesseBytes,
|
||||||
|
if (geladenAmMs != null) 'geladen_am_ms': geladenAmMs,
|
||||||
|
if (rowid != null) 'rowid': rowid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadsCompanion copyWith({
|
||||||
|
Value<String>? navidromeId,
|
||||||
|
Value<String>? titel,
|
||||||
|
Value<String?>? kuenstler,
|
||||||
|
Value<String?>? album,
|
||||||
|
Value<int>? groesseBytes,
|
||||||
|
Value<int>? geladenAmMs,
|
||||||
|
Value<int>? rowid,
|
||||||
|
}) {
|
||||||
|
return DownloadsCompanion(
|
||||||
|
navidromeId: navidromeId ?? this.navidromeId,
|
||||||
|
titel: titel ?? this.titel,
|
||||||
|
kuenstler: kuenstler ?? this.kuenstler,
|
||||||
|
album: album ?? this.album,
|
||||||
|
groesseBytes: groesseBytes ?? this.groesseBytes,
|
||||||
|
geladenAmMs: geladenAmMs ?? this.geladenAmMs,
|
||||||
|
rowid: rowid ?? this.rowid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
|
final map = <String, Expression>{};
|
||||||
|
if (navidromeId.present) {
|
||||||
|
map['navidrome_id'] = Variable<String>(navidromeId.value);
|
||||||
|
}
|
||||||
|
if (titel.present) {
|
||||||
|
map['titel'] = Variable<String>(titel.value);
|
||||||
|
}
|
||||||
|
if (kuenstler.present) {
|
||||||
|
map['kuenstler'] = Variable<String>(kuenstler.value);
|
||||||
|
}
|
||||||
|
if (album.present) {
|
||||||
|
map['album'] = Variable<String>(album.value);
|
||||||
|
}
|
||||||
|
if (groesseBytes.present) {
|
||||||
|
map['groesse_bytes'] = Variable<int>(groesseBytes.value);
|
||||||
|
}
|
||||||
|
if (geladenAmMs.present) {
|
||||||
|
map['geladen_am_ms'] = Variable<int>(geladenAmMs.value);
|
||||||
|
}
|
||||||
|
if (rowid.present) {
|
||||||
|
map['rowid'] = Variable<int>(rowid.value);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return (StringBuffer('DownloadsCompanion(')
|
||||||
|
..write('navidromeId: $navidromeId, ')
|
||||||
|
..write('titel: $titel, ')
|
||||||
|
..write('kuenstler: $kuenstler, ')
|
||||||
|
..write('album: $album, ')
|
||||||
|
..write('groesseBytes: $groesseBytes, ')
|
||||||
|
..write('geladenAmMs: $geladenAmMs, ')
|
||||||
|
..write('rowid: $rowid')
|
||||||
|
..write(')'))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class _$MeloDb extends GeneratedDatabase {
|
abstract class _$MeloDb extends GeneratedDatabase {
|
||||||
_$MeloDb(QueryExecutor e) : super(e);
|
_$MeloDb(QueryExecutor e) : super(e);
|
||||||
$MeloDbManager get managers => $MeloDbManager(this);
|
$MeloDbManager get managers => $MeloDbManager(this);
|
||||||
@@ -2779,6 +3213,7 @@ abstract class _$MeloDb extends GeneratedDatabase {
|
|||||||
this,
|
this,
|
||||||
);
|
);
|
||||||
late final $SongCategoriesTable songCategories = $SongCategoriesTable(this);
|
late final $SongCategoriesTable songCategories = $SongCategoriesTable(this);
|
||||||
|
late final $DownloadsTable downloads = $DownloadsTable(this);
|
||||||
@override
|
@override
|
||||||
Iterable<TableInfo<Table, Object?>> get allTables =>
|
Iterable<TableInfo<Table, Object?>> get allTables =>
|
||||||
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
|
||||||
@@ -2791,6 +3226,7 @@ abstract class _$MeloDb extends GeneratedDatabase {
|
|||||||
favorites,
|
favorites,
|
||||||
playbackHistory,
|
playbackHistory,
|
||||||
songCategories,
|
songCategories,
|
||||||
|
downloads,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5324,6 +5760,225 @@ typedef $$SongCategoriesTableProcessedTableManager =
|
|||||||
SongCategory,
|
SongCategory,
|
||||||
PrefetchHooks Function({bool songId})
|
PrefetchHooks Function({bool songId})
|
||||||
>;
|
>;
|
||||||
|
typedef $$DownloadsTableCreateCompanionBuilder =
|
||||||
|
DownloadsCompanion Function({
|
||||||
|
required String navidromeId,
|
||||||
|
required String titel,
|
||||||
|
Value<String?> kuenstler,
|
||||||
|
Value<String?> album,
|
||||||
|
required int groesseBytes,
|
||||||
|
required int geladenAmMs,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
typedef $$DownloadsTableUpdateCompanionBuilder =
|
||||||
|
DownloadsCompanion Function({
|
||||||
|
Value<String> navidromeId,
|
||||||
|
Value<String> titel,
|
||||||
|
Value<String?> kuenstler,
|
||||||
|
Value<String?> album,
|
||||||
|
Value<int> groesseBytes,
|
||||||
|
Value<int> geladenAmMs,
|
||||||
|
Value<int> rowid,
|
||||||
|
});
|
||||||
|
|
||||||
|
class $$DownloadsTableFilterComposer
|
||||||
|
extends Composer<_$MeloDb, $DownloadsTable> {
|
||||||
|
$$DownloadsTableFilterComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnFilters<String> get navidromeId => $composableBuilder(
|
||||||
|
column: $table.navidromeId,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get titel => $composableBuilder(
|
||||||
|
column: $table.titel,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get kuenstler => $composableBuilder(
|
||||||
|
column: $table.kuenstler,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get album => $composableBuilder(
|
||||||
|
column: $table.album,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<int> get groesseBytes => $composableBuilder(
|
||||||
|
column: $table.groesseBytes,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<int> get geladenAmMs => $composableBuilder(
|
||||||
|
column: $table.geladenAmMs,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$DownloadsTableOrderingComposer
|
||||||
|
extends Composer<_$MeloDb, $DownloadsTable> {
|
||||||
|
$$DownloadsTableOrderingComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
ColumnOrderings<String> get navidromeId => $composableBuilder(
|
||||||
|
column: $table.navidromeId,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get titel => $composableBuilder(
|
||||||
|
column: $table.titel,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get kuenstler => $composableBuilder(
|
||||||
|
column: $table.kuenstler,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get album => $composableBuilder(
|
||||||
|
column: $table.album,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<int> get groesseBytes => $composableBuilder(
|
||||||
|
column: $table.groesseBytes,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<int> get geladenAmMs => $composableBuilder(
|
||||||
|
column: $table.geladenAmMs,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$DownloadsTableAnnotationComposer
|
||||||
|
extends Composer<_$MeloDb, $DownloadsTable> {
|
||||||
|
$$DownloadsTableAnnotationComposer({
|
||||||
|
required super.$db,
|
||||||
|
required super.$table,
|
||||||
|
super.joinBuilder,
|
||||||
|
super.$addJoinBuilderToRootComposer,
|
||||||
|
super.$removeJoinBuilderFromRootComposer,
|
||||||
|
});
|
||||||
|
GeneratedColumn<String> get navidromeId => $composableBuilder(
|
||||||
|
column: $table.navidromeId,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get titel =>
|
||||||
|
$composableBuilder(column: $table.titel, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get kuenstler =>
|
||||||
|
$composableBuilder(column: $table.kuenstler, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get album =>
|
||||||
|
$composableBuilder(column: $table.album, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<int> get groesseBytes => $composableBuilder(
|
||||||
|
column: $table.groesseBytes,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<int> get geladenAmMs => $composableBuilder(
|
||||||
|
column: $table.geladenAmMs,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class $$DownloadsTableTableManager
|
||||||
|
extends
|
||||||
|
RootTableManager<
|
||||||
|
_$MeloDb,
|
||||||
|
$DownloadsTable,
|
||||||
|
Download,
|
||||||
|
$$DownloadsTableFilterComposer,
|
||||||
|
$$DownloadsTableOrderingComposer,
|
||||||
|
$$DownloadsTableAnnotationComposer,
|
||||||
|
$$DownloadsTableCreateCompanionBuilder,
|
||||||
|
$$DownloadsTableUpdateCompanionBuilder,
|
||||||
|
(Download, BaseReferences<_$MeloDb, $DownloadsTable, Download>),
|
||||||
|
Download,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
> {
|
||||||
|
$$DownloadsTableTableManager(_$MeloDb db, $DownloadsTable table)
|
||||||
|
: super(
|
||||||
|
TableManagerState(
|
||||||
|
db: db,
|
||||||
|
table: table,
|
||||||
|
createFilteringComposer: () =>
|
||||||
|
$$DownloadsTableFilterComposer($db: db, $table: table),
|
||||||
|
createOrderingComposer: () =>
|
||||||
|
$$DownloadsTableOrderingComposer($db: db, $table: table),
|
||||||
|
createComputedFieldComposer: () =>
|
||||||
|
$$DownloadsTableAnnotationComposer($db: db, $table: table),
|
||||||
|
updateCompanionCallback:
|
||||||
|
({
|
||||||
|
Value<String> navidromeId = const Value.absent(),
|
||||||
|
Value<String> titel = const Value.absent(),
|
||||||
|
Value<String?> kuenstler = const Value.absent(),
|
||||||
|
Value<String?> album = const Value.absent(),
|
||||||
|
Value<int> groesseBytes = const Value.absent(),
|
||||||
|
Value<int> geladenAmMs = const Value.absent(),
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => DownloadsCompanion(
|
||||||
|
navidromeId: navidromeId,
|
||||||
|
titel: titel,
|
||||||
|
kuenstler: kuenstler,
|
||||||
|
album: album,
|
||||||
|
groesseBytes: groesseBytes,
|
||||||
|
geladenAmMs: geladenAmMs,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
createCompanionCallback:
|
||||||
|
({
|
||||||
|
required String navidromeId,
|
||||||
|
required String titel,
|
||||||
|
Value<String?> kuenstler = const Value.absent(),
|
||||||
|
Value<String?> album = const Value.absent(),
|
||||||
|
required int groesseBytes,
|
||||||
|
required int geladenAmMs,
|
||||||
|
Value<int> rowid = const Value.absent(),
|
||||||
|
}) => DownloadsCompanion.insert(
|
||||||
|
navidromeId: navidromeId,
|
||||||
|
titel: titel,
|
||||||
|
kuenstler: kuenstler,
|
||||||
|
album: album,
|
||||||
|
groesseBytes: groesseBytes,
|
||||||
|
geladenAmMs: geladenAmMs,
|
||||||
|
rowid: rowid,
|
||||||
|
),
|
||||||
|
withReferenceMapper: (p0) => p0
|
||||||
|
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))
|
||||||
|
.toList(),
|
||||||
|
prefetchHooksCallback: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef $$DownloadsTableProcessedTableManager =
|
||||||
|
ProcessedTableManager<
|
||||||
|
_$MeloDb,
|
||||||
|
$DownloadsTable,
|
||||||
|
Download,
|
||||||
|
$$DownloadsTableFilterComposer,
|
||||||
|
$$DownloadsTableOrderingComposer,
|
||||||
|
$$DownloadsTableAnnotationComposer,
|
||||||
|
$$DownloadsTableCreateCompanionBuilder,
|
||||||
|
$$DownloadsTableUpdateCompanionBuilder,
|
||||||
|
(Download, BaseReferences<_$MeloDb, $DownloadsTable, Download>),
|
||||||
|
Download,
|
||||||
|
PrefetchHooks Function()
|
||||||
|
>;
|
||||||
|
|
||||||
class $MeloDbManager {
|
class $MeloDbManager {
|
||||||
final _$MeloDb _db;
|
final _$MeloDb _db;
|
||||||
@@ -5342,4 +5997,6 @@ class $MeloDbManager {
|
|||||||
$$PlaybackHistoryTableTableManager(_db, _db.playbackHistory);
|
$$PlaybackHistoryTableTableManager(_db, _db.playbackHistory);
|
||||||
$$SongCategoriesTableTableManager get songCategories =>
|
$$SongCategoriesTableTableManager get songCategories =>
|
||||||
$$SongCategoriesTableTableManager(_db, _db.songCategories);
|
$$SongCategoriesTableTableManager(_db, _db.songCategories);
|
||||||
|
$$DownloadsTableTableManager get downloads =>
|
||||||
|
$$DownloadsTableTableManager(_db, _db.downloads);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import 'player/audio_handler.dart';
|
|||||||
import 'player/mini_player.dart';
|
import 'player/mini_player.dart';
|
||||||
import 'services/baka_auth.dart';
|
import 'services/baka_auth.dart';
|
||||||
import 'services/logger_service.dart';
|
import 'services/logger_service.dart';
|
||||||
|
import 'services/download_service.dart';
|
||||||
import 'services/melo_cloud_service.dart';
|
import 'services/melo_cloud_service.dart';
|
||||||
import 'services/offline_mode.dart';
|
import 'services/offline_mode.dart';
|
||||||
|
import 'services/navidrome_service.dart';
|
||||||
import 'services/sync_service.dart';
|
import 'services/sync_service.dart';
|
||||||
import 'services/yt_download_service.dart';
|
import 'services/yt_download_service.dart';
|
||||||
import 'settings/app_settings.dart';
|
import 'settings/app_settings.dart';
|
||||||
@@ -39,6 +41,7 @@ late final AudioEffects _effects;
|
|||||||
late final BakaAuth _bakaAuth;
|
late final BakaAuth _bakaAuth;
|
||||||
late final YtDownloadService _ytDownload;
|
late final YtDownloadService _ytDownload;
|
||||||
late final SyncService _sync;
|
late final SyncService _sync;
|
||||||
|
late final DownloadService _downloads;
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -70,6 +73,9 @@ Future<void> main() async {
|
|||||||
cloud: MeloCloudService(auth: _bakaAuth),
|
cloud: MeloCloudService(auth: _bakaAuth),
|
||||||
);
|
);
|
||||||
await _sync.laden();
|
await _sync.laden();
|
||||||
|
final navidrome = NavidromeService();
|
||||||
|
await navidrome.ladeGespeicherteZugangsdaten();
|
||||||
|
_downloads = DownloadService(db: _db, navidrome: navidrome);
|
||||||
// Die Audio-Session steht erst beim Abspielen fest und kann wechseln —
|
// Die Audio-Session steht erst beim Abspielen fest und kann wechseln —
|
||||||
// die Klangeffekte müssen dann neu daran gehängt werden.
|
// die Klangeffekte müssen dann neu daran gehängt werden.
|
||||||
_handler.androidAudioSessionIdStream.listen((id) {
|
_handler.androidAudioSessionIdStream.listen((id) {
|
||||||
@@ -78,6 +84,10 @@ Future<void> main() async {
|
|||||||
void uebernehmeEinstellungen() {
|
void uebernehmeEinstellungen() {
|
||||||
_handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones;
|
_handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones;
|
||||||
_handler.normalizeVolume = _settings.normalizeVolume;
|
_handler.normalizeVolume = _settings.normalizeVolume;
|
||||||
|
_handler.cacheGrenzeBytes = _settings.cacheGrenzeBytes;
|
||||||
|
// Sofort anwenden: wer die Grenze senkt, will nicht warten, bis zufällig
|
||||||
|
// der nächste Titel gestreamt wird.
|
||||||
|
unawaited(_handler.raeumeZwischenspeicherAuf());
|
||||||
}
|
}
|
||||||
|
|
||||||
uebernehmeEinstellungen();
|
uebernehmeEinstellungen();
|
||||||
@@ -110,6 +120,7 @@ class MeloApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
ChangeNotifierProvider<BakaAuth>.value(value: _bakaAuth),
|
||||||
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
ChangeNotifierProvider<YtDownloadService>.value(value: _ytDownload),
|
||||||
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
ChangeNotifierProvider<SyncService>.value(value: _sync),
|
||||||
|
ChangeNotifierProvider<DownloadService>.value(value: _downloads),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'Melo',
|
title: 'Melo',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:audio_session/audio_session.dart';
|
|||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import '../library/database.dart';
|
import '../library/database.dart';
|
||||||
import '../services/cache_manager.dart';
|
import '../services/cache_manager.dart';
|
||||||
|
import '../services/download_speicher.dart';
|
||||||
import '../services/navidrome_service.dart';
|
import '../services/navidrome_service.dart';
|
||||||
import 'headphone_autoplay.dart';
|
import 'headphone_autoplay.dart';
|
||||||
import 'replay_gain.dart';
|
import 'replay_gain.dart';
|
||||||
@@ -142,6 +143,12 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
|||||||
late final SleepTimer sleepTimer;
|
late final SleepTimer sleepTimer;
|
||||||
late final Timer _positionRecordTimer;
|
late final Timer _positionRecordTimer;
|
||||||
late final CacheManager _cache;
|
late final CacheManager _cache;
|
||||||
|
final DownloadSpeicher _downloads = DownloadSpeicher();
|
||||||
|
|
||||||
|
/// Cache-Dateien, die gerade als Quelle in der Warteschlange stehen. Sie
|
||||||
|
/// dürfen nicht verdrängt werden — die Pfade sind im Player schon fest
|
||||||
|
/// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen.
|
||||||
|
final Set<String> _benutzteCacheDateien = {};
|
||||||
final NavidromeService _nav = NavidromeService();
|
final NavidromeService _nav = NavidromeService();
|
||||||
int? _lastCountedIndex;
|
int? _lastCountedIndex;
|
||||||
|
|
||||||
@@ -157,6 +164,16 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
|||||||
/// Einstellungen gesetzt; siehe [loadPlaylist].
|
/// Einstellungen gesetzt; siehe [loadPlaylist].
|
||||||
bool offlineOnly = false;
|
bool offlineOnly = false;
|
||||||
|
|
||||||
|
/// Obergrenze des Zwischenspeichers in Bytes. Wird von den Einstellungen
|
||||||
|
/// gesetzt; bewusst heruntergeladene Titel liegen woanders und zählen nicht
|
||||||
|
/// mit (siehe [DownloadSpeicher]).
|
||||||
|
///
|
||||||
|
/// **0 heißt „nichts behalten"**, nicht „unbegrenzt" — die Einstellung
|
||||||
|
/// bietet genau diese Wahl an. Der Startwert entspricht der Voreinstellung
|
||||||
|
/// der App, damit ein noch nicht übernommener Wert nie versehentlich den
|
||||||
|
/// ganzen Zwischenspeicher leert.
|
||||||
|
int cacheGrenzeBytes = 2048 * 1024 * 1024;
|
||||||
|
|
||||||
MeloAudioHandler({required this.db}) {
|
MeloAudioHandler({required this.db}) {
|
||||||
_cache = CacheManager();
|
_cache = CacheManager();
|
||||||
_cache.init();
|
_cache.init();
|
||||||
@@ -190,6 +207,12 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
|||||||
if (index != null && index < q.length) {
|
if (index != null && index < q.length) {
|
||||||
mediaItem.add(q[index].copyWith(duration: _player.duration));
|
mediaItem.add(q[index].copyWith(duration: _player.duration));
|
||||||
_applyGain(q[index]);
|
_applyGain(q[index]);
|
||||||
|
// Jetzt läuft er wirklich — erst das macht ihn im Sinne der
|
||||||
|
// Verdrängung zu einem „zuletzt gehörten" Titel.
|
||||||
|
final navId = navidromeIdOf(q[index]);
|
||||||
|
if (navId != null) {
|
||||||
|
unawaited(_cache.beruehre(NavidromeService.cacheSchluessel(navId)));
|
||||||
|
}
|
||||||
if (shouldCountPlay(index, _lastCountedIndex)) {
|
if (shouldCountPlay(index, _lastCountedIndex)) {
|
||||||
_lastCountedIndex = index;
|
_lastCountedIndex = index;
|
||||||
final songId = songIdOf(q[index]);
|
final songId = songIdOf(q[index]);
|
||||||
@@ -254,6 +277,9 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
|||||||
// unten "alle übersprungen" und meldete fälschlich den Offline-Modus.
|
// unten "alle übersprungen" und meldete fälschlich den Offline-Modus.
|
||||||
if (items.isEmpty) return 0;
|
if (items.isEmpty) return 0;
|
||||||
|
|
||||||
|
// Die alte Warteschlange ist gleich weg; ihre Dateien dürfen wieder
|
||||||
|
// verdrängt werden.
|
||||||
|
_benutzteCacheDateien.clear();
|
||||||
final quellen = [for (final item in items) await _quelle(item)];
|
final quellen = [for (final item in items) await _quelle(item)];
|
||||||
final behalten = [for (final quelle in quellen) quelle != null];
|
final behalten = [for (final quelle in quellen) quelle != null];
|
||||||
final uebersprungen = behalten.where((b) => !b).length;
|
final uebersprungen = behalten.where((b) => !b).length;
|
||||||
@@ -288,8 +314,14 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
|||||||
|
|
||||||
// Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten.
|
// Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten.
|
||||||
final navId = navidromeIdOf(item);
|
final navId = navidromeIdOf(item);
|
||||||
if (navId != null) {
|
// Wer den Titel bewusst heruntergeladen hat, braucht keine zweite Kopie
|
||||||
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache));
|
// im Zwischenspeicher — das wäre doppelter Platz und doppeltes Datenvolumen.
|
||||||
|
if (navId != null && await _downloads.treffer(navId) == null) {
|
||||||
|
unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache).then((_) {
|
||||||
|
// Direkt nach dem Ablegen aufräumen — sonst wächst der
|
||||||
|
// Zwischenspeicher weiter unbegrenzt.
|
||||||
|
raeumeZwischenspeicherAuf();
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return uebersprungen;
|
return uebersprungen;
|
||||||
}
|
}
|
||||||
@@ -302,19 +334,29 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
|
|||||||
/// gälte der Offline-Modus beim Starten, aber nicht beim Anhängen.
|
/// gälte der Offline-Modus beim Starten, aber nicht beim Anhängen.
|
||||||
Future<AudioSource?> _quelle(MediaItem item) async {
|
Future<AudioSource?> _quelle(MediaItem item) async {
|
||||||
final navId = navidromeIdOf(item);
|
final navId = navidromeIdOf(item);
|
||||||
|
// Erst die bewussten Downloads, dann der Zwischenspeicher: was der Nutzer
|
||||||
|
// mitgenommen hat, soll nie am Netz hängen — auch nicht, wenn zufällig
|
||||||
|
// eine Kopie im Cache liegt, die morgen verdrängt sein kann.
|
||||||
final treffer = navId == null
|
final treffer = navId == null
|
||||||
? null
|
? null
|
||||||
: await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
|
: await _downloads.treffer(navId) ??
|
||||||
|
await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId));
|
||||||
if (offlineOnly &&
|
if (offlineOnly &&
|
||||||
!istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) {
|
!istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (treffer != null) _benutzteCacheDateien.add(treffer.path);
|
||||||
return AudioSource.uri(
|
return AudioSource.uri(
|
||||||
quelleFuer(item, cacheTreffer: treffer?.path),
|
quelleFuer(item, cacheTreffer: treffer?.path),
|
||||||
tag: item,
|
tag: item,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hält den Zwischenspeicher unter der eingestellten Grenze, ohne dabei
|
||||||
|
/// Dateien der laufenden Warteschlange anzufassen.
|
||||||
|
Future<void> raeumeZwischenspeicherAuf() =>
|
||||||
|
_cache.begrenzeAuf(cacheGrenzeBytes, geschuetzt: _benutzteCacheDateien);
|
||||||
|
|
||||||
/// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang.
|
/// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang.
|
||||||
/// Der Server zählt nur bei Server-Titeln — für Titel vom Gerät gilt
|
/// Der Server zählt nur bei Server-Titeln — für Titel vom Gerät gilt
|
||||||
/// allein die lokale Historie.
|
/// allein die lokale Historie.
|
||||||
|
|||||||
@@ -3,6 +3,63 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Ein Eintrag des Zwischenspeichers, wie ihn die Verdrängung sieht.
|
||||||
|
/// Ohne Datei-Zugriff, damit [zuVerdraengen] rein bleibt.
|
||||||
|
class CacheEintrag {
|
||||||
|
const CacheEintrag({
|
||||||
|
required this.pfad,
|
||||||
|
required this.groesse,
|
||||||
|
required this.geaendertMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String pfad;
|
||||||
|
final int groesse;
|
||||||
|
|
||||||
|
/// Wann die Datei zuletzt angefasst wurde. Bewusst „geändert" und nicht
|
||||||
|
/// „zugegriffen": Android hängt Datenträger meist mit `noatime` ein, die
|
||||||
|
/// Zugriffszeit steht dort still. Der Cache-Treffer stempelt die Datei
|
||||||
|
/// deshalb selbst um (siehe [CacheManager.getCachedFile]).
|
||||||
|
final int geaendertMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Welche Dateien weichen müssen, damit der Zwischenspeicher [maxBytes] nicht
|
||||||
|
/// überschreitet — am längsten nicht gehört zuerst.
|
||||||
|
///
|
||||||
|
/// Rein und ohne Dateisystem: die Reihenfolge ist der Teil, der schiefgehen
|
||||||
|
/// kann, und der lässt sich so für sich prüfen.
|
||||||
|
/// [geschuetzt] sind Pfade, die gerade als Quelle in der Warteschlange
|
||||||
|
/// stehen. Sie bleiben liegen, auch wenn die Grenze dadurch überschritten
|
||||||
|
/// bleibt: die Wiedergabe hat die Pfade schon fest eingetragen, und eine
|
||||||
|
/// gelöschte Datei ließe sie mitten im Album abbrechen — ohne Rückfall aufs
|
||||||
|
/// Streamen. Die Grenze wird beim nächsten Mal wieder eingehalten.
|
||||||
|
List<String> zuVerdraengen(
|
||||||
|
List<CacheEintrag> eintraege,
|
||||||
|
int maxBytes, {
|
||||||
|
Set<String> geschuetzt = const {},
|
||||||
|
}) {
|
||||||
|
final grenze = maxBytes < 0 ? 0 : maxBytes;
|
||||||
|
var belegt = eintraege.fold<int>(0, (summe, e) => summe + e.groesse);
|
||||||
|
if (belegt <= grenze) return const [];
|
||||||
|
|
||||||
|
final sortiert = [
|
||||||
|
for (final e in eintraege)
|
||||||
|
if (!geschuetzt.contains(e.pfad)) e,
|
||||||
|
]..sort((a, b) {
|
||||||
|
final zeit = a.geaendertMs.compareTo(b.geaendertMs);
|
||||||
|
// Gleicher Zeitstempel: nach Pfad, damit dasselbe Eingabebild immer
|
||||||
|
// dieselbe Antwort ergibt.
|
||||||
|
return zeit != 0 ? zeit : a.pfad.compareTo(b.pfad);
|
||||||
|
});
|
||||||
|
|
||||||
|
final weg = <String>[];
|
||||||
|
for (final eintrag in sortiert) {
|
||||||
|
if (belegt <= grenze) break;
|
||||||
|
weg.add(eintrag.pfad);
|
||||||
|
belegt -= eintrag.groesse;
|
||||||
|
}
|
||||||
|
return weg;
|
||||||
|
}
|
||||||
|
|
||||||
class CacheManager {
|
class CacheManager {
|
||||||
static const _cacheDir = 'melo_cache';
|
static const _cacheDir = 'melo_cache';
|
||||||
late Directory _cacheFolder;
|
late Directory _cacheFolder;
|
||||||
@@ -42,9 +99,7 @@ class CacheManager {
|
|||||||
try {
|
try {
|
||||||
final hash = md5.convert(streamUrl.codeUnits).toString();
|
final hash = md5.convert(streamUrl.codeUnits).toString();
|
||||||
final file = File('${_cacheFolder.path}/$hash.mp3');
|
final file = File('${_cacheFolder.path}/$hash.mp3');
|
||||||
if (await file.exists()) {
|
if (await file.exists()) return file;
|
||||||
return file;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Fehler beim Abrufen der Cache-Datei: $e');
|
debugPrint('Fehler beim Abrufen der Cache-Datei: $e');
|
||||||
@@ -52,6 +107,65 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stempelt die Datei auf jetzt um — erst dadurch weiß die Verdrängung,
|
||||||
|
/// was zuletzt **gehört** wurde. Die Zugriffszeit des Dateisystems taugt
|
||||||
|
/// dafür nicht (siehe [CacheEintrag.geaendertMs]).
|
||||||
|
///
|
||||||
|
/// Bewusst getrennt vom Nachschlagen: beim Aufbau einer Warteschlange wird
|
||||||
|
/// jede Datei einmal nachgeschlagen. Würde dabei umgestempelt, hätten alle
|
||||||
|
/// fast denselben Zeitstempel und die Verdrängung wüsste nicht mehr, was
|
||||||
|
/// wirklich gehört wurde.
|
||||||
|
Future<void> beruehre(String streamUrl) async {
|
||||||
|
try {
|
||||||
|
final hash = md5.convert(streamUrl.codeUnits).toString();
|
||||||
|
final file = File('${_cacheFolder.path}/$hash.mp3');
|
||||||
|
if (await file.exists()) await file.setLastModified(DateTime.now());
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Cache-Zeitstempel nicht setzbar: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alle Einträge mit Größe und Zeitstempel — Grundlage für [zuVerdraengen].
|
||||||
|
Future<List<CacheEintrag>> eintraege() async {
|
||||||
|
if (!await _cacheFolder.exists()) return const [];
|
||||||
|
final gefunden = <CacheEintrag>[];
|
||||||
|
for (final eintrag in _cacheFolder.listSync()) {
|
||||||
|
if (eintrag is! File) continue;
|
||||||
|
// Halbe Downloads zählen nicht mit; sie räumt der Downloader selbst weg.
|
||||||
|
if (eintrag.path.endsWith('.part')) continue;
|
||||||
|
try {
|
||||||
|
final status = eintrag.statSync();
|
||||||
|
gefunden.add(CacheEintrag(
|
||||||
|
pfad: eintrag.path,
|
||||||
|
groesse: status.size,
|
||||||
|
geaendertMs: status.modified.millisecondsSinceEpoch,
|
||||||
|
));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Cache-Eintrag nicht lesbar: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gefunden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hält den Zwischenspeicher unter [maxBytes] und meldet, wie viele Dateien
|
||||||
|
/// dafür weichen mussten. Die bewusst heruntergeladenen Titel liegen in
|
||||||
|
/// einem anderen Ordner und sind davon nie betroffen.
|
||||||
|
Future<int> begrenzeAuf(int maxBytes, {Set<String> geschuetzt = const {}}) async {
|
||||||
|
final weg =
|
||||||
|
zuVerdraengen(await eintraege(), maxBytes, geschuetzt: geschuetzt);
|
||||||
|
var geloescht = 0;
|
||||||
|
for (final pfad in weg) {
|
||||||
|
try {
|
||||||
|
await File(pfad).delete();
|
||||||
|
geloescht++;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Cache-Eintrag nicht löschbar: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (geloescht > 0) debugPrint('Zwischenspeicher: $geloescht Dateien verdrängt');
|
||||||
|
return geloescht;
|
||||||
|
}
|
||||||
|
|
||||||
Future<File> getCacheFile(String streamUrl) async {
|
Future<File> getCacheFile(String streamUrl) async {
|
||||||
final hash = md5.convert(streamUrl.codeUnits).toString();
|
final hash = md5.convert(streamUrl.codeUnits).toString();
|
||||||
return File('${_cacheFolder.path}/$hash.mp3');
|
return File('${_cacheFolder.path}/$hash.mp3');
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../library/database.dart';
|
||||||
|
import 'download_speicher.dart';
|
||||||
|
import 'navidrome_service.dart';
|
||||||
|
|
||||||
|
/// Ab wie vielen Titeln vor dem Herunterladen nachgefragt wird.
|
||||||
|
///
|
||||||
|
/// Ein Album sind zehn bis zwanzig Titel — da wäre eine Rückfrage nur im Weg.
|
||||||
|
/// Ein Künstler können mehrere hundert und einige Gigabyte sein, und das
|
||||||
|
/// sollte niemand aus Versehen antippen.
|
||||||
|
const rueckfrageAb = 30;
|
||||||
|
|
||||||
|
bool brauchtRueckfrage(int anzahl) => anzahl >= rueckfrageAb;
|
||||||
|
|
||||||
|
/// Grobe Größenschätzung für die Rückfrage. Genau geht nicht — der Server
|
||||||
|
/// verrät die Dateigröße erst beim Abruf; diese Zahl beruht auf der Dauer
|
||||||
|
/// bei rund 256 kbit/s und ist als Hausnummer gedacht.
|
||||||
|
int geschaetzteBytes(Iterable<int> dauernSekunden) =>
|
||||||
|
dauernSekunden.fold<int>(0, (summe, s) => summe + s * 32000);
|
||||||
|
|
||||||
|
enum _Ergebnis { neu, schonDa, fehler }
|
||||||
|
|
||||||
|
/// Lädt Server-Titel bewusst auf das Gerät und führt Buch darüber.
|
||||||
|
///
|
||||||
|
/// Getrennt vom Zwischenspeicher, der beim Abspielen nebenbei entsteht: was
|
||||||
|
/// hier landet, hat der Nutzer gewollt und wird nie von selbst verdrängt.
|
||||||
|
class DownloadService extends ChangeNotifier {
|
||||||
|
DownloadService({
|
||||||
|
required this.db,
|
||||||
|
required this.navidrome,
|
||||||
|
DownloadSpeicher? speicher,
|
||||||
|
}) : speicher = speicher ?? DownloadSpeicher();
|
||||||
|
|
||||||
|
final MeloDb db;
|
||||||
|
final NavidromeService navidrome;
|
||||||
|
final DownloadSpeicher speicher;
|
||||||
|
|
||||||
|
bool _laeuft = false;
|
||||||
|
int _fertig = 0;
|
||||||
|
int _gesamt = 0;
|
||||||
|
int _fehlgeschlagen = 0;
|
||||||
|
String? _aktuell;
|
||||||
|
String? _fehler;
|
||||||
|
bool _abbruchGewuenscht = false;
|
||||||
|
|
||||||
|
bool get laeuft => _laeuft;
|
||||||
|
int get fertig => _fertig;
|
||||||
|
int get gesamt => _gesamt;
|
||||||
|
|
||||||
|
/// Titel, der gerade geladen wird.
|
||||||
|
String? get aktuell => _aktuell;
|
||||||
|
|
||||||
|
/// Meldung des letzten Fehlschlags. Sagt **nicht**, dass der ganze Lauf
|
||||||
|
/// gescheitert ist — siehe [fehlgeschlagen].
|
||||||
|
String? get fehler => _fehler;
|
||||||
|
|
||||||
|
/// Wie viele Titel im letzten Lauf nicht geladen werden konnten.
|
||||||
|
int get fehlgeschlagen => _fehlgeschlagen;
|
||||||
|
|
||||||
|
/// Fortschritt zwischen 0 und 1, oder `null`, solange nichts läuft.
|
||||||
|
double? get anteil =>
|
||||||
|
_laeuft && _gesamt > 0 ? (_fertig / _gesamt).clamp(0.0, 1.0) : null;
|
||||||
|
|
||||||
|
void abbrechen() {
|
||||||
|
if (_laeuft) _abbruchGewuenscht = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt [songs] herunter. Bereits vorhandene Titel werden übersprungen.
|
||||||
|
/// Gibt zurück, wie viele neu dazugekommen sind.
|
||||||
|
Future<int> lade(List<SubsonicSong> songs) async {
|
||||||
|
if (_laeuft) return 0;
|
||||||
|
// Die Zugangsdaten können sich seit dem App-Start geändert haben: wer
|
||||||
|
// sich erst in den Einstellungen anmeldet, hätte hier sonst eine leere
|
||||||
|
// Verbindung und jeder Download liefe ins Leere.
|
||||||
|
if (!navidrome.istVerbunden) {
|
||||||
|
await navidrome.ladeGespeicherteZugangsdaten();
|
||||||
|
}
|
||||||
|
if (!navidrome.istVerbunden) {
|
||||||
|
_fehler = 'Nicht mit dem Musikserver verbunden';
|
||||||
|
notifyListeners();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
_laeuft = true;
|
||||||
|
_abbruchGewuenscht = false;
|
||||||
|
_fehler = null;
|
||||||
|
_fehlgeschlagen = 0;
|
||||||
|
_fertig = 0;
|
||||||
|
_gesamt = songs.length;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
var neu = 0;
|
||||||
|
try {
|
||||||
|
for (final song in songs) {
|
||||||
|
if (_abbruchGewuenscht) break;
|
||||||
|
_aktuell = song.titel;
|
||||||
|
notifyListeners();
|
||||||
|
final ergebnis = await _ladeEinen(song);
|
||||||
|
if (ergebnis == _Ergebnis.neu) neu++;
|
||||||
|
if (ergebnis == _Ergebnis.fehler) _fehlgeschlagen++;
|
||||||
|
_fertig++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_laeuft = false;
|
||||||
|
_aktuell = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_Ergebnis> _ladeEinen(SubsonicSong song) async {
|
||||||
|
try {
|
||||||
|
// Datei und Buchführung entstehen nacheinander. Bricht die App
|
||||||
|
// dazwischen ab, liegt eine Datei ohne Zeile herum — sie tauchte dann
|
||||||
|
// in keiner Liste auf und ließe sich nie wieder loswerden. Deshalb hier
|
||||||
|
// beides prüfen und die fehlende Zeile nachtragen, statt nur „ist schon
|
||||||
|
// da" zu melden.
|
||||||
|
final vorhanden = await speicher.treffer(song.id);
|
||||||
|
if (vorhanden != null) {
|
||||||
|
await _buchen(song, await vorhanden.length());
|
||||||
|
return _Ergebnis.schonDa;
|
||||||
|
}
|
||||||
|
final ziel = await speicher.ziel(song.id);
|
||||||
|
final datei =
|
||||||
|
await navidrome.ladeDateiHerunter(navidrome.streamUrl(song.id), ziel);
|
||||||
|
if (datei == null) {
|
||||||
|
_fehler = 'Nicht ladbar: ${song.titel}';
|
||||||
|
return _Ergebnis.fehler;
|
||||||
|
}
|
||||||
|
await _buchen(song, await datei.length());
|
||||||
|
return _Ergebnis.neu;
|
||||||
|
} catch (e) {
|
||||||
|
_fehler = 'Fehler bei ${song.titel}: $e';
|
||||||
|
debugPrint('Download fehlgeschlagen: $e');
|
||||||
|
return _Ergebnis.fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _buchen(SubsonicSong song, int bytes) =>
|
||||||
|
db.merkeDownload(DownloadsCompanion.insert(
|
||||||
|
navidromeId: song.id,
|
||||||
|
titel: song.titel,
|
||||||
|
kuenstler: Value(song.kuenstler.isEmpty ? null : song.kuenstler),
|
||||||
|
album: Value(song.album.isEmpty ? null : song.album),
|
||||||
|
groesseBytes: bytes,
|
||||||
|
geladenAmMs: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
));
|
||||||
|
|
||||||
|
/// Nimmt einen heruntergeladenen Titel wieder vom Gerät.
|
||||||
|
/// Nimmt einen heruntergeladenen Titel wieder vom Gerät.
|
||||||
|
///
|
||||||
|
/// Die Zeile fällt nur, wenn die Datei wirklich weg ist — sonst bliebe eine
|
||||||
|
/// Datei ohne Buchführung liegen, die in keiner Liste mehr auftaucht.
|
||||||
|
Future<bool> entferne(String navidromeId) async {
|
||||||
|
await speicher.loesche(navidromeId);
|
||||||
|
if (await speicher.treffer(navidromeId) != null) {
|
||||||
|
_fehler = 'Datei ließ sich nicht entfernen';
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await db.vergissDownload(navidromeId);
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> entferneAlle() async {
|
||||||
|
await speicher.loescheAlle();
|
||||||
|
await db.vergissAlleDownloads();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// Ablage für bewusst heruntergeladene Titel.
|
||||||
|
///
|
||||||
|
/// Bewusst **nicht** im Cache-Ordner: den darf Android bei Speichernot ohne
|
||||||
|
/// Vorwarnung leeren — das ist sein Zweck. Einem Album, das jemand für die
|
||||||
|
/// Zugfahrt mitgenommen hat, darf genau das nicht passieren. Deshalb liegt
|
||||||
|
/// es neben der Datenbank im Support-Ordner, den das System in Ruhe lässt.
|
||||||
|
///
|
||||||
|
/// Die Trennung ist auch die Grundlage der Speicher-Rechnung: die Grenze für
|
||||||
|
/// den Zwischenspeicher gilt nur dort, Downloads sind unbegrenzt.
|
||||||
|
class DownloadSpeicher {
|
||||||
|
static const ordnerName = 'melo_downloads';
|
||||||
|
|
||||||
|
Directory? _ordner;
|
||||||
|
|
||||||
|
Future<Directory> _ordnerHolen() async {
|
||||||
|
final vorhanden = _ordner;
|
||||||
|
if (vorhanden != null) return vorhanden;
|
||||||
|
final basis = await getApplicationSupportDirectory();
|
||||||
|
final ordner = Directory(p.join(basis.path, ordnerName));
|
||||||
|
if (!await ordner.exists()) await ordner.create(recursive: true);
|
||||||
|
_ordner = ordner;
|
||||||
|
return ordner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dateiname eines Titels. Wie im Zwischenspeicher über die Server-ID und
|
||||||
|
/// nicht über die Stream-Adresse: die trägt bei jedem App-Start ein neues
|
||||||
|
/// Token und wäre als Schlüssel wertlos.
|
||||||
|
static String dateiName(String navidromeId) =>
|
||||||
|
'${md5.convert('navidrome:$navidromeId'.codeUnits)}.mp3';
|
||||||
|
|
||||||
|
/// Wohin der Titel geschrieben wird — die Datei muss es noch nicht geben.
|
||||||
|
Future<File> ziel(String navidromeId) async =>
|
||||||
|
File(p.join((await _ordnerHolen()).path, dateiName(navidromeId)));
|
||||||
|
|
||||||
|
/// Die fertige Datei, oder `null`, wenn der Titel nicht heruntergeladen ist.
|
||||||
|
Future<File?> treffer(String navidromeId) async {
|
||||||
|
final datei = await ziel(navidromeId);
|
||||||
|
return await datei.exists() ? datei : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> loesche(String navidromeId) async {
|
||||||
|
try {
|
||||||
|
final datei = await ziel(navidromeId);
|
||||||
|
if (await datei.exists()) await datei.delete();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Download nicht löschbar: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loescheAlle() async {
|
||||||
|
try {
|
||||||
|
final ordner = await _ordnerHolen();
|
||||||
|
if (await ordner.exists()) await ordner.delete(recursive: true);
|
||||||
|
_ordner = null;
|
||||||
|
await _ordnerHolen();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Downloads nicht löschbar: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -379,7 +379,18 @@ class NavidromeService {
|
|||||||
Future<File?> ladeInCache(String songId, Uri streamUri, CacheManager cache) async {
|
Future<File?> ladeInCache(String songId, Uri streamUri, CacheManager cache) async {
|
||||||
final ziel = await cache.getCacheFile(cacheSchluessel(songId));
|
final ziel = await cache.getCacheFile(cacheSchluessel(songId));
|
||||||
if (await ziel.exists()) return ziel;
|
if (await ziel.exists()) return ziel;
|
||||||
|
return ladeDateiHerunter(streamUri, ziel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt [streamUri] vollständig nach [ziel] und gibt die fertige Datei
|
||||||
|
/// zurück, oder `null`, wenn dabei etwas schiefging.
|
||||||
|
///
|
||||||
|
/// Dieselbe Logik für den Zwischenspeicher wie für einen bewussten
|
||||||
|
/// Download — nur das Ziel unterscheidet sich. Geschrieben wird zuerst in
|
||||||
|
/// eine `.part`-Datei; erst der vollständige, als Audio bestätigte
|
||||||
|
/// Download wird umbenannt. Ein Abbruch hinterlässt damit keine halbe
|
||||||
|
/// Datei, die später als gültiger Treffer gälte.
|
||||||
|
Future<File?> ladeDateiHerunter(Uri streamUri, File ziel) async {
|
||||||
final teil = File('${ziel.path}.part');
|
final teil = File('${ziel.path}.part');
|
||||||
final klient = http.Client();
|
final klient = http.Client();
|
||||||
try {
|
try {
|
||||||
@@ -387,16 +398,15 @@ class NavidromeService {
|
|||||||
.send(http.Request('GET', streamUri))
|
.send(http.Request('GET', streamUri))
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) {
|
if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) {
|
||||||
await logger.error('Cache abgebrochen: HTTP ${antwort.statusCode}, '
|
await logger.error('Download abgebrochen: HTTP ${antwort.statusCode}, '
|
||||||
'Typ ${antwort.headers['content-type'] ?? '-'}');
|
'Typ ${antwort.headers['content-type'] ?? '-'}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await antwort.stream.pipe(teil.openWrite());
|
await antwort.stream.pipe(teil.openWrite());
|
||||||
await teil.rename(ziel.path);
|
await teil.rename(ziel.path);
|
||||||
debugPrint('Im Cache abgelegt: ${ziel.path}');
|
|
||||||
return ziel;
|
return ziel;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await logger.error('Cache-Download fehlgeschlagen: $e', e, StackTrace.current);
|
await logger.error('Download fehlgeschlagen: $e', e, StackTrace.current);
|
||||||
if (await teil.exists()) await teil.delete();
|
if (await teil.exists()) await teil.delete();
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,10 +7,19 @@ class AppSettings extends ChangeNotifier {
|
|||||||
static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover';
|
static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover';
|
||||||
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
|
static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer';
|
||||||
static const _keyNormalizeVolume = 'gleiche_lautstaerke';
|
static const _keyNormalizeVolume = 'gleiche_lautstaerke';
|
||||||
|
static const _keyCacheGrenze = 'cache_grenze_mb';
|
||||||
|
|
||||||
|
/// Voreinstellung für den Zwischenspeicher: 2 GB. Groß genug für einige
|
||||||
|
/// hundert Titel, klein genug, um ein Telefon nicht zuzumüllen.
|
||||||
|
static const cacheGrenzeStandardMb = 2048;
|
||||||
|
|
||||||
|
/// Zur Auswahl stehende Grenzen in MB. 0 heißt: gar nicht zwischenspeichern.
|
||||||
|
static const cacheGrenzenMb = [0, 512, 1024, 2048, 4096, 8192];
|
||||||
|
|
||||||
bool _groupCoversByCategory = true;
|
bool _groupCoversByCategory = true;
|
||||||
bool _autoPlayOnHeadphones = false;
|
bool _autoPlayOnHeadphones = false;
|
||||||
bool _normalizeVolume = true;
|
bool _normalizeVolume = true;
|
||||||
|
int _cacheGrenzeMb = cacheGrenzeStandardMb;
|
||||||
|
|
||||||
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
|
/// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der
|
||||||
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
|
/// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild.
|
||||||
@@ -23,14 +32,30 @@ class AppSettings extends ChangeNotifier {
|
|||||||
/// auf das Niveau der leiseren abgesenkt.
|
/// auf das Niveau der leiseren abgesenkt.
|
||||||
bool get normalizeVolume => _normalizeVolume;
|
bool get normalizeVolume => _normalizeVolume;
|
||||||
|
|
||||||
|
/// Obergrenze des Zwischenspeichers in MB. Gilt nur für das, was beim
|
||||||
|
/// Abspielen nebenbei anfällt — bewusst heruntergeladene Titel liegen in
|
||||||
|
/// einem eigenen Ordner und werden nie verdrängt.
|
||||||
|
int get cacheGrenzeMb => _cacheGrenzeMb;
|
||||||
|
|
||||||
|
int get cacheGrenzeBytes => _cacheGrenzeMb * 1024 * 1024;
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
|
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
|
||||||
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
|
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
|
||||||
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
|
_normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true;
|
||||||
|
_cacheGrenzeMb = prefs.getInt(_keyCacheGrenze) ?? cacheGrenzeStandardMb;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setCacheGrenzeMb(int mb) async {
|
||||||
|
if (_cacheGrenzeMb == mb) return;
|
||||||
|
_cacheGrenzeMb = mb;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_keyCacheGrenze, mb);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> setGroupCoversByCategory(bool value) async {
|
Future<void> setGroupCoversByCategory(bool value) async {
|
||||||
if (_groupCoversByCategory == value) return;
|
if (_groupCoversByCategory == value) return;
|
||||||
_groupCoversByCategory = value;
|
_groupCoversByCategory = value;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../downloads/downloads_liste_screen.dart';
|
||||||
import '../library/database.dart';
|
import '../library/database.dart';
|
||||||
import '../library/library_service.dart';
|
import '../library/library_service.dart';
|
||||||
import '../library/permissions.dart';
|
import '../library/permissions.dart';
|
||||||
@@ -62,7 +63,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final db = context.read<MeloDb>();
|
final db = context.read<MeloDb>();
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Settings')),
|
appBar: AppBar(title: const Text('Einstellungen')),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
children: [
|
children: [
|
||||||
const _SectionLabel('Bibliothek'),
|
const _SectionLabel('Bibliothek'),
|
||||||
@@ -199,18 +200,61 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const _SectionLabel('Speicher'),
|
||||||
|
StreamBuilder<List<Download>>(
|
||||||
|
stream: db.watchDownloads(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final eintraege = snapshot.data ?? const <Download>[];
|
||||||
|
final bytes =
|
||||||
|
eintraege.fold<int>(0, (summe, e) => summe + e.groesseBytes);
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.download_for_offline_outlined),
|
||||||
|
title: const Text('Heruntergeladen'),
|
||||||
|
subtitle: Text(eintraege.isEmpty
|
||||||
|
? 'Noch nichts heruntergeladen'
|
||||||
|
: '${eintraege.length} Titel · '
|
||||||
|
'${DownloadsListeScreen.formatiereBytes(bytes)}'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (_) => const DownloadsListeScreen(),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.storage),
|
leading: const Icon(Icons.storage),
|
||||||
title: const Text('Cache-Speicher'),
|
title: const Text('Zwischenspeicher'),
|
||||||
subtitle: Text(_formatBytes(_cacheSize)),
|
// Zwei Zahlen, weil es zwei Töpfe sind: was hier steht, ist beim
|
||||||
|
// Abspielen nebenbei angefallen und darf verdrängt werden.
|
||||||
|
subtitle: Text('${_formatBytes(_cacheSize)} von '
|
||||||
|
'${context.watch<AppSettings>().cacheGrenzeMb} MB'),
|
||||||
trailing: _cacheSize > 0
|
trailing: _cacheSize > 0
|
||||||
? IconButton(
|
? IconButton(
|
||||||
tooltip: 'Cache löschen',
|
tooltip: 'Zwischenspeicher leeren',
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
onPressed: () => _clearCache(),
|
onPressed: () => _clearCache(),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.tune),
|
||||||
|
title: const Text('Obergrenze des Zwischenspeichers'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Gilt nicht für heruntergeladene Titel — die bleiben.'),
|
||||||
|
trailing: DropdownButton<int>(
|
||||||
|
value: context.watch<AppSettings>().cacheGrenzeMb,
|
||||||
|
items: [
|
||||||
|
for (final mb in AppSettings.cacheGrenzenMb)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: mb,
|
||||||
|
child: Text(mb == 0 ? 'aus' : '$mb MB'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (mb) {
|
||||||
|
if (mb != null) context.read<AppSettings>().setCacheGrenzeMb(mb);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.favorite),
|
leading: const Icon(Icons.favorite),
|
||||||
title: const Text('Favoriten-Sync'),
|
title: const Text('Favoriten-Sync'),
|
||||||
@@ -268,9 +312,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => StatefulBuilder(
|
builder: (ctx) => StatefulBuilder(
|
||||||
builder: (ctx, setDialogState) => AlertDialog(
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
backgroundColor: Colors.grey.shade900,
|
backgroundColor: MeloTheme.surface,
|
||||||
title: const Text('Navidrome-Anmeldung',
|
title: const Text('Navidrome-Anmeldung',
|
||||||
style: TextStyle(color: Colors.white, fontSize: 16)),
|
style: TextStyle(color: MeloTheme.text1, fontSize: 16)),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -294,8 +338,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
onPressed: () => setDialogState(() => selectedUser = 'Baka'),
|
onPressed: () => setDialogState(() => selectedUser = 'Baka'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: selectedUser == 'Baka'
|
backgroundColor: selectedUser == 'Baka'
|
||||||
? Colors.redAccent
|
? MeloTheme.red
|
||||||
: Colors.grey.shade700,
|
: MeloTheme.surfaceHigh,
|
||||||
),
|
),
|
||||||
child: const Text('Baka'),
|
child: const Text('Baka'),
|
||||||
),
|
),
|
||||||
@@ -306,8 +350,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
onPressed: () => setDialogState(() => selectedUser = 'Tinker'),
|
onPressed: () => setDialogState(() => selectedUser = 'Tinker'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: selectedUser == 'Tinker'
|
backgroundColor: selectedUser == 'Tinker'
|
||||||
? Colors.redAccent
|
? MeloTheme.red
|
||||||
: Colors.grey.shade700,
|
: MeloTheme.surfaceHigh,
|
||||||
),
|
),
|
||||||
child: const Text('Tinker'),
|
child: const Text('Tinker'),
|
||||||
),
|
),
|
||||||
@@ -318,8 +362,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
onPressed: () => setDialogState(() => selectedUser = 'Dustin'),
|
onPressed: () => setDialogState(() => selectedUser = 'Dustin'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: selectedUser == 'Dustin'
|
backgroundColor: selectedUser == 'Dustin'
|
||||||
? Colors.redAccent
|
? MeloTheme.red
|
||||||
: Colors.grey.shade700,
|
: MeloTheme.surfaceHigh,
|
||||||
),
|
),
|
||||||
child: const Text('Dustin'),
|
child: const Text('Dustin'),
|
||||||
),
|
),
|
||||||
@@ -358,7 +402,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2, color: Colors.redAccent),
|
strokeWidth: 2, color: MeloTheme.red),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -401,7 +445,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Login',
|
child: const Text('Login',
|
||||||
style: TextStyle(color: Colors.redAccent)),
|
style: TextStyle(color: MeloTheme.red)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo/services/cache_manager.dart';
|
||||||
|
|
||||||
|
CacheEintrag _e(String pfad, int groesse, int geaendertMs) =>
|
||||||
|
CacheEintrag(pfad: pfad, groesse: groesse, geaendertMs: geaendertMs);
|
||||||
|
|
||||||
|
/// Der Zwischenspeicher wuchs bisher unbegrenzt — es gab nur „alles löschen".
|
||||||
|
/// Diese Regel entscheidet, was wegmuss: am längsten nicht gehört zuerst.
|
||||||
|
void main() {
|
||||||
|
group('zuVerdraengen', () {
|
||||||
|
test('unter der Grenze fällt nichts weg', () {
|
||||||
|
final weg = zuVerdraengen([_e('a', 100, 1), _e('b', 100, 2)], 500);
|
||||||
|
|
||||||
|
expect(weg, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genau auf der Grenze fällt nichts weg', () {
|
||||||
|
final weg = zuVerdraengen([_e('a', 200, 1), _e('b', 300, 2)], 500);
|
||||||
|
|
||||||
|
expect(weg, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der älteste Eintrag geht zuerst', () {
|
||||||
|
// 3 × 200 = 600 bei Grenze 500: einer muss weg, der mit dem
|
||||||
|
// kleinsten Zeitstempel.
|
||||||
|
final weg = zuVerdraengen(
|
||||||
|
[_e('neu', 200, 30), _e('alt', 200, 10), _e('mittel', 200, 20)],
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(weg, ['alt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('es fällt so viel weg, bis die Grenze eingehalten ist', () {
|
||||||
|
// 4 × 200 = 800 bei Grenze 300: zwei reichen nicht, drei müssen weg.
|
||||||
|
final weg = zuVerdraengen(
|
||||||
|
[_e('a', 200, 1), _e('b', 200, 2), _e('c', 200, 3), _e('d', 200, 4)],
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(weg, ['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Grenze 0 räumt alles weg', () {
|
||||||
|
final weg = zuVerdraengen([_e('a', 1, 1), _e('b', 1, 2)], 0);
|
||||||
|
|
||||||
|
expect(weg, ['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine negative Grenze wird wie 0 behandelt', () {
|
||||||
|
expect(zuVerdraengen([_e('a', 1, 1)], -5), ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bei gleichem Zeitstempel entscheidet der Pfad — nie zufällig', () {
|
||||||
|
final weg = zuVerdraengen([_e('b', 200, 7), _e('a', 200, 7)], 200);
|
||||||
|
|
||||||
|
expect(weg, ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein leerer Zwischenspeicher ergibt nichts zu tun', () {
|
||||||
|
expect(zuVerdraengen(const [], 100), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lässt die Ausgangsliste unverändert', () {
|
||||||
|
final eintraege = [_e('b', 200, 2), _e('a', 200, 1)];
|
||||||
|
|
||||||
|
zuVerdraengen(eintraege, 100);
|
||||||
|
|
||||||
|
expect(eintraege.map((e) => e.pfad), ['b', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('geschützte Dateien', () {
|
||||||
|
test('eine benutzte Datei bleibt liegen, auch wenn sie die älteste ist', () {
|
||||||
|
// Der schwerste Fall: die Warteschlange hat den Pfad schon fest als
|
||||||
|
// Quelle eingetragen. Wird die Datei gelöscht, bricht die Wiedergabe
|
||||||
|
// mitten im Album ab — ohne Rückfall aufs Streamen.
|
||||||
|
final weg = zuVerdraengen(
|
||||||
|
[_e('laeuft', 200, 1), _e('alt', 200, 2), _e('neu', 200, 3)],
|
||||||
|
200,
|
||||||
|
geschuetzt: {'laeuft'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(weg, isNot(contains('laeuft')));
|
||||||
|
expect(weg, ['alt', 'neu']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lieber über der Grenze bleiben als die Wiedergabe abwürgen', () {
|
||||||
|
// Alles geschützt: dann wird eben nichts frei. Die Grenze greift beim
|
||||||
|
// nächsten Mal wieder.
|
||||||
|
final weg = zuVerdraengen(
|
||||||
|
[_e('a', 500, 1), _e('b', 500, 2)],
|
||||||
|
100,
|
||||||
|
geschuetzt: {'a', 'b'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(weg, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne Schutzliste verhält es sich wie zuvor', () {
|
||||||
|
final weg = zuVerdraengen([_e('a', 200, 1), _e('b', 200, 2)], 200);
|
||||||
|
|
||||||
|
expect(weg, ['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein geschützter Pfad, den es gar nicht gibt, stört nicht', () {
|
||||||
|
final weg = zuVerdraengen(
|
||||||
|
[_e('a', 200, 1), _e('b', 200, 2)],
|
||||||
|
200,
|
||||||
|
geschuetzt: {'gibtesnicht'},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(weg, ['a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo/services/download_service.dart';
|
||||||
|
import 'package:melo/services/download_speicher.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('brauchtRueckfrage', () {
|
||||||
|
test('ein Album geht ohne Nachfrage durch', () {
|
||||||
|
// Zehn bis zwanzig Titel — da wäre eine Rückfrage nur im Weg.
|
||||||
|
expect(brauchtRueckfrage(12), isFalse);
|
||||||
|
expect(brauchtRueckfrage(29), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ab der Schwelle wird gefragt', () {
|
||||||
|
expect(brauchtRueckfrage(rueckfrageAb), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein ganzer Künstler wird immer erfragt', () {
|
||||||
|
// Mehrere hundert Titel und einige Gigabyte darf niemand aus Versehen
|
||||||
|
// antippen.
|
||||||
|
expect(brauchtRueckfrage(347), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nichts auszuwählen fragt nicht', () {
|
||||||
|
expect(brauchtRueckfrage(0), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('geschaetzteBytes', () {
|
||||||
|
test('rechnet die Dauer in eine Hausnummer um', () {
|
||||||
|
// 200 s bei rund 256 kbit/s ≈ 6,4 MB.
|
||||||
|
expect(geschaetzteBytes([200]), 6400000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zählt mehrere Titel zusammen', () {
|
||||||
|
expect(geschaetzteBytes([100, 100]), geschaetzteBytes([200]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ohne Titel ist die Schätzung null', () {
|
||||||
|
expect(geschaetzteBytes(const []), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('DownloadSpeicher.dateiName', () {
|
||||||
|
test('hängt nur an der Server-ID, nicht an der Stream-Adresse', () {
|
||||||
|
// Die Adresse trägt bei jedem App-Start ein neues Token — als
|
||||||
|
// Schlüssel wäre die Datei nie wiederzufinden.
|
||||||
|
expect(DownloadSpeicher.dateiName('abc'),
|
||||||
|
DownloadSpeicher.dateiName('abc'));
|
||||||
|
expect(DownloadSpeicher.dateiName('abc'),
|
||||||
|
isNot(DownloadSpeicher.dateiName('xyz')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endet auf .mp3, damit der Player den Typ erkennt', () {
|
||||||
|
expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user