diff --git a/lib/database/db_helper.dart b/lib/database/db_helper.dart index 685d46c..e9b697c 100644 --- a/lib/database/db_helper.dart +++ b/lib/database/db_helper.dart @@ -535,4 +535,19 @@ class DbHelper { await txn.delete('songs'); }); } + + /// Speicher-Info für die Einstellungen (F4): Anzahl lokaler Songs + + /// belegter Speicher in Bytes (Σ `groesse_bytes`). + Future> speicherInfo() async { + final d = await db; + final rows = await d.rawQuery( + 'SELECT COUNT(*) AS anzahl, COALESCE(SUM(groesse_bytes), 0) AS bytes ' + 'FROM songs'); + if (rows.isEmpty) return {'anzahl': 0, 'bytes': 0}; + final erste = rows.first; + return { + 'anzahl': (erste['anzahl'] as num?)?.toInt() ?? 0, + 'bytes': (erste['bytes'] as num?)?.toInt() ?? 0, + }; + } } diff --git a/lib/main.dart b/lib/main.dart index 658892f..e4521af 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -186,13 +186,18 @@ class _MeloAppState extends State { Widget build(BuildContext context) { final auth = AuthService(); - return MaterialApp( - title: 'Melo', - debugShowCheckedModeBanner: false, - theme: MeloTheme.theme, - home: auth.istEingeloggt - ? const Scaffold(body: MeloHome()) - : const LoginScreen(), + // ValueListenableBuilder: Akzentfarbe (per User, in Einstellungen wählbar) + // wechselt das komplette Theme live — kein App-Neustart nötig. + return ValueListenableBuilder( + valueListenable: MeloTheme.akzentNotifier, + builder: (_, akzent, __) => MaterialApp( + title: 'Melo', + debugShowCheckedModeBanner: false, + theme: MeloTheme.theme, + home: auth.istEingeloggt + ? const Scaffold(body: MeloHome()) + : const LoginScreen(), + ), ); } diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 66515ce..9c4bfda 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../services/auth_service.dart'; import '../services/navidrome_service.dart'; import '../utils/farb_theme.dart'; +import '../utils/user_effekte.dart'; import '../config/app_config.dart'; import 'home_screen.dart'; @@ -73,6 +74,8 @@ class _LoginScreenState extends State if (result.erfolg) { _setzeStatus('✅ Login erfolgreich!', ok: true); + // Per-User-Akzent (überschreibbar in den Einstellungen) + Begrüßung + await UserEffekt.anwenden(user); // Navidrome automatisch mit gleichen Credentials syncen await NavidromeService().speichereZugangsdaten( AppConfig.navidromeUrl, user, pass); diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 64e9371..393f2bb 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../models/song.dart'; import '../services/favoriten_service.dart'; import '../services/lyrics_service.dart'; @@ -389,7 +390,12 @@ class _NowPlayingScreenState extends State } /// Schlaf-Timer: Chips Aus/15/30/45/60 + Restzeit, wenn aktiv. - void _zeigeSleepTimer() { + /// Der in den Einstellungen gewählte Standard (`sleep_timer_default_min`) + /// ist vorausgewählt markiert, solange kein Timer läuft. + Future _zeigeSleepTimer() async { + final p = await SharedPreferences.getInstance(); + final defaultMin = p.getInt('sleep_timer_default_min') ?? 0; + if (!mounted) return; showModalBottomSheet( context: context, backgroundColor: MeloTheme.dunkel1, @@ -397,6 +403,7 @@ class _NowPlayingScreenState extends State builder: (ctx, setSheetState) { final aktiv = _player.sleepTimerAktiv; final rest = _player.sleepRestzeit; + final zeigeStandard = !aktiv && defaultMin > 0; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), @@ -427,6 +434,14 @@ class _NowPlayingScreenState extends State ], ], ), + if (zeigeStandard) ...[ + const SizedBox(height: 4), + Text( + 'Standard: $defaultMin Min (aus Einstellungen)', + style: const TextStyle( + fontSize: 11, color: MeloTheme.textSekundaer), + ), + ], const SizedBox(height: 14), Row( children: [ @@ -436,21 +451,25 @@ class _NowPlayingScreenState extends State setSheetState(() {}); }), _zeitChip(ctx, '15', const Duration(minutes: 15), + standard: defaultMin == 15 && zeigeStandard, onTap: () { _player.setSleepTimer(const Duration(minutes: 15)); setSheetState(() {}); }), _zeitChip(ctx, '30', const Duration(minutes: 30), + standard: defaultMin == 30 && zeigeStandard, onTap: () { _player.setSleepTimer(const Duration(minutes: 30)); setSheetState(() {}); }), _zeitChip(ctx, '45', const Duration(minutes: 45), + standard: defaultMin == 45 && zeigeStandard, onTap: () { _player.setSleepTimer(const Duration(minutes: 45)); setSheetState(() {}); }), _zeitChip(ctx, '60', const Duration(minutes: 60), + standard: defaultMin == 60 && zeigeStandard, onTap: () { _player.setSleepTimer(const Duration(minutes: 60)); setSheetState(() {}); @@ -467,7 +486,7 @@ class _NowPlayingScreenState extends State } Widget _zeitChip(BuildContext ctx, String label, Duration? dauer, - {bool aktiv = false, VoidCallback? onTap}) { + {bool aktiv = false, bool standard = false, VoidCallback? onTap}) { return GestureDetector( onTap: onTap, child: Container( @@ -476,9 +495,13 @@ class _NowPlayingScreenState extends State decoration: BoxDecoration( color: aktiv ? MeloTheme.rot : MeloTheme.dunkel2, borderRadius: BorderRadius.circular(16), + // Standard-Chip (Einstellung) bekommt einen Akzent-Rahmen + border: standard + ? Border.all(color: MeloTheme.rot, width: 1.5) + : null, ), child: Text( - label, + standard ? '$label ⭐' : label, style: TextStyle( fontSize: 12, color: aktiv ? Colors.white : MeloTheme.textSekundaer, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 9797ed4..b7494ea 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,12 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; import '../utils/farb_theme.dart'; +import '../utils/user_effekte.dart'; +import '../services/auth_service.dart'; +import '../services/cloud_service.dart'; +import '../services/player_service.dart'; +import '../services/sync_service.dart'; +import '../database/db_helper.dart'; import 'cloud_screen.dart'; import 'recap_screen.dart'; import 'erweitert_screen.dart'; -import '../services/cloud_service.dart'; -/// Vollwertiger Einstellungen-Screen – kein Popup mehr +/// Vollwertiger Einstellungen-Screen – kein Popup mehr. +/// Sprint D F4: alle Einstellungen funktionierend + persistiert +/// (Auto-Sync-Intervall, Sleep-Timer-Standard, Wiedergabegeschwindigkeit, +/// Akzentfarbe pro User, Speicher-Info). class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -15,6 +24,97 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { + // ─── Einstellungen (aus SharedPreferences geladen) ─── + int _syncIntervall = 0; // 0 = Aus, sonst Stunden + int _sleepDefaultMin = 0; // 0 = Aus + double _geschwindigkeit = 1.0; + Color _akzent = MeloTheme.akzent; + Map _speicherInfo = const {'anzahl': 0, 'bytes': 0}; + + static const _akzentFarben = [ + Color(0xFFCC0000), // Rot (Standard) + Color(0xFF00B8A9), // Türkis + Color(0xFF1E6FD9), // Blau + Color(0xFF2E9E4F), // Grün + Color(0xFF8E44AD), // Lila + Color(0xFFE67E22), // Orange + Color(0xFFD81B60), // Pink + ]; + + @override + void initState() { + super.initState(); + _ladeEinstellungen(); + _ladeSpeicherInfo(); + } + + Future _ladeEinstellungen() async { + final p = await SharedPreferences.getInstance(); + final speed = p.getDouble('playback_speed') ?? 1.0; + final akzentHex = + p.getString(UserEffekt.akzentKey(AuthService().benutzer)); + if (!mounted) return; + setState(() { + _syncIntervall = p.getInt('cloud_interval') ?? 0; + _sleepDefaultMin = p.getInt('sleep_timer_default_min') ?? 0; + _geschwindigkeit = speed; + _akzent = UserEffekt.farbeAusHex(akzentHex) ?? MeloTheme.akzent; + }); + // Speed-Default auch auf den Player anwenden (gilt fürs Abspielen) + await PlayerService().setGeschwindigkeit(speed); + } + + Future _ladeSpeicherInfo() async { + final info = await DbHelper().speicherInfo(); + if (!mounted) return; + setState(() => _speicherInfo = info); + } + + // ─── Setter (persistieren + anwenden) ─── + + Future _intervallSetzen(int stunden) async { + setState(() => _syncIntervall = stunden); + final p = await SharedPreferences.getInstance(); + await p.setInt('cloud_interval', stunden); + await p.setBool('cloud_auto', stunden > 0); // Kompatibilität (altes Flag) + await SyncService.starteAutoSyncTimer(); // app-weiter Timer neu starten + } + + Future _sleepDefaultSetzen(int min) async { + setState(() => _sleepDefaultMin = min); + final p = await SharedPreferences.getInstance(); + await p.setInt('sleep_timer_default_min', min); + } + + Future _geschwindigkeitSetzen(double wert) async { + setState(() => _geschwindigkeit = wert); + final p = await SharedPreferences.getInstance(); + await p.setDouble('playback_speed', wert); + await PlayerService().setGeschwindigkeit(wert); + } + + Future _akzentSetzen(Color farbe) async { + setState(() => _akzent = farbe); + MeloTheme.akzent = farbe; // live — main.dart rebuildet das Theme + final p = await SharedPreferences.getInstance(); + await p.setString( + UserEffekt.akzentKey(AuthService().benutzer), _hexVonFarbe(farbe)); + } + + String _hexVonFarbe(Color c) { + String hx(int w) => w.toRadixString(16).padLeft(2, '0').toUpperCase(); + return '${hx((c.r * 255).round())}${hx((c.g * 255).round())}${hx((c.b * 255).round())}'; + } + + String get _speicherText { + final anzahl = _speicherInfo['anzahl'] ?? 0; + final bytes = _speicherInfo['bytes'] ?? 0; + if (bytes >= 1073741824) { + return '$anzahl Songs · ${(bytes / 1073741824).toStringAsFixed(2)} GB'; + } + return '$anzahl Songs · ${(bytes / 1048576).toStringAsFixed(1)} MB'; + } + @override Widget build(BuildContext context) { return Scaffold( @@ -65,8 +165,72 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 8), - // ─── Sektion: Daten ─── + // ─── Sektion: Wiedergabe & Sync ─── + _sektionHeader('Wiedergabe & Sync'), + _einstellungsGruppe([ + _gruppenZeile( + label: 'Auto-Sync-Intervall', + wert: _syncIntervall == 0 ? 'Aus' : '${_syncIntervall}h', + inhalt: Row(children: [ + _wahlChip('Aus', _syncIntervall == 0, () => _intervallSetzen(0)), + _wahlChip('1h', _syncIntervall == 1, () => _intervallSetzen(1)), + _wahlChip('3h', _syncIntervall == 3, () => _intervallSetzen(3)), + _wahlChip('6h', _syncIntervall == 6, () => _intervallSetzen(6)), + _wahlChip('12h', _syncIntervall == 12, () => _intervallSetzen(12)), + ]), + ), + const Divider(color: MeloTheme.dunkel2, height: 1), + _gruppenZeile( + label: 'Sleep-Timer-Standard', + wert: _sleepDefaultMin == 0 ? 'Aus' : '$_sleepDefaultMin Min', + inhalt: Row(children: [ + _wahlChip('Aus', _sleepDefaultMin == 0, () => _sleepDefaultSetzen(0)), + _wahlChip('15', _sleepDefaultMin == 15, () => _sleepDefaultSetzen(15)), + _wahlChip('30', _sleepDefaultMin == 30, () => _sleepDefaultSetzen(30)), + _wahlChip('45', _sleepDefaultMin == 45, () => _sleepDefaultSetzen(45)), + _wahlChip('60', _sleepDefaultMin == 60, () => _sleepDefaultSetzen(60)), + ]), + ), + const Divider(color: MeloTheme.dunkel2, height: 1), + _gruppenZeile( + label: 'Wiedergabegeschwindigkeit', + wert: '${_geschwindigkeit.toStringAsFixed(2)}x', + inhalt: Slider( + value: _geschwindigkeit, + min: 0.5, + max: 2.0, + divisions: 6, + activeColor: MeloTheme.akzent, + // Live-Vorschau beim Ziehen, persistieren erst am Ende + onChanged: (v) => setState(() => _geschwindigkeit = v), + onChangeEnd: _geschwindigkeitSetzen, + ), + ), + ]), + const SizedBox(height: 8), + + // ─── Sektion: Aussehen ─── + _sektionHeader('Aussehen'), + _einstellungsGruppe([ + _gruppenZeile( + label: 'Akzentfarbe', + wert: 'Für ${AuthService().benutzer}', + inhalt: Row(children: [ + for (final farbe in _akzentFarben) ...[ + _akzentPunkt(farbe, _akzent == farbe, () => _akzentSetzen(farbe)), + ], + ]), + ), + ]), + const SizedBox(height: 8), + + // ─── Sektion: Daten & Privatsphäre ─── _sektionHeader('Daten & Privatsphäre'), + _infoKachel( + icon: Icons.storage_outlined, + titel: 'Lokale Musik', + wert: _speicherText, + ), _einstellungsKachel( icon: Icons.auto_graph, titel: 'Jahres-Recap', @@ -140,7 +304,7 @@ class _SettingsScreenState extends State { children: [ _infoZeile('Version', '2.52.3'), const Divider(color: MeloTheme.dunkel2, height: 1), - _infoZeile('Theme', 'Schwarz + Rot'), + _infoZeile('Theme', 'Schwarz + Akzent'), const Divider(color: MeloTheme.dunkel2, height: 1), _infoZeile('Auth', AppConfig.authUrl), const Divider(color: MeloTheme.dunkel2, height: 1), @@ -245,6 +409,135 @@ class _SettingsScreenState extends State { ); } + /// Informations-Kachel (nicht antippbar) — z.B. Speicher-Info. + Widget _infoKachel({ + required IconData icon, + required String titel, + required String wert, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: MeloTheme.dunkel2), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: MeloTheme.dunkel2, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: MeloTheme.rot, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + titel, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + Text( + wert, + style: const TextStyle(color: MeloTheme.textSekundaer, fontSize: 12), + ), + ], + ), + ), + ); + } + + /// Gruppe aus mehreren Einstellungs-Zeilen in einer abgerundeten Box. + Widget _einstellungsGruppe(List kinder) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: MeloTheme.dunkel1, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: MeloTheme.dunkel2), + ), + child: Column(children: kinder), + ); + } + + Widget _gruppenZeile({ + required String label, + String? wert, + required Widget inhalt, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + if (wert != null) + Text( + wert, + style: TextStyle(color: MeloTheme.akzent, fontSize: 13, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 10), + inhalt, + ], + ), + ); + } + + Widget _wahlChip(String label, bool aktiv, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: aktiv ? MeloTheme.akzent : MeloTheme.dunkel2, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + color: aktiv ? Colors.white : MeloTheme.textSekundaer, + ), + ), + ), + ); + } + + Widget _akzentPunkt(Color farbe, bool aktiv, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(right: 10), + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: farbe, + border: Border.all( + color: aktiv ? Colors.white : Colors.transparent, + width: 2.5, + ), + ), + child: aktiv ? const Icon(Icons.check, size: 18, color: Colors.white) : null, + ), + ); + } + Widget _infoZeile(String label, String wert) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart index 6c979c0..3e58a56 100644 --- a/lib/services/player_service.dart +++ b/lib/services/player_service.dart @@ -68,6 +68,29 @@ class PlayerService extends ChangeNotifier { final StreamController _songWechsel = StreamController.broadcast(); Stream get onSongWechsel => _songWechsel.stream; + // ─── Wiedergabegeschwindigkeit (F4) ─── + + double _geschwindigkeit = 1.0; + + /// Aktuelle Wiedergabegeschwindigkeit (0.5–2.0, Default 1.0). + double get geschwindigkeit => _geschwindigkeit; + + /// Setzt die Wiedergabegeschwindigkeit (just_audio `setSpeed`) und merkt + /// sie sich, damit [spiele] sie auf jede neue Quelle anwendet (just_audio + /// setzt die Geschwindigkeit bei `setUrl`/`setFilePath` auf 1.0 zurück). + Future setGeschwindigkeit(double wert) async { + _geschwindigkeit = wert.clamp(0.5, 2.0).toDouble(); + if (_player != null) { + try { + await _p.setSpeed(_geschwindigkeit); + } catch (e) { + debugPrint('setSpeed fehlgeschlagen: $e'); + MeloLogger().fehler('player_speed', e); + } + } + notifyListeners(); + } + // ─── Zufallsmodus (Shuffle) ─── bool get zufallsmodus => _zufallsmodus; @@ -138,6 +161,15 @@ class PlayerService extends ChangeNotifier { } if (position > 0) await _p.seek(Duration(seconds: position)); await _p.play(); + // Wiedergabegeschwindigkeit aus der Einstellung anwenden (just_audio + // setzt sie bei jeder neuen Quelle zurück auf 1.0) + if (_geschwindigkeit != 1.0) { + try { + await _p.setSpeed(_geschwindigkeit); + } catch (e) { + debugPrint('setSpeed fehlgeschlagen: $e'); + } + } _songWechsel.add(song); notifyListeners(); // Abspiel-Historie für den Jahres-Recap registrieren diff --git a/lib/utils/user_effekte.dart b/lib/utils/user_effekte.dart index cd8e355..8a3d082 100644 --- a/lib/utils/user_effekte.dart +++ b/lib/utils/user_effekte.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'farb_theme.dart'; /// Login-Effekte: Pro Person eigener Akzent, Willkommens-Sound und Begrüßung. @@ -39,13 +40,35 @@ class UserEffekt { return standard; } + /// Key der per-User-Akzentfarbe in den SharedPreferences. + static String akzentKey(String? user) => + 'akzent_farbe_${(user ?? '').toLowerCase().trim()}'; + + /// '#RRGGBB' (oder 'RRGGBB') → Color; null bei ungültigem Format. + static Color? farbeAusHex(String? hex) { + if (hex == null) return null; + final h = hex.replaceFirst('#', '').trim(); + if (h.length != 6) return null; + final v = int.tryParse(h, radix: 16); + if (v == null) return null; + return Color(0xFF000000 | v); + } + /// Setzt die Akzentfarbe (Thema wechselt live) und spielt den Sound ab. + /// Die in den Einstellungen gespeicherte Akzentfarbe + /// (`akzent_farbe_`, F4) hat Vorrang vor der Standard-Farbe des Users. static Future anwenden(String? user) async { - final e = fuer(user); - MeloTheme.akzent = e.akzent; + var farbe = fuer(user).akzent; + try { + final p = await SharedPreferences.getInstance(); + farbe = farbeAusHex(p.getString(akzentKey(user))) ?? farbe; + } catch (_) { + // Prefs-Fehler ignorieren — Standard-Akzent bleibt + } + MeloTheme.akzent = farbe; try { final p = AudioPlayer(); - await p.setAsset(e.soundAsset); + await p.setAsset(fuer(user).soundAsset); unawaited(p.play()); // Player nach dem Abspielen sauber freigeben (kein Memory-Leak) Future.delayed(const Duration(seconds: 4), () {