import 'dart:async'; import 'dart:ui' show lerpDouble; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../library/database.dart'; import '../services/navidrome_service.dart'; import '../shared/cover.dart'; import '../shared/favorite_button.dart'; import '../shared/server_favorite_button.dart'; import '../shared/theme.dart'; import 'audio_handler.dart'; import 'cover_farbe.dart'; import 'lrc.dart'; import 'queue_screen.dart'; /// Inhalt des Vollbild-Players — wird von HomeShell in ein per progress /// interpoliertes Rechteck gesetzt, ist selbst kein Scaffold/keine Route. class NowPlayingScreen extends StatelessWidget { const NowPlayingScreen({super.key}); void _showLyrics(BuildContext context, String songId) { if (songId.isEmpty) return; showModalBottomSheet( context: context, builder: (ctx) => _LyricsSheet(songId: songId), ); } @override Widget build(BuildContext context) { final handler = context.read(); // Ersetzt, was bisher implizit über Scaffold/AppBar(backgroundColor: // transparent) auf dem durchgehend dunklen Theme lief (siehe // MeloTheme.dark/CLAUDE.md „Dark Theme“) — ohne Scaffold/AppBar muss die // Status-Icon-Helligkeit hier explizit gesetzt werden, sonst bleiben die // Icons je nach Systemzustand zufällig dunkel auf dunklem Grund. return AnnotatedRegion( value: SystemUiOverlayStyle.light, child: _CoverGrund( handler: handler, child: Material( color: Colors.transparent, child: SafeArea( child: Column( children: [ _VollbildLeiste(handler: handler, onLyrics: _showLyrics), Expanded( child: GestureDetector( onVerticalDragStart: (_) {}, // s. Task 8 child: StreamBuilder( stream: handler.mediaItem, builder: (context, snapshot) { final item = snapshot.data; if (item == null) { return const Center(child: Text('Nichts in Wiedergabe')); } return LayoutBuilder( builder: (context, raum) { final nebeneinander = raum.maxWidth > raum.maxHeight; final inhalt = _Angaben(item: item, handler: handler); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: nebeneinander ? Row(children: [ Expanded(child: Center(child: _CoverPlatzhalter())), const SizedBox(width: MeloSpace.lg), Expanded( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: inhalt, ), ), ), ]) : Column(children: [ Expanded(child: Center(child: _CoverPlatzhalter())), const SizedBox(height: MeloSpace.lg), Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 520), child: inhalt, ), ), ]), ); }, ); }, ), ), ), ], ), ), ), ), ); } } /// Schlüssel des Cover-Platzhalters — Task 6 misst darüber die tatsächliche /// Vollbild-Position/-Größe des Covers (`RenderBox.localToGlobal`), statt /// `NowPlayingScreen`s responsives Layout (Row/Column-Weiche, AspectRatio) /// von außen nachzurechnen. final coverPlatzhalterKey = GlobalKey(); /// Platzhalter im Layout — das eigentliche Cover rendert `WanderndesCover` /// (Task 6) in einer eigenen Ebene über allem, damit es unabhängig vom /// restlichen Inhalt zwischen Mini- und Vollbild-Rechteck wandern kann. class _CoverPlatzhalter extends StatelessWidget { const _CoverPlatzhalter(); @override Widget build(BuildContext context) => AspectRatio(key: coverPlatzhalterKey, aspectRatio: 1, child: const SizedBox()); } /// Cover-Bild, das zwischen Mini-Player- und Vollbild-Rechteck wandert. /// /// Ersetzt die frühere `Hero`-Animation: die feuert nur bei echten /// Navigator-Transitions, und `NowPlayingScreen` ist seit dem Overlay-Umbau /// keine Route mehr. Positionierung/Größe kommen direkt aus [progress], /// live an die Zugstrecke der Wischgeste gekoppelt. class WanderndesCover extends StatelessWidget { const WanderndesCover({ super.key, required this.progress, required this.artUri, required this.miniRect, required this.vollbildRect, }); final double progress; final Uri? artUri; final Rect miniRect; final Rect vollbildRect; static const _miniRadius = 6.0; static const _vollbildRadius = 16.0; @override Widget build(BuildContext context) { final rect = Rect.lerp(miniRect, vollbildRect, progress)!; final radius = lerpDouble(_miniRadius, _vollbildRadius, progress)!; return Positioned.fromRect( rect: rect, child: CoverImage(artUri: artUri, radius: radius), ); } } /// Die bisherigen AppBar-Actions (Songtext, Sleep-Timer, Warteschlange) als /// eigene Kopfzeile statt echter AppBar — eine Route/Scaffold gibt es hier /// nicht mehr. class _VollbildLeiste extends StatelessWidget { const _VollbildLeiste({required this.handler, required this.onLyrics}); final MeloAudioHandler handler; final void Function(BuildContext, String) onLyrics; @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ StreamBuilder( stream: handler.mediaItem, builder: (context, snapshot) { final item = snapshot.data; if (item == null) return const SizedBox.shrink(); final songId = songIdOf(item) ?? navidromeIdOf(item) ?? ''; return IconButton( tooltip: songId.isEmpty ? 'Kein Songtext für diesen Titel verfügbar' : 'Songtext', icon: const Icon(Icons.lyrics), onPressed: songId.isEmpty ? null : () => onLyrics(context, songId), ); }, ), _SleepTimerButton(handler: handler), IconButton( tooltip: 'Warteschlange', icon: const Icon(Icons.queue_music), onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => const QueueScreen()), ), ), ], ); } } /// Färbt den Grund des Players nach dem Coverbild ein. /// /// Der Player sah für jeden Titel gleich aus — schwarz. Ein Verlauf aus der /// prägenden Farbe des Covers gibt jedem Album sein eigenes Gesicht, ohne /// dass die Schrift darunter leidet: die Farbe wird vorher so weit /// abgedunkelt, dass Weiß darauf lesbar bleibt (siehe [lesbarAuf]). class _CoverGrund extends StatefulWidget { const _CoverGrund({required this.handler, required this.child}); final MeloAudioHandler handler; final Widget child; @override State<_CoverGrund> createState() => _CoverGrundState(); } class _CoverGrundState extends State<_CoverGrund> { Color _farbe = MeloTheme.black; Uri? _zuletzt; StreamSubscription? _abo; @override void initState() { super.initState(); _abo = widget.handler.mediaItem.listen(_uebernimm); } @override void dispose() { _abo?.cancel(); super.dispose(); } Future _uebernimm(MediaItem? item) async { final art = item?.artUri; // Nur bei einem echten Bildwechsel neu rechnen: `mediaItem` meldet auch // nachgetragene Dauern, und jedes Mal ein Bild zu dekodieren wäre Unfug. if (art == _zuletzt) return; _zuletzt = art; if (art == null) { if (mounted) setState(() => _farbe = MeloTheme.black); return; } final bild = await coverBildQuelle(art); if (bild == null) { if (mounted) setState(() => _farbe = MeloTheme.black); return; } final farbe = lesbarAuf(await farbeAusBild(bild)); if (mounted && art == _zuletzt) setState(() => _farbe = farbe); } @override Widget build(BuildContext context) { return AnimatedContainer( duration: MeloMotion.ruhig(context, MeloMotion.normal), curve: MeloMotion.curve, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, // Nach unten in Schwarz auslaufen: dort sitzen Fortschritt und // Bedienung, und die stehen auf dem gewohnten Grund am ruhigsten. colors: [_farbe, MeloTheme.black], stops: const [0.0, 0.75], ), ), child: widget.child, ); } } /// Titel, Künstler, Fortschritt und Bedienung — der Teil, der im Querformat /// neben das Cover rückt. class _Angaben extends StatelessWidget { const _Angaben({required this.item, required this.handler}); final MediaItem item; final MeloAudioHandler handler; @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Text( item.title, style: Theme.of(context).textTheme.headlineSmall, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, ), ), // Ein Titel der Bibliothek wird lokal favorisiert, einer // vom Server am Server — beide bekommen dasselbe Herz, // statt dass eines davon ausgegraut bleibt. _Herz(item: item), ], ), const SizedBox(height: MeloSpace.sm), Text( item.artist ?? 'Unbekannt', style: const TextStyle(color: MeloTheme.text2), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: MeloSpace.lg), _ProgressBar(handler: handler), const _Controls(), const SizedBox(height: MeloSpace.md), ], ); } } /// Das passende Herz für den laufenden Titel. class _Herz extends StatelessWidget { const _Herz({required this.item}); final MediaItem item; @override Widget build(BuildContext context) { final songId = songIdOf(item); if (songId != null && songId.isNotEmpty) { return FavoriteButton(songId: songId); } final navId = navidromeIdOf(item); if (navId == null) return const SizedBox.shrink(); return ServerFavoriteButton( // Ohne Schlüssel behielte das Herz beim Titelwechsel seinen Zustand: // Flutter würde dasselbe Element weiterverwenden, und der nächste Titel // erschiene als Favorit, obwohl er nie einer war. key: ValueKey(navId), navidromeId: navId, navidrome: context.read().navidrome, ); } } class _ProgressBar extends StatelessWidget { const _ProgressBar({required this.handler}); final MeloAudioHandler handler; @override Widget build(BuildContext context) { return StreamBuilder( stream: handler.durationStream, builder: (context, dSnap) { final total = dSnap.data ?? Duration.zero; return StreamBuilder( stream: handler.positionStream, builder: (context, pSnap) { var position = pSnap.data ?? Duration.zero; final hasDuration = total > Duration.zero; if (hasDuration && position > total) position = total; final maxMs = hasDuration ? total.inMilliseconds.toDouble() : 1.0; final valueMs = position.inMilliseconds.toDouble().clamp(0.0, maxMs).toDouble(); return Column( children: [ Slider( value: valueMs, max: maxMs, semanticFormatterCallback: (v) => _fmt(Duration(milliseconds: v.round())), onChanged: hasDuration ? (v) => handler.seek(Duration(milliseconds: v.round())) : null, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(_fmt(position), style: const TextStyle(color: MeloTheme.text2)), Text(_fmt(total), style: const TextStyle(color: MeloTheme.text2)), ], ), ), ], ); }, ); }, ); } static String _fmt(Duration d) { final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '$m:$s'; } } class _Controls extends StatelessWidget { const _Controls(); @override Widget build(BuildContext context) { final handler = context.read(); return StreamBuilder( stream: handler.playbackState, builder: (context, snapshot) { final state = snapshot.data; final playing = state?.playing ?? false; final shuffle = state?.shuffleMode == AudioServiceShuffleMode.all; final repeat = state?.repeatMode ?? AudioServiceRepeatMode.none; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ IconButton( tooltip: 'Zufallswiedergabe', icon: Icon(Icons.shuffle, color: shuffle ? MeloTheme.red : MeloTheme.text2), onPressed: () => handler.setShuffleMode(shuffle ? AudioServiceShuffleMode.none : AudioServiceShuffleMode.all), ), IconButton( tooltip: 'Vorheriger Titel', iconSize: 40, icon: const Icon(Icons.skip_previous), onPressed: handler.skipToPrevious, ), IconButton( tooltip: playing ? 'Pause' : 'Abspielen', iconSize: 64, icon: Icon(playing ? Icons.pause_circle : Icons.play_circle, color: MeloTheme.red), onPressed: playing ? handler.pause : handler.play, ), IconButton( tooltip: 'Nächster Titel', iconSize: 40, icon: const Icon(Icons.skip_next), onPressed: handler.skipToNext, ), IconButton( tooltip: 'Wiederholen', icon: Icon( repeat == AudioServiceRepeatMode.one ? Icons.repeat_one : Icons.repeat, color: repeat == AudioServiceRepeatMode.none ? MeloTheme.text2 : MeloTheme.red, ), onPressed: () => handler.setRepeatMode(switch (repeat) { AudioServiceRepeatMode.none => AudioServiceRepeatMode.all, AudioServiceRepeatMode.all => AudioServiceRepeatMode.one, _ => AudioServiceRepeatMode.none, }), ), ], ); }, ); } } class _SleepTimerButton extends StatelessWidget { const _SleepTimerButton({required this.handler}); final MeloAudioHandler handler; void _showSleepOptions(BuildContext context) { showModalBottomSheet( context: context, builder: (ctx) => Container( color: Theme.of(ctx).scaffoldBackgroundColor, child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.all(16), child: Text( 'Sleep-Timer', style: Theme.of(ctx).textTheme.titleLarge, ), ), ...const [5, 10, 15, 30, 60].map( (minutes) => ListTile( title: Text('$minutes Minuten'), onTap: () { handler.sleepTimer.start(Duration(minutes: minutes)); Navigator.pop(ctx); }, ), ), ValueListenableBuilder( valueListenable: handler.sleepTimer.remaining, builder: (_, remaining, _) => remaining == null ? const SizedBox.shrink() : ListTile( title: const Text('Timer beenden'), // Ohne die Restzeit hier war sie faktisch unsichtbar — // die einzige andere Stelle ist der Tooltip des // AppBar-Icons, den kaum jemand entdeckt. subtitle: Text( 'Noch ${remaining.inMinutes}:' '${(remaining.inSeconds % 60).toString().padLeft(2, '0')}'), leading: const Icon(Icons.close), onTap: () { handler.sleepTimer.cancel(); Navigator.pop(ctx); }, ), ), const SizedBox(height: 16), ], ), ), ); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: handler.sleepTimer.remaining, builder: (context, remaining, _) { if (remaining == null) { return IconButton( tooltip: 'Sleep-Timer', icon: const Icon(Icons.bedtime_outlined), onPressed: () => _showSleepOptions(context), ); } final minutes = remaining.inMinutes; final seconds = (remaining.inSeconds % 60).toString().padLeft(2, '0'); return IconButton( tooltip: 'Sleep-Timer: noch $minutes:$seconds', icon: Icon(Icons.bedtime, color: MeloTheme.red), onPressed: () => _showSleepOptions(context), ); }, ); } } class _LyricsSheet extends StatefulWidget { const _LyricsSheet({required this.songId}); final String songId; @override State<_LyricsSheet> createState() => _LyricsSheetState(); } class _LyricsSheetState extends State<_LyricsSheet> { late final NavidromeService _nav = NavidromeService(); String? _text; List _zeilen = const []; bool _loading = true; final _scroll = ScrollController(); int? _gezeigteZeile; @override void initState() { super.initState(); _load(); } @override void dispose() { _scroll.dispose(); super.dispose(); } /// Zuerst der Songtext aus dem Tag der Datei — der ist sofort da und /// funktioniert offline. Nur wenn keiner drinsteht, wird der Server gefragt. Future _load() async { final db = context.read(); final lokal = await db.lyricsOf(widget.songId); if (lokal != null) { if (mounted) setState(() => _uebernimm(lokal)); return; } await _nav.ladeGespeicherteZugangsdaten(); if (_nav.istVerbunden) { final lyrics = await _nav.getLyrics(widget.songId); if (mounted) { setState(() => _uebernimm(lyrics.isEmpty ? null : lyrics.text)); } return; } if (mounted) setState(() => _loading = false); } void _uebernimm(String? text) { _text = text; // Trägt der Text Zeitmarken, läuft er mit; sonst bleibt es eine // Textwand — beides kommt vor, je nach Tag und Server. _zeilen = text == null ? const [] : parseLrc(text); _loading = false; } /// Schiebt die aktive Zeile in die Mitte. Nur beim Zeilenwechsel, nicht bei /// jeder Positionsmeldung — sonst ruckelt die Ansicht permanent. void _folge(int index) { if (_gezeigteZeile == index || !_scroll.hasClients) return; _gezeigteZeile = index; final ziel = (index * _zeilenHoehe) - (_scroll.position.viewportDimension / 2) + (_zeilenHoehe / 2); final wohin = ziel.clamp(0.0, _scroll.position.maxScrollExtent); final dauer = MeloMotion.ruhig(context, MeloMotion.normal); // Bei abgeschalteten Systemanimationen wird die Dauer null — `animateTo` // wirft dann eine Zusicherung. Ohne Bewegung ist Springen ohnehin das, // was die Einstellung meint. if (dauer == Duration.zero) { _scroll.jumpTo(wohin); return; } _scroll.animateTo(wohin, duration: dauer, curve: MeloMotion.curve); } static const _zeilenHoehe = 40.0; @override Widget build(BuildContext context) { return Container( color: MeloTheme.black, child: Column( children: [ Padding( padding: const EdgeInsets.all(MeloSpace.md), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( _zeilen.isEmpty ? 'Songtext' : 'Songtext · läuft mit', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600), ), IconButton( tooltip: 'Schließen', icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context), ), ], ), ), Expanded(child: _inhalt()), ], ), ); } Widget _inhalt() { if (_loading) { return const Center( child: CircularProgressIndicator(color: MeloTheme.red), ); } final text = _text; if (text == null) { return const Center( child: Text('Kein Songtext verfügbar', style: TextStyle(color: MeloTheme.text2)), ); } if (_zeilen.isEmpty) { return SingleChildScrollView( padding: const EdgeInsets.all(MeloSpace.md), child: Text( text, style: const TextStyle(fontSize: 14, height: 1.6, color: Colors.white), ), ); } return _Mitlaufend( zeilen: _zeilen, scroll: _scroll, zeilenHoehe: _zeilenHoehe, onZeile: _folge, ); } } /// Die mitlaufende Darstellung: aktive Zeile hell und hervorgehoben, der /// Rest gedämpft. Antippen springt an die Stelle im Titel. class _Mitlaufend extends StatelessWidget { const _Mitlaufend({ required this.zeilen, required this.scroll, required this.zeilenHoehe, required this.onZeile, }); final List zeilen; final ScrollController scroll; final double zeilenHoehe; final ValueChanged onZeile; @override Widget build(BuildContext context) { final handler = context.read(); return StreamBuilder( stream: handler.positionStream, builder: (context, snapshot) { final aktiv = aktiveZeile(zeilen, snapshot.data ?? Duration.zero); if (aktiv != null) { WidgetsBinding.instance.addPostFrameCallback((_) => onZeile(aktiv)); } return ListView.builder( controller: scroll, itemExtent: zeilenHoehe, padding: const EdgeInsets.symmetric(horizontal: MeloSpace.md), itemCount: zeilen.length, itemBuilder: (context, i) { final istAktiv = i == aktiv; return InkWell( onTap: () => handler.seek(zeilen[i].zeit), child: Align( alignment: Alignment.centerLeft, child: AnimatedDefaultTextStyle( duration: MeloMotion.ruhig(context, MeloMotion.fast), style: TextStyle( fontSize: istAktiv ? 17 : 15, height: 1.3, fontWeight: istAktiv ? FontWeight.w700 : FontWeight.w400, color: istAktiv ? Colors.white : MeloTheme.text3, ), child: Text(zeilen[i].text, maxLines: 1, overflow: TextOverflow.ellipsis), ), ), ); }, ); }, ); } }