diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 53365dc..f8ff20c 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -21,7 +21,8 @@ class NowPlayingScreen extends StatefulWidget { State createState() => _NowPlayingScreenState(); } -class _NowPlayingScreenState extends State { +class _NowPlayingScreenState extends State + with SingleTickerProviderStateMixin { final PlayerService _player = PlayerService(); final FavoritenService _favoriten = FavoritenService(); final LyricsService _lyricsService = LyricsService(); @@ -51,9 +52,21 @@ class _NowPlayingScreenState extends State { bool _istFavorit = false; bool _toggleLaeuft = false; // In-Flight-Guard gegen Doppel-Tap-Race + // ─── Glow-Pulsation (synchron zum Play-Zustand) ─── + late final AnimationController _glowController; + + // ─── Cover-Prüfung (async, kein existsSync im Build-Pfad) ─── + bool _hatCover = false; + String? _coverPfad; + @override void initState() { super.initState(); + _glowController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + ); + MeloTheme.akzentNotifier.addListener(_onAkzentGeaendert); _posSub = _player.positionStream.listen((pos) { if (!mounted) return; setState(() => _position = pos); @@ -67,6 +80,7 @@ class _NowPlayingScreenState extends State { ? _player.dauer : Duration.zero; }); + _syncGlowPulsation(); }); _songSub = _player.onSongWechsel.listen((song) { if (!mounted || song == null) return; @@ -80,6 +94,7 @@ class _NowPlayingScreenState extends State { }); _ladeLyricsFuer(song, _ladeGeneration); _ladeFavoritStatus(song); + _ladeCoverStatus(song); }); _player.addListener(_onPlayerChanged); // Initialen Zustand laden (falls schon ein Song läuft) @@ -88,18 +103,22 @@ class _NowPlayingScreenState extends State { _ladeGeneration++; _ladeLyricsFuer(song, _ladeGeneration); _ladeFavoritStatus(song); + _ladeCoverStatus(song); } + _syncGlowPulsation(); _syncSleepTick(); } @override void dispose() { _player.removeListener(_onPlayerChanged); + MeloTheme.akzentNotifier.removeListener(_onAkzentGeaendert); _posSub?.cancel(); _stateSub?.cancel(); _songSub?.cancel(); _sleepTick?.cancel(); _scrollController.dispose(); + _glowController.dispose(); super.dispose(); } @@ -108,9 +127,53 @@ class _NowPlayingScreenState extends State { void _onPlayerChanged() { if (!mounted) return; setState(() {}); + _syncGlowPulsation(); _syncSleepTick(); } + void _onAkzentGeaendert() { + if (mounted) setState(() {}); + } + + /// Glow-Pulsation: nur bei Wiedergabe animieren, bei Pause stillstehen. + void _syncGlowPulsation() { + if (_spielt) { + if (!_glowController.isAnimating) { + _glowController.repeat(reverse: true); + } + } else if (_glowController.isAnimating) { + _glowController.stop(); + _glowController.value = 0; // Ruheposition (kleinster Glow) + } + } + + /// Prüft asynchron, ob die Cover-Datei existiert (statt existsSync im + /// Build-Pfad — vermeidet UI-Jank). try/catch gegen Dateisystem-Fehler. + Future _ladeCoverStatus(Song song) async { + final pfad = song.coverPfad; + if (pfad == null) { + if (mounted) { + setState(() { + _hatCover = false; + _coverPfad = null; + }); + } + return; + } + bool existiert; + try { + existiert = await File(pfad).exists(); + } catch (e) { + MeloLogger().fehler('cover_pfad_check', e); + existiert = false; + } + if (!mounted || _player.aktuellerSong?.coverPfad != pfad) return; + setState(() { + _hatCover = existiert; + _coverPfad = pfad; + }); + } + void _syncSleepTick() { final aktiv = _player.sleepTimerAktiv; if (aktiv && _sleepTick == null) { @@ -435,7 +498,21 @@ class _NowPlayingScreenState extends State { body: Stack( fit: StackFit.expand, children: [ - _hintergrundBlur(song), + // Blur-Hintergrund mit Crossfade beim Songwechsel (Muster wie + // Cover↔Lyrics-Toggle, nur länger für weichen Übergang). + AnimatedSwitcher( + duration: const Duration(milliseconds: 600), + layoutBuilder: (currentChild, previousChildren) => Stack( + fit: StackFit.expand, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + transitionBuilder: (child, anim) => + FadeTransition(opacity: anim, child: child), + child: _hintergrundBlur(song), + ), // Abdunklung für Lesbarkeit const DecoratedBox( decoration: BoxDecoration( @@ -473,12 +550,13 @@ class _NowPlayingScreenState extends State { } /// Blur-Hintergrund aus dem Cover (Bordmittel: ImageFiltered + dart:ui). + /// Existenz-Check kommt aus dem async geladenen [_hatCover]/[_coverPfad] — + /// kein synchrones existsSync im Build-Pfad. Widget _hintergrundBlur(Song? song) { - final hatCover = song != null && - song.coverPfad != null && - File(song.coverPfad!).existsSync(); - if (!hatCover) { + final pfad = _coverPfad; + if (!_hatCover || pfad == null) { return const DecoratedBox( + key: ValueKey('kein-cover'), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -489,8 +567,9 @@ class _NowPlayingScreenState extends State { ); } return ImageFiltered( + key: ValueKey('cover-${song?.id ?? pfad}'), imageFilter: ImageFilter.blur(sigmaX: 45, sigmaY: 45), - child: Image.file(File(song.coverPfad!), fit: BoxFit.cover), + child: Image.file(File(pfad), fit: BoxFit.cover), ); } @@ -546,9 +625,7 @@ class _NowPlayingScreenState extends State { /// Cover-Ansicht: großes Cover (Radius 20 + Glow), Titel, Künstler, Like. Widget _coverAnsicht(Song? song) { - final hatCover = song != null && - song.coverPfad != null && - File(song.coverPfad!).existsSync(); + final hatCover = _hatCover && _coverPfad != null; final coverGroesse = (MediaQuery.of(context).size.width * 0.72).clamp(220.0, 340.0); @@ -567,16 +644,17 @@ class _NowPlayingScreenState extends State { borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: MeloTheme.rot.withValues(alpha: 0.45), - blurRadius: 60, - spreadRadius: 6, + // Glow in der Akzentfarbe des Users, pulsierend bei Wiedergabe + color: MeloTheme.akzent.withValues(alpha: 0.45), + blurRadius: 50 + 20 * _glowController.value, // 50–70 + spreadRadius: 6 + 6 * _glowController.value, // 6–12 ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(20), child: hatCover - ? Image.file(File(song.coverPfad!), fit: BoxFit.cover) + ? Image.file(File(_coverPfad!), fit: BoxFit.cover) : Container( color: MeloTheme.rotHell, child: const Icon(Icons.music_note,