Suche trennt nach Titel, Kuenstler, Kategorien und Wiedergabelisten

Der Suchen-Tab war 59 Zeilen: ein Textfeld ueber einer flachen
Liederliste, nur ueber Titel/Kuenstler/Album. Wer "Nightcore" eingab,
fand alle Titel dieses Kuenstlers untereinander, aber weder den
Kuenstler selbst noch eine gleichnamige Kategorie oder Liste.

- Vier Abschnitte mit Ueberschrift und Trefferzahl. Alben stecken in
  "Kategorien" — der Album-Name ist in Melo die erste Kategorie eines
  Titels, ein eigener Abschnitt waere dieselbe Liste zweimal.
- Treffer am Wortanfang zuerst, dann alphabetisch.
- Entprellt (250 ms); vorher baute jeder Tastendruck alles neu auf.
- Letzte acht Suchen, gemerkt beim Abschicken, mit Loeschen-Knopf.
- Der Tab folgt jetzt dem Aufbau der uebrigen Tabs (war ein Scaffold
  mit eigener AppBar innerhalb eines Tabs).
- Bewusst rein lokal, ohne Server-Abfrage.

Herausgeloest, weil es sonst eine dritte Kopie gegeben haette:
TitelListenScreen ("Ueberschrift + Liederliste", stand zweimal wortgleich
im Baum) und SongZeile (die Suche setzt einzelne Zeilen in ihre
Abschnitte; eine ganze SongList waere dort verschachteltes Scrollen).

Aus dem Code-Review nachgebessert: Suche und Zuordnung benutzen dieselbe
Normalform, sonst zeigte ein Treffer "0 Titel" und oeffnete eine leere
Liste; Kategorien ohne lebende Titel fallen weg; alle Abschnitte sind
gedeckelt; die Drift-Stroeme werden nicht mehr je Rebuild neu abonniert;
das Loeschkreuz erscheint sofort statt nach 250 ms.

Tot geworden und entfernt: MeloDb.searchSongs samt Test.

