mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-06 19:38:58 +00:00
Merge branch 'cmp-rewrite' into desktopweb
This commit is contained in:
commit
3308a60528
20 changed files with 578 additions and 128 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(" ")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -456,6 +456,7 @@ object StreamsRepository {
|
|||
payload = payload,
|
||||
addonName = displayName,
|
||||
addonId = addon.addonId,
|
||||
addonLogo = addon.manifest.logoUrl,
|
||||
)
|
||||
}.fold(
|
||||
onSuccess = { streams ->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue