From 9cac66467961fe7f8757c9a1ee44b99d8442f09a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 17:46:32 -0400 Subject: [PATCH] feat(flutter): register stream-cached files in the audio cache index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap where LockCachingAudioSource wrote files to disk but never told AudioCacheManager about them — meaning evict() couldn't reclaim stream-cached files when usage exceeded the cap, only explicitly-pinned downloads. Wire just_audio's bufferedPositionStream as the "download complete" signal: when bufferedPosition reaches duration (with 200ms slack for header bytes), look up the on-disk file at the LockCaching path, read its size, and insert an audio_cache_index row via the new AudioCacheManager.registerStreamCache(). Source defaults to incidental so stream-cached tracks are first to be evicted under pressure. Dedupe via _streamCacheRegistered Set so we don't hit drift on every ~200ms buffered-position emit. Cache the application cache dir path on first use for the same reason. Eviction now sees the full set of files on disk; usageBytes() (which already walks the dir) and evict() (which reads the index) are finally consistent for stream-cached tracks. Pinned tracks keep their existing manual-download flow unchanged. --- .../lib/cache/audio_cache_manager.dart | 21 ++++++++ flutter_client/lib/player/audio_handler.dart | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/flutter_client/lib/cache/audio_cache_manager.dart b/flutter_client/lib/cache/audio_cache_manager.dart index 34a4a061..5c4afecb 100644 --- a/flutter_client/lib/cache/audio_cache_manager.dart +++ b/flutter_client/lib/cache/audio_cache_manager.dart @@ -83,6 +83,27 @@ class AudioCacheManager { return path; } + /// Registers a file already on disk in the cache index. Intended for + /// the streaming path (LockCachingAudioSource) which writes the file + /// itself; we need an index row so eviction can find and delete it + /// when usage exceeds the cap. `source` defaults to incidental so + /// stream-cached tracks are first to be evicted. + Future registerStreamCache( + String trackId, + String path, + int sizeBytes, { + CacheSource source = CacheSource.incidental, + }) async { + await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( + AudioCacheIndexCompanion.insert( + trackId: trackId, + path: path, + sizeBytes: sizeBytes, + source: source, + ), + ); + } + /// Removes a track from the index AND deletes the file. Future unpin(String trackId) async { final row = await (_db.select(_db.audioCacheIndex) diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 132d9296..00ec6326 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -18,6 +18,12 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl // 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())); } final AudioPlayer _player = AudioPlayer(); @@ -26,6 +32,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl 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 _streamCacheRegistered = {}; + + /// Cached on first use so we don't hit the platform channel every + /// time the buffered-position stream emits (~200ms cadence). + String? _cacheDirPath; + /// Volume stream for UI subscribers. Mirrors the just_audio player's /// volume directly; set via setVolume(double). Stream get volumeStream => _player.volumeStream; @@ -184,6 +200,39 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl ); } + /// 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 _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; // Push the new track's MediaItem onto the mediaItem stream so