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/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); } /// 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 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); /// 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 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; 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]); 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]. /// /// 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"). Future loadPlaylist(List items, {int startIndex = 0}) async { queue.add(items); // Neue Warteschlange: der erste Titel soll wieder zählen, auch wenn er // denselben Index wie der zuletzt gezählte hat. _lastCountedIndex = null; final sources = []; for (final item in items) { // Ein vollständig zwischengespeicherter Server-Titel läuft offline. final navId = navidromeIdOf(item); final treffer = navId == null ? null : await _cache.getCachedFile(NavidromeService.cacheSchluessel(navId)); sources.add(AudioSource.uri( quelleFuer(item, cacheTreffer: treffer?.path), tag: item, )); } await _player.setAudioSources(sources, initialIndex: startIndex); // Bei bekannter letzter Position an dieser Stelle fortsetzen, // statt immer von vorne zu beginnen. final item = items[startIndex]; final resumeMs = await _fortsetzPosition(item); if (resumeMs != null) { await _player.seek(Duration(milliseconds: resumeMs), index: startIndex); } await play(); // Server-Titel im Hintergrund ablegen — das darf den Start nie aufhalten. final navId = navidromeIdOf(item); if (navId != null) { unawaited(_nav.ladeInCache(navId, Uri.parse(item.id), _cache)); } } /// 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. 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(); @override Future pause() => _player.pause(); @override Future stop() async { sleepTimer.cancel(); _positionRecordTimer.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; }