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;
|
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.
|
/// Removes a track from the index AND deletes the file.
|
||||||
Future<void> unpin(String trackId) async {
|
Future<void> unpin(String trackId) async {
|
||||||
final row = await (_db.select(_db.audioCacheIndex)
|
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.
|
// shuffleMode + repeatMode fields stay current for UI subscribers.
|
||||||
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
_player.shuffleModeEnabledStream.listen((_) => _broadcastState(null));
|
||||||
_player.loopModeStream.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();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -26,6 +32,16 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
AlbumCoverCache? _coverCache;
|
AlbumCoverCache? _coverCache;
|
||||||
AudioCacheManager? _audioCacheManager;
|
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 stream for UI subscribers. Mirrors the just_audio player's
|
||||||
/// volume directly; set via setVolume(double).
|
/// volume directly; set via setVolume(double).
|
||||||
Stream<double> get volumeStream => _player.volumeStream;
|
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) {
|
void _onCurrentIndexChanged(int? idx) {
|
||||||
if (idx == null) return;
|
if (idx == null) return;
|
||||||
// Push the new track's MediaItem onto the mediaItem stream so
|
// Push the new track's MediaItem onto the mediaItem stream so
|
||||||
|
|||||||
Reference in New Issue
Block a user