feat: blur unwatched episode

This commit is contained in:
tapframe 2026-05-06 14:31:13 +05:30
parent 1993251087
commit 2af53f416d
15 changed files with 226 additions and 12 deletions

View file

@ -506,6 +506,8 @@
<string name="settings_show_secret">Show value</string> <string name="settings_show_secret">Show value</string>
<string name="settings_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string> <string name="settings_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string>
<string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string> <string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string>
<string name="settings_continue_watching_blur_next_up_description">Blur next episode thumbnails in Continue Watching to avoid spoilers.</string>
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string> <string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string> <string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string> <string name="settings_continue_watching_section_up_next_behavior">UP NEXT BEHAVIOR</string>
@ -557,6 +559,8 @@
<string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string> <string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string>
<string name="settings_meta_episodes">Episodes</string> <string name="settings_meta_episodes">Episodes</string>
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string> <string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
<string name="settings_meta_blur_unwatched_episodes">Blur Unwatched Episodes</string>
<string name="settings_meta_blur_unwatched_episodes_description">Blur episode thumbnails until watched to avoid spoilers.</string>
<string name="settings_meta_group_label">Group %1$d</string> <string name="settings_meta_group_label">Group %1$d</string>
<string name="settings_meta_more_like_this">More like this</string> <string name="settings_meta_more_like_this">More like this</string>
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string> <string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>

View file

