Files
Melo/lib/library/song_list.dart
Hermes (Server) e2304b66cd Gestaffelte Enter-Animation für Songlisten (EinblendItem)
Items in song_list.dart und artist_list.dart faden/rutschen beim ersten
Erscheinen gestaffelt statt abrupt ein — angelehnt an HyperOS' fließende
Motion-Sprache. Läuft einmal pro Element, respektiert Reduce-Motion und
deckelt die Verzögerung ab Index 12.
2026-08-29 12:51:04 +02:00

404 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../player/audio_handler.dart';
import '../player/now_playing_screen.dart';
import '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.dart';
import '../shared/einblend_item.dart';
import '../shared/favorite_button.dart';
import '../shared/lauf_balken.dart';
import 'category_service.dart';
import 'database.dart';
import 'playlist_service.dart';
import 'song_detail_sheet.dart';
import '../shared/theme.dart';
import 'song_media.dart';
/// Häkchen-Kreis anstelle des Covers, solange ausgewählt wird.
class _AuswahlZeichen extends StatelessWidget {
const _AuswahlZeichen({required this.ausgewaehlt});
final bool ausgewaehlt;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 48,
height: 48,
child: Center(
child: Icon(
ausgewaehlt ? Icons.check_circle : Icons.circle_outlined,
color: ausgewaehlt ? MeloTheme.red : MeloTheme.text3,
size: 26,
),
),
);
}
}
/// Höhe einer Songzeile bei normaler Schriftgröße.
///
/// 72 und nicht 64: so hoch ist ein `ListTile` mit Untertitel in Material.
/// Mit 64 schrumpften die Zeilen sichtbar, sobald der Schnellzugriff eine
/// feste Höhe erzwang — gegenüber jeder anderen Liste der App.
const songZeilenHoehe = 72.0;
/// Schriftgröße, an der die Skalierung gemessen wird: die des Titels
/// ([MeloTheme] `bodyLarge`).
const _messSchrift = 15.0;
/// Die Zeilenhöhe bei der eingestellten Schriftgröße.
///
/// Der Schnellzugriff springt über `Index × Höhe` an die richtige Stelle und
/// braucht dafür gleich hohe Zeilen. Eine fest verdrahtete Zahl würde bei
/// großer Systemschrift den Text abschneiden — deshalb mitskaliert.
///
/// Gemessen an einer **echten** Schriftgröße und nicht an 1.0: Android
/// skaliert seit Version 14 nichtlinear, kleine Größen wachsen dort stärker
/// als große. Der Faktor für 1.0 wäre viel zu groß und ließe zwischen den
/// Zeilen Löcher.
double songZeilenHoeheFuer(BuildContext context) =>
songZeilenHoehe *
(MediaQuery.textScalerOf(context).scale(_messSchrift) / _messSchrift);
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
class SongList extends StatelessWidget {
const SongList(
this.songs, {
super.key,
this.controller,
this.festeHoehe = false,
this.auswahl,
this.onAuswahlWechsel,
});
final List<Song> songs;
final ScrollController? controller;
/// Alle Zeilen gleich hoch — nötig, wenn ein Schnellzugriff exakt an eine
/// Position springen soll.
final bool festeHoehe;
/// Die gerade ausgewählten Song-IDs. `null` heißt: kein Auswahl-Modus.
/// Alle Listen, die ihn nicht anbieten, lassen beides weg und verhalten
/// sich unverändert.
final Set<String>? auswahl;
final void Function(String songId)? onAuswahlWechsel;
@override
Widget build(BuildContext context) {
final gewaehlt = auswahl;
return ListView.builder(
controller: controller,
itemExtent: festeHoehe ? songZeilenHoeheFuer(context) : null,
itemCount: songs.length,
itemBuilder: (context, i) => EinblendItem(
index: i,
child: SongZeile(
song: songs[i],
warteschlange: songs,
index: i,
auswahlModus: gewaehlt != null && gewaehlt.isNotEmpty,
ausgewaehlt: gewaehlt?.contains(songs[i].id) ?? false,
onAuswahlWechsel: onAuswahlWechsel == null
? null
: () => onAuswahlWechsel!(songs[i].id),
),
),
);
}
}
/// Eine Zeile der Songliste: Cover, Titel, Untertitel, Herz und Menü.
///
/// Eigenständig, weil die Suche einzelne Zeilen in ihre Abschnitte setzt und
/// eine ganze [SongList] dort eine verschachtelte Scroll-Fläche wäre.
///
/// [warteschlange] ist die Liste, die beim Antippen geladen wird — in der
/// Bibliothek die ganze Liste, in der Suche der Titel-Abschnitt.
class SongZeile extends StatelessWidget {
const SongZeile({
super.key,
required this.song,
required this.warteschlange,
required this.index,
this.auswahlModus = false,
this.ausgewaehlt = false,
this.onAuswahlWechsel,
});
final Song song;
final List<Song> warteschlange;
final int index;
/// Im Auswahl-Modus wählt ein Tipp aus, statt abzuspielen.
final bool auswahlModus;
final bool ausgewaehlt;
/// Fehlt sie, bietet die Zeile keine Auswahl an — langes Drücken tut nichts.
final VoidCallback? onAuswahlWechsel;
/// "Künstler | Kategorie1 · Kategorie2" — wie im UI-Entwurf.
static String _subtitle(Song song, List<String> categories) {
final artist = song.artist ?? 'Unbekannt';
if (categories.isEmpty) return artist;
return '$artist | ${categories.join(' · ')}';
}
/// Öffnet ein Bottom-Sheet mit allen Playlisten, um [song] hinzuzufügen.
Future<void> _showAddToPlaylist(BuildContext context) async {
final db = context.read<MeloDb>();
final service = context.read<PlaylistService>();
await showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: StreamBuilder<List<Playlist>>(
stream: db.watchPlaylists(),
builder: (context, snapshot) {
final playlists = snapshot.data ?? const [];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.add),
title: const Text('Neue Playlist'),
onTap: () async {
final newId = await showDialog<String?>(
context: sheetContext,
builder: (_) => const CreatePlaylistDialog(),
);
if (newId == null) return;
final position = (await db.watchPlaylistSongs(newId).first).length;
await service.addSongToPlaylist(newId, song.id, position);
if (sheetContext.mounted) {
Navigator.pop(sheetContext);
ScaffoldMessenger.of(sheetContext).showSnackBar(
const SnackBar(content: Text('Zu Playlist hinzugefügt')),
);
}
},
),
for (final playlist in playlists)
ListTile(
leading: const Icon(Icons.playlist_play),
title: Text(playlist.name),
onTap: () async {
final position =
(await db.watchPlaylistSongs(playlist.id).first).length;
await service.addSongToPlaylist(playlist.id, song.id, position);
if (sheetContext.mounted) {
Navigator.pop(sheetContext);
ScaffoldMessenger.of(sheetContext).showSnackBar(
SnackBar(content: Text('Zu ${playlist.name} hinzugefügt')),
);
}
},
),
],
);
},
),
),
);
}
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
///
/// Die Offline-Meldungen greifen hier heute nie: diese Liste zeigt nur
/// Titel der Bibliothek, und die liegen als Datei vor. Sie stehen trotzdem
/// da, weil die Alternative — den Rückgabewert wegwerfen und "hinzugefügt"
/// melden — still lügen würde, sobald ein Server-Titel hier ankommt.
Future<void> _showMenu(BuildContext context) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
final messenger = ScaffoldMessenger.of(context);
MediaItem item() => songToMediaItem(
song,
cover: categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
await showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Metadaten'),
onTap: () {
Navigator.pop(sheetContext);
SongDetailSheet.show(context, song);
},
),
ListTile(
leading: const Icon(Icons.playlist_play),
title: const Text('Als Nächstes spielen'),
onTap: () async {
Navigator.pop(sheetContext);
final ok = await handler.playNext(item());
messenger.showSnackBar(SnackBar(
content: Text(
ok ? 'Läuft als Nächstes' : nichtOfflineHinweis)));
},
),
ListTile(
leading: const Icon(Icons.queue),
title: const Text('Zur Warteschlange hinzufügen'),
subtitle: const Text(
'Nur für jetzt — nicht in Favoriten oder Wiedergabelisten'),
onTap: () async {
Navigator.pop(sheetContext);
final ok = await handler.addToQueue(item());
messenger.showSnackBar(SnackBar(
content: Text(ok
? 'Zur Warteschlange hinzugefügt'
: nichtOfflineHinweis)));
},
),
ListTile(
leading: const Icon(Icons.playlist_add),
title: const Text('Zu Wiedergabeliste hinzufügen'),
onTap: () {
Navigator.pop(sheetContext);
_showAddToPlaylist(context);
},
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
// Nullable gelesen: ohne laufenden Player (etwa im Widget-Test) bleibt
// die Zeile darstellbar, nur ohne Lauf-Markierung.
final handler = context.read<MeloAudioHandler?>();
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
final cover = categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory);
return StreamBuilder<MediaItem?>(
stream: handler?.mediaItem,
// Der Strom liefert seinen aktuellen Wert erst im nächsten Frame.
// Ohne diesen Startwert erschiene die laufende Zeile beim Zurückscrollen
// einen Frame lang unmarkiert — Balken und rote Schrift ploppen nach.
initialData: handler?.mediaItem.valueOrNull,
builder: (context, laufSnap) {
final markiert = istAmLaufen(laufSnap.data, song.id);
// Nur die laufende Zeile hört auf den Wiedergabe-Zustand: der meldet
// auch den Pufferstand und feuert mehrmals pro Sekunde. Der
// StreamBuilder bleibt trotzdem immer stehen — verschwände er,
// änderte sich die Form des Baums, und das Herz daneben würde sein
// Abo verlieren und kurz leer aussehen.
return StreamBuilder<PlaybackState>(
stream: markiert ? handler?.playbackState : null,
initialData: handler?.playbackState.valueOrNull,
builder: (context, zustandSnap) => _zeile(
context,
categories: categories,
settings: settings,
cover: cover,
markiert: markiert,
spielt: markiert && (zustandSnap.data?.playing ?? false),
),
);
},
);
}
Widget _zeile(
BuildContext context, {
required CategoryService categories,
required AppSettings settings,
required String? cover,
required bool markiert,
required bool spielt,
}) {
return ListTile(
selected: ausgewaehlt,
selectedTileColor: MeloTheme.surfaceHigh,
// Im Auswahl-Modus tritt das Häkchen an die Stelle des Covers: eine
// zusätzliche Spalte hätte die Zeile schmaler gemacht und den Titel
// beim Umschalten springen lassen.
leading: auswahlModus
? _AuswahlZeichen(ausgewaehlt: ausgewaehlt)
: LaufMarkierung(
markiert: markiert,
spielt: spielt,
radius: 6,
child: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
),
title: Text(
song.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: markiert
? const TextStyle(color: MeloTheme.red, fontWeight: FontWeight.w600)
: null,
),
subtitle: Text(
_subtitle(song, categories.of(song.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Herz und Menü gelten je einem Titel — im Auswahl-Modus wäre unklar,
// auf welchen sie sich beziehen.
trailing: auswahlModus
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: song.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context),
),
],
),
onLongPress: onAuswahlWechsel,
onTap: () async {
if (auswahlModus) {
onAuswahlWechsel?.call();
return;
}
FocusScope.of(context).unfocus();
final handler = context.read<MeloAudioHandler>();
final messenger = ScaffoldMessenger.of(context);
try {
final uebersprungen = await playSongs(
handler,
warteschlange,
index,
coverOf: (s) => categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory),
);
if (!context.mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const NowPlayingScreen()),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
},
);
}
}