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:
@@ -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((_) {
|
||||
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<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) {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user