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
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:
+3
-3
@@ -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
-12
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
-18
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
-7
@@ -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(
|
||||||
|
|||||||
+12
-12
@@ -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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user