This commit is contained in:
i7xre 2026-07-23 18:56:44 -04:00 committed by GitHub
commit 17b245cecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 391 additions and 33 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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