mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +00:00
feat: trakt library source option to switch between trakt/nuvio library
This commit is contained in:
parent
d00aba86af
commit
55b97d97ad
12 changed files with 381 additions and 93 deletions
|
|
@ -16,6 +16,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_mdblist_settings",
|
||||
"nuvio_trakt_auth",
|
||||
"nuvio_trakt_library",
|
||||
"nuvio_trakt_settings",
|
||||
"nuvio_watched",
|
||||
"nuvio_stream_link_cache",
|
||||
"nuvio_continue_watching_preferences",
|
||||
|
|
|
|||
|
|
@ -783,6 +783,14 @@
|
|||
<string name="settings_trakt_open_login">Open Trakt Login</string>
|
||||
<string name="settings_trakt_save_actions_description">Your Save actions can now target Trakt watchlist and personal lists.</string>
|
||||
<string name="settings_trakt_sign_in_description">Sign in with Trakt to enable list-based saving and Trakt library mode.</string>
|
||||
<string name="trakt_library_source_title">Library Source</string>
|
||||
<string name="trakt_library_source_subtitle">Choose which library to use for saving and viewing your collection</string>
|
||||
<string name="trakt_library_source_dialog_title">Library Source</string>
|
||||
<string name="trakt_library_source_dialog_subtitle">Choose where to save and manage your library items</string>
|
||||
<string name="trakt_library_source_trakt">Trakt</string>
|
||||
<string name="trakt_library_source_nuvio">Nuvio Library</string>
|
||||
<string name="trakt_library_source_trakt_selected">Trakt library selected</string>
|
||||
<string name="trakt_library_source_nuvio_selected">Nuvio library selected</string>
|
||||
<string name="trakt_watch_progress_title">Watch Progress</string>
|
||||
<string name="trakt_watch_progress_subtitle">Choose which progress source powers resume and continue watching</string>
|
||||
<string name="trakt_watch_progress_dialog_title">Watch Progress</string>
|
||||
|
|
|
|||
|
|
@ -152,8 +152,6 @@ 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.updater.AppUpdaterHost
|
||||
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||
|
|
@ -486,10 +484,6 @@ private fun MainAppContent(
|
|||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val traktAuthUiState by remember {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktAuthRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val authState by AuthRepository.state.collectAsStateWithLifecycle()
|
||||
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
|
||||
val playerSettingsUiState by remember {
|
||||
|
|
@ -508,7 +502,7 @@ private fun MainAppContent(
|
|||
NetworkStatusRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val downloadedProviderLabel = stringResource(Res.string.provider_downloaded)
|
||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
||||
val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT
|
||||
var initialHomeReady by rememberSaveable { mutableStateOf(false) }
|
||||
var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) }
|
||||
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
|
||||
|
|
@ -1664,12 +1658,12 @@ private fun MainAppContent(
|
|||
onToggleLibrary = {
|
||||
selectedPosterForActions?.let { preview ->
|
||||
val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L)
|
||||
if (!isTraktConnected) {
|
||||
if (!isTraktLibrarySource) {
|
||||
LibraryRepository.toggleSaved(libraryItem)
|
||||
} else {
|
||||
pickerItem = libraryItem
|
||||
pickerTitle = preview.name
|
||||
pickerTabs = LibraryRepository.traktListTabs()
|
||||
pickerTabs = LibraryRepository.libraryListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
|
|
@ -1677,7 +1671,7 @@ private fun MainAppContent(
|
|||
coroutineScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.traktListTabs()
|
||||
val tabs = LibraryRepository.libraryListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
|
|
|
|||
|
|
@ -276,39 +276,39 @@ fun MetaDetailsScreen(
|
|||
val isSaved = remember(
|
||||
libraryUiState.items,
|
||||
libraryUiState.sections,
|
||||
traktAuthUiState.mode,
|
||||
libraryUiState.sourceMode,
|
||||
meta.id,
|
||||
meta.type,
|
||||
) {
|
||||
LibraryRepository.isSaved(meta.id, meta.type)
|
||||
}
|
||||
val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED
|
||||
val toggleSaved = remember(meta, isTraktConnected) {
|
||||
val openLibraryListPicker = remember(meta) {
|
||||
{
|
||||
val libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L)
|
||||
if (!isTraktConnected) {
|
||||
LibraryRepository.toggleSaved(libraryItem)
|
||||
} else {
|
||||
pickerTabs = LibraryRepository.traktListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
detailsScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.traktListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||
pickerTabs = LibraryRepository.libraryListTabs()
|
||||
pickerMembership = pickerTabs.associate { it.key to false }
|
||||
pickerPending = true
|
||||
pickerError = null
|
||||
showLibraryListPicker = true
|
||||
detailsScope.launch {
|
||||
runCatching {
|
||||
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
|
||||
val tabs = LibraryRepository.libraryListTabs()
|
||||
pickerTabs = tabs
|
||||
pickerMembership = tabs.associate { tab ->
|
||||
tab.key to (snapshot[tab.key] == true)
|
||||
}
|
||||
pickerPending = false
|
||||
}.onFailure { error ->
|
||||
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
|
||||
}
|
||||
Unit
|
||||
pickerPending = false
|
||||
}
|
||||
Unit
|
||||
}
|
||||
}
|
||||
val toggleSaved = remember(meta) {
|
||||
{
|
||||
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
|
||||
}
|
||||
}
|
||||
val movieProgress = watchProgressUiState.byVideoId[meta.id]
|
||||
|
|
@ -639,6 +639,7 @@ fun MetaDetailsScreen(
|
|||
onPrimaryPlayClick = onPrimaryPlayClick,
|
||||
onPrimaryPlayLongClick = onPrimaryPlayLongClick,
|
||||
onSaveClick = toggleSaved,
|
||||
onSaveLongClick = openLibraryListPicker,
|
||||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
||||
|
|
@ -946,6 +947,7 @@ private fun ConfiguredMetaSections(
|
|||
onPrimaryPlayClick: () -> Unit,
|
||||
onPrimaryPlayLongClick: (() -> Unit)?,
|
||||
onSaveClick: () -> Unit,
|
||||
onSaveLongClick: (() -> Unit)?,
|
||||
showManualPlayOption: Boolean,
|
||||
preferredEpisodeSeasonNumber: Int?,
|
||||
preferredEpisodeNumber: Int?,
|
||||
|
|
@ -1010,6 +1012,7 @@ private fun ConfiguredMetaSections(
|
|||
onPlayClick = onPrimaryPlayClick,
|
||||
onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null,
|
||||
onSaveClick = onSaveClick,
|
||||
onSaveLongClick = onSaveLongClick,
|
||||
)
|
||||
}
|
||||
MetaScreenSectionKey.OVERVIEW -> {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,8 @@ import androidx.compose.foundation.layout.width
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -44,6 +41,7 @@ fun DetailActionButtons(
|
|||
onPlayClick: () -> Unit = {},
|
||||
onPlayLongClick: (() -> Unit)? = null,
|
||||
onSaveClick: () -> Unit = {},
|
||||
onSaveLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val playPainter = appIconPainter(AppIconResource.PlayerPlay)
|
||||
val libraryAddPainter = appIconPainter(AppIconResource.LibraryAddPlus)
|
||||
|
|
@ -96,35 +94,49 @@ fun DetailActionButtons(
|
|||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onSaveClick,
|
||||
Surface(
|
||||
modifier = rowButtonModifier.height(50.dp),
|
||||
shape = RoundedCornerShape(40.dp),
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
if (isSaved) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = libraryAddPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onClick = onSaveClick,
|
||||
onLongClick = onSaveLongClick,
|
||||
role = Role.Button,
|
||||
)
|
||||
.height(50.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isSaved) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = libraryAddPainter,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = saveLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = saveLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider
|
|||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktLibraryRepository
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktListType
|
||||
import com.nuvio.app.features.trakt.TraktMembershipChanges
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -65,12 +72,28 @@ object LibraryRepository {
|
|||
TraktAuthRepository.isAuthenticated.collectLatest { authenticated ->
|
||||
if (authenticated) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
|
||||
if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) {
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } }
|
||||
}
|
||||
}
|
||||
publish()
|
||||
}
|
||||
}
|
||||
syncScope.launch {
|
||||
TraktSettingsRepository.uiState
|
||||
.map { it.librarySourceMode }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { source ->
|
||||
if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
publish()
|
||||
refreshTraktLibraryAsync()
|
||||
} else {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
syncScope.launch {
|
||||
TraktLibraryRepository.uiState.collectLatest {
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
|
|
@ -82,23 +105,29 @@ object LibraryRepository {
|
|||
|
||||
fun ensureLoaded() {
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
TraktLibraryRepository.ensureLoaded()
|
||||
if (hasLoaded) return
|
||||
loadFromDisk(ProfileRepository.activeProfileId)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
refreshTraktLibraryAsync()
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
refreshTraktLibraryAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onProfileChanged(profileId: Int) {
|
||||
if (profileId == currentProfileId && hasLoaded) return
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
loadFromDisk(profileId)
|
||||
TraktAuthRepository.onProfileChanged()
|
||||
TraktLibraryRepository.onProfileChanged()
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.preloadListTabsAsync()
|
||||
refreshTraktLibraryAsync()
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
refreshTraktLibraryAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +159,7 @@ object LibraryRepository {
|
|||
suspend fun pullFromServer(profileId: Int) {
|
||||
currentProfileId = profileId
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
|
||||
publish()
|
||||
|
|
@ -157,7 +186,7 @@ object LibraryRepository {
|
|||
fun toggleSaved(item: LibraryItem) {
|
||||
ensureLoaded()
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
syncScope.launch {
|
||||
runCatching { TraktLibraryRepository.toggleWatchlist(item) }
|
||||
.onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } }
|
||||
|
|
@ -175,7 +204,6 @@ object LibraryRepository {
|
|||
|
||||
fun save(item: LibraryItem) {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) return
|
||||
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -184,7 +212,6 @@ object LibraryRepository {
|
|||
|
||||
fun remove(id: String) {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) return
|
||||
if (itemsById.remove(id) != null) {
|
||||
publish()
|
||||
persist()
|
||||
|
|
@ -195,7 +222,7 @@ object LibraryRepository {
|
|||
fun isSaved(id: String, type: String? = null): Boolean {
|
||||
ensureLoaded()
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
if (type != null) {
|
||||
return TraktLibraryRepository.isInAnyList(id, type)
|
||||
}
|
||||
|
|
@ -212,46 +239,65 @@ object LibraryRepository {
|
|||
fun savedItem(id: String): LibraryItem? {
|
||||
ensureLoaded()
|
||||
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
return itemsById[id]
|
||||
}
|
||||
|
||||
fun traktListTabs() = TraktLibraryRepository.currentListTabs()
|
||||
fun libraryListTabs(): List<TraktListTab> {
|
||||
val traktTabs = if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.currentListTabs()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
return libraryTabsWithLocal(traktTabs)
|
||||
}
|
||||
|
||||
fun traktListTabs(): List<TraktListTab> = libraryListTabs()
|
||||
|
||||
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
return TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
||||
}
|
||||
val inLocal = itemsById.containsKey(item.id)
|
||||
return mapOf(LOCAL_LIST_KEY to inLocal)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
||||
return libraryMembershipWithLocal(
|
||||
inLocal = inLocal,
|
||||
traktMembership = traktMembership,
|
||||
)
|
||||
}
|
||||
return libraryMembershipWithLocal(inLocal = inLocal)
|
||||
}
|
||||
|
||||
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
|
||||
ensureLoaded()
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
TraktLibraryRepository.applyMembershipChanges(
|
||||
item = item,
|
||||
changes = TraktMembershipChanges(desiredMembership = desiredMembership),
|
||||
)
|
||||
publish()
|
||||
return
|
||||
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
|
||||
val currentlyInLocal = itemsById.containsKey(item.id)
|
||||
if (localDesired != currentlyInLocal) {
|
||||
if (localDesired) {
|
||||
save(item)
|
||||
} else {
|
||||
remove(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
val shouldBeSaved = desiredMembership.values.any { it }
|
||||
if (shouldBeSaved) {
|
||||
save(item)
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY }
|
||||
if (traktMembership.isNotEmpty()) {
|
||||
TraktLibraryRepository.applyMembershipChanges(
|
||||
item = item,
|
||||
changes = TraktMembershipChanges(desiredMembership = traktMembership),
|
||||
)
|
||||
}
|
||||
publish()
|
||||
} else {
|
||||
remove(item.id)
|
||||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pushToServer() {
|
||||
syncScope.launch {
|
||||
if (TraktAuthRepository.isAuthenticated.value) return@launch
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
val syncItems = itemsById.values.map { it.toSyncItem() }
|
||||
|
|
@ -267,7 +313,7 @@ object LibraryRepository {
|
|||
}
|
||||
|
||||
private fun publish() {
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
val traktState = TraktLibraryRepository.uiState.value
|
||||
val sections = traktState.listTabs.mapNotNull { tab ->
|
||||
val listItems = traktState.entriesByList[tab.key].orEmpty()
|
||||
|
|
@ -334,9 +380,42 @@ object LibraryRepository {
|
|||
publish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectedLibrarySourceMode(): LibrarySourceMode {
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
return TraktSettingsRepository.uiState.value.librarySourceMode
|
||||
}
|
||||
|
||||
private fun effectiveLibrarySourceMode(): LibrarySourceMode =
|
||||
resolveEffectiveLibrarySourceMode(
|
||||
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
|
||||
source = selectedLibrarySourceMode(),
|
||||
)
|
||||
|
||||
private fun isTraktLibrarySourceActive(): Boolean =
|
||||
effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT
|
||||
}
|
||||
|
||||
private const val LOCAL_LIST_KEY = "local"
|
||||
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
|
||||
internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library"
|
||||
|
||||
internal fun localLibraryListTab(): TraktListTab =
|
||||
TraktListTab(
|
||||
key = LOCAL_LIBRARY_LIST_KEY,
|
||||
title = LOCAL_LIBRARY_LIST_TITLE,
|
||||
type = TraktListType.WATCHLIST,
|
||||
)
|
||||
|
||||
internal fun libraryTabsWithLocal(traktTabs: List<TraktListTab>): List<TraktListTab> =
|
||||
listOf(localLibraryListTab()) + traktTabs
|
||||
|
||||
internal fun libraryMembershipWithLocal(
|
||||
inLocal: Boolean,
|
||||
traktMembership: Map<String, Boolean> = emptyMap(),
|
||||
): Map<String, Boolean> =
|
||||
linkedMapOf<String, Boolean>(LOCAL_LIBRARY_LIST_KEY to inLocal).apply {
|
||||
putAll(traktMembership)
|
||||
}
|
||||
|
||||
private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
||||
id = contentId,
|
||||
|
|
|
|||
|
|
@ -136,8 +136,8 @@ object ProfileRepository {
|
|||
)
|
||||
persist()
|
||||
WatchedRepository.onProfileChanged(profileIndex)
|
||||
LibraryRepository.onProfileChanged(profileIndex)
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
LibraryRepository.onProfileChanged(profileIndex)
|
||||
WatchProgressRepository.onProfileChanged(profileIndex)
|
||||
AddonRepository.onProfileChanged(profileIndex)
|
||||
if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.library.LibrarySourceMode
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktBrandAsset
|
||||
import com.nuvio.app.features.trakt.TraktAuthUiState
|
||||
|
|
@ -73,6 +74,14 @@ import nuvio.composeapp.generated.resources.trakt_continue_watching_window
|
|||
import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_cw_window_title
|
||||
import nuvio.composeapp.generated.resources.trakt_days_format
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_title
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_trakt
|
||||
import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title
|
||||
import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected
|
||||
|
|
@ -136,15 +145,27 @@ private fun TraktFeatureRows(
|
|||
commentsEnabled: Boolean,
|
||||
onCommentsEnabledChange: (Boolean) -> Unit,
|
||||
) {
|
||||
var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var statusMessage by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode)
|
||||
val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
|
||||
val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap)
|
||||
val traktSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
|
||||
val nuvioSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
|
||||
val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected)
|
||||
val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected)
|
||||
val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected)
|
||||
val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected)
|
||||
|
||||
TraktSettingsActionRow(
|
||||
title = stringResource(Res.string.trakt_library_source_title),
|
||||
description = stringResource(Res.string.trakt_library_source_subtitle),
|
||||
value = librarySourceValue,
|
||||
isTablet = isTablet,
|
||||
onClick = { showLibrarySourceDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
TraktSettingsActionRow(
|
||||
title = stringResource(Res.string.trakt_watch_progress_title),
|
||||
description = stringResource(Res.string.trakt_watch_progress_subtitle),
|
||||
|
|
@ -176,15 +197,31 @@ private fun TraktFeatureRows(
|
|||
)
|
||||
}
|
||||
|
||||
if (showLibrarySourceDialog) {
|
||||
LibrarySourceModeDialog(
|
||||
selectedSource = settingsUiState.librarySourceMode,
|
||||
onSourceSelected = { source ->
|
||||
TraktSettingsRepository.setLibrarySourceMode(source)
|
||||
statusMessage = if (source == LibrarySourceMode.TRAKT) {
|
||||
traktLibrarySelectedMessage
|
||||
} else {
|
||||
nuvioLibrarySelectedMessage
|
||||
}
|
||||
showLibrarySourceDialog = false
|
||||
},
|
||||
onDismiss = { showLibrarySourceDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showWatchProgressDialog) {
|
||||
WatchProgressSourceDialog(
|
||||
selectedSource = settingsUiState.watchProgressSource,
|
||||
onSourceSelected = { source ->
|
||||
TraktSettingsRepository.setWatchProgressSource(source)
|
||||
statusMessage = if (source == WatchProgressSource.TRAKT) {
|
||||
traktSelectedMessage
|
||||
traktProgressSelectedMessage
|
||||
} else {
|
||||
nuvioSelectedMessage
|
||||
nuvioProgressSelectedMessage
|
||||
}
|
||||
showWatchProgressDialog = false
|
||||
},
|
||||
|
|
@ -271,6 +308,13 @@ private fun TraktInfoRow(
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun librarySourceModeLabel(source: LibrarySourceMode): String =
|
||||
when (source) {
|
||||
LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt)
|
||||
LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun watchProgressSourceLabel(source: WatchProgressSource): String =
|
||||
when (source) {
|
||||
|
|
@ -288,6 +332,59 @@ private fun continueWatchingDaysCapLabel(daysCap: Int): String {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun LibrarySourceModeDialog(
|
||||
selectedSource: LibrarySourceMode,
|
||||
onSourceSelected: (LibrarySourceMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
BasicAlertDialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_library_source_dialog_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.trakt_library_source_dialog_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source ->
|
||||
TraktDialogOption(
|
||||
label = librarySourceModeLabel(source),
|
||||
selected = source == selectedSource,
|
||||
onClick = { onSourceSelected(source) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_playback_dialog_close),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun WatchProgressSourceDialog(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import com.nuvio.app.features.library.LibrarySourceMode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -35,16 +36,22 @@ enum class WatchProgressSource {
|
|||
}
|
||||
|
||||
val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT
|
||||
val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT
|
||||
|
||||
fun librarySourceModeFromStorage(value: String?): LibrarySourceMode =
|
||||
LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE
|
||||
|
||||
data class TraktSettingsUiState(
|
||||
val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE,
|
||||
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
|
||||
val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class StoredTraktSettings(
|
||||
val watchProgressSource: String? = null,
|
||||
val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP,
|
||||
val librarySourceMode: String? = null,
|
||||
)
|
||||
|
||||
object TraktSettingsRepository {
|
||||
|
|
@ -87,6 +94,13 @@ object TraktSettingsRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setLibrarySourceMode(mode: LibrarySourceMode) {
|
||||
ensureLoaded()
|
||||
if (_uiState.value.librarySourceMode == mode) return
|
||||
_uiState.value = _uiState.value.copy(librarySourceMode = mode)
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
hasLoaded = true
|
||||
|
||||
|
|
@ -104,6 +118,7 @@ object TraktSettingsRepository {
|
|||
TraktSettingsUiState(
|
||||
watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource),
|
||||
continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap),
|
||||
librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode),
|
||||
)
|
||||
} else {
|
||||
TraktSettingsUiState()
|
||||
|
|
@ -116,6 +131,7 @@ object TraktSettingsRepository {
|
|||
StoredTraktSettings(
|
||||
watchProgressSource = _uiState.value.watchProgressSource.name,
|
||||
continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap,
|
||||
librarySourceMode = _uiState.value.librarySourceMode.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -133,3 +149,18 @@ fun shouldUseTraktProgress(
|
|||
isAuthenticated: Boolean,
|
||||
source: WatchProgressSource,
|
||||
): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT
|
||||
|
||||
fun effectiveLibrarySourceMode(
|
||||
isAuthenticated: Boolean,
|
||||
source: LibrarySourceMode,
|
||||
): LibrarySourceMode =
|
||||
if (isAuthenticated && source == LibrarySourceMode.TRAKT) {
|
||||
LibrarySourceMode.TRAKT
|
||||
} else {
|
||||
LibrarySourceMode.LOCAL
|
||||
}
|
||||
|
||||
fun shouldUseTraktLibrary(
|
||||
isAuthenticated: Boolean,
|
||||
source: LibrarySourceMode,
|
||||
): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktListType
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
|
|
@ -37,4 +39,34 @@ class LibraryRepositoryTest {
|
|||
assertEquals(PosterShape.Poster, preview.posterShape)
|
||||
assertEquals("banner", preview.banner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library tabs include local Nuvio library before Trakt tabs`() {
|
||||
val traktTab = TraktListTab(
|
||||
key = "trakt:watchlist",
|
||||
title = "Watchlist",
|
||||
type = TraktListType.WATCHLIST,
|
||||
)
|
||||
|
||||
val tabs = libraryTabsWithLocal(listOf(traktTab))
|
||||
|
||||
assertEquals(listOf("local", "trakt:watchlist"), tabs.map { it.key })
|
||||
assertEquals("Nuvio Library", tabs.first().title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library membership always includes local state before Trakt membership`() {
|
||||
val membership = libraryMembershipWithLocal(
|
||||
inLocal = true,
|
||||
traktMembership = mapOf("trakt:watchlist" to false),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"local" to true,
|
||||
"trakt:watchlist" to false,
|
||||
),
|
||||
membership,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import com.nuvio.app.features.library.LibrarySourceMode
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
|
|
@ -20,6 +21,19 @@ class TraktSettingsRepositoryTest {
|
|||
assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library source defaults to Trakt for unset or invalid storage`() {
|
||||
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(null))
|
||||
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(""))
|
||||
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("not-a-source"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library source restores valid storage values`() {
|
||||
assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT"))
|
||||
assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `continue watching cap normalizes finite windows and all history`() {
|
||||
assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0))
|
||||
|
|
@ -34,4 +48,20 @@ class TraktSettingsRepositoryTest {
|
|||
assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC))
|
||||
assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `effective library source uses Trakt only when authenticated and selected`() {
|
||||
assertEquals(
|
||||
LibrarySourceMode.LOCAL,
|
||||
effectiveLibrarySourceMode(isAuthenticated = false, source = LibrarySourceMode.TRAKT),
|
||||
)
|
||||
assertEquals(
|
||||
LibrarySourceMode.LOCAL,
|
||||
effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.LOCAL),
|
||||
)
|
||||
assertEquals(
|
||||
LibrarySourceMode.TRAKT,
|
||||
effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"mdblist_use_audience",
|
||||
"trakt_auth_payload",
|
||||
"trakt_library_payload",
|
||||
"trakt_settings_payload",
|
||||
)
|
||||
|
||||
actual fun wipe() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue