improved trakt saving to list behaviour

This commit is contained in:
tapframe 2026-04-06 13:15:53 +05:30
parent 99f251ac4e
commit 3a8e0e9d87
5 changed files with 146 additions and 54 deletions

View file

@ -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"
}

View file

@ -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,
)
}
}
}
}

View file

@ -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"
}

View file

@ -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)

View file

@ -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<TraktListTab> = 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<TraktLibraryUiState> = _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<String, String>): 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<String, List<LibraryItem>>()
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<String, String>): List<TraktListTab> {
val watchlistTabs = listOf(
TraktListTab(
key = WATCHLIST_KEY,
title = "Watchlist",
type = TraktListType.WATCHLIST,
),
)
return watchlistTabs + fetchPersonalLists(headers)
}
private suspend fun hydrateEntriesFromAddonMeta(
entriesByList: Map<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> = coroutineScope {