feat: avatar storage and caching functionality

This commit is contained in:
tapframe 2026-04-18 23:01:16 +05:30
parent bd0540df72
commit 161e5d81bb
12 changed files with 208 additions and 35 deletions

View file

@ -25,6 +25,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerPictureInPictureManager import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.AvatarStorage
import com.nuvio.app.features.profiles.ProfileStorage import com.nuvio.app.features.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.search.SearchHistoryStorage import com.nuvio.app.features.search.SearchHistoryStorage
@ -59,6 +60,7 @@ class MainActivity : ComponentActivity() {
HomeCatalogSettingsStorage.initialize(applicationContext) HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext) PlayerSettingsStorage.initialize(applicationContext)
ProfileStorage.initialize(applicationContext) ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)
SearchHistoryStorage.initialize(applicationContext) SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext) ThemeSettingsStorage.initialize(applicationContext)

View file

@ -9,6 +9,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_home_catalog_settings", "nuvio_home_catalog_settings",
"nuvio_player_settings", "nuvio_player_settings",
"nuvio_profile_cache", "nuvio_profile_cache",
"nuvio_avatar_cache",
"nuvio_theme_settings", "nuvio_theme_settings",
"nuvio_poster_card_style", "nuvio_poster_card_style",
"nuvio_mdblist_settings", "nuvio_mdblist_settings",

View file

@ -0,0 +1,25 @@
package com.nuvio.app.features.profiles
import android.content.Context
import android.content.SharedPreferences
actual object AvatarStorage {
private const val preferencesName = "nuvio_avatar_cache"
private const val payloadKey = "avatar_catalog_payload"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadPayload(): String? =
preferences?.getString(payloadKey, null)
actual fun savePayload(payload: String) {
preferences
?.edit()
?.putString(payloadKey, payload)
?.apply()
}
}

View file

