From 59551fa29e118f242248da6c6fb22ac098b4d726 Mon Sep 17 00:00:00 2001 From: Hassan Date: Sat, 18 Jul 2026 10:33:43 +0300 Subject: [PATCH] fix: blur unwatched episode artwork on legacy Android --- .../commonMain/kotlin/com/nuvio/app/App.kt | 13 ++- .../com/nuvio/app/core/ui/SpoilerBlur.kt | 33 ++++++ .../app/features/details/MetaDetailsScreen.kt | 15 ++- .../details/components/DetailSeriesContent.kt | 96 +++++++++++++--- .../components/HomeContinueWatchingSection.kt | 38 ++++-- .../features/player/PlayerEpisodesPanel.kt | 8 +- .../components/EpisodeRowPositioningTest.kt | 108 ++++++++++++++++++ .../ContinueWatchingSpoilerArtworkTest.kt | 79 +++++++++++++ .../watched/WatchedEpisodeActionsTest.kt | 34 ++++++ 9 files changed, 391 insertions(+), 33 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SpoilerBlur.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeRowPositioningTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/ContinueWatchingSpoilerArtworkTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActionsTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ebdba5e9d..8c62c3df3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -155,6 +155,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 @@ -3374,8 +3376,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), anchor = anchor, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SpoilerBlur.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SpoilerBlur.kt new file mode 100644 index 000000000..ea53af3b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SpoilerBlur.kt @@ -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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 5f335f2ad..692b82f3f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -944,6 +944,8 @@ fun MetaDetailsScreen( showManualPlayOption = showManualPlayOption, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, preferredEpisodeNumber = seriesAction?.episodeNumber, + isEpisodePositionReady = watchedUiState.isLoaded && + watchProgressUiState.hasLoadedRemoteProgress, hasProductionSection = hasProductionSection, hasTrailersSection = hasTrailersSection, hasEpisodes = hasEpisodes, @@ -1334,8 +1336,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, @@ -1582,6 +1591,7 @@ private fun LazyListScope.configuredMetaSectionItems( showManualPlayOption: Boolean, preferredEpisodeSeasonNumber: Int?, preferredEpisodeNumber: Int?, + isEpisodePositionReady: Boolean, hasProductionSection: Boolean, hasTrailersSection: Boolean, hasEpisodes: Boolean, @@ -1658,6 +1668,7 @@ private fun LazyListScope.configuredMetaSectionItems( showManualPlayOption = showManualPlayOption, preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber, preferredEpisodeNumber = preferredEpisodeNumber, + isEpisodePositionReady = isEpisodePositionReady, hasProductionSection = hasProductionSection, hasTrailersSection = hasTrailersSection, hasEpisodes = hasEpisodes, @@ -1807,6 +1818,7 @@ private fun ConfiguredMetaSections( showManualPlayOption: Boolean, preferredEpisodeSeasonNumber: Int?, preferredEpisodeNumber: Int?, + isEpisodePositionReady: Boolean, hasProductionSection: Boolean, hasTrailersSection: Boolean, hasEpisodes: Boolean, @@ -1952,6 +1964,7 @@ private fun ConfiguredMetaSections( horizontalScrollPadding = horizontalScrollPadding, preferredSeasonNumber = preferredEpisodeSeasonNumber, preferredEpisodeNumber = preferredEpisodeNumber, + isEpisodePositionReady = isEpisodePositionReady, episodeCardStyle = settings.episodeCardStyle, progressByVideoId = progressByVideoId, watchedKeys = watchedKeys, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 71ba2eded..812796f19 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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 = emptyMap(), watchedKeys: Set = emptySet(), @@ -172,9 +173,24 @@ fun DetailSeriesContent( ?.takeIf { it in groupedEpisodes } ?: seasons.first() var selectedSeasonOverride by rememberSaveable(meta.id) { mutableStateOf(null) } - val currentSeason = selectedSeasonOverride - ?.takeIf { it in groupedEpisodes } - ?: defaultSeason + var initialSeasonSnapshot by rememberSaveable(meta.id) { mutableStateOf(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, 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? = 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 = 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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 2ad956799..739ebf66c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -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, @@ -691,7 +699,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 { @@ -719,7 +729,7 @@ private fun ContinueWatchingCard( .posterCardClickable( onClick = onClick, onLongClick = onLongClick, - zoomImageUrl = imageUrl, + zoomImageUrl = zoomImageUrl, zoomCornerRadius = cardMetrics.cornerRadius, ), ) { @@ -729,7 +739,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() @@ -902,11 +912,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(), @@ -1013,6 +1026,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), @@ -1026,21 +1043,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, ) } @@ -1131,6 +1144,7 @@ private fun ContinueWatchingPosterCard( private fun ArtworkPanel( imageUrl: String?, width: Dp, + blurActive: Boolean = false, blurred: Boolean = false, contentScale: ContentScale = ContentScale.Crop, modifier: Modifier = Modifier, @@ -1146,7 +1160,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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 36a01dfee..967ecc0f6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -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, ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeRowPositioningTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeRowPositioningTest.kt new file mode 100644 index 000000000..4d280e490 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeRowPositioningTest.kt @@ -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, + ), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/ContinueWatchingSpoilerArtworkTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/ContinueWatchingSpoilerArtworkTest.kt new file mode 100644 index 000000000..50d87de71 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/components/ContinueWatchingSpoilerArtworkTest.kt @@ -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, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActionsTest.kt new file mode 100644 index 000000000..151aee78a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActionsTest.kt @@ -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), + ) + } +}