Merge branch 'cmp-rewrite' into desktopweb

This commit is contained in:
tapframe 2026-06-11 00:34:38 +05:30
commit 3308a60528
20 changed files with 578 additions and 128 deletions

View file

@ -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",

View file

@ -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<String> =
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"
}
}

View file

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

View file

@ -1456,4 +1456,7 @@
<string name="cloud_library_type_torrents">Torrenty</string>
<string name="cloud_library_type_usenet">Usenet</string>
<string name="cloud_library_type_web">Web</string>
<string name="settings_stream_addon_logo_title">Logo dodatku</string>
<string name="settings_stream_addon_logo_description">Pokazuj logo i nazwę dodatku obok źródeł.</string>
<string name="settings_stream_display_section">Wyświetlanie</string>
</resources>

View file

@ -686,6 +686,9 @@
<string name="settings_stream_badges_section">Fusion Style</string>
<string name="settings_stream_size_badges_title">Size badges</string>
<string name="settings_stream_size_badges_description">Show file size badges in stream results and player source panels.</string>
<string name="settings_stream_addon_logo_title">Addon logo</string>
<string name="settings_stream_addon_logo_description">Show addon logo and name next to stream sources.</string>
<string name="settings_stream_display_section">Display</string>
<string name="settings_stream_badge_position_title">Badge position</string>
<string name="settings_stream_badge_position_description">Choose whether Fusion and size badges appear above or below stream cards.</string>
<string name="settings_stream_badge_position_dialog_title">Badge position</string>

View file

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

View file

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

View file

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

View file

@ -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<String?>(null) }
var episodeImdbRatings by remember(type, id) { mutableStateOf<Map<Pair<Int, Int>, 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<TraktCommentReview>,
isCommentsLoading: Boolean,
isCommentsLoadingMore: Boolean,
commentsCurrentPage: Int,
commentsPageCount: Int,
commentsError: String?,
episodeImdbRatings: Map<Pair<Int, Int>, Double>,
onRetryComments: () -> Unit,
onLoadMoreComments: () -> Unit,
onCommentClick: (TraktCommentReview) -> Unit,
onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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<MetaScreenSectionItem>,
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<Int>()
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<TraktCommentReview>,
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(

View file

@ -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<DebridDeviceAuthorization>(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(" ")

View file

@ -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",

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ object StreamParser {
payload: String,
addonName: String,
addonId: String,
addonLogo: String? = null,
): List<StreamItem> {
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"),

View file

@ -456,6 +456,7 @@ object StreamsRepository {
payload = payload,
addonName = displayName,
addonId = addon.addonId,
addonLogo = addon.manifest.logoUrl,
)
}.fold(
onSuccess = { streams ->

View file

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

View file

@ -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<String> =
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"
}
}

View file

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