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.
237 lines
8.2 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|