v2.50.1 — Player-Glow an Akzent + Pulsation, Cover-Crossfade, existsSync-Fix

## Fullscreen-Player (now_playing_screen.dart)
- Glow-Farbe folgt MeloTheme.akzentNotifier (Per-User-Akzent) statt fixem Rot; Listener rebuildet bei Akzentwechsel
- Dynamische Glow-Pulsation: AnimationController (Bordmittel) pulsiert blurRadius 50–70 / spreadRadius 6–12 nur bei Wiedergabe, stillstehend bei Pause
- Blur-Hintergrund crossfaded beim Songwechsel (AnimatedSwitcher 600ms, layoutBuilder mit StackFit.expand, ValueKey je Song)
- existsSync aus dem Build-Pfad entfernt: async _ladeCoverStatus mit try/catch, Guard gegen stale Song-Wechsel
This commit is contained in:
Dustin
2026-08-04 20:44:40 +02:00
parent 69b8f29f88
commit e584a7d672
+92 -14
View File
@@ -21,7 +21,8 @@ class NowPlayingScreen extends StatefulWidget {
State<NowPlayingScreen> createState() => _NowPlayingScreenState(); State<NowPlayingScreen> createState() => _NowPlayingScreenState();
} }
class _NowPlayingScreenState extends State<NowPlayingScreen> { class _NowPlayingScreenState extends State<NowPlayingScreen>
with SingleTickerProviderStateMixin {
final PlayerService _player = PlayerService(); final PlayerService _player = PlayerService();
final FavoritenService _favoriten = FavoritenService(); final FavoritenService _favoriten = FavoritenService();
final LyricsService _lyricsService = LyricsService(); final LyricsService _lyricsService = LyricsService();
@@ -51,9 +52,21 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
bool _istFavorit = false; bool _istFavorit = false;
bool _toggleLaeuft = false; // In-Flight-Guard gegen Doppel-Tap-Race 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 @override
void initState() { void initState() {
super.initState(); super.initState();
_glowController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
);
MeloTheme.akzentNotifier.addListener(_onAkzentGeaendert);
_posSub = _player.positionStream.listen((pos) { _posSub = _player.positionStream.listen((pos) {
if (!mounted) return; if (!mounted) return;
setState(() => _position = pos); setState(() => _position = pos);
@@ -67,6 +80,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
? _player.dauer ? _player.dauer
: Duration.zero; : Duration.zero;
}); });
_syncGlowPulsation();
}); });
_songSub = _player.onSongWechsel.listen((song) { _songSub = _player.onSongWechsel.listen((song) {
if (!mounted || song == null) return; if (!mounted || song == null) return;
@@ -80,6 +94,7 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
}); });
_ladeLyricsFuer(song, _ladeGeneration); _ladeLyricsFuer(song, _ladeGeneration);
_ladeFavoritStatus(song); _ladeFavoritStatus(song);
_ladeCoverStatus(song);
}); });
_player.addListener(_onPlayerChanged); _player.addListener(_onPlayerChanged);
// Initialen Zustand laden (falls schon ein Song läuft) // Initialen Zustand laden (falls schon ein Song läuft)
@@ -88,18 +103,22 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
_ladeGeneration++; _ladeGeneration++;
_ladeLyricsFuer(song, _ladeGeneration); _ladeLyricsFuer(song, _ladeGeneration);
_ladeFavoritStatus(song); _ladeFavoritStatus(song);
_ladeCoverStatus(song);
} }
_syncGlowPulsation();
_syncSleepTick(); _syncSleepTick();
} }
@override @override
void dispose() { void dispose() {
_player.removeListener(_onPlayerChanged); _player.removeListener(_onPlayerChanged);
MeloTheme.akzentNotifier.removeListener(_onAkzentGeaendert);
_posSub?.cancel(); _posSub?.cancel();
_stateSub?.cancel(); _stateSub?.cancel();
_songSub?.cancel(); _songSub?.cancel();
_sleepTick?.cancel(); _sleepTick?.cancel();
_scrollController.dispose(); _scrollController.dispose();
_glowController.dispose();
super.dispose(); super.dispose();
} }
@@ -108,9 +127,53 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
void _onPlayerChanged() { void _onPlayerChanged() {
if (!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
_syncGlowPulsation();
_syncSleepTick(); _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<void> _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() { void _syncSleepTick() {
final aktiv = _player.sleepTimerAktiv; final aktiv = _player.sleepTimerAktiv;
if (aktiv && _sleepTick == null) { if (aktiv && _sleepTick == null) {
@@ -435,7 +498,21 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
body: Stack( body: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ 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 // Abdunklung für Lesbarkeit
const DecoratedBox( const DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -473,12 +550,13 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
} }
/// Blur-Hintergrund aus dem Cover (Bordmittel: ImageFiltered + dart:ui). /// 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) { Widget _hintergrundBlur(Song? song) {
final hatCover = song != null && final pfad = _coverPfad;
song.coverPfad != null && if (!_hatCover || pfad == null) {
File(song.coverPfad!).existsSync();
if (!hatCover) {
return const DecoratedBox( return const DecoratedBox(
key: ValueKey('kein-cover'),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
@@ -489,8 +567,9 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
); );
} }
return ImageFiltered( return ImageFiltered(
key: ValueKey('cover-${song?.id ?? pfad}'),
imageFilter: ImageFilter.blur(sigmaX: 45, sigmaY: 45), 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<NowPlayingScreen> {
/// Cover-Ansicht: großes Cover (Radius 20 + Glow), Titel, Künstler, Like. /// Cover-Ansicht: großes Cover (Radius 20 + Glow), Titel, Künstler, Like.
Widget _coverAnsicht(Song? song) { Widget _coverAnsicht(Song? song) {
final hatCover = song != null && final hatCover = _hatCover && _coverPfad != null;
song.coverPfad != null &&
File(song.coverPfad!).existsSync();
final coverGroesse = final coverGroesse =
(MediaQuery.of(context).size.width * 0.72).clamp(220.0, 340.0); (MediaQuery.of(context).size.width * 0.72).clamp(220.0, 340.0);
@@ -567,16 +644,17 @@ class _NowPlayingScreenState extends State<NowPlayingScreen> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: MeloTheme.rot.withValues(alpha: 0.45), // Glow in der Akzentfarbe des Users, pulsierend bei Wiedergabe
blurRadius: 60, color: MeloTheme.akzent.withValues(alpha: 0.45),
spreadRadius: 6, blurRadius: 50 + 20 * _glowController.value, // 5070
spreadRadius: 6 + 6 * _glowController.value, // 612
), ),
], ],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: hatCover child: hatCover
? Image.file(File(song.coverPfad!), fit: BoxFit.cover) ? Image.file(File(_coverPfad!), fit: BoxFit.cover)
: Container( : Container(
color: MeloTheme.rotHell, color: MeloTheme.rotHell,
child: const Icon(Icons.music_note, child: const Icon(Icons.music_note,