From 1ddde129597f860976ff3a100b6e0d06fe23339d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 22:18:58 -0400 Subject: [PATCH] perf: lazy player source build + Cache-Control on byte endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- flutter_client/lib/player/audio_handler.dart | 80 ++++++++++++++++---- internal/api/media.go | 12 +++ internal/api/playlists.go | 5 ++ 3 files changed, 84 insertions(+), 13 deletions(-) diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 3bade142..663f9c28 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 get volumeStream => _player.volumeStream; @@ -90,31 +100,74 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl } Future setQueueFromTracks(List 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 _fillRemainingSources( + List 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 diff --git a/internal/api/media.go b/internal/api/media.go index 1e0e7d8e..0ff6cbbb 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -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) } diff --git a/internal/api/playlists.go b/internal/api/playlists.go index 4c8079bb..4d845cee 100644 --- a/internal/api/playlists.go +++ b/internal/api/playlists.go @@ -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) }