diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bd6f2..adeec9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### đŸŽŒ Songtext, Warteschlange, Sperrbildschirm-Cover und gleiche LautstĂ€rke (2026-08-20) +- 📝 **Automatischer Songtext:** Beim Scan wird der Songtext aus dem Tag der + Datei mitgelesen und in der Wiedergabe sofort angezeigt — **ohne Server und + ohne Internet**. Nur wenn die Datei keinen Text enthĂ€lt, wird der + Navidrome-Server gefragt. + 🐛 Nebenbei gefixt: Der Songtext-Knopf hat bisher die Datei-Adresse statt der + Song-ID weitergegeben, dadurch kam vom Server nie ein Text zurĂŒck. +- ➕ **Warteschlange ohne Nebenwirkungen:** Im ⋼-MenĂŒ einer Songzeile gibt es + jetzt **"Als NĂ€chstes spielen"** und **"Zur Warteschlange hinzufĂŒgen"** — + beides Ă€ndert **nichts** an Favoriten oder Wiedergabelisten und + unterbricht die laufende Wiedergabe nicht. Umgekehrt entfernt das + ✕ in der Warteschlange den Titel weiterhin nur dort — er bleibt Favorit + und bleibt in jeder Wiedergabeliste. +- đŸ–Œïž **Sperrbildschirm zeigt dasselbe Bild wie die App:** LĂ€uft ein Titel, + bekommt der Sperrbildschirm jetzt das **Kategorie-Cover** (sofern die + Einstellung "Gleiche Kategorie = gleiches Coverbild" an ist) statt eines + abweichenden Bildes. Auch Wiedergabelisten zeigen jetzt dieses Cover. +- 🔉 **Gleiche LautstĂ€rke (ReplayGain):** Neuer Schalter unter *Einstellungen → + Ton* (standardmĂ€ĂŸig an). Melo liest den ReplayGain-Tag der Datei und + **senkt laute Titel ab**, damit alles gleich laut wirkt. + ⚠ **Ehrlich dazugesagt:** VerstĂ€rken kann ein Player nicht ĂŒber 100 % + hinaus — angeglichen wird deshalb immer nach unten. Und es funktioniert + nur bei Dateien, die den Tag mitbringen (typisch FLAC/OGG und mit + ReplayGain versehene MP3s). Dateien ohne Tag bleiben unverĂ€ndert. +- 🔧 **Details:** + - DB-Schema 6: neue Spalten `lyrics` und `gain_db`. + - Neu: `player/replay_gain.dart` (7 Tests), `playNextIndex` + + `addToQueue`/`playNext` im Audio-Handler (3 Tests), + Cover-Vorgabe in `songToMediaItem` (3 Tests). +- ✅ **Getestet:** 154 Tests grĂŒn, `flutter analyze` ohne Befund. + ### đŸŽšïž Equalizer, Dolby Atmos und automatische Wiedergabe (2026-08-20) - ✅ **Was:** Die Einstellungen haben einen neuen Bereich **Ton** mit Equalizer, Dolby Atmos und der automatischen Wiedergabe bei Kopfhörern. diff --git a/lib/library/database.dart b/lib/library/database.dart index 29b197b..7a78201 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -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 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 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. diff --git a/lib/library/database.g.dart b/lib/library/database.g.dart index 57871c4..1eea1fd 100644 --- a/lib/library/database.g.dart +++ b/lib/library/database.g.dart @@ -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 lyrics = GeneratedColumn( + 'lyrics', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _gainDbMeta = const VerificationMeta('gainDb'); + @override + late final GeneratedColumn gainDb = GeneratedColumn( + 'gain_db', + aliasedName, + true, + type: DriftSqlType.double, + requiredDuringInsert: false, + ); @override List 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 { /// 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 { required this.deleted, required this.playCount, required this.categoriesEdited, + this.lyrics, + this.gainDb, }); @override Map toColumns(bool nullToAbsent) { @@ -377,6 +427,12 @@ class Song extends DataClass implements Insertable { map['deleted'] = Variable(deleted); map['play_count'] = Variable(playCount); map['categories_edited'] = Variable(categoriesEdited); + if (!nullToAbsent || lyrics != null) { + map['lyrics'] = Variable(lyrics); + } + if (!nullToAbsent || gainDb != null) { + map['gain_db'] = Variable(gainDb); + } return map; } @@ -402,6 +458,12 @@ class Song extends DataClass implements Insertable { 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 { deleted: serializer.fromJson(json['deleted']), playCount: serializer.fromJson(json['playCount']), categoriesEdited: serializer.fromJson(json['categoriesEdited']), + lyrics: serializer.fromJson(json['lyrics']), + gainDb: serializer.fromJson(json['gainDb']), ); } @override @@ -441,6 +505,8 @@ class Song extends DataClass implements Insertable { 'deleted': serializer.toJson(deleted), 'playCount': serializer.toJson(playCount), 'categoriesEdited': serializer.toJson(categoriesEdited), + 'lyrics': serializer.toJson(lyrics), + 'gainDb': serializer.toJson(gainDb), }; } @@ -457,6 +523,8 @@ class Song extends DataClass implements Insertable { bool? deleted, int? playCount, bool? categoriesEdited, + Value lyrics = const Value.absent(), + Value gainDb = const Value.absent(), }) => Song( id: id ?? this.id, path: path ?? this.path, @@ -470,6 +538,8 @@ class Song extends DataClass implements Insertable { 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 { 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 { ..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 { deleted, playCount, categoriesEdited, + lyrics, + gainDb, ); @override bool operator ==(Object other) => @@ -545,7 +621,9 @@ class Song extends DataClass implements Insertable { 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 { @@ -561,6 +639,8 @@ class SongsCompanion extends UpdateCompanion { final Value deleted; final Value playCount; final Value categoriesEdited; + final Value lyrics; + final Value gainDb; final Value rowid; const SongsCompanion({ this.id = const Value.absent(), @@ -575,6 +655,8 @@ class SongsCompanion extends UpdateCompanion { 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 { 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 { Expression? deleted, Expression? playCount, Expression? categoriesEdited, + Expression? lyrics, + Expression? gainDb, Expression? rowid, }) { return RawValuesInsertable({ @@ -624,6 +710,8 @@ class SongsCompanion extends UpdateCompanion { 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 { Value? deleted, Value? playCount, Value? categoriesEdited, + Value? lyrics, + Value? gainDb, Value? rowid, }) { return SongsCompanion( @@ -656,6 +746,8 @@ class SongsCompanion extends UpdateCompanion { 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 { if (categoriesEdited.present) { map['categories_edited'] = Variable(categoriesEdited.value); } + if (lyrics.present) { + map['lyrics'] = Variable(lyrics.value); + } + if (gainDb.present) { + map['gain_db'] = Variable(gainDb.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -720,6 +818,8 @@ class SongsCompanion extends UpdateCompanion { ..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 deleted, Value playCount, Value categoriesEdited, + Value lyrics, + Value gainDb, Value rowid, }); typedef $$SongsTableUpdateCompanionBuilder = @@ -2614,6 +2716,8 @@ typedef $$SongsTableUpdateCompanionBuilder = Value deleted, Value playCount, Value categoriesEdited, + Value lyrics, + Value gainDb, Value rowid, }); @@ -2764,6 +2868,16 @@ class $$SongsTableFilterComposer extends Composer<_$MeloDb, $SongsTable> { builder: (column) => ColumnFilters(column), ); + ColumnFilters get lyrics => $composableBuilder( + column: $table.lyrics, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get gainDb => $composableBuilder( + column: $table.gainDb, + builder: (column) => ColumnFilters(column), + ); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableFilterComposer f) f, ) { @@ -2932,6 +3046,16 @@ class $$SongsTableOrderingComposer extends Composer<_$MeloDb, $SongsTable> { column: $table.categoriesEdited, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get lyrics => $composableBuilder( + column: $table.lyrics, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings 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 get lyrics => + $composableBuilder(column: $table.lyrics, builder: (column) => column); + + GeneratedColumn get gainDb => + $composableBuilder(column: $table.gainDb, builder: (column) => column); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableAnnotationComposer a) f, ) { @@ -3132,6 +3262,8 @@ class $$SongsTableTableManager Value deleted = const Value.absent(), Value playCount = const Value.absent(), Value categoriesEdited = const Value.absent(), + Value lyrics = const Value.absent(), + Value gainDb = const Value.absent(), Value 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 deleted = const Value.absent(), Value playCount = const Value.absent(), Value categoriesEdited = const Value.absent(), + Value lyrics = const Value.absent(), + Value gainDb = const Value.absent(), Value 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 diff --git a/lib/library/scan_service.dart b/lib/library/scan_service.dart index 1cab331..c3c7fa7 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 '../player/replay_gain.dart'; import 'categories.dart'; import 'database.dart'; @@ -64,6 +65,11 @@ Future 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 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 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; +} diff --git a/lib/library/song_list.dart b/lib/library/song_list.dart index 3a80c27..ffb8ec2 100644 --- a/lib/library/song_list.dart +++ b/lib/library/song_list.dart @@ -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 _showMenu(BuildContext context, Song song) async { + final handler = context.read(); + final categories = context.read(); + final settings = context.read(); + 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( diff --git a/lib/library/song_media.dart b/lib/library/song_media.dart index b92d319..d3724db 100644 --- a/lib/library/song_media.dart +++ b/lib/library/song_media.dart @@ -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 playSongs(MeloAudioHandler handler, List songs, int startIndex) { +/// Spielt [songs] ab [startIndex] ab. [coverOf] liefert je Song das +/// anzuzeigende Cover (siehe [songToMediaItem]). +Future playSongs( + MeloAudioHandler handler, + List 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, ); } diff --git a/lib/main.dart b/lib/main.dart index 6e77b0f..9c46d03 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -54,10 +54,13 @@ Future main() async { _handler.androidAudioSessionIdStream.listen((id) { if (id != null) _effects.attach(id); }); - _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones; - _settings.addListener( - () => _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones, - ); + void uebernehmeEinstellungen() { + _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones; + _handler.normalizeVolume = _settings.normalizeVolume; + } + + uebernehmeEinstellungen(); + _settings.addListener(uebernehmeEinstellungen); runApp(const MeloApp()); } diff --git a/lib/player/audio_handler.dart b/lib/player/audio_handler.dart index 412654c..092f23e 100644 --- a/lib/player/audio_handler.dart +++ b/lib/player/audio_handler.dart @@ -7,6 +7,7 @@ import '../library/database.dart'; import '../services/cache_manager.dart'; import '../services/navidrome_service.dart'; import 'headphone_autoplay.dart'; +import 'replay_gain.dart'; import 'sleep_timer.dart'; /// Entscheidet, ob eine gespeicherte Wiedergabeposition beim Laden @@ -28,6 +29,13 @@ bool shouldResumeAt(int lastPositionMs, Duration? trackDuration) { bool shouldCountPlay(int? newIndex, int? lastCountedIndex) => newIndex != null && newIndex != lastCountedIndex; +/// Stelle, an der ein Titel eingefĂŒgt wird, damit er als NĂ€chstes lĂ€uft: +/// direkt hinter dem laufenden. LĂ€uft nichts, kommt er ans Ende. +int playNextIndex({required int? currentIndex, required int queueLength}) { + if (currentIndex == null) return queueLength; + return (currentIndex + 1).clamp(0, queueLength); +} + /// Kern der Wiedergabe: kapselt just_audio hinter audio_service, /// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren. class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { @@ -43,6 +51,10 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { /// Wird von den App-Einstellungen gesetzt. bool autoPlayOnHeadphones = false; + /// Alle Titel auf eine Ă€hnliche LautstĂ€rke bringen (ReplayGain). + /// Wird von den App-Einstellungen gesetzt. + bool normalizeVolume = true; + MeloAudioHandler({required this.db}) { _cache = CacheManager(); _cache.init(); @@ -69,6 +81,7 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final q = queue.value; if (index != null && index < q.length) { mediaItem.add(q[index].copyWith(duration: _player.duration)); + _applyGain(q[index]); if (shouldCountPlay(index, _lastCountedIndex)) { _lastCountedIndex = index; final songId = q[index].extras?['songId'] as String?; @@ -88,6 +101,13 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { }); } + /// Gleicht die LautstĂ€rke des Titels an. Ohne ReplayGain-Tag oder bei + /// abgeschalteter Einstellung bleibt sie unverĂ€ndert. + void _applyGain(MediaItem item) { + final gain = item.extras?['gainDb'] as double?; + _player.setVolume(normalizeVolume ? volumeForGain(gain) : 1.0); + } + /// Startet die Wiedergabe, wenn Kopfhörer verbunden werden und der Nutzer /// das in den Einstellungen erlaubt hat. Future _watchHeadphones() async { @@ -163,6 +183,26 @@ class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { await play(); } + /// HĂ€ngt [item] hinten an die Warteschlange an, ohne die laufende + /// Wiedergabe zu unterbrechen. Favoriten und Wiedergabelisten bleiben + /// unberĂŒhrt — die Warteschlange ist nur fĂŒr diese Sitzung. + Future addToQueue(MediaItem item) async { + await _player.addAudioSource(AudioSource.uri(Uri.parse(item.id), tag: item)); + queue.add([...queue.value, item]); + } + + /// Spielt [item] als NĂ€chstes, direkt nach dem laufenden Titel. + Future playNext(MediaItem item) async { + final index = playNextIndex( + currentIndex: _player.currentIndex, + queueLength: queue.value.length, + ); + await _player.insertAudioSource( + index, AudioSource.uri(Uri.parse(item.id), tag: item)); + final updated = [...queue.value]..insert(index, item); + queue.add(updated); + } + @override Future play() => _player.play(); diff --git a/lib/player/now_playing_screen.dart b/lib/player/now_playing_screen.dart index c63b798..0782dcc 100644 --- a/lib/player/now_playing_screen.dart +++ b/lib/player/now_playing_screen.dart @@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../library/database.dart'; import '../services/navidrome_service.dart'; import '../shared/cover.dart'; import '../shared/favorite_button.dart'; @@ -36,9 +37,10 @@ class NowPlayingScreen extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ IconButton( - tooltip: 'Lyrics', + tooltip: 'Songtext', icon: const Icon(Icons.lyrics), - onPressed: () => _showLyrics(context, item.id), + onPressed: () => _showLyrics( + context, item.extras?['songId'] as String? ?? item.id), ), FavoriteButton(songId: item.extras?['songId'] as String? ?? ''), ], @@ -314,26 +316,36 @@ class _LyricsSheet extends StatefulWidget { class _LyricsSheetState extends State<_LyricsSheet> { late final NavidromeService _nav = NavidromeService(); - Lyrics? _lyrics; + String? _text; bool _loading = true; @override void initState() { super.initState(); - _nav.ladeGespeicherteZugangsdaten().then((_) { - if (_nav.istVerbunden) { - _nav.getLyrics(widget.songId).then((lyrics) { - if (mounted) { - setState(() { - _lyrics = lyrics; - _loading = false; - }); - } + _load(); + } + + /// Zuerst der Songtext aus dem Tag der Datei — der ist sofort da und + /// funktioniert offline. Nur wenn keiner drinsteht, wird der Server gefragt. + Future _load() async { + final db = context.read(); + final lokal = await db.lyricsOf(widget.songId); + if (lokal != null) { + if (mounted) setState(() { _text = lokal; _loading = false; }); + return; + } + await _nav.ladeGespeicherteZugangsdaten(); + if (_nav.istVerbunden) { + final lyrics = await _nav.getLyrics(widget.songId); + if (mounted) { + setState(() { + _text = lyrics.isEmpty ? null : lyrics.text; + _loading = false; }); - } else { - setState(() => _loading = false); } - }); + return; + } + if (mounted) setState(() => _loading = false); } @override @@ -347,7 +359,7 @@ class _LyricsSheetState extends State<_LyricsSheet> { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('📝 Lyrics', + const Text('📝 Songtext', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), IconButton( icon: const Icon(Icons.close), @@ -361,15 +373,15 @@ class _LyricsSheetState extends State<_LyricsSheet> { ? const Center( child: CircularProgressIndicator(color: MeloTheme.red), ) - : _lyrics?.isEmpty ?? true + : _text == null ? const Center( - child: Text('Keine Lyrics verfĂŒgbar', + child: Text('Kein Songtext verfĂŒgbar', style: TextStyle(color: Colors.white54)), ) : SingleChildScrollView( padding: const EdgeInsets.all(16), child: Text( - _lyrics?.text ?? '', + _text!, style: const TextStyle( fontSize: 14, height: 1.6, diff --git a/lib/player/replay_gain.dart b/lib/player/replay_gain.dart new file mode 100644 index 0000000..9c35c68 --- /dev/null +++ b/lib/player/replay_gain.dart @@ -0,0 +1,20 @@ +import 'dart:math' as math; + +/// Liest einen ReplayGain-Wert aus einem Tag wie "-6.54 dB". +/// Gibt null zurĂŒck, wenn im Tag nichts Brauchbares steht. +double? parseReplayGain(String? raw) { + if (raw == null) return null; + final match = RegExp(r'-?\+?\d+(\.\d+)?').firstMatch(raw.trim()); + if (match == null) return null; + return double.tryParse(match.group(0)!.replaceFirst('+', '')); +} + +/// Rechnet eine ReplayGain-Angabe in Dezibel in einen LautstĂ€rkefaktor um. +/// +/// Angeglichen wird ausschließlich **nach unten**: laute Titel werden leiser +/// gemacht. Über 100 % hinaus kann der Player nicht verstĂ€rken, deshalb +/// bleiben Titel mit positivem Wert unverĂ€ndert. +double volumeForGain(double? gainDb) { + if (gainDb == null || gainDb >= 0) return 1.0; + return math.pow(10, gainDb / 20).toDouble().clamp(0.0, 1.0); +} diff --git a/lib/playlists/playlist_detail_screen.dart b/lib/playlists/playlist_detail_screen.dart index 5fd022c..19616c5 100644 --- a/lib/playlists/playlist_detail_screen.dart +++ b/lib/playlists/playlist_detail_screen.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../library/category_service.dart'; import '../library/database.dart'; import '../library/playlist_service.dart'; import '../library/song_media.dart'; import '../player/audio_handler.dart'; +import '../settings/app_settings.dart'; import '../shared/cover.dart'; /// Playlist-Detailansicht: Songs anzeigen, per Drag & Drop umsortieren, entfernen. @@ -17,6 +19,8 @@ class PlaylistDetailScreen extends StatelessWidget { final db = context.read(); final handler = context.read(); final service = context.read(); + final categories = context.watch(); + final settings = context.watch(); return Scaffold( appBar: AppBar(title: Text(playlist.name)), body: StreamBuilder>( @@ -47,11 +51,15 @@ class PlaylistDetailScreen extends StatelessWidget { final s = songs[i]; return ListTile( key: ValueKey(s.id), - leading: CoverImage( - artUri: s.coverPath != null ? Uri.file(s.coverPath!) : null, - size: 48, - radius: 6, - ), + leading: Builder(builder: (context) { + final cover = categories.coverFor(s, + groupByCategory: settings.groupCoversByCategory); + return CoverImage( + 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), @@ -62,7 +70,13 @@ class PlaylistDetailScreen 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( diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart index 9aca760..7137d44 100644 --- a/lib/settings/app_settings.dart +++ b/lib/settings/app_settings.dart @@ -6,9 +6,11 @@ import 'package:shared_preferences/shared_preferences.dart'; class AppSettings extends ChangeNotifier { static const _keyGroupCovers = 'gleiche_kategorie_gleiches_cover'; static const _keyAutoPlayHeadphones = 'autoplay_kopfhoerer'; + static const _keyNormalizeVolume = 'gleiche_lautstaerke'; bool _groupCoversByCategory = true; bool _autoPlayOnHeadphones = false; + bool _normalizeVolume = true; /// "Gleiche Kategorie = gleiches Coverbild" — an: das Cover kommt von der /// ersten Kategorie des Songs, aus: jeder Song zeigt sein eigenes Bild. @@ -17,10 +19,15 @@ class AppSettings extends ChangeNotifier { /// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden. bool get autoPlayOnHeadphones => _autoPlayOnHeadphones; + /// "Gleiche LautstĂ€rke": laute Titel werden anhand ihres ReplayGain-Tags + /// auf das Niveau der leiseren abgesenkt. + bool get normalizeVolume => _normalizeVolume; + Future init() async { final prefs = await SharedPreferences.getInstance(); _groupCoversByCategory = prefs.getBool(_keyGroupCovers) ?? true; _autoPlayOnHeadphones = prefs.getBool(_keyAutoPlayHeadphones) ?? false; + _normalizeVolume = prefs.getBool(_keyNormalizeVolume) ?? true; notifyListeners(); } @@ -32,6 +39,14 @@ class AppSettings extends ChangeNotifier { await prefs.setBool(_keyGroupCovers, value); } + Future setNormalizeVolume(bool value) async { + if (_normalizeVolume == value) return; + _normalizeVolume = value; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_keyNormalizeVolume, value); + } + Future setAutoPlayOnHeadphones(bool value) async { if (_autoPlayOnHeadphones == value) return; _autoPlayOnHeadphones = value; diff --git a/lib/settings/settings_screen.dart b/lib/settings/settings_screen.dart index 234fad5..127032a 100644 --- a/lib/settings/settings_screen.dart +++ b/lib/settings/settings_screen.dart @@ -123,6 +123,16 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.open_in_new), onTap: () => _oeffneSystemKlangeffekte(context), ), + Consumer( + builder: (context, settings, _) => SwitchListTile( + secondary: const Icon(Icons.equalizer), + title: const Text('Gleiche LautstĂ€rke'), + subtitle: const Text( + 'Senkt laute Titel ab (nur bei Dateien mit ReplayGain-Tag)'), + value: settings.normalizeVolume, + onChanged: settings.setNormalizeVolume, + ), + ), Consumer( builder: (context, settings, _) => SwitchListTile( secondary: const Icon(Icons.headset), diff --git a/lib/shared/sortable_song_list.dart b/lib/shared/sortable_song_list.dart index d656d34..36cd495 100644 --- a/lib/shared/sortable_song_list.dart +++ b/lib/shared/sortable_song_list.dart @@ -4,8 +4,10 @@ import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/song_list.dart'; import '../library/song_media.dart'; +import '../library/category_service.dart'; import '../library/song_sort.dart'; import '../player/audio_handler.dart'; +import '../settings/app_settings.dart'; import 'sort_store.dart'; import 'theme.dart'; @@ -47,10 +49,18 @@ class _SortableSongListState extends State { Future _shuffle() async { if (widget.songs.isEmpty) return; final handler = context.read(); + final categories = context.read(); + final settings = context.read(); final messenger = ScaffoldMessenger.of(context); final shuffled = [...widget.songs]..shuffle(); try { - await playSongs(handler, shuffled, 0); + await playSongs( + handler, + shuffled, + 0, + coverOf: (song) => categories.coverFor(song, + groupByCategory: settings.groupCoversByCategory), + ); } catch (e) { messenger.showSnackBar( SnackBar(content: Text('Wiedergabe fehlgeschlagen: $e')), diff --git a/test/library/song_media_test.dart b/test/library/song_media_test.dart index 1f48057..b635586 100644 --- a/test/library/song_media_test.dart +++ b/test/library/song_media_test.dart @@ -2,6 +2,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:melo/library/database.dart'; import 'package:melo/library/song_media.dart'; +Song _song({String? coverPath}) => Song( + id: 'song-1', + path: '/a.mp3', + title: 'A', + coverPath: coverPath, + dateAddedMs: 0, + updatedAtMs: 0, + deleted: false, + playCount: 0, + categoriesEdited: false, + ); + void main() { test('songToMediaItem trĂ€gt die Song-UUID in extras', () { final song = Song( @@ -23,4 +35,22 @@ void main() { expect(item.extras?['songId'], song.id); }); + + test('ohne Cover-Vorgabe gilt das eigene Cover des Songs', () { + final item = songToMediaItem(_song(coverPath: '/eigen.img')); + + expect(item.artUri, Uri.file('/eigen.img')); + }); + + test('Cover-Vorgabe (z. B. aus der Kategorie) gewinnt — auch auf dem ' + 'Sperrbildschirm', () { + final item = + songToMediaItem(_song(coverPath: '/eigen.img'), cover: '/kategorie.img'); + + expect(item.artUri, Uri.file('/kategorie.img')); + }); + + test('ohne jedes Cover bleibt artUri leer', () { + expect(songToMediaItem(_song()).artUri, isNull); + }); } diff --git a/test/player/queue_insert_test.dart b/test/player/queue_insert_test.dart new file mode 100644 index 0000000..ac5302e --- /dev/null +++ b/test/player/queue_insert_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/audio_handler.dart'; + +void main() { + group('playNextIndex', () { + test('fĂŒgt direkt hinter dem laufenden Titel ein', () { + expect(playNextIndex(currentIndex: 0, queueLength: 3), 1); + expect(playNextIndex(currentIndex: 2, queueLength: 5), 3); + }); + + test('hĂ€ngt ans Ende, wenn der letzte Titel lĂ€uft', () { + expect(playNextIndex(currentIndex: 2, queueLength: 3), 3); + }); + + test('hĂ€ngt ans Ende, wenn nichts lĂ€uft', () { + expect(playNextIndex(currentIndex: null, queueLength: 4), 4); + expect(playNextIndex(currentIndex: null, queueLength: 0), 0); + }); + }); +} diff --git a/test/player/replay_gain_test.dart b/test/player/replay_gain_test.dart new file mode 100644 index 0000000..98bee07 --- /dev/null +++ b/test/player/replay_gain_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/player/replay_gain.dart'; + +void main() { + group('parseReplayGain', () { + test('liest das ĂŒbliche Tag-Format', () { + expect(parseReplayGain('-6.54 dB'), closeTo(-6.54, 0.001)); + expect(parseReplayGain('+2.30 dB'), closeTo(2.30, 0.001)); + expect(parseReplayGain('-8.120000 dB'), closeTo(-8.12, 0.001)); + }); + + test('kommt auch ohne Einheit zurecht', () { + expect(parseReplayGain('-3.2'), closeTo(-3.2, 0.001)); + }); + + test('ignoriert unbrauchbare Werte', () { + expect(parseReplayGain(null), isNull); + expect(parseReplayGain(''), isNull); + expect(parseReplayGain('keine Ahnung'), isNull); + }); + }); + + group('volumeForGain', () { + test('ohne Wert bleibt die LautstĂ€rke unverĂ€ndert', () { + expect(volumeForGain(null), 1.0); + }); + + test('negative VerstĂ€rkung senkt die LautstĂ€rke ab', () { + // -6 dB entspricht ungefĂ€hr der halben Amplitude. + expect(volumeForGain(-6), closeTo(0.501, 0.005)); + expect(volumeForGain(-20), closeTo(0.1, 0.005)); + }); + + test('positive VerstĂ€rkung bleibt bei voller LautstĂ€rke', () { + // Lauter als 100 % geht nicht — angeglichen wird nach unten. + expect(volumeForGain(6), 1.0); + }); + + test('sehr starke Absenkung bleibt hörbar', () { + expect(volumeForGain(-60), greaterThan(0.0)); + }); + }); +}