Compare commits
2
Commits
7ec8a82871
...
5dc6aa9c2f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dc6aa9c2f | ||
|
|
ec1e59921b |
@@ -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 +
|
||||
|
||||
+3
-3
@@ -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
-12
@@ -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 }
|
||||
|
||||
|
||||
-18
@@ -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)
|
||||
|
||||
|
||||
+7
-3
@@ -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 " +
|
||||
|
||||
Vendored
+5
-7
@@ -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(
|
||||
|
||||
+29
@@ -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(
|
||||
|
||||
+12
-12
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user