mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 06:52:18 +00:00
feat(trakt): integrate TraktListPickerDialog for library item management
This commit is contained in:
parent
94c5bf4991
commit
fe2868e068
5 changed files with 398 additions and 176 deletions
|
|
@ -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<MetaPreview?>(null) }
|
||||
var showLibraryListPicker by remember { mutableStateOf(false) }
|
||||
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
|
||||
var pickerTitle by remember { mutableStateOf("") }
|
||||
var pickerTabs by remember { mutableStateOf<List<TraktListTab>>(emptyList()) }
|
||||
var pickerMembership by remember { mutableStateOf<Map<String, Boolean>>(emptyMap()) }
|
||||
var pickerPending by remember { mutableStateOf(false) }
|
||||
var pickerError by remember { mutableStateOf<String?>(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(),
|
||||
|
|
|
|||
|
|
@ -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<TraktListTab>,
|
||||
membership: Map<String, Boolean>,
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<TraktListTab>,
|
||||
membership: Map<String, Boolean>,
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TraktListTab> = emptyList(),
|
||||
|
|
@ -48,6 +51,8 @@ object TraktLibraryRepository {
|
|||
val uiState: StateFlow<TraktLibraryUiState> = _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<String, Boolean>,
|
||||
): 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<TraktListTab>,
|
||||
entriesByList: Map<String, List<LibraryItem>>,
|
||||
): TraktLibraryUiState {
|
||||
val normalizedEntriesByList = linkedMapOf<String, List<LibraryItem>>()
|
||||
listTabs.forEach { tab ->
|
||||
normalizedEntriesByList[tab.key] = entriesByList[tab.key].orEmpty()
|
||||
}
|
||||
|
||||
val membershipByContent = mutableMapOf<String, MutableSet<String>>()
|
||||
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<String, String>): TraktLibraryUiState = withContext(Dispatchers.Default) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue