mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 07:21:58 +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_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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue