Songtext aus Datei-Tags, Warteschlangen-Aktionen, Kategorie-Cover im

Sperrbildschirm und ReplayGain

- DB-Schema 6: lyrics + gain_db
- Songtext bevorzugt lokalen Tag, Server nur als Rueckfall; Song-ID-Bug
  beim Lyrics-Aufruf behoben
- "Als Naechstes spielen" / "Zur Warteschlange hinzufuegen" ohne Eingriff
  in Favoriten oder Wiedergabelisten
- songToMediaItem nimmt eine Cover-Vorgabe: Sperrbildschirm zeigt das
  Kategorie-Cover
- ReplayGain: Tags werden gelesen, laute Titel abgesenkt, abschaltbar

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
This commit is contained in:
2026-08-20 18:41:36 +02:00
co-authored by Claude Opus 5
parent 1325badf90
commit 4ebd0a8dd4
17 changed files with 534 additions and 46 deletions
+23 -1
View File
@@ -30,6 +30,14 @@ class Songs extends Table {
/// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei.
BoolColumn get categoriesEdited => boolean().withDefault(const Constant(false))();
/// Songtext aus dem Tag der Datei — Grundlage für den automatischen
/// Songtext ohne Server.
TextColumn get lyrics => text().nullable()();
/// ReplayGain des Titels in Dezibel, sofern die Datei den Tag mitbringt —
/// Grundlage für "Gleiche Lautstärke".
RealColumn get gainDb => real().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -112,7 +120,7 @@ class MeloDb extends _$MeloDb {
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
@override
int get schemaVersion => 4;
int get schemaVersion => 6;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -131,6 +139,12 @@ class MeloDb extends _$MeloDb {
await m.addColumn(songs, songs.categoriesEdited);
await m.createTable(songCategories);
}
if (from < 5) {
await m.addColumn(songs, songs.lyrics);
}
if (from < 6) {
await m.addColumn(songs, songs.gainDb);
}
},
);
@@ -324,6 +338,14 @@ class MeloDb extends _$MeloDb {
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
}
/// Songtext eines Songs, sofern beim Scan einer im Tag gefunden wurde.
Future<String?> lyricsOf(String songId) async {
final row = await (select(songs)..where((s) => s.id.equals(songId)))
.getSingleOrNull();
final text = row?.lyrics?.trim();
return (text == null || text.isEmpty) ? null : text;
}
// === Kategorien ===
/// Alle Kategorien-Zuordnungen, nach Song gebündelt und in gespeicherter
/// Reihenfolge — die UI braucht sie immer für die ganze sichtbare Liste.
+140 -2
View File
@@ -140,6 +140,24 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
),
defaultValue: const Constant(false),
);
static const VerificationMeta _lyricsMeta = const VerificationMeta('lyrics');
@override
late final GeneratedColumn<String> lyrics = GeneratedColumn<String>(
'lyrics',
aliasedName,
true,
type: DriftSqlType.string,
requiredDuringInsert: false,
);
static const VerificationMeta _gainDbMeta = const VerificationMeta('gainDb');
@override
late final GeneratedColumn<double> gainDb = GeneratedColumn<double>(
'gain_db',
aliasedName,
true,
type: DriftSqlType.double,
requiredDuringInsert: false,
);
@override
List<GeneratedColumn> get $columns => [
id,
@@ -154,6 +172,8 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
deleted,
playCount,
categoriesEdited,
lyrics,
gainDb,
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -255,6 +275,18 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
),
);
}
if (data.containsKey('lyrics')) {
context.handle(
_lyricsMeta,
lyrics.isAcceptableOrUnknown(data['lyrics']!, _lyricsMeta),
);
}
if (data.containsKey('gain_db')) {
context.handle(
_gainDbMeta,
gainDb.isAcceptableOrUnknown(data['gain_db']!, _gainDbMeta),
);
}
return context;
}
@@ -312,6 +344,14 @@ class $SongsTable extends Songs with TableInfo<$SongsTable, Song> {
DriftSqlType.bool,
data['${effectivePrefix}categories_edited'],
)!,
lyrics: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}lyrics'],
),
gainDb: attachedDatabase.typeMapping.read(
DriftSqlType.double,
data['${effectivePrefix}gain_db'],
),
);
}
@@ -340,6 +380,14 @@ class Song extends DataClass implements Insertable<Song> {
/// 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;
/// Songtext aus dem Tag der Datei — Grundlage für den automatischen
/// Songtext ohne Server.
final String? lyrics;
/// ReplayGain des Titels in Dezibel, sofern die Datei den Tag mitbringt —
/// Grundlage für "Gleiche Lautstärke".
final double? gainDb;
const Song({
required this.id,
required this.path,
@@ -353,6 +401,8 @@ class Song extends DataClass implements Insertable<Song> {
required this.deleted,
required this.playCount,
required this.categoriesEdited,
this.lyrics,
this.gainDb,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@@ -377,6 +427,12 @@ class Song extends DataClass implements Insertable<Song> {
map['deleted'] = Variable<bool>(deleted);
map['play_count'] = Variable<int>(playCount);
map['categories_edited'] = Variable<bool>(categoriesEdited);
if (!nullToAbsent || lyrics != null) {
map['lyrics'] = Variable<String>(lyrics);
}
if (!nullToAbsent || gainDb != null) {
map['gain_db'] = Variable<double>(gainDb);
}
return map;
}
@@ -402,6 +458,12 @@ class Song extends DataClass implements Insertable<Song> {
deleted: Value(deleted),
playCount: Value(playCount),
categoriesEdited: Value(categoriesEdited),
lyrics: lyrics == null && nullToAbsent
? const Value.absent()
: Value(lyrics),
gainDb: gainDb == null && nullToAbsent
? const Value.absent()
: Value(gainDb),
);
}
@@ -423,6 +485,8 @@ class Song extends DataClass implements Insertable<Song> {
deleted: serializer.fromJson<bool>(json['deleted']),
playCount: serializer.fromJson<int>(json['playCount']),
categoriesEdited: serializer.fromJson<bool>(json['categoriesEdited']),
lyrics: serializer.fromJson<String?>(json['lyrics']),
gainDb: serializer.fromJson<double?>(json['gainDb']),
);
}
@override
@@ -441,6 +505,8 @@ class Song extends DataClass implements Insertable<Song> {
'deleted': serializer.toJson<bool>(deleted),
'playCount': serializer.toJson<int>(playCount),
'categoriesEdited': serializer.toJson<bool>(categoriesEdited),
'lyrics': serializer.toJson<String?>(lyrics),
'gainDb': serializer.toJson<double?>(gainDb),
};
}
@@ -457,6 +523,8 @@ class Song extends DataClass implements Insertable<Song> {
bool? deleted,
int? playCount,
bool? categoriesEdited,
Value<String?> lyrics = const Value.absent(),
Value<double?> gainDb = const Value.absent(),
}) => Song(
id: id ?? this.id,
path: path ?? this.path,
@@ -470,6 +538,8 @@ class Song extends DataClass implements Insertable<Song> {
deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
categoriesEdited: categoriesEdited ?? this.categoriesEdited,
lyrics: lyrics.present ? lyrics.value : this.lyrics,
gainDb: gainDb.present ? gainDb.value : this.gainDb,
);
Song copyWithCompanion(SongsCompanion data) {
return Song(
@@ -493,6 +563,8 @@ class Song extends DataClass implements Insertable<Song> {
categoriesEdited: data.categoriesEdited.present
? data.categoriesEdited.value
: this.categoriesEdited,
lyrics: data.lyrics.present ? data.lyrics.value : this.lyrics,
gainDb: data.gainDb.present ? data.gainDb.value : this.gainDb,
);
}
@@ -510,7 +582,9 @@ class Song extends DataClass implements Insertable<Song> {
..write('updatedAtMs: $updatedAtMs, ')
..write('deleted: $deleted, ')
..write('playCount: $playCount, ')
..write('categoriesEdited: $categoriesEdited')
..write('categoriesEdited: $categoriesEdited, ')
..write('lyrics: $lyrics, ')
..write('gainDb: $gainDb')
..write(')'))
.toString();
}
@@ -529,6 +603,8 @@ class Song extends DataClass implements Insertable<Song> {
deleted,
playCount,
categoriesEdited,
lyrics,
gainDb,
);
@override
bool operator ==(Object other) =>
@@ -545,7 +621,9 @@ class Song extends DataClass implements Insertable<Song> {
other.updatedAtMs == this.updatedAtMs &&
other.deleted == this.deleted &&
other.playCount == this.playCount &&
other.categoriesEdited == this.categoriesEdited);
other.categoriesEdited == this.categoriesEdited &&
other.lyrics == this.lyrics &&
other.gainDb == this.gainDb);
}
class SongsCompanion extends UpdateCompanion<Song> {
@@ -561,6 +639,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
final Value<bool> deleted;
final Value<int> playCount;
final Value<bool> categoriesEdited;
final Value<String?> lyrics;
final Value<double?> gainDb;
final Value<int> rowid;
const SongsCompanion({
this.id = const Value.absent(),
@@ -575,6 +655,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.categoriesEdited = const Value.absent(),
this.lyrics = const Value.absent(),
this.gainDb = const Value.absent(),
this.rowid = const Value.absent(),
});
SongsCompanion.insert({
@@ -590,6 +672,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
this.deleted = const Value.absent(),
this.playCount = const Value.absent(),
this.categoriesEdited = const Value.absent(),
this.lyrics = const Value.absent(),
this.gainDb = const Value.absent(),
this.rowid = const Value.absent(),
}) : id = Value(id),
path = Value(path),
@@ -609,6 +693,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
Expression<bool>? deleted,
Expression<int>? playCount,
Expression<bool>? categoriesEdited,
Expression<String>? lyrics,
Expression<double>? gainDb,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@@ -624,6 +710,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (deleted != null) 'deleted': deleted,
if (playCount != null) 'play_count': playCount,
if (categoriesEdited != null) 'categories_edited': categoriesEdited,
if (lyrics != null) 'lyrics': lyrics,
if (gainDb != null) 'gain_db': gainDb,
if (rowid != null) 'rowid': rowid,
});
}
@@ -641,6 +729,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
Value<bool>? deleted,
Value<int>? playCount,
Value<bool>? categoriesEdited,
Value<String?>? lyrics,
Value<double?>? gainDb,
Value<int>? rowid,
}) {
return SongsCompanion(
@@ -656,6 +746,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
deleted: deleted ?? this.deleted,
playCount: playCount ?? this.playCount,
categoriesEdited: categoriesEdited ?? this.categoriesEdited,
lyrics: lyrics ?? this.lyrics,
gainDb: gainDb ?? this.gainDb,
rowid: rowid ?? this.rowid,
);
}
@@ -699,6 +791,12 @@ class SongsCompanion extends UpdateCompanion<Song> {
if (categoriesEdited.present) {
map['categories_edited'] = Variable<bool>(categoriesEdited.value);
}
if (lyrics.present) {
map['lyrics'] = Variable<String>(lyrics.value);
}
if (gainDb.present) {
map['gain_db'] = Variable<double>(gainDb.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@@ -720,6 +818,8 @@ class SongsCompanion extends UpdateCompanion<Song> {
..write('deleted: $deleted, ')
..write('playCount: $playCount, ')
..write('categoriesEdited: $categoriesEdited, ')
..write('lyrics: $lyrics, ')
..write('gainDb: $gainDb, ')
..write('rowid: $rowid')
..write(')'))
.toString();
@@ -2598,6 +2698,8 @@ typedef $$SongsTableCreateCompanionBuilder =
Value<bool> deleted,
Value<int> playCount,
Value<bool> categoriesEdited,
Value<String?> lyrics,
Value<double?> gainDb,
Value<int> rowid,
});
typedef $$SongsTableUpdateCompanionBuilder =
@@ -2614,6 +2716,8 @@ typedef $$SongsTableUpdateCompanionBuilder =
Value<bool> deleted,
Value<int> playCount,
Value<bool> categoriesEdited,
Value<String?> lyrics,
Value<double?> gainDb,
Value<int> rowid,
});
@@ -2764,6 +2868,16 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> {
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get lyrics => $composableBuilder(
column: $table.lyrics,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<double> get gainDb => $composableBuilder(
column: $table.gainDb,
builder: (column) => ColumnFilters(column),
);
Expression<bool> playlistSongsRefs(
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
) {
@@ -2932,6 +3046,16 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> {
column: $table.categoriesEdited,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get lyrics => $composableBuilder(
column: $table.lyrics,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<double> get gainDb => $composableBuilder(
column: $table.gainDb,
builder: (column) => ColumnOrderings(column),
);
}
class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
@@ -2986,6 +3110,12 @@ class $$SongsTableAnnotationComposer extends Composer<_$MeloDb, $SongsTable> {
builder: (column) => column,
);
GeneratedColumn<String> get lyrics =>
$composableBuilder(column: $table.lyrics, builder: (column) => column);
GeneratedColumn<double> get gainDb =>
$composableBuilder(column: $table.gainDb, builder: (column) => column);
Expression<T> playlistSongsRefs<T extends Object>(
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
) {
@@ -3132,6 +3262,8 @@ class $$SongsTableTableManager
Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<bool> categoriesEdited = const Value.absent(),
Value<String?> lyrics = const Value.absent(),
Value<double?> gainDb = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => SongsCompanion(
id: id,
@@ -3146,6 +3278,8 @@ class $$SongsTableTableManager
deleted: deleted,
playCount: playCount,
categoriesEdited: categoriesEdited,
lyrics: lyrics,
gainDb: gainDb,
rowid: rowid,
),
createCompanionCallback:
@@ -3162,6 +3296,8 @@ class $$SongsTableTableManager
Value<bool> deleted = const Value.absent(),
Value<int> playCount = const Value.absent(),
Value<bool> categoriesEdited = const Value.absent(),
Value<String?> lyrics = const Value.absent(),
Value<double?> gainDb = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) => SongsCompanion.insert(
id: id,
@@ -3176,6 +3312,8 @@ class $$SongsTableTableManager
deleted: deleted,
playCount: playCount,
categoriesEdited: categoriesEdited,
lyrics: lyrics,
gainDb: gainDb,
rowid: rowid,
),
withReferenceMapper: (p0) => p0
+30
View File
@@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart';
import '../player/replay_gain.dart';
import 'categories.dart';
import 'database.dart';
@@ -64,6 +65,11 @@ Future<int> scanFolders(
// Unlesbare/kaputte Tags: Datei trotzdem mit Dateinamen aufnehmen.
}
// ReplayGain steht je nach Format an unterschiedlicher Stelle und ist im
// vereinheitlichten AudioMetadata nicht enthalten — daher der zweite,
// gezielte Blick in die Roh-Tags.
final gainDb = _readGain(file);
final rawTitle = meta?.title?.trim();
final title = (rawTitle != null && rawTitle.isNotEmpty)
? rawTitle
@@ -92,6 +98,8 @@ Future<int> scanFolders(
dateAddedMs: prev?.dateAddedMs ?? now,
updatedAtMs: now,
deleted: const Value(false),
lyrics: Value(meta?.lyrics),
gainDb: Value(gainDb),
));
if (prev?.categoriesEdited != true) {
categories[id] = parseCategoryList(meta?.genres ?? const []);
@@ -111,3 +119,25 @@ Future<int> scanFolders(
}
return companions.length;
}
/// Liest den ReplayGain-Wert des Titels aus den Roh-Tags: bei FLAC/OGG aus
/// dem Vorbis-Kommentar, bei MP3 aus einem TXXX-Feld. Fehlt der Tag oder
/// lässt sich die Datei nicht lesen, gibt es keine Angleichung.
double? _readGain(File file) {
try {
final tags = readAllMetadata(file, getImage: false);
if (tags is VorbisMetadata) {
return parseReplayGain(tags.replayGainTrackGain.firstOrNull);
}
if (tags is Mp3Metadata) {
for (final entry in tags.customMetadata.entries) {
if (entry.key.toLowerCase() == 'replaygain_track_gain') {
return parseReplayGain(entry.value);
}
}
}
} catch (_) {
// Kein ReplayGain ist kein Fehler — dann bleibt die Lautstärke wie sie ist.
}
return null;
}
+39 -1
View File
@@ -1,3 +1,4 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -83,6 +84,15 @@ class SongList extends StatelessWidget {
/// Menü hinter dem Drei-Punkte-Symbol einer Songzeile.
Future<void> _showMenu(BuildContext context, Song song) async {
final handler = context.read<MeloAudioHandler>();
final categories = context.read<CategoryService>();
final settings = context.read<AppSettings>();
final messenger = ScaffoldMessenger.of(context);
MediaItem item() => songToMediaItem(
song,
cover: categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
await showModalBottomSheet(
context: context,
builder: (sheetContext) => SafeArea(
@@ -97,6 +107,28 @@ class SongList extends StatelessWidget {
SongDetailSheet.show(context, song);
},
),
ListTile(
leading: const Icon(Icons.playlist_play),
title: const Text('Als Nächstes spielen'),
onTap: () async {
Navigator.pop(sheetContext);
await handler.playNext(item());
messenger.showSnackBar(const SnackBar(
content: Text('Läuft als Nächstes')));
},
),
ListTile(
leading: const Icon(Icons.queue),
title: const Text('Zur Warteschlange hinzufügen'),
subtitle: const Text(
'Nur für jetzt — nicht in Favoriten oder Wiedergabelisten'),
onTap: () async {
Navigator.pop(sheetContext);
await handler.addToQueue(item());
messenger.showSnackBar(const SnackBar(
content: Text('Zur Warteschlange hinzugefügt')));
},
),
ListTile(
leading: const Icon(Icons.playlist_add),
title: const Text('Zu Wiedergabeliste hinzufügen'),
@@ -147,7 +179,13 @@ class SongList extends StatelessWidget {
),
onTap: () async {
try {
await playSongs(handler, songs, i);
await playSongs(
handler,
songs,
i,
coverOf: (song) => categories.coverFor(song,
groupByCategory: settings.groupCoversByCategory),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
+24 -12
View File
@@ -3,20 +3,32 @@ import 'package:audio_service/audio_service.dart';
import '../player/audio_handler.dart';
import 'database.dart';
MediaItem songToMediaItem(Song s) => MediaItem(
id: Uri.file(s.path).toString(),
title: s.title,
artist: s.artist,
album: s.album,
duration: s.durationMs != null ? Duration(milliseconds: s.durationMs!) : null,
artUri: s.coverPath != null ? Uri.file(s.coverPath!) : null,
extras: {'songId': s.id},
);
/// [cover] überschreibt das Coverbild des Songs — damit auf Sperrbildschirm
/// und in der Benachrichtigung dasselbe Bild erscheint wie in der App
/// (Einstellung "Gleiche Kategorie = gleiches Coverbild").
MediaItem songToMediaItem(Song s, {String? cover}) {
final art = cover ?? s.coverPath;
return MediaItem(
id: Uri.file(s.path).toString(),
title: s.title,
artist: s.artist,
album: s.album,
duration: s.durationMs != null ? Duration(milliseconds: s.durationMs!) : null,
artUri: art != null ? Uri.file(art) : null,
extras: {'songId': s.id, 'gainDb': s.gainDb},
);
}
/// Spielt [songs] ab [startIndex] ab.
Future<void> playSongs(MeloAudioHandler handler, List<Song> songs, int startIndex) {
/// Spielt [songs] ab [startIndex] ab. [coverOf] liefert je Song das
/// anzuzeigende Cover (siehe [songToMediaItem]).
Future<void> playSongs(
MeloAudioHandler handler,
List<Song> songs,
int startIndex, {
String? Function(Song)? coverOf,
}) {
return handler.loadPlaylist(
songs.map(songToMediaItem).toList(),
[for (final s in songs) songToMediaItem(s, cover: coverOf?.call(s))],
startIndex: startIndex,
);
}