mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: blur unwatched episode
This commit is contained in:
parent
1993251087
commit
2af53f416d
15 changed files with 226 additions and 12 deletions
|
|
@ -506,6 +506,8 @@
|
|||
<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_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_on_launch">ON LAUNCH</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_episodes">Episodes</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_more_like_this">More like this</string>
|
||||
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>
|
||||
|
|
|
|||
|
|
@ -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<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<String, WatchProgressEntry> = emptyMap(),
|
||||
watchedKeys: Set<String> = 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<String>,
|
||||
fallbackImage: String?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
|
||||
|
||||
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 ->
|
||||
if (
|
||||
!isTraktProgressActive &&
|
||||
watchedUiState.isLoaded &&
|
||||
cached.contentId !in completedSeriesContentIds
|
||||
) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
|
@ -431,6 +447,7 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -454,6 +471,7 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -496,6 +514,7 @@ fun HomeScreen(
|
|||
HomeContinueWatchingSection(
|
||||
items = continueWatchingItems,
|
||||
style = continueWatchingPreferences.style,
|
||||
blurNextUp = continueWatchingPreferences.blurNextUp,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
sectionPadding = homeSectionPadding,
|
||||
layout = continueWatchingLayout,
|
||||
|
|
@ -584,6 +603,13 @@ internal fun buildHomeContinueWatchingItems(
|
|||
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
|
||||
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
|
||||
): List<ContinueWatchingItem> {
|
||||
val inProgressSeriesIds = visibleEntries
|
||||
.asSequence()
|
||||
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
|
||||
.map { entry -> entry.parentMetaId }
|
||||
.filter(String::isNotBlank)
|
||||
.toSet()
|
||||
|
||||
return buildList {
|
||||
addAll(
|
||||
visibleEntries.map { entry ->
|
||||
|
|
@ -596,7 +622,8 @@ internal fun buildHomeContinueWatchingItems(
|
|||
},
|
||||
)
|
||||
addAll(
|
||||
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
|
||||
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
|
||||
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
|
||||
HomeContinueWatchingCandidate(
|
||||
lastUpdatedEpochMs = lastUpdatedEpochMs,
|
||||
item = item,
|
||||
|
|
@ -610,10 +637,13 @@ internal fun buildHomeContinueWatchingItems(
|
|||
.thenByDescending { it.isProgressEntry },
|
||||
)
|
||||
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
|
||||
.distinctBy { it.item.videoId }
|
||||
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
|
||||
.map(HomeContinueWatchingCandidate::item)
|
||||
}
|
||||
|
||||
private fun String?.isSeriesTypeForContinueWatching(): Boolean =
|
||||
equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)
|
||||
|
||||
private data class CompletedSeriesCandidate(
|
||||
val content: WatchingContentRef,
|
||||
val seasonNumber: Int,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import androidx.compose.material3.contentColorFor
|
|||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
|
|
@ -54,6 +55,7 @@ private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
|||
internal fun HomeContinueWatchingSection(
|
||||
items: List<ContinueWatchingItem>,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
blurNextUp: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
sectionPadding: Dp? = null,
|
||||
layout: ContinueWatchingLayout? = null,
|
||||
|
|
@ -66,6 +68,7 @@ internal fun HomeContinueWatchingSection(
|
|||
HomeContinueWatchingSectionContent(
|
||||
items = items,
|
||||
style = style,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
sectionPadding = sectionPadding,
|
||||
layout = layout,
|
||||
|
|
@ -77,6 +80,7 @@ internal fun HomeContinueWatchingSection(
|
|||
HomeContinueWatchingSectionContent(
|
||||
items = items,
|
||||
style = style,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||
layout = rememberContinueWatchingLayout(maxWidth.value),
|
||||
|
|
@ -91,6 +95,7 @@ internal fun HomeContinueWatchingSection(
|
|||
private fun HomeContinueWatchingSectionContent(
|
||||
items: List<ContinueWatchingItem>,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
blurNextUp: Boolean,
|
||||
modifier: Modifier,
|
||||
sectionPadding: Dp,
|
||||
layout: ContinueWatchingLayout,
|
||||
|
|
@ -110,12 +115,14 @@ private fun HomeContinueWatchingSectionContent(
|
|||
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
|
|
@ -273,6 +280,7 @@ private fun PosterCardPreview() {
|
|||
private fun ContinueWatchingWideCard(
|
||||
item: ContinueWatchingItem,
|
||||
layout: ContinueWatchingLayout,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
|
|
@ -293,10 +301,16 @@ private fun ContinueWatchingWideCard(
|
|||
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(
|
||||
imageUrl = artworkUrl,
|
||||
width = layout.widePosterStripWidth,
|
||||
blurred = shouldBlurArtwork,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
Column(
|
||||
|
|
@ -384,6 +398,7 @@ private fun ContinueWatchingWideCard(
|
|||
private fun ContinueWatchingPosterCard(
|
||||
item: ContinueWatchingItem,
|
||||
layout: ContinueWatchingLayout,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
|
|
@ -404,12 +419,19 @@ private fun ContinueWatchingPosterCard(
|
|||
)
|
||||
.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) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
@ -489,6 +511,7 @@ private fun ContinueWatchingPosterCard(
|
|||
private fun ArtworkPanel(
|
||||
imageUrl: String?,
|
||||
width: Dp,
|
||||
blurred: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
|
|
@ -500,7 +523,9 @@ private fun ArtworkPanel(
|
|||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ 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
|
||||
|
|
@ -60,6 +61,9 @@ import coil3.compose.AsyncImage
|
|||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
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 org.jetbrains.compose.resources.stringResource
|
||||
|
||||
|
|
@ -72,8 +76,13 @@ import org.jetbrains.compose.resources.stringResource
|
|||
fun PlayerEpisodesPanel(
|
||||
visible: Boolean,
|
||||
episodes: List<MetaVideo>,
|
||||
parentMetaType: String,
|
||||
parentMetaId: String,
|
||||
currentSeason: Int?,
|
||||
currentEpisode: Int?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
// episode stream sub-view state
|
||||
episodeStreamsState: EpisodeStreamsPanelState,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
|
|
@ -134,8 +143,13 @@ fun PlayerEpisodesPanel(
|
|||
} else {
|
||||
EpisodesListSubView(
|
||||
episodes = episodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = currentSeason,
|
||||
currentEpisode = currentEpisode,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onSeasonSelected = onSeasonSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
onDismiss = onDismiss,
|
||||
|
|
@ -158,8 +172,13 @@ data class EpisodeStreamsPanelState(
|
|||
@Composable
|
||||
private fun EpisodesListSubView(
|
||||
episodes: List<MetaVideo>,
|
||||
parentMetaType: String,
|
||||
parentMetaId: String,
|
||||
currentSeason: Int?,
|
||||
currentEpisode: Int?,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
onEpisodeSelected: (MetaVideo) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
|
|
@ -296,9 +315,24 @@ private fun EpisodesListSubView(
|
|||
key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" },
|
||||
) { _, episode ->
|
||||
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(
|
||||
episode = episode,
|
||||
isCurrent = isCurrent,
|
||||
isWatched = isWatched,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onClick = { onEpisodeSelected(episode) },
|
||||
)
|
||||
}
|
||||
|
|
@ -311,9 +345,12 @@ private fun EpisodesListSubView(
|
|||
private fun EpisodeRow(
|
||||
episode: MetaVideo,
|
||||
isCurrent: Boolean,
|
||||
isWatched: Boolean,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
|
@ -342,7 +379,8 @@ private fun EpisodeRow(
|
|||
modifier = Modifier
|
||||
.width(80.dp)
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
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.downloads.DownloadItem
|
||||
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.StreamsUiState
|
||||
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.WatchProgressPlaybackSession
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
|
|
@ -143,6 +145,18 @@ fun PlayerScreen(
|
|||
PlayerSettingsRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.uiState
|
||||
}.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(
|
||||
modifier = modifier
|
||||
|
|
@ -1799,8 +1813,13 @@ fun PlayerScreen(
|
|||
PlayerEpisodesPanel(
|
||||
visible = showEpisodesPanel,
|
||||
episodes = allEpisodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = activeSeasonNumber,
|
||||
currentEpisode = activeEpisodeNumber,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
episodeStreamsState = episodeStreamsPanelState.copy(
|
||||
streamsUiState = episodeStreamsRepoState,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
|
|||
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_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_on_launch
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior
|
||||
|
|
@ -48,6 +50,7 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isVisible: Boolean,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
upNextFromFurthestEpisode: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
showResumePromptOnLaunch: Boolean,
|
||||
) {
|
||||
item {
|
||||
|
|
@ -91,6 +94,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
|
|||
isTablet = isTablet,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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_episodes
|
||||
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_more_like_this
|
||||
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
|
||||
|
|
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
|
|||
selectedStyle = uiState.episodeCardStyle,
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ fun ContinueWatchingSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -376,6 +376,7 @@ private fun MobileSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||
|
|
@ -614,6 +615,7 @@ private fun TabletSettingsScreen(
|
|||
isVisible = continueWatchingPreferencesUiState.isVisible,
|
||||
style = continueWatchingPreferencesUiState.style,
|
||||
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
|
||||
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
|
||||
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
|
||||
)
|
||||
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -13,6 +14,8 @@ private data class StoredContinueWatchingPreferences(
|
|||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
@SerialName("blur_continue_watching_next_up")
|
||||
val blurNextUp: Boolean = false,
|
||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||
val showResumePromptOnLaunch: Boolean = true,
|
||||
)
|
||||
|
|
@ -46,6 +49,7 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible: Boolean,
|
||||
style: ContinueWatchingSectionStyle,
|
||||
upNextFromFurthestEpisode: Boolean,
|
||||
blurNextUp: Boolean = false,
|
||||
dismissedNextUpKeys: Set<String>,
|
||||
) {
|
||||
ensureLoaded()
|
||||
|
|
@ -53,6 +57,7 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = isVisible,
|
||||
style = style,
|
||||
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
|
||||
blurNextUp = blurNextUp,
|
||||
dismissedNextUpKeys = dismissedNextUpKeys
|
||||
.map(String::trim)
|
||||
.filter(String::isNotBlank)
|
||||
|
|
@ -79,6 +84,7 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = stored.isVisible,
|
||||
style = stored.style,
|
||||
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
|
||||
blurNextUp = stored.blurNextUp,
|
||||
dismissedNextUpKeys = stored.dismissedNextUpKeys,
|
||||
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
|
||||
)
|
||||
|
|
@ -105,6 +111,12 @@ object ContinueWatchingPreferencesRepository {
|
|||
persist()
|
||||
}
|
||||
|
||||
fun setBlurNextUp(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
_uiState.value = _uiState.value.copy(blurNextUp = enabled)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun addDismissedNextUpKey(key: String) {
|
||||
ensureLoaded()
|
||||
val normalizedKey = key.trim()
|
||||
|
|
@ -139,6 +151,7 @@ object ContinueWatchingPreferencesRepository {
|
|||
isVisible = _uiState.value.isVisible,
|
||||
style = _uiState.value.style,
|
||||
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
|
||||
blurNextUp = _uiState.value.blurNextUp,
|
||||
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
|
||||
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ data class ContinueWatchingPreferencesUiState(
|
|||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
val blurNextUp: Boolean = false,
|
||||
val dismissedNextUpKeys: Set<String> = emptySet(),
|
||||
val showResumePromptOnLaunch: Boolean = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,6 +61,29 @@ class HomeScreenTest {
|
|||
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
|
||||
fun `Trakt continue watching window filters old progress only when Trakt source is active`() {
|
||||
val oldEntry = progressEntry(
|
||||
|
|
|
|||
Loading…
Reference in a new issue