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/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 48dfbdd04..f849d9339 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -686,6 +686,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/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index babed0cee..e6fc06aef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -542,21 +542,26 @@ fun App() { if (gateScreen == AppGateScreen.ProfileSwitching.name) return@LaunchedEffect 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/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index dffe47a80..aef463c9a 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( @@ -752,7 +784,7 @@ fun MetaDetailsScreen( val viewportHeight = maxHeight 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) { @@ -773,124 +805,121 @@ fun MetaDetailsScreen( ) } } - Column( + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() - .zIndex(1f) - .verticalScroll(scrollState), + .zIndex(1f), ) { - DetailHero( - meta = meta, - isTablet = isTablet, - contentMaxWidth = contentMaxWidth, - viewportHeight = viewportHeight, - 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, + viewportHeight = viewportHeight, + 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))) } } @@ -903,7 +932,7 @@ fun MetaDetailsScreen( .fillMaxWidth() .height(132.dp) .graphicsLayer { - translationY = heroHeightPx.toFloat() - scrollState.value + translationY = heroHeightPx.toFloat() - detailScrollOffsetPx } .background( Brush.verticalGradient( @@ -1265,6 +1294,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( 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/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 2567b1a20..b500ad0df 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 @@ -469,6 +469,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 30677df07..9b415ed5d 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 @@ -838,6 +838,7 @@ internal fun StreamList( debridEnabled = debridEnabled, appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, badgePlacement = streamBadgeSettings.badgePlacement, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, @@ -865,6 +866,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, @@ -912,6 +914,7 @@ private fun LazyListScope.streamSection( enabled = stream.isSelectableForPlayback(debridEnabled), appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = showFileSizeBadges, + showAddonLogo = showAddonLogo, badgePlacement = badgePlacement, onClick = { if (stream.isSelectableForPlayback(debridEnabled)) { @@ -1019,6 +1022,7 @@ private fun StreamCard( enabled: Boolean, appendInstantServiceToDefaultName: Boolean, showFileSizeBadges: Boolean, + showAddonLogo: Boolean, badgePlacement: StreamBadgePlacement, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, @@ -1046,7 +1050,7 @@ private fun StreamCard( ) .secondaryClick(if (enabled) onLongClick else null) .padding(14.dp), - verticalAlignment = Alignment.Top, + verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { if (hasBadges && badgePlacement == StreamBadgePlacement.TOP) { @@ -1085,6 +1089,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/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" + } } 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) {