396 Tests gruen (vorher 360), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
Hermes (Server)
2026-08-23 20:08:40 +02:00
co-authored by Claude Opus 5
parent d15867d962
commit ca28264c0e
14 changed files with 1247 additions and 182 deletions
+3 -16
View File
@@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
import 'database.dart';
import 'song_grouping.dart';
import 'song_list.dart';
import '../shared/titel_listen_screen.dart';
import '../shared/theme.dart';
/// Bibliotheks-Tab: Künstler-Übersicht, gruppiert aus [MeloDb.watchSongs].
@@ -35,7 +35,8 @@ class ArtistListScreen extends StatelessWidget {
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _ArtistSongsScreen(artist: artist, songs: artistSongs),
builder: (_) =>
TitelListenScreen(titel: artist, songs: artistSongs),
),
),
);
@@ -46,17 +47,3 @@ class ArtistListScreen extends StatelessWidget {
}
}
/// Zeigt alle Songs eines Künstlers.
class _ArtistSongsScreen extends StatelessWidget {
const _ArtistSongsScreen({required this.artist, required this.songs});
final String artist;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(artist)),
body: SongList(songs),
);
}
}
+2 -17
View File
@@ -6,7 +6,7 @@ import '../shared/cover.dart';
import 'categories.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/titel_listen_screen.dart';
import '../shared/theme.dart';
/// Bibliotheks-Reiter „Kategorie": alle Kategorien der Bibliothek.
@@ -57,7 +57,7 @@ class CategoryListScreen extends StatelessWidget {
context,
MaterialPageRoute(
builder: (_) =>
_KategorieLiederScreen(name: name, songs: lieder),
TitelListenScreen(titel: name, songs: lieder),
),
),
);
@@ -67,18 +67,3 @@ class CategoryListScreen extends StatelessWidget {
);
}
}
/// Zeigt alle Lieder einer Kategorie.
class _KategorieLiederScreen extends StatelessWidget {
const _KategorieLiederScreen({required this.name, required this.songs});
final String name;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(name)),
body: SongList(songs),
);
}
}
-17
View File
@@ -191,23 +191,6 @@ class MeloDb extends _$MeloDb {
.watch();
}
/// Sucht Songs nach Titel, Künstler oder Album. Wildcards werden escaped.
Stream<List<Song>> searchSongs(String query) {
final escaped = query
.toLowerCase()
.replaceAll('\\', '\\\\')
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
final like = '%$escaped%';
return (select(songs)
..where((s) =>
s.deleted.equals(false) &
(s.title.lower().like(like, escapeChar: '\\') |
s.artist.lower().like(like, escapeChar: '\\') |
s.album.lower().like(like, escapeChar: '\\')))
..orderBy([(s) => OrderingTerm(expression: s.title)]))
.watch();
}
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get();
+401 -37
View File
@@ -1,10 +1,30 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../playlists/playlist_detail_screen.dart';
import '../shared/theme.dart';
import '../shared/titel_listen_screen.dart';
import 'category_service.dart';
import 'database.dart';
import 'song_list.dart';
import '../shared/theme.dart';
import 'suche.dart';
import 'such_verlauf.dart';
/// Wie lange nach dem letzten Tastendruck gewartet wird, bevor gesucht wird.
/// Ohne diese Pause baut jeder Buchstabe die ganze Trefferliste neu auf.
const _entprellung = Duration(milliseconds: 250);
/// Wie viele Einträge ein Abschnitt höchstens zeigt.
///
/// Gilt für alle vier: eine kurze Eingabe wie „a" trifft in einer großen
/// Bibliothek leicht hunderte Künstler, und die würden sonst alle bei jedem
/// Neuzeichnen gebaut. Die Gesamtzahl steht in der Überschrift, und die
/// Sortierung stellt die passendsten nach vorn.
const _vorschau = 5;
/// Suchen-Tab: findet in der Bibliothek und trennt die Treffer nach Art.
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@@ -13,47 +33,391 @@ class SearchScreen extends StatefulWidget {
}
class _SearchScreenState extends State<SearchScreen> {
final _feld = TextEditingController();
Timer? _entprellen;
// Einmal anlegen, nicht in jedem build: der Bildschirm hängt über
// CategoryService auch an jedem Scan-Fortschritt, und ein neuer Stream je
// Rebuild würde die Abfrage laufend neu ausführen.
late final Stream<List<Song>> _songs = context.read<MeloDb>().watchSongs();
late final Stream<List<Playlist>> _listen =
context.read<MeloDb>().watchPlaylists();
String _query = '';
List<String> _verlauf = const [];
@override
void initState() {
super.initState();
SuchVerlauf.laden().then((v) {
if (mounted) setState(() => _verlauf = v);
});
}
@override
void dispose() {
_entprellen?.cancel();
_feld.dispose();
super.dispose();
}
void _tippt(String wert) {
_entprellen?.cancel();
_entprellen = Timer(_entprellung, () {
if (mounted) setState(() => _query = wert.trim());
});
}
/// Merkt den Begriff, wenn der Nutzer die Suche abschickt.
///
/// Bewusst nicht beim Tippen und erst recht nicht beim Aufbau der Treffer:
/// von dort aus würde jeder Rebuild schreiben und über `setState` den
/// nächsten Rebuild auslösen. Abschicken ist außerdem das ehrlichere
/// Signal — halbe Wörter beim Tippen sind keine Suche, die man wiederholen
/// möchte.
Future<void> _merke(String begriff) async {
if (begriff.trim().isEmpty) return;
final neu = await SuchVerlauf.merke(begriff);
if (mounted) setState(() => _verlauf = neu);
}
void _uebernimm(String begriff) {
_entprellen?.cancel();
_feld.text = begriff;
setState(() => _query = begriff);
}
Future<void> _verlaufLeeren() async {
await SuchVerlauf.leeren();
if (mounted) setState(() => _verlauf = const []);
}
void _leeren() {
_entprellen?.cancel();
_feld.clear();
setState(() => _query = '');
}
@override
Widget build(BuildContext context) {
final db = context.read<MeloDb>();
return Scaffold(
appBar: AppBar(
title: TextField(
autofocus: false,
decoration: const InputDecoration(
hintText: 'Titel, Künstler, Album …',
border: InputBorder.none,
prefixIcon: Icon(Icons.search),
),
onChanged: (v) => setState(() => _query = v.trim()),
),
),
body: _query.isEmpty
? const Center(
child: Text('Suchbegriff eingeben',
style: TextStyle(color: MeloTheme.text2)),
)
: StreamBuilder<List<Song>>(
stream: db.searchSongs(_query),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Fehler: ${snapshot.error}',
style: const TextStyle(color: MeloTheme.text2)),
);
}
final songs = snapshot.data ?? const [];
if (songs.isEmpty) {
return const Center(
child: Text('Nichts gefunden',
style: TextStyle(color: MeloTheme.text2)),
);
}
return SongList(songs);
},
final kategorien = context.watch<CategoryService>().alleNamen;
return SafeArea(
bottom: false,
child: Column(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(20, 16, 20, 4),
child: Align(
alignment: Alignment.centerLeft,
child: Text('Suchen',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700)),
),
),
_Suchfeld(
controller: _feld,
onChanged: _tippt,
onAbschicken: _merke,
onLeeren: _leeren,
),
Expanded(
child: _query.isEmpty
? _Verlauf(
begriffe: _verlauf,
onWaehlen: _uebernimm,
onLeeren: _verlaufLeeren,
)
: StreamBuilder<List<Song>>(
stream: _songs,
builder: (context, songSnap) {
if (songSnap.hasError) {
return _Hinweis('Fehler: ${songSnap.error}');
}
return StreamBuilder<List<Playlist>>(
stream: _listen,
builder: (context, listenSnap) {
if (listenSnap.hasError) {
return _Hinweis('Fehler: ${listenSnap.error}');
}
final treffer = sucheLokal(
songs: songSnap.data ?? const [],
kategorien: kategorien,
playlisten: listenSnap.data ?? const [],
query: _query,
);
if (treffer.istLeer) {
return const _Hinweis('Nichts gefunden');
}
return _Ergebnisse(
treffer: treffer,
alleSongs: songSnap.data ?? const [],
);
},
);
},
),
),
],
),
);
}
}
class _Suchfeld extends StatelessWidget {
const _Suchfeld({
required this.controller,
required this.onChanged,
required this.onAbschicken,
required this.onLeeren,
});
final TextEditingController controller;
final ValueChanged<String> onChanged;
final ValueChanged<String> onAbschicken;
final VoidCallback onLeeren;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.sm, MeloSpace.md, MeloSpace.sm),
child: Container(
height: 44,
decoration: BoxDecoration(
color: MeloTheme.surfaceHigh,
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
const SizedBox(width: MeloSpace.md - 2),
const Icon(Icons.search, color: MeloTheme.text2, size: 20),
const SizedBox(width: MeloSpace.sm + 2),
Expanded(
child: TextField(
controller: controller,
onChanged: onChanged,
onSubmitted: onAbschicken,
textInputAction: TextInputAction.search,
style: const TextStyle(fontSize: 14, color: MeloTheme.text1),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: 'Titel, Künstler, Kategorien und Listen',
hintStyle: TextStyle(color: MeloTheme.text2, fontSize: 14),
),
),
),
// Feste Breite und direkt am Controller: sonst erschiene das X
// erst nach der Entprellung — und würde das Textfeld dabei um
// die Knopfbreite enger springen lassen.
SizedBox(
width: MeloTheme.minTouchTarget,
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, wert, _) => wert.text.isEmpty
? const SizedBox.shrink()
: IconButton(
tooltip: 'Eingabe löschen',
icon: const Icon(Icons.close, size: 20),
onPressed: onLeeren,
),
),
),
],
),
),
);
}
}
/// Letzte Suchbegriffe, solange das Feld leer ist.
class _Verlauf extends StatelessWidget {
const _Verlauf({
required this.begriffe,
required this.onWaehlen,
required this.onLeeren,
});
final List<String> begriffe;
final ValueChanged<String> onWaehlen;
final VoidCallback onLeeren;
@override
Widget build(BuildContext context) {
if (begriffe.isEmpty) {
return const _Hinweis('Suchbegriff eingeben');
}
return ListView(
children: [
Row(
children: [
const Expanded(child: _AbschnittsKopf(label: 'Zuletzt gesucht')),
TextButton(onPressed: onLeeren, child: const Text('Löschen')),
const SizedBox(width: MeloSpace.sm),
],
),
for (final begriff in begriffe)
ListTile(
leading: const Icon(Icons.history),
title: Text(begriff),
onTap: () => onWaehlen(begriff),
),
],
);
}
}
/// Die Treffer, nach Art getrennt.
class _Ergebnisse extends StatelessWidget {
const _Ergebnisse({required this.treffer, required this.alleSongs});
final SuchTreffer treffer;
/// Grundlage für die Titel eines Künstlers — die Suche liefert nur die
/// Namen, die Zuordnung steckt in der Bibliothek.
final List<Song> alleSongs;
void _oeffne(BuildContext context, String titel, List<Song> songs) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => TitelListenScreen(titel: titel, songs: songs),
));
}
@override
Widget build(BuildContext context) {
final dienst = context.watch<CategoryService>();
// Einmal bündeln statt je Treffer die ganze Bibliothek zu filtern — und
// unter demselben Schlüssel, den die Suche zum Zusammenfassen benutzt.
final nachKuenstler = kuenstlerNachSchluessel(alleSongs);
final nachKategorie =
kategorienNachSchluessel(alleSongs, (s) => dienst.of(s.id));
// Eine Kategorie, an der nur noch gelöschte Titel hängen, steht weiter in
// der Namensliste des Dienstes. Als Treffer wäre sie eine Sackgasse:
// „0 Titel" und ein leerer Bildschirm dahinter.
final kategorien = [
for (final name in treffer.kategorien)
if (nachKategorie.containsKey(suchSchluessel(name))) name,
];
return ListView(
children: [
if (treffer.titel.isNotEmpty) ...[
_AbschnittsKopf(label: 'Titel', anzahl: treffer.titel.length),
for (var i = 0; i < treffer.titel.length && i < _vorschau; i++)
SongZeile(
song: treffer.titel[i],
warteschlange: treffer.titel,
index: i,
),
if (treffer.titel.length > _vorschau)
_MehrZeile(
label: 'Alle ${treffer.titel.length} Titel anzeigen',
onTap: () => _oeffne(context, 'Titel', treffer.titel),
),
],
if (treffer.kuenstler.isNotEmpty) ...[
_AbschnittsKopf(label: 'Künstler', anzahl: treffer.kuenstler.length),
for (final name in treffer.kuenstler.take(_vorschau))
_NamensZeile(
symbol: Icons.person_outline,
name: name,
songs: nachKuenstler[suchSchluessel(name)] ?? const [],
onTap: _oeffne,
),
],
if (kategorien.isNotEmpty) ...[
_AbschnittsKopf(label: 'Kategorien', anzahl: kategorien.length),
for (final name in kategorien.take(_vorschau))
_NamensZeile(
symbol: Icons.label_outline,
name: name,
songs: nachKategorie[suchSchluessel(name)] ?? const [],
onTap: _oeffne,
),
],
if (treffer.playlisten.isNotEmpty) ...[
_AbschnittsKopf(
label: 'Wiedergabelisten', anzahl: treffer.playlisten.length),
for (final liste in treffer.playlisten.take(_vorschau))
ListTile(
leading: const Icon(Icons.queue_music),
title:
Text(liste.name, maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(playlist: liste),
)),
),
],
const SizedBox(height: MeloSpace.md),
],
);
}
}
/// Zeile für einen Künstler oder eine Kategorie: Name und Titelzahl.
class _NamensZeile extends StatelessWidget {
const _NamensZeile({
required this.symbol,
required this.name,
required this.songs,
required this.onTap,
});
final IconData symbol;
final String name;
final List<Song> songs;
final void Function(BuildContext, String, List<Song>) onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(symbol),
title: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${songs.length} Titel'),
onTap: () => onTap(context, name, songs),
);
}
}
class _AbschnittsKopf extends StatelessWidget {
const _AbschnittsKopf({required this.label, this.anzahl});
final String label;
final int? anzahl;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(
MeloSpace.md, MeloSpace.md, MeloSpace.md, MeloSpace.xs),
child: Text(
anzahl == null ? label.toUpperCase() : '${label.toUpperCase()} $anzahl',
style: Theme.of(context).textTheme.labelLarge,
),
);
}
}
class _MehrZeile extends StatelessWidget {
const _MehrZeile({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(label, style: const TextStyle(color: MeloTheme.red)),
onTap: onTap,
);
}
}
class _Hinweis extends StatelessWidget {
const _Hinweis(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Text(text, style: const TextStyle(color: MeloTheme.text2)),
);
}
}
+92 -64
View File
@@ -18,8 +18,47 @@ class SongList extends StatelessWidget {
const SongList(this.songs, {super.key});
final List<Song> songs;
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: songs.length,
itemBuilder: (context, i) => SongZeile(
song: songs[i],
warteschlange: songs,
index: i,
),
);
}
}
/// 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,
});
final Song song;
final List<Song> warteschlange;
final int index;
/// "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, Song song) async {
Future<void> _showAddToPlaylist(BuildContext context) async {
final db = context.read<MeloDb>();
final service = context.read<PlaylistService>();
await showModalBottomSheet(
@@ -75,20 +114,13 @@ class SongList extends StatelessWidget {
);
}
/// "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(' · ')}';
}
/// 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, Song song) async {
Future<void> _showMenu(BuildContext context) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
@@ -119,9 +151,8 @@ class SongList extends StatelessWidget {
Navigator.pop(sheetContext);
final ok = await handler.playNext(item());
messenger.showSnackBar(SnackBar(
content: Text(ok
? 'Läuft als Nächstes'
: nichtOfflineHinweis)));
content: Text(
ok ? 'Läuft als Nächstes' : nichtOfflineHinweis)));
},
),
ListTile(
@@ -143,7 +174,7 @@ class SongList extends StatelessWidget {
title: const Text('Zu Wiedergabeliste hinzufügen'),
onTap: () {
Navigator.pop(sheetContext);
_showAddToPlaylist(context, song);
_showAddToPlaylist(context);
},
),
],
@@ -154,60 +185,57 @@ class SongList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final handler = context.read<MeloAudioHandler>();
// Der Handler wird erst beim Antippen gebraucht und deshalb auch erst
// dann gelesen: so lässt sich eine Zeile darstellen, ohne dass ein
// laufender Player bereitstehen muss.
final categories = context.watch<CategoryService>();
final settings = context.watch<AppSettings>();
return ListView.builder(
itemCount: songs.length,
itemBuilder: (context, i) {
final s = songs[i];
final cover = categories.coverFor(s,
groupByCategory: settings.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
final cover = categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory);
return ListTile(
leading: CoverImage(
artUri: cover != null ? Uri.file(cover) : null,
size: 48,
radius: 6,
),
title: Text(song.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_subtitle(song, categories.of(song.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: song.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context),
),
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
_subtitle(s, categories.of(s.id)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
FavoriteButton(songId: s.id),
IconButton(
tooltip: 'Mehr',
icon: const Icon(Icons.more_vert),
onPressed: () => _showMenu(context, s),
),
],
),
onTap: () async {
final messenger = ScaffoldMessenger.of(context);
try {
final uebersprungen = await playSongs(
handler,
songs,
i,
coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
if (uebersprungen > 0) {
messenger.showSnackBar(
SnackBar(content: Text(offlineHinweis(uebersprungen))),
);
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')),
);
}
},
);
],
),
onTap: () async {
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')),
);
}
},
);
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Wie viele Suchbegriffe gemerkt werden. Mehr passen nicht auf den Schirm,
/// ohne dass die Liste selbst zum Suchproblem wird.
const verlaufMaximum = 8;
/// [verlauf] mit [begriff] vorn: der zuletzt gesuchte Begriff steht oben,
/// jeder Begriff nur einmal, insgesamt höchstens [verlaufMaximum].
///
/// Rein und ohne Speicher — die Reihenfolge-Regeln sind der Teil, der
/// schiefgehen kann.
List<String> verlaufMit(List<String> verlauf, String begriff) {
final neu = begriff.trim();
if (neu.isEmpty) return List<String>.from(verlauf);
final ergebnis = [
neu,
for (final alt in verlauf)
if (alt.toLowerCase() != neu.toLowerCase()) alt,
];
return ergebnis.take(verlaufMaximum).toList();
}
/// Merkt sich die letzten Suchbegriffe über einen App-Neustart hinweg.
/// Aufbau wie [SortStore]: statische Methoden auf SharedPreferences.
class SuchVerlauf {
static const _key = 'such_verlauf';
static Future<List<String>> laden() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(_key) ?? const [];
}
/// Trägt [begriff] ein und gibt den neuen Verlauf zurück.
static Future<List<String>> merke(String begriff) async {
final prefs = await SharedPreferences.getInstance();
final neu = verlaufMit(prefs.getStringList(_key) ?? const [], begriff);
await prefs.setStringList(_key, neu);
return neu;
}
static Future<void> leeren() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'database.dart';
/// Ergebnis einer Suche in der Bibliothek, nach Art der Treffer getrennt.
///
/// Bewusst vier Listen statt einer gemischten: der Nutzer sucht entweder ein
/// Lied oder einen Künstler, und eine flache Liste zwingt ihn, das selbst
/// auseinanderzuhalten.
class SuchTreffer {
const SuchTreffer({
this.titel = const [],
this.kuenstler = const [],
this.kategorien = const [],
this.playlisten = const [],
});
final List<Song> titel;
final List<String> kuenstler;
final List<String> kategorien;
final List<Playlist> playlisten;
bool get istLeer =>
titel.isEmpty &&
kuenstler.isEmpty &&
kategorien.isEmpty &&
playlisten.isEmpty;
}
/// Normalform eines Namens für den Abgleich.
///
/// Die Suche fasst „Neoni", „NEONI" und „Neoni " zu einem Treffer zusammen.
/// Wer danach die Titel dieses Namens sucht, muss **dieselbe** Normalform
/// verwenden — sonst zeigt der Treffer „0 Titel" und öffnet eine leere Liste,
/// weil er auf eine Schreibweise zeigt, die so nirgends gespeichert ist.
String suchSchluessel(String name) => name.trim().toLowerCase();
/// Bündelt [songs] unter [suchSchluessel] ihrer Künstler. Nur Titel mit
/// Künstlernamen; verschiedene Schreibweisen fallen zusammen.
Map<String, List<Song>> kuenstlerNachSchluessel(List<Song> songs) {
final gebuendelt = <String, List<Song>>{};
for (final song in songs) {
final name = song.artist?.trim() ?? '';
if (name.isEmpty) continue;
gebuendelt.putIfAbsent(suchSchluessel(name), () => []).add(song);
}
return gebuendelt;
}
/// Bündelt [songs] unter [suchSchluessel] ihrer Kategorien. [kategorienVon]
/// liefert die Kategorien eines Titels (wie `CategoryService.of`).
Map<String, List<Song>> kategorienNachSchluessel(
List<Song> songs,
List<String> Function(Song) kategorienVon,
) {
final gebuendelt = <String, List<Song>>{};
for (final song in songs) {
for (final name in kategorienVon(song)) {
if (name.trim().isEmpty) continue;
gebuendelt.putIfAbsent(suchSchluessel(name), () => []).add(song);
}
}
return gebuendelt;
}
/// Durchsucht die Bibliothek. Rein — ohne Datenbank und ohne Oberfläche,
/// damit die Regeln unten für sich prüfbar sind.
///
/// [kategorien] sind alle vergebenen Kategorienamen. Alben stehen dort mit
/// drin: der Album-Name ist in Melo die erste Kategorie eines Titels (siehe
/// `kategorienAusTags`), ein eigener Alben-Abschnitt wäre also dieselbe Liste
/// zweimal.
SuchTreffer sucheLokal({
required List<Song> songs,
required List<String> kategorien,
required List<Playlist> playlisten,
required String query,
}) {
final gesucht = query.trim().toLowerCase();
if (gesucht.isEmpty) return const SuchTreffer();
return SuchTreffer(
titel: _sortiere(
songs.where((s) => _passtSong(s, gesucht)).toList(),
gesucht,
(s) => s.title,
),
kuenstler: _sortiere(
_eindeutig(songs.map((s) => s.artist))
.where((name) => _enthaelt(name, gesucht))
.toList(),
gesucht,
(name) => name,
),
kategorien: _sortiere(
kategorien.where((name) => _enthaelt(name, gesucht)).toList(),
gesucht,
(name) => name,
),
playlisten: _sortiere(
playlisten.where((p) => _enthaelt(p.name, gesucht)).toList(),
gesucht,
(p) => p.name,
),
);
}
bool _enthaelt(String? wert, String gesucht) =>
wert != null && wert.toLowerCase().contains(gesucht);
/// Ein Titel passt, wenn Titel, Künstler **oder** Album passen — er steht
/// deshalb trotzdem nur einmal in der Trefferliste.
bool _passtSong(Song s, String gesucht) =>
_enthaelt(s.title, gesucht) ||
_enthaelt(s.artist, gesucht) ||
_enthaelt(s.album, gesucht);
/// Nicht-leere Werte, jeder nur einmal, in der Reihenfolge des ersten
/// Auftretens. Groß-/Kleinschreibung entscheidet nicht über Gleichheit.
List<String> _eindeutig(Iterable<String?> werte) {
final gesehen = <String>{};
final ergebnis = <String>[];
for (final wert in werte) {
final name = wert?.trim() ?? '';
if (name.isEmpty) continue;
if (gesehen.add(name.toLowerCase())) ergebnis.add(name);
}
return ergebnis;
}
/// Treffer, die mit der Eingabe **beginnen**, zuerst — danach alphabetisch.
/// Wer „roses" tippt, meint eher „Roses" als „Wild Roses".
List<T> _sortiere<T>(List<T> werte, String gesucht, String Function(T) nameVon) {
int rang(T wert) =>
nameVon(wert).toLowerCase().startsWith(gesucht) ? 0 : 1;
return werte
..sort((a, b) {
final unterschied = rang(a).compareTo(rang(b));
if (unterschied != 0) return unterschied;
return nameVon(a).toLowerCase().compareTo(nameVon(b).toLowerCase());
});
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import '../library/database.dart';
import '../library/song_list.dart';
/// Vollbild-Liste einer festen Titelmenge unter einer Überschrift — die
/// Ansicht hinter einem Künstler, einer Kategorie oder einem Suchabschnitt.
///
/// Vorher stand dieselbe Klasse zweimal privat im Baum (`_ArtistSongsScreen`,
/// `_KategorieLiederScreen`); die Suche hätte eine dritte Kopie gebraucht.
class TitelListenScreen extends StatelessWidget {
const TitelListenScreen({
super.key,
required this.titel,
required this.songs,
});
final String titel;
final List<Song> songs;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(titel)),
body: SongList(songs),
);
}
}