Compare commits

...
2 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 5dc6aa9c2f feat(android): Storage shows the Liked songs and Recently played budgets (#6309)
release / web (push) Successful in 1m46s
release / govulncheck (push) Successful in 18s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m39s
release / Build signed APK (releases and dev) (push) Successful in 5m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
Two cap selectors, each with its usage, and a "Keep liked songs
offline" switch replace the single cache limit and its next-launch
caveat; caps apply at once.

Docs that said the liked bucket did not exist are restated, and the
audio-cache index's unused per-source eviction queries are deleted:
eviction follows the liked set, not the index's source column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:23:49 -04:00
bvandeusenandClaude Opus 5.5 ec1e59921b feat(android): liking a track downloads its audio into the Liked budget on Wi-Fi (#6308)
LikedAudioPinner downloads liked tracks' whole audio when "Keep liked
songs offline" is on: most recently liked first, one at a time, only
on an unmetered network while the server is reachable, and only while
the Liked budget has room for another track at CD bitrate, so it never
pushes out liked audio already on the phone. Tracks liked before this
ships are covered by the same pass. Any change (a like, an unlike, the
switch, the network) restarts the pass and cancels a download in flight.

Supporting changes:
- ConnectivityObserver.unmetered: the default network's NOT_METERED bit.
- Liked track ids are read newest first, and refreshIds stamps the
  server's newest-first order into likedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:23:49 -04:00
11 changed files with 278 additions and 101 deletions
@@ -20,6 +20,7 @@ import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.LikedAudioPinner
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
@@ -129,6 +130,13 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
/**
* Same construct-the-singleton trick — LikedAudioPinner's init block
* downloads liked tracks into the Liked songs budget. Without this
* @Inject, liking a track would not keep it offline.
*/
@Suppress("unused") @Inject lateinit var likedAudioPinner: LikedAudioPinner
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
@@ -17,9 +17,9 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Nothing reads it yet, and the
* Storage card hides this control.
* - [cacheLikedTracks]: "Keep liked songs offline". When true,
* `LikedAudioPinner` downloads liked tracks into the Liked budget on
* Wi-Fi, most recently liked first, while the budget has room.
*
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@@ -4,18 +4,10 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant
/**
* Source category for a cached audio file in `audio_cache_index`.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `AUTO_LIKED`: auto-cached because the track is liked
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming
*
* Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
* today, and SimpleCache evicts by plain LRU, so nothing applies the
* order yet.
* How a row reached `audio_cache_index`. `CacheIndexer` writes
* `INCIDENTAL` (the track was played); the other values are stored names
* no writer uses. Eviction does not read this: the audio cache's two
* budgets follow the liked set (see `TwoBucketCacheEvictor`).
*/
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
@@ -4,7 +4,6 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String>
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
@Query(
"SELECT * FROM audio_cache_index " +
"WHERE source = :source " +
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
)
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
suspend fun totalBytes(): Long
@Query(
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
)
suspend fun bytesBySource(source: CacheSource): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity)
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface CachedLikeDao {
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
/**
* The user's liked track IDs, most recently liked first. The audio
* cache's Liked budget and the liked-audio downloads read this.
*/
@Query(
"SELECT entityId FROM cached_likes " +
"WHERE userId = :userId AND entityType = 'track'",
"WHERE userId = :userId AND entityType = 'track' " +
"ORDER BY likedAt DESC",
)
fun observeLikedTrackIds(userId: String): Flow<List<String>>
fun observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
@Query(
"SELECT entityId FROM cached_likes " +
@@ -10,13 +10,11 @@ import kotlinx.datetime.Instant
* One row per track that has played through the audio cache (see
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
*
* The `source` column is meant to drive a 2-bucket eviction
* (incidental first, then auto-prefetch, manual last). Nothing evicts
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on
* brand-new rows until the first post-cache play touches them.
* `source` says how the row arrived (see [CacheSource]); eviction does
* not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
* when the user last played the track, which orders the offline
* "recently played" pool. Nullable on brand-new rows until the first
* post-cache play touches them.
*/
@Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity(
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
/**
* Whether the network the device is using now is unmetered: Wi-Fi or
* Ethernet, but not mobile data or a hotspot Android marks metered.
* Downloads the user did not start (the liked-song downloads) wait for
* this.
*/
val unmetered: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))
}
override fun onLost(network: Network) {
trySend(false)
}
}
cm.registerDefaultNetworkCallback(callback)
trySend(isActiveNetworkUnmetered())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
private fun isActiveNetworkUnmetered(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
@@ -22,10 +22,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* Cache-first reads + offline-first writes for likes.
@@ -122,18 +124,22 @@ class LikesRepository @Inject constructor(
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/**
* The liked track ids of whoever is signed in, following sign-in and
* account changes. Emits nothing while signed out, so a long-lived
* collector never mistakes the anonymous empty set for "no likes".
* The liked track ids of whoever is signed in, most recently liked
* first, following sign-in and account changes. Emits nothing while
* signed out, so a long-lived collector never mistakes the anonymous
* empty set for "no likes".
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
fun observeSignedInLikedTrackIdsNewestFirst(): Flow<List<String>> =
authController.currentUser
.map { it?.id }
.distinctUntilChanged()
.filterNotNull()
.flatMapLatest { userId -> likeDao.observeLikedIdsOfType(userId, ENTITY_TRACK) }
.map { it.toSet() }
.flatMapLatest { userId -> likeDao.observeLikedTrackIdsNewestFirst(userId) }
/** [observeSignedInLikedTrackIdsNewestFirst] as a set. */
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
observeSignedInLikedTrackIdsNewestFirst().map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
@@ -184,14 +190,21 @@ class LikesRepository @Inject constructor(
* device would stay on the Liked tab forever. The replaceAllForUser
* DAO method runs a transactional delete-then-insert so the local
* set is exactly what the server reports.
*
* The server lists each kind most recently liked first but sends no
* times, so the rows are stamped a millisecond apart in that order;
* `likedAt` then sorts the way the server does.
*/
suspend fun refreshIds() {
val uid = currentUserId()
val wire = api.ids()
val now = Clock.System.now()
fun rowsOf(type: String, ids: List<String>) =
ids.mapIndexed { i, id -> CachedLikeEntity(uid, type, id, likedAt = now - i.milliseconds) }
val rows = mutableListOf<CachedLikeEntity>()
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
rows += rowsOf(ENTITY_ARTIST, wire.artistIds)
rows += rowsOf(ENTITY_ALBUM, wire.albumIds)
rows += rowsOf(ENTITY_TRACK, wire.trackIds)
likeDao.replaceAllForUser(uid, rows)
}
@@ -0,0 +1,132 @@
package com.fabledsword.minstrel.player
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheWriter
import androidx.media3.datasource.cache.ContentMetadata
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
// The Liked budget must have room for a track at CD bitrate (1411 kbps)
// before one is downloaded, so a download never pushes out liked audio that
// is already on the phone. A track of unknown length is assumed to be ten
// minutes.
private const val CD_BYTES_PER_SECOND = 176_400L
private const val UNKNOWN_DURATION_SEC = 600
/**
* Keeps liked songs on the phone: when "Keep liked songs offline" is on,
* every liked track's whole audio is downloaded into the cache, where
* [TwoBucketCacheEvictor] counts it against the Liked songs budget.
*
* - Most recently liked first, one track at a time, so the newest likes get
* the room and the server is never asked for more than one file at once.
* - Only on an unmetered network (Wi-Fi or Ethernet) while the server is
* reachable. A like made on mobile data downloads once the phone is back
* on Wi-Fi.
* - Stops when the Liked budget cannot fit another track. Liked tracks
* played after that are still cached as they stream.
* - Any change (a like, an unlike, the switch, the network) restarts the
* pass, cancelling a download in flight. Tracks already fully on the
* phone are skipped without touching the network, and a track whose
* download failed is tried again on the next pass.
*
* Downloads are keyed by track id exactly like playback and
* [AudioPrefetcher], so the player reads what this writes.
*
* Eager-constructed via the construct-the-singleton trick in
* `MinstrelApplication`.
*/
@Singleton
class LikedAudioPinner @Inject constructor(
@ApplicationScope scope: CoroutineScope,
likes: LikesRepository,
authStore: AuthStore,
connectivity: ConnectivityObserver,
serverHealth: NetworkStatusController,
private val playerFactory: PlayerFactory,
private val evictor: TwoBucketCacheEvictor,
private val trackDao: CachedTrackDao,
okHttpClient: OkHttpClient,
) {
private val cacheDataSourceFactory: CacheDataSource.Factory =
CacheDataSource.Factory()
.setCache(playerFactory.simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
private data class Pass(val likedNewestFirst: List<String>, val enabled: Boolean, val canDownload: Boolean)
init {
scope.launch(Dispatchers.IO) {
combine(
likes.observeSignedInLikedTrackIdsNewestFirst(),
authStore.cacheSettings.map { it.cacheLikedTracks },
connectivity.unmetered,
serverHealth.state.map { it == ServerHealth.Healthy || it == ServerHealth.Unstable },
) { liked, enabled, unmetered, reachable ->
Pass(liked, enabled, unmetered && reachable)
}
.distinctUntilChanged()
.collectLatest { pass ->
if (pass.enabled && pass.canDownload) pinAll(pass.likedNewestFirst)
}
}
}
private suspend fun pinAll(likedNewestFirst: List<String>) {
for (trackId in likedNewestFirst) {
val track = downloadable(trackId)
if (track != null) {
val durationSec = track.durationSec.takeIf { it > 0 } ?: UNKNOWN_DURATION_SEC
if (evictor.usage().likedRoomBytes < durationSec * CD_BYTES_PER_SECOND) return
runCatching { runInterruptible { download(track.id, track.streamUrl) } }
.onFailure { Timber.d(it, "liked audio: download of %s failed; next pass retries", track.id) }
}
}
}
/** The track, when it still needs downloading and the server can serve it. */
private suspend fun downloadable(trackId: String): TrackRef? {
if (isFullyCached(trackId)) return null
val track = trackDao.getById(trackId)?.toDomain()
return track?.takeIf { !it.unavailable && it.streamUrl.isNotBlank() }
}
private fun isFullyCached(trackId: String): Boolean {
val cache = playerFactory.simpleCache
val length = ContentMetadata.getContentLength(cache.getContentMetadata(trackId))
return length != C.LENGTH_UNSET.toLong() && cache.isCached(trackId, 0, length)
}
private fun download(trackId: String, streamUrl: String) {
val dataSpec = DataSpec.Builder()
.setUri(Uri.parse(resolveServerUrl(streamUrl) ?: streamUrl))
.setKey(trackId)
.build()
CacheWriter(cacheDataSourceFactory.createDataSource(), dataSpec, null, null).cache()
}
}
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -16,6 +17,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -25,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -55,21 +58,26 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
UsageRow(usedBytes = state.usedBytes)
// Only the rolling cap is shown. Nothing enforces a separate
// liked cap or pins liked tracks yet, and a control that silently
// does nothing is worse than none. The prefetch window has no
// control either; AudioPrefetcher uses the stored value (default
// 5). CacheSettings keeps every field so the controls can return.
// Two budgets: a liked track that is also recently played counts
// against Liked songs only. The prefetch window has no control;
// AudioPrefetcher uses the stored value (default 5).
CapDropdown(
label = "Cache limit",
label = "Liked songs",
hint = "${formatBytes(state.likedBytes)} used",
value = state.settings.likedCapBytes,
onSet = viewModel::setLikedCapBytes,
)
CapDropdown(
label = "Recently played",
hint = "${formatBytes(state.recentBytes)} used",
value = state.settings.rollingCapBytes,
onSet = viewModel::setRollingCapBytes,
)
Text(
text = "Takes effect on the next app launch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
SwitchRow(
label = "Keep liked songs offline",
hint = "Downloads on Wi-Fi",
checked = state.settings.cacheLikedTracks,
onCheckedChange = viewModel::setCacheLikedTracks,
)
StorageActions(
isSyncing = state.isSyncing,
@@ -91,41 +99,57 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
}
@Composable
private fun UsageRow(usedBytes: Long) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Used",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = formatBytes(usedBytes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) {
private fun CapDropdown(label: String, hint: String, value: Long, onSet: (Long) -> Unit) {
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second
?: formatBytes(value)
LabeledDropdown(
label = label,
hint = hint,
selectedLabel = selectedLabel,
options = CAP_OPTIONS,
onSelect = onSet,
)
}
/** A setting's name, with one short line under it. */
@Composable
private fun RowLabel(label: String, hint: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = hint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SwitchRow(label: String, hint: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange),
verticalAlignment = Alignment.CenterVertically,
) {
RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f))
Spacer(Modifier.size(12.dp))
Switch(checked = checked, onCheckedChange = null)
}
}
/**
* Label on the left, button-with-current-value on the right that opens
* a DropdownMenu of [options]. Used by the cap selectors.
* Label and hint on the left, button-with-current-value on the right that
* opens a DropdownMenu of [options]. Used by the cap selectors.
*/
@Composable
private fun <T> LabeledDropdown(
label: String,
hint: String,
selectedLabel: String,
options: List<Pair<T, String>>,
onSelect: (T) -> Unit,
@@ -135,12 +159,7 @@ private fun <T> LabeledDropdown(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f))
OutlinedButton(onClick = { expanded = true }) {
Text(selectedLabel)
DropdownMenu(
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.player.PlayerFactory
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -18,25 +19,25 @@ import javax.inject.Inject
data class StorageUiState(
val settings: CacheSettings = CacheSettings.DEFAULT,
val usedBytes: Long = 0L,
val likedBytes: Long = 0L,
val recentBytes: Long = 0L,
val isSyncing: Boolean = false,
val isClearing: Boolean = false,
)
/**
* Backs the Storage section of the Settings screen. Exposes the
* persisted CacheSettings + the current SimpleCache usage, plus
* actions for "Sync now" and "Clear cache".
* persisted CacheSettings and how much of each audio budget (Liked
* songs, Recently played) is in use, plus "Sync now" and "Clear cache".
*
* Settings changes go through AuthStore.setCacheSettings (write-
* through to Room). The cap settings only take effect on next app
* launch because SimpleCache is constructed once per process; the
* UI surfaces that caveat (next commit).
* through to Room), and the evictor applies a new cap at once.
*/
@HiltViewModel
class StorageViewModel @Inject constructor(
private val authStore: AuthStore,
private val playerFactory: PlayerFactory,
private val cacheEvictor: TwoBucketCacheEvictor,
private val syncController: SyncController,
) : ViewModel() {
@@ -47,9 +48,10 @@ class StorageViewModel @Inject constructor(
viewModelScope.launch {
authStore.cacheSettings.collect { s ->
internal.update { it.copy(settings = s) }
// A smaller cap trims the cache, so re-read the usage.
refreshUsage()
}
}
refreshUsage()
}
fun setLikedCapBytes(bytes: Long) {
@@ -108,13 +110,11 @@ class StorageViewModel @Inject constructor(
}
}
/** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */
/** Re-reads each budget's usage from the evictor. Cheap: two longs. */
private fun refreshUsage() {
viewModelScope.launch {
val used = withContext(Dispatchers.IO) {
runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L)
}
internal.update { it.copy(usedBytes = used) }
val usage = withContext(Dispatchers.IO) { cacheEvictor.usage() }
internal.update { it.copy(likedBytes = usage.likedBytes, recentBytes = usage.recentBytes) }
}
}