Die P2-Liste aus dem Review — Kleinigkeiten, die man einzeln nicht vermisst und zusammen sofort merkt. - Das Coverbild wandert beim Oeffnen der Vollbild-Wiedergabe an seinen Platz, statt dass ein neuer Bildschirm aufspringt. - Wischen am Mini-Player: nach oben oeffnen, seitwaerts Titelwechsel. Mit Schwelle, weil der Mini-Player zugleich ein Knopf ist und der Finger beim Tippen fast immer ein paar Pixel wandert. Nach unten bewusst unbelegt, damit niemand die Wiedergabe versehentlich wegschiebt. - Kuenstlerliste mit Bildern und Albenzahl; dahinter eine Kuenstlerseite mit seinen Alben und darunter allen Titeln (dafuer neu: groupByAlbum). Bei nur einem Album entfaellt die Zwischenstufe. - Alphabet-Sprung am rechten Rand, nur bei Namenssortierung und ab 30 Titeln. Umlaute zaehlen zu ihrem Grundbuchstaben, Ziffern und Zeichen unter "#" — dieselbe Regel wie die Sortierung. - Mehrfachauswahl per langem Druck, mit "Zur Warteschlange" und "Zu Wiedergabeliste". - Abgeschaltete Systemanimationen gelten jetzt ueberall, nicht nur bei den Lauf-Balken. - Querformat und Tablet: der Player legt Cover und Bedienung nebeneinander und begrenzt die Textbreite. Beim Bauen gefunden: der Alphabet-Sprung landete bei absteigender Sortierung am Ende eines Buchstaben-Blocks statt an seinem Anfang. Aus dem Code-Review nachgebessert, sieben Punkte: die Alphabet-Leiste wurde breiter, je weniger Buchstaben sie hatte (bei einem einzigen 528 dp — die Songliste waere verschwunden); die neue Kuenstlerseite baute alle Titel sofort und oeffnete damit bei 300 Titeln ebenso viele Abos; die Bildlauf-Animation im Songtext stuerzte bei abgeschalteten Systemanimationen ab; die feste Zeilenhoehe war 64 statt 72 und die Schriftskalierung an 1.0 statt an einer echten Groesse gemessen; bereits enthaltene Titel wurden in Wiedergabelisten verschoben statt uebersprungen und trotzdem mitgezaehlt; ein Fehler im Stapel brach still ab; und die Zurueck-Geste verliess den Bildschirm, statt die Auswahl zu beenden. Selbst gefunden: in der Kuenstlerliste hing der Datenbank-Strom an CategoryService und wurde bei jeder Kategorie-Meldung neu aufgesetzt. Nebenbei aufgeklaert, warum ein neuer Widget-Test endlos hing: Aufbau in setUp laeuft ausserhalb der Testzone, die drift-Stroeme liefen dort in echter Zeit, waehrend pumpAndSettle in Testzeit auf sie wartete. Aufbau in den Testkoerper verschoben. Der aeltere Haenger im BACKLOG hat eine andere Ursache (db.close() dort nachweislich ausgeschlossen); die Spur ist notiert. 500 Tests gruen (vorher 457), flutter analyze ohne Befund, Release-APK gebaut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
603 lines
20 KiB
Dart
603 lines
20 KiB
Dart
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 '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 Scaffold(
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|