feat(android): Storage shows the Liked songs and Recently played budgets (#6309)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m46s
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>
This commit is contained in:
2026-10-23 15:23:49 -04:00
co-authored by Claude Opus 5.5
parent ec1e59921b
commit 5dc6aa9c2f
6 changed files with 80 additions and 89 deletions
@@ -17,9 +17,9 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]). * only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher` * - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* pre-downloads. * pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its * - [cacheLikedTracks]: "Keep liked songs offline". When true,
* audio to the liked-cache bucket. Nothing reads it yet, and the * `LikedAudioPinner` downloads liked tracks into the Liked budget on
* Storage card hides this control. * Wi-Fi, most recently liked first, while the budget has room.
* *
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true. * Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/ */
@@ -4,18 +4,10 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
/** /**
* Source category for a cached audio file in `audio_cache_index`. * How a row reached `audio_cache_index`. `CacheIndexer` writes
* * `INCIDENTAL` (the track was played); the other values are stored names
* - `MANUAL`: user explicitly pinned (e.g. "save for offline") * no writer uses. Eviction does not read this: the audio cache's two
* - `AUTO_LIKED`: auto-cached because the track is liked * budgets follow the liked set (see `TwoBucketCacheEvictor`).
* - `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.
*/ */
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL } 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.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST") @Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String> 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity) suspend fun upsert(row: AudioCacheIndexEntity)
@@ -10,13 +10,11 @@ import kotlinx.datetime.Instant
* One row per track that has played through the audio cache (see * One row per track that has played through the audio cache (see
* `CacheIndexer`); SimpleCache, not this table, says what is on disk. * `CacheIndexer`); SimpleCache, not this table, says what is on disk.
* *
* The `source` column is meant to drive a 2-bucket eviction * `source` says how the row arrived (see [CacheSource]); eviction does
* (incidental first, then auto-prefetch, manual last). Nothing evicts * not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
* by it yet: SimpleCache evicts by plain LRU over the rolling cap. * when the user last played the track, which orders the offline
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last * "recently played" pool. Nullable on brand-new rows until the first
* played it; drives the offline "recently played" ordering and is the * post-cache play touches them.
* tiebreaker for LRU eviction within a source bucket. Nullable on
* brand-new rows until the first post-cache play touches them.
*/ */
@Entity(tableName = "audio_cache_index") @Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity( data class AudioCacheIndexEntity(
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -16,6 +17,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -55,21 +58,26 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
UsageRow(usedBytes = state.usedBytes) // Two budgets: a liked track that is also recently played counts
// Only the rolling cap is shown. Nothing enforces a separate // against Liked songs only. The prefetch window has no control;
// liked cap or pins liked tracks yet, and a control that silently // AudioPrefetcher uses the stored value (default 5).
// 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.
CapDropdown( 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, value = state.settings.rollingCapBytes,
onSet = viewModel::setRollingCapBytes, onSet = viewModel::setRollingCapBytes,
) )
Text( SwitchRow(
text = "Takes effect on the next app launch.", label = "Keep liked songs offline",
style = MaterialTheme.typography.bodySmall, hint = "Downloads on Wi-Fi",
color = MaterialTheme.colorScheme.onSurfaceVariant, checked = state.settings.cacheLikedTracks,
onCheckedChange = viewModel::setCacheLikedTracks,
) )
StorageActions( StorageActions(
isSyncing = state.isSyncing, isSyncing = state.isSyncing,
@@ -91,41 +99,57 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
} }
@Composable @Composable
private fun UsageRow(usedBytes: Long) { private fun CapDropdown(label: String, hint: String, value: Long, onSet: (Long) -> Unit) {
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) {
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second
?: formatBytes(value) ?: formatBytes(value)
LabeledDropdown( LabeledDropdown(
label = label, label = label,
hint = hint,
selectedLabel = selectedLabel, selectedLabel = selectedLabel,
options = CAP_OPTIONS, options = CAP_OPTIONS,
onSelect = onSet, 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 * Label and hint on the left, button-with-current-value on the right that
* a DropdownMenu of [options]. Used by the cap selectors. * opens a DropdownMenu of [options]. Used by the cap selectors.
*/ */
@Composable @Composable
private fun <T> LabeledDropdown( private fun <T> LabeledDropdown(
label: String, label: String,
hint: String,
selectedLabel: String, selectedLabel: String,
options: List<Pair<T, String>>, options: List<Pair<T, String>>,
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
@@ -135,12 +159,7 @@ private fun <T> LabeledDropdown(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f))
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
OutlinedButton(onClick = { expanded = true }) { OutlinedButton(onClick = { expanded = true }) {
Text(selectedLabel) Text(selectedLabel)
DropdownMenu( DropdownMenu(
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheSettings 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.cache.sync.SyncController
import com.fabledsword.minstrel.player.PlayerFactory import com.fabledsword.minstrel.player.PlayerFactory
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -18,25 +19,25 @@ import javax.inject.Inject
data class StorageUiState( data class StorageUiState(
val settings: CacheSettings = CacheSettings.DEFAULT, val settings: CacheSettings = CacheSettings.DEFAULT,
val usedBytes: Long = 0L, val likedBytes: Long = 0L,
val recentBytes: Long = 0L,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val isClearing: Boolean = false, val isClearing: Boolean = false,
) )
/** /**
* Backs the Storage section of the Settings screen. Exposes the * Backs the Storage section of the Settings screen. Exposes the
* persisted CacheSettings + the current SimpleCache usage, plus * persisted CacheSettings and how much of each audio budget (Liked
* actions for "Sync now" and "Clear cache". * songs, Recently played) is in use, plus "Sync now" and "Clear cache".
* *
* Settings changes go through AuthStore.setCacheSettings (write- * Settings changes go through AuthStore.setCacheSettings (write-
* through to Room). The cap settings only take effect on next app * through to Room), and the evictor applies a new cap at once.
* launch because SimpleCache is constructed once per process; the
* UI surfaces that caveat (next commit).
*/ */
@HiltViewModel @HiltViewModel
class StorageViewModel @Inject constructor( class StorageViewModel @Inject constructor(
private val authStore: AuthStore, private val authStore: AuthStore,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val cacheEvictor: TwoBucketCacheEvictor,
private val syncController: SyncController, private val syncController: SyncController,
) : ViewModel() { ) : ViewModel() {
@@ -47,9 +48,10 @@ class StorageViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
authStore.cacheSettings.collect { s -> authStore.cacheSettings.collect { s ->
internal.update { it.copy(settings = s) } internal.update { it.copy(settings = s) }
// A smaller cap trims the cache, so re-read the usage.
refreshUsage()
} }
} }
refreshUsage()
} }
fun setLikedCapBytes(bytes: Long) { 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() { private fun refreshUsage() {
viewModelScope.launch { viewModelScope.launch {
val used = withContext(Dispatchers.IO) { val usage = withContext(Dispatchers.IO) { cacheEvictor.usage() }
runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L) internal.update { it.copy(likedBytes = usage.likedBytes, recentBytes = usage.recentBytes) }
}
internal.update { it.copy(usedBytes = used) }
} }
} }