@ -278,40 +278,81 @@ fun App() {
AuthRepository.initialize() AuthRepository.initialize()
} }
LaunchedEffect(Unit) {
NetworkStatusRepository.ensureStarted()
ProfileRepository.loadCachedProfiles()
}
val authState by AuthRepository.state.collectAsStateWithLifecycle() val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val networkStatusUiState by remember {
NetworkStatusRepository.uiState
}.collectAsStateWithLifecycle()
var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) } var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) }
var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) } var editingProfile by remember { mutableStateOf<NuvioProfile?>(null) }
var isNewProfile by remember { mutableStateOf(false) } var isNewProfile by remember { mutableStateOf(false) }
var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(authState) { fun enterProfileGate(profiles: List<NuvioProfile>, syncOnEnter: Boolean) {
if (profiles.isEmpty()) {
autoSkipProfileSelection = true
gateScreen = AppGateScreen.ProfileSelection.name
return
}
autoSkipProfileSelection = true
if (profiles.size == 1) {
val onlyProfile = profiles.first()
ProfileRepository.selectProfile(onlyProfile.profileIndex)
if (syncOnEnter) {
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
}
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
} else {
gateScreen = AppGateScreen.ProfileSelection.name
}
}
LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) {
val cachedProfiles = profileState.profiles
val allowOfflineProfileAccess =
cachedProfiles.isNotEmpty() &&
authState !is AuthState.Authenticated &&
networkStatusUiState.condition != NetworkCondition.Online
when (authState) { when (authState) {
is AuthState.Loading -> gateScreen = AppGateScreen.Loading.name is AuthState.Loading -> {
if (allowOfflineProfileAccess) {
enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
gateScreen = AppGateScreen.Loading.name
}
}
is AuthState.Unauthenticated -> { is AuthState.Unauthenticated -> {
ProfileRepository.clearInMemory() if (allowOfflineProfileAccess) {
gateScreen = AppGateScreen.Auth.name enterProfileGate(cachedProfiles, syncOnEnter = false)
} else {
ProfileRepository.clearInMemory()
gateScreen = AppGateScreen.Auth.name
}
} }
is AuthState.Authenticated -> { is AuthState.Authenticated -> {
val authenticatedState = authState as AuthState.Authenticated val authenticatedState = authState as AuthState.Authenticated
ProfileRepository.ensureLoaded(authenticatedState.userId) ProfileRepository.ensureLoaded(authenticatedState.userId)
if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.name) { if (gateScreen == AppGateScreen.Loading.name || gateScreen == AppGateScreen.Auth.name) {
autoSkipProfileSelection = true enterProfileGate(ProfileRepository.state.value.profiles, syncOnEnter = true)
val cachedProfiles = ProfileRepository.state.value.profiles
if (cachedProfiles.size == 1) {
val onlyProfile = cachedProfiles.first()
ProfileRepository.selectProfile(onlyProfile.profileIndex)
SyncManager.pullAllForProfile(onlyProfile.profileIndex)
gateScreen = AppGateScreen.Main.name
autoSkipProfileSelection = false
} else {
gateScreen = AppGateScreen.ProfileSelection.name
}
} }
} }
} }
} }
LaunchedEffect((authState as? AuthState.Authenticated)?.userId) {
val authenticatedState = authState as? AuthState.Authenticated ?: return@LaunchedEffect
ProfileRepository.ensureLoaded(authenticatedState.userId)
ProfileRepository.pullProfiles()
}
LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) { LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) {
if ( if (
autoSkipProfileSelection && autoSkipProfileSelection &&
@ -352,7 +393,9 @@ fun App() {
ProfileSelectionScreen( ProfileSelectionScreen(
onProfileSelected = { profile -> onProfileSelected = { profile ->
ProfileRepository.selectProfile(profile.profileIndex) ProfileRepository.selectProfile(profile.profileIndex)
SyncManager.pullAllForProfile(profile.profileIndex) if (authState is AuthState.Authenticated) {
SyncManager.pullAllForProfile(profile.profileIndex)
}
gateScreen = AppGateScreen.Main.name gateScreen = AppGateScreen.Main.name
}, },
onEditProfile = { profile -> onEditProfile = { profile ->

View file

@ -7,34 +7,78 @@ import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
private data class StoredAvatarCatalogPayload(
val items: List<AvatarCatalogItem> = emptyList(),
)
object AvatarRepository { object AvatarRepository {
private val log = Logger.withTag("AvatarRepository") private val log = Logger.withTag("AvatarRepository")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val _avatars = MutableStateFlow<List<AvatarCatalogItem>>(emptyList()) private val _avatars = MutableStateFlow<List<AvatarCatalogItem>>(emptyList())
val avatars: StateFlow<List<AvatarCatalogItem>> = _avatars.asStateFlow() val avatars: StateFlow<List<AvatarCatalogItem>> = _avatars.asStateFlow()
private var loaded = false private var loaded = false
private var cacheHydrated = false
private var fetchInFlight = false
suspend fun fetchAvatars() { suspend fun fetchAvatars() {
hydrateFromCacheIfNeeded()
if (loaded && _avatars.value.isNotEmpty()) return if (loaded && _avatars.value.isNotEmpty()) return
doFetch() doFetch()
} }
suspend fun refreshAvatars() { suspend fun refreshAvatars() {
hydrateFromCacheIfNeeded()
doFetch() doFetch()
} }
private fun hydrateFromCacheIfNeeded() {
if (cacheHydrated) return
cacheHydrated = true
val payload = AvatarStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) return
val stored = runCatching {
json.decodeFromString<StoredAvatarCatalogPayload>(payload)
}.getOrNull() ?: return
val items = stored.items
.filter { it.isActive }
.sortedWith(compareBy({ it.category }, { it.sortOrder }))
if (items.isEmpty()) return
_avatars.value = items
loaded = true
}
private suspend fun doFetch() { private suspend fun doFetch() {
if (fetchInFlight) return
fetchInFlight = true
runCatching { runCatching {
val result = SupabaseProvider.client.postgrest.rpc("get_avatar_catalog") val result = SupabaseProvider.client.postgrest.rpc("get_avatar_catalog")
val items = result.decodeList<AvatarCatalogItem>() val items = result.decodeList<AvatarCatalogItem>()
_avatars.value = items.filter { it.isActive }.sortedWith( val activeItems = items.filter { it.isActive }.sortedWith(
compareBy({ it.category }, { it.sortOrder }), compareBy({ it.category }, { it.sortOrder }),
) )
_avatars.value = activeItems
loaded = true loaded = true
AvatarStorage.savePayload(
json.encodeToString(
StoredAvatarCatalogPayload(items = activeItems),
),
)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to fetch avatar catalog" } log.e(e) { "Failed to fetch avatar catalog" }
}.also {
fetchInFlight = false
} }
} }
} }

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.profiles
internal expect object AvatarStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreen
@ -79,9 +81,13 @@ fun ProfileEditScreen(
var showDeleteConfirm by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) }
var showPinSetup by remember { mutableStateOf(false) } var showPinSetup by remember { mutableStateOf(false) }
var showPinClear by remember { mutableStateOf(false) } var showPinClear by remember { mutableStateOf(false) }
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle() val avatars by AvatarRepository.avatars.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { AvatarRepository.fetchAvatars() } LaunchedEffect(Unit) {
AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
}
LaunchedEffect(isNew, avatars, selectedAvatarId) { LaunchedEffect(isNew, avatars, selectedAvatarId) {
if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) { if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) {
selectedAvatarId = avatars.first().id selectedAvatarId = avatars.first().id
@ -272,7 +278,11 @@ fun ProfileEditScreen(
hasExistingPin = currentProfile.pinEnabled, hasExistingPin = currentProfile.pinEnabled,
onDone = { onDone = {
showPinSetup = false showPinSetup = false
scope.launch { ProfileRepository.pullProfiles() } scope.launch {
if (authState is AuthState.Authenticated) {
ProfileRepository.pullProfiles()
}
}
}, },
onDismiss = { showPinSetup = false }, onDismiss = { showPinSetup = false },
) )

View file

@ -60,35 +60,31 @@ object ProfileRepository {
val activeProfileId: Int get() = activeProfileIndex val activeProfileId: Int get() = activeProfileIndex
fun loadCachedProfiles(): Boolean {
val stored = decodeStoredPayload() ?: return false
loadedCacheForUserId = stored.userId
applyStoredPayload(stored)
return _state.value.profiles.isNotEmpty()
}
fun ensureLoaded(userId: String) { fun ensureLoaded(userId: String) {
if (loadedCacheForUserId == userId && _state.value.isLoaded) return if (loadedCacheForUserId == userId && _state.value.isLoaded) return
val stored = decodeStoredPayload()
loadedCacheForUserId = userId loadedCacheForUserId = userId
val payload = ProfileStorage.loadPayload().orEmpty().trim() if (stored == null) {
if (payload.isEmpty()) {
_state.value = ProfileState() _state.value = ProfileState()
activeProfileIndex = 1 activeProfileIndex = 1
return return
} }
val stored = runCatching {
json.decodeFromString<StoredProfilePayload>(payload)
}.getOrNull() ?: return
if (stored.userId != userId) { if (stored.userId != userId) {
_state.value = ProfileState() _state.value = ProfileState()
activeProfileIndex = 1 activeProfileIndex = 1
return return
} }
val profiles = stored.profiles.sortedBy { it.profileIndex } applyStoredPayload(stored)
activeProfileIndex = stored.activeProfileIndex
_state.value = ProfileState(
profiles = profiles,
activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(),
isLoaded = profiles.isNotEmpty(),
)
_state.value.activeProfile?.let { activeProfileIndex = it.profileIndex }
} }
fun clearInMemory() { fun clearInMemory() {
@ -346,6 +342,26 @@ object ProfileRepository {
persist() persist()
} }
private fun decodeStoredPayload(): StoredProfilePayload? {
val payload = ProfileStorage.loadPayload().orEmpty().trim()
if (payload.isEmpty()) return null
return runCatching {
json.decodeFromString<StoredProfilePayload>(payload)
}.getOrNull()
}
private fun applyStoredPayload(stored: StoredProfilePayload) {
val profiles = stored.profiles.sortedBy { it.profileIndex }
activeProfileIndex = stored.activeProfileIndex
_state.value = ProfileState(
profiles = profiles,
activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(),
isLoaded = profiles.isNotEmpty(),
)
_state.value.activeProfile?.let { activeProfileIndex = it.profileIndex }
}
private fun persist() { private fun persist() {
val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return
ProfileStorage.savePayload( ProfileStorage.savePayload(

View file

@ -57,6 +57,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -67,6 +69,7 @@ fun ProfileSelectionScreen(
onAddProfile: () -> Unit, onAddProfile: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val authState by AuthRepository.state.collectAsStateWithLifecycle()
val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pinDialogProfile by remember { mutableStateOf<NuvioProfile?>(null) } var pinDialogProfile by remember { mutableStateOf<NuvioProfile?>(null) }
@ -77,11 +80,16 @@ fun ProfileSelectionScreen(
val manageAlpha = remember { Animatable(0f) } val manageAlpha = remember { Animatable(0f) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
ProfileRepository.pullProfiles()
AvatarRepository.fetchAvatars() AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars() AvatarRepository.refreshAvatars()
} }
LaunchedEffect(authState) {
if (authState is AuthState.Authenticated) {
ProfileRepository.pullProfiles()
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { titleAlpha.animateTo(1f, tween(600, easing = FastOutSlowInEasing)) } launch { titleAlpha.animateTo(1f, tween(600, easing = FastOutSlowInEasing)) }
launch { titleOffset.animateTo(0f, tween(600, easing = FastOutSlowInEasing)) } launch { titleOffset.animateTo(0f, tween(600, easing = FastOutSlowInEasing)) }

View file

@ -83,6 +83,7 @@ fun ProfileSwitcherTab(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
AvatarRepository.fetchAvatars() AvatarRepository.fetchAvatars()
AvatarRepository.refreshAvatars()
} }
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current

View file

@ -3,7 +3,10 @@ package com.nuvio.app.core.storage
import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDefaults
internal actual object PlatformLocalAccountDataCleaner { internal actual object PlatformLocalAccountDataCleaner {
private val plainKeys = listOf("profile_payload") private val plainKeys = listOf(
"profile_payload",
"avatar_catalog_payload",
)
private val profileIndexedPrefixes = listOf( private val profileIndexedPrefixes = listOf(
"installed_manifest_urls_", "installed_manifest_urls_",
"plugins_state_", "plugins_state_",

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.profiles
import platform.Foundation.NSUserDefaults
actual object AvatarStorage {
private const val payloadKey = "avatar_catalog_payload"
actual fun loadPayload(): String? =
NSUserDefaults.standardUserDefaults.stringForKey(payloadKey)
actual fun savePayload(payload: String) {
NSUserDefaults.standardUserDefaults.setObject(payload, forKey = payloadKey)
}
}