diff --git a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt index a070d6ef..e5439bc7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/SettingsScreen.kt @@ -83,6 +83,7 @@ fun SettingsScreen( ) { AccountCard(username = state.username, serverUrl = state.serverUrl) AppearanceCard(themeMode = themeMode, onPick = themeVm::setThemeMode) + StorageCard() AboutCard() SignOutButton(isSigningOut = state.isSigningOut, onSignOut = viewModel::signOut) } 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 new file mode 100644 index 00000000..0654b5a7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageCard.kt @@ -0,0 +1,306 @@ +package com.fabledsword.minstrel.settings.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +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.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle + +private const val GIB: Long = 1024L * 1024 * 1024 +private const val MIB: Long = 1024L * 1024 +private const val KIB: Long = 1024L + +private val CAP_OPTIONS: List> = listOf( + 1L * GIB to "1 GB", + 5L * GIB to "5 GB", + 10L * GIB to "10 GB", + 25L * GIB to "25 GB", + 0L to "Unlimited", +) + +private val PREFETCH_OPTIONS: List = listOf(1, 3, 5, 7, 10) + +@Composable +fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + var showClearConfirm by remember { mutableStateOf(false) } + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Storage", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + UsageRow(usedBytes = state.usedBytes) + CapDropdown( + label = "Liked cache limit", + value = state.settings.likedCapBytes, + onSet = viewModel::setLikedCapBytes, + ) + CapDropdown( + label = "Recently-played cache limit", + value = state.settings.rollingCapBytes, + onSet = viewModel::setRollingCapBytes, + ) + PrefetchDropdown( + value = state.settings.prefetchWindow, + onSet = viewModel::setPrefetchWindow, + ) + SwitchRow( + label = "Cache liked tracks", + value = state.settings.cacheLikedTracks, + onSet = viewModel::setCacheLikedTracks, + ) + Text( + text = "Cache limits take effect on the next app launch.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StorageActions( + isSyncing = state.isSyncing, + isClearing = state.isClearing, + onSync = viewModel::syncNow, + onClear = { showClearConfirm = true }, + ) + } + } + if (showClearConfirm) { + ClearCacheConfirmDialog( + onConfirm = { + showClearConfirm = false + viewModel.clearCache() + }, + onDismiss = { showClearConfirm = false }, + ) + } +} + +@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, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second + ?: formatBytes(value) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(), + ) + ExposedDropdownMenuDefaults.run { + androidx.compose.material3.ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + CAP_OPTIONS.forEach { (bytes, name) -> + DropdownMenuItem( + text = { Text(name) }, + onClick = { + expanded = false + onSet(bytes) + }, + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PrefetchDropdown(value: Int, onSet: (Int) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val selected = if (value in PREFETCH_OPTIONS) value else PREFETCH_OPTIONS[2] + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Pre-fetch ahead", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + value = "$selected tracks", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(), + ) + androidx.compose.material3.ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + PREFETCH_OPTIONS.forEach { n -> + DropdownMenuItem( + text = { Text("$n tracks") }, + onClick = { + expanded = false + onSet(n) + }, + ) + } + } + } + } +} + +@Composable +private fun SwitchRow(label: String, value: Boolean, onSet: (Boolean) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Switch(checked = value, onCheckedChange = onSet) + } +} + +@Composable +private fun StorageActions( + isSyncing: Boolean, + isClearing: Boolean, + onSync: () -> Unit, + onClear: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = onClear, + enabled = !isClearing, + ) { + if (isClearing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (isClearing) "Clearing…" else "Clear cache") + } + OutlinedButton( + onClick = onSync, + enabled = !isSyncing, + ) { + if (isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.size(8.dp)) + } + Text(if (isSyncing) "Syncing…" else "Sync now") + } + } +} + +@Composable +private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Clear cache?") }, + text = { + Text( + "This deletes all cached audio. The next play of any track " + + "re-downloads from the server.", + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { Text("Clear") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +private fun formatBytes(n: Long): String { + if (n <= 0L) return "0 B" + return when { + n < KIB -> "$n B" + n < MIB -> "${"%.1f".format(n.toDouble() / KIB)} KB" + n < GIB -> "${"%.1f".format(n.toDouble() / MIB)} MB" + else -> "${"%.2f".format(n.toDouble() / GIB)} GB" + } +} 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 new file mode 100644 index 00000000..e87a60ac --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/settings/ui/StorageViewModel.kt @@ -0,0 +1,124 @@ +package com.fabledsword.minstrel.settings.ui + +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.sync.SyncController +import com.fabledsword.minstrel.player.PlayerFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +data class StorageUiState( + val settings: CacheSettings = CacheSettings.DEFAULT, + val usedBytes: 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". + * + * 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). + */ +@HiltViewModel +class StorageViewModel @Inject constructor( + private val authStore: AuthStore, + private val playerFactory: PlayerFactory, + private val syncController: SyncController, +) : ViewModel() { + + private val internal = MutableStateFlow(StorageUiState()) + val state: StateFlow = internal.asStateFlow() + + init { + viewModelScope.launch { + authStore.cacheSettings.collect { s -> + internal.update { it.copy(settings = s) } + } + } + refreshUsage() + } + + fun setLikedCapBytes(bytes: Long) { + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(likedCapBytes = bytes)) + } + + fun setRollingCapBytes(bytes: Long) { + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(rollingCapBytes = bytes)) + } + + fun setPrefetchWindow(n: Int) { + val clamped = n.coerceIn(1, MAX_PREFETCH_WINDOW) + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(prefetchWindow = clamped)) + } + + fun setCacheLikedTracks(on: Boolean) { + val current = authStore.cacheSettings.value + authStore.setCacheSettings(current.copy(cacheLikedTracks = on)) + } + + fun syncNow() { + if (internal.value.isSyncing) return + viewModelScope.launch { + internal.update { it.copy(isSyncing = true) } + try { + syncController.syncSafe() + } finally { + internal.update { it.copy(isSyncing = false) } + } + } + } + + fun clearCache() { + if (internal.value.isClearing) return + viewModelScope.launch { + internal.update { it.copy(isClearing = true) } + try { + withContext(Dispatchers.IO) { + // SimpleCache.getKeys() + removeResource is the safe + // path — releasing the cache mid-flight would crash + // any in-progress playback. Iterating + removing + // works while the cache is in use. + val cache = playerFactory.simpleCache + val keys = cache.keys.toList() + for (k in keys) { + runCatching { cache.removeResource(k) } + } + } + refreshUsage() + } finally { + internal.update { it.copy(isClearing = false) } + } + } + } + + /** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */ + private fun refreshUsage() { + viewModelScope.launch { + val used = withContext(Dispatchers.IO) { + runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L) + } + internal.update { it.copy(usedBytes = used) } + } + } + + companion object { + private const val MAX_PREFETCH_WINDOW = 10 + } +}