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]).
* - [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,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(
@@ -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) }
}
}