1ddde12959
Two unrelated wins as a single batch.
Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.
New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.
Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.
Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
change rarely (re-scan, MBID enrichment); a day of cache is safe
and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
recompute when contents change; short enough for edits to feel
fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
are immutable for a given id (scanner re-indexes by file_path; new
files get new ids). LockCachingAudioSource on the Flutter side
already disk-caches, but proper headers let it skip even the
conditional 304 on repeat plays.
434 lines
17 KiB
Dart
434 lines
17 KiB
Dart
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,
|
|
// ExoPlayer surfaces stream errors (404, premature EOS, decoder
|
|
// failure, network drop) here. Without an error sink, the
|
|
// player just goes quiet — exactly the "starts then stops"
|
|
// symptom we hit.
|
|
onError: (Object e, StackTrace st) {
|
|
debugPrint('audio_handler: playbackEventStream error: $e\n$st');
|
|
},
|
|
);
|
|
_player.currentIndexStream.listen(_onCurrentIndexChanged);
|
|
// Re-broadcast on shuffle/repeat changes so the PlaybackState's
|
|
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
|
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
|
_player.loopModeStream.listen((_) => _broadcastState(null));
|
|
// Watch buffered-position so we can register stream-cached files
|
|
// in the audio cache index once they're fully downloaded. Without
|
|
// this, files written by LockCachingAudioSource never appear in
|
|
// the index and the eviction loop can't reclaim them.
|
|
_player.bufferedPositionStream
|
|
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
|
// Diagnostic: log every player-state transition so silent stops
|
|
// surface as something we can read in the log.
|
|
_player.playerStateStream.listen((s) {
|
|
debugPrint(
|
|
'audio_handler: state playing=${s.playing} processing=${s.processingState}');
|
|
});
|
|
_player.processingStateStream.listen((ps) {
|
|
debugPrint('audio_handler: processingState=$ps');
|
|
});
|
|
}
|
|
|
|
final AudioPlayer _player = AudioPlayer();
|
|
String _baseUrl = '';
|
|
String? _token;
|
|
AlbumCoverCache? _coverCache;
|
|
AudioCacheManager? _audioCacheManager;
|
|
|
|
/// Trackers to dedupe registration — once we've inserted an index row
|
|
/// for a trackId, don't repeat the work on every buffered-position
|
|
/// emit. Cleared on dispose only; surviving across queue rebuilds is
|
|
/// fine because the index is itself the source of truth.
|
|
final Set<String> _streamCacheRegistered = {};
|
|
|
|
/// Cached on first use so we don't hit the platform channel every
|
|
/// time the buffered-position stream emits (~200ms cadence).
|
|
String? _cacheDirPath;
|
|
|
|
/// True while _fillRemainingSources is doing backward-fill inserts
|
|
/// at index 0..initialIndex-1. Each insert shifts the player's
|
|
/// currentIndex (it tracks the actively-playing source through
|
|
/// list mutations), and the resulting _onCurrentIndexChanged
|
|
/// callbacks would push the wrong MediaItem onto the stream
|
|
/// (queue.value[shifted_idx] != actively-playing track). When
|
|
/// the fill completes, currentIndex == initialIndex, mediaItem
|
|
/// is already correct, and we re-enable normal listener behavior.
|
|
bool _suppressIndexUpdates = false;
|
|
|
|
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
|
/// volume directly; set via setVolume(double).
|
|
Stream<double> get volumeStream => _player.volumeStream;
|
|
double get volume => _player.volume;
|
|
|
|
/// Position stream for UI subscribers. just_audio emits roughly every
|
|
/// 200ms while playing, which is what makes the seek bar advance
|
|
/// smoothly. PlaybackState.updatePosition only changes on event
|
|
/// transitions (play/pause/buffer), so it's too coarse for live
|
|
/// scrubbing UI.
|
|
Stream<Duration> get positionStream => _player.positionStream;
|
|
Duration get position => _player.position;
|
|
|
|
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<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
|
|
if (tracks.isEmpty) return;
|
|
final clampedInitial = initialIndex.clamp(0, tracks.length - 1);
|
|
|
|
// Populate the visible queue + current mediaItem immediately so
|
|
// the player UI reflects the user's tap before any source has
|
|
// been built. Source list at the just_audio layer fills in
|
|
// asynchronously below.
|
|
final items = tracks.map(_toMediaItem).toList();
|
|
queue.add(items);
|
|
mediaItem.add(items[clampedInitial]);
|
|
|
|
debugPrint('audio_handler.setQueueFromTracks: '
|
|
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
|
final sw = Stopwatch()..start();
|
|
|
|
// Fast path: build only the initial source so the player can
|
|
// start. Remaining sources stream in via _fillRemainingSources()
|
|
// in the background — addAudioSource for next/auto-advance
|
|
// tracks, insertAudioSource for skipPrev tracks.
|
|
final initial = await _buildAudioSource(tracks[clampedInitial]);
|
|
debugPrint('audio_handler: built initial source in '
|
|
'${sw.elapsedMilliseconds}ms');
|
|
sw.reset();
|
|
sw.start();
|
|
|
|
await _player.setAudioSources([initial], initialIndex: 0);
|
|
debugPrint('audio_handler: setAudioSources(1) ${sw.elapsedMilliseconds}ms');
|
|
|
|
unawaited(_loadArtForCurrentItem());
|
|
unawaited(_fillRemainingSources(tracks, clampedInitial));
|
|
}
|
|
|
|
/// Background fill of the rest of the just_audio source list after
|
|
/// the initial source is playing. Forward direction first (most
|
|
/// common skipNext target). Backward inserts shift the player's
|
|
/// currentIndex; we suppress _onCurrentIndexChanged side effects
|
|
/// for those so the mediaItem stream doesn't bounce to the wrong
|
|
/// queue entry.
|
|
Future<void> _fillRemainingSources(
|
|
List<TrackRef> tracks, int initialIndex) async {
|
|
final sw = Stopwatch()..start();
|
|
for (var i = initialIndex + 1; i < tracks.length; i++) {
|
|
try {
|
|
final src = await _buildAudioSource(tracks[i]);
|
|
await _player.addAudioSource(src);
|
|
} catch (e) {
|
|
debugPrint('audio_handler: forward fill failed for ${tracks[i].id}: $e');
|
|
}
|
|
}
|
|
debugPrint('audio_handler: forward fill (${tracks.length - initialIndex - 1}) '
|
|
'${sw.elapsedMilliseconds}ms');
|
|
if (initialIndex > 0) {
|
|
sw.reset();
|
|
sw.start();
|
|
_suppressIndexUpdates = true;
|
|
try {
|
|
for (var i = 0; i < initialIndex; i++) {
|
|
final src = await _buildAudioSource(tracks[i]);
|
|
await _player.insertAudioSource(i, src);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('audio_handler: backward fill failed: $e');
|
|
} finally {
|
|
_suppressIndexUpdates = false;
|
|
}
|
|
debugPrint('audio_handler: backward fill ($initialIndex) '
|
|
'${sw.elapsedMilliseconds}ms');
|
|
}
|
|
}
|
|
|
|
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<AudioSource> _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) {
|
|
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
|
final cacheFile = File('${_cacheDirPath!}/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<void> 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<void> 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) {
|
|
// Stash album_id + artist_id in extras so widgets reconstructing
|
|
// a TrackRef from the MediaItem (player kebab → "Go to artist",
|
|
// "Go to album") have the IDs they need to navigate. Earlier code
|
|
// only carried album_id which left "Go to artist" pushing
|
|
// /artists/ (empty id, route 404).
|
|
final extras = <String, dynamic>{};
|
|
if (t.albumId.isNotEmpty) extras['album_id'] = t.albumId;
|
|
if (t.artistId.isNotEmpty) extras['artist_id'] = t.artistId;
|
|
return MediaItem(
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artistName,
|
|
album: t.albumTitle,
|
|
duration: Duration(seconds: t.durationSec),
|
|
extras: extras.isEmpty ? null : extras,
|
|
);
|
|
}
|
|
|
|
/// Once a track is fully buffered (LockCaching has written the whole
|
|
/// file to disk), insert an audio_cache_index row so the file shows
|
|
/// up to AudioCacheManager.evict() and clearAll(). No-op if the
|
|
/// cache manager isn't configured, no current track, the file isn't
|
|
/// fully buffered yet, the on-disk file is missing, or we already
|
|
/// registered this trackId during this subscription.
|
|
Future<void> _maybeRegisterStreamCache() async {
|
|
final mgr = _audioCacheManager;
|
|
if (mgr == null) return;
|
|
final current = mediaItem.value;
|
|
if (current == null) return;
|
|
final trackId = current.id;
|
|
if (_streamCacheRegistered.contains(trackId)) return;
|
|
|
|
final dur = _player.duration;
|
|
if (dur == null) return;
|
|
final buf = _player.bufferedPosition;
|
|
// 200ms slack for header bytes / encoding rounding.
|
|
if (buf < dur - const Duration(milliseconds: 200)) return;
|
|
|
|
_cacheDirPath ??= (await getApplicationCacheDirectory()).path;
|
|
final path = '${_cacheDirPath!}/audio_cache/$trackId.mp3';
|
|
final file = File(path);
|
|
if (!await file.exists()) return;
|
|
final size = await file.length();
|
|
if (size <= 0) return;
|
|
|
|
_streamCacheRegistered.add(trackId);
|
|
await mgr.registerStreamCache(trackId, path, size);
|
|
debugPrint(
|
|
'audio_handler: registered stream cache for $trackId ($size bytes)');
|
|
}
|
|
|
|
void _onCurrentIndexChanged(int? idx) {
|
|
if (idx == null) return;
|
|
if (_suppressIndexUpdates) return;
|
|
// Push the new track's MediaItem onto the mediaItem stream so
|
|
// the player UI rebuilds with the new title/artist/album/cover.
|
|
// Without this, the bar and full player stayed pinned to whichever
|
|
// track was passed via setQueueFromTracks(initialIndex:) regardless
|
|
// of skip/auto-advance.
|
|
final items = queue.value;
|
|
if (idx >= 0 && idx < items.length) {
|
|
mediaItem.add(items[idx]);
|
|
}
|
|
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<void> _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<void> play() => _player.play();
|
|
|
|
@override
|
|
Future<void> pause() => _player.pause();
|
|
|
|
@override
|
|
Future<void> seek(Duration position) => _player.seek(position);
|
|
|
|
@override
|
|
Future<void> skipToNext() => _player.seekToNext();
|
|
|
|
@override
|
|
Future<void> skipToPrevious() => _player.seekToPrevious();
|
|
|
|
@override
|
|
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
|
|
await _player
|
|
.setShuffleModeEnabled(shuffleMode != AudioServiceShuffleMode.none);
|
|
// _broadcastState picks up the change via shuffleModeEnabledStream.
|
|
}
|
|
|
|
@override
|
|
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) async {
|
|
final loop = switch (repeatMode) {
|
|
AudioServiceRepeatMode.none => LoopMode.off,
|
|
AudioServiceRepeatMode.one => LoopMode.one,
|
|
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
|
|
};
|
|
await _player.setLoopMode(loop);
|
|
}
|
|
|
|
/// Sets player volume in [0.0, 1.0]. Note: most mobile browsers tie
|
|
/// page audio to system volume — this is the in-app slider for parity
|
|
/// with desktop/web; on mobile the system volume is the real control.
|
|
Future<void> setVolume(double v) async {
|
|
await _player.setVolume(v.clamp(0.0, 1.0));
|
|
}
|
|
|
|
// _broadcastState accepts a nullable event because the shuffle/repeat
|
|
// listeners don't have one — we just want to re-emit PlaybackState
|
|
// with up-to-date shuffleMode/repeatMode fields.
|
|
void _broadcastState(PlaybackEvent? event) {
|
|
final playing = _player.playing;
|
|
playbackState.add(PlaybackState(
|
|
controls: [
|
|
MediaControl.skipToPrevious,
|
|
if (playing) MediaControl.pause else MediaControl.play,
|
|
MediaControl.skipToNext,
|
|
],
|
|
// androidCompactActionIndices tells the system which controls
|
|
// appear in the collapsed/lock-screen view. Without this, some
|
|
// Android versions render the player without working buttons.
|
|
androidCompactActionIndices: const [0, 1, 2],
|
|
// systemActions enumerates which actions the system can invoke
|
|
// on us — without play/pause/skip in here, taps on lock-screen
|
|
// controls don't route back to the handler on Android 13+.
|
|
systemActions: const {
|
|
MediaAction.play,
|
|
MediaAction.pause,
|
|
MediaAction.skipToNext,
|
|
MediaAction.skipToPrevious,
|
|
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 ?? _player.currentIndex,
|
|
shuffleMode: _player.shuffleModeEnabled
|
|
? AudioServiceShuffleMode.all
|
|
: AudioServiceShuffleMode.none,
|
|
repeatMode: switch (_player.loopMode) {
|
|
LoopMode.off => AudioServiceRepeatMode.none,
|
|
LoopMode.one => AudioServiceRepeatMode.one,
|
|
LoopMode.all => AudioServiceRepeatMode.all,
|
|
},
|
|
));
|
|
}
|
|
}
|