@ -690,6 +690,7 @@ fun MetaDetailsScreen(
onTrailerClick = resolveTrailer, onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId, progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys, watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick, onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onOpenMeta = onOpenMeta, onOpenMeta = onOpenMeta,
@ -970,6 +971,7 @@ private fun ConfiguredMetaSections(
onTrailerClick: (MetaTrailer) -> Unit, onTrailerClick: (MetaTrailer) -> Unit,
progressByVideoId: Map<String, WatchProgressEntry>, progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>, watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit, onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?, onOpenMeta: ((MetaPreview) -> Unit)?,
@ -1062,6 +1064,7 @@ private fun ConfiguredMetaSections(
episodeCardStyle = settings.episodeCardStyle, episodeCardStyle = settings.episodeCardStyle,
progressByVideoId = progressByVideoId, progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick, onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress, onEpisodeLongPress = onEpisodeLongPress,
) )

View file

@ -45,6 +45,7 @@ data class MetaScreenSettingsUiState(
val cinematicBackground: Boolean = false, val cinematicBackground: Boolean = false,
val tabLayout: Boolean = false, val tabLayout: Boolean = false,
val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, val episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
val blurUnwatchedEpisodes: Boolean = false,
) )
enum class MetaEpisodeCardStyle { enum class MetaEpisodeCardStyle {
@ -81,6 +82,8 @@ private data class StoredMetaScreenSettingsPayload(
@SerialName("tvStyleLayout") @SerialName("tvStyleLayout")
val tabLayout: Boolean = false, val tabLayout: Boolean = false,
val episodeCardStyle: String = "horizontal", val episodeCardStyle: String = "horizontal",
@SerialName("blur_unwatched_episodes")
val blurUnwatchedEpisodes: Boolean = false,
) )
private data class MetaScreenSectionDefinition( private data class MetaScreenSectionDefinition(
@ -156,6 +159,7 @@ object MetaScreenSettingsRepository {
private var cinematicBackground: Boolean = false private var cinematicBackground: Boolean = false
private var tabLayout: Boolean = false private var tabLayout: Boolean = false
private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal private var episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal
private var blurUnwatchedEpisodes: Boolean = false
private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) }
fun ensureLoaded() { fun ensureLoaded() {
@ -172,6 +176,7 @@ object MetaScreenSettingsRepository {
tabLayout = parsed.tabLayout tabLayout = parsed.tabLayout
episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle) episodeCardStyle = MetaEpisodeCardStyle.parse(parsed.episodeCardStyle)
?: MetaEpisodeCardStyle.Horizontal ?: MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = parsed.blurUnwatchedEpisodes
preferences = parsed.items.mapNotNull { item -> preferences = parsed.items.mapNotNull { item ->
val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null val key = runCatching { MetaScreenSectionKey.valueOf(item.key) }.getOrNull() ?: return@mapNotNull null
key to item key to item
@ -190,6 +195,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState() _uiState.value = MetaScreenSettingsUiState()
ensureLoaded() ensureLoaded()
} }
@ -215,6 +221,13 @@ object MetaScreenSettingsRepository {
persist() persist()
} }
fun setBlurUnwatchedEpisodes(enabled: Boolean) {
ensureLoaded()
blurUnwatchedEpisodes = enabled
publish()
persist()
}
fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) { fun setTabGroup(key: MetaScreenSectionKey, groupId: Int?) {
ensureLoaded() ensureLoaded()
if (!key.canBeTabbed) return if (!key.canBeTabbed) return
@ -233,6 +246,8 @@ object MetaScreenSettingsRepository {
preferences.clear() preferences.clear()
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
_uiState.value = MetaScreenSettingsUiState() _uiState.value = MetaScreenSettingsUiState()
} }
@ -241,11 +256,13 @@ object MetaScreenSettingsRepository {
cinematicBackground: Boolean, cinematicBackground: Boolean,
tabLayout: Boolean, tabLayout: Boolean,
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
blurUnwatchedEpisodes: Boolean = false,
) { ) {
ensureLoaded() ensureLoaded()
this.cinematicBackground = cinematicBackground this.cinematicBackground = cinematicBackground
this.tabLayout = tabLayout this.tabLayout = tabLayout
this.episodeCardStyle = episodeCardStyle this.episodeCardStyle = episodeCardStyle
this.blurUnwatchedEpisodes = blurUnwatchedEpisodes
preferences = items.associate { item -> preferences = items.associate { item ->
item.key to StoredMetaScreenSectionPreference( item.key to StoredMetaScreenSectionPreference(
key = item.key.name, key = item.key.name,
@ -271,6 +288,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = false cinematicBackground = false
tabLayout = false tabLayout = false
episodeCardStyle = MetaEpisodeCardStyle.Horizontal episodeCardStyle = MetaEpisodeCardStyle.Horizontal
blurUnwatchedEpisodes = false
normalizePreferences() normalizePreferences()
publish() publish()
persist() persist()
@ -337,6 +355,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground, cinematicBackground = cinematicBackground,
tabLayout = tabLayout, tabLayout = tabLayout,
episodeCardStyle = episodeCardStyle, episodeCardStyle = episodeCardStyle,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
) )
} }
@ -348,6 +367,7 @@ object MetaScreenSettingsRepository {
cinematicBackground = cinematicBackground, cinematicBackground = cinematicBackground,
tabLayout = tabLayout, tabLayout = tabLayout,
episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle), episodeCardStyle = MetaEpisodeCardStyle.persist(episodeCardStyle),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
), ),
), ),
) )

View file

