import 'dart:async'; import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.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 der Warteschlange. /// Tipp = Song wird als Nächstes gespielt (spieleAlsNaechstes). void _zeigePlayNextMenue() { final queue = _player.warteschlange; final aktuell = _player.aktuellerIndex; final kommende = []; for (var i = aktuell + 1; i < queue.length; i++) { kommende.add(queue[i]); } 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( '${aktuell + i + 2}', 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. void _zeigeSleepTimer() { showModalBottomSheet( context: context, backgroundColor: MeloTheme.dunkel1, builder: (sheetCtx) => StatefulBuilder( builder: (ctx, setSheetState) { final aktiv = _player.sleepTimerAktiv; final rest = _player.sleepRestzeit; 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), ), ], ], ), const SizedBox(height: 14), Row( children: [ _zeitChip(ctx, 'Aus', null, aktiv: !aktiv, onTap: () { _player.cancelSleepTimer(); setSheetState(() {}); }), _zeitChip(ctx, '15', const Duration(minutes: 15), onTap: () { _player.setSleepTimer(const Duration(minutes: 15)); setSheetState(() {}); }), _zeitChip(ctx, '30', const Duration(minutes: 30), onTap: () { _player.setSleepTimer(const Duration(minutes: 30)); setSheetState(() {}); }), _zeitChip(ctx, '45', const Duration(minutes: 45), onTap: () { _player.setSleepTimer(const Duration(minutes: 45)); setSheetState(() {}); }), _zeitChip(ctx, '60', const Duration(minutes: 60), onTap: () { _player.setSleepTimer(const Duration(minutes: 60)); setSheetState(() {}); }), ], ), ], ), ), ); }, ), ); } Widget _zeitChip(BuildContext ctx, String label, Duration? dauer, {bool aktiv = 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), ), child: Text( 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(), const SizedBox(height: 18), ], ), ), ], ), ); } /// 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: 45, sigmaY: 45), child: Image.file(File(pfad), fit: BoxFit.cover), ); } Widget _kopfzeile() { final sleepAktiv = _player.sleepTimerAktiv; final rest = _player.sleepRestzeit; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), 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 Spacer(), // Lyrics-Toggle (🎤): wechselt Cover ↔ Lyrics IconButton( icon: Icon( _zeigeLyrics ? Icons.album : Icons.mic_none, color: _zeigeLyrics ? MeloTheme.rot : Colors.white, ), onPressed: () => setState(() => _zeigeLyrics = !_zeigeLyrics), tooltip: _zeigeLyrics ? 'Cover anzeigen' : 'Lyrics anzeigen', ), // Sleep-Timer if (sleepAktiv && rest != null) Padding( padding: const EdgeInsets.only(right: 4), child: Text( _formatRestzeit(rest), style: const TextStyle(fontSize: 12, color: MeloTheme.rot), ), ), IconButton( icon: Icon(Icons.bedtime, size: 22, color: sleepAktiv ? MeloTheme.rot : Colors.white), onPressed: _zeigeSleepTimer, tooltip: 'Schlaf-Timer', ), // Queue-Zugriff IconButton( icon: const Icon(Icons.queue_music, size: 24, color: Colors.white), onPressed: _zeigeWarteschlange, tooltip: 'Warteschlange', ), ], ), ); } /// Cover-Ansicht: großes Cover (Radius 20 + Glow), Titel, Künstler, Like. Widget _coverAnsicht(Song? song) { final hatCover = _hatCover && _coverPfad != null; final coverGroesse = (MediaQuery.of(context).size.width * 0.72).clamp(220.0, 340.0); return SingleChildScrollView( key: const ValueKey('cover'), padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const SizedBox(height: 16), // 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(20), boxShadow: [ BoxShadow( // 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(_coverPfad!), fit: BoxFit.cover) : Container( color: MeloTheme.rotHell, child: const Icon(Icons.music_note, size: 90, color: MeloTheme.rot), ), ), ), ), const SizedBox(height: 30), // Titel + Künstler + Like Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( song?.titel ?? 'Kein Song', maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white, ), ), const SizedBox(height: 4), Text( song?.kuenstler ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15, color: MeloTheme.textSekundaer, ), ), ], ), ), // Like-Button (Favoriten) IconButton( icon: Icon( _istFavorit ? Icons.favorite : Icons.favorite_border, size: 28, color: _istFavorit ? MeloTheme.rot : Colors.white, ), onPressed: _toggleFavorit, tooltip: _istFavorit ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen', ), ], ), 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( trackHeight: 3, activeTrackColor: MeloTheme.rot, inactiveTrackColor: MeloTheme.dunkel2, thumbColor: MeloTheme.rot, overlayColor: MeloTheme.rot.withValues(alpha: 0.2), ), 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)), ], ), ), ], ); } Widget _controls() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _shuffleBtn(), _repeatBtn(), _ctrlBtn(Icons.skip_previous, 34, _player.vorheriges), _playBtn(), // Next: Long-Press öffnet zusätzlich das Play-Next-Menü GestureDetector( onLongPress: _zeigePlayNextMenue, child: _ctrlBtn(Icons.skip_next, 34, _player.naechstes), ), // Play-Next-Menü _ctrlBtn(Icons.playlist_play, 30, _zeigePlayNextMenue, farbe: MeloTheme.textSekundaer), ], ), ); } 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: 64, height: 64, alignment: Alignment.center, child: Icon( _spielt ? Icons.pause : Icons.play_arrow, size: 36, color: Colors.white, ), ), ), ); } String _formatZeit(Duration d) { final min = d.inMinutes; final sek = d.inSeconds % 60; return '$min:${sek.toString().padLeft(2, '0')}'; } }