P3-Liste aus dem UX-Review: Player nimmt Cover-Farbe auf, "Teilen" aus YouTube trägt die Adresse in den Downloader ein, Android Auto blättert durch die Bibliothek, Laufweite der Typografie geschärft, Überschriften vereinheitlicht. 533 Tests grün, flutter analyze ohne Befund.
684 lines
22 KiB
Dart
684 lines
22 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.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';
|
|
|
|
/// Vollbild-Wiedergabe: Cover, Titel, Fortschritt, Transport-Controls.
|
|
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>();
|
|
return _CoverGrund(
|
|
handler: handler,
|
|
child: Scaffold(
|
|
// Der Grund kommt aus dem Coverbild — deshalb hier durchsichtig.
|
|
backgroundColor: Colors.transparent,
|
|
extendBodyBehindAppBar: true,
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
actions: [
|
|
StreamBuilder<MediaItem?>(
|
|
stream: handler.mediaItem,
|
|
builder: (context, snapshot) {
|
|
final item = snapshot.data;
|
|
if (item == null) return const SizedBox.shrink();
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
tooltip: 'Songtext',
|
|
icon: const Icon(Icons.lyrics),
|
|
// Bei Server-Titeln die Navidrome-ID, nicht die
|
|
// Abspiel-Adresse: mit der fragte die App den Server nach
|
|
// einem Titel namens "https://…" und bekam nie einen Text.
|
|
onPressed: () => _showLyrics(
|
|
context, songIdOf(item) ?? navidromeIdOf(item) ?? ''),
|
|
),
|
|
// 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),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
_SleepTimerButton(handler: handler),
|
|
IconButton(
|
|
tooltip: 'Warteschlange',
|
|
icon: const Icon(Icons.queue_music),
|
|
onPressed: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const QueueScreen()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
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) {
|
|
// Im Querformat ist die Höhe knapp: Cover und Bedienung
|
|
// untereinander ließen vom Cover einen Streifen übrig oder
|
|
// schoben die Knöpfe aus dem Bild. Nebeneinander bleibt beides
|
|
// benutzbar. Dieselbe Aufteilung hilft auf einem Tablet.
|
|
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: _Cover(item: item)),
|
|
),
|
|
const SizedBox(width: MeloSpace.lg),
|
|
// Der Inhalt bekommt eine feste Obergrenze: auf
|
|
// einem breiten Bildschirm zerrte er sonst über
|
|
// die ganze Breite und wäre schlecht lesbar.
|
|
Expanded(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints:
|
|
const BoxConstraints(maxWidth: 420),
|
|
child: inhalt,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: Column(
|
|
children: [
|
|
Expanded(
|
|
child: Center(child: _Cover(item: item)),
|
|
),
|
|
const SizedBox(height: MeloSpace.lg),
|
|
Center(
|
|
child: ConstrainedBox(
|
|
constraints:
|
|
const BoxConstraints(maxWidth: 520),
|
|
child: inhalt,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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: [
|
|
Text(
|
|
item.title,
|
|
style: Theme.of(context).textTheme.headlineSmall,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Gemeinsamer Name des Coverbilds in Mini-Player und Vollbild — daran
|
|
/// erkennt Flutter, dass es dasselbe Bild ist, und lässt es zwischen beiden
|
|
/// wandern statt einen neuen Bildschirm aufspringen zu lassen.
|
|
const coverHeldenName = 'melo-cover';
|
|
|
|
class _Cover extends StatelessWidget {
|
|
const _Cover({required this.item});
|
|
final MediaItem item;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AspectRatio(
|
|
aspectRatio: 1,
|
|
child: Hero(
|
|
tag: coverHeldenName,
|
|
child: CoverImage(artUri: item.artUri, radius: 16),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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'),
|
|
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(
|
|
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),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|