diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 5c657824..b6f26e87 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -506,6 +506,8 @@
Show value
Show a popup to continue where you left off when opening the app after leaving from the player.
Resume prompt on launch
+ Blur next episode thumbnails in Continue Watching to avoid spoilers.
+ Blur Unwatched in Continue Watching
Poster Card Style
ON LAUNCH
UP NEXT BEHAVIOR
@@ -557,6 +559,8 @@
Detail-first stacked cards
Episodes
Seasons and episode list for series.
+ Blur Unwatched Episodes
+ Blur episode thumbnails until watched to avoid spoilers.
Group %1$d
More like this
TMDB recommendation backdrops on detail page
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 b4f31fe6..80c724a3 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
@@ -690,6 +690,7 @@ fun MetaDetailsScreen(
onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys,
+ blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onOpenMeta = onOpenMeta,
@@ -970,6 +971,7 @@ private fun ConfiguredMetaSections(
onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map,
watchedKeys: Set,
+ blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
@@ -1062,6 +1064,7 @@ private fun ConfiguredMetaSections(
episodeCardStyle = settings.episodeCardStyle,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt
index 22f1d1eb..8d4f8c0f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt
@@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
val cinematicBackground: Boolean = false,
val tabLayout: Boolean = false,
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
+ val blurUnwatchedEpisodes: Boolean = false,
)
enum class MetaEpisodeCardStyle {
@@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
@SerialName("tvStyleLayout")
val tabLayout: Boolean = false,
val episodeCardStyle: String = "horizontal",
+ @SerialName("blur_unwatched_episodes")
+ val blurUnwatchedEpisodes: Boolean = false,
)
private data class MetaScreenSectionDefinition(
@@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ private var blurUnwatchedEpisodes: Boolean = false
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() {
@@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
tabLayout = parsed.tabLayout
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
?: MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
preferences = parsed.items.mapNotNull { item ->
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
key to item
@@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState()
ensureLoaded()
}
@@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
persist()
}
+ fun setBlurUnwatchedEpisodes(enabled: Boolean) {
+ ensureLoaded()
+ blurUnwatchedEpisodes = enabled
+ publish()
+ persist()
+ }
+
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
ensureLoaded()
if (!key.canBeTabbed) return
@@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
preferences.clear()
cinematicBackground = false
tabLayout = false
+ episodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState()
}
@@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
cinematicBackground: Boolean,
tabLayout: Boolean,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
+ blurUnwatchedEpisodes: Boolean = false,
) {
ensureLoaded()
this.cinematicBackground = cinematicBackground
this.tabLayout = tabLayout
this.episodeCardStyle = episodeCardStyle
+ this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
preferences = items.associate { item ->
item.key to StoredMetaScreenSectionPreference(
key = item.key.name,
@@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false
tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
+ blurUnwatchedEpisodes = false
normalizePreferences()
publish()
persist()
@@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
episodeCardStyle = episodeCardStyle,
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
)
}
@@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground,
tabLayout = tabLayout,
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
),
),
)
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 485c729a..10f42141 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
@@ -45,6 +45,7 @@ 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
@@ -90,6 +91,7 @@ fun DetailSeriesContent(
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
progressByVideoId: Map = emptyMap(),
watchedKeys: Set = emptySet(),
+ blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
) {
@@ -276,6 +278,7 @@ fun DetailSeriesContent(
watchedKeys = watchedKeys,
fallbackImage = meta.background ?: meta.poster,
progressByVideoId = progressByVideoId,
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
preferredEpisodeNumber = preferredEpisodeNumber,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
@@ -295,13 +298,14 @@ fun DetailSeriesContent(
video = episode,
fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
- isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
+ isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
),
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
@@ -553,6 +557,7 @@ private fun EpisodeHorizontalRow(
watchedKeys: Set,
fallbackImage: String?,
progressByVideoId: Map,
+ blurUnwatchedEpisodes: Boolean,
preferredEpisodeNumber: Int? = null,
onEpisodeClick: ((MetaVideo) -> Unit)?,
onEpisodeLongPress: ((MetaVideo) -> Unit)?,
@@ -597,13 +602,14 @@ private fun EpisodeHorizontalRow(
video = episode,
fallbackImage = fallbackImage,
progressEntry = progressByVideoId[episodeVideoId],
- isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
+ isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = metaType,
metaId = parentMetaId,
episode = episode,
),
+ blurUnwatchedEpisodes = blurUnwatchedEpisodes,
metrics = rowMetrics,
onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) },
@@ -619,6 +625,7 @@ private fun EpisodeHorizontalCard(
fallbackImage: String?,
progressEntry: WatchProgressEntry?,
isWatched: Boolean,
+ blurUnwatchedEpisodes: Boolean,
metrics: EpisodeHorizontalCardMetrics,
onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null,
@@ -642,11 +649,14 @@ private fun EpisodeHorizontalCard(
),
) {
val imageUrl = video.thumbnail ?: fallbackImage
+ val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
}
@@ -889,6 +899,7 @@ private fun EpisodeListCard(
fallbackImage: String?,
progressEntry: WatchProgressEntry?,
isWatched: Boolean,
+ blurUnwatchedEpisodes: Boolean,
sizing: SeriesContentSizing,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
@@ -923,11 +934,14 @@ private fun EpisodeListCard(
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) {
val imageUrl = video.thumbnail ?: fallbackImage
+ val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = video.title,
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
} else {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
index 644295bd..d0144ead 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
@@ -166,6 +166,9 @@ fun HomeScreen(
)
}
}
+ val completedSeriesContentIds = remember(completedSeriesCandidates) {
+ completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
+ }
val visibleContinueWatchingEntries = remember(
effectiveWatchProgressEntries,
latestCompletedBySeries,
@@ -181,8 +184,21 @@ fun HomeScreen(
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf