mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge 59551fa29e into 755f6ff9b8
This commit is contained in:
commit
17b245cecc
9 changed files with 391 additions and 33 deletions
|
|
@ -157,6 +157,8 @@ import com.nuvio.app.features.tmdb.TmdbEntityKind
|
|||
import com.nuvio.app.features.home.HomeCatalogSection
|
||||
import com.nuvio.app.features.home.HomeScreen
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.components.shouldProtectNextUpArtwork
|
||||
import com.nuvio.app.features.home.components.spoilerSafeArtworkUrl
|
||||
import com.nuvio.app.features.library.LibraryItem
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.LibrarySection
|
||||
|
|
@ -3399,8 +3401,17 @@ private fun MainAppContent(
|
|||
key(item.videoId, anchor) {
|
||||
val showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState)
|
||||
val showDetailsOption = !item.isCloudLibraryContinueWatchingItem()
|
||||
val shouldProtectArtwork = item.shouldProtectNextUpArtwork(
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
|
||||
)
|
||||
val actionArtworkUrl = if (shouldProtectArtwork) {
|
||||
anchor.imageUrl ?: item.spoilerSafeArtworkUrl()
|
||||
} else {
|
||||
anchor.imageUrl ?: item.poster ?: item.imageUrl
|
||||
}
|
||||
NuvioPosterZoomActionOverlay(
|
||||
imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl),
|
||||
imageUrl = cloudLibraryDisplayArtworkUrl(actionArtworkUrl),
|
||||
title = item.title,
|
||||
subtitle = localizedContinueWatchingSubtitle(item),
|
||||
depthSurface = NuvioCardDepthSurface.ContinueWatching,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeInputScale
|
||||
import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
|
||||
private val SpoilerBlurFallbackTint = HazeTint(Color.Black.copy(alpha = 0.85f))
|
||||
private val SpoilerBlurClearTint = HazeTint(Color.Transparent)
|
||||
|
||||
/**
|
||||
* Blurs spoiler-sensitive artwork on every platform supported by Haze. If blur cannot be
|
||||
* initialized, the fallback tint keeps the spoiler content obscured. The effect node remains
|
||||
* attached while watched state changes so Haze can update its legacy Android rendering path.
|
||||
*/
|
||||
internal fun Modifier.spoilerBlur(
|
||||
active: Boolean,
|
||||
blurred: Boolean,
|
||||
radius: Dp = 18.dp,
|
||||
): Modifier = if (active) {
|
||||
hazeEffect {
|
||||
blurRadius = radius
|
||||
blurEnabled = blurred
|
||||
inputScale = if (blurred) HazeInputScale.Auto else HazeInputScale.None
|
||||
noiseFactor = 0f
|
||||
fallbackTint = if (blurred) SpoilerBlurFallbackTint else SpoilerBlurClearTint
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
|
@ -945,6 +945,8 @@ fun MetaDetailsScreen(
|
|||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = seriesAction?.seasonNumber,
|
||||
preferredEpisodeNumber = seriesAction?.episodeNumber,
|
||||
isEpisodePositionReady = watchedUiState.isLoaded &&
|
||||
watchProgressUiState.hasLoadedRemoteProgress,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
hasEpisodes = hasEpisodes,
|
||||
|
|
@ -1335,8 +1337,15 @@ fun MetaDetailsScreen(
|
|||
val seasonLabel = selectedEpisode.season?.let {
|
||||
stringResource(Res.string.episodes_season, it)
|
||||
} ?: stringResource(Res.string.episodes_specials)
|
||||
val shouldProtectArtwork = metaScreenSettingsUiState.blurUnwatchedEpisodes &&
|
||||
!isSelectedEpisodeWatched
|
||||
val actionArtworkUrl = if (shouldProtectArtwork) {
|
||||
zoomAnchor.imageUrl ?: meta.background ?: meta.poster
|
||||
} else {
|
||||
zoomAnchor.imageUrl ?: selectedEpisode.thumbnail ?: meta.background ?: meta.poster
|
||||
}
|
||||
NuvioPosterZoomActionOverlay(
|
||||
imageUrl = zoomAnchor.imageUrl ?: selectedEpisode.thumbnail ?: meta.background ?: meta.poster,
|
||||
imageUrl = actionArtworkUrl,
|
||||
title = selectedEpisode.title,
|
||||
subtitle = localizedSeasonEpisodeCode(selectedEpisode.season, selectedEpisode.episode) ?: seasonLabel,
|
||||
isWatched = isSelectedEpisodeWatched,
|
||||
|
|
@ -1584,6 +1593,7 @@ private fun LazyListScope.configuredMetaSectionItems(
|
|||
showManualPlayOption: Boolean,
|
||||
preferredEpisodeSeasonNumber: Int?,
|
||||
preferredEpisodeNumber: Int?,
|
||||
isEpisodePositionReady: Boolean,
|
||||
hasProductionSection: Boolean,
|
||||
hasTrailersSection: Boolean,
|
||||
hasEpisodes: Boolean,
|
||||
|
|
@ -1660,6 +1670,7 @@ private fun LazyListScope.configuredMetaSectionItems(
|
|||
showManualPlayOption = showManualPlayOption,
|
||||
preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
isEpisodePositionReady = isEpisodePositionReady,
|
||||
hasProductionSection = hasProductionSection,
|
||||
hasTrailersSection = hasTrailersSection,
|
||||
hasEpisodes = hasEpisodes,
|
||||
|
|
@ -1809,6 +1820,7 @@ private fun ConfiguredMetaSections(
|
|||
showManualPlayOption: Boolean,
|
||||
preferredEpisodeSeasonNumber: Int?,
|
||||
preferredEpisodeNumber: Int?,
|
||||
isEpisodePositionReady: Boolean,
|
||||
hasProductionSection: Boolean,
|
||||
hasTrailersSection: Boolean,
|
||||
hasEpisodes: Boolean,
|
||||
|
|
@ -1954,6 +1966,7 @@ private fun ConfiguredMetaSections(
|
|||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
preferredSeasonNumber = preferredEpisodeSeasonNumber,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
isEpisodePositionReady = isEpisodePositionReady,
|
||||
episodeCardStyle = settings.episodeCardStyle,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -69,6 +68,7 @@ import com.nuvio.app.core.ui.NuvioProgressBar
|
|||
import com.nuvio.app.core.ui.nuvioCardDepth
|
||||
import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.spoilerBlur
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaEpisodeCardStyle
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
|
|
@ -99,6 +99,7 @@ fun DetailSeriesContent(
|
|||
horizontalScrollPadding: Dp = 0.dp,
|
||||
preferredSeasonNumber: Int? = null,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
isEpisodePositionReady: Boolean = true,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
|
|
@ -172,9 +173,24 @@ fun DetailSeriesContent(
|
|||
?.takeIf { it in groupedEpisodes }
|
||||
?: seasons.first()
|
||||
var selectedSeasonOverride by rememberSaveable(meta.id) { mutableStateOf<Int?>(null) }
|
||||
val currentSeason = selectedSeasonOverride
|
||||
?.takeIf { it in groupedEpisodes }
|
||||
?: defaultSeason
|
||||
var initialSeasonSnapshot by rememberSaveable(meta.id) { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(isEpisodePositionReady, defaultSeason, seasons) {
|
||||
val nextSnapshot = captureInitialSeasonSnapshot(
|
||||
isReady = isEpisodePositionReady,
|
||||
capturedSeason = initialSeasonSnapshot,
|
||||
defaultSeason = defaultSeason,
|
||||
availableSeasons = seasons,
|
||||
)
|
||||
if (nextSnapshot != initialSeasonSnapshot) {
|
||||
initialSeasonSnapshot = nextSnapshot
|
||||
}
|
||||
}
|
||||
val currentSeason = resolveCurrentSeason(
|
||||
selectedSeasonOverride = selectedSeasonOverride,
|
||||
initialSeasonSnapshot = initialSeasonSnapshot,
|
||||
defaultSeason = defaultSeason,
|
||||
availableSeasons = seasons,
|
||||
)
|
||||
|
||||
var seasonViewMode by remember {
|
||||
mutableStateOf(SeasonViewModeStorage.load() ?: SeasonViewMode.Posters)
|
||||
|
|
@ -300,7 +316,12 @@ fun DetailSeriesContent(
|
|||
progressByVideoId = progressByVideoId,
|
||||
episodeRatings = episodeRatings,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
preferredEpisodeNumber = preferredEpisodeNumberForSeason(
|
||||
currentSeason = seasonForContent,
|
||||
preferredSeasonNumber = preferredSeasonNumber,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
),
|
||||
isEpisodePositionReady = isEpisodePositionReady,
|
||||
onEpisodeClick = onEpisodeClick,
|
||||
onEpisodeLongPress = onEpisodeLongPress,
|
||||
)
|
||||
|
|
@ -603,26 +624,30 @@ private fun EpisodeHorizontalRow(
|
|||
episodeRatings: Map<Pair<Int, Int>, Double>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
isEpisodePositionReady: Boolean,
|
||||
onEpisodeClick: ((MetaVideo) -> Unit)?,
|
||||
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
|
||||
) {
|
||||
val rowMetrics = rememberEpisodeHorizontalCardMetrics(maxWidthDp)
|
||||
val listState = rememberLazyListState()
|
||||
var hasPositioned by remember(episodes) { mutableStateOf(false) }
|
||||
val seasonNumber = episodes.firstOrNull()?.season
|
||||
var hasPositioned by remember(parentMetaId, seasonNumber) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(episodes, preferredEpisodeNumber, isEpisodePositionReady) {
|
||||
// The preferred episode advances when an episode is marked watched. Keep the user's row
|
||||
// position after the initial placement instead of scrolling the selected card off-screen.
|
||||
if (!shouldInitializeEpisodeRowPosition(isEpisodePositionReady, hasPositioned)) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
hasPositioned = true
|
||||
|
||||
LaunchedEffect(episodes, preferredEpisodeNumber) {
|
||||
val targetIndex = if (preferredEpisodeNumber != null) {
|
||||
episodes.indexOfFirst { it.episode == preferredEpisodeNumber }
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
if (targetIndex >= 0) {
|
||||
if (hasPositioned) {
|
||||
listState.animateScrollToItem(targetIndex)
|
||||
} else {
|
||||
listState.scrollToItem(targetIndex)
|
||||
hasPositioned = true
|
||||
}
|
||||
listState.scrollToItem(targetIndex)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -686,6 +711,8 @@ private fun EpisodeHorizontalCard(
|
|||
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
|
||||
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
|
||||
val imageUrl = video.thumbnail ?: fallbackImage
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
val zoomImageUrl = if (shouldBlurArtwork) fallbackImage else imageUrl
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(metrics.cardWidth)
|
||||
|
|
@ -700,18 +727,17 @@ private fun EpisodeHorizontalCard(
|
|||
.posterCardClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongPress,
|
||||
zoomImageUrl = imageUrl,
|
||||
zoomImageUrl = zoomImageUrl,
|
||||
zoomCornerRadius = metrics.cornerRadius,
|
||||
),
|
||||
) {
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = video.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
.spoilerBlur(active = blurUnwatchedEpisodes, blurred = shouldBlurArtwork),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -838,6 +864,42 @@ private fun EpisodeHorizontalCard(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun preferredEpisodeNumberForSeason(
|
||||
currentSeason: Int,
|
||||
preferredSeasonNumber: Int?,
|
||||
preferredEpisodeNumber: Int?,
|
||||
): Int? = preferredEpisodeNumber?.takeIf {
|
||||
preferredSeasonNumber == null ||
|
||||
normalizeSeasonNumber(preferredSeasonNumber) == currentSeason
|
||||
}
|
||||
|
||||
internal fun shouldInitializeEpisodeRowPosition(
|
||||
isReady: Boolean,
|
||||
hasPositioned: Boolean,
|
||||
): Boolean = isReady && !hasPositioned
|
||||
|
||||
internal fun captureInitialSeasonSnapshot(
|
||||
isReady: Boolean,
|
||||
capturedSeason: Int?,
|
||||
defaultSeason: Int,
|
||||
availableSeasons: Collection<Int>,
|
||||
): Int? = when {
|
||||
!isReady -> capturedSeason?.takeIf { it in availableSeasons }
|
||||
capturedSeason != null && capturedSeason in availableSeasons -> capturedSeason
|
||||
defaultSeason in availableSeasons -> defaultSeason
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun resolveCurrentSeason(
|
||||
selectedSeasonOverride: Int?,
|
||||
initialSeasonSnapshot: Int?,
|
||||
defaultSeason: Int,
|
||||
availableSeasons: Collection<Int>,
|
||||
): Int = selectedSeasonOverride
|
||||
?.takeIf { it in availableSeasons }
|
||||
?: initialSeasonSnapshot?.takeIf { it in availableSeasons }
|
||||
?: defaultSeason
|
||||
|
||||
private data class EpisodeHorizontalCardMetrics(
|
||||
val rowHorizontalPadding: Dp,
|
||||
val rowVerticalPadding: Dp,
|
||||
|
|
@ -1089,7 +1151,7 @@ private fun EpisodeListCard(
|
|||
contentDescription = video.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
.spoilerBlur(active = blurUnwatchedEpisodes, blurred = shouldBlurArtwork),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
|
@ -60,6 +59,7 @@ import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
|||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.core.ui.spoilerBlur
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -228,6 +228,14 @@ private fun ContinueWatchingItem.continueWatchingCardArtworkUrl(
|
|||
private fun firstNonBlank(vararg values: String?): String? =
|
||||
values.firstOrNull { value -> !value.isNullOrBlank() }?.trim()
|
||||
|
||||
internal fun ContinueWatchingItem.shouldProtectNextUpArtwork(
|
||||
blurNextUp: Boolean,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
): Boolean = blurNextUp && useEpisodeThumbnails && isNextUp
|
||||
|
||||
internal fun ContinueWatchingItem.spoilerSafeArtworkUrl(): String? =
|
||||
firstNonBlank(poster, background)
|
||||
|
||||
@Composable
|
||||
internal fun HomeContinueWatchingSection(
|
||||
items: List<ContinueWatchingItem>,
|
||||
|
|
@ -695,7 +703,9 @@ private fun ContinueWatchingCard(
|
|||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
preferBackdropForNextUp = preferBackdropForNextUp,
|
||||
)
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val shouldProtectArtwork = item.shouldProtectNextUpArtwork(blurNextUp, useEpisodeThumbnails)
|
||||
val shouldBlurArtwork = shouldProtectArtwork && imageUrl == firstNonBlank(item.episodeThumbnail)
|
||||
val zoomImageUrl = if (shouldProtectArtwork) item.spoilerSafeArtworkUrl() else imageUrl
|
||||
val episodeCode = if (item.seasonNumber != null && item.episodeNumber != null) {
|
||||
stringResource(Res.string.streams_episode_badge, item.seasonNumber, item.episodeNumber)
|
||||
} else {
|
||||
|
|
@ -723,7 +733,7 @@ private fun ContinueWatchingCard(
|
|||
.posterCardClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
zoomImageUrl = imageUrl,
|
||||
zoomImageUrl = zoomImageUrl,
|
||||
zoomCornerRadius = cardMetrics.cornerRadius,
|
||||
),
|
||||
) {
|
||||
|
|
@ -733,7 +743,7 @@ private fun ContinueWatchingCard(
|
|||
contentDescription = item.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier)
|
||||
.spoilerBlur(active = blurNextUp && useEpisodeThumbnails, blurred = shouldBlurArtwork)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
|
|
@ -906,11 +916,14 @@ private fun ContinueWatchingWideCard(
|
|||
onLongClick = onLongClick,
|
||||
),
|
||||
) {
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
|
||||
val shouldProtectArtwork = item.shouldProtectNextUpArtwork(blurNextUp, useEpisodeThumbnails)
|
||||
val shouldBlurArtwork = shouldProtectArtwork &&
|
||||
artworkUrl == firstNonBlank(item.episodeThumbnail)
|
||||
ArtworkPanel(
|
||||
imageUrl = artworkUrl,
|
||||
width = layout.widePosterStripWidth,
|
||||
blurActive = blurNextUp && useEpisodeThumbnails,
|
||||
blurred = shouldBlurArtwork,
|
||||
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
|
|
@ -1017,6 +1030,10 @@ private fun ContinueWatchingPosterCard(
|
|||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails)
|
||||
val shouldProtectArtwork = item.shouldProtectNextUpArtwork(blurNextUp, useEpisodeThumbnails)
|
||||
val shouldBlurArtwork = shouldProtectArtwork &&
|
||||
imageUrl == firstNonBlank(item.episodeThumbnail)
|
||||
val zoomImageUrl = if (shouldProtectArtwork) item.spoilerSafeArtworkUrl() else imageUrl
|
||||
Column(
|
||||
modifier = Modifier.width(layout.posterCardWidth),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -1034,21 +1051,17 @@ private fun ContinueWatchingPosterCard(
|
|||
.posterCardClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
zoomImageUrl = imageUrl,
|
||||
zoomImageUrl = zoomImageUrl,
|
||||
zoomCornerRadius = layout.cardRadius,
|
||||
),
|
||||
) {
|
||||
val shouldBlurArtwork = blurNextUp &&
|
||||
useEpisodeThumbnails &&
|
||||
item.isNextUp &&
|
||||
imageUrl == firstNonBlank(item.episodeThumbnail)
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = cloudLibraryDisplayArtworkUrl(imageUrl),
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
.spoilerBlur(active = blurNextUp && useEpisodeThumbnails, blurred = shouldBlurArtwork),
|
||||
contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -1139,6 +1152,7 @@ private fun ContinueWatchingPosterCard(
|
|||
private fun ArtworkPanel(
|
||||
imageUrl: String?,
|
||||
width: Dp,
|
||||
blurActive: Boolean = false,
|
||||
blurred: Boolean = false,
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -1154,7 +1168,7 @@ private fun ArtworkPanel(
|
|||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
||||
.spoilerBlur(active = blurActive, blurred = blurred),
|
||||
contentScale = contentScale,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -48,6 +47,7 @@ import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
|||
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.core.ui.spoilerBlur
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
||||
|
|
@ -384,7 +384,11 @@ private fun EpisodeRow(
|
|||
contentDescription = episode.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier),
|
||||
.spoilerBlur(
|
||||
active = blurUnwatchedEpisodes,
|
||||
blurred = shouldBlurArtwork,
|
||||
radius = NuvioTokens.Space.s18,
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class EpisodeRowPositioningTest {
|
||||
@Test
|
||||
fun `preferred episode is only applied to its own season`() {
|
||||
assertEquals(
|
||||
4,
|
||||
preferredEpisodeNumberForSeason(
|
||||
currentSeason = 2,
|
||||
preferredSeasonNumber = 2,
|
||||
preferredEpisodeNumber = 4,
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
preferredEpisodeNumberForSeason(
|
||||
currentSeason = 1,
|
||||
preferredSeasonNumber = 2,
|
||||
preferredEpisodeNumber = 4,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferred episode without season remains supported`() {
|
||||
assertEquals(
|
||||
3,
|
||||
preferredEpisodeNumberForSeason(
|
||||
currentSeason = 1,
|
||||
preferredSeasonNumber = null,
|
||||
preferredEpisodeNumber = 3,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial row position waits for hydration and is consumed once`() {
|
||||
assertFalse(
|
||||
shouldInitializeEpisodeRowPosition(
|
||||
isReady = false,
|
||||
hasPositioned = false,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
shouldInitializeEpisodeRowPosition(
|
||||
isReady = true,
|
||||
hasPositioned = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldInitializeEpisodeRowPosition(
|
||||
isReady = true,
|
||||
hasPositioned = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial season stays stable when watched state advances the preferred episode`() {
|
||||
val seasons = listOf(1, 2, 3)
|
||||
var snapshot = captureInitialSeasonSnapshot(
|
||||
isReady = false,
|
||||
capturedSeason = null,
|
||||
defaultSeason = 1,
|
||||
availableSeasons = seasons,
|
||||
)
|
||||
assertNull(snapshot)
|
||||
|
||||
snapshot = captureInitialSeasonSnapshot(
|
||||
isReady = true,
|
||||
capturedSeason = snapshot,
|
||||
defaultSeason = 2,
|
||||
availableSeasons = seasons,
|
||||
)
|
||||
assertEquals(2, snapshot)
|
||||
|
||||
snapshot = captureInitialSeasonSnapshot(
|
||||
isReady = true,
|
||||
capturedSeason = snapshot,
|
||||
defaultSeason = 1,
|
||||
availableSeasons = seasons,
|
||||
)
|
||||
assertEquals(2, snapshot)
|
||||
assertEquals(
|
||||
2,
|
||||
resolveCurrentSeason(
|
||||
selectedSeasonOverride = null,
|
||||
initialSeasonSnapshot = snapshot,
|
||||
defaultSeason = 1,
|
||||
availableSeasons = seasons,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
resolveCurrentSeason(
|
||||
selectedSeasonOverride = 3,
|
||||
initialSeasonSnapshot = snapshot,
|
||||
defaultSeason = 1,
|
||||
availableSeasons = seasons,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ContinueWatchingSpoilerArtworkTest {
|
||||
@Test
|
||||
fun `protected preview prefers series poster and never episode thumbnail`() {
|
||||
val item = nextUpItem(
|
||||
poster = "series-poster",
|
||||
background = "series-background",
|
||||
episodeThumbnail = "episode-thumbnail",
|
||||
)
|
||||
|
||||
assertEquals("series-poster", item.spoilerSafeArtworkUrl())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `protected preview falls back to series background`() {
|
||||
val item = nextUpItem(
|
||||
poster = null,
|
||||
background = "series-background",
|
||||
episodeThumbnail = "episode-thumbnail",
|
||||
)
|
||||
|
||||
assertEquals("series-background", item.spoilerSafeArtworkUrl())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `protected preview returns no image when only episode artwork exists`() {
|
||||
val item = nextUpItem(
|
||||
poster = null,
|
||||
background = null,
|
||||
episodeThumbnail = "episode-thumbnail",
|
||||
)
|
||||
|
||||
assertNull(item.spoilerSafeArtworkUrl())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next up artwork is protected only when both preferences are enabled`() {
|
||||
val item = nextUpItem()
|
||||
|
||||
assertTrue(item.shouldProtectNextUpArtwork(blurNextUp = true, useEpisodeThumbnails = true))
|
||||
assertFalse(item.shouldProtectNextUpArtwork(blurNextUp = false, useEpisodeThumbnails = true))
|
||||
assertFalse(item.shouldProtectNextUpArtwork(blurNextUp = true, useEpisodeThumbnails = false))
|
||||
assertFalse(
|
||||
item.copy(isNextUp = false)
|
||||
.shouldProtectNextUpArtwork(blurNextUp = true, useEpisodeThumbnails = true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextUpItem(
|
||||
poster: String? = "series-poster",
|
||||
background: String? = "series-background",
|
||||
episodeThumbnail: String? = "episode-thumbnail",
|
||||
): ContinueWatchingItem = ContinueWatchingItem(
|
||||
parentMetaId = "show",
|
||||
parentMetaType = "series",
|
||||
videoId = "show:2:1",
|
||||
title = "Show",
|
||||
subtitle = "S2E1 • Episode",
|
||||
imageUrl = episodeThumbnail,
|
||||
poster = poster,
|
||||
background = background,
|
||||
seasonNumber = 2,
|
||||
episodeNumber = 1,
|
||||
episodeTitle = "Episode",
|
||||
episodeThumbnail = episodeThumbnail,
|
||||
isNextUp = true,
|
||||
resumePositionMs = 0L,
|
||||
durationMs = 0L,
|
||||
progressFraction = 0f,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.nuvio.app.features.watched
|
||||
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class WatchedEpisodeActionsTest {
|
||||
@Test
|
||||
fun `previous released episodes include every older season`() {
|
||||
val meta = MetaDetails(
|
||||
id = "show",
|
||||
type = "series",
|
||||
name = "Show",
|
||||
videos = listOf(
|
||||
MetaVideo(id = "s2e2", title = "S2E2", season = 2, episode = 2, released = "2026-02-08"),
|
||||
MetaVideo(id = "s1e2", title = "S1E2", season = 1, episode = 2, released = "2026-01-08"),
|
||||
MetaVideo(id = "s2e1", title = "S2E1", season = 2, episode = 1, released = "2026-02-01"),
|
||||
MetaVideo(id = "s1e1", title = "S1E1", season = 1, episode = 1, released = "2026-01-01"),
|
||||
),
|
||||
)
|
||||
val target = meta.videos.first { it.id == "s2e2" }
|
||||
|
||||
val previousEpisodes = meta.previousReleasedEpisodesBefore(
|
||||
target = target,
|
||||
todayIsoDate = "2026-03-01",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("s1e1", "s1e2", "s2e1"),
|
||||
previousEpisodes.map(MetaVideo::id),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue