diff --git a/CHANGELOG.md b/CHANGELOG.md index b2955b9..367ed6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### 🏷️ Kategorien, Metadaten-Ansicht und Expertenmodus (2026-08-20) +- ✅ **Was:** Jeder Song kann jetzt **mehreren Kategorien** angehören. Sie + stehen in der Songzeile hinter dem Künstler ("Neoni | Trap cool · Chill") — + genau wie in deinem Entwurf. +- 📥 **Woher sie kommen:** Beim Scan aus dem **Genre-Tag** der Datei. Mehrere + Werte in einem Tag werden automatisch getrennt (`;` `,` `|` `/`), doppelte + fallen weg. Über **⋮ → Metadaten → Bearbeiten** kannst du Kategorien selbst + hinzufügen oder entfernen; bereits vergebene Kategorien werden als + Vorschläge angeboten. +- 🔒 **Von Hand schlägt Automatik:** Sobald du die Kategorien eines Songs + selbst änderst, überschreibt ein späterer Scan sie **nicht** mehr. +- 🖼️ **Gleiche Kategorie = gleiches Coverbild:** Neue Einstellung unter + *Einstellungen → Darstellung* (standardmäßig **an**, jederzeit abschaltbar). + Ist sie an, zeigen alle Songs einer Kategorie das Bild der **ersten** + Kategorie; hat die Kategorie kein Bild, bleibt das eigene Cover. Im + Metadaten-Fenster ist die bildgebende Kategorie mit einem Bild-Symbol + markiert. +- 🔬 **Expertenmodus:** Im Metadaten-Fenster ausklappbar — Titel, Künstler, + Album, Dauer, Wiedergaben, Hinzugefügt-Datum, Herkunft der Kategorien, + Format, Dateigröße und Pfad. +- 🔧 **Details:** + - DB-Schema 4: neue Tabelle `song_categories` (Song ↔ Kategorie mit + Reihenfolge) und Spalte `categories_edited`. Bestehende Bibliotheken + werden automatisch migriert. + - Neu: `library/categories.dart` (reine Logik: Tag zerlegen, Cover je + Kategorie ermitteln — 12 Tests), `library/category_service.dart` + (hält Kategorien + Cover-Zuordnung einmal zentral, 3 Tests), + `library/song_detail_sheet.dart`, `settings/app_settings.dart`. + - Beide Scanner (Android-MediaStore und Ordner-Scan) schreiben die + Kategorien mit. + - Das Drei-Punkte-Menü der Songzeile ersetzt den bisherigen + Playlist-Knopf und enthält "Metadaten" und "Zu Wiedergabeliste + hinzufügen". +- ✅ **Getestet:** 129 Tests grün, `flutter analyze` ohne Befund. + ### 🎨 Neue Melo-Oberfläche: 4 Tabs + Sortierung (2026-08-20) - ✅ **Was:** Die App hat jetzt die von dir gewünschte Struktur mit **4 Tabs: Meine Musik · Suche · Favoriten · Download**. "Meine Musik" ist diff --git a/lib/library/android_scan.dart b/lib/library/android_scan.dart index 39f4949..5434b43 100644 --- a/lib/library/android_scan.dart +++ b/lib/library/android_scan.dart @@ -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 scanAndroidMediaStore( final now = DateTime.now().millisecondsSinceEpoch; final companions = []; + // Siehe scan_service.dart: erst Songs, dann Kategorien. + final categories = >{}; var done = 0; if (songs.isNotEmpty) onProgress?.call(0, songs.length); @@ -69,6 +72,9 @@ Future 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 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); } diff --git a/lib/library/categories.dart b/lib/library/categories.dart new file mode 100644 index 0000000..958e431 --- /dev/null +++ b/lib/library/categories.dart @@ -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 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 parseCategoryList(Iterable raw) { + return _dedupe(raw.expand((e) => e.split(_separators)).map((e) => e.trim())); +} + +List _dedupe(Iterable candidates) { + final seen = {}; + final result = []; + 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 categoryCovers( + List songs, + Map> categoriesBySong, +) { + final ordered = [...songs] + ..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); + final covers = {}; + for (final song in ordered) { + final cover = song.coverPath; + if (cover == null) continue; + for (final category in categoriesBySong[song.id] ?? const []) { + 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 categories, + Map covers, { + required bool groupByCategory, +}) { + if (!groupByCategory || categories.isEmpty) return song.coverPath; + return covers[categories.first] ?? song.coverPath; +} diff --git a/lib/library/category_service.dart b/lib/library/category_service.dart new file mode 100644 index 0000000..49eefe4 --- /dev/null +++ b/lib/library/category_service.dart @@ -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> _songs; + late final StreamSubscription>> _categories; + + List _allSongs = const []; + Map> _byId = const {}; + Map _covers = const {}; + + /// Kategorien eines Songs in gespeicherter Reihenfolge. + List 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 setCategories(String songId, List names) => + _db.setCategories(songId, names, byUser: true); + + Future> allNames() => _db.allCategoryNames(); + + void _recompute() { + _covers = categoryCovers(_allSongs, _byId); + notifyListeners(); + } + + @override + void dispose() { + _songs.cancel(); + _categories.cancel(); + super.dispose(); + } +} diff --git a/lib/library/database.dart b/lib/library/database.dart index e87aa25..29b197b 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -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 get primaryKey => {id}; } @@ -65,6 +69,17 @@ class PlaylistSongs extends Table { Set 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 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 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>> watchCategoriesBySong() { + return (select(songCategories) + ..orderBy([(c) => OrderingTerm(expression: c.position)])) + .watch() + .map((rows) { + final grouped = >{}; + for (final row in rows) { + grouped.putIfAbsent(row.songId, () => []).add(row.name); + } + return grouped; + }); + } + + Future> 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 setCategories( + String songId, + List 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> 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, diff --git a/lib/library/database.g.dart b/lib/library/database.g.dart index ad986ad..57871c4 100644 --- a/lib/library/database.g.dart +++ b/lib/library/database.g.dart @@ -125,6 +125,21 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { requiredDuringInsert: false, defaultValue: const Constant(0), ); + static const VerificationMeta _categoriesEditedMeta = const VerificationMeta( + 'categoriesEdited', + ); + @override + late final GeneratedColumn categoriesEdited = GeneratedColumn( + 'categories_edited', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("categories_edited" IN (0, 1))', + ), + defaultValue: const Constant(false), + ); @override List get $columns => [ id, @@ -138,6 +153,7 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { updatedAtMs, deleted, playCount, + categoriesEdited, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -230,6 +246,15 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { playCount.isAcceptableOrUnknown(data['play_count']!, _playCountMeta), ); } + if (data.containsKey('categories_edited')) { + context.handle( + _categoriesEditedMeta, + categoriesEdited.isAcceptableOrUnknown( + data['categories_edited']!, + _categoriesEditedMeta, + ), + ); + } return context; } @@ -283,6 +308,10 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> { DriftSqlType.int, data['${effectivePrefix}play_count'], )!, + categoriesEdited: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}categories_edited'], + )!, ); } @@ -307,6 +336,10 @@ class Song extends DataClass implements Insertable { /// Wie oft der Song vollständig gestartet wurde — Grundlage für die /// Sortierung "Wie oft abgespielt". final int playCount; + + /// Sobald die Kategorien eines Songs von Hand geändert wurden, überschreibt + /// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei. + final bool categoriesEdited; const Song({ required this.id, required this.path, @@ -319,6 +352,7 @@ class Song extends DataClass implements Insertable { required this.updatedAtMs, required this.deleted, required this.playCount, + required this.categoriesEdited, }); @override Map toColumns(bool nullToAbsent) { @@ -342,6 +376,7 @@ class Song extends DataClass implements Insertable { map['updated_at_ms'] = Variable(updatedAtMs); map['deleted'] = Variable(deleted); map['play_count'] = Variable(playCount); + map['categories_edited'] = Variable(categoriesEdited); return map; } @@ -366,6 +401,7 @@ class Song extends DataClass implements Insertable { updatedAtMs: Value(updatedAtMs), deleted: Value(deleted), playCount: Value(playCount), + categoriesEdited: Value(categoriesEdited), ); } @@ -386,6 +422,7 @@ class Song extends DataClass implements Insertable { updatedAtMs: serializer.fromJson(json['updatedAtMs']), deleted: serializer.fromJson(json['deleted']), playCount: serializer.fromJson(json['playCount']), + categoriesEdited: serializer.fromJson(json['categoriesEdited']), ); } @override @@ -403,6 +440,7 @@ class Song extends DataClass implements Insertable { 'updatedAtMs': serializer.toJson(updatedAtMs), 'deleted': serializer.toJson(deleted), 'playCount': serializer.toJson(playCount), + 'categoriesEdited': serializer.toJson(categoriesEdited), }; } @@ -418,6 +456,7 @@ class Song extends DataClass implements Insertable { int? updatedAtMs, bool? deleted, int? playCount, + bool? categoriesEdited, }) => Song( id: id ?? this.id, path: path ?? this.path, @@ -430,6 +469,7 @@ class Song extends DataClass implements Insertable { updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, playCount: playCount ?? this.playCount, + categoriesEdited: categoriesEdited ?? this.categoriesEdited, ); Song copyWithCompanion(SongsCompanion data) { return Song( @@ -450,6 +490,9 @@ class Song extends DataClass implements Insertable { : this.updatedAtMs, deleted: data.deleted.present ? data.deleted.value : this.deleted, playCount: data.playCount.present ? data.playCount.value : this.playCount, + categoriesEdited: data.categoriesEdited.present + ? data.categoriesEdited.value + : this.categoriesEdited, ); } @@ -466,7 +509,8 @@ class Song extends DataClass implements Insertable { ..write('dateAddedMs: $dateAddedMs, ') ..write('updatedAtMs: $updatedAtMs, ') ..write('deleted: $deleted, ') - ..write('playCount: $playCount') + ..write('playCount: $playCount, ') + ..write('categoriesEdited: $categoriesEdited') ..write(')')) .toString(); } @@ -484,6 +528,7 @@ class Song extends DataClass implements Insertable { updatedAtMs, deleted, playCount, + categoriesEdited, ); @override bool operator ==(Object other) => @@ -499,7 +544,8 @@ class Song extends DataClass implements Insertable { other.dateAddedMs == this.dateAddedMs && other.updatedAtMs == this.updatedAtMs && other.deleted == this.deleted && - other.playCount == this.playCount); + other.playCount == this.playCount && + other.categoriesEdited == this.categoriesEdited); } class SongsCompanion extends UpdateCompanion { @@ -514,6 +560,7 @@ class SongsCompanion extends UpdateCompanion { final Value updatedAtMs; final Value deleted; final Value playCount; + final Value categoriesEdited; final Value rowid; const SongsCompanion({ this.id = const Value.absent(), @@ -527,6 +574,7 @@ class SongsCompanion extends UpdateCompanion { this.updatedAtMs = const Value.absent(), this.deleted = const Value.absent(), this.playCount = const Value.absent(), + this.categoriesEdited = const Value.absent(), this.rowid = const Value.absent(), }); SongsCompanion.insert({ @@ -541,6 +589,7 @@ class SongsCompanion extends UpdateCompanion { required int updatedAtMs, this.deleted = const Value.absent(), this.playCount = const Value.absent(), + this.categoriesEdited = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), path = Value(path), @@ -559,6 +608,7 @@ class SongsCompanion extends UpdateCompanion { Expression? updatedAtMs, Expression? deleted, Expression? playCount, + Expression? categoriesEdited, Expression? rowid, }) { return RawValuesInsertable({ @@ -573,6 +623,7 @@ class SongsCompanion extends UpdateCompanion { if (updatedAtMs != null) 'updated_at_ms': updatedAtMs, if (deleted != null) 'deleted': deleted, if (playCount != null) 'play_count': playCount, + if (categoriesEdited != null) 'categories_edited': categoriesEdited, if (rowid != null) 'rowid': rowid, }); } @@ -589,6 +640,7 @@ class SongsCompanion extends UpdateCompanion { Value? updatedAtMs, Value? deleted, Value? playCount, + Value? categoriesEdited, Value? rowid, }) { return SongsCompanion( @@ -603,6 +655,7 @@ class SongsCompanion extends UpdateCompanion { updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, playCount: playCount ?? this.playCount, + categoriesEdited: categoriesEdited ?? this.categoriesEdited, rowid: rowid ?? this.rowid, ); } @@ -643,6 +696,9 @@ class SongsCompanion extends UpdateCompanion { if (playCount.present) { map['play_count'] = Variable(playCount.value); } + if (categoriesEdited.present) { + map['categories_edited'] = Variable(categoriesEdited.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -663,6 +719,7 @@ class SongsCompanion extends UpdateCompanion { ..write('updatedAtMs: $updatedAtMs, ') ..write('deleted: $deleted, ') ..write('playCount: $playCount, ') + ..write('categoriesEdited: $categoriesEdited, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -2235,6 +2292,271 @@ class PlaybackHistoryCompanion extends UpdateCompanion { } } +class $SongCategoriesTable extends SongCategories + with TableInfo<$SongCategoriesTable, SongCategory> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SongCategoriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _songIdMeta = const VerificationMeta('songId'); + @override + late final GeneratedColumn songId = GeneratedColumn( + 'song_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES songs (id)', + ), + ); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _positionMeta = const VerificationMeta( + 'position', + ); + @override + late final GeneratedColumn position = GeneratedColumn( + 'position', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + @override + List get $columns => [songId, name, position]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'song_categories'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('song_id')) { + context.handle( + _songIdMeta, + songId.isAcceptableOrUnknown(data['song_id']!, _songIdMeta), + ); + } else if (isInserting) { + context.missing(_songIdMeta); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, + name.isAcceptableOrUnknown(data['name']!, _nameMeta), + ); + } else if (isInserting) { + context.missing(_nameMeta); + } + if (data.containsKey('position')) { + context.handle( + _positionMeta, + position.isAcceptableOrUnknown(data['position']!, _positionMeta), + ); + } else if (isInserting) { + context.missing(_positionMeta); + } + return context; + } + + @override + Set get $primaryKey => {songId, name}; + @override + SongCategory map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SongCategory( + songId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}song_id'], + )!, + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + position: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}position'], + )!, + ); + } + + @override + $SongCategoriesTable createAlias(String alias) { + return $SongCategoriesTable(attachedDatabase, alias); + } +} + +class SongCategory extends DataClass implements Insertable { + final String songId; + final String name; + final int position; + const SongCategory({ + required this.songId, + required this.name, + required this.position, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['song_id'] = Variable(songId); + map['name'] = Variable(name); + map['position'] = Variable(position); + return map; + } + + SongCategoriesCompanion toCompanion(bool nullToAbsent) { + return SongCategoriesCompanion( + songId: Value(songId), + name: Value(name), + position: Value(position), + ); + } + + factory SongCategory.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SongCategory( + songId: serializer.fromJson(json['songId']), + name: serializer.fromJson(json['name']), + position: serializer.fromJson(json['position']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'songId': serializer.toJson(songId), + 'name': serializer.toJson(name), + 'position': serializer.toJson(position), + }; + } + + SongCategory copyWith({String? songId, String? name, int? position}) => + SongCategory( + songId: songId ?? this.songId, + name: name ?? this.name, + position: position ?? this.position, + ); + SongCategory copyWithCompanion(SongCategoriesCompanion data) { + return SongCategory( + songId: data.songId.present ? data.songId.value : this.songId, + name: data.name.present ? data.name.value : this.name, + position: data.position.present ? data.position.value : this.position, + ); + } + + @override + String toString() { + return (StringBuffer('SongCategory(') + ..write('songId: $songId, ') + ..write('name: $name, ') + ..write('position: $position') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(songId, name, position); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SongCategory && + other.songId == this.songId && + other.name == this.name && + other.position == this.position); +} + +class SongCategoriesCompanion extends UpdateCompanion { + final Value songId; + final Value name; + final Value position; + final Value rowid; + const SongCategoriesCompanion({ + this.songId = const Value.absent(), + this.name = const Value.absent(), + this.position = const Value.absent(), + this.rowid = const Value.absent(), + }); + SongCategoriesCompanion.insert({ + required String songId, + required String name, + required int position, + this.rowid = const Value.absent(), + }) : songId = Value(songId), + name = Value(name), + position = Value(position); + static Insertable custom({ + Expression? songId, + Expression? name, + Expression? position, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (songId != null) 'song_id': songId, + if (name != null) 'name': name, + if (position != null) 'position': position, + if (rowid != null) 'rowid': rowid, + }); + } + + SongCategoriesCompanion copyWith({ + Value? songId, + Value? name, + Value? position, + Value? rowid, + }) { + return SongCategoriesCompanion( + songId: songId ?? this.songId, + name: name ?? this.name, + position: position ?? this.position, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (songId.present) { + map['song_id'] = Variable(songId.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (position.present) { + map['position'] = Variable(position.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SongCategoriesCompanion(') + ..write('songId: $songId, ') + ..write('name: $name, ') + ..write('position: $position, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + abstract class _$MeloDb extends GeneratedDatabase { _$MeloDb(QueryExecutor e) : super(e); $MeloDbManager get managers => $MeloDbManager(this); @@ -2246,6 +2568,7 @@ abstract class _$MeloDb extends GeneratedDatabase { late final $PlaybackHistoryTable playbackHistory = $PlaybackHistoryTable( this, ); + late final $SongCategoriesTable songCategories = $SongCategoriesTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @@ -2257,6 +2580,7 @@ abstract class _$MeloDb extends GeneratedDatabase { playlistSongs, favorites, playbackHistory, + songCategories, ]; } @@ -2273,6 +2597,7 @@ typedef $$SongsTableCreateCompanionBuilder = required int updatedAtMs, Value deleted, Value playCount, + Value categoriesEdited, Value rowid, }); typedef $$SongsTableUpdateCompanionBuilder = @@ -2288,6 +2613,7 @@ typedef $$SongsTableUpdateCompanionBuilder = Value updatedAtMs, Value deleted, Value playCount, + Value categoriesEdited, Value rowid, }); @@ -2350,6 +2676,24 @@ final class $$SongsTableReferences manager.$state.copyWith(prefetchedData: cache), ); } + + static MultiTypedResultKey<$SongCategoriesTable, List> + _songCategoriesRefsTable(_$MeloDb db) => MultiTypedResultKey.fromTable( + db.songCategories, + aliasName: 'songs__id__song_categories__song_id', + ); + + $$SongCategoriesTableProcessedTableManager get songCategoriesRefs { + final manager = $$SongCategoriesTableTableManager( + $_db, + $_db.songCategories, + ).filter((f) => f.songId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_songCategoriesRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } } class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> { @@ -2415,6 +2759,11 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get categoriesEdited => $composableBuilder( + column: $table.categoriesEdited, + builder: (column) => ColumnFilters(column), + ); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableFilterComposer f) f, ) { @@ -2489,6 +2838,31 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> { ); return f(composer); } + + Expression songCategoriesRefs( + Expression Function($$SongCategoriesTableFilterComposer f) f, + ) { + final $$SongCategoriesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.songCategories, + getReferencedColumn: (t) => t.songId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SongCategoriesTableFilterComposer( + $db: $db, + $table: $db.songCategories, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> { @@ -2553,6 +2927,11 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> { column: $table.playCount, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get categoriesEdited => $composableBuilder( + column: $table.categoriesEdited, + builder: (column) => ColumnOrderings(column), + ); } class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> { @@ -2602,6 +2981,11 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> { GeneratedColumn get playCount => $composableBuilder(column: $table.playCount, builder: (column) => column); + GeneratedColumn get categoriesEdited => $composableBuilder( + column: $table.categoriesEdited, + builder: (column) => column, + ); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableAnnotationComposer a) f, ) { @@ -2676,6 +3060,31 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> { ); return f(composer); } + + Expression songCategoriesRefs( + Expression Function($$SongCategoriesTableAnnotationComposer a) f, + ) { + final $$SongCategoriesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.songCategories, + getReferencedColumn: (t) => t.songId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SongCategoriesTableAnnotationComposer( + $db: $db, + $table: $db.songCategories, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } } class $$SongsTableTableManager @@ -2695,6 +3104,7 @@ class $$SongsTableTableManager bool playlistSongsRefs, bool favoritesRefs, bool playbackHistoryRefs, + bool songCategoriesRefs, }) > { $$SongsTableTableManager(_$MeloDb db, $SongsTable table) @@ -2721,6 +3131,7 @@ class $$SongsTableTableManager Value updatedAtMs = const Value.absent(), Value deleted = const Value.absent(), Value playCount = const Value.absent(), + Value categoriesEdited = const Value.absent(), Value rowid = const Value.absent(), }) => SongsCompanion( id: id, @@ -2734,6 +3145,7 @@ class $$SongsTableTableManager updatedAtMs: updatedAtMs, deleted: deleted, playCount: playCount, + categoriesEdited: categoriesEdited, rowid: rowid, ), createCompanionCallback: @@ -2749,6 +3161,7 @@ class $$SongsTableTableManager required int updatedAtMs, Value deleted = const Value.absent(), Value playCount = const Value.absent(), + Value categoriesEdited = const Value.absent(), Value rowid = const Value.absent(), }) => SongsCompanion.insert( id: id, @@ -2762,6 +3175,7 @@ class $$SongsTableTableManager updatedAtMs: updatedAtMs, deleted: deleted, playCount: playCount, + categoriesEdited: categoriesEdited, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -2775,6 +3189,7 @@ class $$SongsTableTableManager playlistSongsRefs = false, favoritesRefs = false, playbackHistoryRefs = false, + songCategoriesRefs = false, }) { return PrefetchHooks( db: db, @@ -2782,6 +3197,7 @@ class $$SongsTableTableManager if (playlistSongsRefs) db.playlistSongs, if (favoritesRefs) db.favorites, if (playbackHistoryRefs) db.playbackHistory, + if (songCategoriesRefs) db.songCategories, ], addJoins: null, getPrefetchedDataCallback: (items) async { @@ -2845,6 +3261,27 @@ class $$SongsTableTableManager ), typedResults: items, ), + if (songCategoriesRefs) + await $_getPrefetchedData< + Song, + $SongsTable, + SongCategory + >( + currentTable: table, + referencedTable: $$SongsTableReferences + ._songCategoriesRefsTable(db), + managerFromTypedResult: (p0) => + $$SongsTableReferences( + db, + table, + p0, + ).songCategoriesRefs, + referencedItemsForCurrentItem: + (item, referencedItems) => referencedItems.where( + (e) => e.songId == item.id, + ), + typedResults: items, + ), ]; }, ); @@ -2869,6 +3306,7 @@ typedef $$SongsTableProcessedTableManager = bool playlistSongsRefs, bool favoritesRefs, bool playbackHistoryRefs, + bool songCategoriesRefs, }) >; typedef $$FoldersTableCreateCompanionBuilder = @@ -4314,6 +4752,290 @@ typedef $$PlaybackHistoryTableProcessedTableManager = PlaybackHistoryData, PrefetchHooks Function({bool songId}) >; +typedef $$SongCategoriesTableCreateCompanionBuilder = + SongCategoriesCompanion Function({ + required String songId, + required String name, + required int position, + Value rowid, + }); +typedef $$SongCategoriesTableUpdateCompanionBuilder = + SongCategoriesCompanion Function({ + Value songId, + Value name, + Value position, + Value rowid, + }); + +final class $$SongCategoriesTableReferences + extends BaseReferences<_$MeloDb, $SongCategoriesTable, SongCategory> { + $$SongCategoriesTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $SongsTable _songIdTable(_$MeloDb db) => + db.songs.createAlias('song_categories__song_id__songs__id'); + + $$SongsTableProcessedTableManager get songId { + final $_column = $_itemColumn('song_id')!; + + final manager = $$SongsTableTableManager( + $_db, + $_db.songs, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_songIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$SongCategoriesTableFilterComposer + extends Composer<_$MeloDb, $SongCategoriesTable> { + $$SongCategoriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get position => $composableBuilder( + column: $table.position, + builder: (column) => ColumnFilters(column), + ); + + $$SongsTableFilterComposer get songId { + final $$SongsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.songId, + referencedTable: $db.songs, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SongsTableFilterComposer( + $db: $db, + $table: $db.songs, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$SongCategoriesTableOrderingComposer + extends Composer<_$MeloDb, $SongCategoriesTable> { + $$SongCategoriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get name => $composableBuilder( + column: $table.name, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get position => $composableBuilder( + column: $table.position, + builder: (column) => ColumnOrderings(column), + ); + + $$SongsTableOrderingComposer get songId { + final $$SongsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.songId, + referencedTable: $db.songs, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SongsTableOrderingComposer( + $db: $db, + $table: $db.songs, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$SongCategoriesTableAnnotationComposer + extends Composer<_$MeloDb, $SongCategoriesTable> { + $$SongCategoriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get name => + $composableBuilder(column: $table.name, builder: (column) => column); + + GeneratedColumn get position => + $composableBuilder(column: $table.position, builder: (column) => column); + + $$SongsTableAnnotationComposer get songId { + final $$SongsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.songId, + referencedTable: $db.songs, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$SongsTableAnnotationComposer( + $db: $db, + $table: $db.songs, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$SongCategoriesTableTableManager + extends + RootTableManager< + _$MeloDb, + $SongCategoriesTable, + SongCategory, + $$SongCategoriesTableFilterComposer, + $$SongCategoriesTableOrderingComposer, + $$SongCategoriesTableAnnotationComposer, + $$SongCategoriesTableCreateCompanionBuilder, + $$SongCategoriesTableUpdateCompanionBuilder, + (SongCategory, $$SongCategoriesTableReferences), + SongCategory, + PrefetchHooks Function({bool songId}) + > { + $$SongCategoriesTableTableManager(_$MeloDb db, $SongCategoriesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SongCategoriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SongCategoriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$SongCategoriesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value songId = const Value.absent(), + Value name = const Value.absent(), + Value position = const Value.absent(), + Value rowid = const Value.absent(), + }) => SongCategoriesCompanion( + songId: songId, + name: name, + position: position, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String songId, + required String name, + required int position, + Value rowid = const Value.absent(), + }) => SongCategoriesCompanion.insert( + songId: songId, + name: name, + position: position, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable(table), + $$SongCategoriesTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({songId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (songId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.songId, + referencedTable: $$SongCategoriesTableReferences + ._songIdTable(db), + referencedColumn: + $$SongCategoriesTableReferences + ._songIdTable(db) + .id, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$SongCategoriesTableProcessedTableManager = + ProcessedTableManager< + _$MeloDb, + $SongCategoriesTable, + SongCategory, + $$SongCategoriesTableFilterComposer, + $$SongCategoriesTableOrderingComposer, + $$SongCategoriesTableAnnotationComposer, + $$SongCategoriesTableCreateCompanionBuilder, + $$SongCategoriesTableUpdateCompanionBuilder, + (SongCategory, $$SongCategoriesTableReferences), + SongCategory, + PrefetchHooks Function({bool songId}) + >; class $MeloDbManager { final _$MeloDb _db; @@ -4330,4 +5052,6 @@ class $MeloDbManager { $$FavoritesTableTableManager(_db, _db.favorites); $$PlaybackHistoryTableTableManager get playbackHistory => $$PlaybackHistoryTableTableManager(_db, _db.playbackHistory); + $$SongCategoriesTableTableManager get songCategories => + $$SongCategoriesTableTableManager(_db, _db.songCategories); } diff --git a/lib/library/scan_service.dart b/lib/library/scan_service.dart index d45fb66..1cab331 100644 --- a/lib/library/scan_service.dart +++ b/lib/library/scan_service.dart @@ -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 scanFolders( final now = DateTime.now().millisecondsSinceEpoch; final companions = []; + // Kategorien werden erst nach dem Upsert geschrieben — vorher gibt es die + // Songzeile noch nicht, auf die sie verweisen. + final categories = >{}; var done = 0; if (files.isNotEmpty) onProgress?.call(0, files.length); @@ -89,6 +93,9 @@ Future 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 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); } diff --git a/lib/library/song_detail_sheet.dart b/lib/library/song_detail_sheet.dart new file mode 100644 index 0000000..2fa6d91 --- /dev/null +++ b/lib/library/song_detail_sheet.dart @@ -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 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(); + final settings = context.watch(); + 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 _editCategories(BuildContext context, List current) async { + final service = context.read(); + final suggestions = await service.allNames(); + if (!context.mounted) return; + final result = await showDialog>( + 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 current; + final List suggestions; + + @override + State<_CategoryEditor> createState() => _CategoryEditorState(); +} + +class _CategoryEditorState extends State<_CategoryEditor> { + late final List _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'), + ), + ], + ); + } +} diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index 60ab526..3a80c27 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -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 categories) { + final artist = song.artist ?? 'Unbekannt'; + if (categories.isEmpty) return artist; + return '$artist | ${categories.join(' · ')}'; + } + + /// Menü hinter dem Drei-Punkte-Symbol einer Songzeile. + Future _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(); + final categories = context.watch(); + final settings = context.watch(); 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), ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 0116335..71460c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'downloads/downloads_screen.dart'; +import 'library/category_service.dart'; import 'library/database.dart'; import 'library/favorites_screen.dart'; import 'library/library_service.dart'; @@ -13,6 +14,7 @@ import 'player/audio_handler.dart'; import 'player/mini_player.dart'; import 'services/logger_service.dart'; import 'services/offline_mode.dart'; +import 'settings/app_settings.dart'; import 'shared/theme.dart'; late final MeloAudioHandler _handler; @@ -20,6 +22,8 @@ late final MeloDb _db; late final LibraryService _library; late final PlaylistService _playlists; late final OfflineMode _offlineMode; +late final CategoryService _categories; +late final AppSettings _settings; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -29,6 +33,9 @@ Future main() async { _playlists = PlaylistService(_db); _offlineMode = OfflineMode(); await _offlineMode.init(); + _categories = CategoryService(_db); + _settings = AppSettings(); + await _settings.init(); _handler = await AudioService.init( builder: () => MeloAudioHandler(db: _db), config: const AudioServiceConfig( @@ -53,6 +60,8 @@ class MeloApp extends StatelessWidget { ChangeNotifierProvider.value(value: _library), ChangeNotifierProvider.value(value: _playlists), ChangeNotifierProvider.value(value: _offlineMode), + ChangeNotifierProvider.value(value: _categories), + ChangeNotifierProvider.value(value: _settings), ], child: MaterialApp( title: 'Melo', diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart new file mode 100644 index 0000000..9aca760 --- /dev/null +++ b/lib/settings/app_settings.dart @@ -0,0 +1,42 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Nutzer-Einstellungen, die die ganze App betreffen. Werden beim Start +/// einmal geladen und bei jeder Änderung sofort gespeichert. +class AppSettings extends ChangeNotifier { + static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover'; + static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer'; + + bool _groupCoversByCategory = true; + bool _autoPlayOnHeadphones = false; + + /// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der + /// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild. + bool get groupCoversByCategory => _groupCoversByCategory; + + /// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden. + bool get autoPlayOnHeadphones => _autoPlayOnHeadphones; + + Future init() async { + final prefs = await SharedPreferences.getInstance(); + _groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true; + _autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false; + notifyListeners(); + } + + Future setGroupCoversByCategory(bool value) async { + if (_groupCoversByCategory == value) return; + _groupCoversByCategory = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyGroupCovers, value); + } + + Future setAutoPlayOnHeadphones(bool value) async { + if (_autoPlayOnHeadphones == value) return; + _autoPlayOnHeadphones = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyAutoPlayHeadphones, value); + } +} diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index b7d84c3..dc31f52 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -9,6 +9,7 @@ import '../services/cache_manager.dart'; import '../services/logger_service.dart'; import '../services/navidrome_service.dart'; import '../services/offline_mode.dart'; +import 'app_settings.dart'; import 'library_stats.dart'; /// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo. @@ -88,6 +89,18 @@ class _SettingsScreenState extends State { }, ), const Divider(height: 1), + const _SectionLabel('Darstellung'), + Consumer( + builder: (context, settings, _) => SwitchListTile( + secondary: const Icon(Icons.photo_library_outlined), + title: const Text('Gleiche Kategorie = gleiches Coverbild'), + subtitle: const Text( + 'Songs einer Kategorie zeigen das Bild der ersten Kategorie'), + value: settings.groupCoversByCategory, + onChanged: settings.setGroupCoversByCategory, + ), + ), + const Divider(height: 1), const _SectionLabel('Musikserver'), ListTile( leading: const Icon(Icons.cloud_circle), diff --git a/test/library/categories_test.dart b/test/library/categories_test.dart new file mode 100644 index 0000000..7232796 --- /dev/null +++ b/test/library/categories_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/categories.dart'; +import 'package:melo/library/database.dart'; + +Song _song(String id, {String? coverPath, String title = 'T'}) => Song( + id: id, + path: '/$id.mp3', + title: title, + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + playCount: 0, + coverPath: coverPath, + categoriesEdited: false, + ); + +void main() { + group('parseCategories', () { + test('trennt an Semikolon, Komma, Schrägstrich und Pipe', () { + expect(parseCategories('Rock; Pop'), ['Rock', 'Pop']); + expect(parseCategories('Rock,Pop'), ['Rock', 'Pop']); + expect(parseCategories('Rock/Pop'), ['Rock', 'Pop']); + expect(parseCategories('Rock|Pop'), ['Rock', 'Pop']); + }); + + test('behält Leerzeichen innerhalb einer Kategorie', () { + expect(parseCategories('Hip Hop; Trap cool'), ['Hip Hop', 'Trap cool']); + }); + + test('entfernt Leereinträge und doppelte Kategorien', () { + expect(parseCategories('Rock;;rock; Pop ;Rock'), ['Rock', 'Pop']); + }); + + test('leerer oder fehlender Tag ergibt keine Kategorie', () { + expect(parseCategories(null), isEmpty); + expect(parseCategories(' '), isEmpty); + }); + + test('verarbeitet auch eine Liste aus mehreren Tag-Feldern', () { + expect(parseCategoryList(['Rock', 'Pop;Trap', 'rock']), + ['Rock', 'Pop', 'Trap']); + }); + }); + + group('categoryCovers', () { + test('nimmt je Kategorie das erste vorhandene Cover', () { + final songs = [ + _song('1', title: 'A', coverPath: null), + _song('2', title: 'B', coverPath: '/cover_b.img'), + _song('3', title: 'C', coverPath: '/cover_c.img'), + ]; + final categories = { + '1': ['Rock'], + '2': ['Rock'], + '3': ['Pop'], + }; + + final covers = categoryCovers(songs, categories); + + expect(covers['Rock'], '/cover_b.img'); + expect(covers['Pop'], '/cover_c.img'); + }); + + test('Kategorie ganz ohne Cover taucht nicht auf', () { + final songs = [_song('1', coverPath: null)]; + + final covers = categoryCovers(songs, {'1': ['Leer']}); + + expect(covers.containsKey('Leer'), isFalse); + }); + + test('ist unabhängig von der Reihenfolge der Eingabeliste', () { + final songs = [ + _song('2', title: 'B', coverPath: '/cover_b.img'), + _song('1', title: 'A', coverPath: '/cover_a.img'), + ]; + final categories = {'1': ['Rock'], '2': ['Rock']}; + + expect(categoryCovers(songs, categories)['Rock'], '/cover_a.img'); + }); + }); + + group('effectiveCover', () { + final covers = {'Rock': '/rock.img', 'Pop': '/pop.img'}; + + test('nimmt das Cover der ersten Kategorie', () { + final cover = effectiveCover( + _song('1', coverPath: '/eigen.img'), + ['Pop', 'Rock'], + covers, + groupByCategory: true, + ); + + expect(cover, '/pop.img'); + }); + + test('fällt auf das eigene Cover zurück, wenn die Kategorie keins hat', () { + final cover = effectiveCover( + _song('1', coverPath: '/eigen.img'), + ['Unbekannt'], + covers, + groupByCategory: true, + ); + + expect(cover, '/eigen.img'); + }); + + test('abgeschaltet gilt immer das eigene Cover', () { + final cover = effectiveCover( + _song('1', coverPath: '/eigen.img'), + ['Pop'], + covers, + groupByCategory: false, + ); + + expect(cover, '/eigen.img'); + }); + + test('ohne Kategorien gilt das eigene Cover', () { + final cover = effectiveCover( + _song('1', coverPath: '/eigen.img'), + const [], + covers, + groupByCategory: true, + ); + + expect(cover, '/eigen.img'); + }); + }); +} diff --git a/test/library/category_service_test.dart b/test/library/category_service_test.dart new file mode 100644 index 0000000..1d5b732 --- /dev/null +++ b/test/library/category_service_test.dart @@ -0,0 +1,65 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; + +void main() { + late MeloDb db; + late CategoryService service; + + setUp(() => db = MeloDb(NativeDatabase.memory())); + tearDown(() async { + service.dispose(); + await db.close(); + }); + + Future insert(String id, {String? cover}) => db.upsertSongs([ + SongsCompanion.insert( + id: id, + path: '/$id.mp3', + title: 'Song $id', + coverPath: Value(cover), + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + + Future settle() => Future.delayed(const Duration(milliseconds: 50)); + + test('reicht die Kategorien eines Songs durch', () async { + await insert('1'); + await db.setCategories('1', ['Rock', 'Pop']); + service = CategoryService(db); + await settle(); + + expect(service.of('1'), ['Rock', 'Pop']); + expect(service.of('unbekannt'), isEmpty); + }); + + test('Songs derselben Kategorie bekommen dasselbe Cover', () async { + await insert('1', cover: null); + await insert('2', cover: '/rock.img'); + await db.setCategories('1', ['Rock']); + await db.setCategories('2', ['Rock']); + service = CategoryService(db); + await settle(); + + final ohneCover = (await db.watchSongs().first).firstWhere((s) => s.id == '1'); + expect(service.coverFor(ohneCover, groupByCategory: true), '/rock.img'); + expect(service.coverFor(ohneCover, groupByCategory: false), isNull); + }); + + test('von Hand gesetzte Kategorien markieren den Song als bearbeitet', + () async { + await insert('1'); + service = CategoryService(db); + await settle(); + + await service.setCategories('1', ['Eigen']); + await settle(); + + expect(service.of('1'), ['Eigen']); + expect((await db.watchSongs().first).single.categoriesEdited, isTrue); + }); +} diff --git a/test/library/database_categories_test.dart b/test/library/database_categories_test.dart new file mode 100644 index 0000000..641bb23 --- /dev/null +++ b/test/library/database_categories_test.dart @@ -0,0 +1,69 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; + +void main() { + late MeloDb db; + setUp(() => db = MeloDb(NativeDatabase.memory())); + tearDown(() => db.close()); + + Future insert(String id) => db.upsertSongs([ + SongsCompanion.insert( + id: id, + path: '/$id.mp3', + title: 'Song $id', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + + test('ein Song kann mehreren Kategorien angehören, Reihenfolge bleibt', + () async { + await insert('1'); + + await db.setCategories('1', ['Trap cool', 'Rock', 'Chill']); + + expect(await db.categoriesOf('1'), ['Trap cool', 'Rock', 'Chill']); + }); + + test('setCategories ersetzt die bisherigen Kategorien', () async { + await insert('1'); + await db.setCategories('1', ['Alt']); + + await db.setCategories('1', ['Neu']); + + expect(await db.categoriesOf('1'), ['Neu']); + }); + + test('byUser markiert den Song als von Hand bearbeitet', () async { + await insert('1'); + + await db.setCategories('1', ['Rock']); + expect((await db.watchSongs().first).single.categoriesEdited, isFalse); + + await db.setCategories('1', ['Pop'], byUser: true); + expect((await db.watchSongs().first).single.categoriesEdited, isTrue); + }); + + test('watchCategoriesBySong bündelt nach Song', () async { + await insert('1'); + await insert('2'); + await db.setCategories('1', ['Rock', 'Pop']); + await db.setCategories('2', ['Jazz']); + + final byId = await db.watchCategoriesBySong().first; + + expect(byId['1'], ['Rock', 'Pop']); + expect(byId['2'], ['Jazz']); + }); + + test('allCategoryNames liefert jede Kategorie einmal, alphabetisch', + () async { + await insert('1'); + await insert('2'); + await db.setCategories('1', ['Rock', 'Ambient']); + await db.setCategories('2', ['Rock']); + + expect(await db.allCategoryNames(), ['Ambient', 'Rock']); + }); +} diff --git a/test/library/my_music_screen_test.dart b/test/library/my_music_screen_test.dart index 114ee19..7a19de4 100644 --- a/test/library/my_music_screen_test.dart +++ b/test/library/my_music_screen_test.dart @@ -2,12 +2,14 @@ import 'package:drift/drift.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; import 'package:melo/library/database.dart'; import 'package:melo/library/favorites_screen.dart'; import 'package:melo/library/library_service.dart'; import 'package:melo/library/my_music_screen.dart'; import 'package:melo/library/playlist_service.dart'; import 'package:melo/player/audio_handler.dart'; +import 'package:melo/settings/app_settings.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -19,6 +21,8 @@ Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists, ChangeNotifierProvider.value(value: lib), ChangeNotifierProvider.value(value: playlists), Provider.value(value: handler), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), ], child: MaterialApp(home: Scaffold(body: child)), ); diff --git a/test/library/song_grouping_test.dart b/test/library/song_grouping_test.dart index f80412c..3be269c 100644 --- a/test/library/song_grouping_test.dart +++ b/test/library/song_grouping_test.dart @@ -18,6 +18,7 @@ Song _song({ updatedAtMs: 0, deleted: false, playCount: 0, + categoriesEdited: false, ); } diff --git a/test/library/song_list_categories_test.dart b/test/library/song_list_categories_test.dart new file mode 100644 index 0000000..1379500 --- /dev/null +++ b/test/library/song_list_categories_test.dart @@ -0,0 +1,90 @@ +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/library/song_list.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Widget _wrap(MeloDb db, MeloAudioHandler handler) => MultiProvider( + providers: [ + Provider.value(value: db), + Provider.value(value: handler), + ChangeNotifierProvider(create: (_) => PlaylistService(db)), + ChangeNotifierProvider(create: (_) => CategoryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), + ], + child: MaterialApp( + home: Scaffold( + body: StreamBuilder>( + stream: db.watchSongs(), + builder: (context, snapshot) => SongList(snapshot.data ?? const []), + ), + ), + ), + ); + +Future _insertRoses(MeloDb db) async { + await db.upsertSongs([ + SongsCompanion.insert( + id: '1', + path: '/a.mp3', + title: 'Roses', + artist: const Value('Neoni'), + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCategories('1', ['Trap cool', 'Chill']); +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('Songzeile zeigt "Künstler | Kategorien"', (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final handler = MeloAudioHandler(db: db); + await _insertRoses(db); + + await tester.pumpWidget(_wrap(db, handler)); + await tester.pumpAndSettle(); + + expect(find.text('Roses'), findsOneWidget); + expect(find.text('Neoni | Trap cool · Chill'), findsOneWidget); + + handler.dispose(); + await db.close(); + }); + + testWidgets('Metadaten-Ansicht zeigt Kategorien und den Expertenmodus', + (tester) async { + final db = MeloDb(NativeDatabase.memory()); + final handler = MeloAudioHandler(db: db); + await _insertRoses(db); + + await tester.pumpWidget(_wrap(db, handler)); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Metadaten')); + await tester.pumpAndSettle(); + + expect(find.text('Kategorien'), findsOneWidget); + expect(find.text('Trap cool'), findsWidgets); + expect(find.text('Expertenmodus'), findsOneWidget); + + await tester.tap(find.text('Expertenmodus')); + await tester.pumpAndSettle(); + + expect(find.text('Wiedergaben'), findsOneWidget); + + handler.dispose(); + await db.close(); + }); +} diff --git a/test/library/song_media_test.dart b/test/library/song_media_test.dart index 0dabeef..1f48057 100644 --- a/test/library/song_media_test.dart +++ b/test/library/song_media_test.dart @@ -16,6 +16,7 @@ void main() { updatedAtMs: 0, deleted: false, playCount: 0, + categoriesEdited: false, ); final item = songToMediaItem(song); diff --git a/test/library/song_sort_test.dart b/test/library/song_sort_test.dart index 89bc13a..311b4a2 100644 --- a/test/library/song_sort_test.dart +++ b/test/library/song_sort_test.dart @@ -16,6 +16,7 @@ Song _song( updatedAtMs: 0, deleted: false, playCount: playCount, + categoriesEdited: false, ); } diff --git a/test/settings/library_stats_test.dart b/test/settings/library_stats_test.dart index 7035fd7..fdb8e97 100644 --- a/test/settings/library_stats_test.dart +++ b/test/settings/library_stats_test.dart @@ -12,6 +12,7 @@ Song _song({required String id, int? durationMs}) { updatedAtMs: 0, deleted: false, playCount: 0, + categoriesEdited: false, ); } diff --git a/test/settings/settings_screen_test.dart b/test/settings/settings_screen_test.dart index cce085a..c5ef131 100644 --- a/test/settings/settings_screen_test.dart +++ b/test/settings/settings_screen_test.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import 'package:melo/library/database.dart'; import 'package:melo/library/library_service.dart'; import 'package:melo/services/offline_mode.dart'; +import 'package:melo/settings/app_settings.dart'; import 'package:melo/settings/settings_screen.dart'; Widget _wrap(MeloDb db, Widget child) { @@ -14,6 +15,7 @@ Widget _wrap(MeloDb db, Widget child) { Provider.value(value: db), ChangeNotifierProvider.value(value: OfflineMode()), ChangeNotifierProvider.value(value: LibraryService(db)), + ChangeNotifierProvider(create: (_) => AppSettings()), ], child: MaterialApp(home: child), );