Files
Melo/lib/library/song_list.dart
T
Hermes (Server)andClaude Opus 5 490e73ce41 Politur: Wischen, wandernde Cover, Alphabet-Sprung, Mehrfachauswahl
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
2026-08-24 15:09:46 +02:00

394 lines
14 KiB
Dart
Raw 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 '../playlists/create_playlist_dialog.dart';
import '../settings/app_settings.dart';
import '../shared/cover.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) => 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;
}
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 (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
},
);
}
}