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:
@@ -5,6 +5,41 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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)
|
### 🎨 Neue Melo-Oberfläche: 4 Tabs + Sortierung (2026-08-20)
|
||||||
- ✅ **Was:** Die App hat jetzt die von dir gewünschte Struktur mit
|
- ✅ **Was:** Die App hat jetzt die von dir gewünschte Struktur mit
|
||||||
**4 Tabs: Meine Musik · Suche · Favoriten · Download**. "Meine Musik" ist
|
**4 Tabs: Meine Musik · Suche · Favoriten · Download**. "Meine Musik" ist
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'categories.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
|
|
||||||
const _uuid = Uuid();
|
const _uuid = Uuid();
|
||||||
@@ -34,6 +35,8 @@ Future<int> scanAndroidMediaStore(
|
|||||||
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
final companions = <SongsCompanion>[];
|
final companions = <SongsCompanion>[];
|
||||||
|
// Siehe scan_service.dart: erst Songs, dann Kategorien.
|
||||||
|
final categories = <String, List<String>>{};
|
||||||
var done = 0;
|
var done = 0;
|
||||||
|
|
||||||
if (songs.isNotEmpty) onProgress?.call(0, songs.length);
|
if (songs.isNotEmpty) onProgress?.call(0, songs.length);
|
||||||
@@ -69,6 +72,9 @@ Future<int> scanAndroidMediaStore(
|
|||||||
updatedAtMs: now,
|
updatedAtMs: now,
|
||||||
deleted: const Value(false),
|
deleted: const Value(false),
|
||||||
));
|
));
|
||||||
|
if (prev?.categoriesEdited != true) {
|
||||||
|
categories[id] = parseCategories(s.genre);
|
||||||
|
}
|
||||||
++done;
|
++done;
|
||||||
if (done % 50 == 0 || done == songs.length) {
|
if (done % 50 == 0 || done == songs.length) {
|
||||||
onProgress?.call(done, songs.length);
|
onProgress?.call(done, songs.length);
|
||||||
@@ -76,6 +82,9 @@ Future<int> scanAndroidMediaStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.upsertSongs(companions);
|
await db.upsertSongs(companions);
|
||||||
|
for (final entry in categories.entries) {
|
||||||
|
await db.setCategories(entry.key, entry.value);
|
||||||
|
}
|
||||||
if (companions.isNotEmpty) {
|
if (companions.isNotEmpty) {
|
||||||
await db.markMissing(now);
|
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".
|
/// Sortierung "Wie oft abgespielt".
|
||||||
IntColumn get playCount => integer().withDefault(const Constant(0))();
|
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
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -65,6 +69,17 @@ class PlaylistSongs extends Table {
|
|||||||
Set<Column> get primaryKey => {playlistId, songId};
|
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.
|
/// Favorisierte Songs.
|
||||||
class Favorites extends Table {
|
class Favorites extends Table {
|
||||||
TextColumn get songId => text().references(Songs, #id)();
|
TextColumn get songId => text().references(Songs, #id)();
|
||||||
@@ -84,12 +99,20 @@ class PlaybackHistory extends Table {
|
|||||||
Set<Column> get primaryKey => {songId, playedAtMs};
|
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 {
|
class MeloDb extends _$MeloDb {
|
||||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -104,6 +127,10 @@ class MeloDb extends _$MeloDb {
|
|||||||
if (from < 3) {
|
if (from < 3) {
|
||||||
await m.addColumn(songs, songs.playCount);
|
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());
|
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 ===
|
// === Playback History ===
|
||||||
/// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim
|
/// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim
|
||||||
/// periodischen Speichern der Position — sonst würde der Zähler hochlaufen,
|
/// 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:path_provider/path_provider.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import 'categories.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
|
|
||||||
const _audioExt = {
|
const _audioExt = {
|
||||||
@@ -45,6 +46,9 @@ Future<int> scanFolders(
|
|||||||
|
|
||||||
final now = DateTime.now().millisecondsSinceEpoch;
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
final companions = <SongsCompanion>[];
|
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;
|
var done = 0;
|
||||||
|
|
||||||
if (files.isNotEmpty) onProgress?.call(0, files.length);
|
if (files.isNotEmpty) onProgress?.call(0, files.length);
|
||||||
@@ -89,6 +93,9 @@ Future<int> scanFolders(
|
|||||||
updatedAtMs: now,
|
updatedAtMs: now,
|
||||||
deleted: const Value(false),
|
deleted: const Value(false),
|
||||||
));
|
));
|
||||||
|
if (prev?.categoriesEdited != true) {
|
||||||
|
categories[id] = parseCategoryList(meta?.genres ?? const []);
|
||||||
|
}
|
||||||
++done;
|
++done;
|
||||||
if (done % 50 == 0 || done == files.length) {
|
if (done % 50 == 0 || done == files.length) {
|
||||||
onProgress?.call(done, files.length);
|
onProgress?.call(done, files.length);
|
||||||
@@ -96,6 +103,9 @@ Future<int> scanFolders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db.upsertSongs(companions);
|
await db.upsertSongs(companions);
|
||||||
|
for (final entry in categories.entries) {
|
||||||
|
await db.setCategories(entry.key, entry.value);
|
||||||
|
}
|
||||||
if (companions.isNotEmpty) {
|
if (companions.isNotEmpty) {
|
||||||
await db.markMissing(now);
|
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 '../player/audio_handler.dart';
|
||||||
import '../playlists/create_playlist_dialog.dart';
|
import '../playlists/create_playlist_dialog.dart';
|
||||||
|
import '../settings/app_settings.dart';
|
||||||
import '../shared/cover.dart';
|
import '../shared/cover.dart';
|
||||||
import '../shared/favorite_button.dart';
|
import '../shared/favorite_button.dart';
|
||||||
|
import 'category_service.dart';
|
||||||
import 'database.dart';
|
import 'database.dart';
|
||||||
import 'playlist_service.dart';
|
import 'playlist_service.dart';
|
||||||
|
import 'song_detail_sheet.dart';
|
||||||
import 'song_media.dart';
|
import 'song_media.dart';
|
||||||
|
|
||||||
/// Scrollbare Songliste; Tippen spielt die ganze Liste ab dem Song ab.
|
/// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final handler = context.read<MeloAudioHandler>();
|
final handler = context.read<MeloAudioHandler>();
|
||||||
|
final categories = context.watch<CategoryService>();
|
||||||
|
final settings = context.watch<AppSettings>();
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: songs.length,
|
itemCount: songs.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final s = songs[i];
|
final s = songs[i];
|
||||||
|
final cover = categories.coverFor(s,
|
||||||
|
groupByCategory: settings.groupCoversByCategory);
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: CoverImage(
|
leading: CoverImage(
|
||||||
artUri: s.coverPath != null ? Uri.file(s.coverPath!) : null,
|
artUri: cover != null ? Uri.file(cover) : null,
|
||||||
size: 48,
|
size: 48,
|
||||||
radius: 6,
|
radius: 6,
|
||||||
),
|
),
|
||||||
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
title: Text(s.title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text(s.artist ?? 'Unbekannt',
|
subtitle: Text(
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
_subtitle(s, categories.of(s.id)),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
FavoriteButton(songId: s.id),
|
FavoriteButton(songId: s.id),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Zu Playlist hinzufügen',
|
tooltip: 'Mehr',
|
||||||
icon: const Icon(Icons.playlist_add),
|
icon: const Icon(Icons.more_vert),
|
||||||
onPressed: () => _showAddToPlaylist(context, s),
|
onPressed: () => _showMenu(context, s),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'downloads/downloads_screen.dart';
|
import 'downloads/downloads_screen.dart';
|
||||||
|
import 'library/category_service.dart';
|
||||||
import 'library/database.dart';
|
import 'library/database.dart';
|
||||||
import 'library/favorites_screen.dart';
|
import 'library/favorites_screen.dart';
|
||||||
import 'library/library_service.dart';
|
import 'library/library_service.dart';
|
||||||
@@ -13,6 +14,7 @@ import 'player/audio_handler.dart';
|
|||||||
import 'player/mini_player.dart';
|
import 'player/mini_player.dart';
|
||||||
import 'services/logger_service.dart';
|
import 'services/logger_service.dart';
|
||||||
import 'services/offline_mode.dart';
|
import 'services/offline_mode.dart';
|
||||||
|
import 'settings/app_settings.dart';
|
||||||
import 'shared/theme.dart';
|
import 'shared/theme.dart';
|
||||||
|
|
||||||
late final MeloAudioHandler _handler;
|
late final MeloAudioHandler _handler;
|
||||||
@@ -20,6 +22,8 @@ late final MeloDb _db;
|
|||||||
late final LibraryService _library;
|
late final LibraryService _library;
|
||||||
late final PlaylistService _playlists;
|
late final PlaylistService _playlists;
|
||||||
late final OfflineMode _offlineMode;
|
late final OfflineMode _offlineMode;
|
||||||
|
late final CategoryService _categories;
|
||||||
|
late final AppSettings _settings;
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -29,6 +33,9 @@ Future<void> main() async {
|
|||||||
_playlists = PlaylistService(_db);
|
_playlists = PlaylistService(_db);
|
||||||
_offlineMode = OfflineMode();
|
_offlineMode = OfflineMode();
|
||||||
await _offlineMode.init();
|
await _offlineMode.init();
|
||||||
|
_categories = CategoryService(_db);
|
||||||
|
_settings = AppSettings();
|
||||||
|
await _settings.init();
|
||||||
_handler = await AudioService.init(
|
_handler = await AudioService.init(
|
||||||
builder: () => MeloAudioHandler(db: _db),
|
builder: () => MeloAudioHandler(db: _db),
|
||||||
config: const AudioServiceConfig(
|
config: const AudioServiceConfig(
|
||||||
@@ -53,6 +60,8 @@ class MeloApp extends StatelessWidget {
|
|||||||
ChangeNotifierProvider<LibraryService>.value(value: _library),
|
ChangeNotifierProvider<LibraryService>.value(value: _library),
|
||||||
ChangeNotifierProvider<PlaylistService>.value(value: _playlists),
|
ChangeNotifierProvider<PlaylistService>.value(value: _playlists),
|
||||||
ChangeNotifierProvider<OfflineMode>.value(value: _offlineMode),
|
ChangeNotifierProvider<OfflineMode>.value(value: _offlineMode),
|
||||||
|
ChangeNotifierProvider<CategoryService>.value(value: _categories),
|
||||||
|
ChangeNotifierProvider<AppSettings>.value(value: _settings),
|
||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: 'Melo',
|
title: 'Melo',
|
||||||
|
|||||||
@@ -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<void> init() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true;
|
||||||
|
_autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setGroupCoversByCategory(bool value) async {
|
||||||
|
if (_groupCoversByCategory == value) return;
|
||||||
|
_groupCoversByCategory = value;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyGroupCovers, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setAutoPlayOnHeadphones(bool value) async {
|
||||||
|
if (_autoPlayOnHeadphones == value) return;
|
||||||
|
_autoPlayOnHeadphones = value;
|
||||||
|
notifyListeners();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_keyAutoPlayHeadphones, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import '../services/cache_manager.dart';
|
|||||||
import '../services/logger_service.dart';
|
import '../services/logger_service.dart';
|
||||||
import '../services/navidrome_service.dart';
|
import '../services/navidrome_service.dart';
|
||||||
import '../services/offline_mode.dart';
|
import '../services/offline_mode.dart';
|
||||||
|
import 'app_settings.dart';
|
||||||
import 'library_stats.dart';
|
import 'library_stats.dart';
|
||||||
|
|
||||||
/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo.
|
/// Settings-Tab: Bibliotheks-Statistik, Berechtigungen, Navidrome, Über Melo.
|
||||||
@@ -88,6 +89,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
|
const _SectionLabel('Darstellung'),
|
||||||
|
Consumer<AppSettings>(
|
||||||
|
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'),
|
const _SectionLabel('Musikserver'),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.cloud_circle),
|
leading: const Icon(Icons.cloud_circle),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> 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<void> settle() => Future<void>.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> 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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/database.dart';
|
||||||
import 'package:melo/library/favorites_screen.dart';
|
import 'package:melo/library/favorites_screen.dart';
|
||||||
import 'package:melo/library/library_service.dart';
|
import 'package:melo/library/library_service.dart';
|
||||||
import 'package:melo/library/my_music_screen.dart';
|
import 'package:melo/library/my_music_screen.dart';
|
||||||
import 'package:melo/library/playlist_service.dart';
|
import 'package:melo/library/playlist_service.dart';
|
||||||
import 'package:melo/player/audio_handler.dart';
|
import 'package:melo/player/audio_handler.dart';
|
||||||
|
import 'package:melo/settings/app_settings.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ Widget _wrap(MeloDb db, LibraryService lib, PlaylistService playlists,
|
|||||||
ChangeNotifierProvider<LibraryService>.value(value: lib),
|
ChangeNotifierProvider<LibraryService>.value(value: lib),
|
||||||
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
ChangeNotifierProvider<PlaylistService>.value(value: playlists),
|
||||||
Provider<MeloAudioHandler>.value(value: handler),
|
Provider<MeloAudioHandler>.value(value: handler),
|
||||||
|
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
|
||||||
|
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
|
||||||
],
|
],
|
||||||
child: MaterialApp(home: Scaffold(body: child)),
|
child: MaterialApp(home: Scaffold(body: child)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Song _song({
|
|||||||
updatedAtMs: 0,
|
updatedAtMs: 0,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
playCount: 0,
|
playCount: 0,
|
||||||
|
categoriesEdited: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<MeloDb>.value(value: db),
|
||||||
|
Provider<MeloAudioHandler>.value(value: handler),
|
||||||
|
ChangeNotifierProvider<PlaylistService>(create: (_) => PlaylistService(db)),
|
||||||
|
ChangeNotifierProvider<CategoryService>(create: (_) => CategoryService(db)),
|
||||||
|
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: StreamBuilder<List<Song>>(
|
||||||
|
stream: db.watchSongs(),
|
||||||
|
builder: (context, snapshot) => SongList(snapshot.data ?? const []),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ void main() {
|
|||||||
updatedAtMs: 0,
|
updatedAtMs: 0,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
playCount: 0,
|
playCount: 0,
|
||||||
|
categoriesEdited: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final item = songToMediaItem(song);
|
final item = songToMediaItem(song);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Song _song(
|
|||||||
updatedAtMs: 0,
|
updatedAtMs: 0,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
playCount: playCount,
|
playCount: playCount,
|
||||||
|
categoriesEdited: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ Song _song({required String id, int? durationMs}) {
|
|||||||
updatedAtMs: 0,
|
updatedAtMs: 0,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
playCount: 0,
|
playCount: 0,
|
||||||
|
categoriesEdited: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:melo/library/database.dart';
|
import 'package:melo/library/database.dart';
|
||||||
import 'package:melo/library/library_service.dart';
|
import 'package:melo/library/library_service.dart';
|
||||||
import 'package:melo/services/offline_mode.dart';
|
import 'package:melo/services/offline_mode.dart';
|
||||||
|
import 'package:melo/settings/app_settings.dart';
|
||||||
import 'package:melo/settings/settings_screen.dart';
|
import 'package:melo/settings/settings_screen.dart';
|
||||||
|
|
||||||
Widget _wrap(MeloDb db, Widget child) {
|
Widget _wrap(MeloDb db, Widget child) {
|
||||||
@@ -14,6 +15,7 @@ Widget _wrap(MeloDb db, Widget child) {
|
|||||||
Provider<MeloDb>.value(value: db),
|
Provider<MeloDb>.value(value: db),
|
||||||
ChangeNotifierProvider<OfflineMode>.value(value: OfflineMode()),
|
ChangeNotifierProvider<OfflineMode>.value(value: OfflineMode()),
|
||||||
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
|
ChangeNotifierProvider<LibraryService>.value(value: LibraryService(db)),
|
||||||
|
ChangeNotifierProvider<AppSettings>(create: (_) => AppSettings()),
|
||||||
],
|
],
|
||||||
child: MaterialApp(home: child),
|
child: MaterialApp(home: child),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user