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

1044 lines
36 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 'package:shared_preferences/shared_preferences.dart';
import '../models/song.dart';
import '../services/favoriten_service.dart';
import '../services/lyrics_service.dart';
import '../services/melo_logger.dart';
import '../services/player_service.dart';
import '../utils/farb_theme.dart';
import '../widgets/warteschlange_sheet.dart';
/// Fullscreen-Player: großes Cover (mit Glow + Blur-Hintergrund), Lyrics
/// (Cover ↔ Lyrics per 🎤-Toggle), Fortschritt mit Seek, alle Controls,
/// Like-Button (Favoriten), Sleep-Timer und Queue-Zugriff.
class NowPlayingScreen extends StatefulWidget {
const NowPlayingScreen({super.key});
@override
State<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 in der tatsächlichen
/// Wiedergabe-Reihenfolge (inkl. Shuffle). Tipp = Song wird als Nächstes
/// gespielt (spieleAlsNaechstes).
void _zeigePlayNextMenue() {
// Position 0 = aktuell spielender Song → nur die kommenden anzeigen
final kommende = _player.wiedergabeReihenfolge.skip(1).toList();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: MeloTheme.dunkel1,
builder: (sheetCtx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 8),
child: Row(
children: [
const Icon(Icons.playlist_play,
size: 18, color: MeloTheme.rot),
const SizedBox(width: 8),
const Text(
'Als Nächstes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(width: 8),
Text(
'${kommende.length} Titel',
style: const TextStyle(
fontSize: 12, color: MeloTheme.textSekundaer),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close,
size: 18, color: MeloTheme.textSekundaer),
onPressed: () => Navigator.pop(sheetCtx),
tooltip: 'Schließen',
),
],
),
),
const Divider(height: 1, color: MeloTheme.dunkel2),
if (kommende.isEmpty)
const Padding(
padding: EdgeInsets.all(24),
child: Text(
'Keine weiteren Songs in der Warteschlange',
style:
TextStyle(fontSize: 13, color: MeloTheme.textSekundaer),
),
)
else
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: kommende.length,
itemBuilder: (_, i) {
final song = kommende[i];
return ListTile(
dense: true,
leading: Text(
'${i + 1}', // Position in der Wiedergabe-Reihenfolge
style: const TextStyle(
fontSize: 11, color: MeloTheme.textSekundaer),
),
title: Text(song.titel,
style: const TextStyle(
fontSize: 13, color: Colors.white)),
subtitle: Text(song.kuenstler,
style: const TextStyle(
fontSize: 11, color: MeloTheme.textSekundaer)),
onTap: () {
_player.spieleAlsNaechstes(song);
Navigator.pop(sheetCtx);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Als Nächstes: ${song.titel}'),
duration: const Duration(milliseconds: 1200),
behavior: SnackBarBehavior.floating,
backgroundColor: MeloTheme.dunkel2,
),
);
},
);
},
),
),
],
),
),
);
}
/// Schlaf-Timer: Chips Aus/15/30/45/60 + Restzeit, wenn aktiv.
/// Der in den Einstellungen gewählte Standard (`sleep_timer_default_min`)
/// ist vorausgewählt markiert, solange kein Timer läuft.
Future<void> _zeigeSleepTimer() async {
final p = await SharedPreferences.getInstance();
final defaultMin = p.getInt('sleep_timer_default_min') ?? 0;
if (!mounted) return;
showModalBottomSheet(
context: context,
backgroundColor: MeloTheme.dunkel1,
builder: (sheetCtx) => StatefulBuilder(
builder: (ctx, setSheetState) {
final aktiv = _player.sleepTimerAktiv;
final rest = _player.sleepRestzeit;
final zeigeStandard = !aktiv && defaultMin > 0;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bedtime,
size: 18,
color: aktiv ? MeloTheme.rot : Colors.white),
const SizedBox(width: 8),
const Text(
'Schlaf-Timer',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white),
),
if (aktiv && rest != null) ...[
const SizedBox(width: 10),
Text(
_formatRestzeit(rest),
style: const TextStyle(
fontSize: 14, color: MeloTheme.rot),
),
],
],
),
if (zeigeStandard) ...[
const SizedBox(height: 4),
Text(
'Standard: $defaultMin Min (aus Einstellungen)',
style: const TextStyle(
fontSize: 11, color: MeloTheme.textSekundaer),
),
],
const SizedBox(height: 14),
Row(
children: [
_zeitChip(ctx, 'Aus', null, aktiv: !aktiv,
onTap: () {
_player.cancelSleepTimer();
setSheetState(() {});
}),
_zeitChip(ctx, '15', const Duration(minutes: 15),
standard: defaultMin == 15 && zeigeStandard,
onTap: () {
_player.setSleepTimer(const Duration(minutes: 15));
setSheetState(() {});
}),
_zeitChip(ctx, '30', const Duration(minutes: 30),
standard: defaultMin == 30 && zeigeStandard,
onTap: () {
_player.setSleepTimer(const Duration(minutes: 30));
setSheetState(() {});
}),
_zeitChip(ctx, '45', const Duration(minutes: 45),
standard: defaultMin == 45 && zeigeStandard,
onTap: () {
_player.setSleepTimer(const Duration(minutes: 45));
setSheetState(() {});
}),
_zeitChip(ctx, '60', const Duration(minutes: 60),
standard: defaultMin == 60 && zeigeStandard,
onTap: () {
_player.setSleepTimer(const Duration(minutes: 60));
setSheetState(() {});
}),
],
),
],
),
),
);
},
),
);
}
Widget _zeitChip(BuildContext ctx, String label, Duration? dauer,
{bool aktiv = false, bool standard = false, VoidCallback? onTap}) {
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: aktiv ? MeloTheme.rot : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(16),
// Standard-Chip (Einstellung) bekommt einen Akzent-Rahmen
border: standard
? Border.all(color: MeloTheme.rot, width: 1.5)
: null,
),
child: Text(
standard ? '$label ⭐' : label,
style: TextStyle(
fontSize: 12,
color: aktiv ? Colors.white : MeloTheme.textSekundaer,
),
),
),
);
}
String _formatRestzeit(Duration rest) {
final min = rest.inMinutes;
final sek = rest.inSeconds % 60;
return '⏱ ${min.toString().padLeft(2, '0')}:${sek.toString().padLeft(2, '0')}';
}
// ─── Build ───
@override
Widget build(BuildContext context) {
final song = _player.aktuellerSong;
return Scaffold(
backgroundColor: MeloTheme.schwarz,
body: Stack(
fit: StackFit.expand,
children: [
// Blur-Hintergrund mit Crossfade beim Songwechsel (Muster wie
// Cover↔Lyrics-Toggle, nur länger für weichen Übergang).
AnimatedSwitcher(
duration: const Duration(milliseconds: 600),
layoutBuilder: (currentChild, previousChildren) => Stack(
fit: StackFit.expand,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
),
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: _hintergrundBlur(song),
),
// Abdunklung für Lesbarkeit
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.black87],
),
),
),
SafeArea(
child: Column(
children: [
_kopfzeile(),
// Mittelteil: Cover ↔ Lyrics
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: _zeigeLyrics
? _lyricsAnsicht()
: _coverAnsicht(song),
),
),
_fortschritt(),
_controls(),
_untereLeiste(),
const SizedBox(height: 10),
],
),
),
],
),
);
}
/// Blur-Hintergrund aus dem Cover (Bordmittel: ImageFiltered + dart:ui).
/// Existenz-Check kommt aus dem async geladenen [_hatCover]/[_coverPfad] —
/// kein synchrones existsSync im Build-Pfad.
Widget _hintergrundBlur(Song? song) {
final pfad = _coverPfad;
if (!_hatCover || pfad == null) {
return const DecoratedBox(
key: ValueKey('kein-cover'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF1A0A0A), MeloTheme.schwarz],
),
),
);
}
return ImageFiltered(
// Key auf den tatsächlichen Cover-Pfad statt song.id: Der Switcher
// wechselt erst, wenn das NEUE Cover wirklich geladen ist (echter
// Crossfade) — nie vorher (altes Bild unter neuem Key = unsichtbarer
// Fade + harter Cut beim Nachladen).
key: ValueKey('cover-$pfad'),
imageFilter: ImageFilter.blur(sigmaX: 22, sigmaY: 22),
child: Image.file(File(pfad), fit: BoxFit.cover),
);
}
/// Kopfzeile im Spotify-Stil: „JETZT LÄUFT" links neben dem Schließen-Pfeil,
/// rechts Like-Button (Favoriten) und Queue-Zugriff.
Widget _kopfzeile() {
return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 8, 0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.keyboard_arrow_down,
size: 30, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Schließen',
),
const Text(
'JETZT LÄUFT',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 2.2,
color: MeloTheme.textSekundaer,
),
),
const Spacer(),
// Like-Button (Favoriten)
IconButton(
icon: Icon(
_istFavorit ? Icons.favorite : Icons.favorite_border,
size: 24,
color: _istFavorit ? MeloTheme.rot : Colors.white,
),
onPressed: _toggleFavorit,
tooltip: _istFavorit
? 'Aus Favoriten entfernen'
: 'Zu Favoriten hinzufügen',
),
// Queue-Zugriff
IconButton(
icon: const Icon(Icons.queue_music, size: 24, color: Colors.white),
onPressed: _zeigeWarteschlange,
tooltip: 'Warteschlange',
),
],
),
);
}
/// Cover-Ansicht: großes Cover (Radius 24 + Glow + Karten-Schatten),
/// darunter Titel und Künstler in klarer Typografie (Spotify-Stil).
Widget _coverAnsicht(Song? song) {
final hatCover = _hatCover && _coverPfad != null;
final coverGroesse =
(MediaQuery.of(context).size.width * 0.78).clamp(240.0, 360.0);
return SingleChildScrollView(
key: const ValueKey('cover'),
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 12),
// Cover mit Glow — AnimatedBuilder um den Container: Ohne Listener/
// Builder würde der BoxShadow nur bei fremden setStates neu gebaut
// (Pulsation zufällig sichtbar, friert bei Pause/Pufferung ein).
AnimatedBuilder(
animation: _glowController,
builder: (context, _) => Container(
width: coverGroesse,
height: coverGroesse,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [
// Glow in der Akzentfarbe des Users, pulsierend bei Wiedergabe
BoxShadow(
color: MeloTheme.akzent.withValues(alpha: 0.4),
blurRadius: 45 + 18 * _glowController.value, // 4563
spreadRadius: 5 + 5 * _glowController.value, // 510
),
// Karten-Schatten für Tiefe (Cover als „Karte")
BoxShadow(
color: Colors.black.withValues(alpha: 0.55),
blurRadius: 28,
offset: const Offset(0, 14),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: hatCover
? Image.file(File(_coverPfad!), fit: BoxFit.cover)
: Container(
color: MeloTheme.rotHell,
child: const Icon(Icons.music_note,
size: 100, color: MeloTheme.rot),
),
),
),
),
const SizedBox(height: 30),
// Titel + Künstler linksbündig (Like-Button ist jetzt im Header)
Align(
alignment: Alignment.centerLeft,
child: Text(
song?.titel ?? 'Kein Song',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: Text(
song?.kuenstler ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
color: MeloTheme.textSekundaer,
),
),
),
const SizedBox(height: 8),
],
),
);
}
/// Lyrics-Ansicht: Auto-Scroll zum aktuellen Vers, Tipp = Seek.
Widget _lyricsAnsicht() {
final zeilen = _lyrics;
Widget inhalt;
if (_lyricsLaden) {
inhalt = const Center(
child: CircularProgressIndicator(color: MeloTheme.rot),
);
} else if (zeilen == null || zeilen.isEmpty) {
inhalt = const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lyrics_outlined, size: 40, color: MeloTheme.textSekundaer),
SizedBox(height: 12),
Text(
'Keine Lyrics verfügbar',
style: TextStyle(fontSize: 15, color: Colors.white),
),
SizedBox(height: 4),
Text(
'Lege eine .lrc-Datei neben den Song oder versuche es später erneut.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: MeloTheme.textSekundaer),
),
],
),
),
);
} else {
inhalt = NotificationListener<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(
// Dickerer Slider (Spotify-Stil), weiße Blende
trackHeight: 6,
activeTrackColor: MeloTheme.rot,
inactiveTrackColor: MeloTheme.dunkel2,
thumbColor: Colors.white,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayColor: MeloTheme.rot.withValues(alpha: 0.2),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: posMs,
max: dauerMs,
onChangeStart: (v) =>
setState(() => _dragPosition = Duration(milliseconds: v.round())),
onChanged: (v) =>
setState(() => _dragPosition = Duration(milliseconds: v.round())),
onChangeEnd: (v) {
_player.seek(Duration(milliseconds: v.round()));
setState(() => _dragPosition = null);
},
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Row(
children: [
Text(_formatZeit(anzeigePosition),
style: const TextStyle(
fontSize: 12, color: MeloTheme.textSekundaer)),
const Spacer(),
Text(_formatZeit(_dauer),
style: const TextStyle(
fontSize: 12, color: MeloTheme.textSekundaer)),
],
),
),
],
);
}
/// Controls im Spotify-Stil: Shuffle, Prev, Play/Pause (groß), Next, Repeat.
Widget _controls() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_shuffleBtn(),
_ctrlBtn(Icons.skip_previous, 36, _player.vorheriges),
_playBtn(),
// Next: Long-Press öffnet zusätzlich das Play-Next-Menü
GestureDetector(
onLongPress: _zeigePlayNextMenue,
child: _ctrlBtn(Icons.skip_next, 36, _player.naechstes),
),
_repeatBtn(),
],
),
);
}
Widget _ctrlBtn(IconData icon, double groesse, VoidCallback onTap,
{Color farbe = Colors.white}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(50),
onTap: onTap,
child: Container(
width: 46,
height: 46,
alignment: Alignment.center,
child: Icon(icon, size: groesse, color: farbe),
),
),
);
}
Widget _shuffleBtn() {
final aktiv = _player.zufallsmodus;
return _ctrlBtn(Icons.shuffle, 26, () => _player.setZufallsmodus(!aktiv),
farbe: aktiv ? MeloTheme.rot : Colors.white);
}
Widget _repeatBtn() {
final modus = _player.wiederholmodus;
final (icon, farbe) = switch (modus) {
Wiederholmodus.aus => (Icons.repeat, Colors.white),
Wiederholmodus.titel => (Icons.repeat_one, MeloTheme.rot),
Wiederholmodus.playlist => (Icons.repeat, MeloTheme.rot),
};
return _ctrlBtn(icon, 26, _naechsterWiederholmodus, farbe: farbe);
}
void _naechsterWiederholmodus() {
final modus = _player.wiederholmodus;
_player.setWiederholmodus(switch (modus) {
Wiederholmodus.aus => Wiederholmodus.titel,
Wiederholmodus.titel => Wiederholmodus.playlist,
Wiederholmodus.playlist => Wiederholmodus.aus,
});
}
Widget _playBtn() {
return Material(
color: MeloTheme.rot,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: _player.playPause,
child: Container(
width: 74,
height: 74,
alignment: Alignment.center,
child: Icon(
_spielt ? Icons.pause : Icons.play_arrow,
size: 42,
color: Colors.white,
),
),
),
);
}
/// Untere Leiste (Spotify-Stil): Schlaf-Timer links, Lyrics-Toggle und
/// Geschwindigkeits-Anzeige rechts.
Widget _untereLeiste() {
final sleepAktiv = _player.sleepTimerAktiv;
final rest = _player.sleepRestzeit;
final geschwindigkeit = _player.geschwindigkeit;
final speedAktiv = geschwindigkeit != 1.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 2),
child: Row(
children: [
// Schlaf-Timer (Tipp = Auswahl-Sheet)
GestureDetector(
onTap: _zeigeSleepTimer,
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.bedtime,
size: 20,
color: sleepAktiv ? MeloTheme.rot : MeloTheme.textSekundaer,
),
if (sleepAktiv && rest != null) ...[
const SizedBox(width: 6),
Text(
_formatRestzeit(rest).replaceAll('⏱ ', ''),
style: const TextStyle(fontSize: 12, color: MeloTheme.rot),
),
],
],
),
),
const Spacer(),
// Lyrics-Toggle (🎤): wechselt Cover ↔ Lyrics
IconButton(
icon: Icon(
_zeigeLyrics ? Icons.album : Icons.mic_none,
size: 22,
color: _zeigeLyrics ? MeloTheme.rot : Colors.white70,
),
onPressed: () => setState(() => _zeigeLyrics = !_zeigeLyrics),
tooltip: _zeigeLyrics ? 'Cover anzeigen' : 'Lyrics anzeigen',
),
const SizedBox(width: 4),
// Geschwindigkeit (Tipp = nächste Stufe, wird persistiert)
GestureDetector(
onTap: _naechsteGeschwindigkeit,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: speedAktiv ? MeloTheme.rot : MeloTheme.dunkel2,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${geschwindigkeit.toStringAsFixed(2)}x',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: speedAktiv ? Colors.white : MeloTheme.textSekundaer,
),
),
),
),
],
),
);
}
/// Gängige Geschwindigkeitsstufen — Tipp im Fullscreen wechselt durch.
static const List<double> _geschwindigkeiten = [
0.5, 0.75, 1.0, 1.25, 1.5, 2.0,
];
Future<void> _naechsteGeschwindigkeit() async {
final aktuell = _player.geschwindigkeit;
final idx = _geschwindigkeiten.indexOf(aktuell);
final neu = idx >= 0
? _geschwindigkeiten[(idx + 1) % _geschwindigkeiten.length]
: 1.0;
// Wie in den Einstellungen persistieren, damit die Wahl erhalten bleibt
try {
final p = await SharedPreferences.getInstance();
await p.setDouble('playback_speed', neu);
} catch (e) {
debugPrint('Geschwindigkeit persistieren fehlgeschlagen: $e');
}
await _player.setGeschwindigkeit(neu);
}
String _formatZeit(Duration d) {
final min = d.inMinutes;
final sek = d.inSeconds % 60;
return '$min:${sek.toString().padLeft(2, '0')}';
}
}