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.
This commit is contained in:
@@ -5,6 +5,55 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 🧑🤝🧑 UX-Simulation: Mehrfachauswahl, Baka-Login, Bestätigungen, Suche (2026-08-25)
|
||||||
|
|
||||||
|
7 Agenten sind die App wie ein normaler Nutzer durchgegangen (Erststart,
|
||||||
|
Bibliothek, Download, Suche, Favoriten, Player, Einstellungen) und haben 39
|
||||||
|
konkrete Reibungspunkte gefunden — verglichen mit Apple Music/Spotify. Erster
|
||||||
|
Block der Fixe:
|
||||||
|
|
||||||
|
- 🖐️ **Mehrfachauswahl funktionierte nur im Lieder- und Favoriten-Reiter.**
|
||||||
|
Öffnete man eine Kategorie oder das Album eines Künstlers, tat langes
|
||||||
|
Drücken auf einen Titel gar nichts — derselbe Ripple-Effekt, aber ohne
|
||||||
|
Wirkung. Die Auswahl-Leiste und die Sammel-Aktionen ("Zur Warteschlange",
|
||||||
|
"Zu Wiedergabeliste") aus `SortableSongList` sind jetzt in
|
||||||
|
`lib/shared/auswahl_leiste.dart` geteilt und laufen auch in der
|
||||||
|
Album-/Kategorie-Detailansicht (`TitelListenScreen`, jetzt mit Sortierung
|
||||||
|
und Shuffle wie der Lieder-Reiter) und in der Künstlerseite
|
||||||
|
(`KuenstlerScreen`).
|
||||||
|
- 🔁 **Zwei "Favoriten"-Listen mit unterschiedlichem Funktionsumfang.** Der
|
||||||
|
Favoriten-Tab bot Sortieren/Shuffle/Auswahl, der über Playlisten
|
||||||
|
erreichbare zweite Favoriten-Bildschirm zeigte dieselben Songs nur nackt.
|
||||||
|
Beide zeigen jetzt dieselbe `SortableSongList`.
|
||||||
|
- 🔐 **Baka-Login blieb bei falschem Passwort bis zu 15 Sekunden lang
|
||||||
|
stumm.** Der Dialog schloss sich sofort beim Antippen von "Anmelden",
|
||||||
|
noch bevor die Anfrage überhaupt losging; der Fehler kam erst danach in
|
||||||
|
einer unauffälligen grauen Zeile. Jetzt bleibt der Dialog offen, zeigt
|
||||||
|
einen Ladespinner und den Fehler direkt darin — wie beim (bereits guten)
|
||||||
|
Navidrome-Login.
|
||||||
|
- 🗑️ **Cache leeren und Navidrome-Abmelden liefen ohne Rückfrage.** Ein
|
||||||
|
versehentlicher Tipp auf das kleine Papierkorb- bzw. Logout-Icon hat
|
||||||
|
sofort gewirkt. Beide fragen jetzt nach, das einzelne Entfernen eines
|
||||||
|
Downloads ebenso (analog zum bereits vorhandenen "Alle entfernen"-Dialog).
|
||||||
|
- 📡 **Downloads bei toter Serververbindung meldeten "War schon
|
||||||
|
heruntergeladen"** — dieselbe Meldung wie bei tatsächlich schon
|
||||||
|
vorhandenen Titeln. Jetzt erscheint der wirkliche Grund
|
||||||
|
("Nicht mit dem Musikserver verbunden"). YouTube-Fehlermeldungen sind
|
||||||
|
außerdem jetzt rot statt in derselben Neutralfarbe wie Erfolgsmeldungen.
|
||||||
|
- 🔍 **Suche:** Die Tastatur blieb nach Auswahl eines Treffers offen und
|
||||||
|
verdeckte die Liste — schließt jetzt automatisch. "Nichts gefunden" nennt
|
||||||
|
jetzt den gesuchten Begriff. Einzelne Verlaufseinträge lassen sich
|
||||||
|
entfernen, ohne den ganzen Suchverlauf zu löschen.
|
||||||
|
- 🎤 **Zwei irreführende Icons:** "Musik-Berechtigung" in den Einstellungen
|
||||||
|
zeigte ein Mikrofon (fragt aber Speicherzugriff ab, kein Mikrofon);
|
||||||
|
"Musik erkennen" zeigte ein Fragezeichen-Symbol (Hilfe-Icon) statt eines
|
||||||
|
erkennbaren Mikrofon-Symbols.
|
||||||
|
- ✏️ Kleinere Textkorrekturen: der Hinweis "beim Baka-Konto anmelden" verwies
|
||||||
|
auf einen nicht existierenden Tab "Online" (richtig: Download-Tab); die
|
||||||
|
ReplayGain-Beschreibung nannte unerklärten Audio-Fachjargon.
|
||||||
|
- ✅ **Getestet:** 537 Tests grün (vorher 533, 4 neu für `verlaufOhne`),
|
||||||
|
`flutter analyze` ohne Befund.
|
||||||
|
|
||||||
### 🎨 Cover-Farbe, Teilen, Android Auto, geschärfte Typografie (2026-08-24)
|
### 🎨 Cover-Farbe, Teilen, Android Auto, geschärfte Typografie (2026-08-24)
|
||||||
|
|
||||||
Die P3-Liste aus dem Review.
|
Die P3-Liste aus dem Review.
|
||||||
|
|||||||
@@ -50,6 +50,34 @@ class DownloadsListeScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _einzelnEntfernen(BuildContext context, Download eintrag) async {
|
||||||
|
final dienst = context.read<DownloadService>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final ja = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Vom Gerät nehmen?'),
|
||||||
|
content: Text('„${eintrag.titel}" wird vom Gerät genommen. '
|
||||||
|
'Am Server bleibt der Titel unverändert.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Entfernen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ja != true) return;
|
||||||
|
await dienst.entferne(eintrag.navidromeId);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('„${eintrag.titel}" entfernt')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final db = context.read<MeloDb>();
|
final db = context.read<MeloDb>();
|
||||||
@@ -102,9 +130,7 @@ class DownloadsListeScreen extends StatelessWidget {
|
|||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
tooltip: 'Vom Gerät nehmen',
|
tooltip: 'Vom Gerät nehmen',
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
onPressed: () => context
|
onPressed: () => _einzelnEntfernen(context, eintrag),
|
||||||
.read<DownloadService>()
|
|
||||||
.entferne(eintrag.navidromeId),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -464,7 +464,12 @@ class _LadeKnopfState extends State<_LadeKnopf> {
|
|||||||
if (dienst.fehlgeschlagen > 0) {
|
if (dienst.fehlgeschlagen > 0) {
|
||||||
teile.add('${dienst.fehlgeschlagen} fehlgeschlagen');
|
teile.add('${dienst.fehlgeschlagen} fehlgeschlagen');
|
||||||
}
|
}
|
||||||
if (teile.isEmpty) return 'War schon heruntergeladen';
|
if (teile.isEmpty) {
|
||||||
|
// Kein Titel, kein Fehlschlag: entweder war wirklich schon alles da,
|
||||||
|
// oder der Download ist gar nicht erst gestartet (z.B. keine
|
||||||
|
// Verbindung zum Server) — dann steckt der Grund in dienst.fehler.
|
||||||
|
return dienst.fehler ?? 'War schon heruntergeladen';
|
||||||
|
}
|
||||||
return teile.join(' · ');
|
return teile.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,15 +639,10 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _anmelden() async {
|
Future<void> _anmelden() async {
|
||||||
final auth = context.read<BakaAuth>();
|
await showDialog(
|
||||||
final daten = await showDialog<(String, String)>(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => const _AnmeldeDialog(),
|
builder: (_) => const _AnmeldeDialog(),
|
||||||
);
|
);
|
||||||
if (daten == null || !mounted) return;
|
|
||||||
final fehler = await auth.anmelden(daten.$1, daten.$2);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _meldung = fehler);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _herunterladen() async {
|
Future<void> _herunterladen() async {
|
||||||
@@ -785,7 +785,11 @@ class _YouTubeBereichState extends State<_YouTubeBereich> {
|
|||||||
if (_meldung != null) ...[
|
if (_meldung != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_meldung!,
|
Text(_meldung!,
|
||||||
style: const TextStyle(color: MeloTheme.text2, fontSize: 13)),
|
style: TextStyle(
|
||||||
|
color: _meldung!.startsWith('✅')
|
||||||
|
? MeloTheme.text2
|
||||||
|
: MeloTheme.red,
|
||||||
|
fontSize: 13)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -804,6 +808,8 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
|
|||||||
final _benutzer = TextEditingController(text: 'Baka');
|
final _benutzer = TextEditingController(text: 'Baka');
|
||||||
final _passwort = TextEditingController();
|
final _passwort = TextEditingController();
|
||||||
bool _sichtbar = false;
|
bool _sichtbar = false;
|
||||||
|
bool _verbindet = false;
|
||||||
|
String? _fehler;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -812,6 +818,24 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _anmelden() async {
|
||||||
|
setState(() {
|
||||||
|
_verbindet = true;
|
||||||
|
_fehler = null;
|
||||||
|
});
|
||||||
|
final auth = context.read<BakaAuth>();
|
||||||
|
final fehler = await auth.anmelden(_benutzer.text.trim(), _passwort.text);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (fehler == null) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_verbindet = false;
|
||||||
|
_fehler = fehler;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
@@ -821,10 +845,12 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
|
|||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: _benutzer,
|
controller: _benutzer,
|
||||||
|
enabled: !_verbindet,
|
||||||
decoration: const InputDecoration(labelText: 'Benutzername'),
|
decoration: const InputDecoration(labelText: 'Benutzername'),
|
||||||
),
|
),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _passwort,
|
controller: _passwort,
|
||||||
|
enabled: !_verbindet,
|
||||||
obscureText: !_sichtbar,
|
obscureText: !_sichtbar,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Passwort',
|
labelText: 'Passwort',
|
||||||
@@ -834,16 +860,30 @@ class _AnmeldeDialogState extends State<_AnmeldeDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (_fehler != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_fehler!,
|
||||||
|
style: const TextStyle(color: MeloTheme.red, fontSize: 12)),
|
||||||
|
],
|
||||||
|
if (_verbindet)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: MeloTheme.red),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: _verbindet ? null : () => Navigator.pop(context),
|
||||||
child: const Text('Abbrechen'),
|
child: const Text('Abbrechen'),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(
|
onPressed: _verbindet ? null : _anmelden,
|
||||||
context, (_benutzer.text.trim(), _passwort.text)),
|
|
||||||
child: const Text('Anmelden'),
|
child: const Text('Anmelden'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../settings/app_settings.dart';
|
import '../settings/app_settings.dart';
|
||||||
|
import '../shared/auswahl.dart';
|
||||||
|
import '../shared/auswahl_leiste.dart';
|
||||||
import '../shared/cover.dart';
|
import '../shared/cover.dart';
|
||||||
import '../shared/theme.dart';
|
import '../shared/theme.dart';
|
||||||
import '../shared/titel_listen_screen.dart';
|
import '../shared/titel_listen_screen.dart';
|
||||||
@@ -92,45 +94,100 @@ String kuenstlerUntertitel(int titel, int alben) =>
|
|||||||
///
|
///
|
||||||
/// Vorher führte ein Künstler direkt auf eine flache Titelliste — bei einem
|
/// Vorher führte ein Künstler direkt auf eine flache Titelliste — bei einem
|
||||||
/// Künstler mit acht Alben stand alles ungeordnet untereinander.
|
/// Künstler mit acht Alben stand alles ungeordnet untereinander.
|
||||||
class KuenstlerScreen extends StatelessWidget {
|
class KuenstlerScreen extends StatefulWidget {
|
||||||
const KuenstlerScreen({super.key, required this.name, required this.songs});
|
const KuenstlerScreen({super.key, required this.name, required this.songs});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final List<Song> songs;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final alben = groupByAlbum(songs);
|
final alben = groupByAlbum(widget.songs);
|
||||||
// Bei einem einzigen Album wäre die Zwischenstufe nur ein Klick mehr.
|
// Bei einem einzigen Album wäre die Zwischenstufe nur ein Klick mehr.
|
||||||
final zeigeAlben = alben.length > 1;
|
final zeigeAlben = alben.length > 1;
|
||||||
final albumListe = zeigeAlben ? alben.entries.toList() : const [];
|
final albumListe = zeigeAlben ? alben.entries.toList() : const [];
|
||||||
// Kopf „Alben" + Alben + Trenner + Kopf „Alle Titel"
|
// Kopf „Alben" + Alben + Trenner + Kopf „Alle Titel"
|
||||||
final vorspann = zeigeAlben ? albumListe.length + 3 : 0;
|
final vorspann = zeigeAlben ? albumListe.length + 3 : 0;
|
||||||
|
|
||||||
// Bewusst `builder` und nicht `ListView(children: …)`: jede Songzeile
|
return PopScope(
|
||||||
// hört auf den Player und öffnet für ihr Herz eine eigene Abfrage. Bei
|
// Zurück beendet zuerst die Auswahl, wie in SortableSongList — sonst
|
||||||
// einem Künstler mit 300 Titeln wären das 300 Abos beim Öffnen der Seite.
|
// verlöre man die Auswahl mit derselben Geste, die überall sonst
|
||||||
return Scaffold(
|
// „einen Schritt zurück" bedeutet.
|
||||||
appBar: AppBar(title: Text(name)),
|
canPop: !_waehltAus,
|
||||||
body: ListView.builder(
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
itemCount: vorspann + songs.length,
|
if (!didPop && _waehltAus) _beendeAuswahl();
|
||||||
itemBuilder: (context, i) {
|
},
|
||||||
if (zeigeAlben) {
|
child: Scaffold(
|
||||||
if (i == 0) return const _Kopf('Alben');
|
appBar: _waehltAus
|
||||||
if (i <= albumListe.length) {
|
? AppBar(
|
||||||
final eintrag = albumListe[i - 1];
|
automaticallyImplyLeading: false,
|
||||||
return _AlbumZeile(name: eintrag.key, songs: eintrag.value);
|
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');
|
||||||
}
|
}
|
||||||
if (i == albumListe.length + 1) return const Divider(height: 1);
|
final index = i - vorspann;
|
||||||
if (i == albumListe.length + 2) return const _Kopf('Alle Titel');
|
final song = widget.songs[index];
|
||||||
}
|
return SongZeile(
|
||||||
final index = i - vorspann;
|
song: song,
|
||||||
return SongZeile(
|
warteschlange: widget.songs,
|
||||||
song: songs[index],
|
index: index,
|
||||||
warteschlange: songs,
|
auswahlModus: _waehltAus,
|
||||||
index: index,
|
ausgewaehlt: _auswahl.contains(song.id),
|
||||||
);
|
onAuswahlWechsel: () => _wechsleAuswahl(song.id),
|
||||||
},
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ class _Header extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Musik erkennen',
|
tooltip: 'Musik erkennen',
|
||||||
icon: const Icon(Icons.help_outline),
|
icon: const Icon(Icons.mic_none),
|
||||||
onPressed: () => MusicRecognitionSheet.show(context),
|
onPressed: () => MusicRecognitionSheet.show(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
if (mounted) setState(() => _verlauf = const []);
|
if (mounted) setState(() => _verlauf = const []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _verlaufEntferne(String begriff) async {
|
||||||
|
final neu = await SuchVerlauf.entferne(begriff);
|
||||||
|
if (mounted) setState(() => _verlauf = neu);
|
||||||
|
}
|
||||||
|
|
||||||
void _leeren() {
|
void _leeren() {
|
||||||
_entprellen?.cancel();
|
_entprellen?.cancel();
|
||||||
_feld.clear();
|
_feld.clear();
|
||||||
@@ -118,6 +123,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
begriffe: _verlauf,
|
begriffe: _verlauf,
|
||||||
onWaehlen: _uebernimm,
|
onWaehlen: _uebernimm,
|
||||||
onLeeren: _verlaufLeeren,
|
onLeeren: _verlaufLeeren,
|
||||||
|
onEntfernen: _verlaufEntferne,
|
||||||
)
|
)
|
||||||
: StreamBuilder<List<Song>>(
|
: StreamBuilder<List<Song>>(
|
||||||
stream: _songs,
|
stream: _songs,
|
||||||
@@ -138,7 +144,7 @@ class _SearchScreenState extends State<SearchScreen> {
|
|||||||
query: _query,
|
query: _query,
|
||||||
);
|
);
|
||||||
if (treffer.istLeer) {
|
if (treffer.istLeer) {
|
||||||
return const _Hinweis('Nichts gefunden');
|
return _Hinweis('Nichts gefunden für „$_query"');
|
||||||
}
|
}
|
||||||
return _Ergebnisse(
|
return _Ergebnisse(
|
||||||
treffer: treffer,
|
treffer: treffer,
|
||||||
@@ -228,11 +234,13 @@ class _Verlauf extends StatelessWidget {
|
|||||||
required this.begriffe,
|
required this.begriffe,
|
||||||
required this.onWaehlen,
|
required this.onWaehlen,
|
||||||
required this.onLeeren,
|
required this.onLeeren,
|
||||||
|
required this.onEntfernen,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<String> begriffe;
|
final List<String> begriffe;
|
||||||
final ValueChanged<String> onWaehlen;
|
final ValueChanged<String> onWaehlen;
|
||||||
final VoidCallback onLeeren;
|
final VoidCallback onLeeren;
|
||||||
|
final ValueChanged<String> onEntfernen;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -252,6 +260,11 @@ class _Verlauf extends StatelessWidget {
|
|||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.history),
|
leading: const Icon(Icons.history),
|
||||||
title: Text(begriff),
|
title: Text(begriff),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: '„$begriff" aus dem Verlauf entfernen',
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
onPressed: () => onEntfernen(begriff),
|
||||||
|
),
|
||||||
onTap: () => onWaehlen(begriff),
|
onTap: () => onWaehlen(begriff),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -270,6 +283,7 @@ class _Ergebnisse extends StatelessWidget {
|
|||||||
final List<Song> alleSongs;
|
final List<Song> alleSongs;
|
||||||
|
|
||||||
void _oeffne(BuildContext context, String titel, List<Song> songs) {
|
void _oeffne(BuildContext context, String titel, List<Song> songs) {
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
Navigator.of(context).push(MaterialPageRoute(
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
builder: (_) => TitelListenScreen(titel: titel, songs: songs),
|
builder: (_) => TitelListenScreen(titel: titel, songs: songs),
|
||||||
));
|
));
|
||||||
@@ -334,9 +348,12 @@ class _Ergebnisse extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.queue_music),
|
leading: const Icon(Icons.queue_music),
|
||||||
title:
|
title:
|
||||||
Text(liste.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
Text(liste.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
onTap: () {
|
||||||
builder: (_) => PlaylistDetailScreen(playlist: liste),
|
FocusScope.of(context).unfocus();
|
||||||
)),
|
Navigator.of(context).push(MaterialPageRoute(
|
||||||
|
builder: (_) => PlaylistDetailScreen(playlist: liste),
|
||||||
|
));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: MeloSpace.md),
|
const SizedBox(height: MeloSpace.md),
|
||||||
|
|||||||
@@ -367,6 +367,7 @@ class SongZeile extends StatelessWidget {
|
|||||||
onAuswahlWechsel?.call();
|
onAuswahlWechsel?.call();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
final handler = context.read<MeloAudioHandler>();
|
final handler = context.read<MeloAudioHandler>();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ List<String> verlaufMit(List<String> verlauf, String begriff) {
|
|||||||
return ergebnis.take(verlaufMaximum).toList();
|
return ergebnis.take(verlaufMaximum).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [verlauf] ohne [begriff] — zum gezielten Entfernen eines einzelnen
|
||||||
|
/// Eintrags, ohne den ganzen Verlauf zu leeren.
|
||||||
|
List<String> verlaufOhne(List<String> verlauf, String begriff) {
|
||||||
|
return [
|
||||||
|
for (final alt in verlauf)
|
||||||
|
if (alt.toLowerCase() != begriff.trim().toLowerCase()) alt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Merkt sich die letzten Suchbegriffe über einen App-Neustart hinweg.
|
/// Merkt sich die letzten Suchbegriffe über einen App-Neustart hinweg.
|
||||||
/// Aufbau wie [SortStore]: statische Methoden auf SharedPreferences.
|
/// Aufbau wie [SortStore]: statische Methoden auf SharedPreferences.
|
||||||
class SuchVerlauf {
|
class SuchVerlauf {
|
||||||
@@ -42,4 +51,12 @@ class SuchVerlauf {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_key);
|
await prefs.remove(_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Entfernt [begriff] aus dem Verlauf und gibt den neuen Verlauf zurück.
|
||||||
|
static Future<List<String>> entferne(String begriff) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final neu = verlaufOhne(prefs.getStringList(_key) ?? const [], begriff);
|
||||||
|
await prefs.setStringList(_key, neu);
|
||||||
|
return neu;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import '../library/database.dart';
|
import '../library/database.dart';
|
||||||
import '../library/playlist_service.dart';
|
import '../library/playlist_service.dart';
|
||||||
import '../library/song_list.dart';
|
import '../shared/sort_store.dart';
|
||||||
|
import '../shared/sortable_song_list.dart';
|
||||||
import '../shared/theme.dart';
|
import '../shared/theme.dart';
|
||||||
import 'create_playlist_dialog.dart';
|
import 'create_playlist_dialog.dart';
|
||||||
import 'playlist_detail_screen.dart';
|
import 'playlist_detail_screen.dart';
|
||||||
@@ -146,6 +147,10 @@ class _Empty extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Gepushte Ansicht der favorisierten Songs.
|
/// Gepushte Ansicht der favorisierten Songs.
|
||||||
|
///
|
||||||
|
/// Dieselbe [SortableSongList] wie im Favoriten-Tab (Shuffle, Sortieren,
|
||||||
|
/// Mehrfachauswahl) — vorher stand hier eine nackte [SongList] ohne all das,
|
||||||
|
/// obwohl es dieselben Songs sind wie im Tab.
|
||||||
class FavoritesListScreen extends StatelessWidget {
|
class FavoritesListScreen extends StatelessWidget {
|
||||||
const FavoritesListScreen({super.key});
|
const FavoritesListScreen({super.key});
|
||||||
|
|
||||||
@@ -157,13 +162,15 @@ class FavoritesListScreen extends StatelessWidget {
|
|||||||
body: StreamBuilder<List<Song>>(
|
body: StreamBuilder<List<Song>>(
|
||||||
stream: db.watchFavorites(),
|
stream: db.watchFavorites(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final songs = snapshot.data ?? const [];
|
final songs = snapshot.data ?? const <Song>[];
|
||||||
if (songs.isEmpty) {
|
return SortableSongList(
|
||||||
return const Center(
|
songs: songs,
|
||||||
child: Text('Noch keine Favoriten', style: TextStyle(color: MeloTheme.text2)),
|
storeKey: SortStore.favoriten,
|
||||||
);
|
empty: const Center(
|
||||||
}
|
child: Text('Noch keine Favoriten',
|
||||||
return SongList(songs);
|
style: TextStyle(color: MeloTheme.text2)),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -133,7 +133,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
secondary: const Icon(Icons.equalizer),
|
secondary: const Icon(Icons.equalizer),
|
||||||
title: const Text('Gleiche Lautstärke'),
|
title: const Text('Gleiche Lautstärke'),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
'Senkt laute Titel ab (nur bei Dateien mit ReplayGain-Tag)'),
|
'Senkt laute Titel automatisch ab (wirkt nur bei Titeln '
|
||||||
|
'mit Lautstärke-Metadaten)'),
|
||||||
value: settings.normalizeVolume,
|
value: settings.normalizeVolume,
|
||||||
onChanged: settings.setNormalizeVolume,
|
onChanged: settings.setNormalizeVolume,
|
||||||
),
|
),
|
||||||
@@ -281,7 +282,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
const _SectionLabel('Berechtigungen'),
|
const _SectionLabel('Berechtigungen'),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.mic_none),
|
leading: const Icon(Icons.perm_media_outlined),
|
||||||
title: const Text('Musik-Berechtigung'),
|
title: const Text('Musik-Berechtigung'),
|
||||||
trailing: TextButton(
|
trailing: TextButton(
|
||||||
onPressed: openMusicPermissionSettings,
|
onPressed: openMusicPermissionSettings,
|
||||||
@@ -457,11 +458,51 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _trennNavidrome() async {
|
Future<void> _trennNavidrome() async {
|
||||||
|
final ja = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Von Navidrome abmelden?'),
|
||||||
|
content: const Text(
|
||||||
|
'Die gespeicherten Zugangsdaten werden gelöscht. Für die '
|
||||||
|
'erneute Anmeldung brauchst du Server-Adresse, Benutzername '
|
||||||
|
'und Passwort wieder.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Abmelden'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ja != true) return;
|
||||||
await _navidrome.loescheZugangsdaten();
|
await _navidrome.loescheZugangsdaten();
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _clearCache() async {
|
Future<void> _clearCache() async {
|
||||||
|
final ja = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Zwischenspeicher leeren?'),
|
||||||
|
content: Text('${_formatBytes(_cacheSize)} werden gelöscht. '
|
||||||
|
'Beim nächsten Abspielen lädt Melo diese Titel erneut.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Abbrechen'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Leeren'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ja != true) return;
|
||||||
await _cache.clearCache();
|
await _cache.clearCache();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _cacheSize = 0);
|
setState(() => _cacheSize = 0);
|
||||||
@@ -558,7 +599,7 @@ class _CloudSyncBereich extends StatelessWidget {
|
|||||||
leading: Icon(Icons.cloud_off),
|
leading: Icon(Icons.cloud_off),
|
||||||
title: Text('Nicht angemeldet'),
|
title: Text('Nicht angemeldet'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
'Für den Abgleich im Tab „Online“ beim Baka-Konto anmelden'),
|
'Für den Abgleich im Download-Tab beim Baka-Konto anmelden'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../library/database.dart';
|
||||||
|
import '../library/category_service.dart';
|
||||||
|
import '../library/playlist_service.dart';
|
||||||
|
import '../library/song_media.dart';
|
||||||
|
import '../player/audio_handler.dart';
|
||||||
|
import '../playlists/create_playlist_dialog.dart';
|
||||||
|
import '../settings/app_settings.dart';
|
||||||
|
import 'auswahl.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
/// Kopfzeile im Auswahl-Modus: Anzahl und die beiden Stapel-Aktionen.
|
||||||
|
///
|
||||||
|
/// Gemeinsam für alle Songlisten, die Mehrfachauswahl anbieten — sie ersetzt
|
||||||
|
/// die sonst dort stehende Zeile, statt darunter zu erscheinen.
|
||||||
|
class AuswahlLeiste extends StatelessWidget {
|
||||||
|
const AuswahlLeiste({
|
||||||
|
super.key,
|
||||||
|
required this.anzahl,
|
||||||
|
required this.onAbbrechen,
|
||||||
|
required this.onWiedergabeliste,
|
||||||
|
required this.onWarteschlange,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int anzahl;
|
||||||
|
final VoidCallback onAbbrechen;
|
||||||
|
final VoidCallback onWiedergabeliste;
|
||||||
|
final VoidCallback onWarteschlange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
MeloSpace.sm, MeloSpace.xs, MeloSpace.sm, MeloSpace.xs),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Auswahl beenden',
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: onAbbrechen,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
auswahlText(anzahl),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Zur Warteschlange hinzufügen',
|
||||||
|
icon: const Icon(Icons.queue),
|
||||||
|
onPressed: onWarteschlange,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Zu Wiedergabeliste hinzufügen',
|
||||||
|
icon: const Icon(Icons.playlist_add),
|
||||||
|
onPressed: onWiedergabeliste,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt [gewaehlte] in eine (neue oder bestehende) Wiedergabeliste, mit
|
||||||
|
/// Rückmeldung über Neues/schon-Vorhandenes/Fehler.
|
||||||
|
Future<void> fuegeZuWiedergabelisteHinzu(
|
||||||
|
BuildContext context, List<Song> gewaehlte) async {
|
||||||
|
final db = context.read<MeloDb>();
|
||||||
|
final dienst = context.read<PlaylistService>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final ziel = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: StreamBuilder<List<Playlist>>(
|
||||||
|
stream: db.watchPlaylists(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final listen = snapshot.data ?? const <Playlist>[];
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.add),
|
||||||
|
title: const Text('Neue Wiedergabeliste'),
|
||||||
|
onTap: () async {
|
||||||
|
final neu = await showDialog<String?>(
|
||||||
|
context: sheetContext,
|
||||||
|
builder: (_) => const CreatePlaylistDialog(),
|
||||||
|
);
|
||||||
|
if (neu != null && sheetContext.mounted) {
|
||||||
|
Navigator.pop(sheetContext, neu);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
for (final liste in listen)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.playlist_play),
|
||||||
|
title: Text(liste.name),
|
||||||
|
onTap: () => Navigator.pop(sheetContext, liste.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ziel == null) return;
|
||||||
|
final vorhanden = await db.watchPlaylistSongs(ziel).first;
|
||||||
|
// Wer schon drin ist, bleibt, wo er ist. Ein erneutes Einfügen würde ihn
|
||||||
|
// wegen des Primärschlüssels nur verschieben und Lücken hinterlassen.
|
||||||
|
final bekannt = {for (final s in vorhanden) s.id};
|
||||||
|
var position = vorhanden.length;
|
||||||
|
var neu = 0;
|
||||||
|
String? fehler;
|
||||||
|
for (final song in gewaehlte) {
|
||||||
|
if (bekannt.contains(song.id)) continue;
|
||||||
|
try {
|
||||||
|
await dienst.addSongToPlaylist(ziel, song.id, position++);
|
||||||
|
neu++;
|
||||||
|
} catch (e) {
|
||||||
|
fehler = '$e';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(
|
||||||
|
content: Text(playlistMeldung(
|
||||||
|
neu: neu,
|
||||||
|
schonDa: gewaehlte.length - neu,
|
||||||
|
fehler: fehler,
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hängt [gewaehlte] ans Ende der Warteschlange, mit Rückmeldung, falls
|
||||||
|
/// nicht alle Titel angehängt werden konnten (z. B. offline).
|
||||||
|
Future<void> fuegeZuWarteschlangeHinzu(
|
||||||
|
BuildContext context, List<Song> gewaehlte) async {
|
||||||
|
final handler = context.read<MeloAudioHandler>();
|
||||||
|
final categories = context.read<CategoryService>();
|
||||||
|
final settings = context.read<AppSettings>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
var angehaengt = 0;
|
||||||
|
for (final song in gewaehlte) {
|
||||||
|
final ok = await handler.addToQueue(songToMediaItem(
|
||||||
|
song,
|
||||||
|
cover: categories.coverFor(song,
|
||||||
|
groupByCategory: settings.groupCoversByCategory),
|
||||||
|
));
|
||||||
|
if (ok) angehaengt++;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(
|
||||||
|
content: Text(angehaengt == gewaehlte.length
|
||||||
|
? '$angehaengt Titel in der Warteschlange'
|
||||||
|
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../library/category_service.dart';
|
||||||
import '../library/database.dart';
|
import '../library/database.dart';
|
||||||
import '../library/song_list.dart';
|
import '../library/song_list.dart';
|
||||||
import '../library/song_media.dart';
|
import '../library/song_media.dart';
|
||||||
import '../library/category_service.dart';
|
|
||||||
import '../library/playlist_service.dart';
|
|
||||||
import '../library/song_sort.dart';
|
import '../library/song_sort.dart';
|
||||||
import '../player/audio_handler.dart';
|
import '../player/audio_handler.dart';
|
||||||
import '../settings/app_settings.dart';
|
import '../settings/app_settings.dart';
|
||||||
import '../playlists/create_playlist_dialog.dart';
|
|
||||||
import 'alphabet_leiste.dart';
|
import 'alphabet_leiste.dart';
|
||||||
import 'auswahl.dart';
|
import 'auswahl.dart';
|
||||||
|
import 'auswahl_leiste.dart';
|
||||||
import 'sort_store.dart';
|
import 'sort_store.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
@@ -59,95 +58,13 @@ class _SortableSongListState extends State<SortableSongList> {
|
|||||||
[for (final s in sorted) if (_auswahl.contains(s.id)) s];
|
[for (final s in sorted) if (_auswahl.contains(s.id)) s];
|
||||||
|
|
||||||
Future<void> _inWiedergabeliste(List<Song> gewaehlte) async {
|
Future<void> _inWiedergabeliste(List<Song> gewaehlte) async {
|
||||||
final db = context.read<MeloDb>();
|
await fuegeZuWiedergabelisteHinzu(context, gewaehlte);
|
||||||
final dienst = context.read<PlaylistService>();
|
if (mounted) _beendeAuswahl();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
final ziel = await showModalBottomSheet<String>(
|
|
||||||
context: context,
|
|
||||||
builder: (sheetContext) => SafeArea(
|
|
||||||
child: StreamBuilder<List<Playlist>>(
|
|
||||||
stream: db.watchPlaylists(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final listen = snapshot.data ?? const <Playlist>[];
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.add),
|
|
||||||
title: const Text('Neue Wiedergabeliste'),
|
|
||||||
onTap: () async {
|
|
||||||
final neu = await showDialog<String?>(
|
|
||||||
context: sheetContext,
|
|
||||||
builder: (_) => const CreatePlaylistDialog(),
|
|
||||||
);
|
|
||||||
if (neu != null && sheetContext.mounted) {
|
|
||||||
Navigator.pop(sheetContext, neu);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
for (final liste in listen)
|
|
||||||
ListTile(
|
|
||||||
leading: const Icon(Icons.playlist_play),
|
|
||||||
title: Text(liste.name),
|
|
||||||
onTap: () => Navigator.pop(sheetContext, liste.id),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (ziel == null) return;
|
|
||||||
final vorhanden = await db.watchPlaylistSongs(ziel).first;
|
|
||||||
// Wer schon drin ist, bleibt, wo er ist. Ein erneutes Einfügen würde ihn
|
|
||||||
// wegen des Primärschlüssels nur verschieben und Lücken hinterlassen.
|
|
||||||
final bekannt = {for (final s in vorhanden) s.id};
|
|
||||||
var position = vorhanden.length;
|
|
||||||
var neu = 0;
|
|
||||||
String? fehler;
|
|
||||||
for (final song in gewaehlte) {
|
|
||||||
if (bekannt.contains(song.id)) continue;
|
|
||||||
try {
|
|
||||||
await dienst.addSongToPlaylist(ziel, song.id, position++);
|
|
||||||
neu++;
|
|
||||||
} catch (e) {
|
|
||||||
fehler = '$e';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
_beendeAuswahl();
|
|
||||||
messenger.showSnackBar(SnackBar(
|
|
||||||
content: Text(playlistMeldung(
|
|
||||||
neu: neu,
|
|
||||||
schonDa: gewaehlte.length - neu,
|
|
||||||
fehler: fehler,
|
|
||||||
)),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _inWarteschlange(List<Song> gewaehlte) async {
|
Future<void> _inWarteschlange(List<Song> gewaehlte) async {
|
||||||
final handler = context.read<MeloAudioHandler>();
|
await fuegeZuWarteschlangeHinzu(context, gewaehlte);
|
||||||
final categories = context.read<CategoryService>();
|
if (mounted) _beendeAuswahl();
|
||||||
final settings = context.read<AppSettings>();
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
var angehaengt = 0;
|
|
||||||
for (final song in gewaehlte) {
|
|
||||||
final ok = await handler.addToQueue(songToMediaItem(
|
|
||||||
song,
|
|
||||||
cover: categories.coverFor(song,
|
|
||||||
groupByCategory: settings.groupCoversByCategory),
|
|
||||||
));
|
|
||||||
if (ok) angehaengt++;
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
_beendeAuswahl();
|
|
||||||
messenger.showSnackBar(SnackBar(
|
|
||||||
content: Text(angehaengt == gewaehlte.length
|
|
||||||
? '$angehaengt Titel in der Warteschlange'
|
|
||||||
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -274,7 +191,7 @@ class _SortableSongListState extends State<SortableSongList> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (_waehltAus)
|
if (_waehltAus)
|
||||||
_AuswahlLeiste(
|
AuswahlLeiste(
|
||||||
anzahl: _auswahl.length,
|
anzahl: _auswahl.length,
|
||||||
onAbbrechen: _beendeAuswahl,
|
onAbbrechen: _beendeAuswahl,
|
||||||
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
|
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
|
||||||
@@ -338,59 +255,6 @@ class _SortableSongListState extends State<SortableSongList> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kopfzeile im Auswahl-Modus: Anzahl und die beiden Stapel-Aktionen.
|
|
||||||
///
|
|
||||||
/// Sie ersetzt die Shuffle-Zeile, statt darunter zu erscheinen — sonst
|
|
||||||
/// rutschte die Liste beim Auswählen nach unten weg.
|
|
||||||
class _AuswahlLeiste extends StatelessWidget {
|
|
||||||
const _AuswahlLeiste({
|
|
||||||
required this.anzahl,
|
|
||||||
required this.onAbbrechen,
|
|
||||||
required this.onWiedergabeliste,
|
|
||||||
required this.onWarteschlange,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int anzahl;
|
|
||||||
final VoidCallback onAbbrechen;
|
|
||||||
final VoidCallback onWiedergabeliste;
|
|
||||||
final VoidCallback onWarteschlange;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
MeloSpace.sm, MeloSpace.xs, MeloSpace.sm, MeloSpace.xs),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Auswahl beenden',
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
onPressed: onAbbrechen,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
auswahlText(anzahl),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Zur Warteschlange hinzufügen',
|
|
||||||
icon: const Icon(Icons.queue),
|
|
||||||
onPressed: onWarteschlange,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Zu Wiedergabeliste hinzufügen',
|
|
||||||
icon: const Icon(Icons.playlist_add),
|
|
||||||
onPressed: onWiedergabeliste,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bottom-Sheet zur Wahl von Sortier-Kriterium und Richtung.
|
/// Bottom-Sheet zur Wahl von Sortier-Kriterium und Richtung.
|
||||||
class _SortSheet extends StatefulWidget {
|
class _SortSheet extends StatefulWidget {
|
||||||
const _SortSheet({required this.current});
|
const _SortSheet({required this.current});
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../library/database.dart';
|
import '../library/database.dart';
|
||||||
import '../library/song_list.dart';
|
import 'sortable_song_list.dart';
|
||||||
|
|
||||||
/// Vollbild-Liste einer festen Titelmenge unter einer Überschrift — die
|
/// Vollbild-Liste einer festen Titelmenge unter einer Überschrift — die
|
||||||
/// Ansicht hinter einem Künstler, einer Kategorie oder einem Suchabschnitt.
|
/// Ansicht hinter einem Künstler, einer Kategorie oder einem Suchabschnitt.
|
||||||
///
|
///
|
||||||
/// Vorher stand dieselbe Klasse zweimal privat im Baum (`_ArtistSongsScreen`,
|
/// Vorher stand dieselbe Klasse zweimal privat im Baum (`_ArtistSongsScreen`,
|
||||||
/// `_KategorieLiederScreen`); die Suche hätte eine dritte Kopie gebraucht.
|
/// `_KategorieLiederScreen`); die Suche hätte eine dritte Kopie gebraucht.
|
||||||
|
///
|
||||||
|
/// [SortableSongList] statt der nackten [SongList]: sonst bot diese Ansicht
|
||||||
|
/// weder Sortieren noch Mehrfachauswahl an, obwohl dieselbe Songzeile im
|
||||||
|
/// Lieder-Reiter beides kann — für den Nutzer wirkte langes Drücken hier wie
|
||||||
|
/// ein stiller Fehlschlag.
|
||||||
class TitelListenScreen extends StatelessWidget {
|
class TitelListenScreen extends StatelessWidget {
|
||||||
const TitelListenScreen({
|
const TitelListenScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -22,7 +27,7 @@ class TitelListenScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(titel)),
|
appBar: AppBar(title: Text(titel)),
|
||||||
body: SongList(songs),
|
body: SortableSongList(songs: songs, storeKey: 'titel_liste'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,11 +127,12 @@ void main() {
|
|||||||
await aufraeumen(tester);
|
await aufraeumen(tester);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('ohne Treffer erscheint "Nichts gefunden"', (tester) async {
|
testWidgets('ohne Treffer erscheint "Nichts gefunden" mit dem Suchbegriff',
|
||||||
|
(tester) async {
|
||||||
await pumpe(tester);
|
await pumpe(tester);
|
||||||
await tippe(tester, 'jazzfrühstück');
|
await tippe(tester, 'jazzfrühstück');
|
||||||
|
|
||||||
expect(find.text('Nichts gefunden'), findsOneWidget);
|
expect(find.text('Nichts gefunden für „jazzfrühstück"'), findsOneWidget);
|
||||||
|
|
||||||
await aufraeumen(tester);
|
await aufraeumen(tester);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,4 +44,27 @@ void main() {
|
|||||||
expect(vorher, ['rock']);
|
expect(vorher, ['rock']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('verlaufOhne', () {
|
||||||
|
test('entfernt genau den gesuchten Begriff', () {
|
||||||
|
expect(verlaufOhne(['trap', 'neoni', 'rock'], 'neoni'),
|
||||||
|
['trap', 'rock']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Groß- und Kleinschreibung gilt als derselbe Begriff', () {
|
||||||
|
expect(verlaufOhne(['Rock', 'trap'], 'rock'), ['trap']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein nicht enthaltener Begriff ändert nichts', () {
|
||||||
|
expect(verlaufOhne(['rock'], 'trap'), ['rock']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lässt die Ausgangsliste unverändert', () {
|
||||||
|
final vorher = ['rock', 'trap'];
|
||||||
|
|
||||||
|
verlaufOhne(vorher, 'rock');
|
||||||
|
|
||||||
|
expect(vorher, ['rock', 'trap']);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user