import 'dart:async'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import 'package:path_provider/path_provider.dart'; import '../cache/audio_cache_manager.dart'; import '../models/track.dart'; import 'album_cover_cache.dart'; class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler { MinstrelAudioHandler() { _player.playbackEventStream.listen(_broadcastState); _player.currentIndexStream.listen(_onCurrentIndexChanged); } final AudioPlayer _player = AudioPlayer(); String _baseUrl = ''; String? _token; AlbumCoverCache? _coverCache; AudioCacheManager? _audioCacheManager; void configure({ required String baseUrl, required String? token, AlbumCoverCache? coverCache, AudioCacheManager? audioCacheManager, }) { _baseUrl = baseUrl; _token = token; if (coverCache != null) _coverCache = coverCache; if (audioCacheManager != null) _audioCacheManager = audioCacheManager; debugPrint('audio_handler.configure: baseUrl="$baseUrl" ' 'tokenPresent=${token != null && token.isNotEmpty} ' 'coverCachePresent=${_coverCache != null} ' 'audioCachePresent=${_audioCacheManager != null}'); } Future setQueueFromTracks(List tracks, {int initialIndex = 0}) async { final items = tracks.map(_toMediaItem).toList(); queue.add(items); if (items.isNotEmpty) { mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]); } debugPrint('audio_handler.setQueueFromTracks: ' '_baseUrl="$_baseUrl" trackCount=${tracks.length}'); final sources = await Future.wait(tracks.map(_buildAudioSource)); await _player.setAudioSources( sources, initialIndex: initialIndex, ); // Kick the cover fetch for the initial item — async, doesn't block // playback. Subsequent track changes are handled by the // currentIndexStream listener. unawaited(_loadArtForCurrentItem()); } String _resolveStreamUrl(TrackRef t) { if (t.streamUrl.isEmpty) { return '$_baseUrl/api/tracks/${t.id}/stream'; } final parsed = Uri.tryParse(t.streamUrl); if (parsed != null && parsed.hasScheme) { return t.streamUrl; } return '$_baseUrl${t.streamUrl}'; } /// Builds an AudioSource for a track. Cache-aware: /// 1. If the track is fully cached on disk, returns a file:// source. /// 2. Else returns a LockCachingAudioSource that streams + caches as /// it plays (subsequent plays will hit the cache). /// /// Without an audio cache manager configured (e.g. in older code paths /// that pre-date #357), falls back to a plain network AudioSource.uri. Future _buildAudioSource(TrackRef t) async { final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; final mgr = _audioCacheManager; // 1. Cache hit: play from disk, no headers needed. if (mgr != null) { final path = await mgr.pathFor(t.id); if (path != null) { debugPrint('audio_handler: cache hit for track.id=${t.id} → file://$path'); return AudioSource.uri(Uri.file(path)); } } final url = _resolveStreamUrl(t); final parsed = Uri.parse(url); if (!parsed.hasScheme || parsed.host.isEmpty) { throw StateError( 'audio_handler: refused to play scheme-less URL "$url" ' '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' 'track.id="${t.id}"). configure() must be called with a ' 'non-empty baseUrl before setQueueFromTracks().', ); } // 2. Cache miss WITH manager: stream + cache-as-you-play. Future // plays of this track will hit the cache. If the cache write // completes, we don't currently register an index row — that would // require a download-complete hook from just_audio that's not // exposed cleanly. Acceptable for v1: prefetcher / explicit // pin / Download buttons cover the index path; LockCaching handles // the network optimization. if (mgr != null) { final cacheDir = await getApplicationCacheDirectory(); final cacheFile = File('${cacheDir.path}/audio_cache/${t.id}.mp3'); debugPrint('audio_handler: cache miss for track.id=${t.id}, ' 'using LockCachingAudioSource → ${cacheFile.path}'); // ignore: experimental_member_use return LockCachingAudioSource(parsed, headers: headers, cacheFile: cacheFile); } // 3. No manager configured: plain network source (legacy path). debugPrint('audio_handler: no cache manager; track.id=${t.id} → "$url"'); return AudioSource.uri(parsed, headers: headers); } /// Inserts [track] right after the currently-playing item so it plays /// next. If nothing is playing, appends to the end. Future playNext(TrackRef track) async { final source = await _buildAudioSource(track); final item = _toMediaItem(track); final currentIdx = _player.currentIndex; final insertAt = currentIdx == null ? queue.value.length : currentIdx + 1; await _player.insertAudioSource(insertAt, source); final current = queue.value; queue.add([ ...current.sublist(0, insertAt), item, ...current.sublist(insertAt), ]); } /// Appends [track] to the end of the queue. Future enqueue(TrackRef track) async { final source = await _buildAudioSource(track); final item = _toMediaItem(track); await _player.addAudioSource(source); queue.add([...queue.value, item]); } MediaItem _toMediaItem(TrackRef t) => MediaItem( id: t.id, title: t.title, artist: t.artistName, album: t.albumTitle, duration: Duration(seconds: t.durationSec), // Stash album_id in extras so _loadArtForCurrentItem can pull it // back without re-walking the track list. extras: t.albumId.isEmpty ? null : {'album_id': t.albumId}, ); void _onCurrentIndexChanged(int? idx) { if (idx == null) return; unawaited(_loadArtForCurrentItem()); } /// Async-fetches the cover for whichever item is currently active and /// pushes a MediaItem update with artUri set. No-op if no cache is /// configured, no current item, the item has no album_id in extras, /// or the fetch returns null. Future _loadArtForCurrentItem() async { final cache = _coverCache; if (cache == null) return; final current = mediaItem.value; if (current == null) return; final albumId = current.extras?['album_id'] as String?; if (albumId == null || albumId.isEmpty) return; if (current.artUri != null) return; // already set final path = await cache.getOrFetch(albumId); if (path == null) return; // Discard if the user advanced to another track while we waited. if (mediaItem.value?.id != current.id) return; mediaItem.add(current.copyWith(artUri: Uri.file(path))); } @override Future play() => _player.play(); @override Future pause() => _player.pause(); @override Future seek(Duration position) => _player.seek(position); @override Future skipToNext() => _player.seekToNext(); @override Future skipToPrevious() => _player.seekToPrevious(); void _broadcastState(PlaybackEvent event) { final playing = _player.playing; playbackState.add(PlaybackState( controls: [ MediaControl.skipToPrevious, if (playing) MediaControl.pause else MediaControl.play, MediaControl.skipToNext, ], systemActions: const {MediaAction.seek}, processingState: switch (_player.processingState) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, ProcessingState.buffering => AudioProcessingState.buffering, ProcessingState.ready => AudioProcessingState.ready, ProcessingState.completed => AudioProcessingState.completed, }, playing: playing, updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, queueIndex: event.currentIndex, )); } }