Kategorien (mehrere je Song), Metadaten-Ansicht mit Expertenmodus
- DB-Schema 4: Tabelle song_categories + Spalte categories_edited - Kategorien kommen aus dem Genre-Tag (Trennung an ; , | /), manuell bearbeitbar; von Hand gesetzte Kategorien ueberlebt der naechste Scan - Songzeile zeigt "Kuenstler | Kategorie1 - Kategorie2" - Einstellung "Gleiche Kategorie = gleiches Coverbild" (Standard an) - Metadaten-Sheet mit ausklappbarem Expertenmodus - Neu: categories.dart, category_service.dart, song_detail_sheet.dart, app_settings.dart Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
This commit is contained in:
@@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'categories.dart';
|
||||
import 'database.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
@@ -34,6 +35,8 @@ Future<int> scanAndroidMediaStore(
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final companions = <SongsCompanion>[];
|
||||
// Siehe scan_service.dart: erst Songs, dann Kategorien.
|
||||
final categories = <String, List<String>>{};
|
||||
var done = 0;
|
||||
|
||||
if (songs.isNotEmpty) onProgress?.call(0, songs.length);
|
||||
@@ -69,6 +72,9 @@ Future<int> scanAndroidMediaStore(
|
||||
updatedAtMs: now,
|
||||
deleted: const Value(false),
|
||||
));
|
||||
if (prev?.categoriesEdited != true) {
|
||||
categories[id] = parseCategories(s.genre);
|
||||
}
|
||||
++done;
|
||||
if (done % 50 == 0 || done == songs.length) {
|
||||
onProgress?.call(done, songs.length);
|
||||
@@ -76,6 +82,9 @@ Future<int> scanAndroidMediaStore(
|
||||
}
|
||||
|
||||
await db.upsertSongs(companions);
|
||||
for (final entry in categories.entries) {
|
||||
await db.setCategories(entry.key, entry.value);
|
||||
}
|
||||
if (companions.isNotEmpty) {
|
||||
await db.markMissing(now);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'database.dart';
|
||||
|
||||
/// Trennzeichen, mit denen Genre-Tags mehrere Werte in einem Feld ablegen.
|
||||
final _separators = RegExp(r'[;,|/\n]');
|
||||
|
||||
/// Zerlegt einen Genre-Tag in einzelne Kategorien. Doppelte Einträge fallen
|
||||
/// weg (ohne Rücksicht auf Groß-/Kleinschreibung), die erste Schreibweise
|
||||
/// gewinnt.
|
||||
List<String> parseCategories(String? raw) {
|
||||
if (raw == null) return const [];
|
||||
return _dedupe(raw.split(_separators).map((e) => e.trim()));
|
||||
}
|
||||
|
||||
/// Wie [parseCategories], aber für Tag-Formate, die bereits mehrere Felder
|
||||
/// liefern (z. B. ID3v2 mit mehreren GENRE-Frames).
|
||||
List<String> parseCategoryList(Iterable<String> raw) {
|
||||
return _dedupe(raw.expand((e) => e.split(_separators)).map((e) => e.trim()));
|
||||
}
|
||||
|
||||
List<String> _dedupe(Iterable<String> candidates) {
|
||||
final seen = <String>{};
|
||||
final result = <String>[];
|
||||
for (final candidate in candidates) {
|
||||
if (candidate.isEmpty) continue;
|
||||
if (seen.add(candidate.toLowerCase())) result.add(candidate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Ermittelt je Kategorie ein Coverbild: das erste vorhandene Cover unter den
|
||||
/// Songs der Kategorie, nach Titel geordnet — damit dieselbe Bibliothek immer
|
||||
/// dasselbe Bild ergibt. Kategorien ohne jedes Cover fehlen im Ergebnis.
|
||||
Map<String, String> categoryCovers(
|
||||
List<Song> songs,
|
||||
Map<String, List<String>> categoriesBySong,
|
||||
) {
|
||||
final ordered = [...songs]
|
||||
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
|
||||
final covers = <String, String>{};
|
||||
for (final song in ordered) {
|
||||
final cover = song.coverPath;
|
||||
if (cover == null) continue;
|
||||
for (final category in categoriesBySong[song.id] ?? const <String>[]) {
|
||||
covers.putIfAbsent(category, () => cover);
|
||||
}
|
||||
}
|
||||
return covers;
|
||||
}
|
||||
|
||||
/// Das anzuzeigende Cover eines Songs: bei aktiver Einstellung "Gleiche
|
||||
/// Kategorie = gleiches Coverbild" das Bild seiner **ersten** Kategorie,
|
||||
/// sonst (oder wenn die Kategorie kein Bild hat) sein eigenes.
|
||||
String? effectiveCover(
|
||||
Song song,
|
||||
List<String> categories,
|
||||
Map<String, String> covers, {
|
||||
required bool groupByCategory,
|
||||
}) {
|
||||
if (!groupByCategory || categories.isEmpty) return song.coverPath;
|
||||
return covers[categories.first] ?? song.coverPath;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'categories.dart';
|
||||
import 'database.dart';
|
||||
|
||||
/// Hält die Kategorien aller Songs und das daraus abgeleitete Cover je
|
||||
/// Kategorie an einer Stelle — sonst müsste jede Liederliste beide Streams
|
||||
/// selbst beobachten und die Cover-Zuordnung könnte je Liste abweichen.
|
||||
class CategoryService extends ChangeNotifier {
|
||||
CategoryService(this._db) {
|
||||
_songs = _db.watchSongs().listen((songs) {
|
||||
_allSongs = songs;
|
||||
_recompute();
|
||||
});
|
||||
_categories = _db.watchCategoriesBySong().listen((byId) {
|
||||
_byId = byId;
|
||||
_recompute();
|
||||
});
|
||||
}
|
||||
|
||||
final MeloDb _db;
|
||||
late final StreamSubscription<List<Song>> _songs;
|
||||
late final StreamSubscription<Map<String, List<String>>> _categories;
|
||||
|
||||
List<Song> _allSongs = const [];
|
||||
Map<String, List<String>> _byId = const {};
|
||||
Map<String, String> _covers = const {};
|
||||
|
||||
/// Kategorien eines Songs in gespeicherter Reihenfolge.
|
||||
List<String> of(String songId) => _byId[songId] ?? const [];
|
||||
|
||||
/// Das anzuzeigende Cover eines Songs unter Berücksichtigung der
|
||||
/// Einstellung "Gleiche Kategorie = gleiches Coverbild".
|
||||
String? coverFor(Song song, {required bool groupByCategory}) {
|
||||
return effectiveCover(song, of(song.id), _covers,
|
||||
groupByCategory: groupByCategory);
|
||||
}
|
||||
|
||||
Future<void> setCategories(String songId, List<String> names) =>
|
||||
_db.setCategories(songId, names, byUser: true);
|
||||
|
||||
Future<List<String>> allNames() => _db.allCategoryNames();
|
||||
|
||||
void _recompute() {
|
||||
_covers = categoryCovers(_allSongs, _byId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_songs.cancel();
|
||||
_categories.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,10 @@ class Songs extends Table {
|
||||
/// Sortierung "Wie oft abgespielt".
|
||||
IntColumn get playCount => integer().withDefault(const Constant(0))();
|
||||
|
||||
/// Sobald die Kategorien eines Songs von Hand geändert wurden, überschreibt
|
||||
/// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei.
|
||||
BoolColumn get categoriesEdited => boolean().withDefault(const Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -65,6 +69,17 @@ class PlaylistSongs extends Table {
|
||||
Set<Column> get primaryKey => {playlistId, songId};
|
||||
}
|
||||
|
||||
/// Kategorien eines Songs — ein Song kann mehreren angehören. [position]
|
||||
/// hält die Reihenfolge fest; die erste Kategorie bestimmt das Cover.
|
||||
class SongCategories extends Table {
|
||||
TextColumn get songId => text().references(Songs, #id)();
|
||||
TextColumn get name => text()();
|
||||
IntColumn get position => integer()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {songId, name};
|
||||
}
|
||||
|
||||
/// Favorisierte Songs.
|
||||
class Favorites extends Table {
|
||||
TextColumn get songId => text().references(Songs, #id)();
|
||||
@@ -84,12 +99,20 @@ class PlaybackHistory extends Table {
|
||||
Set<Column> get primaryKey => {songId, playedAtMs};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Songs, Folders, Playlists, PlaylistSongs, Favorites, PlaybackHistory])
|
||||
@DriftDatabase(tables: [
|
||||
Songs,
|
||||
Folders,
|
||||
Playlists,
|
||||
PlaylistSongs,
|
||||
Favorites,
|
||||
PlaybackHistory,
|
||||
SongCategories,
|
||||
])
|
||||
class MeloDb extends _$MeloDb {
|
||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -104,6 +127,10 @@ class MeloDb extends _$MeloDb {
|
||||
if (from < 3) {
|
||||
await m.addColumn(songs, songs.playCount);
|
||||
}
|
||||
if (from < 4) {
|
||||
await m.addColumn(songs, songs.categoriesEdited);
|
||||
await m.createTable(songCategories);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -297,6 +324,63 @@ class MeloDb extends _$MeloDb {
|
||||
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
|
||||
}
|
||||
|
||||
// === Kategorien ===
|
||||
/// Alle Kategorien-Zuordnungen, nach Song gebündelt und in gespeicherter
|
||||
/// Reihenfolge — die UI braucht sie immer für die ganze sichtbare Liste.
|
||||
Stream<Map<String, List<String>>> watchCategoriesBySong() {
|
||||
return (select(songCategories)
|
||||
..orderBy([(c) => OrderingTerm(expression: c.position)]))
|
||||
.watch()
|
||||
.map((rows) {
|
||||
final grouped = <String, List<String>>{};
|
||||
for (final row in rows) {
|
||||
grouped.putIfAbsent(row.songId, () => []).add(row.name);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<String>> categoriesOf(String songId) async {
|
||||
final rows = await (select(songCategories)
|
||||
..where((c) => c.songId.equals(songId))
|
||||
..orderBy([(c) => OrderingTerm(expression: c.position)]))
|
||||
.get();
|
||||
return rows.map((r) => r.name).toList();
|
||||
}
|
||||
|
||||
/// Ersetzt die Kategorien eines Songs. [byUser] markiert den Song als von
|
||||
/// Hand bearbeitet, sodass der nächste Scan ihn in Ruhe lässt.
|
||||
Future<void> setCategories(
|
||||
String songId,
|
||||
List<String> names, {
|
||||
bool byUser = false,
|
||||
}) async {
|
||||
await transaction(() async {
|
||||
await (delete(songCategories)..where((c) => c.songId.equals(songId))).go();
|
||||
for (var i = 0; i < names.length; i++) {
|
||||
await into(songCategories).insert(SongCategoriesCompanion.insert(
|
||||
songId: songId,
|
||||
name: names[i],
|
||||
position: i,
|
||||
));
|
||||
}
|
||||
if (byUser) {
|
||||
await (update(songs)..where((s) => s.id.equals(songId)))
|
||||
.write(const SongsCompanion(categoriesEdited: Value(true)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Alle vergebenen Kategorien, alphabetisch — für Vorschläge beim Bearbeiten.
|
||||
Future<List<String>> allCategoryNames() async {
|
||||
final rows = await (selectOnly(songCategories, distinct: true)
|
||||
..addColumns([songCategories.name]))
|
||||
.get();
|
||||
final names = rows.map((r) => r.read(songCategories.name)!).toList()
|
||||
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
|
||||
return names;
|
||||
}
|
||||
|
||||
// === Playback History ===
|
||||
/// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim
|
||||
/// periodischen Speichern der Position — sonst würde der Zähler hochlaufen,
|
||||
|
||||
+726
-2
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'categories.dart';
|
||||
import 'database.dart';
|
||||
|
||||
const _audioExt = {
|
||||
@@ -45,6 +46,9 @@ Future<int> scanFolders(
|
||||
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
final companions = <SongsCompanion>[];
|
||||
// Kategorien werden erst nach dem Upsert geschrieben — vorher gibt es die
|
||||
// Songzeile noch nicht, auf die sie verweisen.
|
||||
final categories = <String, List<String>>{};
|
||||
var done = 0;
|
||||
|
||||
if (files.isNotEmpty) onProgress?.call(0, files.length);
|
||||
@@ -89,6 +93,9 @@ Future<int> scanFolders(
|
||||
updatedAtMs: now,
|
||||
deleted: const Value(false),
|
||||
));
|
||||
if (prev?.categoriesEdited != true) {
|
||||
categories[id] = parseCategoryList(meta?.genres ?? const []);
|
||||
}
|
||||
++done;
|
||||
if (done % 50 == 0 || done == files.length) {
|
||||
onProgress?.call(done, files.length);
|
||||
@@ -96,6 +103,9 @@ Future<int> scanFolders(
|
||||
}
|
||||
|
||||
await db.upsertSongs(companions);
|
||||
for (final entry in categories.entries) {
|
||||
await db.setCategories(entry.key, entry.value);
|
||||
}
|
||||
if (companions.isNotEmpty) {
|
||||
await db.markMissing(now);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../settings/app_settings.dart';
|
||||
import '../shared/cover.dart';
|
||||
import '../shared/theme.dart';
|
||||
import 'category_service.dart';
|
||||
import 'database.dart';
|
||||
|
||||
/// Metadaten eines Songs: Cover, Titel, Künstler und Kategorien (bearbeitbar),
|
||||
/// darunter der ausklappbare Expertenmodus mit allen weiteren Angaben.
|
||||
class SongDetailSheet extends StatelessWidget {
|
||||
const SongDetailSheet({super.key, required this.song});
|
||||
final Song song;
|
||||
|
||||
static Future<void> show(BuildContext context, Song song) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: MeloTheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => SongDetailSheet(song: song),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categories = context.watch<CategoryService>();
|
||||
final settings = context.watch<AppSettings>();
|
||||
final names = categories.of(song.id);
|
||||
final cover =
|
||||
categories.coverFor(song, groupByCategory: settings.groupCoversByCategory);
|
||||
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CoverImage(
|
||||
artUri: cover != null ? Uri.file(cover) : null,
|
||||
size: 88,
|
||||
radius: 10,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(song.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(song.artist ?? 'Unbekannter Künstler',
|
||||
style: const TextStyle(color: Colors.white70)),
|
||||
if (song.album != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(song.album!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white38, fontSize: 12)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
const Text('Kategorien',
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.edit, size: 16),
|
||||
label: const Text('Bearbeiten'),
|
||||
onPressed: () => _editCategories(context, names),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (names.isEmpty)
|
||||
const Text('Keine Kategorie',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (var i = 0; i < names.length; i++)
|
||||
Chip(
|
||||
label: Text(names[i]),
|
||||
backgroundColor: MeloTheme.surfaceHigh,
|
||||
side: BorderSide.none,
|
||||
// Die erste Kategorie liefert das Coverbild.
|
||||
avatar: i == 0
|
||||
? const Icon(Icons.image, size: 16, color: MeloTheme.red)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ExpertSection(song: song),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editCategories(BuildContext context, List<String> current) async {
|
||||
final service = context.read<CategoryService>();
|
||||
final suggestions = await service.allNames();
|
||||
if (!context.mounted) return;
|
||||
final result = await showDialog<List<String>>(
|
||||
context: context,
|
||||
builder: (_) => _CategoryEditor(current: current, suggestions: suggestions),
|
||||
);
|
||||
if (result == null) return;
|
||||
await service.setCategories(song.id, result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ausklappbarer Bereich mit allen weiteren Metadaten.
|
||||
class _ExpertSection extends StatelessWidget {
|
||||
const _ExpertSection({required this.song});
|
||||
final Song song;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: EdgeInsets.zero,
|
||||
childrenPadding: const EdgeInsets.only(bottom: 8),
|
||||
title: const Text('Expertenmodus',
|
||||
style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: const Text('Weitere Metadaten',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
children: [
|
||||
_Row('Titel', song.title),
|
||||
_Row('Künstler', song.artist ?? '—'),
|
||||
_Row('Album', song.album ?? '—'),
|
||||
_Row('Dauer', _formatDuration(song.durationMs)),
|
||||
_Row('Wiedergaben', '${song.playCount}'),
|
||||
_Row('Hinzugefügt', _formatDate(song.dateAddedMs)),
|
||||
_Row('Kategorien', song.categoriesEdited
|
||||
? 'von Hand gesetzt (Scan überschreibt nicht)'
|
||||
: 'aus dem Genre-Tag der Datei'),
|
||||
_Row('Format', _extension(song.path)),
|
||||
_Row('Dateigröße', _fileSize(song.path)),
|
||||
_Row('Pfad', song.path),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Row extends StatelessWidget {
|
||||
const _Row(this.label, this.value);
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(label,
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 13)),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value, style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(int? ms) {
|
||||
if (ms == null) return '—';
|
||||
final d = Duration(milliseconds: ms);
|
||||
final minutes = d.inMinutes;
|
||||
final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
String _formatDate(int ms) {
|
||||
final d = DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${two(d.day)}.${two(d.month)}.${d.year} ${two(d.hour)}:${two(d.minute)}';
|
||||
}
|
||||
|
||||
String _extension(String path) {
|
||||
final dot = path.lastIndexOf('.');
|
||||
return dot == -1 ? '—' : path.substring(dot + 1).toUpperCase();
|
||||
}
|
||||
|
||||
String _fileSize(String path) {
|
||||
try {
|
||||
final bytes = File(path).lengthSync();
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(0)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
} catch (_) {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog zum Hinzufügen und Entfernen von Kategorien.
|
||||
class _CategoryEditor extends StatefulWidget {
|
||||
const _CategoryEditor({required this.current, required this.suggestions});
|
||||
final List<String> current;
|
||||
final List<String> suggestions;
|
||||
|
||||
@override
|
||||
State<_CategoryEditor> createState() => _CategoryEditorState();
|
||||
}
|
||||
|
||||
class _CategoryEditorState extends State<_CategoryEditor> {
|
||||
late final List<String> _names = [...widget.current];
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _add(String raw) {
|
||||
final name = raw.trim();
|
||||
if (name.isEmpty) return;
|
||||
if (_names.any((n) => n.toLowerCase() == name.toLowerCase())) return;
|
||||
setState(() => _names.add(name));
|
||||
_controller.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final offen = widget.suggestions
|
||||
.where((s) => !_names.any((n) => n.toLowerCase() == s.toLowerCase()))
|
||||
.toList();
|
||||
return AlertDialog(
|
||||
backgroundColor: MeloTheme.surface,
|
||||
title: const Text('Kategorien'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Die erste Kategorie liefert das Coverbild.',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_names.isEmpty)
|
||||
const Text('Noch keine Kategorie',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 13))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final name in _names)
|
||||
InputChip(
|
||||
label: Text(name),
|
||||
backgroundColor: MeloTheme.surfaceHigh,
|
||||
side: BorderSide.none,
|
||||
onDeleted: () => setState(() => _names.remove(name)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kategorie hinzufügen',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: _add,
|
||||
),
|
||||
if (offen.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
const Text('Bereits vergeben',
|
||||
style: TextStyle(color: Colors.white38, fontSize: 12)),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final name in offen.take(12))
|
||||
ActionChip(
|
||||
label: Text(name),
|
||||
backgroundColor: MeloTheme.surfaceHigh,
|
||||
side: BorderSide.none,
|
||||
onPressed: () => _add(name),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Abbrechen'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
// Ein noch nicht bestätigter Text im Feld soll nicht verloren gehen.
|
||||
_add(_controller.text);
|
||||
Navigator.pop(context, _names);
|
||||
},
|
||||
child: const Text('Speichern'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../player/audio_handler.dart';
|
||||
import '../playlists/create_playlist_dialog.dart';
|
||||
import '../settings/app_settings.dart';
|
||||
import '../shared/cover.dart';
|
||||
import '../shared/favorite_button.dart';
|
||||
import 'category_service.dart';
|
||||
import 'database.dart';
|
||||
import 'playlist_service.dart';
|
||||
import 'song_detail_sheet.dart';
|
||||
import 'song_media.dart';
|
||||
|
||||
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
|
||||
@@ -71,30 +74,74 @@ 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.
|
||||
Future<void> _showMenu(BuildContext context, Song song) async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('Metadaten'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
SongDetailSheet.show(context, song);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.playlist_add),
|
||||
title: const Text('Zu Wiedergabeliste hinzufügen'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_showAddToPlaylist(context, song);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
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: s.coverPath != null ? Uri.file(s.coverPath!) : null,
|
||||
artUri: cover != null ? Uri.file(cover) : null,
|
||||
size: 48,
|
||||
radius: 6,
|
||||
),
|
||||
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(s.artist ?? 'Unbekannt',
|
||||
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: 'Zu Playlist hinzufügen',
|
||||
icon: const Icon(Icons.playlist_add),
|
||||
onPressed: () => _showAddToPlaylist(context, s),
|
||||
tooltip: 'Mehr',
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () => _showMenu(context, s),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user