diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index dd927c52a..3cb530ca9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -71,6 +71,7 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.sync.SyncManager import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets import com.nuvio.app.core.ui.NuvioPosterActionSheet +import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.NuvioTheme import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.catalog.CatalogRepository @@ -113,6 +114,9 @@ import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -336,14 +340,26 @@ private fun MainAppContent( val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var selectedPosterForActions by remember { mutableStateOf(null) } + var showLibraryListPicker by remember { mutableStateOf(false) } + var pickerItem by remember { mutableStateOf(null) } + var pickerTitle by remember { mutableStateOf("") } + var pickerTabs by remember { mutableStateOf>(emptyList()) } + var pickerMembership by remember { mutableStateOf>(emptyMap()) } + var pickerPending by remember { mutableStateOf(false) } + var pickerError by remember { mutableStateOf(null) } val libraryUiState by remember { LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() + val traktAuthUiState by remember { + TraktAuthRepository.ensureLoaded() + TraktAuthRepository.uiState + }.collectAsStateWithLifecycle() val watchedUiState by remember { WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED var initialHomeReady by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { kotlinx.coroutines.delay(5_000) @@ -901,7 +917,29 @@ private fun MainAppContent( onDismiss = { selectedPosterForActions = null }, onToggleLibrary = { selectedPosterForActions?.let { preview -> - LibraryRepository.toggleSaved(preview.toLibraryItem(savedAtEpochMs = 0L)) + val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) + if (!isTraktConnected) { + LibraryRepository.toggleSaved(libraryItem) + } else { + pickerItem = libraryItem + pickerTitle = preview.name + coroutineScope.launch { + pickerPending = true + pickerError = null + runCatching { + val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) + 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" + } + pickerPending = false + } + } } }, onToggleWatched = { @@ -913,6 +951,47 @@ private fun MainAppContent( }, ) + TraktListPickerDialog( + visible = showLibraryListPicker, + title = pickerTitle, + tabs = pickerTabs, + membership = pickerMembership, + isPending = pickerPending, + errorMessage = pickerError, + onToggle = { listKey -> + pickerMembership = pickerMembership.toMutableMap().apply { + this[listKey] = !(this[listKey] == true) + } + }, + onDismiss = { + if (!pickerPending) { + showLibraryListPicker = false + pickerItem = null + pickerError = null + } + }, + onSave = { + val item = pickerItem ?: return@TraktListPickerDialog + coroutineScope.launch { + pickerPending = true + pickerError = null + runCatching { + LibraryRepository.applyMembershipChanges( + item = item, + desiredMembership = pickerMembership, + ) + }.onSuccess { + showLibraryListPicker = false + pickerItem = null + pickerError = null + }.onFailure { error -> + pickerError = error.message ?: "Failed to update Trakt lists" + } + pickerPending = false + } + }, + ) + androidx.compose.animation.AnimatedVisibility( visible = !initialHomeReady || profileSwitchLoading, enter = fadeIn(), 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 new file mode 100644 index 000000000..3a87d1d75 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt @@ -0,0 +1,149 @@ +package com.nuvio.app.core.ui + +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.trakt.TraktListTab + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TraktListPickerDialog( + visible: Boolean, + title: String, + tabs: List, + membership: Map, + isPending: Boolean, + errorMessage: String?, + onToggle: (String) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, +) { + if (!visible) return + + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(20.dp), + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Choose where to save this title on Trakt", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (!errorMessage.isNullOrBlank()) { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + 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, + ) + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + ) { + Button( + onClick = onDismiss, + enabled = !isPending, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text("Cancel") + } + Button( + onClick = onSave, + enabled = !isPending, + ) { + if (isPending) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + ) + } else { + Text("Save") + } + } + } + } + } + } +} \ No newline at end of file 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 14bebf90a..9dc190d0b 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 @@ -20,21 +20,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -52,6 +43,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.CommentDetailSheet @@ -233,9 +225,8 @@ fun MetaDetailsScreen( pickerPending = true pickerError = null runCatching { - LibraryRepository.pullFromServer(com.nuvio.app.features.profiles.ProfileRepository.activeProfileId) - val tabs = LibraryRepository.traktListTabs() val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) + val tabs = LibraryRepository.traktListTabs() pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) @@ -864,123 +855,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun TraktListPickerDialog( - visible: Boolean, - title: String, - tabs: List, - membership: Map, - isPending: Boolean, - errorMessage: String?, - onToggle: (String) -> Unit, - onSave: () -> Unit, - onDismiss: () -> Unit, -) { - if (!visible) return - - BasicAlertDialog( - onDismissRequest = onDismiss, - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(20.dp), - ) { - Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Choose where to save this title on Trakt", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - if (!errorMessage.isNullOrBlank()) { - Text( - text = errorMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - - 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, - ) - } - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), - ) { - Button( - onClick = onDismiss, - enabled = !isPending, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Text("Cancel") - } - Button( - onClick = onSave, - enabled = !isPending, - ) { - if (isPending) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp), - ) - } else { - Text("Save") - } - } - } - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 62f7f2ef0..6fe603e9a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -55,7 +55,7 @@ fun DetailFloatingHeader( val headerTopPadding = (safeAreaTop - 6.dp).coerceAtLeast(safeAreaTop * 0.8f) val interactive = progress > 0.05f val surfaceColor = if (isIos) { - MaterialTheme.colorScheme.surface.copy(alpha = 0.84f) + MaterialTheme.colorScheme.surface.copy(alpha = 1.0f) } else { MaterialTheme.colorScheme.background } 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 13f11cca7..03ebb4183 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 @@ -15,7 +15,9 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -30,6 +32,7 @@ private const val WATCHLIST_KEY = "trakt:watchlist" 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 data class TraktLibraryUiState( val listTabs: List = emptyList(), @@ -48,6 +51,8 @@ object TraktLibraryRepository { val uiState: StateFlow = _uiState.asStateFlow() private var hasLoaded = false + private val refreshMutex = Mutex() + private var lastRefreshAtMs: Long = 0L fun ensureLoaded() { if (hasLoaded) return @@ -73,39 +78,61 @@ object TraktLibraryRepository { } suspend fun refreshNow() { + refresh(force = true) + } + + suspend fun ensureFresh() { + refresh(force = false) + } + + private suspend fun refresh(force: Boolean) { ensureLoaded() - AddonRepository.initialize() - withTimeoutOrNull(4_000L) { - AddonRepository.awaitManifestsLoaded() + refreshMutex.withLock { + val now = TraktPlatformClock.nowEpochMs() + val current = _uiState.value + if ( + !force && + current.listTabs.isNotEmpty() && + now - lastRefreshAtMs <= SNAPSHOT_CACHE_TTL_MS + ) { + return + } + + AddonRepository.initialize() + withTimeoutOrNull(4_000L) { + AddonRepository.awaitManifestsLoaded() + } + + val headers = TraktAuthRepository.authorizedHeaders() + if (headers == null) { + _uiState.value = TraktLibraryUiState() + lastRefreshAtMs = 0L + return + } + + _uiState.value = current.copy(isLoading = true, errorMessage = null) + + val result = runCatching { + fetchSnapshot(headers) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "Failed to refresh Trakt library: ${error.message}" } + }.getOrNull() + + if (result == null) { + _uiState.value = current.copy(isLoading = false, errorMessage = "Failed to load Trakt library") + return + } + + _uiState.value = result.copy(isLoading = false, errorMessage = null) + lastRefreshAtMs = now } - - val headers = TraktAuthRepository.authorizedHeaders() - if (headers == null) { - _uiState.value = TraktLibraryUiState() - return - } - - _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null) - - val result = runCatching { - fetchSnapshot(headers) - }.onFailure { error -> - if (error is CancellationException) throw error - log.w { "Failed to refresh Trakt library: ${error.message}" } - }.getOrNull() - - if (result == null) { - _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Failed to load Trakt library") - return - } - - _uiState.value = result.copy(isLoading = false, errorMessage = null) } suspend fun getMembershipSnapshot(item: LibraryItem): TraktMembershipSnapshot { ensureLoaded() - if (_uiState.value.listTabs.isEmpty() && TraktAuthRepository.isAuthenticated.value) { - refreshNow() + if (TraktAuthRepository.isAuthenticated.value) { + ensureFresh() } val itemMembership = _uiState.value.membershipByContent[contentKey(item.id, item.type)].orEmpty() val map = _uiState.value.listTabs.associate { tab -> @@ -127,33 +154,129 @@ object TraktLibraryRepository { suspend fun applyMembershipChanges(item: LibraryItem, changes: TraktMembershipChanges) { ensureLoaded() val headers = TraktAuthRepository.authorizedHeaders() ?: return + ensureFresh() + val current = getMembershipSnapshot(item).listMembership val desired = changes.desiredMembership val keys = (current.keys + desired.keys).distinct() + val previousState = _uiState.value - for (key in keys) { - val before = current[key] == true - val after = desired[key] == true - if (before == after) continue + _uiState.value = applyOptimisticMembershipChanges( + state = previousState, + item = item, + desiredMembership = desired, + ) - if (key == WATCHLIST_KEY) { - if (after) { - addToWatchlist(headers, item) + try { + for (key in keys) { + val before = current[key] == true + val after = desired[key] == true + if (before == after) continue + + if (key == WATCHLIST_KEY) { + if (after) { + addToWatchlist(headers, item) + } else { + removeFromWatchlist(headers, item) + } } else { - removeFromWatchlist(headers, item) + val listId = key.removePrefix(PERSONAL_LIST_PREFIX) + if (listId == key || listId.isBlank()) continue + if (after) { + addToPersonalList(headers, listId, item) + } else { + removeFromPersonalList(headers, listId, item) + } } + } + } catch (error: Throwable) { + _uiState.value = previousState + throw error + } + } + + private fun applyOptimisticMembershipChanges( + state: TraktLibraryUiState, + item: LibraryItem, + desiredMembership: Map, + ): TraktLibraryUiState { + if (state.listTabs.isEmpty()) return state + + val contentKey = contentKey(item.id, item.type) + val currentMembership = state.membershipByContent[contentKey].orEmpty() + val updatedEntriesByList = state.entriesByList.toMutableMap() + val keys = (currentMembership + desiredMembership.keys).distinct() + + keys.forEach { listKey -> + val before = currentMembership.contains(listKey) + val after = desiredMembership[listKey] == true + if (before == after) return@forEach + + if (after) { + val resolvedItem = resolveOptimisticItem(state, item) + updatedEntriesByList[listKey] = listOf(resolvedItem) + + updatedEntriesByList[listKey].orEmpty().filterNot { + contentKey(it.id, it.type) == contentKey + } } else { - val listId = key.removePrefix(PERSONAL_LIST_PREFIX) - if (listId == key || listId.isBlank()) continue - if (after) { - addToPersonalList(headers, listId, item) - } else { - removeFromPersonalList(headers, listId, item) + updatedEntriesByList[listKey] = updatedEntriesByList[listKey].orEmpty().filterNot { + contentKey(it.id, it.type) == contentKey } } } - refreshNow() + return rebuildUiState( + listTabs = state.listTabs, + entriesByList = updatedEntriesByList, + ) + } + + private fun resolveOptimisticItem( + state: TraktLibraryUiState, + item: LibraryItem, + ): LibraryItem { + val existing = state.allItems.firstOrNull { + contentKey(it.id, it.type) == contentKey(item.id, item.type) + } + val base = existing ?: item + val savedAt = base.savedAtEpochMs.takeIf { it > 0L } + ?: item.savedAtEpochMs.takeIf { it > 0L } + ?: TraktPlatformClock.nowEpochMs() + + return base.copy(savedAtEpochMs = savedAt) + } + + private fun rebuildUiState( + listTabs: List, + entriesByList: Map>, + ): TraktLibraryUiState { + val normalizedEntriesByList = linkedMapOf>() + listTabs.forEach { tab -> + normalizedEntriesByList[tab.key] = entriesByList[tab.key].orEmpty() + } + + val membershipByContent = mutableMapOf>() + normalizedEntriesByList.forEach { (listKey, entries) -> + entries.forEach { entry -> + membershipByContent + .getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() } + .add(listKey) + } + } + + val allItems = normalizedEntriesByList.values + .flatten() + .distinctBy { contentKey(it.id, it.type) } + .sortedByDescending { it.savedAtEpochMs } + + return TraktLibraryUiState( + listTabs = listTabs, + entriesByList = normalizedEntriesByList, + allItems = allItems, + membershipByContent = membershipByContent.mapValues { it.value.toSet() }, + isLoading = false, + errorMessage = null, + ) } private suspend fun fetchSnapshot(headers: Map): TraktLibraryUiState = withContext(Dispatchers.Default) {