feat(trakt): integrate TraktListPickerDialog for library item management

This commit is contained in:
tapframe 2026-04-03 00:51:09 +05:30
parent 94c5bf4991
commit fe2868e068
5 changed files with 398 additions and 176 deletions

View file

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

View file

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

View file

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

View file

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

View file

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