import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:just_audio/just_audio.dart'; import '../library/database.dart'; import '../services/cache_manager.dart'; import '../services/download_speicher.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 /// wiederhergestellt werden soll. Kein Resume bei Position 0 oder wenn die /// Position zu nah am (bekannten) Ende des Tracks liegt — sonst würde die /// Wiedergabe sofort wieder enden. bool shouldResumeAt(int lastPositionMs, Duration? trackDuration) { if (lastPositionMs <= 0) return false; if (trackDuration != null && lastPositionMs >= trackDuration.inMilliseconds - 3000) { return false; } return true; } /// Entscheidet, ob für den Titel an [newIndex] eine Wiedergabe gezählt wird. /// Gezählt wird jeder Titelwechsel; eine Wiederholung desselben Titels /// (LoopMode.one) zählt bewusst nicht erneut. 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); } /// Entfernt den Titel an [removeIndex] aus [queue]. List removeFromQueue(List queue, int removeIndex) { return List.from(queue)..removeAt(removeIndex); } /// Verschiebt den Titel von [oldIndex] nach [newIndex]. `newIndex` ist bereits /// um das entfernte Element bereinigt (siehe `ReorderableListView.onReorderItem`) /// und passt damit zu `AudioPlayer.moveAudioSource`. List reorderQueue(List queue, int oldIndex, int newIndex) { final result = List.from(queue); final moved = result.removeAt(oldIndex); result.insert(newIndex, moved); return result; } /// Der zuletzt als Wiedergabe gezählte Index, nachdem der Titel an /// [entfernt] aus der Warteschlange genommen wurde. /// /// Ohne diese Nachführung zeigt der gemerkte Index auf den Nachbarn: der /// laufende Titel bekäme beim nächsten `currentIndexStream`-Ereignis eine /// zweite Wiedergabe gutgeschrieben, obwohl er nie neu gestartet wurde. int? gezaehlterIndexNachEntfernen(int? gezaehlt, int entfernt) { if (gezaehlt == null) return null; if (gezaehlt == entfernt) return null; return gezaehlt > entfernt ? gezaehlt - 1 : gezaehlt; } /// Der zuletzt als Wiedergabe gezählte Index, nachdem ein Titel von [von] /// nach [nach] verschoben wurde. Siehe [gezaehlterIndexNachEntfernen]. int? gezaehlterIndexNachVerschieben(int? gezaehlt, int von, int nach) { if (gezaehlt == null) return null; if (gezaehlt == von) return nach; if (von < gezaehlt && gezaehlt <= nach) return gezaehlt - 1; if (nach <= gezaehlt && gezaehlt < von) return gezaehlt + 1; return gezaehlt; } /// Navidrome-Song-ID eines Warteschlangen-Eintrags, oder `null` bei einem /// Titel vom Gerät. /// /// [MediaItem.id] ist immer schon eine abspielbare URI — `file://` beim Gerät, /// die fertige Stream-Adresse beim Server — und taugt deshalb NICHT als /// Server-ID. Früher wurde sie genau dafür benutzt: die App fragte den Server /// nach einem Titel namens `file:///storage/...`, bekam eine Fehlerantwort und /// gab die an den Player weiter. Lokale Lieder waren dadurch unabspielbar /// ("(0) Source error"). String? navidromeIdOf(MediaItem item) => item.extras?['navidromeId'] as String?; /// UUID des Titels in der lokalen Bibliothek, oder `null` bei einem Titel, /// der nur am Server liegt. Trägt Historie, Wiedergabezähler und Resume. String? songIdOf(MediaItem item) => item.extras?['songId'] as String?; /// Ob der Titel mit der Bibliotheks-UUID [songId] gerade in der Wiedergabe /// steht. [aktuell] ist der laufende Eintrag, oder `null`, wenn nichts läuft. /// /// Der leere [songId] gilt nie als laufend: Titel, die nur am Server liegen, /// haben keine UUID, und ohne diese Prüfung markierte ein Server-Titel jede /// Zeile, der ebenfalls eine fehlt. bool istAmLaufen(MediaItem? aktuell, String songId) => songId.isNotEmpty && aktuell != null && songIdOf(aktuell) == songId; /// Ob für [item] im Server-Cache nachgesehen wird. Titel vom Gerät liegen /// bereits als Datei vor — für sie darf der Server nie befragt werden. bool nutztServerCache(MediaItem item) => navidromeIdOf(item) != null; /// Abspielbare Quelle eines Warteschlangen-Eintrags. [cacheTreffer] ist der /// Pfad einer bereits vollständig zwischengespeicherten Datei, sonst `null`. Uri quelleFuer(MediaItem item, {String? cacheTreffer}) => cacheTreffer != null ? Uri.file(cacheTreffer) : Uri.parse(item.id); /// Ob [item] ohne Netz läuft: Titel vom Gerät immer, Server-Titel nur mit /// vollständigem Cache-Treffer. bool istOfflineVerfuegbar(MediaItem item, {required bool cacheTreffer}) => navidromeIdOf(item) == null || cacheTreffer; /// Der Startindex, nachdem Titel aus der Warteschlange gefiltert wurden. /// [behalten] hat einen Eintrag je ursprünglichem Titel. /// /// Fällt der gewählte Titel selbst weg, rückt der nächste behaltene an seine /// Stelle — der Nutzer landet dort, wo er getippt hat, und nicht am Anfang. int startIndexNachFilter(List behalten, int startIndex) { var davor = 0; for (var i = 0; i < startIndex && i < behalten.length; i++) { if (behalten[i]) davor++; } final gesamt = behalten.where((b) => b).length; if (gesamt == 0) return 0; return davor.clamp(0, gesamt - 1); } /// Im Offline-Modus ist keiner der gewählten Titel ohne Netz abspielbar. class NichtsOfflineVerfuegbar implements Exception { const NichtsOfflineVerfuegbar(); @override String toString() => 'Im Offline-Modus ist keiner dieser Titel verfügbar'; } /// Kern der Wiedergabe: kapselt just_audio hinter audio_service, /// damit Hintergrund-Wiedergabe + Lockscreen/Notification funktionieren. class MeloAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { final AudioPlayer _player = AudioPlayer(); final MeloDb db; late final SleepTimer sleepTimer; late final Timer _positionRecordTimer; late final CacheManager _cache; final DownloadSpeicher _downloads = DownloadSpeicher(); /// Cache-Dateien, die gerade als Quelle in der Warteschlange stehen. Sie /// dürfen nicht verdrängt werden — die Pfade sind im Player schon fest /// eingetragen, eine gelöschte Datei ließe ihn mitten im Album abbrechen. final Set _benutzteCacheDateien = {}; final NavidromeService _nav = NavidromeService(); int? _lastCountedIndex; /// Wiedergabe automatisch starten, sobald Kopfhörer verbunden werden. /// 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; /// Nur abspielen, was ohne Netz da ist. Wird vom Offline-Schalter in den /// Einstellungen gesetzt; siehe [loadPlaylist]. bool offlineOnly = false; /// Obergrenze des Zwischenspeichers in Bytes. Wird von den Einstellungen /// gesetzt; bewusst heruntergeladene Titel liegen woanders und zählen nicht /// mit (siehe [DownloadSpeicher]). /// /// **0 heißt „nichts behalten"**, nicht „unbegrenzt" — die Einstellung /// bietet genau diese Wahl an. Der Startwert entspricht der Voreinstellung /// der App, damit ein noch nicht übernommener Wert nie versehentlich den /// ganzen Zwischenspeicher leert. int cacheGrenzeBytes = 2048 * 1024 * 1024; MeloAudioHandler({required this.db}) { _cache = CacheManager(); _cache.init(); // Einmalig beim Start — nicht bei jeder Wiedergabe, sonst verzögert ein // Speicherzugriff jeden Titelstart. Gebraucht nur für Server-Titel. unawaited(_nav.ladeGespeicherteZugangsdaten()); sleepTimer = SleepTimer(onElapsed: pause); // just_audio-Events → audio_service PlaybackState _player.playbackEventStream.map(_transformEvent).pipe(playbackState); // Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird. // Synche auch zum Server wenn verbunden. _positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) { final item = mediaItem.value; if (!_player.playing || item == null) return; final posMs = _player.position.inMilliseconds; final songId = songIdOf(item); if (songId != null) db.recordPlayback(songId, posMs); // Nur echte Server-Titel scrobbeln — eine lokale UUID kennt der // Server nicht und jeder Aufruf wäre eine Anfrage ins Leere. final navId = navidromeIdOf(item); if (navId != null && _nav.istVerbunden) { _nav.scrobble(navId, posMs ~/ 1000); } }); // Aktuellen Track ans System melden (Lockscreen/Notification). // Bereits bekannte Dauer erhalten, damit sie nicht auf null zurückfällt. _player.currentIndexStream.listen((index) { final q = queue.value; if (index != null && index < q.length) { mediaItem.add(q[index].copyWith(duration: _player.duration)); _applyGain(q[index]); // Jetzt läuft er wirklich — erst das macht ihn im Sinne der // Verdrängung zu einem „zuletzt gehörten" Titel. final navId = navidromeIdOf(q[index]); if (navId != null) { unawaited(_cache.beruehre(NavidromeService.cacheSchluessel(navId))); } if (shouldCountPlay(index, _lastCountedIndex)) { _lastCountedIndex = index; final songId = songIdOf(q[index]); if (songId != null) db.incrementPlayCount(songId); } } }); _watchHeadphones(); // Ermittelte Dauer nachtragen _player.durationStream.listen((duration) { final item = mediaItem.value; if (item != null && duration != null) { mediaItem.add(item.copyWith(duration: duration)); } }); } /// 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 { final session = await AudioSession.instance; session.devicesChangedEventStream.listen((event) { final added = event.devicesAdded.map((d) => d.type).toSet(); if (shouldAutoPlay( added: added, enabled: autoPlayOnHeadphones, hasQueue: queue.value.isNotEmpty, playing: _player.playing, )) { play(); } }); } /// Audio-Session des Players — die Klangeffekte hängen daran. Stream get androidAudioSessionIdStream => _player.androidAudioSessionIdStream; /// Ersetzt die Warteschlange und startet ab [startIndex]. Gibt zurück, wie /// viele Titel der Offline-Modus übersprungen hat (sonst 0). /// /// Baut die Quellen ohne einen einzigen Netzzugriff: [MediaItem.id] ist /// bereits abspielbar. Früher lud diese Schleife die *ganze* Warteschlange /// vorab vom Server herunter — bei einer Bibliothek mit hunderten Titeln /// begann die Wiedergabe deshalb nie, und ein zweiter Tipp brach den /// laufenden Ladevorgang ab ("Loading interrupted"). /// /// Ist [offlineOnly] gesetzt, fallen Server-Titel ohne Cache-Treffer heraus, /// statt still gestreamt zu werden. Wirft [NichtsOfflineVerfuegbar], wenn /// dadurch nichts übrig bleibt. Future loadPlaylist(List items, {int startIndex = 0}) async { // Ohne Titel gibt es nichts zu tun. Ohne diese Zeile sähe der Filter // unten "alle übersprungen" und meldete fälschlich den Offline-Modus. if (items.isEmpty) return 0; // Die alte Warteschlange ist gleich weg; ihre Dateien dürfen wieder // verdrängt werden. _benutzteCacheDateien.clear(); final quellen = [for (final item in items) await _quelle(item)]; final behalten = [for (final quelle in quellen) quelle != null]; final uebersprungen = behalten.where((b) => !b).length; if (uebersprungen == items.length) throw const NichtsOfflineVerfuegbar(); final gefiltert = []; final sources = []; for (var i = 0; i < items.length; i++) { final quelle = quellen[i]; if (quelle == null) continue; gefiltert.add(items[i]); sources.add(quelle); } final start = startIndexNachFilter(behalten, startIndex); queue.add(gefiltert); // Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er // denselben Index wie der zuletzt gezählte hat. _lastCountedIndex = null; await _player.setAudioSources(sources, initialIndex: start); // Bei bekannter letzter Position an dieser Stelle fortsetzen, // statt immer von vorne zu beginnen. final item = gefiltert[start]; final resumeMs = await _fortsetzPosition(item); if (resumeMs != null) { await _player.seek(Duration(milliseconds: resumeMs), index: start); } await play(); // Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten. final navId = navidromeIdOf(item); // Wer den Titel bewusst heruntergeladen hat, braucht keine zweite Kopie // im Zwischenspeicher — das wäre doppelter Platz und doppeltes Datenvolumen. if (navId != null && await _downloads.treffer(navId) == null) { unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache).then((_) { // Direkt nach dem Ablegen aufräumen — sonst wächst der // Zwischenspeicher weiter unbegrenzt. raeumeZwischenspeicherAuf(); })); } return uebersprungen; } /// Baut die Abspiel-Quelle für [item] und sieht dabei einmal im Cache nach: /// ein vollständig zwischengespeicherter Server-Titel läuft ohne Netz. /// /// Gibt `null` zurück, wenn [offlineOnly] gesetzt ist und der Titel das Netz /// bräuchte. Eine Stelle für alle drei Wege in die Warteschlange — sonst /// gälte der Offline-Modus beim Starten, aber nicht beim Anhängen. Future _quelle(MediaItem item) async { final navId = navidromeIdOf(item); // Erst die bewussten Downloads, dann der Zwischenspeicher: was der Nutzer // mitgenommen hat, soll nie am Netz hängen — auch nicht, wenn zufällig // eine Kopie im Cache liegt, die morgen verdrängt sein kann. final treffer = navId == null ? null : await _downloads.treffer(navId) ?? await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId)); if (offlineOnly && !istOfflineVerfuegbar(item, cacheTreffer: treffer != null)) { return null; } if (treffer != null) _benutzteCacheDateien.add(treffer.path); return AudioSource.uri( quelleFuer(item, cacheTreffer: treffer?.path), tag: item, ); } /// Hält den Zwischenspeicher unter der eingestellten Grenze, ohne dabei /// Dateien der laufenden Warteschlange anzufassen. Future raeumeZwischenspeicherAuf() => _cache.begrenzeAuf(cacheGrenzeBytes, geschuetzt: _benutzteCacheDateien); /// Position, an der [item] fortgesetzt wird, oder `null` für den Anfang. /// Der Server zählt nur bei Server-Titeln — für Titel vom Gerät gilt /// allein die lokale Historie. Future _fortsetzPosition(MediaItem item) async { final navId = navidromeIdOf(item); if (navId != null && _nav.istVerbunden) { final serverMs = await _nav.getBookmark(navId); if (serverMs != null && shouldResumeAt(serverMs, item.duration)) { return serverMs; } } final songId = songIdOf(item); if (songId != null) { final localMs = await db.lastPosition(songId); if (localMs != null && shouldResumeAt(localMs, item.duration)) { return localMs; } } return null; } /// 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. /// /// `false`, wenn der Offline-Modus den Titel ausschließt. Future addToQueue(MediaItem item) async { final quelle = await _quelle(item); if (quelle == null) return false; await _player.addAudioSource(quelle); queue.add([...queue.value, item]); return true; } /// Spielt [item] als Nächstes, direkt nach dem laufenden Titel. /// `false`, wenn der Offline-Modus den Titel ausschließt. /// /// Eingefügt wird immer *hinter* dem laufenden Titel — der zuletzt gezählte /// Index verschiebt sich dadurch nie. Future playNext(MediaItem item) async { final quelle = await _quelle(item); if (quelle == null) return false; final index = playNextIndex( currentIndex: _player.currentIndex, queueLength: queue.value.length, ); await _player.insertAudioSource(index, quelle); final updated = [...queue.value]..insert(index, item); queue.add(updated); return true; } /// Nimmt den Titel an [index] aus der Warteschlange, ohne die Wiedergabe /// zu unterbrechen. /// /// Früher baute diese Aktion die ganze Warteschlange mit [loadPlaylist] neu /// auf: die Wiedergabe setzte hörbar aus, sprang an den Anfang des Titels /// zurück und wurde erneut gezählt. `just_audio` kann einzelne Quellen /// entfernen — der laufende Titel merkt davon nichts. /// Warteschlange und Zählerstand werden **vor** dem Player gesetzt. /// /// `just_audio` sendet beim Umbauen seiner Quellenliste noch während des /// `await` neue Index-Ereignisse. Käme unsere Buchführung erst danach, sähe /// der Zuhörer auf `currentIndexStream` (siehe Konstruktor) die *alte* /// Warteschlange und meldete den falschen Titel — samt Wiedergabe-Zählung /// für ein Lied, das gar nicht läuft. [loadPlaylist] macht es genauso. @override Future removeQueueItemAt(int index) async { final aktuell = queue.value; if (index < 0 || index >= aktuell.length) return; final rest = removeFromQueue(aktuell, index); queue.add(rest); _lastCountedIndex = gezaehlterIndexNachEntfernen(_lastCountedIndex, index); await _player.removeAudioSourceAt(index); // War es der letzte Titel, bleibt sonst eine Benachrichtigung ohne // Wiedergabe stehen — mitsamt Knöpfen, die ins Leere greifen. if (rest.isEmpty) await stop(); } /// Verschiebt den Titel von [oldIndex] nach [newIndex], ohne die Wiedergabe /// zu unterbrechen. Siehe [removeQueueItemAt] — auch hier steht die /// Buchführung vor dem Player. Future moveQueueItem(int oldIndex, int newIndex) async { final aktuell = queue.value; if (oldIndex < 0 || oldIndex >= aktuell.length) return; if (newIndex < 0 || newIndex >= aktuell.length) return; if (oldIndex == newIndex) return; queue.add(reorderQueue(aktuell, oldIndex, newIndex)); _lastCountedIndex = gezaehlterIndexNachVerschieben(_lastCountedIndex, oldIndex, newIndex); await _player.moveAudioSource(oldIndex, newIndex); } @override Future play() => _player.play(); @override Future pause() => _player.pause(); /// Der Positions-Timer wird hier bewusst **nicht** abgebrochen. /// /// Er ist ein `Timer.periodic` und entsteht nur einmal im Konstruktor — /// einmal abgebrochen, käme er für den Rest der App-Sitzung nicht zurück. /// [stop] löst aber nicht nur der Nutzer aus: das System ruft es beim /// Wegwischen der Benachrichtigung auf. Danach merkte sich die App keine /// Hörposition mehr und scrobbelte nicht mehr — bis zum Neustart und ohne /// jeden Hinweis. Er kostet nichts: seine erste Zeile steigt aus, solange /// nichts läuft. Abgebrochen wird er allein in [dispose]. @override Future stop() async { sleepTimer.cancel(); await _player.stop(); await super.stop(); } /// Gibt Hintergrund-Ressourcen frei (Positions-Timer), ohne die Wiedergabe /// zu stoppen. Für Tests, die den Handler ohne vollständigen App-Lifecycle /// verwenden und daher [stop] nicht durchlaufen. void dispose() { _positionRecordTimer.cancel(); } @override Future seek(Duration position) => _player.seek(position); @override Future skipToNext() => _player.seekToNext(); @override Future skipToPrevious() => _player.seekToPrevious(); @override Future skipToQueueItem(int index) => _player.seek(Duration.zero, index: index); @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { final enabled = shuffleMode == AudioServiceShuffleMode.all; if (enabled) await _player.shuffle(); await _player.setShuffleModeEnabled(enabled); } @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) { return _player.setLoopMode(switch (repeatMode) { AudioServiceRepeatMode.one => LoopMode.one, AudioServiceRepeatMode.all => LoopMode.all, _ => LoopMode.off, }); } PlaybackState _transformEvent(PlaybackEvent event) { return PlaybackState( controls: [ MediaControl.skipToPrevious, if (_player.playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, ], systemActions: const { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, }, androidCompactActionIndices: const [0, 1, 2], processingState: const { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, ProcessingState.buffering: AudioProcessingState.buffering, ProcessingState.ready: AudioProcessingState.ready, ProcessingState.completed: AudioProcessingState.completed, }[_player.processingState] ?? AudioProcessingState.idle, playing: _player.playing, updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, queueIndex: event.currentIndex, ); } // Streams, die die UI beobachtet. Dauer direkt vom Player — zuverlässiger // als mediaItem.duration, das beim Track-Wechsel kurzzeitig null sein kann. Stream get positionStream => _player.positionStream; Stream get durationStream => _player.durationStream; }