mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-27 11:23:02 +00:00
improved trakt saving to list behaviour
This commit is contained in:
parent
99f251ac4e
commit
3a8e0e9d87
5 changed files with 146 additions and 54 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue