This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/screens/now_playing_screen.dart
T
Dustin e584a7d672 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
2026-08-04 20:44:40 +02:00

932 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends State<NowPlayingScreen>
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<Duration>? _posSub;
StreamSubscription<PlayerState>? _stateSub;
StreamSubscription<Song?>? _songSub;
Timer? _sleepTick;
// ─── Lyrics ───
bool _zeigeLyrics = false;
List<LyricsZeile>? _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;
});
_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<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() {
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<void> _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<void> _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<void> _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 = <Song>[];
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: ValueKey('cover-${song?.id ?? 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
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, // 5070
spreadRadius: 6 + 6 * _glowController.value, // 612
),
],
),
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<ScrollNotification>(
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')}';
}
}