diff --git a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt index 5a7753b2..7319b662 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/auth/AuthStore.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.auth +import com.fabledsword.minstrel.cache.audiocache.CacheSettings import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity import com.fabledsword.minstrel.di.ApplicationScope @@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import javax.inject.Inject import javax.inject.Singleton @@ -46,6 +48,11 @@ class AuthStore @Inject constructor( private val clientIdState = MutableStateFlow(null) val clientId: StateFlow = clientIdState.asStateFlow() + private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT) + val cacheSettings: StateFlow = cacheSettingsState.asStateFlow() + + private val json = Json { ignoreUnknownKeys = true } + init { scope.launch { dao.observe().collect { row -> @@ -54,10 +61,18 @@ class AuthStore @Inject constructor( userJsonState.value = row?.userJson themeModeState.value = row?.themeMode clientIdState.value = row?.clientId + cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson) } } } + private fun decodeCacheSettings(raw: String?): CacheSettings { + if (raw.isNullOrEmpty()) return CacheSettings.DEFAULT + return runCatching { + json.decodeFromString(CacheSettings.serializer(), raw) + }.getOrDefault(CacheSettings.DEFAULT) + } + fun setSessionCookie(value: String?) { sessionCookieState.value = value scope.launch { persistCookie(value) } @@ -83,6 +98,12 @@ class AuthStore @Inject constructor( scope.launch { persistClientId(value) } } + fun setCacheSettings(value: CacheSettings) { + cacheSettingsState.value = value + val encoded = json.encodeToString(CacheSettings.serializer(), value) + scope.launch { persistCacheSettings(encoded) } + } + private suspend fun persistCookie(value: String?) { if (dao.get() == null) { dao.upsert(currentEntity().copy(sessionCookie = value)) @@ -123,6 +144,14 @@ class AuthStore @Inject constructor( } } + private suspend fun persistCacheSettings(json: String) { + if (dao.get() == null) { + dao.upsert(currentEntity().copy(cacheSettingsJson = json)) + } else { + dao.setCacheSettingsJson(json) + } + } + private fun currentEntity(): AuthSessionEntity = AuthSessionEntity( id = ROW_ID, sessionCookie = sessionCookieState.value, @@ -130,6 +159,10 @@ class AuthStore @Inject constructor( userJson = userJsonState.value, themeMode = themeModeState.value, clientId = clientIdState.value, + cacheSettingsJson = json.encodeToString( + CacheSettings.serializer(), + cacheSettingsState.value, + ), ) companion object { 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 new file mode 100644 index 00000000..246ac2a0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt @@ -0,0 +1,37 @@ +package com.fabledsword.minstrel.cache.audiocache + +import kotlinx.serialization.Serializable + +private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024 +private const val DEFAULT_PREFETCH_WINDOW = 5 + +/** + * User-tunable audio cache settings. Mirrors Flutter's `CacheSettings` + * (cache_settings_provider.dart) field-for-field. Persisted as a JSON + * blob on the auth_session single-row table via [AuthStore]. + * + * - [likedCapBytes]: budget for cached files of liked tracks. 0 means + * unlimited. Effective at next app start (SimpleCache is built once + * per process). + * - [rollingCapBytes]: budget for incidental / auto-prefetch downloads. + * Same 0=unlimited / restart-to-apply caveat. + * - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher + * pre-downloads. Has no effect until the prefetcher lands (separate + * audit follow-up) — persisted now so the user can pre-configure. + * - [cacheLikedTracks]: when true, liking a track should also pin its + * audio to the liked-cache bucket. Has no effect until pin-on-like + * lands (separate follow-up). + * + * Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true. + */ +@Serializable +data class CacheSettings( + val likedCapBytes: Long = FIVE_GIB_BYTES, + val rollingCapBytes: Long = FIVE_GIB_BYTES, + val prefetchWindow: Int = DEFAULT_PREFETCH_WINDOW, + val cacheLikedTracks: Boolean = true, +) { + companion object { + val DEFAULT: CacheSettings = CacheSettings() + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt index 0a473095..e84a8263 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity CachedHomeIndexEntity::class, AuthSessionEntity::class, ], - version = 4, + version = 5, exportSchema = true, ) @TypeConverters(MinstrelTypeConverters::class) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt index 6901490d..5bb2ccc9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/AuthSessionDao.kt @@ -38,4 +38,8 @@ interface AuthSessionDao { /** Partial update: change only the stable client install id. */ @Query("UPDATE auth_session SET clientId = :clientId WHERE id = 0") suspend fun setClientId(clientId: String?) + + /** Partial update: change only the serialized cache settings. */ + @Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0") + suspend fun setCacheSettingsJson(json: String?) } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt index 81528c6e..c36cfa34 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/AuthSessionEntity.kt @@ -29,4 +29,11 @@ data class AuthSessionEntity( val userJson: String? = null, val themeMode: String? = null, val clientId: String? = null, + /** + * JSON-encoded CacheSettings (cache/audiocache/CacheSettings.kt). + * Null = use defaults. Stored as a JSON blob rather than four + * individual columns to keep schema changes cheap when the + * CacheSettings shape evolves. + */ + val cacheSettingsJson: String? = null, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt index cea2719c..ebba0584 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt @@ -1,5 +1,6 @@ package com.fabledsword.minstrel.player +import com.fabledsword.minstrel.auth.AuthStore import com.fabledsword.minstrel.cache.audiocache.CacheConfig import dagger.Module import dagger.Provides @@ -21,7 +22,23 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object PlayerModule { + /** + * CacheConfig snapshot from the user's persisted CacheSettings. + * SimpleCache is constructed once per process, so changes via the + * Settings UI take effect on next app launch (not mid-flight). + * AuthStore's StateFlow hydrates async from Room — when the + * factory @Provides runs, the value is either the persisted + * setting (if Room finished first) or CacheSettings.DEFAULT. + * Worst case the user's tuning lands on next-next launch, which + * is acceptable for a single-user device pref. + */ @Provides @Singleton - fun provideCacheConfig(): CacheConfig = CacheConfig() + fun provideCacheConfig(authStore: AuthStore): CacheConfig { + val s = authStore.cacheSettings.value + return CacheConfig( + likedCapBytes = s.likedCapBytes, + rollingCapBytes = s.rollingCapBytes, + ) + } }