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 0439ce3ed2 v2.50.4 — Glow-Pulsation rebuildet + Cover-Crossfade beim Songwechsel
## Fix 1 (MED): Glow-Pulsation rebuildet jetzt wirklich
- now_playing_screen.dart: Cover-Container mit AnimatedBuilder(animation: _glowController) gewrappt — BoxShadow (blur 50–70 / spread 6–12) wird jetzt pro Animations-Tick neu gebaut statt nur bei fremden setStates (Pulsation war vorher nur zufällig über positionStream sichtbar und fror bei Pause/Pufferung ein)

## Fix 2 (MED): Echter Crossfade beim Songwechsel
- _songSub-Listener setzt _hatCover/_coverPfad jetzt SYNCHRON auf false/null (im selben setState) — Hintergrund und Front-Cover zeigen beim Songwechsel kein altes Cover mehr, bis die async Existenz-Prüfung durch ist
- _hintergrundBlur-Key auf den tatsächlichen Cover-Pfad gelegt ('cover-$pfad' statt song.id) — Switcher wechselt erst bei geladenem neuem Cover (Crossfade Gradient→Cover), kein Fade alt→alt + harter Cut mehr

flutter analyze: 0 Issues, Tests 98/98 grün, keine neuen Packages
2026-08-04 20:55:24 +02:00

947 lines
32 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;
// 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<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 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, // 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')}';
}
}