Files
Melo/lib/library/artist_list.dart
Hermes (Server) 07acf77ba4 UX-Simulation: Mehrfachauswahl überall, Baka-Login, Bestätigungen, Suche
7 Agenten sind die App als normaler Nutzer durchgegangen (Erststart,
Bibliothek, Download, Suche, Favoriten, Player, Einstellungen) und haben
39 Reibungspunkte gefunden. Erster Block:

- Mehrfachauswahl lief nur im Lieder-/Favoriten-Reiter, nicht in
  Kategorie-/Künstler-/Album-Ansichten — jetzt überall, per geteilter
  auswahl_leiste.dart
- Zwei unterschiedlich ausgestattete Favoriten-Listen (Tab vs. Playlisten)
  vereinheitlicht
- Baka-Login zeigt Ladezustand + Fehler im Dialog statt bis zu 15s stumm
  zu bleiben (wie der Navidrome-Login)
- Cache leeren, Navidrome-Abmelden, Download einzeln entfernen fragen jetzt
  nach
- Download-Fehlermeldung bei toter Serververbindung nennt den echten Grund
  statt "War schon heruntergeladen"
- YouTube-Fehlermeldungen sind jetzt rot statt neutralfarben
- Suche schließt die Tastatur nach Auswahl, nennt den Suchbegriff bei
  "Nichts gefunden", einzelne Verlaufseinträge sind entfernbar
- Icon-/Text-Korrekturen (Mikrofon-Icon fälschlich bei Speicherzugriff,
  "Online"-Tab existiert nicht, ReplayGain-Jargon)

537 Tests grün (vorher 533), flutter analyze ohne Befund.
2026-08-25 09:42:41 +02:00

