import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/library_service.dart'; import '../library/permissions.dart'; import '../library/playlist_service.dart'; import '../services/cache_manager.dart'; import '../services/logger_service.dart'; import '../services/navidrome_service.dart'; import '../services/offline_mode.dart'; import '../player/audio_effects.dart'; import 'app_settings.dart'; import 'equalizer_screen.dart'; import 'library_stats.dart'; /// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo. class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { 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 _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 _oeffneSystemKlangeffekte(BuildContext context) async { final effects = context.read(); 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(); return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ListView( children: [ const _SectionLabel('Bibliothek'), StreamBuilder>( 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( 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( 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( 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( 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: Colors.white54), ), ), ListTile( leading: const Icon(Icons.wifi_off), title: const Text('📴 Offline-Modus'), subtitle: const Text('Nur gecachte Lieder abspielen'), trailing: Consumer( builder: (context, offlineMode, _) { return Switch( value: offlineMode.enabled, onChanged: (value) => offlineMode.setEnabled(value), ); }, ), ), ListTile( leading: const Icon(Icons.storage), title: const Text('🗄️ Cache-Speicher'), subtitle: Text(_formatBytes(_cacheSize)), trailing: _cacheSize > 0 ? IconButton( tooltip: 'Cache löschen', icon: const Icon(Icons.delete), onPressed: () => _clearCache(), ) : null, ), 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('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: Colors.grey.shade900, title: const Text('🌐 Navidrome Login', style: TextStyle(color: Colors.white, 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: Colors.white70)), const SizedBox(height: 8), Row( children: [ Expanded( child: FilledButton( onPressed: () => setDialogState(() => selectedUser = 'Baka'), style: FilledButton.styleFrom( backgroundColor: selectedUser == 'Baka' ? Colors.redAccent : Colors.grey.shade700, ), child: const Text('Baka'), ), ), const SizedBox(width: 8), Expanded( child: FilledButton( onPressed: () => setDialogState(() => selectedUser = 'Tinker'), style: FilledButton.styleFrom( backgroundColor: selectedUser == 'Tinker' ? Colors.redAccent : Colors.grey.shade700, ), child: const Text('Tinker'), ), ), const SizedBox(width: 8), Expanded( child: FilledButton( onPressed: () => setDialogState(() => selectedUser = 'Dustin'), style: FilledButton.styleFrom( backgroundColor: selectedUser == 'Dustin' ? Colors.redAccent : Colors.grey.shade700, ), 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: Colors.redAccent), ), ), ], ), ), 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: Colors.redAccent)), ), ], ), ), ).then((_) { urlCtrl.dispose(); passCtrl.dispose(); }); } Future _trennNavidrome() async { await _navidrome.loescheZugangsdaten(); if (mounted) setState(() {}); } Future _clearCache() async { await _cache.clearCache(); if (mounted) { setState(() => _cacheSize = 0); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cache gelöscht')), ); } } Future _syncServerFavorites() async { final playlistService = context.read(); 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 _syncServerPlaylists() async { final playlistService = context.read(); 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: Colors.white54, fontSize: 12, fontWeight: FontWeight.bold, ), ), ); } }