From 5dc6aa9c2fe3c2749ed2a3afaf1b2c79e4cc3bf6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 23 Oct 2026 15:23:49 -0400 Subject: [PATCH] feat(android): Storage shows the Liked songs and Recently played budgets (#6309) 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 --- .../cache/audiocache/CacheSettings.kt | 6 +- .../minstrel/cache/db/TypeConverters.kt | 16 +--- .../cache/db/dao/AudioCacheIndexDao.kt | 18 ---- .../db/entities/AudioCacheIndexEntity.kt | 12 +-- .../minstrel/settings/ui/StorageCard.kt | 93 +++++++++++-------- .../minstrel/settings/ui/StorageViewModel.kt | 24 ++--- 6 files changed, 80 insertions(+), 89 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt index 6099a25f..c752d9d5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt @@ -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. */ diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt index 0f868f55..bff8e5a5 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt @@ -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 } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt index 3991f50a..543b5b6b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AudioCacheIndexDao.kt @@ -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 - /** 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 - - /** 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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt index 49e391c2..77049ad0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AudioCacheIndexEntity.kt @@ -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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt index 612194c1..e18c689b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt @@ -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 LabeledDropdown( label: String, + hint: String, selectedLabel: String, options: List>, onSelect: (T) -> Unit, @@ -135,12 +159,7 @@ private fun 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( diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt index e87a60ac..d168b4e9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt @@ -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) } } }