import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; import '../library/database.dart'; import '../services/cache_manager.dart'; import '../services/navidrome_service.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; } /// 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(); MeloAudioHandler({required this.db}) { _cache = CacheManager(); _cache.init(); sleepTimer = SleepTimer(onElapsed: pause); // just_audio-Events → audio_service PlaybackState _player.playbackEventStream.map(_transformEvent).pipe(playbackState); // Wiedergabeposition alle ~5s persistieren, solange aktiv abgespielt wird. _positionRecordTimer = Timer.periodic(const Duration(seconds: 5), (_) { final songId = mediaItem.value?.extras?['songId'] as String?; if (_player.playing && songId != null) { db.recordPlayback(songId, _player.position.inMilliseconds); } }); // 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)); } }); // Ermittelte Dauer nachtragen _player.durationStream.listen((duration) { final item = mediaItem.value; if (item != null && duration != null) { mediaItem.add(item.copyWith(duration: duration)); } }); } /// Ersetzt die Warteschlange und startet ab [startIndex]. /// Auto-Caching: Streame von Server + speichere lokal gleichzeitig. Future loadPlaylist(List items, {int startIndex = 0}) async { queue.add(items); await _nav.ladeGespeicherteZugangsdaten(); final sources = []; for (final item in items) { Uri sourceUri = Uri.parse(item.id); if (_nav.istVerbunden) { final cachedUri = await _nav.streamAndCacheToLocal(item.id, _cache); if (cachedUri != null) { sourceUri = cachedUri; } } sources.add(AudioSource.uri(sourceUri, tag: item)); } await _player.setAudioSources(sources, initialIndex: startIndex); // Bei bekannter letzter Position an dieser Stelle fortsetzen, // statt immer von vorne zu beginnen. final songId = items[startIndex].extras?['songId'] as String?; if (songId != null) { final lastMs = await db.lastPosition(songId); if (lastMs != null && shouldResumeAt(lastMs, items[startIndex].duration)) { await _player.seek(Duration(milliseconds: lastMs), index: startIndex); } } await play(); } @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; }