From 19b115b0001231944dff07c72e7633ef17880fe8 Mon Sep 17 00:00:00 2001 From: Dustin Date: Tue, 4 Aug 2026 16:37:13 +0200 Subject: [PATCH] =?UTF-8?q?v2.49.1=20=E2=80=94=20Now-Playing-Fullscreen=20?= =?UTF-8?q?(Cover,=20Controls,=20Like,=20Queue,=20Sleep-Timer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## NowPlayingScreen (neu: lib/screens/now_playing_screen.dart) - Blur-Hintergrund aus dem Cover (ImageFiltered + ImageFilter.blur, Bordmittel), Abdunklungs-Verlauf - Großes Cover: Radius 20, Glow-Effekt (BoxShadow rot), Fallback auf Noten-Icon - Titel (groß) + Künstler, Like-Button (Herz): togglet Favoriten via FavoritenService (lokal) mit SnackBar - Fortschritt: Slider mit Seek (Drag-State), Position/Dauer-Text - Controls: Shuffle, Repeat (3 Zustände), Prev, Play/Pause (roter Kreis), Next (Long-Press = Play-Next-Menü), Play-Next-Button - Play-Next-Menü: kommende Queue-Songs, Tipp = spieleAlsNaechstes + SnackBar - Sleep-Timer: Button + Restzeit ⏱ in der Kopfzeile, Sheet mit Chips Aus/15/30/45/60 - Queue-Zugriff: Button in der Kopfzeile → WarteschlangeSheet - Slide-up/Slide-down via PageRouteBuilder (MiniPlayer) ## MiniPlayer - Tipp öffnet jetzt den Fullscreen-Player (statt direkt der Queue) - Queue nur noch via Button im Fullscreen - Keine neuen Packages, flutter analyze 0 Issues, Tests 35/35 --- lib/screens/now_playing_screen.dart | 839 ++++++++++++++++++++++++++++ lib/widgets/mini_player.dart | 33 +- 2 files changed, 863 insertions(+), 9 deletions(-) create mode 100644 lib/screens/now_playing_screen.dart diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart new file mode 100644 index 0000000..6d833d6 --- /dev/null +++ b/lib/screens/now_playing_screen.dart @@ -0,0 +1,839 @@ +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/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 { + 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; + + @override + void initState() { + super.initState(); + _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; + }); + }); + _songSub = _player.onSongWechsel.listen((song) { + if (!mounted || song == null) return; + _ladeGeneration++; + setState(() { + _aktiveZeile = 0; + _letzterAutoIndex = -1; + _zeigeLyrics = false; + _lyrics = null; + _lyricsLaden = true; + }); + _ladeLyricsFuer(song, _ladeGeneration); + _ladeFavoritStatus(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); + } + _syncSleepTick(); + } + + @override + void dispose() { + _player.removeListener(_onPlayerChanged); + _posSub?.cancel(); + _stateSub?.cancel(); + _songSub?.cancel(); + _sleepTick?.cancel(); + _scrollController.dispose(); + super.dispose(); + } + + // ─── Zustands-Sync ─── + + void _onPlayerChanged() { + if (!mounted) return; + setState(() {}); + _syncSleepTick(); + } + + 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 { + final song = _player.aktuellerSong; + final id = song?.id; + if (song == null || id == null) return; + await _favoriten.umschalten(id); + if (!mounted) return; + setState(() => _istFavorit = !_istFavorit); + 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: [ + _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). + Widget _hintergrundBlur(Song? song) { + final hatCover = song != null && + song.coverPfad != null && + File(song.coverPfad!).existsSync(); + if (!hatCover) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF1A0A0A), MeloTheme.schwarz], + ), + ), + ); + } + return ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 45, sigmaY: 45), + child: Image.file(File(song.coverPfad!), 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 = song != null && + song.coverPfad != null && + File(song.coverPfad!).existsSync(); + 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 + Container( + width: coverGroesse, + height: coverGroesse, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: MeloTheme.rot.withValues(alpha: 0.45), + blurRadius: 60, + spreadRadius: 6, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: hatCover + ? Image.file(File(song.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')}'; + } +} diff --git a/lib/widgets/mini_player.dart b/lib/widgets/mini_player.dart index f681cdb..eceb799 100644 --- a/lib/widgets/mini_player.dart +++ b/lib/widgets/mini_player.dart @@ -5,7 +5,7 @@ import 'package:just_audio/just_audio.dart'; import '../services/player_service.dart'; import '../models/song.dart'; import '../utils/farb_theme.dart'; -import 'warteschlange_sheet.dart'; +import '../screens/now_playing_screen.dart'; class MiniPlayer extends StatefulWidget { const MiniPlayer({super.key}); @@ -52,13 +52,28 @@ class _MiniPlayerState extends State { if (mounted) setState(() {}); } - /// Öffnet die Warteschlange als Bottom-Sheet - void _zeigeWarteschlange() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: MeloTheme.dunkel1, - builder: (_) => const WarteschlangeSheet(), + /// Öffnet den Fullscreen-Player mit Slide-up/Slide-down-Animation. + /// Die Warteschlange ist von dort per Button erreichbar. + void _oeffneNowPlaying() { + Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: const Duration(milliseconds: 350), + reverseTransitionDuration: const Duration(milliseconds: 280), + pageBuilder: (_, __, ___) => const NowPlayingScreen(), + transitionsBuilder: (_, animation, __, child) { + final offset = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ), + ); + return SlideTransition(position: offset, child: child); + }, + ), ); } @@ -81,7 +96,7 @@ class _MiniPlayerState extends State { final hatCover = song.coverPfad != null && File(song.coverPfad!).existsSync(); return GestureDetector( - onTap: _zeigeWarteschlange, + onTap: _oeffneNowPlaying, child: Container( margin: const EdgeInsets.symmetric(horizontal: 16), decoration: BoxDecoration(