feat(flutter): register stream-cached files in the audio cache index
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.
This commit is contained in:
@@ -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<void> 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<void> unpin(String trackId) async {
|
||||
final row = await (_db.select(_db.audioCacheIndex)
|
||||
|
||||
@@ -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<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;
|
||||
|
||||
/// Volume stream for UI subscribers. Mirrors the just_audio player's
|
||||
/// volume directly; set via setVolume(double).
|
||||
Stream<double> 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<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;
|
||||
// Push the new track's MediaItem onto the mediaItem stream so
|
||||
|
||||
Reference in New Issue
Block a user