237 lines
8.2 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../settings/app_settings.dart';
import '../shared/auswahl.dart';
import '../shared/auswahl_leiste.dart';
import '../shared/cover.dart';
import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_grouping.dart';
import 'song_list.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
class ArtistListScreen extends StatefulWidget {
const ArtistListScreen({super.key});
@override
State<ArtistListScreen> createState() => _ArtistListScreenState();
}
class _ArtistListScreenState extends State<ArtistListScreen> {
// Einmal anlegen, nicht in jedem build: die Zeilen hängen an
// CategoryService, und ein neuer Strom je Meldung würde die Abfrage
// während eines Scans laufend neu ausführen.
late final Stream<List<Song>> _songs = context.read<MeloDb>().watchSongs();
@override
Widget build(BuildContext context) {
return StreamBuilder<List<Song>>(
stream: _songs,
builder: (context, snapshot) {
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Keine Künstler',
style: TextStyle(color: MeloTheme.text2)),
);
}
final grouped = groupByArtist(songs);
final artists = grouped.keys.toList();
return ListView.builder(
itemCount: artists.length,
itemBuilder: (context, i) {
final artist = artists[i];
return _KuenstlerZeile(name: artist, songs: grouped[artist]!);
},
);
},
);
}
}
/// Eine Künstlerzeile. Eigenes Widget, damit nur sie an [CategoryService]
/// hängt und nicht der Bildschirm mit seinem Datenbank-Strom.
class _KuenstlerZeile extends StatelessWidget {
const _KuenstlerZeile({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
final cover = kategorien.coverFor(songs.first,
groupByCategory: einstellungen.groupCoversByCategory);
return ListTile(
// Ein Bild statt einer leeren Fläche: die Liste war reiner Text und
// dadurch schwer zu überfliegen.
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle:
Text(kuenstlerUntertitel(songs.length, groupByAlbum(songs).length)),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => KuenstlerScreen(name: name, songs: songs),
)),
);
}
}
/// "12 Titel · 3 Alben" — die Albenzahl nur, wenn es mehr als eines ist.
/// „Titel" hat im Deutschen keine eigene Mehrzahl, deshalb ohne Fallunterscheidung.
String kuenstlerUntertitel(int titel, int alben) =>
alben > 1 ? '$titel Titel · $alben Alben' : '$titel Titel';
/// Die Seite eines Künstlers: seine Alben, darunter alle Titel.
///
/// Vorher führte ein Künstler direkt auf eine flache Titelliste — bei einem
/// Künstler mit acht Alben stand alles ungeordnet untereinander.
class KuenstlerScreen extends StatefulWidget {
const KuenstlerScreen({super.key, required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
State<KuenstlerScreen> createState() => _KuenstlerScreenState();
}
class _KuenstlerScreenState extends State<KuenstlerScreen> {
/// Song-IDs der gerade ausgewählten Titel in „Alle Titel" — leer heißt:
/// kein Auswahl-Modus. Wie in SortableSongList, damit langes Drücken auf
/// einer Songzeile überall in der App dasselbe tut.
Set<String> _auswahl = const {};
bool get _waehltAus => _auswahl.isNotEmpty;
void _wechsleAuswahl(String songId) =>
setState(() => _auswahl = auswahlNach(_auswahl, songId));
void _beendeAuswahl() => setState(() => _auswahl = const {});
List<Song> get _gewaehlteSongs =>
[for (final s in widget.songs) if (_auswahl.contains(s.id)) s];
Future<void> _inWiedergabeliste() async {
await fuegeZuWiedergabelisteHinzu(context, _gewaehlteSongs);
if (mounted) _beendeAuswahl();
}
Future<void> _inWarteschlange() async {
await fuegeZuWarteschlangeHinzu(context, _gewaehlteSongs);
if (mounted) _beendeAuswahl();
}
@override
Widget build(BuildContext context) {
final alben = groupByAlbum(widget.songs);
// Bei einem einzigen Album wäre die Zwischenstufe nur ein Klick mehr.
final zeigeAlben = alben.length > 1;
final albumListe = zeigeAlben ? alben.entries.toList() : const [];
// Kopf „Alben" + Alben + Trenner + Kopf „Alle Titel"
final vorspann = zeigeAlben ? albumListe.length + 3 : 0;
return PopScope(
// Zurück beendet zuerst die Auswahl, wie in SortableSongList — sonst
// verlöre man die Auswahl mit derselben Geste, die überall sonst
// „einen Schritt zurück" bedeutet.
canPop: !_waehltAus,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _waehltAus) _beendeAuswahl();
},
child: Scaffold(
appBar: _waehltAus
? AppBar(
automaticallyImplyLeading: false,
titleSpacing: 0,
title: AuswahlLeiste(
anzahl: _auswahl.length,
onAbbrechen: _beendeAuswahl,
onWiedergabeliste: _inWiedergabeliste,
onWarteschlange: _inWarteschlange,
),
)
: AppBar(title: Text(widget.name)),
// Bewusst `builder` und nicht `ListView(children: …)`: jede Songzeile
// hört auf den Player und öffnet für ihr Herz eine eigene Abfrage.
// Bei einem Künstler mit 300 Titeln wären das 300 Abos beim Öffnen
// der Seite.
body: ListView.builder(
itemCount: vorspann + widget.songs.length,
itemBuilder: (context, i) {
if (zeigeAlben) {
if (i == 0) return const _Kopf('Alben');
if (i <= albumListe.length) {
final eintrag = albumListe[i - 1];
return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
}
if (i == albumListe.length + 1) return const Divider(height: 1);
if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
}
final index = i - vorspann;
final song = widget.songs[index];
return SongZeile(
song: song,
warteschlange: widget.songs,
index: index,
auswahlModus: _waehltAus,
ausgewaehlt: _auswahl.contains(song.id),
onAuswahlWechsel: () => _wechsleAuswahl(song.id),
);
},
),
),
);
}
}
class _AlbumZeile extends StatelessWidget {
const _AlbumZeile({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
final kategorien = context.watch<CategoryService>();
final einstellungen = context.watch<AppSettings>();
final cover = kategorien.coverFor(songs.first,
groupByCategory: einstellungen.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${songs.length} Titel'),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TitelListenScreen(titel: name, songs: songs),
)),
);
}
}
class _Kopf extends StatelessWidget {
const _Kopf(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.md, MeloSpace.md, MeloSpace.xs),
child: Text(text.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge),
);
}
}