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( NuvioPosterActionSheet(
item = selectedPosterForActions, item = selectedPosterForActions,
isSaved = selectedPosterForActions?.let { preview -> isSaved = selectedPosterForActions?.let { preview ->
LibraryRepository.isSaved(preview.id) LibraryRepository.isSaved(preview.id, preview.type)
} == true, } == true,
isWatched = selectedPosterForActions?.let { preview -> isWatched = selectedPosterForActions?.let { preview ->
WatchingState.isPosterWatched( WatchingState.isPosterWatched(
@ -1123,9 +1123,12 @@ private fun MainAppContent(
} else { } else {
pickerItem = libraryItem pickerItem = libraryItem
pickerTitle = preview.name pickerTitle = preview.name
pickerTabs = LibraryRepository.traktListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
coroutineScope.launch { coroutineScope.launch {
pickerPending = true
pickerError = null
runCatching { runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs() val tabs = LibraryRepository.traktListTabs()
@ -1133,7 +1136,6 @@ private fun MainAppContent(
pickerMembership = tabs.associate { tab -> pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true) tab.key to (snapshot[tab.key] == true)
} }
showLibraryListPicker = true
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to load Trakt lists" 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@ -74,41 +75,65 @@ fun TraktListPickerDialog(
) )
} }
LazyColumn( if (isPending && tabs.isEmpty()) {
modifier = Modifier Box(
.fillMaxWidth() modifier = Modifier
.height(280.dp), .fillMaxWidth()
verticalArrangement = Arrangement.spacedBy(8.dp), .height(280.dp),
) { contentAlignment = Alignment.Center,
items(items = tabs, key = { it.key }) { tab -> ) {
val selected = membership[tab.key] == true Column(
Row( horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier verticalArrangement = Arrangement.spacedBy(12.dp),
.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( CircularProgressIndicator(
text = tab.title, strokeWidth = 2.dp,
modifier = Modifier.weight(1f), modifier = Modifier.size(24.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
) )
if (selected) { Text(
androidx.compose.material3.Icon( text = "Loading your Trakt lists…",
imageVector = Icons.Rounded.Check, style = MaterialTheme.typography.bodyMedium,
contentDescription = null, color = MaterialTheme.colorScheme.onSurfaceVariant,
tint = MaterialTheme.colorScheme.primary, )
}
}
} 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 -> { displayedMeta != null -> {
val meta = displayedMeta val meta = displayedMeta
val todayIsoDate = CurrentDateProvider.todayIsoDate() val todayIsoDate = CurrentDateProvider.todayIsoDate()
val isSaved = remember(libraryUiState.items, meta.id) { val isSaved = remember(
libraryUiState.items.any { it.id == meta.id } libraryUiState.items,
libraryUiState.sections,
traktAuthUiState.mode,
meta.id,
meta.type,
) {
LibraryRepository.isSaved(meta.id, meta.type)
} }
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
val toggleSaved = remember(meta, isTraktConnected) { val toggleSaved = remember(meta, isTraktConnected) {
@ -223,17 +229,19 @@ fun MetaDetailsScreen(
if (!isTraktConnected) { if (!isTraktConnected) {
LibraryRepository.toggleSaved(libraryItem) LibraryRepository.toggleSaved(libraryItem)
} else { } else {
pickerTabs = LibraryRepository.traktListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
detailsScope.launch { detailsScope.launch {
pickerPending = true
pickerError = null
runCatching { runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.traktListTabs() val tabs = LibraryRepository.traktListTabs()
pickerTabs = tabs pickerTabs = tabs
pickerMembership = tabs.associate { tab -> pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true) tab.key to (snapshot[tab.key] == true)
} }
showLibraryListPicker = true
}.onFailure { error -> }.onFailure { error ->
pickerError = error.message ?: "Failed to load Trakt lists" pickerError = error.message ?: "Failed to load Trakt lists"
} }

View file

@ -64,6 +64,7 @@ object LibraryRepository {
syncScope.launch { syncScope.launch {
TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
if (authenticated) { if (authenticated) {
TraktLibraryRepository.preloadListTabsAsync()
runCatching { TraktLibraryRepository.refreshNow() } runCatching { TraktLibraryRepository.refreshNow() }
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
} }
@ -85,6 +86,7 @@ object LibraryRepository {
if (hasLoaded) return if (hasLoaded) return
loadFromDisk(ProfileRepository.activeProfileId) loadFromDisk(ProfileRepository.activeProfileId)
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
refreshTraktLibraryAsync() refreshTraktLibraryAsync()
} }
} }
@ -95,6 +97,7 @@ object LibraryRepository {
TraktAuthRepository.onProfileChanged() TraktAuthRepository.onProfileChanged()
TraktLibraryRepository.onProfileChanged() TraktLibraryRepository.onProfileChanged()
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
TraktLibraryRepository.preloadListTabsAsync()
refreshTraktLibraryAsync() refreshTraktLibraryAsync()
} }
} }
@ -189,10 +192,13 @@ object LibraryRepository {
} }
} }
fun isSaved(id: String): Boolean { fun isSaved(id: String, type: String? = null): Boolean {
ensureLoaded() ensureLoaded()
if (TraktAuthRepository.isAuthenticated.value) { if (TraktAuthRepository.isAuthenticated.value) {
if (type != null) {
return TraktLibraryRepository.isInAnyList(id, type)
}
val entry = TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } val entry = TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
if (entry != null) { if (entry != null) {
return TraktLibraryRepository.isInAnyList(entry.id, entry.type) 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.details.MetaDetailsRepository
import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryItem
import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
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.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock 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_TIMEOUT_MS = 3_500L
private const val METADATA_FETCH_CONCURRENCY = 5 private const val METADATA_FETCH_CONCURRENCY = 5
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
data class TraktLibraryUiState( data class TraktLibraryUiState(
val listTabs: List<TraktListTab> = emptyList(), val listTabs: List<TraktListTab> = emptyList(),
@ -46,6 +50,7 @@ data class TraktLibraryUiState(
object TraktLibraryRepository { object TraktLibraryRepository {
private val log = Logger.withTag("TraktLibrary") private val log = Logger.withTag("TraktLibrary")
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _uiState = MutableStateFlow(TraktLibraryUiState()) private val _uiState = MutableStateFlow(TraktLibraryUiState())
val uiState: StateFlow<TraktLibraryUiState> = _uiState.asStateFlow() val uiState: StateFlow<TraktLibraryUiState> = _uiState.asStateFlow()
@ -53,20 +58,37 @@ object TraktLibraryRepository {
private var hasLoaded = false private var hasLoaded = false
private val refreshMutex = Mutex() private val refreshMutex = Mutex()
private var lastRefreshAtMs: Long = 0L private var lastRefreshAtMs: Long = 0L
private var lastListTabsRefreshAtMs: Long = 0L
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
hasLoaded = true 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() { fun onProfileChanged() {
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L
_uiState.value = TraktLibraryUiState() _uiState.value = TraktLibraryUiState()
ensureLoaded() ensureLoaded()
} }
fun clearLocalState() { fun clearLocalState() {
hasLoaded = false hasLoaded = false
lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L
_uiState.value = TraktLibraryUiState() _uiState.value = TraktLibraryUiState()
} }
@ -85,6 +107,21 @@ object TraktLibraryRepository {
refresh(force = false) 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) { private suspend fun refresh(force: Boolean) {
ensureLoaded() ensureLoaded()
refreshMutex.withLock { refreshMutex.withLock {
@ -107,6 +144,7 @@ object TraktLibraryRepository {
if (headers == null) { if (headers == null) {
_uiState.value = TraktLibraryUiState() _uiState.value = TraktLibraryUiState()
lastRefreshAtMs = 0L lastRefreshAtMs = 0L
lastListTabsRefreshAtMs = 0L
return return
} }
@ -280,21 +318,23 @@ object TraktLibraryRepository {
} }
private suspend fun fetchSnapshot(headers: Map<String, String>): TraktLibraryUiState = withContext(Dispatchers.Default) { private suspend fun fetchSnapshot(headers: Map<String, String>): TraktLibraryUiState = withContext(Dispatchers.Default) {
val watchlistTabs = listOf( val now = TraktPlatformClock.nowEpochMs()
TraktListTab( val cachedTabs = _uiState.value.listTabs
key = WATCHLIST_KEY, val allTabs = if (
title = "Watchlist", cachedTabs.isNotEmpty() &&
type = TraktListType.WATCHLIST, now - lastListTabsRefreshAtMs <= LIST_TABS_CACHE_TTL_MS
), ) {
) cachedTabs
} else {
val personalLists = fetchPersonalLists(headers) fetchListTabs(headers).also {
val allTabs = watchlistTabs + personalLists lastListTabsRefreshAtMs = now
}
}
val entriesByList = linkedMapOf<String, List<LibraryItem>>() val entriesByList = linkedMapOf<String, List<LibraryItem>>()
entriesByList[WATCHLIST_KEY] = fetchWatchlistItems(headers) entriesByList[WATCHLIST_KEY] = fetchWatchlistItems(headers)
personalLists.forEach { tab -> allTabs.filter { it.type == TraktListType.PERSONAL }.forEach { tab ->
val listId = tab.traktListId?.toString() ?: return@forEach val listId = tab.traktListId?.toString() ?: return@forEach
entriesByList[tab.key] = fetchPersonalListItems(headers, listId) 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( private suspend fun hydrateEntriesFromAddonMeta(
entriesByList: Map<String, List<LibraryItem>>, entriesByList: Map<String, List<LibraryItem>>,
): Map<String, List<LibraryItem>> = coroutineScope { ): Map<String, List<LibraryItem>> = coroutineScope {