## Now-Playing-Fullscreen - Kopfzeile: „JETZT LÄUFT"-Header + Like-Button + Queue-Button (Like aus der Titel-Zeile in den Header verschoben) - Großes Cover zentriert (78 % Breite, Radius 24) mit Glow-Pulsation + Karten-Schatten - Titel (24, w800) + Künstler (16) linksbündig in klarer Typografie - Fortschrittsbalken: dickerer Slider (6 px, weiße Blende), Position links / Dauer rechts - Controls-Reihenfolge wie Spotify: Shuffle, Prev, Play/Pause (74 px), Next, Repeat - Neue untere Leiste: Schlaf-Timer links, Lyrics-Toggle + Geschwindigkeits-Chip rechts - Geschwindigkeits-Chip: Tipp wechselt 0.5x–2.0x, wird wie in den Einstellungen persistiert (playback_speed) - Blur-Hintergrund dezenter (Sigma 45 → 22) - Versionsstring in main.dart auf 2.54 aktualisiert (Logger)
1047 lines
36 KiB
Dart
1047 lines
36 KiB
Dart
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 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.
|
||
/// 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, // 45–63
|
||
spreadRadius: 5 + 5 * _glowController.value, // 5–10
|
||
),
|
||
// 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')}';
|
||
}
|
||
}
|