import 'dart:async'; 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'; import '../services/melo_logger.dart'; import '../services/player_service.dart'; import '../utils/farb_theme.dart'; import '../widgets/warteschlange_sheet.dart'; /// Fullscreen-Player: großes Cover (mit Glow + Blur-Hintergrund), Lyrics /// (Cover ↔ Lyrics per 🎤-Toggle), Fortschritt mit Seek, alle Controls, /// Like-Button (Favoriten), Sleep-Timer und Queue-Zugriff. class NowPlayingScreen extends StatefulWidget { const NowPlayingScreen({super.key}); @override State createState() => _NowPlayingScreenState(); } class _NowPlayingScreenState extends State with SingleTickerProviderStateMixin { final PlayerService _player = PlayerService(); final FavoritenService _favoriten = FavoritenService(); final LyricsService _lyricsService = LyricsService(); // ─── Wiedergabe-Zustand ─── Duration _position = Duration.zero; Duration _dauer = Duration.zero; bool _spielt = false; Duration? _dragPosition; // während der Nutzer den Slider zieht StreamSubscription? _posSub; StreamSubscription? _stateSub; StreamSubscription? _songSub; Timer? _sleepTick; // ─── Lyrics ─── bool _zeigeLyrics = false; List? _lyrics; bool _lyricsLaden = true; // erst laden, dann anzeigen (Spinner statt „keine Lyrics") int _ladeGeneration = 0; int _aktiveZeile = 0; int _letzterAutoIndex = -1; DateTime? _letzterNutzerScroll; final ScrollController _scrollController = ScrollController(); // ─── Favoriten (Like) ─── 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); _aktualisiereAutoScroll(pos); }); _stateSub = _player.stateStream.listen((state) { if (!mounted) return; setState(() { _spielt = state.playing; _dauer = state.processingState == ProcessingState.ready ? _player.dauer : Duration.zero; }); _syncGlowPulsation(); }); _songSub = _player.onSongWechsel.listen((song) { if (!mounted || song == null) return; _ladeGeneration++; setState(() { _aktiveZeile = 0; _letzterAutoIndex = -1; _zeigeLyrics = false; _lyrics = null; _lyricsLaden = true; // Cover-Zustand SYNCHRON zurücksetzen: Sonst rendern Hintergrund // (und Front-Cover) beim Songwechsel kurz das ALTE Cover, bis die // async Existenz-Prüfung durch ist — der Switcher-Key (song.id) // wechselt sofort → Fade alt→alt, dann harter Cut aufs neue Cover. _hatCover = false; _coverPfad = null; }); _ladeLyricsFuer(song, _ladeGeneration); _ladeFavoritStatus(song); _ladeCoverStatus(song); }); _player.addListener(_onPlayerChanged); // Initialen Zustand laden (falls schon ein Song läuft) final song = _player.aktuellerSong; if (song != null) { _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(); } // ─── Zustands-Sync ─── 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) { _sleepTick = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); } else if (!aktiv && _sleepTick != null) { _sleepTick?.cancel(); _sleepTick = null; } } // ─── Favoriten ─── Future _ladeFavoritStatus(Song song) async { final id = song.id; if (id == null) { if (mounted) setState(() => _istFavorit = false); return; } final ist = await _favoriten.istFavorit(id); if (mounted && identical(_player.aktuellerSong, song)) { setState(() => _istFavorit = ist); } } Future _toggleFavorit() async { if (_toggleLaeuft) return; // zweiter Tap während laufendem Toggle ignorieren final song = _player.aktuellerSong; final id = song?.id; if (song == null || id == null) return; _toggleLaeuft = true; bool neuerStatus; try { neuerStatus = await _favoriten.umschalten(id); } catch (e) { debugPrint('Favoriten-Toggle fehlgeschlagen: $e'); MeloLogger().fehler('favoriten_toggle_ui', e); neuerStatus = _istFavorit; // unverändert lassen } finally { _toggleLaeuft = false; } if (!mounted) return; // Zustand aus der DB (Quelle der Wahrheit) übernehmen statt blind zu flippen setState(() => _istFavorit = neuerStatus); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(_istFavorit ? '❤️ Zu Favoriten hinzugefügt' : 'Aus Favoriten entfernt'), duration: const Duration(milliseconds: 1200), behavior: SnackBarBehavior.floating, backgroundColor: MeloTheme.dunkel2, ), ); } // ─── Lyrics laden ─── Future _ladeLyricsFuer(Song song, int generation) async { final zeilen = await _lyricsService.ladeLyrics(song); if (!mounted || generation != _ladeGeneration) return; setState(() { _lyricsLaden = false; _lyrics = zeilen; _aktiveZeile = 0; _letzterAutoIndex = -1; }); if (_scrollController.hasClients) _scrollController.jumpTo(0); } // ─── Auto-Scroll (nur bei Wiedergabe, nicht während Nutzer-Scroll) ─── void _aktualisiereAutoScroll(Duration pos) { final zeilen = _lyrics; if (!_zeigeLyrics || !_spielt || zeilen == null || zeilen.isEmpty) return; final idx = LyricsService.indexFuerPosition(pos, zeilen); if (idx == _letzterAutoIndex) return; final letzterScroll = _letzterNutzerScroll; if (letzterScroll != null && DateTime.now().difference(letzterScroll) < const Duration(seconds: 2)) { return; } _letzterAutoIndex = idx; setState(() => _aktiveZeile = idx); if (_scrollController.hasClients) { final ziel = (idx * 46.0) .clamp(0.0, _scrollController.position.maxScrollExtent); _scrollController.animateTo( ziel, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } } // ─── Sheets / Dialoge ─── void _zeigeWarteschlange() { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: MeloTheme.dunkel1, builder: (_) => const WarteschlangeSheet(), ); } /// Menü „Als Nächstes": zeigt die kommenden Songs in der tatsächlichen /// Wiedergabe-Reihenfolge (inkl. Shuffle). Tipp = Song wird als Nächstes /// gespielt (spieleAlsNaechstes). void _zeigePlayNextMenue() { // Position 0 = aktuell spielender Song → nur die kommenden anzeigen final kommende = _player.wiedergabeReihenfolge.skip(1).toList(); showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: MeloTheme.dunkel1, builder: (sheetCtx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 14, 20, 8), child: Row( children: [ const Icon(Icons.playlist_play, size: 18, color: MeloTheme.rot), const SizedBox(width: 8), const Text( 'Als Nächstes', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white, ), ), const SizedBox(width: 8), Text( '${kommende.length} Titel', style: const TextStyle( fontSize: 12, color: MeloTheme.textSekundaer), ), const Spacer(), IconButton( icon: const Icon(Icons.close, size: 18, color: MeloTheme.textSekundaer), onPressed: () => Navigator.pop(sheetCtx), tooltip: 'Schließen', ), ], ), ), const Divider(height: 1, color: MeloTheme.dunkel2), if (kommende.isEmpty) const Padding( padding: EdgeInsets.all(24), child: Text( 'Keine weiteren Songs in der Warteschlange', style: TextStyle(fontSize: 13, color: MeloTheme.textSekundaer), ), ) else Flexible( child: ListView.builder( shrinkWrap: true, itemCount: kommende.length, itemBuilder: (_, i) { final song = kommende[i]; return ListTile( dense: true, leading: Text( '${i + 1}', // Position in der Wiedergabe-Reihenfolge style: const TextStyle( fontSize: 11, color: MeloTheme.textSekundaer), ), title: Text(song.titel, style: const TextStyle( fontSize: 13, color: Colors.white)), subtitle: Text(song.kuenstler, style: const TextStyle( fontSize: 11, color: MeloTheme.textSekundaer)), onTap: () { _player.spieleAlsNaechstes(song); Navigator.pop(sheetCtx); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Als Nächstes: ${song.titel}'), duration: const Duration(milliseconds: 1200), behavior: SnackBarBehavior.floating, backgroundColor: MeloTheme.dunkel2, ), ); }, ); }, ), ), ], ), ), ); } /// Schlaf-Timer: Chips Aus/15/30/45/60 + Restzeit, wenn aktiv. /// 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, builder: (sheetCtx) => StatefulBuilder( 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), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.bedtime, size: 18, color: aktiv ? MeloTheme.rot : Colors.white), const SizedBox(width: 8), const Text( 'Schlaf-Timer', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), ), if (aktiv && rest != null) ...[ const SizedBox(width: 10), Text( _formatRestzeit(rest), style: const TextStyle( fontSize: 14, color: MeloTheme.rot), ), ], ], ), 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: [ _zeitChip(ctx, 'Aus', null, aktiv: !aktiv, onTap: () { _player.cancelSleepTimer(); 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(() {}); }), ], ), ], ), ), ); }, ), ); } Widget _zeitChip(BuildContext ctx, String label, Duration? dauer, {bool aktiv = false, bool standard = false, 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.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( standard ? '$label ⭐' : label, style: TextStyle( fontSize: 12, color: aktiv ? Colors.white : MeloTheme.textSekundaer, ), ), ), ); } String _formatRestzeit(Duration rest) { final min = rest.inMinutes; final sek = rest.inSeconds % 60; return '⏱ ${min.toString().padLeft(2, '0')}:${sek.toString().padLeft(2, '0')}'; } // ─── Build ─── @override Widget build(BuildContext context) { final song = _player.aktuellerSong; return Scaffold( backgroundColor: MeloTheme.schwarz, body: Stack( fit: StackFit.expand, children: [ // 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( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.black54, Colors.black87], ), ), ), SafeArea( child: Column( children: [ _kopfzeile(), // Mittelteil: Cover ↔ Lyrics Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 250), transitionBuilder: (child, anim) => FadeTransition(opacity: anim, child: child), child: _zeigeLyrics ? _lyricsAnsicht() : _coverAnsicht(song), ), ), _fortschritt(), _controls(), _untereLeiste(), const SizedBox(height: 10), ], ), ), ], ), ); } /// 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 pfad = _coverPfad; if (!_hatCover || pfad == null) { return const DecoratedBox( key: ValueKey('kein-cover'), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF1A0A0A), MeloTheme.schwarz], ), ), ); } return ImageFiltered( // Key auf den tatsächlichen Cover-Pfad statt song.id: Der Switcher // wechselt erst, wenn das NEUE Cover wirklich geladen ist (echter // Crossfade) — nie vorher (altes Bild unter neuem Key = unsichtbarer // Fade + harter Cut beim Nachladen). key: ValueKey('cover-$pfad'), imageFilter: ImageFilter.blur(sigmaX: 22, sigmaY: 22), child: Image.file(File(pfad), fit: BoxFit.cover), ); } /// Kopfzeile im Spotify-Stil: „JETZT LÄUFT" links neben dem Schließen-Pfeil, /// rechts Like-Button (Favoriten) und Queue-Zugriff. Widget _kopfzeile() { return Padding( padding: const EdgeInsets.fromLTRB(4, 2, 8, 0), child: Row( children: [ IconButton( icon: const Icon(Icons.keyboard_arrow_down, size: 30, color: Colors.white), onPressed: () => Navigator.of(context).pop(), tooltip: 'Schließen', ), const Text( 'JETZT LÄUFT', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 2.2, color: MeloTheme.textSekundaer, ), ), const Spacer(), // Like-Button (Favoriten) IconButton( icon: Icon( _istFavorit ? Icons.favorite : Icons.favorite_border, size: 24, color: _istFavorit ? MeloTheme.rot : Colors.white, ), onPressed: _toggleFavorit, tooltip: _istFavorit ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', ), // Queue-Zugriff IconButton( icon: const Icon(Icons.queue_music, size: 24, color: Colors.white), onPressed: _zeigeWarteschlange, tooltip: 'Warteschlange', ), ], ), ); } /// Cover-Ansicht: großes Cover (Radius 24 + Glow + Karten-Schatten), /// darunter Titel und Künstler in klarer Typografie (Spotify-Stil). Widget _coverAnsicht(Song? song) { final hatCover = _hatCover && _coverPfad != null; final coverGroesse = (MediaQuery.of(context).size.width * 0.78).clamp(240.0, 360.0); return SingleChildScrollView( key: const ValueKey('cover'), padding: const EdgeInsets.symmetric(horizontal: 28), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(height: 12), // Cover mit Glow — AnimatedBuilder um den Container: Ohne Listener/ // Builder würde der BoxShadow nur bei fremden setStates neu gebaut // (Pulsation zufällig sichtbar, friert bei Pause/Pufferung ein). AnimatedBuilder( animation: _glowController, builder: (context, _) => Container( width: coverGroesse, height: coverGroesse, decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), boxShadow: [ // Glow in der Akzentfarbe des Users, pulsierend bei Wiedergabe BoxShadow( color: MeloTheme.akzent.withValues(alpha: 0.4), blurRadius: 45 + 18 * _glowController.value, // 45–63 spreadRadius: 5 + 5 * _glowController.value, // 5–10 ), // Karten-Schatten für Tiefe (Cover als „Karte") BoxShadow( color: Colors.black.withValues(alpha: 0.55), blurRadius: 28, offset: const Offset(0, 14), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(24), child: hatCover ? Image.file(File(_coverPfad!), fit: BoxFit.cover) : Container( color: MeloTheme.rotHell, child: const Icon(Icons.music_note, size: 100, color: MeloTheme.rot), ), ), ), ), const SizedBox(height: 30), // Titel + Künstler linksbündig (Like-Button ist jetzt im Header) Align( alignment: Alignment.centerLeft, child: Text( song?.titel ?? 'Kein Song', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 24, fontWeight: FontWeight.w800, color: Colors.white, ), ), ), const SizedBox(height: 6), Align( alignment: Alignment.centerLeft, child: Text( song?.kuenstler ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, color: MeloTheme.textSekundaer, ), ), ), const SizedBox(height: 8), ], ), ); } /// Lyrics-Ansicht: Auto-Scroll zum aktuellen Vers, Tipp = Seek. Widget _lyricsAnsicht() { final zeilen = _lyrics; Widget inhalt; if (_lyricsLaden) { inhalt = const Center( child: CircularProgressIndicator(color: MeloTheme.rot), ); } else if (zeilen == null || zeilen.isEmpty) { inhalt = const Center( child: Padding( padding: EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.lyrics_outlined, size: 40, color: MeloTheme.textSekundaer), SizedBox(height: 12), Text( 'Keine Lyrics verfügbar', style: TextStyle(fontSize: 15, color: Colors.white), ), SizedBox(height: 4), Text( 'Lege eine .lrc-Datei neben den Song oder versuche es später erneut.', textAlign: TextAlign.center, style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer), ), ], ), ), ); } else { inhalt = NotificationListener( onNotification: (n) { if (n is ScrollUpdateNotification && n.dragDetails != null) { _letzterNutzerScroll = DateTime.now(); } return false; }, child: ListView.builder( key: const ValueKey('lyrics'), controller: _scrollController, padding: const EdgeInsets.symmetric(vertical: 24), itemCount: zeilen.length, itemBuilder: (_, i) { final aktiv = i == _aktiveZeile; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _player.seek(zeilen[i].zeit), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 9), child: Text( zeilen[i].text, textAlign: TextAlign.center, style: TextStyle( fontSize: 16, height: 1.4, fontWeight: aktiv ? FontWeight.w700 : FontWeight.w400, color: aktiv ? MeloTheme.rot : Colors.white70, ), ), ), ); }, ), ); } return KeyedSubtree( key: const ValueKey('lyrics-view'), child: inhalt, ); } Widget _fortschritt() { final dauerMs = (_dauer.inMilliseconds > 0 ? _dauer.inMilliseconds : 1).toDouble(); final anzeigePosition = _dragPosition ?? _position; final posMs = anzeigePosition.inMilliseconds .toDouble() .clamp(0.0, dauerMs); return Column( children: [ SliderTheme( data: SliderTheme.of(context).copyWith( // Dickerer Slider (Spotify-Stil), weiße Blende trackHeight: 6, activeTrackColor: MeloTheme.rot, inactiveTrackColor: MeloTheme.dunkel2, thumbColor: Colors.white, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), overlayColor: MeloTheme.rot.withValues(alpha: 0.2), overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), ), child: Slider( value: posMs, max: dauerMs, onChangeStart: (v) => setState(() => _dragPosition = Duration(milliseconds: v.round())), onChanged: (v) => setState(() => _dragPosition = Duration(milliseconds: v.round())), onChangeEnd: (v) { _player.seek(Duration(milliseconds: v.round())); setState(() => _dragPosition = null); }, ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 28), child: Row( children: [ Text(_formatZeit(anzeigePosition), style: const TextStyle( fontSize: 12, color: MeloTheme.textSekundaer)), const Spacer(), Text(_formatZeit(_dauer), style: const TextStyle( fontSize: 12, color: MeloTheme.textSekundaer)), ], ), ), ], ); } /// Controls im Spotify-Stil: Shuffle, Prev, Play/Pause (groß), Next, Repeat. Widget _controls() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _shuffleBtn(), _ctrlBtn(Icons.skip_previous, 36, _player.vorheriges), _playBtn(), // Next: Long-Press öffnet zusätzlich das Play-Next-Menü GestureDetector( onLongPress: _zeigePlayNextMenue, child: _ctrlBtn(Icons.skip_next, 36, _player.naechstes), ), _repeatBtn(), ], ), ); } Widget _ctrlBtn(IconData icon, double groesse, VoidCallback onTap, {Color farbe = Colors.white}) { return Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(50), onTap: onTap, child: Container( width: 46, height: 46, alignment: Alignment.center, child: Icon(icon, size: groesse, color: farbe), ), ), ); } Widget _shuffleBtn() { final aktiv = _player.zufallsmodus; return _ctrlBtn(Icons.shuffle, 26, () => _player.setZufallsmodus(!aktiv), farbe: aktiv ? MeloTheme.rot : Colors.white); } Widget _repeatBtn() { final modus = _player.wiederholmodus; final (icon, farbe) = switch (modus) { Wiederholmodus.aus => (Icons.repeat, Colors.white), Wiederholmodus.titel => (Icons.repeat_one, MeloTheme.rot), Wiederholmodus.playlist => (Icons.repeat, MeloTheme.rot), }; return _ctrlBtn(icon, 26, _naechsterWiederholmodus, farbe: farbe); } void _naechsterWiederholmodus() { final modus = _player.wiederholmodus; _player.setWiederholmodus(switch (modus) { Wiederholmodus.aus => Wiederholmodus.titel, Wiederholmodus.titel => Wiederholmodus.playlist, Wiederholmodus.playlist => Wiederholmodus.aus, }); } Widget _playBtn() { return Material( color: MeloTheme.rot, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: _player.playPause, child: Container( width: 74, height: 74, alignment: Alignment.center, child: Icon( _spielt ? Icons.pause : Icons.play_arrow, size: 42, color: Colors.white, ), ), ), ); } /// Untere Leiste (Spotify-Stil): Schlaf-Timer links, Lyrics-Toggle und /// Geschwindigkeits-Anzeige rechts. Widget _untereLeiste() { final sleepAktiv = _player.sleepTimerAktiv; final rest = _player.sleepRestzeit; final geschwindigkeit = _player.geschwindigkeit; final speedAktiv = geschwindigkeit != 1.0; return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2), child: Row( children: [ // Schlaf-Timer (Tipp = Auswahl-Sheet) GestureDetector( onTap: _zeigeSleepTimer, behavior: HitTestBehavior.opaque, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.bedtime, size: 20, color: sleepAktiv ? MeloTheme.rot : MeloTheme.textSekundaer, ), if (sleepAktiv && rest != null) ...[ const SizedBox(width: 6), Text( _formatRestzeit(rest).replaceAll('⏱ ', ''), style: const TextStyle(fontSize: 12, color: MeloTheme.rot), ), ], ], ), ), const Spacer(), // Lyrics-Toggle (🎤): wechselt Cover ↔ Lyrics IconButton( icon: Icon( _zeigeLyrics ? Icons.album : Icons.mic_none, size: 22, color: _zeigeLyrics ? MeloTheme.rot : Colors.white70, ), onPressed: () => setState(() => _zeigeLyrics = !_zeigeLyrics), tooltip: _zeigeLyrics ? 'Cover anzeigen' : 'Lyrics anzeigen', ), const SizedBox(width: 4), // Geschwindigkeit (Tipp = nächste Stufe, wird persistiert) GestureDetector( onTap: _naechsteGeschwindigkeit, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: speedAktiv ? MeloTheme.rot : MeloTheme.dunkel2, borderRadius: BorderRadius.circular(12), ), child: Text( '${geschwindigkeit.toStringAsFixed(2)}x', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: speedAktiv ? Colors.white : MeloTheme.textSekundaer, ), ), ), ), ], ), ); } /// Gängige Geschwindigkeitsstufen — Tipp im Fullscreen wechselt durch. static const List _geschwindigkeiten = [ 0.5, 0.75, 1.0, 1.25, 1.5, 2.0, ]; Future _naechsteGeschwindigkeit() async { final aktuell = _player.geschwindigkeit; final idx = _geschwindigkeiten.indexOf(aktuell); final neu = idx >= 0 ? _geschwindigkeiten[(idx + 1) % _geschwindigkeiten.length] : 1.0; // Wie in den Einstellungen persistieren, damit die Wahl erhalten bleibt try { final p = await SharedPreferences.getInstance(); await p.setDouble('playback_speed', neu); } catch (e) { debugPrint('Geschwindigkeit persistieren fehlgeschlagen: $e'); } await _player.setGeschwindigkeit(neu); } String _formatZeit(Duration d) { final min = d.inMinutes; final sek = d.inSeconds % 60; return '$min:${sek.toString().padLeft(2, '0')}'; } }