Bug (Code-Review, verifiziert): _Angaben (Titel/Künstler/Fortschritt/ Controls) hat eine intrinsische Mindesthöhe, die während der Öffnen-Geste (progress zwischen ~0.3 und ~1.0, siehe main.dart Positioned.fromRect mit Rect.lerp Mini-/Vollbild-Player) das noch kleinere, interpolierte Rechteck übersteigt — Flutter meldete dann "RenderFlex overflowed" bei praktisch jeder normalen Öffnen-Geste. Fix: der Inhalt-Slot (in beiden responsiven Zweigen — Row nebeneinander und Column untereinander) steckt jetzt in ClipRect(child: ConstrainedBox(maxHeight: raum.maxHeight - Lücke, child: OverflowBox(maxHeight: infinity, fit: deferToChild, child: _Angaben))). ConstrainedBox deckelt nur, was OverflowBox nach AUSSEN als eigene Größe meldet (verhindert, dass der umgebende Row/Column selbst überläuft); OverflowBox gibt _Angaben davon unabhängig weiterhin unbegrenzte Höhe (verhindert, dass _Angaben intern überläuft) — überschüssiger Inhalt wird vom ClipRect während der kurzen, ohnehin noch recht durchsichtigen Übergangsphase einfach nicht gemalt statt eine Overflow-Warnung auszulösen. Bei ausreichend Platz (Endzustand progress==1) ist raum.maxHeight groß genug, dass nichts geklemmt wird — die Optik bleibt dort unverändert (now_playing_heart_test.dart bestätigt das weiterhin). Test zuerst (RED: "RenderFlex overflowed by 13 pixels" reproduziert, mit NowPlayingScreen in einem echten, interpolierten Zwischenrechteck bei progress≈0.4 auf Basis der HomeShell-Rect-Formel): test/player/ now_playing_screen_overflow_test.dart. Volle Suite (626 Tests) grün. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SM6e73V9ZethPiqHSocxUA
812 lines
28 KiB
Dart
812 lines
28 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' show lerpDouble;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart' show OverflowBoxFit;
|
|
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 'player_expansion_controller.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<MeloAudioHandler>();
|
|
// 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<SystemUiOverlayStyle>(
|
|
value: SystemUiOverlayStyle.light,
|
|
child: _CoverGrund(
|
|
handler: handler,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
_VollbildLeiste(handler: handler, onLyrics: _showLyrics),
|
|
Expanded(
|
|
child: _SchliessGeste(
|
|
child: StreamBuilder<MediaItem?>(
|
|
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;
|
|
// Während der Öffnen-Geste ist das interpolierte
|
|
// Rechteck (main.dart, Rect.lerp Mini-/Vollbild-
|
|
// Player) oft noch kleiner, als _Angaben an
|
|
// Mindesthöhe braucht — ohne diese Kapselung meldet
|
|
// Flutter dann "RenderFlex overflowed" (im Row-Zweig
|
|
// unten bekäme _Angaben selbst eine zu knappe Höhe
|
|
// zugeteilt, im Column-Zweig überschreitet Lücke +
|
|
// _Angaben-Eigenhöhe zusammen die verfügbare Höhe).
|
|
// ConstrainedBox AUSSEN um OverflowBox deckelt, was
|
|
// OverflowBox nach außen als eigene Größe meldet,
|
|
// hart auf das von raum bekannte Maß; OverflowBox
|
|
// selbst gibt ihrem Kind (_Angaben) über
|
|
// maxHeight: infinity davon UNABHÄNGIG weiterhin
|
|
// unbegrenzte Höhe — _Angaben behält so seine
|
|
// natürliche Größe, der überschüssige Teil wird
|
|
// vom ClipRect einfach nicht gemalt statt eine
|
|
// Overflow-Warnung auszulösen. Läge die Deckelung
|
|
// stattdessen INNEN (zwischen OverflowBox und
|
|
// _Angaben), bekäme _Angaben selbst zu wenig Höhe
|
|
// und liefe intern über. Bei ausreichend Platz
|
|
// (Endzustand progress==1) ist raum.maxHeight groß
|
|
// genug, dass nichts geklemmt wird — die Optik
|
|
// bleibt dort unverändert.
|
|
final inhalt = ClipRect(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: (raum.maxHeight - MeloSpace.lg)
|
|
.clamp(0.0, double.infinity),
|
|
),
|
|
child: OverflowBox(
|
|
alignment: Alignment.center,
|
|
maxHeight: double.infinity,
|
|
fit: OverflowBoxFit.deferToChild,
|
|
child: _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),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Schließt den Vollbild-Player per Wischgeste nach unten — symmetrisch zum
|
|
/// Öffnen am Mini-Player, live über denselben PlayerExpansionController.
|
|
/// Eigenes StatefulWidget, weil eine lokale Rohstrecke über die Gestendauer
|
|
/// mitgeführt werden muss (analog zum bisherigen `_versatz`-Muster in
|
|
/// mini_player.dart).
|
|
class _SchliessGeste extends StatefulWidget {
|
|
const _SchliessGeste({required this.child});
|
|
final Widget child;
|
|
|
|
@override
|
|
State<_SchliessGeste> createState() => _SchliessGesteState();
|
|
}
|
|
|
|
class _SchliessGesteState extends State<_SchliessGeste> {
|
|
double _strecke = 0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final controller = context.read<PlayerExpansionController>();
|
|
return GestureDetector(
|
|
// Ohne opaque behavior fängt die Geste keine Berührungen über den
|
|
// großflächigen, unbemalten Bereichen des Inhalts ab (z.B. über dem
|
|
// Cover-Platzhalter, der nur ein leeres SizedBox ist) — GestureDetector
|
|
// reagiert per Default (deferToChild) nur dort, wo ein Nachfahre
|
|
// tatsächlich etwas zeichnet (per Testlauf verifiziert).
|
|
behavior: HitTestBehavior.opaque,
|
|
onVerticalDragStart: (_) => _strecke = 0,
|
|
onVerticalDragUpdate: (details) {
|
|
_strecke += details.delta.dy;
|
|
controller.dragBy(details.delta.dy);
|
|
},
|
|
onVerticalDragEnd: (details) => controller.dragEnd(
|
|
context, _strecke, details.velocity.pixelsPerSecond.dy,
|
|
ausVollbild: true),
|
|
child: widget.child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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<MediaItem?>(
|
|
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<MediaItem?>? _abo;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_abo = widget.handler.mediaItem.listen(_uebernimm);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_abo?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _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<MeloAudioHandler>().navidrome,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProgressBar extends StatelessWidget {
|
|
const _ProgressBar({required this.handler});
|
|
final MeloAudioHandler handler;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return StreamBuilder<Duration?>(
|
|
stream: handler.durationStream,
|
|
builder: (context, dSnap) {
|
|
final total = dSnap.data ?? Duration.zero;
|
|
return StreamBuilder<Duration>(
|
|
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<MeloAudioHandler>();
|
|
return StreamBuilder<PlaybackState>(
|
|
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<Duration?>(
|
|
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<Duration?>(
|
|
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<LyricZeile> _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<void> _load() async {
|
|
final db = context.read<MeloDb>();
|
|
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<LyricZeile> zeilen;
|
|
final ScrollController scroll;
|
|
final double zeilenHoehe;
|
|
final ValueChanged<int> onZeile;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final handler = context.read<MeloAudioHandler>();
|
|
return StreamBuilder<Duration>(
|
|
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),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|