perf: lazy player source build + Cache-Control on byte endpoints
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.
This commit is contained in:
@@ -60,6 +60,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
/// 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;
|
||||
@@ -90,31 +100,74 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
}
|
||||
|
||||
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);
|
||||
if (items.isNotEmpty) {
|
||||
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
|
||||
}
|
||||
mediaItem.add(items[clampedInitial]);
|
||||
|
||||
debugPrint('audio_handler.setQueueFromTracks: '
|
||||
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
||||
final sw = Stopwatch()..start();
|
||||
final sources = await Future.wait(tracks.map(_buildAudioSource));
|
||||
debugPrint('audio_handler: built ${sources.length} sources in '
|
||||
|
||||
// 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(
|
||||
sources,
|
||||
initialIndex: initialIndex,
|
||||
);
|
||||
debugPrint('audio_handler: setAudioSources ${sw.elapsedMilliseconds}ms');
|
||||
await _player.setAudioSources([initial], initialIndex: 0);
|
||||
debugPrint('audio_handler: setAudioSources(1) ${sw.elapsedMilliseconds}ms');
|
||||
|
||||
// Kick the cover fetch for the initial item — async, doesn't block
|
||||
// playback. Subsequent track changes are handled by the
|
||||
// currentIndexStream listener.
|
||||
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) {
|
||||
@@ -259,6 +312,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
||||
|
||||
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
|
||||
|
||||
@@ -112,6 +112,12 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", imageContentType(path))
|
||||
// Cover bytes change rarely (cover-source enrichment, manual rescan,
|
||||
// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
|
||||
// clients skip the conditional GET for the bulk of a session, but
|
||||
// stale art clears within 24h after a re-scan. ServeContent below
|
||||
// still emits Last-Modified for the conditional path when needed.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||
}
|
||||
|
||||
@@ -140,5 +146,11 @@ func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
// Track bytes are immutable for a given track id (the scanner
|
||||
// indexes by file_path; re-encoded files take new ids). One-year
|
||||
// max-age + immutable lets the client cache (LockCachingAudioSource
|
||||
// on the Flutter side, browser cache on web) skip even the
|
||||
// conditional GET on repeat plays.
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
||||
}
|
||||
|
||||
@@ -386,6 +386,11 @@ func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
full := filepath.Join(h.dataDir, *detail.CoverPath)
|
||||
// Playlist collages recompute when the playlist's tracks change
|
||||
// (system playlists re-rendered on rebuild, user playlists when
|
||||
// modified). 5 minutes is short enough for normal edits to feel
|
||||
// fresh, long enough to skip repeat fetches during a session.
|
||||
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
|
||||
http.ServeFile(w, r, full)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user