Files
Melo/lib/settings/settings_screen.dart
T
Hermes (Server)andClaude Opus 5 b42e045e67 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
2026-08-23 21:31:53 +02:00

611 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../downloads/downloads_liste_screen.dart';
import '../library/database.dart';
import '../library/library_service.dart';
import '../library/permissions.dart';
import '../library/playlist_service.dart';
import '../services/baka_auth.dart';
import '../services/cache_manager.dart';
import '../services/logger_service.dart';
import '../services/navidrome_service.dart';
import '../services/offline_mode.dart';
import '../services/sync_service.dart';
import '../shared/theme.dart';
import '../player/audio_effects.dart';
import 'app_settings.dart';
import 'equalizer_screen.dart';
import 'library_stats.dart';
/// Settings-Tab: Bibliotheks-Statistik, Geräte-Abgleich, Berechtigungen,
/// Navidrome, Über Melo.
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
late final NavidromeService _navidrome = NavidromeService();
late final CacheManager _cache = CacheManager();
int _cacheSize = 0;
@override
void initState() {
super.initState();
_navidrome.ladeGespeicherteZugangsdaten();
_cache.init().then((_) => _updateCacheSize());
}
Future<void> _updateCacheSize() async {
final size = await _cache.getCacheSize();
if (mounted) setState(() => _cacheSize = size);
}
/// Öffnet die Klangeffekt-Oberfläche des Systems (auf POCO/Xiaomi ist das
/// Dolby Atmos). Nicht jedes Gerät bietet sie an.
Future<void> _oeffneSystemKlangeffekte(BuildContext context) async {
final effects = context.read<AudioEffects>();
final messenger = ScaffoldMessenger.of(context);
final geoeffnet = await effects.openSystemPanel();
if (!geoeffnet) {
messenger.showSnackBar(
const SnackBar(
content: Text('Dieses Gerät bietet keine System-Klangeffekte an'),
),
);
}
}
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(title: const Text('Einstellungen')),
body: ListView(
children: [
const _SectionLabel('Bibliothek'),
StreamBuilder<List<Song>>(
stream: db.watchSongs(),
builder: (context, snapshot) {
final songs = snapshot.data ?? const [];
return Column(
children: [
ListTile(
leading: const Icon(Icons.library_music),
title: Text('${songs.length} Songs in der Bibliothek'),
),
ListTile(
leading: const Icon(Icons.schedule),
title:
Text('${formatTotalDuration(songs)} Gesamtspieldauer'),
),
Consumer<LibraryService>(
builder: (context, lib, _) => Column(
children: [
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: Text(addMusicLabel),
subtitle: const Text(
'Musikordner auswählen und einlesen'),
enabled: !lib.scanning,
onTap: lib.pickFolderAndScan,
),
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Erneut scannen'),
subtitle: lib.scanning
? Text('Scanne … ${lib.scanDone}'
'${lib.scanTotal > 0 ? ' / ${lib.scanTotal}' : ''}')
: const Text('Bekannte Ordner neu einlesen'),
enabled: !lib.scanning,
onTap: lib.rescan,
),
],
),
),
],
);
},
),
const Divider(height: 1),
const _SectionLabel('Ton'),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Equalizer'),
subtitle: const Text('Klangprofile, Bass und Immersives Audio'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const EqualizerScreen()),
),
),
ListTile(
leading: const Icon(Icons.surround_sound),
title: const Text('Dolby Atmos'),
subtitle: const Text('Klangeffekte des Systems öffnen'),
trailing: const Icon(Icons.open_in_new),
onTap: () => _oeffneSystemKlangeffekte(context),
),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
secondary: const Icon(Icons.equalizer),
title: const Text('Gleiche Lautstärke'),
subtitle: const Text(
'Senkt laute Titel ab (nur bei Dateien mit ReplayGain-Tag)'),
value: settings.normalizeVolume,
onChanged: settings.setNormalizeVolume,
),
),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
secondary: const Icon(Icons.headset),
title: const Text('Automatische Wiedergabe'),
subtitle: const Text(
'Startet, sobald Kopfhörer verbunden werden (auch Bluetooth)'),
value: settings.autoPlayOnHeadphones,
onChanged: settings.setAutoPlayOnHeadphones,
),
),
const Divider(height: 1),
const _SectionLabel('Darstellung'),
Consumer<AppSettings>(
builder: (context, settings, _) => SwitchListTile(
secondary: const Icon(Icons.photo_library_outlined),
title: const Text('Gleiche Kategorie = gleiches Coverbild'),
subtitle: const Text(
'Songs einer Kategorie zeigen das Bild der ersten Kategorie'),
value: settings.groupCoversByCategory,
onChanged: settings.setGroupCoversByCategory,
),
),
const Divider(height: 1),
const _SectionLabel('Musikserver'),
ListTile(
leading: const Icon(Icons.cloud_circle),
title: const Text('Navidrome'),
subtitle: _navidrome.istVerbunden
? const Text('Verbunden')
: const Text('Nicht verbunden'),
trailing: _navidrome.istVerbunden
? IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _trennNavidrome(),
)
: IconButton(
icon: const Icon(Icons.login),
onPressed: () => _zeigeNavidromeDialog(),
),
),
if (!_navidrome.istVerbunden)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'💡 Tipp: Oder nutze Melo lokal ohne Server-Konto (nur deine Musik auf dem Gerät)',
style: TextStyle(fontSize: 12, color: MeloTheme.text2),
),
),
ListTile(
leading: const Icon(Icons.wifi_off),
title: const Text('Offline-Modus'),
subtitle: const Text(
'Titel vom Server nur abspielen, wenn sie schon hier liegen'),
trailing: Consumer<OfflineMode>(
builder: (context, offlineMode, _) {
return Switch(
value: offlineMode.enabled,
onChanged: (value) => offlineMode.setEnabled(value),
);
},
),
),
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(
leading: const Icon(Icons.storage),
title: const Text('Zwischenspeicher'),
// 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
? IconButton(
tooltip: 'Zwischenspeicher leeren',
icon: const Icon(Icons.delete),
onPressed: () => _clearCache(),
)
: 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(
leading: const Icon(Icons.favorite),
title: const Text('Favoriten-Sync'),
subtitle: const Text('Favoriten vom Server importieren'),
trailing: IconButton(
tooltip: 'Vom Server laden',
icon: const Icon(Icons.cloud_download),
onPressed: () => _syncServerFavorites(),
),
),
ListTile(
leading: const Icon(Icons.playlist_play),
title: const Text('Playlisten-Sync'),
subtitle: const Text('Server-Playlisten importieren'),
trailing: IconButton(
tooltip: 'Vom Server laden',
icon: const Icon(Icons.cloud_download),
onPressed: () => _syncServerPlaylists(),
),
),
const Divider(height: 1),
const _SectionLabel('Geräte-Abgleich'),
const _CloudSyncBereich(),
const Divider(height: 1),
const _SectionLabel('Berechtigungen'),
ListTile(
leading: const Icon(Icons.mic_none),
title: const Text('Musik-Berechtigung'),
trailing: TextButton(
onPressed: openMusicPermissionSettings,
child: const Text('Einstellungen öffnen'),
),
),
const Divider(height: 1),
const _SectionLabel('Über Melo'),
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Melo'),
subtitle: Text('Deine Musik. Offline. Kein Abo.'),
),
],
),
);
}
void _zeigeNavidromeDialog() {
final urlCtrl = TextEditingController(text: 'https://musik.baka-net.de');
final passCtrl = TextEditingController();
String selectedUser = 'Baka';
bool verbindet = false;
bool obscure = true;
String? fehler;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: MeloTheme.surface,
title: const Text('Navidrome-Anmeldung',
style: TextStyle(color: MeloTheme.text1, fontSize: 16)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: urlCtrl,
style: const TextStyle(color: Colors.white),
decoration: const InputDecoration(
labelText: 'Server-URL',
labelStyle: TextStyle(color: Colors.grey),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
const Text('Benutzer:', style: TextStyle(fontSize: 12, color: MeloTheme.text2)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: () => setDialogState(() => selectedUser = 'Baka'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Baka'
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Baka'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () => setDialogState(() => selectedUser = 'Tinker'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Tinker'
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Tinker'),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: () => setDialogState(() => selectedUser = 'Dustin'),
style: FilledButton.styleFrom(
backgroundColor: selectedUser == 'Dustin'
? MeloTheme.red
: MeloTheme.surfaceHigh,
),
child: const Text('Dustin'),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: passCtrl,
style: const TextStyle(color: Colors.white),
obscureText: obscure,
decoration: InputDecoration(
labelText: 'Passwort',
labelStyle: const TextStyle(color: Colors.grey),
border: const OutlineInputBorder(),
// Anzeigen-Umschalter: verdeckte Tippfehler im Passwort
// sichtbar machen (häufigste Login-Fehlerquelle).
suffixIcon: IconButton(
icon: Icon(
obscure ? Icons.visibility_off : Icons.visibility,
color: Colors.grey,
),
onPressed: () => setDialogState(() => obscure = !obscure),
),
),
),
if (fehler != null) ...[
const SizedBox(height: 8),
Text(fehler!,
style: const TextStyle(color: Colors.red, fontSize: 12)),
],
if (verbindet)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: MeloTheme.red),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Abbrechen')),
TextButton(
onPressed: verbindet
? null
: () async {
setDialogState(() => verbindet = true);
// Randständige Leerzeichen entfernen (verdeckte
// Fehlerquelle im obscureText-Feld / bei Autofill).
final url = urlCtrl.text.trim();
final pass = passCtrl.text.trim();
logger.info(
'Navidrome Login-Versuch (pwLeer=${pass.isEmpty})');
_navidrome.setCredentials(url, selectedUser, pass);
final ok = await _navidrome.ping();
setDialogState(() => verbindet = false);
if (ok && ctx.mounted) {
logger.info('Navidrome Login erfolgreich');
await _navidrome.speichereZugangsdaten(
url, selectedUser, pass);
if (ctx.mounted) Navigator.pop(ctx);
if (mounted) setState(() {});
} else if (ctx.mounted) {
// Server-Meldung + Passwortlänge festhalten: „Wrong
// username or password" bei pwLen=10 zeigt sofort
// einen Tippfehler statt eines Netzwerkproblems.
final grund = _navidrome.letzterLoginFehler;
await logger.error(
'Navidrome Login fehlgeschlagen '
'(pwLen=${pass.length}, grund=${grund ?? 'unbekannt'})');
setDialogState(() =>
fehler = '❌ ${grund ?? 'Login fehlgeschlagen'}');
}
},
child: const Text('Login',
style: TextStyle(color: MeloTheme.red)),
),
],
),
),
).then((_) {
urlCtrl.dispose();
passCtrl.dispose();
});
}
Future<void> _trennNavidrome() async {
await _navidrome.loescheZugangsdaten();
if (mounted) setState(() {});
}
Future<void> _clearCache() async {
await _cache.clearCache();
if (mounted) {
setState(() => _cacheSize = 0);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cache gelöscht')),
);
}
}
Future<void> _syncServerFavorites() async {
final playlistService = context.read<PlaylistService>();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lade Favoriten vom Server...')),
);
final count = await playlistService.syncFavoritesFromServer();
if (mounted) {
if (count == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ Server nicht verbunden oder keine Favoriten')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('✅ $count Favoriten importiert')),
);
}
}
}
Future<void> _syncServerPlaylists() async {
final playlistService = context.read<PlaylistService>();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lade Playlisten vom Server...')),
);
final count = await playlistService.syncPlaylistsFromServer();
if (mounted) {
if (count == 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('❌ Server nicht verbunden oder keine Playlisten')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('✅ $count Playlisten importiert')),
);
}
}
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
/// Kleine graue Überschrift über einer Einstellungs-Gruppe.
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.label);
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
label,
style: const TextStyle(
color: MeloTheme.text2,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
}
}
/// Abgleich mit der Melo-Cloud: was auf einem Gerät dazukommt, verschwindet
/// oder gehört wird, steht danach auf allen Geräten gleich.
class _CloudSyncBereich extends StatelessWidget {
const _CloudSyncBereich();
@override
Widget build(BuildContext context) {
final auth = context.watch<BakaAuth>();
final sync = context.watch<SyncService>();
if (!auth.istAngemeldet) {
return const ListTile(
leading: Icon(Icons.cloud_off),
title: Text('Nicht angemeldet'),
subtitle: Text(
'Für den Abgleich im Tab „Online“ beim Baka-Konto anmelden'),
);
}
return Column(
children: [
ListTile(
leading: const Icon(Icons.cloud_sync),
title: Text('Angemeldet als ${auth.benutzer}'),
subtitle: Text(sync.status ??
(sync.letzterLauf != null
? 'Zuletzt abgeglichen: ${_zeitText(sync.letzterLauf!)}'
: 'Noch nie abgeglichen')),
trailing: sync.laeuft
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: IconButton(
tooltip: 'Jetzt abgleichen',
icon: const Icon(Icons.sync),
onPressed: sync.synchronisiere,
),
),
if (sync.laeuft && sync.gesamt > 0)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: LinearProgressIndicator(
value: sync.erledigt / sync.gesamt,
color: MeloTheme.red,
),
),
if (sync.fehler != null)
ListTile(
leading: const Icon(Icons.error_outline, color: Colors.orange),
title: Text(sync.fehler!,
style: const TextStyle(color: Colors.orange, fontSize: 13)),
),
],
);
}
}
String _zeitText(DateTime zeit) {
final vergangen = DateTime.now().difference(zeit);
if (vergangen.inMinutes < 1) return 'gerade eben';
if (vergangen.inHours < 1) return 'vor ${vergangen.inMinutes} Min.';
if (vergangen.inDays < 1) return 'vor ${vergangen.inHours} Std.';
return 'vor ${vergangen.inDays} Tagen';
}