From 860e5e4e2c54f987582332a6100f344d0650f33b Mon Sep 17 00:00:00 2001 From: skoruppa Date: Wed, 10 Jun 2026 14:46:48 +0200 Subject: [PATCH 1/3] Add option to enable addons logo in stream screen --- .../StreamBadgeSettingsStorage.android.kt | 7 +++++ .../composeResources/values-pl/strings.xml | 3 ++ .../composeResources/values/strings.xml | 3 ++ .../app/features/settings/SettingsSearch.kt | 9 ++++++ .../features/settings/StreamsSettingsPage.kt | 18 +++++++++++ .../streams/StreamBadgeSettingsRepository.kt | 13 ++++++++ .../streams/StreamBadgeSettingsStorage.kt | 2 ++ .../app/features/streams/StreamModels.kt | 1 + .../app/features/streams/StreamParser.kt | 2 ++ .../app/features/streams/StreamsRepository.kt | 1 + .../app/features/streams/StreamsScreen.kt | 31 ++++++++++++++++++- .../streams/StreamBadgeSettingsStorage.ios.kt | 7 +++++ 12 files changed, 96 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt index cfab3bd0f..64a951188 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt @@ -16,6 +16,7 @@ actual object StreamBadgeSettingsStorage { private const val legacyDebridPreferencesName = "nuvio_debrid_settings" private const val streamBadgeRulesKey = "stream_badge_rules" private const val showFileSizeBadgesKey = "show_file_size_badges" + private const val showAddonLogoKey = "show_addon_logo" private const val streamBadgePlacementKey = "stream_badge_placement" private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" @@ -41,6 +42,12 @@ actual object StreamBadgeSettingsStorage { saveBoolean(showFileSizeBadgesKey, enabled) } + actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey) + + actual fun saveShowAddonLogo(enabled: Boolean) { + saveBoolean(showAddonLogoKey, enabled) + } + actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) actual fun saveStreamBadgePlacement(placement: String) { diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 3e487c29c..f179fcf56 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1456,4 +1456,7 @@ Torrenty Usenet Web + Logo dodatku + Pokazuj logo i nazwę dodatku obok źródeł. + Wyświetlanie diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 582b500a1..be681d8d9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -681,6 +681,9 @@ Fusion Style Size badges Show file size badges in stream results and player source panels. + Addon logo + Show addon logo and name next to stream sources. + Display Badge position Choose whether Fusion and size badges appear above or below stream cards. Badge position diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index d5fb310db..5d0f8cf39 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -463,6 +463,15 @@ internal fun settingsSearchEntries( val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering) val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments) val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode) + addRow( + page = SettingsPage.Streams, + key = "stream-addon-logo", + title = stringResource(Res.string.settings_stream_addon_logo_title), + description = stringResource(Res.string.settings_stream_addon_logo_description), + pageLabel = streamsPage, + section = stringResource(Res.string.settings_stream_display_section), + icon = Icons.Rounded.Style, + ) addRow( page = SettingsPage.Streams, key = "stream-size-badges", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt index 7075eb1a1..7943b55a4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt @@ -87,6 +87,9 @@ import nuvio.composeapp.generated.resources.settings_stream_badge_urls_title import nuvio.composeapp.generated.resources.settings_stream_badges_section import nuvio.composeapp.generated.resources.settings_stream_size_badges_description import nuvio.composeapp.generated.resources.settings_stream_size_badges_title +import nuvio.composeapp.generated.resources.settings_stream_addon_logo_title +import nuvio.composeapp.generated.resources.settings_stream_addon_logo_description +import nuvio.composeapp.generated.resources.settings_stream_display_section import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { @@ -129,6 +132,21 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { } } + SettingsSection( + title = stringResource(Res.string.settings_stream_display_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_stream_addon_logo_title), + description = stringResource(Res.string.settings_stream_addon_logo_description), + checked = currentSettings.showAddonLogo, + isTablet = isTablet, + onCheckedChange = StreamBadgeSettingsRepository::setShowAddonLogo, + ) + } + } + if (showBadgeImportDialog) { BadgeUrlManagerDialog( currentRules = currentRules, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt index c48690d4c..397d26adf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.json.Json data class StreamBadgeSettingsUiState( val rules: StreamBadgeRules = StreamBadgeRules(), val showFileSizeBadges: Boolean = true, + val showAddonLogo: Boolean = false, val badgePlacement: StreamBadgePlacement = StreamBadgePlacement.BOTTOM, ) @@ -36,6 +37,7 @@ object StreamBadgeSettingsRepository { private var hasLoaded = false private var streamBadgeRules = StreamBadgeRules() private var showFileSizeBadges = true + private var showAddonLogo = false private var badgePlacement = StreamBadgePlacement.BOTTOM fun ensureLoaded() { @@ -51,6 +53,7 @@ object StreamBadgeSettingsRepository { hasLoaded = false streamBadgeRules = StreamBadgeRules() showFileSizeBadges = true + showAddonLogo = false badgePlacement = StreamBadgePlacement.BOTTOM _uiState.value = StreamBadgeSettingsUiState() } @@ -133,6 +136,14 @@ object StreamBadgeSettingsRepository { StreamBadgeSettingsStorage.saveShowFileSizeBadges(enabled) } + fun setShowAddonLogo(enabled: Boolean) { + ensureLoaded() + if (showAddonLogo == enabled) return + showAddonLogo = enabled + publish() + StreamBadgeSettingsStorage.saveShowAddonLogo(enabled) + } + fun setBadgePlacement(placement: StreamBadgePlacement) { ensureLoaded() if (badgePlacement == placement) return @@ -151,6 +162,7 @@ object StreamBadgeSettingsRepository { } streamBadgeRules = storedRules ?: legacyRules ?: StreamBadgeRules() showFileSizeBadges = StreamBadgeSettingsStorage.loadShowFileSizeBadges() ?: true + showAddonLogo = StreamBadgeSettingsStorage.loadShowAddonLogo() ?: false badgePlacement = StreamBadgeSettingsStorage.loadStreamBadgePlacement() ?.let { storedPlacement -> StreamBadgePlacement.entries.firstOrNull { placement -> @@ -169,6 +181,7 @@ object StreamBadgeSettingsRepository { _uiState.value = StreamBadgeSettingsUiState( rules = streamBadgeRules, showFileSizeBadges = showFileSizeBadges, + showAddonLogo = showAddonLogo, badgePlacement = badgePlacement, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt index 5df554535..abd6dffee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt @@ -7,6 +7,8 @@ internal expect object StreamBadgeSettingsStorage { fun saveStreamBadgeRules(rules: String) fun loadShowFileSizeBadges(): Boolean? fun saveShowFileSizeBadges(enabled: Boolean) + fun loadShowAddonLogo(): Boolean? + fun saveShowAddonLogo(enabled: Boolean) fun loadStreamBadgePlacement(): String? fun saveStreamBadgePlacement(placement: String) fun loadLegacyDebridStreamBadgeRules(): String? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 7a8406c33..58a70141b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -17,6 +17,7 @@ data class StreamItem( val sourceName: String? = null, val addonName: String, val addonId: String, + val addonLogo: String? = null, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, val debridCacheStatus: StreamDebridCacheStatus? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 9a6aa866d..a27033eda 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -18,6 +18,7 @@ object StreamParser { payload: String, addonName: String, addonId: String, + addonLogo: String? = null, ): List { val root = json.parseToJsonElement(payload).jsonObject val streamsArray = root["streams"] as? JsonArray ?: return emptyList() @@ -46,6 +47,7 @@ object StreamParser { sources = obj.stringList("sources"), addonName = addonName, addonId = addonId, + addonLogo = addonLogo, clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index bc7bc787d..49fc361a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -456,6 +456,7 @@ object StreamsRepository { payload = payload, addonName = displayName, addonId = addon.addonId, + addonLogo = addon.manifest.logoUrl, ) }.fold( onSuccess = { streams -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 0f6281e22..c31ff887c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -833,6 +833,7 @@ internal fun StreamList( debridEnabled = debridEnabled, appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, badgePlacement = streamBadgeSettings.badgePlacement, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, @@ -860,6 +861,7 @@ private fun LazyListScope.streamSection( debridEnabled: Boolean, appendInstantServiceToDefaultName: Boolean, showFileSizeBadges: Boolean, + showAddonLogo: Boolean, badgePlacement: StreamBadgePlacement, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, @@ -907,6 +909,7 @@ private fun LazyListScope.streamSection( enabled = stream.isSelectableForPlayback(debridEnabled), appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = showFileSizeBadges, + showAddonLogo = showAddonLogo, badgePlacement = badgePlacement, onClick = { if (stream.isSelectableForPlayback(debridEnabled)) { @@ -1014,6 +1017,7 @@ private fun StreamCard( enabled: Boolean, appendInstantServiceToDefaultName: Boolean, showFileSizeBadges: Boolean, + showAddonLogo: Boolean, badgePlacement: StreamBadgePlacement, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, @@ -1040,7 +1044,7 @@ private fun StreamCard( onLongClick = onLongClick, ) .padding(14.dp), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { if (hasBadges && badgePlacement == StreamBadgePlacement.TOP) { @@ -1079,6 +1083,31 @@ private fun StreamCard( ) } } + + if (showAddonLogo) { + Spacer(modifier = Modifier.width(12.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!stream.addonLogo.isNullOrBlank()) { + AsyncImage( + model = stream.addonLogo, + contentDescription = stream.addonName, + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Fit, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stream.addonName, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt index 716304d85..8dbfc035c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt @@ -13,6 +13,7 @@ import platform.Foundation.NSUserDefaults actual object StreamBadgeSettingsStorage { private const val streamBadgeRulesKey = "stream_badge_rules" private const val showFileSizeBadgesKey = "show_file_size_badges" + private const val showAddonLogoKey = "show_addon_logo" private const val streamBadgePlacementKey = "stream_badge_placement" private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey) @@ -29,6 +30,12 @@ actual object StreamBadgeSettingsStorage { saveBoolean(showFileSizeBadgesKey, enabled) } + actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey) + + actual fun saveShowAddonLogo(enabled: Boolean) { + saveBoolean(showAddonLogoKey, enabled) + } + actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) actual fun saveStreamBadgePlacement(placement: String) { From 83e475d23d660ef84f14360b4b09a2056f079978 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:05:39 +0530 Subject: [PATCH 2/3] refactor(MetaDetailsScreen): replace vertical scroll with lazy column --- .../app/features/details/MetaDetailsScreen.kt | 491 +++++++++++++----- 1 file changed, 371 insertions(+), 120 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 1e48bb62d..86e2cfa10 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -25,8 +25,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -111,6 +112,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -189,20 +191,30 @@ fun MetaDetailsScreen( var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } + var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } val shouldShowComments = commentsEnabled && traktAuthUiState.mode == TraktConnectionMode.CONNECTED && displayedMeta != null && displayedMeta.type.lowercase().let { it == "movie" || it == "series" || it == "show" || it == "tv" } - LaunchedEffect(displayedMeta?.id, shouldShowComments) { - if (!shouldShowComments || displayedMeta == null) { + LaunchedEffect(displayedMeta?.id) { + deferredMetaWorkAllowed = false + if (displayedMeta != null) { + delay(250) + deferredMetaWorkAllowed = true + } + } + + LaunchedEffect(displayedMeta?.id, shouldShowComments, deferredMetaWorkAllowed) { + if (displayedMeta == null || !shouldShowComments) { comments = emptyList() commentsCurrentPage = 0 commentsPageCount = 0 commentsError = null return@LaunchedEffect } + if (!deferredMetaWorkAllowed) return@LaunchedEffect isCommentsLoading = true commentsError = null try { @@ -216,8 +228,9 @@ fun MetaDetailsScreen( isCommentsLoading = false } - LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) { + LaunchedEffect(displayedMeta?.id, displayedMeta?.videos, deferredMetaWorkAllowed) { val metaForRatings = displayedMeta + if (!deferredMetaWorkAllowed) return@LaunchedEffect if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) { episodeImdbRatings = emptyMap() return@LaunchedEffect @@ -462,7 +475,8 @@ fun MetaDetailsScreen( ) } } - LaunchedEffect(meta.type, debridWarmupTarget) { + LaunchedEffect(meta.type, debridWarmupTarget, deferredMetaWorkAllowed) { + if (!deferredMetaWorkAllowed) return@LaunchedEffect AddonStreamWarmupRepository.preload( type = meta.type, videoId = debridWarmupTarget.videoId, @@ -509,11 +523,16 @@ fun MetaDetailsScreen( var heroTrailerReady by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } var heroTrailerFinished by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } val heroTrailerMuted by HeroTrailerAudioState.muted.collectAsStateWithLifecycle() - LaunchedEffect(heroTrailerPlaybackEnabled, heroTrailerCandidate?.id, heroTrailerCandidate?.key) { + LaunchedEffect( + heroTrailerPlaybackEnabled, + heroTrailerCandidate?.id, + heroTrailerCandidate?.key, + deferredMetaWorkAllowed, + ) { heroTrailerPlaybackSource = null heroTrailerReady = false heroTrailerFinished = false - if (!heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { + if (!deferredMetaWorkAllowed || !heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { return@LaunchedEffect } val resolvedSource = runCatching { @@ -718,7 +737,7 @@ fun MetaDetailsScreen( savedProgress?.lastPositionMs, ) } - val scrollState = rememberScrollState() + val listState = rememberLazyListState() val density = LocalDensity.current val safeAreaTopPx = with(density) { WindowInsets.statusBars @@ -728,7 +747,20 @@ fun MetaDetailsScreen( } var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) } val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f) - val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f + val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat() + } else { + heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset + } + val heroScrollOffset = detailScrollOffsetPx.toInt() + val headerTarget = if ( + heroHeightPx > 0 && + (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx) + ) { + 1f + } else { + 0f + } val heroTrailerSourceUrl = heroTrailerPlaybackSource ?.videoUrl ?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails } @@ -737,7 +769,7 @@ fun MetaDetailsScreen( ?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() } val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null && !isLeavingDetails && - (heroHeightPx == 0 || scrollState.value <= thresholdPx) + (heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx) val headerProgress by animateFloatAsState( targetValue = headerTarget, animationSpec = tween( @@ -751,7 +783,7 @@ fun MetaDetailsScreen( val isTablet = maxWidth >= 720.dp val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet) - val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground + val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed Box(modifier = Modifier.fillMaxSize()) { if (cinematicEnabled) { @@ -772,123 +804,120 @@ fun MetaDetailsScreen( ) } } - Column( + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() - .zIndex(1f) - .verticalScroll(scrollState), + .zIndex(1f), ) { - DetailHero( - meta = meta, - isTablet = isTablet, - contentMaxWidth = contentMaxWidth, - scrollOffset = scrollState.value, - onHeightChanged = { heroHeightPx = it }, - heroTrailerSourceUrl = heroTrailerSourceUrl, - heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, - heroTrailerReady = heroTrailerReady, - heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, - heroTrailerMuted = heroTrailerMuted, - onHeroTrailerMuteToggle = { - HeroTrailerAudioState.toggleMuted() - }, - onHeroTrailerReady = { - if (!heroTrailerFinished) { - heroTrailerReady = true - } - }, - onHeroTrailerEnded = { - heroTrailerReady = false - heroTrailerFinished = true - }, - onHeroTrailerError = { - heroTrailerReady = false - heroTrailerFinished = true - }, - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = contentHorizontalPadding) - .widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified), - verticalArrangement = Arrangement.spacedBy(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ConfiguredMetaSections( - settings = metaScreenSettingsUiState, + item(key = "detail-hero") { + DetailHero( meta = meta, isTablet = isTablet, - playButtonLabel = playButtonLabel, - isSaved = isSaved, - isWatched = isWatched, - onPrimaryPlayClick = onPrimaryPlayClick, - onPrimaryPlayLongClick = onPrimaryPlayLongClick, - onSaveClick = toggleSaved, - onSaveLongClick = openLibraryListPicker, - onWatchedClick = toggleWatched, - showManualPlayOption = showManualPlayOption, - preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, - preferredEpisodeNumber = seriesAction?.episodeNumber, - hasProductionSection = hasProductionSection, - hasTrailersSection = hasTrailersSection, - hasEpisodes = hasEpisodes, - hasAdditionalInfoSection = hasAdditionalInfoSection, - hasCollectionSection = hasCollectionSection, - hasMoreLikeThisSection = hasMoreLikeThisSection, - shouldShowComments = shouldShowComments, - comments = comments, - isCommentsLoading = isCommentsLoading, - isCommentsLoadingMore = isCommentsLoadingMore, - commentsCurrentPage = commentsCurrentPage, - commentsPageCount = commentsPageCount, - commentsError = commentsError, - episodeImdbRatings = episodeImdbRatings, - onRetryComments = { - detailsScope.launch { - isCommentsLoading = true - commentsError = null - try { - val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) - comments = result.items - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (e: Exception) { - commentsError = e.message ?: getString(Res.string.details_comments_load_failed) - } - isCommentsLoading = false + contentMaxWidth = contentMaxWidth, + scrollOffset = heroScrollOffset, + onHeightChanged = { heroHeightPx = it }, + heroTrailerSourceUrl = heroTrailerSourceUrl, + heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, + heroTrailerReady = heroTrailerReady, + heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, + heroTrailerMuted = heroTrailerMuted, + onHeroTrailerMuteToggle = { + HeroTrailerAudioState.toggleMuted() + }, + onHeroTrailerReady = { + if (!heroTrailerFinished) { + heroTrailerReady = true } }, - onLoadMoreComments = { - detailsScope.launch { - isCommentsLoadingMore = true - try { - val nextPage = commentsCurrentPage + 1 - val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) - val existingIds = comments.map { it.id }.toSet() - val newComments = result.items.filter { it.id !in existingIds } - comments = comments + newComments - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (_: Exception) { } - isCommentsLoadingMore = false - } + onHeroTrailerEnded = { + heroTrailerReady = false + heroTrailerFinished = true + }, + onHeroTrailerError = { + heroTrailerReady = false + heroTrailerFinished = true }, - onCommentClick = { review -> selectedComment = review }, - onTrailerClick = resolveTrailer, - progressByVideoId = progressByVideoId, - watchedKeys = watchedUiState.watchedKeys, - blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, - onEpisodeClick = onEpisodePlayClick, - onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, - onSeasonLongPress = { season -> selectedSeasonForActions = season }, - onOpenMeta = onOpenMeta, - onCastClick = onCastClick, - onCompanyClick = onCompanyClick, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, ) + } + configuredMetaSectionItems( + settings = metaScreenSettingsUiState, + meta = meta, + isTablet = isTablet, + contentHorizontalPadding = contentHorizontalPadding, + contentMaxWidth = if (isTablet) contentMaxWidth else Dp.Unspecified, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = toggleSaved, + onSaveLongClick = openLibraryListPicker, + onWatchedClick = toggleWatched, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, + preferredEpisodeNumber = seriesAction?.episodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = { + detailsScope.launch { + isCommentsLoading = true + commentsError = null + try { + val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) + comments = result.items + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (e: Exception) { + commentsError = e.message ?: getString(Res.string.details_comments_load_failed) + } + isCommentsLoading = false + } + }, + onLoadMoreComments = { + detailsScope.launch { + isCommentsLoadingMore = true + try { + val nextPage = commentsCurrentPage + 1 + val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) + val existingIds = comments.map { it.id }.toSet() + val newComments = result.items.filter { it.id !in existingIds } + comments = comments + newComments + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (_: Exception) { } + isCommentsLoadingMore = false + } + }, + onCommentClick = { review -> selectedComment = review }, + onTrailerClick = resolveTrailer, + progressByVideoId = progressByVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, + onEpisodeClick = onEpisodePlayClick, + onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onSeasonLongPress = { season -> selectedSeasonForActions = season }, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + + item(key = "detail-bottom-spacer") { Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp))) } } @@ -901,7 +930,7 @@ fun MetaDetailsScreen( .fillMaxWidth() .height(132.dp) .graphicsLayer { - translationY = heroHeightPx.toFloat() - scrollState.value + translationY = heroHeightPx.toFloat() - detailScrollOffsetPx } .background( Brush.verticalGradient( @@ -1263,6 +1292,228 @@ private fun MetaDetails.toMetaPreview(): MetaPreview = genres = genres, ) +private fun LazyListScope.configuredMetaSectionItems( + settings: MetaScreenSettingsUiState, + meta: MetaDetails, + isTablet: Boolean, + contentHorizontalPadding: Dp, + contentMaxWidth: Dp, + playButtonLabel: String, + isSaved: Boolean, + isWatched: Boolean, + onPrimaryPlayClick: () -> Unit, + onPrimaryPlayLongClick: (() -> Unit)?, + onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, + onWatchedClick: () -> Unit, + showManualPlayOption: Boolean, + preferredEpisodeSeasonNumber: Int?, + preferredEpisodeNumber: Int?, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + isCommentsLoadingMore: Boolean, + commentsCurrentPage: Int, + commentsPageCount: Int, + commentsError: String?, + episodeImdbRatings: Map, Double>, + onRetryComments: () -> Unit, + onLoadMoreComments: () -> Unit, + onCommentClick: (TraktCommentReview) -> Unit, + onTrailerClick: (MetaTrailer) -> Unit, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, + onEpisodeClick: (MetaVideo) -> Unit, + onEpisodeLongPress: (MetaVideo) -> Unit, + onSeasonLongPress: (Int) -> Unit, + onOpenMeta: ((MetaPreview) -> Unit)?, + onCastClick: ((MetaPerson, String?) -> Unit)?, + onCompanyClick: ((MetaCompany, String) -> Unit)?, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, +) { + val enabledItems = settings.items.filter { it.enabled } + fun sectionHasContent(key: MetaScreenSectionKey): Boolean = + metaSectionHasContent( + key = key, + meta = meta, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + commentsError = commentsError, + ) + + fun addSectionItem( + key: String, + sectionItems: List, + forceTabLayout: Boolean = settings.tabLayout, + ) { + item(key = key) { + DetailSectionContainer( + horizontalPadding = contentHorizontalPadding, + contentMaxWidth = contentMaxWidth, + ) { + ConfiguredMetaSections( + settings = settings.copy( + items = sectionItems, + tabLayout = forceTabLayout, + ), + meta = meta, + isTablet = isTablet, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = onSaveClick, + onSaveLongClick = onSaveLongClick, + onWatchedClick = onWatchedClick, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber, + preferredEpisodeNumber = preferredEpisodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = onRetryComments, + onLoadMoreComments = onLoadMoreComments, + onCommentClick = onCommentClick, + onTrailerClick = onTrailerClick, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + onEpisodeClick = onEpisodeClick, + onEpisodeLongPress = onEpisodeLongPress, + onSeasonLongPress = onSeasonLongPress, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + } + + if (!settings.tabLayout) { + enabledItems + .filter { sectionHasContent(it.key) } + .forEach { section -> + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = false, + ) + } + return + } + + val processedGroups = mutableSetOf() + enabledItems.forEach { section -> + val groupId = section.tabGroup + if (groupId == null) { + if (sectionHasContent(section.key)) { + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = true, + ) + } + } else if (groupId !in processedGroups) { + processedGroups.add(groupId) + val groupMembers = enabledItems.filter { item -> + item.tabGroup == groupId && sectionHasContent(item.key) + } + if (groupMembers.isNotEmpty()) { + addSectionItem( + key = "detail-section-group-$groupId", + sectionItems = groupMembers, + forceTabLayout = groupMembers.size > 1, + ) + } + } + } +} + +@Composable +private fun DetailSectionContainer( + horizontalPadding: Dp, + contentMaxWidth: Dp, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .padding(bottom = 20.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (contentMaxWidth == Dp.Unspecified) { + Modifier + } else { + Modifier.widthIn(max = contentMaxWidth) + }, + ), + ) { + content() + } + } +} + +private fun metaSectionHasContent( + key: MetaScreenSectionKey, + meta: MetaDetails, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + commentsError: String?, +): Boolean = + when (key) { + MetaScreenSectionKey.ACTIONS -> true + MetaScreenSectionKey.OVERVIEW -> true + MetaScreenSectionKey.PRODUCTION -> hasProductionSection + MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty() + MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank()) + MetaScreenSectionKey.TRAILERS -> hasTrailersSection + MetaScreenSectionKey.EPISODES -> hasEpisodes + MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection + MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection + MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection + } + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections( From 2b2d1f56ad634def46c578acac0af242bcf43a90 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 11 Jun 2026 00:28:00 +0530 Subject: [PATCH 3/3] Fix Android process-death recovery Persist pending debrid device-code authorization so returning from the browser after Android process death can continue polling the same session. Recover cached profiles during auth rehydration instead of briefly dumping users to login after background kills. Include debrid and downloads preferences in local account cleanup on sign-out. --- ...PlatformLocalAccountDataCleaner.android.kt | 2 + .../debrid/DebridSettingsStorage.android.kt | 21 ++++++++ .../commonMain/kotlin/com/nuvio/app/App.kt | 15 ++++-- .../app/features/debrid/DebridProviderApis.kt | 2 + .../features/debrid/DebridSettingsStorage.kt | 3 ++ .../features/settings/DebridSettingsPage.kt | 53 ++++++++++++++++++- .../debrid/DebridSettingsStorage.ios.kt | 20 +++++++ 7 files changed, 110 insertions(+), 6 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 27b3d1f52..d289ea5a4 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -14,7 +14,9 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_profile_pin_cache", "nuvio_theme_settings", "nuvio_poster_card_style", + "nuvio_debrid_settings", "nuvio_mdblist_settings", + "nuvio_downloads", "nuvio_trakt_auth", "nuvio_trakt_library", "nuvio_trakt_settings", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index de8e76b1e..08751085a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -30,6 +30,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -150,6 +151,20 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId))) + ?.apply() + } + private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) @@ -249,4 +264,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 32b14c525..cd96fbd9c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -466,21 +466,26 @@ fun App() { LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { val cachedProfiles = profileState.profiles - val allowOfflineProfileAccess = + val hasCachedProfileAccess = cachedProfiles.isNotEmpty() && - authState !is AuthState.Authenticated && - networkStatusUiState.condition != NetworkCondition.Online + authState !is AuthState.Authenticated + val allowCachedProfileAccess = + hasCachedProfileAccess && + ( + networkStatusUiState.condition != NetworkCondition.Online || + gateScreen != AppGateScreen.Auth.name + ) when (authState) { is AuthState.Loading -> { - if (allowOfflineProfileAccess) { + if (hasCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { gateScreen = AppGateScreen.Loading.name } } is AuthState.Unauthenticated -> { - if (allowOfflineProfileAccess) { + if (allowCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { ProfileRepository.clearInMemory() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt index 295179d8e..1b0db4b14 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamItem import kotlinx.coroutines.CancellationException +import kotlinx.serialization.Serializable internal interface DebridProviderApi { val provider: DebridProvider @@ -35,6 +36,7 @@ internal object DebridProviderApis { } } +@Serializable internal data class DebridDeviceAuthorization( val providerId: String, val deviceCode: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 7d68db489..700914e34 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -35,6 +35,9 @@ internal expect object DebridSettingsStorage { fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? fun saveStreamDescriptionTemplate(template: String) + fun loadPendingDeviceAuthorization(providerId: String): String? + fun savePendingDeviceAuthorization(providerId: String, payload: String) + fun clearPendingDeviceAuthorization(providerId: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 646a1597e..4b812d927 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -59,6 +59,7 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults import com.nuvio.app.features.debrid.DebridStreamAudioChannel import com.nuvio.app.features.debrid.DebridStreamAudioTag @@ -74,6 +75,9 @@ import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.delay +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_clear @@ -1467,12 +1471,21 @@ private fun DebridDeviceAuthDialog( isStarting = true isPolling = false statusMessage = null + if (restartNonce == 0) { + loadPendingDeviceAuthorization(provider.id)?.let { pendingSession -> + session = pendingSession + isStarting = false + statusMessage = waitingMessage + return@LaunchedEffect + } + } val startResult = runCatching { DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") }.onFailure { error -> if (error is CancellationException) throw error } session = startResult.getOrNull() + session?.let(::savePendingDeviceAuthorization) isStarting = false statusMessage = if (session == null) { startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") } @@ -1504,6 +1517,7 @@ private fun DebridDeviceAuthDialog( isPolling = false when (result) { is DebridDeviceAuthorizationTokenResult.Authorized -> { + clearPendingDeviceAuthorization(provider.id) onConnected(result.accessToken) onDismiss() return@LaunchedEffect @@ -1514,16 +1528,19 @@ private fun DebridDeviceAuthDialog( } DebridDeviceAuthorizationTokenResult.Expired -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = expiredMessage return@LaunchedEffect } is DebridDeviceAuthorizationTokenResult.Failed -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage) return@LaunchedEffect } DebridDeviceAuthorizationTokenResult.Unsupported -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = failedMessage return@LaunchedEffect } @@ -1621,6 +1638,7 @@ private fun DebridDeviceAuthDialog( if (isConnected) { Button( onClick = { + clearPendingDeviceAuthorization(provider.id) onDisconnect() onDismiss() }, @@ -1629,7 +1647,12 @@ private fun DebridDeviceAuthDialog( } } if (!isConnected && !isStarting && session == null) { - TextButton(onClick = { restartNonce += 1 }) { + TextButton( + onClick = { + clearPendingDeviceAuthorization(provider.id) + restartNonce += 1 + }, + ) { Text(stringResource(Res.string.action_retry)) } } @@ -1649,6 +1672,34 @@ private fun DebridDeviceAuthDialog( } } +private val debridDeviceAuthorizationJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private fun loadPendingDeviceAuthorization(providerId: String): DebridDeviceAuthorization? = + DebridSettingsStorage.loadPendingDeviceAuthorization(providerId) + .orEmpty() + .trim() + .takeIf { it.isNotBlank() } + ?.let { payload -> + runCatching { + debridDeviceAuthorizationJson.decodeFromString(payload) + }.getOrNull() + } + ?.takeIf { it.providerId == providerId } + +private fun savePendingDeviceAuthorization(session: DebridDeviceAuthorization) { + DebridSettingsStorage.savePendingDeviceAuthorization( + providerId = session.providerId, + payload = debridDeviceAuthorizationJson.encodeToString(session), + ) +} + +private fun clearPendingDeviceAuthorization(providerId: String) { + DebridSettingsStorage.clearPendingDeviceAuthorization(providerId) +} + private fun Throwable.isCancelledHttpRequest(): Boolean { val text = listOfNotNull(message, toString()) .joinToString(" ") diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index d11d9c644..bc68c7e45 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -28,6 +28,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -142,6 +143,19 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey( + ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId)), + ) + } + private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key) @@ -232,4 +246,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } }