diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 6d4c1e7a..5634668e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1106,7 +1106,7 @@ private fun MainAppContent( NuvioPosterActionSheet( item = selectedPosterForActions, isSaved = selectedPosterForActions?.let { preview -> - LibraryRepository.isSaved(preview.id) + LibraryRepository.isSaved(preview.id, preview.type) } == true, isWatched = selectedPosterForActions?.let { preview -> WatchingState.isPosterWatched( @@ -1123,9 +1123,12 @@ private fun MainAppContent( } else { pickerItem = libraryItem pickerTitle = preview.name + pickerTabs = LibraryRepository.traktListTabs() + pickerMembership = pickerTabs.associate { it.key to false } + pickerPending = true + pickerError = null + showLibraryListPicker = true coroutineScope.launch { - pickerPending = true - pickerError = null runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val tabs = LibraryRepository.traktListTabs() @@ -1133,7 +1136,6 @@ private fun MainAppContent( pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) } - showLibraryListPicker = true }.onFailure { error -> pickerError = error.message ?: "Failed to load Trakt lists" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt index 3a87d1d7..6a39e445 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -74,41 +75,65 @@ fun TraktListPickerDialog( ) } - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .height(280.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(items = tabs, key = { it.key }) { tab -> - val selected = membership[tab.key] == true - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) - }, - shape = RoundedCornerShape(12.dp), - ) - .clickable(enabled = !isPending) { onToggle(tab.key) } - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + if (isPending && tabs.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(280.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = tab.title, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), ) - if (selected) { - androidx.compose.material3.Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + Text( + text = "Loading your Trakt lists…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(280.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(items = tabs, key = { it.key }) { tab -> + val selected = membership[tab.key] == true + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + }, + shape = RoundedCornerShape(12.dp), + ) + .clickable(enabled = !isPending) { onToggle(tab.key) } + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = tab.title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, ) + if (selected) { + androidx.compose.material3.Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 1a03ec8c..4de2e17e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -213,8 +213,14 @@ fun MetaDetailsScreen( displayedMeta != null -> { val meta = displayedMeta val todayIsoDate = CurrentDateProvider.todayIsoDate() - val isSaved = remember(libraryUiState.items, meta.id) { - libraryUiState.items.any { it.id == meta.id } + val isSaved = remember( + libraryUiState.items, + libraryUiState.sections, + traktAuthUiState.mode, + meta.id, + meta.type, + ) { + LibraryRepository.isSaved(meta.id, meta.type) } val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED val toggleSaved = remember(meta, isTraktConnected) { @@ -223,17 +229,19 @@ fun MetaDetailsScreen( if (!isTraktConnected) { LibraryRepository.toggleSaved(libraryItem) } else { + pickerTabs = LibraryRepository.traktListTabs() + pickerMembership = pickerTabs.associate { it.key to false } + pickerPending = true + pickerError = null + showLibraryListPicker = true detailsScope.launch { - pickerPending = true - pickerError = null runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.traktListTabs() + val tabs = LibraryRepository.traktListTabs() pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) } - showLibraryListPicker = true }.onFailure { error -> pickerError = error.message ?: "Failed to load Trakt lists" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 42ca64c6..6c9607a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -64,6 +64,7 @@ object LibraryRepository { syncScope.launch { TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> if (authenticated) { + TraktLibraryRepository.preloadListTabsAsync() runCatching { TraktLibraryRepository.refreshNow() } .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } } @@ -85,6 +86,7 @@ object LibraryRepository { if (hasLoaded) return loadFromDisk(ProfileRepository.activeProfileId) if (TraktAuthRepository.isAuthenticated.value) { + TraktLibraryRepository.preloadListTabsAsync() refreshTraktLibraryAsync() } } @@ -95,6 +97,7 @@ object LibraryRepository { TraktAuthRepository.onProfileChanged() TraktLibraryRepository.onProfileChanged() if (TraktAuthRepository.isAuthenticated.value) { + TraktLibraryRepository.preloadListTabsAsync() refreshTraktLibraryAsync() } } @@ -189,10 +192,13 @@ object LibraryRepository { } } - fun isSaved(id: String): Boolean { + fun isSaved(id: String, type: String? = null): Boolean { ensureLoaded() if (TraktAuthRepository.isAuthenticated.value) { + if (type != null) { + return TraktLibraryRepository.isInAnyList(id, type) + } val entry = TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } if (entry != null) { return TraktLibraryRepository.isInAnyList(entry.id, entry.type) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 5d152c7e..75aa2601 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -7,14 +7,17 @@ import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.tmdb.TmdbService +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock @@ -33,6 +36,7 @@ private const val PERSONAL_LIST_PREFIX = "trakt:list:" private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_CONCURRENCY = 5 private const val SNAPSHOT_CACHE_TTL_MS = 60_000L +private const val LIST_TABS_CACHE_TTL_MS = 60_000L data class TraktLibraryUiState( val listTabs: List = emptyList(), @@ -46,6 +50,7 @@ data class TraktLibraryUiState( object TraktLibraryRepository { private val log = Logger.withTag("TraktLibrary") private val json = Json { ignoreUnknownKeys = true } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _uiState = MutableStateFlow(TraktLibraryUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -53,20 +58,37 @@ object TraktLibraryRepository { private var hasLoaded = false private val refreshMutex = Mutex() private var lastRefreshAtMs: Long = 0L + private var lastListTabsRefreshAtMs: Long = 0L fun ensureLoaded() { if (hasLoaded) return hasLoaded = true } + fun preloadListTabsAsync() { + if (!TraktAuthRepository.isAuthenticated.value) return + if (_uiState.value.listTabs.isNotEmpty()) return + scope.launch { + runCatching { preloadListTabs() } + .onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to preload Trakt list tabs: ${error.message}" } + } + } + } + fun onProfileChanged() { hasLoaded = false + lastRefreshAtMs = 0L + lastListTabsRefreshAtMs = 0L _uiState.value = TraktLibraryUiState() ensureLoaded() } fun clearLocalState() { hasLoaded = false + lastRefreshAtMs = 0L + lastListTabsRefreshAtMs = 0L _uiState.value = TraktLibraryUiState() } @@ -85,6 +107,21 @@ object TraktLibraryRepository { refresh(force = false) } + private suspend fun preloadListTabs() { + ensureLoaded() + refreshMutex.withLock { + if (_uiState.value.listTabs.isNotEmpty()) return + + val headers = TraktAuthRepository.authorizedHeaders() ?: return + val tabs = fetchListTabs(headers) + _uiState.value = _uiState.value.copy( + listTabs = tabs, + errorMessage = null, + ) + lastListTabsRefreshAtMs = TraktPlatformClock.nowEpochMs() + } + } + private suspend fun refresh(force: Boolean) { ensureLoaded() refreshMutex.withLock { @@ -107,6 +144,7 @@ object TraktLibraryRepository { if (headers == null) { _uiState.value = TraktLibraryUiState() lastRefreshAtMs = 0L + lastListTabsRefreshAtMs = 0L return } @@ -280,21 +318,23 @@ object TraktLibraryRepository { } private suspend fun fetchSnapshot(headers: Map): TraktLibraryUiState = withContext(Dispatchers.Default) { - val watchlistTabs = listOf( - TraktListTab( - key = WATCHLIST_KEY, - title = "Watchlist", - type = TraktListType.WATCHLIST, - ), - ) - - val personalLists = fetchPersonalLists(headers) - val allTabs = watchlistTabs + personalLists + val now = TraktPlatformClock.nowEpochMs() + val cachedTabs = _uiState.value.listTabs + val allTabs = if ( + cachedTabs.isNotEmpty() && + now - lastListTabsRefreshAtMs <= LIST_TABS_CACHE_TTL_MS + ) { + cachedTabs + } else { + fetchListTabs(headers).also { + lastListTabsRefreshAtMs = now + } + } val entriesByList = linkedMapOf>() entriesByList[WATCHLIST_KEY] = fetchWatchlistItems(headers) - personalLists.forEach { tab -> + allTabs.filter { it.type == TraktListType.PERSONAL }.forEach { tab -> val listId = tab.traktListId?.toString() ?: return@forEach entriesByList[tab.key] = fetchPersonalListItems(headers, listId) } @@ -323,6 +363,17 @@ object TraktLibraryRepository { ) } + private suspend fun fetchListTabs(headers: Map): List { + val watchlistTabs = listOf( + TraktListTab( + key = WATCHLIST_KEY, + title = "Watchlist", + type = TraktListType.WATCHLIST, + ), + ) + return watchlistTabs + fetchPersonalLists(headers) + } + private suspend fun hydrateEntriesFromAddonMeta( entriesByList: Map>, ): Map> = coroutineScope {