@ -45,6 +45,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -90,6 +91,7 @@ fun DetailSeriesContent(
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(), progressByVideoId: Map<String, WatchProgressEntry> = emptyMap(),
watchedKeys: Set<String> = emptySet(), watchedKeys: Set<String> = emptySet(),
blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null, onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null, onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
) { ) {
@ -276,6 +278,7 @@ fun DetailSeriesContent(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
fallbackImage = meta.background ?: meta.poster, fallbackImage = meta.background ?: meta.poster,
progressByVideoId = progressByVideoId, progressByVideoId = progressByVideoId,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
preferredEpisodeNumber = preferredEpisodeNumber, preferredEpisodeNumber = preferredEpisodeNumber,
onEpisodeClick = onEpisodeClick, onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress, onEpisodeLongPress = onEpisodeLongPress,
@ -295,13 +298,14 @@ fun DetailSeriesContent(
video = episode, video = episode,
fallbackImage = meta.background ?: meta.poster, fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId], progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched( WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
metaType = meta.type, metaType = meta.type,
metaId = meta.id, metaId = meta.id,
episode = episode, episode = episode,
), ),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
sizing = sizing, sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) }, onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -553,6 +557,7 @@ private fun EpisodeHorizontalRow(
watchedKeys: Set<String>, watchedKeys: Set<String>,
fallbackImage: String?, fallbackImage: String?,
progressByVideoId: Map<String, WatchProgressEntry>, progressByVideoId: Map<String, WatchProgressEntry>,
blurUnwatchedEpisodes: Boolean,
preferredEpisodeNumber: Int? = null, preferredEpisodeNumber: Int? = null,
onEpisodeClick: ((MetaVideo) -> Unit)?, onEpisodeClick: ((MetaVideo) -> Unit)?,
onEpisodeLongPress: ((MetaVideo) -> Unit)?, onEpisodeLongPress: ((MetaVideo) -> Unit)?,
@ -597,13 +602,14 @@ private fun EpisodeHorizontalRow(
video = episode, video = episode,
fallbackImage = fallbackImage, fallbackImage = fallbackImage,
progressEntry = progressByVideoId[episodeVideoId], progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true || isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched( WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys, watchedKeys = watchedKeys,
metaType = metaType, metaType = metaType,
metaId = parentMetaId, metaId = parentMetaId,
episode = episode, episode = episode,
), ),
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
metrics = rowMetrics, metrics = rowMetrics,
onClick = { onEpisodeClick?.invoke(episode) }, onClick = { onEpisodeClick?.invoke(episode) },
onLongPress = { onEpisodeLongPress?.invoke(episode) }, onLongPress = { onEpisodeLongPress?.invoke(episode) },
@ -619,6 +625,7 @@ private fun EpisodeHorizontalCard(
fallbackImage: String?, fallbackImage: String?,
progressEntry: WatchProgressEntry?, progressEntry: WatchProgressEntry?,
isWatched: Boolean, isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
metrics: EpisodeHorizontalCardMetrics, metrics: EpisodeHorizontalCardMetrics,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongPress: (() -> Unit)? = null, onLongPress: (() -> Unit)? = null,
@ -642,11 +649,14 @@ private fun EpisodeHorizontalCard(
), ),
) { ) {
val imageUrl = video.thumbnail ?: fallbackImage val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = video.title, contentDescription = video.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
@ -889,6 +899,7 @@ private fun EpisodeListCard(
fallbackImage: String?, fallbackImage: String?,
progressEntry: WatchProgressEntry?, progressEntry: WatchProgressEntry?,
isWatched: Boolean, isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
sizing: SeriesContentSizing, sizing: SeriesContentSizing,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
@ -923,11 +934,14 @@ private fun EpisodeListCard(
.clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)), .clip(RoundedCornerShape(topStart = sizing.cardRadius, bottomStart = sizing.cardRadius)),
) { ) {
val imageUrl = video.thumbnail ?: fallbackImage val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = video.title, contentDescription = video.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} else { } else {

View file

@ -166,6 +166,9 @@ fun HomeScreen(
) )
} }
} }
val completedSeriesContentIds = remember(completedSeriesCandidates) {
completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
}
val visibleContinueWatchingEntries = remember( val visibleContinueWatchingEntries = remember(
effectiveWatchProgressEntries, effectiveWatchProgressEntries,
latestCompletedBySeries, latestCompletedBySeries,
@ -181,8 +184,21 @@ fun HomeScreen(
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) } var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) { val cachedNextUpItems = remember(
cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys,
completedSeriesContentIds,
isTraktProgressActive,
watchedUiState.isLoaded,
) {
cachedSnapshots.first.mapNotNull { cached -> cachedSnapshots.first.mapNotNull { cached ->
if (
!isTraktProgressActive &&
watchedUiState.isLoaded &&
cached.contentId !in completedSeriesContentIds
) {
return@mapNotNull null
}
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) { if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
return@mapNotNull null return@mapNotNull null
} }
@ -431,6 +447,7 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding, sectionPadding = homeSectionPadding,
layout = continueWatchingLayout, layout = continueWatchingLayout,
@ -454,6 +471,7 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding, sectionPadding = homeSectionPadding,
layout = continueWatchingLayout, layout = continueWatchingLayout,
@ -496,6 +514,7 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp), modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding, sectionPadding = homeSectionPadding,
layout = continueWatchingLayout, layout = continueWatchingLayout,
@ -584,6 +603,13 @@ internal fun buildHomeContinueWatchingItems(
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(), cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>, nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
): List<ContinueWatchingItem> { ): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
return buildList { return buildList {
addAll( addAll(
visibleEntries.map { entry -> visibleEntries.map { entry ->
@ -596,7 +622,8 @@ internal fun buildHomeContinueWatchingItems(
}, },
) )
addAll( addAll(
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) -> nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
HomeContinueWatchingCandidate( HomeContinueWatchingCandidate(
lastUpdatedEpochMs = lastUpdatedEpochMs, lastUpdatedEpochMs = lastUpdatedEpochMs,
item = item, item = item,
@ -610,10 +637,13 @@ internal fun buildHomeContinueWatchingItems(
.thenByDescending { it.isProgressEntry }, .thenByDescending { it.isProgressEntry },
) )
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() } .filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { it.item.videoId } .distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
.map(HomeContinueWatchingCandidate::item) .map(HomeContinueWatchingCandidate::item)
} }
private fun String?.isSeriesTypeForContinueWatching(): Boolean =
equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)
private data class CompletedSeriesCandidate( private data class CompletedSeriesCandidate(
val content: WatchingContentRef, val content: WatchingContentRef,
val seasonNumber: Int, val seasonNumber: Int,

View file

@ -27,6 +27,7 @@ import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -54,6 +55,7 @@ private fun continueWatchingProgressPercent(progressFraction: Float): Int =
internal fun HomeContinueWatchingSection( internal fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
blurNextUp: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sectionPadding: Dp? = null, sectionPadding: Dp? = null,
layout: ContinueWatchingLayout? = null, layout: ContinueWatchingLayout? = null,
@ -66,6 +68,7 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent( HomeContinueWatchingSectionContent(
items = items, items = items,
style = style, style = style,
blurNextUp = blurNextUp,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding, sectionPadding = sectionPadding,
layout = layout, layout = layout,
@ -77,6 +80,7 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent( HomeContinueWatchingSectionContent(
items = items, items = items,
style = style, style = style,
blurNextUp = blurNextUp,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
layout = rememberContinueWatchingLayout(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value),
@ -91,6 +95,7 @@ internal fun HomeContinueWatchingSection(
private fun HomeContinueWatchingSectionContent( private fun HomeContinueWatchingSectionContent(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
blurNextUp: Boolean,
modifier: Modifier, modifier: Modifier,
sectionPadding: Dp, sectionPadding: Dp,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
@ -110,12 +115,14 @@ private fun HomeContinueWatchingSectionContent(
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item, item = item,
layout = layout, layout = layout,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } }, onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } },
) )
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item, item = item,
layout = layout, layout = layout,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } }, onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } },
) )
@ -273,6 +280,7 @@ private fun PosterCardPreview() {
private fun ContinueWatchingWideCard( private fun ContinueWatchingWideCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
blurNextUp: Boolean,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
) { ) {
@ -293,10 +301,16 @@ private fun ContinueWatchingWideCard(
onLongClick = onLongClick, onLongClick = onLongClick,
), ),
) { ) {
val artworkUrl = item.poster ?: item.background ?: item.imageUrl val shouldBlurArtwork = blurNextUp && item.isNextUp
val artworkUrl = if (shouldBlurArtwork) {
item.episodeThumbnail ?: item.imageUrl ?: item.background ?: item.poster
} else {
item.poster ?: item.background ?: item.imageUrl
}
ArtworkPanel( ArtworkPanel(
imageUrl = artworkUrl, imageUrl = artworkUrl,
width = layout.widePosterStripWidth, width = layout.widePosterStripWidth,
blurred = shouldBlurArtwork,
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
) )
Column( Column(
@ -384,6 +398,7 @@ private fun ContinueWatchingWideCard(
private fun ContinueWatchingPosterCard( private fun ContinueWatchingPosterCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
blurNextUp: Boolean,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
) { ) {
@ -404,12 +419,19 @@ private fun ContinueWatchingPosterCard(
) )
.posterCardClickable(onClick = onClick, onLongClick = onLongClick), .posterCardClickable(onClick = onClick, onLongClick = onLongClick),
) { ) {
val imageUrl = item.poster ?: item.imageUrl val shouldBlurArtwork = blurNextUp && item.isNextUp
val imageUrl = if (shouldBlurArtwork) {
item.episodeThumbnail ?: item.imageUrl ?: item.poster
} else {
item.poster ?: item.imageUrl
}
if (imageUrl != null) { if (imageUrl != null) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = item.title, contentDescription = item.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
@ -489,6 +511,7 @@ private fun ContinueWatchingPosterCard(
private fun ArtworkPanel( private fun ArtworkPanel(
imageUrl: String?, imageUrl: String?,
width: Dp, width: Dp,
blurred: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Box(
@ -500,7 +523,9 @@ private fun ArtworkPanel(
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }

View file

@ -48,6 +48,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
@ -60,6 +61,9 @@ import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.application.WatchingState
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource
fun PlayerEpisodesPanel( fun PlayerEpisodesPanel(
visible: Boolean, visible: Boolean,
episodes: List<MetaVideo>, episodes: List<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
currentSeason: Int?, currentSeason: Int?,
currentEpisode: Int?, currentEpisode: Int?,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
// episode stream sub-view state // episode stream sub-view state
episodeStreamsState: EpisodeStreamsPanelState, episodeStreamsState: EpisodeStreamsPanelState,
onSeasonSelected: (Int) -> Unit, onSeasonSelected: (Int) -> Unit,
@ -134,8 +143,13 @@ fun PlayerEpisodesPanel(
} else { } else {
EpisodesListSubView( EpisodesListSubView(
episodes = episodes, episodes = episodes,
parentMetaType = parentMetaType,
parentMetaId = parentMetaId,
currentSeason = currentSeason, currentSeason = currentSeason,
currentEpisode = currentEpisode, currentEpisode = currentEpisode,
progressByVideoId = progressByVideoId,
watchedKeys = watchedKeys,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onSeasonSelected = onSeasonSelected, onSeasonSelected = onSeasonSelected,
onEpisodeSelected = onEpisodeSelected, onEpisodeSelected = onEpisodeSelected,
onDismiss = onDismiss, onDismiss = onDismiss,
@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState(
@Composable @Composable
private fun EpisodesListSubView( private fun EpisodesListSubView(
episodes: List<MetaVideo>, episodes: List<MetaVideo>,
parentMetaType: String,
parentMetaId: String,
currentSeason: Int?, currentSeason: Int?,
currentEpisode: Int?, currentEpisode: Int?,
progressByVideoId: Map<String, WatchProgressEntry>,
watchedKeys: Set<String>,
blurUnwatchedEpisodes: Boolean,
onSeasonSelected: (Int) -> Unit, onSeasonSelected: (Int) -> Unit,
onEpisodeSelected: (MetaVideo) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@ -296,9 +315,24 @@ private fun EpisodesListSubView(
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" }, key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
) { _, episode -> ) { _, episode ->
val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
val isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = parentMetaType,
metaId = parentMetaId,
episode = episode,
)
EpisodeRow( EpisodeRow(
episode = episode, episode = episode,
isCurrent = isCurrent, isCurrent = isCurrent,
isWatched = isWatched,
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onClick = { onEpisodeSelected(episode) }, onClick = { onEpisodeSelected(episode) },
) )
} }
@ -311,9 +345,12 @@ private fun EpisodesListSubView(
private fun EpisodeRow( private fun EpisodeRow(
episode: MetaVideo, episode: MetaVideo,
isCurrent: Boolean, isCurrent: Boolean,
isWatched: Boolean,
blurUnwatchedEpisodes: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
Row( Row(
modifier = Modifier modifier = Modifier
@ -342,7 +379,8 @@ private fun EpisodeRow(
modifier = Modifier modifier = Modifier
.width(80.dp) .width(80.dp)
.height(48.dp) .height(48.dp)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp))
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.downloads.DownloadItem import com.nuvio.app.features.downloads.DownloadItem
import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsRepository
@ -55,6 +56,7 @@ import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository
@ -143,6 +145,18 @@ fun PlayerScreen(
PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState PlayerSettingsRepository.uiState
}.collectAsStateWithLifecycle() }.collectAsStateWithLifecycle()
val metaScreenSettingsUiState by remember {
MetaScreenSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val watchedUiState by remember {
WatchedRepository.ensureLoaded()
WatchedRepository.uiState
}.collectAsStateWithLifecycle()
val watchProgressUiState by remember {
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
BoxWithConstraints( BoxWithConstraints(
modifier = modifier modifier = modifier
@ -1799,8 +1813,13 @@ fun PlayerScreen(
PlayerEpisodesPanel( PlayerEpisodesPanel(
visible = showEpisodesPanel, visible = showEpisodesPanel,
episodes = allEpisodes, episodes = allEpisodes,
parentMetaType = parentMetaType,
parentMetaId = parentMetaId,
currentSeason = activeSeasonNumber, currentSeason = activeSeasonNumber,
currentEpisode = activeEpisodeNumber, currentEpisode = activeEpisodeNumber,
progressByVideoId = watchProgressUiState.byVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
episodeStreamsState = episodeStreamsPanelState.copy( episodeStreamsState = episodeStreamsPanelState.copy(
streamsUiState = episodeStreamsRepoState, streamsUiState = episodeStreamsRepoState,
), ),

View file

@ -28,6 +28,8 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title
import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style
import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
@ -48,6 +50,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isVisible: Boolean, isVisible: Boolean,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean, upNextFromFurthestEpisode: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean, showResumePromptOnLaunch: Boolean,
) { ) {
item { item {
@ -91,6 +94,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isTablet = isTablet, isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode, onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode,
) )
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_continue_watching_blur_next_up_title),
description = stringResource(Res.string.settings_continue_watching_blur_next_up_description),
checked = blurNextUp,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp,
)
} }
} }
} }

View file

@ -78,6 +78,8 @@ import nuvio.composeapp.generated.resources.settings_meta_episode_style_list
import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description
import nuvio.composeapp.generated.resources.settings_meta_episodes import nuvio.composeapp.generated.resources.settings_meta_episodes
import nuvio.composeapp.generated.resources.settings_meta_episodes_description import nuvio.composeapp.generated.resources.settings_meta_episodes_description
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description
import nuvio.composeapp.generated.resources.settings_meta_group_label import nuvio.composeapp.generated.resources.settings_meta_group_label
import nuvio.composeapp.generated.resources.settings_meta_more_like_this import nuvio.composeapp.generated.resources.settings_meta_more_like_this
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
selectedStyle = uiState.episodeCardStyle, selectedStyle = uiState.episodeCardStyle,
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle, onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
) )
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_meta_blur_unwatched_episodes),
description = stringResource(Res.string.settings_meta_blur_unwatched_episodes_description),
checked = uiState.blurUnwatchedEpisodes,
isTablet = isTablet,
onCheckedChange = { MetaScreenSettingsRepository.setBlurUnwatchedEpisodes(it) },
)
} }
} }
} }

View file

@ -127,6 +127,7 @@ fun ContinueWatchingSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible, isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style, style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
} }

View file

@ -376,6 +376,7 @@ private fun MobileSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible, isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style, style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
@ -614,6 +615,7 @@ private fun TabletSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible, isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style, style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
) )
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -13,6 +14,8 @@ private data class StoredContinueWatchingPreferences(
val isVisible: Boolean = true, val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true, val upNextFromFurthestEpisode: Boolean = true,
@SerialName("blur_continue_watching_next_up")
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(), val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true, val showResumePromptOnLaunch: Boolean = true,
) )
@ -46,6 +49,7 @@ object ContinueWatchingPreferencesRepository {
isVisible: Boolean, isVisible: Boolean,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean, upNextFromFurthestEpisode: Boolean,
blurNextUp: Boolean = false,
dismissedNextUpKeys: Set<String>, dismissedNextUpKeys: Set<String>,
) { ) {
ensureLoaded() ensureLoaded()
@ -53,6 +57,7 @@ object ContinueWatchingPreferencesRepository {
isVisible = isVisible, isVisible = isVisible,
style = style, style = style,
upNextFromFurthestEpisode = upNextFromFurthestEpisode, upNextFromFurthestEpisode = upNextFromFurthestEpisode,
blurNextUp = blurNextUp,
dismissedNextUpKeys = dismissedNextUpKeys dismissedNextUpKeys = dismissedNextUpKeys
.map(String::trim) .map(String::trim)
.filter(String::isNotBlank) .filter(String::isNotBlank)
@ -79,6 +84,7 @@ object ContinueWatchingPreferencesRepository {
isVisible = stored.isVisible, isVisible = stored.isVisible,
style = stored.style, style = stored.style,
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode, upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys, dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch, showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
) )
@ -105,6 +111,12 @@ object ContinueWatchingPreferencesRepository {
persist() persist()
} }
fun setBlurNextUp(enabled: Boolean) {
ensureLoaded()
_uiState.value = _uiState.value.copy(blurNextUp = enabled)
persist()
}
fun addDismissedNextUpKey(key: String) { fun addDismissedNextUpKey(key: String) {
ensureLoaded() ensureLoaded()
val normalizedKey = key.trim() val normalizedKey = key.trim()
@ -139,6 +151,7 @@ object ContinueWatchingPreferencesRepository {
isVisible = _uiState.value.isVisible, isVisible = _uiState.value.isVisible,
style = _uiState.value.style, style = _uiState.value.style,
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode, upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
), ),

View file

@ -163,6 +163,7 @@ data class ContinueWatchingPreferencesUiState(
val isVisible: Boolean = true, val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true, val upNextFromFurthestEpisode: Boolean = true,
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(), val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true, val showResumePromptOnLaunch: Boolean = true,
) )

View file

@ -61,6 +61,29 @@ class HomeScreenTest {
assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle)
} }
@Test
fun `build home continue watching items suppresses next up when series has in progress resume`() {
val inProgress = progressEntry(
videoId = "show:1:4",
title = "Show",
episodeNumber = 4,
episodeTitle = "Current",
lastUpdatedEpochMs = 200L,
)
val nextUp = continueWatchingItem(
videoId = "show:1:5",
subtitle = "Up Next • S1E5 • Next",
)
val result = buildHomeContinueWatchingItems(
visibleEntries = listOf(inProgress),
nextUpItemsBySeries = mapOf("show" to (500L to nextUp)),
)
assertEquals(listOf("show:1:4"), result.map(ContinueWatchingItem::videoId))
assertEquals("S1E4 • Current", result.single().subtitle)
}
@Test @Test
fun `Trakt continue watching window filters old progress only when Trakt source is active`() { fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
val oldEntry = progressEntry( val oldEntry = progressEntry(