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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,20 +3,32 @@ import 'package:audio_service/audio_service.dart';
|
||||
import '../player/audio_handler.dart';
|
||||
import 'database.dart';
|
||||
|
||||
MediaItem songToMediaItem(Song s) => MediaItem(
|
||||
/// [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: s.coverPath != null ? Uri.file(s.coverPath!) : null,
|
||||
extras: {'songId': s.id},
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
+6
-3
@@ -54,10 +54,13 @@ Future<void> main() async {
|
||||
_handler.androidAudioSessionIdStream.listen((id) {
|
||||
if (id != null) _effects.attach(id);
|
||||
});
|
||||
void uebernehmeEinstellungen() {
|
||||
_handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones;
|
||||
_settings.addListener(
|
||||
() => _handler.autoPlayOnHeadphones = _settings.autoPlayOnHeadphones,
|
||||
);
|
||||
_handler.normalizeVolume = _settings.normalizeVolume;
|
||||
}
|
||||
|
||||
uebernehmeEinstellungen();
|
||||
_settings.addListener(uebernehmeEinstellungen);
|
||||
|
||||
runApp(const MeloApp());
|
||||
}
|
||||
|
||||
@@ -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<void> _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<void> 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<void> 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<void> play() => _player.play();
|
||||
|
||||
|
||||
@@ -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((_) {
|
||||
_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<void> _load() async {
|
||||
final db = context.read<MeloDb>();
|
||||
final lokal = await db.lyricsOf(widget.songId);
|
||||
if (lokal != null) {
|
||||
if (mounted) setState(() { _text = lokal; _loading = false; });
|
||||
return;
|
||||
}
|
||||
await _nav.ladeGespeicherteZugangsdaten();
|
||||
if (_nav.istVerbunden) {
|
||||
_nav.getLyrics(widget.songId).then((lyrics) {
|
||||
final lyrics = await _nav.getLyrics(widget.songId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_lyrics = lyrics;
|
||||
_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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<MeloDb>();
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final service = context.read<PlaylistService>();
|
||||
final categories = context.watch<CategoryService>();
|
||||
final settings = context.watch<AppSettings>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(playlist.name)),
|
||||
body: StreamBuilder<List<Song>>(
|
||||
@@ -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,
|
||||
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(
|
||||
|
||||
@@ -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<void> 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<void> setNormalizeVolume(bool value) async {
|
||||
if (_normalizeVolume == value) return;
|
||||
_normalizeVolume = value;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_keyNormalizeVolume, value);
|
||||
}
|
||||
|
||||
Future<void> setAutoPlayOnHeadphones(bool value) async {
|
||||
if (_autoPlayOnHeadphones == value) return;
|
||||
_autoPlayOnHeadphones = value;
|
||||
|
||||
@@ -123,6 +123,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const Icon(Icons.open_in_new),
|
||||
onTap: () => _oeffneSystemKlangeffekte(context),
|
||||
),
|
||||
Consumer<AppSettings>(
|
||||
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<AppSettings>(
|
||||
builder: (context, settings, _) => SwitchListTile(
|
||||
secondary: const Icon(Icons.headset),
|
||||
|
||||
@@ -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<SortableSongList> {
|
||||
Future<void> _shuffle() async {
|
||||
if (widget.songs.isEmpty) return;
|
||||
final handler = context.read<MeloAudioHandler>();
|
||||
final categories = context.read<CategoryService>();
|
||||
final settings = context.read<AppSettings>();
|
||||
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')),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user