Merge branch 'parity' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-06 15:24:00 +05:30
commit 9e9db390f9
29 changed files with 869 additions and 77 deletions

View file

@ -506,6 +506,10 @@
<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_show_unaired_next_up_description">Include upcoming episodes in Continue Watching before they air.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Show Unaired Next Up Episodes</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>
@ -518,6 +522,8 @@
<string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string> <string name="settings_continue_watching_style_wide_description">Info-dense horizontal card</string>
<string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string> <string name="settings_continue_watching_up_next_description">Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.</string>
<string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string> <string name="settings_continue_watching_up_next_title">Up Next From Furthest Episode</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Use episode thumbnails as default image. When disabled, uses backdrop.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">Use Episode Thumbnails in Continue Watching</string>
<string name="settings_content_discovery_section_home">HOME</string> <string name="settings_content_discovery_section_home">HOME</string>
<string name="settings_content_discovery_section_sources">SOURCES</string> <string name="settings_content_discovery_section_sources">SOURCES</string>
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string> <string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
@ -557,6 +563,8 @@
<string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string> <string name="settings_meta_episode_style_list_description">Detail-first stacked cards</string>
<string name="settings_meta_episodes">Episodes</string> <string name="settings_meta_episodes">Episodes</string>
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string> <string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
<string name="settings_meta_blur_unwatched_episodes">Blur Unwatched Episodes</string>
<string name="settings_meta_blur_unwatched_episodes_description">Blur episode thumbnails until watched to avoid spoilers.</string>
<string name="settings_meta_group_label">Group %1$d</string> <string name="settings_meta_group_label">Group %1$d</string>
<string name="settings_meta_more_like_this">More like this</string> <string name="settings_meta_more_like_this">More like this</string>
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string> <string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.details package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
@ -206,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
content = WatchingContentRef(type = type, id = id), content = WatchingContentRef(type = type, id = id),
seasonNumber = season, seasonNumber = season,
episodeNumber = episode, episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
) )
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction = private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =

View file

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

View file

@ -40,6 +40,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching
import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.nextUpDismissKey
import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
@ -49,6 +50,7 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.home.components.HomeCollectionRowSection
@ -166,6 +168,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,11 +186,28 @@ 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,
continueWatchingPreferences.showUnairedNextUp,
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
} }
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
return@mapNotNull null
}
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
cached.contentId to (cached.sortTimestamp to item) cached.contentId to (cached.sortTimestamp to item)
}.toMap() }.toMap()
@ -264,7 +286,11 @@ fun HomeScreen(
HomeCatalogSettingsRepository.syncCollections(collections) HomeCatalogSettingsRepository.syncCollections(collections)
} }
LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) { LaunchedEffect(
completedSeriesCandidates,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
) {
if (completedSeriesCandidates.isEmpty()) { if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap() nextUpItemsBySeries = emptyMap()
return@LaunchedEffect return@LaunchedEffect
@ -285,7 +311,7 @@ fun HomeScreen(
seasonNumber = completedEntry.seasonNumber, seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber, episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate, todayIsoDate = todayIsoDate,
showUnairedNextUp = isTraktProgressActive, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
) ?: return@withPermit null ) ?: return@withPermit null
val item = completedEntry.toContinueWatchingSeed(meta) val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode) .toUpNextContinueWatchingItem(nextEpisode)
@ -313,6 +339,10 @@ fun HomeScreen(
episodeTitle = item.episodeTitle, episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail, episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription, pauseDescription = item.pauseDescription,
released = item.released,
hasAired = item.released?.let { released ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
} ?: true,
lastWatched = pair.first, lastWatched = pair.first,
sortTimestamp = pair.first, sortTimestamp = pair.first,
seedSeason = item.nextUpSeedSeasonNumber, seedSeason = item.nextUpSeedSeasonNumber,
@ -431,6 +461,8 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
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 +486,8 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
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 +530,8 @@ fun HomeScreen(
HomeContinueWatchingSection( HomeContinueWatchingSection(
items = continueWatchingItems, items = continueWatchingItems,
style = continueWatchingPreferences.style, style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
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 +620,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 +639,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,7 +654,7 @@ 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)
} }
@ -668,6 +712,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
episodeTitle = episodeTitle, episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail, episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription, pauseDescription = pauseDescription,
released = released,
isNextUp = true, isNextUp = true,
nextUpSeedSeasonNumber = seedSeason, nextUpSeedSeasonNumber = seedSeason,
nextUpSeedEpisodeNumber = seedEpisode, nextUpSeedEpisodeNumber = seedEpisode,
@ -734,5 +779,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
episodeTitle = episodeTitle ?: fallback.episodeTitle, episodeTitle = episodeTitle ?: fallback.episodeTitle,
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
pauseDescription = pauseDescription ?: fallback.pauseDescription, pauseDescription = pauseDescription ?: fallback.pauseDescription,
released = released ?: fallback.released,
) )
} }

View file

@ -27,6 +27,7 @@ import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -50,10 +51,44 @@ import org.jetbrains.compose.resources.stringResource
private fun continueWatchingProgressPercent(progressFraction: Float): Int = private fun continueWatchingProgressPercent(progressFraction: Float): Int =
(progressFraction * 100f).roundToInt().coerceIn(1, 99) (progressFraction * 100f).roundToInt().coerceIn(1, 99)
private fun ContinueWatchingItem.continueWatchingArtworkUrl(
useEpisodeThumbnails: Boolean,
): String? = when {
isNextUp && useEpisodeThumbnails -> firstNonBlank(
episodeThumbnail,
background,
poster,
imageUrl,
)
isNextUp -> firstNonBlank(
background,
poster,
episodeThumbnail,
imageUrl,
)
useEpisodeThumbnails -> firstNonBlank(
episodeThumbnail,
background,
poster,
imageUrl,
)
else -> firstNonBlank(
background,
poster,
episodeThumbnail,
imageUrl,
)
}
private fun firstNonBlank(vararg values: String?): String? =
values.firstOrNull { value -> !value.isNullOrBlank() }?.trim()
@Composable @Composable
internal fun HomeContinueWatchingSection( internal fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
useEpisodeThumbnails: Boolean = true,
blurNextUp: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sectionPadding: Dp? = null, sectionPadding: Dp? = null,
layout: ContinueWatchingLayout? = null, layout: ContinueWatchingLayout? = null,
@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent( HomeContinueWatchingSectionContent(
items = items, items = items,
style = style, style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding, sectionPadding = sectionPadding,
layout = layout, layout = layout,
@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent( HomeContinueWatchingSectionContent(
items = items, items = items,
style = style, style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
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 +130,8 @@ internal fun HomeContinueWatchingSection(
private fun HomeContinueWatchingSectionContent( private fun HomeContinueWatchingSectionContent(
items: List<ContinueWatchingItem>, items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
modifier: Modifier, modifier: Modifier,
sectionPadding: Dp, sectionPadding: Dp,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent(
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item, item = item,
layout = layout, layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
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,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } }, onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } },
) )
@ -273,6 +318,8 @@ private fun PosterCardPreview() {
private fun ContinueWatchingWideCard( private fun ContinueWatchingWideCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
) { ) {
@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard(
onLongClick = onLongClick, onLongClick = onLongClick,
), ),
) { ) {
val artworkUrl = item.poster ?: item.background ?: item.imageUrl val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
ArtworkPanel( ArtworkPanel(
imageUrl = artworkUrl, imageUrl = artworkUrl,
width = layout.widePosterStripWidth, width = layout.widePosterStripWidth,
blurred = shouldBlurArtwork,
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
) )
Column( Column(
@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard(
private fun ContinueWatchingPosterCard( private fun ContinueWatchingPosterCard(
item: ContinueWatchingItem, item: ContinueWatchingItem,
layout: ContinueWatchingLayout, layout: ContinueWatchingLayout,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?, onLongClick: (() -> Unit)?,
) { ) {
@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard(
) )
.posterCardClickable(onClick = onClick, onLongClick = onLongClick), .posterCardClickable(onClick = onClick, onLongClick = onLongClick),
) { ) {
val imageUrl = item.poster ?: item.imageUrl val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
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 +543,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 +555,9 @@ private fun ArtworkPanel(
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }

View file

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

View file

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

View file

@ -28,6 +28,10 @@ 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_show_unaired_next_up_description
import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_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
@ -40,6 +44,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_style_wid
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description
import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_description
import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_title
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isVisible: Boolean, isVisible: Boolean,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean, upNextFromFurthestEpisode: Boolean,
useEpisodeThumbnails: Boolean,
showUnairedNextUp: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean, showResumePromptOnLaunch: Boolean,
) { ) {
item { item {
@ -84,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isTablet = isTablet, isTablet = isTablet,
) { ) {
SettingsGroup(isTablet = isTablet) { SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title),
description = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description),
checked = useEpisodeThumbnails,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setUseEpisodeThumbnails,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow( SettingsSwitchRow(
title = stringResource(Res.string.settings_continue_watching_up_next_title), title = stringResource(Res.string.settings_continue_watching_up_next_title),
description = stringResource(Res.string.settings_continue_watching_up_next_description), description = stringResource(Res.string.settings_continue_watching_up_next_description),
@ -91,6 +108,24 @@ 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_show_unaired_next_up_title),
description = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description),
checked = showUnairedNextUp,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setShowUnairedNextUp,
)
if (useEpisodeThumbnails) {
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_continue_watching_blur_next_up_title),
description = stringResource(Res.string.settings_continue_watching_blur_next_up_description),
checked = blurNextUp,
isTablet = isTablet,
onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp,
)
}
} }
} }
} }

View file

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

View file

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

View file

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

View file

@ -4,8 +4,13 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
private const val BASE_URL = "https://api.trakt.tv" private const val BASE_URL = "https://api.trakt.tv"
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 80f private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f
private const val HISTORY_LIMIT = 250 private const val HISTORY_LIMIT = 250
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
private const val METADATA_FETCH_CONCURRENCY = 5 private const val METADATA_FETCH_CONCURRENCY = 5
@ -113,8 +118,8 @@ object TraktProgressRepository {
} }
scope.launch { scope.launch {
val historyEntries = runCatching { val completedEntries = runCatching {
fetchHistoryEntries(headers) fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers)
}.onFailure { error -> }.onFailure { error ->
if (error is CancellationException) throw error if (error is CancellationException) throw error
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
@ -122,7 +127,7 @@ object TraktProgressRepository {
if (!isLatestRefreshRequest(requestId)) return@launch if (!isLatestRefreshRequest(requestId)) return@launch
val merged = mergeNewestByVideoId(playbackEntries + historyEntries) val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
isLoading = false, isLoading = false,
@ -345,12 +350,32 @@ object TraktProgressRepository {
mergeNewestByVideoId(completedEpisodes + completedMovies) mergeNewestByVideoId(completedEpisodes + completedMovies)
} }
private suspend fun fetchWatchedShowSeedEntries(
headers: Map<String, String>,
): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
ContinueWatchingPreferencesRepository.ensureLoaded()
val useFurthestEpisode = ContinueWatchingPreferencesRepository.uiState.value.upNextFromFurthestEpisode
val payload = httpGetTextWithHeaders(
url = "$BASE_URL/sync/watched/shows",
headers = headers,
)
val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload)
watchedShows
.mapNotNull { item ->
mapWatchedShowSeed(
item = item,
useFurthestEpisode = useFurthestEpisode,
)
}
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
}
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> { private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>() val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
entries.forEach { rawEntry -> entries.forEach { rawEntry ->
val entry = rawEntry.normalizedCompletion() val entry = rawEntry.normalizedCompletion()
val existing = mergedByVideoId[entry.videoId] val existing = mergedByVideoId[entry.videoId]
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) {
mergedByVideoId[entry.videoId] = entry mergedByVideoId[entry.videoId] = entry
} }
} }
@ -360,6 +385,18 @@ object TraktProgressRepository {
.sortedByDescending { it.lastUpdatedEpochMs } .sortedByDescending { it.lastUpdatedEpochMs }
} }
private fun shouldReplaceProgressSnapshotEntry(
existing: WatchProgressEntry,
candidate: WatchProgressEntry,
): Boolean {
val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching()
val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching()
if (existingInProgress != candidateInProgress) {
return candidateInProgress
}
return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs
}
private fun mergeEntriesPreferRichMetadata( private fun mergeEntriesPreferRichMetadata(
current: List<WatchProgressEntry>, current: List<WatchProgressEntry>,
hydrated: List<WatchProgressEntry>, hydrated: List<WatchProgressEntry>,
@ -499,6 +536,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
progressPercent = progressPercent, progressPercent = progressPercent,
source = WatchProgressSourceTraktPlayback,
).normalizedCompletion() ).normalizedCompletion()
} }
@ -533,6 +571,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
progressPercent = progressPercent, progressPercent = progressPercent,
source = WatchProgressSourceTraktPlayback,
).normalizedCompletion() ).normalizedCompletion()
} }
@ -564,6 +603,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
isCompleted = true, isCompleted = true,
progressPercent = 100f, progressPercent = 100f,
source = WatchProgressSourceTraktHistory,
) )
} }
@ -583,6 +623,73 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
isCompleted = true, isCompleted = true,
progressPercent = 100f, progressPercent = 100f,
source = WatchProgressSourceTraktHistory,
)
}
private fun mapWatchedShowSeed(
item: TraktWatchedShowItem,
useFurthestEpisode: Boolean,
): WatchProgressEntry? {
val show = item.show ?: return null
val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title)
if (parentMetaId.isBlank()) return null
val completedEpisode = item.seasons.orEmpty()
.asSequence()
.filter { season -> (season.number ?: 0) > 0 }
.flatMap { season ->
val seasonNumber = season.number ?: return@flatMap emptySequence()
season.episodes.orEmpty()
.asSequence()
.filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 }
.mapNotNull { episode ->
val episodeNumber = episode.number ?: return@mapNotNull null
TraktWatchedShowEpisodeSeed(
season = seasonNumber,
episode = episodeNumber,
watchedAt = rankedTimestamp(
isoDate = episode.lastWatchedAt ?: item.lastWatchedAt,
fallbackIndex = 0,
),
)
}
}
.maxWithOrNull(
if (useFurthestEpisode) {
compareBy<TraktWatchedShowEpisodeSeed>(
{ it.season },
{ it.episode },
{ it.watchedAt },
)
} else {
compareBy<TraktWatchedShowEpisodeSeed>(
{ it.watchedAt },
{ it.season },
{ it.episode },
)
},
) ?: return null
return WatchProgressEntry(
contentType = "series",
parentMetaId = parentMetaId,
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = parentMetaId,
seasonNumber = completedEpisode.season,
episodeNumber = completedEpisode.episode,
fallbackVideoId = null,
),
title = show.title ?: parentMetaId,
seasonNumber = completedEpisode.season,
episodeNumber = completedEpisode.episode,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = completedEpisode.watchedAt,
isCompleted = true,
progressPercent = 100f,
source = WatchProgressSourceTraktShowProgress,
) )
} }
@ -597,14 +704,10 @@ object TraktProgressRepository {
} }
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long { private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
val compactDigits = isoDate isoDate
?.filter(Char::isDigit) ?.takeIf { it.isNotBlank() }
?.take(14) ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
?.takeIf { it.length >= 8 } ?.let { return it }
?.padEnd(14, '0')
?.toLongOrNull()
if (compactDigits != null) return compactDigits
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
} }
} }
@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem(
@SerialName("movie") val movie: TraktMedia? = null, @SerialName("movie") val movie: TraktMedia? = null,
) )
@Serializable
private data class TraktWatchedShowItem(
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
@SerialName("show") val show: TraktMedia? = null,
@SerialName("seasons") val seasons: List<TraktWatchedShowSeason>? = null,
)
@Serializable
private data class TraktWatchedShowSeason(
@SerialName("number") val number: Int? = null,
@SerialName("episodes") val episodes: List<TraktWatchedShowEpisode>? = null,
)
@Serializable
private data class TraktWatchedShowEpisode(
@SerialName("number") val number: Int? = null,
@SerialName("plays") val plays: Int? = null,
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
)
private data class TraktWatchedShowEpisodeSeed(
val season: Int,
val episode: Int,
val watchedAt: Long,
)
@Serializable @Serializable
private data class TraktMedia( private data class TraktMedia(
@SerialName("title") val title: String? = null, @SerialName("title") val title: String? = null,

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.watched package com.nuvio.app.features.watched
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.watchedKey import com.nuvio.app.features.watching.domain.watchedKey
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
val WatchedItem.isEpisode: Boolean val WatchedItem.isEpisode: Boolean
get() = season != null && episode != null get() = season != null && episode != null
internal fun WatchedItem.normalizedMarkedAt(): WatchedItem {
val normalized = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs)
return if (normalized == markedAtEpochMs) this else copy(markedAtEpochMs = normalized)
}
internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long {
if (value !in CompactWatchedTimestampMin..CompactWatchedTimestampMax) return value
val raw = value.toString().padStart(14, '0')
val year = raw.substring(0, 4).toIntOrNull() ?: return value
val month = raw.substring(4, 6).toIntOrNull() ?: return value
val day = raw.substring(6, 8).toIntOrNull() ?: return value
val hour = raw.substring(8, 10).toIntOrNull() ?: return value
val minute = raw.substring(10, 12).toIntOrNull() ?: return value
val second = raw.substring(12, 14).toIntOrNull() ?: return value
if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) {
return value
}
val iso = buildString {
append(year.toString().padStart(4, '0'))
append('-')
append(month.toString().padStart(2, '0'))
append('-')
append(day.toString().padStart(2, '0'))
append('T')
append(hour.toString().padStart(2, '0'))
append(':')
append(minute.toString().padStart(2, '0'))
append(':')
append(second.toString().padStart(2, '0'))
append('Z')
}
return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value
}
fun watchedItemKey( fun watchedItemKey(
type: String, type: String,
id: String, id: String,
@ -47,3 +85,5 @@ fun watchedItemKey(
episodeNumber = episode, episodeNumber = episode,
) )
private const val CompactWatchedTimestampMin = 19000101000000L
private const val CompactWatchedTimestampMax = 29991231235959L

View file

@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
@ -42,8 +45,8 @@ object WatchedRepository {
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf() private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
private fun activeSyncAdapter(): WatchedSyncAdapter = private fun activePullSyncAdapter(): WatchedSyncAdapter =
if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter
fun ensureLoaded() { fun ensureLoaded() {
if (hasLoaded) return if (hasLoaded) return
@ -72,21 +75,27 @@ object WatchedRepository {
val items = runCatching { val items = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload).items json.decodeFromString<StoredWatchedPayload>(payload).items
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
itemsByKey = items.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }.toMutableMap() itemsByKey = items
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
} }
publish() publish()
} }
suspend fun pullFromServer(profileId: Int) { suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
currentProfileId = profileId currentProfileId = profileId
runCatching { runCatching {
val serverItems = activeSyncAdapter().pull( val serverItems = activePullSyncAdapter().pull(
profileId = profileId, profileId = profileId,
pageSize = watchedItemsPageSize, pageSize = watchedItemsPageSize,
) )
itemsByKey = serverItems itemsByKey = serverItems
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap() .toMutableMap()
hasLoaded = true hasLoaded = true
@ -203,7 +212,7 @@ object WatchedRepository {
runCatching { runCatching {
if (items.isEmpty()) return@runCatching if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
activeSyncAdapter().push(profileId = profileId, items = items) pushToActiveTargets(profileId = profileId, items = items)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to push watched items" } log.e(e) { "Failed to push watched items" }
} }
@ -215,7 +224,7 @@ object WatchedRepository {
runCatching { runCatching {
if (items.isEmpty()) return@runCatching if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId val profileId = ProfileRepository.activeProfileId
activeSyncAdapter().delete(profileId = profileId, items = items) deleteFromActiveTargets(profileId = profileId, items = items)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to push watched item delete" } log.e(e) { "Failed to push watched item delete" }
} }
@ -223,7 +232,9 @@ object WatchedRepository {
} }
private fun publish() { private fun publish() {
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs } val items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs }
_uiState.value = WatchedUiState( _uiState.value = WatchedUiState(
items = items, items = items,
watchedKeys = items.mapTo(linkedSetOf()) { watchedKeys = items.mapTo(linkedSetOf()) {
@ -238,9 +249,55 @@ object WatchedRepository {
currentProfileId, currentProfileId,
json.encodeToString( json.encodeToString(
StoredWatchedPayload( StoredWatchedPayload(
items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }, items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
), ),
), ),
) )
} }
private fun shouldUseTraktWatchedSync(): Boolean =
shouldUseTraktWatchedSync(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
source = TraktSettingsRepository.uiState.value.watchProgressSource,
)
private suspend fun pushToActiveTargets(
profileId: Int,
items: Collection<WatchedItem>,
) {
if (shouldUseTraktWatchedSync()) {
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
return
}
syncAdapter.push(profileId = profileId, items = items)
if (TraktAuthRepository.isAuthenticated.value) {
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
}
}
private suspend fun deleteFromActiveTargets(
profileId: Int,
items: Collection<WatchedItem>,
) {
if (shouldUseTraktWatchedSync()) {
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
return
}
syncAdapter.delete(profileId = profileId, items = items)
if (TraktAuthRepository.isAuthenticated.value) {
TraktWatchedSyncAdapter.delete(profileId = profileId, items = items)
}
}
} }
internal fun shouldUseTraktWatchedSync(
isAuthenticated: Boolean,
source: WatchProgressSource,
): Boolean = shouldUseTraktProgress(
isAuthenticated = isAuthenticated,
source = source,
)

View file

@ -3,13 +3,15 @@ package com.nuvio.app.features.watching.application
import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.continueWatchingEntries
import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching
import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.WatchingProgressRecord
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
object WatchingState { object WatchingState {
@ -59,7 +61,9 @@ object WatchingState {
add(WatchingContentRef(type = item.type, id = item.id)) add(WatchingContentRef(type = item.type, id = item.id))
} }
} }
val progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord) val progressRecords = progressEntries
.filter { entry -> entry.shouldUseAsCompletedSeedForContinueWatching() }
.map(WatchProgressEntry::toDomainProgressRecord)
val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord) val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
return contentRefs.mapNotNull { content -> return contentRefs.mapNotNull { content ->
latestCompletedSeriesEpisode( latestCompletedSeriesEpisode(
@ -73,21 +77,9 @@ object WatchingState {
fun visibleContinueWatchingEntries( fun visibleContinueWatchingEntries(
progressEntries: List<WatchProgressEntry>, progressEntries: List<WatchProgressEntry>,
@Suppress("UNUSED_PARAMETER")
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>, latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
): List<WatchProgressEntry> { ): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
val visibleIds = continueWatchingProgressEntries(
progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord),
)
.filter { record ->
val latestCompleted = latestCompletedBySeries[record.content]
latestCompleted == null || record.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs
}
.mapTo(linkedSetOf()) { record -> record.videoId }
return progressEntries
.filter { entry -> entry.videoId in visibleIds }
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
}
} }
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
content = WatchingContentRef(type = type, id = id), content = WatchingContentRef(type = type, id = id),
seasonNumber = season, seasonNumber = season,
episodeNumber = episode, episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
) )

View file

@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> { override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
val params = buildJsonObject { put("p_profile_id", profileId) } val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
return result.decodeList<WatchProgressSyncEntry>().map { entry -> val serverEntries = result.decodeList<WatchProgressSyncEntry>()
val records = serverEntries.map { entry ->
ProgressSyncRecord( ProgressSyncRecord(
contentId = entry.contentId, contentId = entry.contentId,
contentType = entry.contentType, contentType = entry.contentType,
@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
lastWatched = entry.lastWatched, lastWatched = entry.lastWatched,
) )
} }
return records
} }
override suspend fun push( override suspend fun push(

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.watching.sync
import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc import io.github.jan.supabase.postgrest.rpc
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
name = syncItem.title, name = syncItem.title,
season = syncItem.season, season = syncItem.season,
episode = syncItem.episode, episode = syncItem.episode,
markedAtEpochMs = syncItem.watchedAt, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt),
) )
} }
} }
@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
title = item.name, title = item.name,
season = item.season, season = item.season,
episode = item.episode, episode = item.episode,
watchedAt = item.markedAtEpochMs, watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs),
) )
} }
val params = buildJsonObject { val params = buildJsonObject {

View file

@ -5,7 +5,9 @@ import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktEpisodeMappingService import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
} }
private fun rankedTimestamp(isoDate: String?): Long { private fun rankedTimestamp(isoDate: String?): Long {
val digits = isoDate return isoDate
?.filter(Char::isDigit) ?.takeIf { it.isNotBlank() }
?.take(14) ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
?.takeIf { it.length >= 8 } ?: 0L
?.padEnd(14, '0')
?.toLongOrNull()
return digits ?: 0L
} }
private fun epochMsToIso(epochMs: Long): String { private fun epochMsToIso(epochMs: Long): String {
// Convert to a compact ISO 8601 UTC string. val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs)
// Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places, if (normalizedEpochMs <= 0L) return "unknown"
// or a real epoch-ms. We only send when it looks like real epoch-ms. if (normalizedEpochMs < 10_000_000_000L) return "unknown"
if (epochMs <= 0L) return "unknown"
if (epochMs < 10_000_000_000L) {
// Looks like seconds-based or ranked timestamp — send unknown
return "unknown"
}
// Real epoch ms → simple ISO via arithmetic // Real epoch ms → simple ISO via arithmetic
val totalSeconds = epochMs / 1000 val totalSeconds = normalizedEpochMs / 1000
val s = (totalSeconds % 60).toInt() val s = (totalSeconds % 60).toInt()
val m = ((totalSeconds / 60) % 60).toInt() val m = ((totalSeconds / 60) % 60).toInt()
val h = ((totalSeconds / 3600) % 24).toInt() val h = ((totalSeconds / 3600) % 24).toInt()

View file

@ -19,6 +19,8 @@ data class CachedNextUpItem(
val episodeTitle: String? = null, val episodeTitle: String? = null,
val episodeThumbnail: String? = null, val episodeThumbnail: String? = null,
val pauseDescription: String? = null, val pauseDescription: String? = null,
val released: String? = null,
val hasAired: Boolean = true,
val lastWatched: Long, val lastWatched: Long,
val sortTimestamp: Long, val sortTimestamp: Long,
val seedSeason: Int? = null, val seedSeason: Int? = null,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@ -13,6 +14,12 @@ 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("use_episode_thumbnails_in_cw")
val useEpisodeThumbnails: Boolean = true,
@SerialName("show_unaired_next_up")
val showUnairedNextUp: 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 +53,9 @@ object ContinueWatchingPreferencesRepository {
isVisible: Boolean, isVisible: Boolean,
style: ContinueWatchingSectionStyle, style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean, upNextFromFurthestEpisode: Boolean,
useEpisodeThumbnails: Boolean = true,
showUnairedNextUp: Boolean = true,
blurNextUp: Boolean = false,
dismissedNextUpKeys: Set<String>, dismissedNextUpKeys: Set<String>,
) { ) {
ensureLoaded() ensureLoaded()
@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = isVisible, isVisible = isVisible,
style = style, style = style,
upNextFromFurthestEpisode = upNextFromFurthestEpisode, upNextFromFurthestEpisode = upNextFromFurthestEpisode,
useEpisodeThumbnails = useEpisodeThumbnails,
showUnairedNextUp = showUnairedNextUp,
blurNextUp = blurNextUp,
dismissedNextUpKeys = dismissedNextUpKeys dismissedNextUpKeys = dismissedNextUpKeys
.map(String::trim) .map(String::trim)
.filter(String::isNotBlank) .filter(String::isNotBlank)
@ -79,6 +92,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = stored.isVisible, isVisible = stored.isVisible,
style = stored.style, style = stored.style,
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode, upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
useEpisodeThumbnails = stored.useEpisodeThumbnails,
showUnairedNextUp = stored.showUnairedNextUp,
blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys, dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch, showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
) )
@ -105,6 +121,24 @@ object ContinueWatchingPreferencesRepository {
persist() persist()
} }
fun setUseEpisodeThumbnails(enabled: Boolean) {
ensureLoaded()
_uiState.value = _uiState.value.copy(useEpisodeThumbnails = enabled)
persist()
}
fun setShowUnairedNextUp(enabled: Boolean) {
ensureLoaded()
_uiState.value = _uiState.value.copy(showUnairedNextUp = enabled)
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 +173,9 @@ 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,
useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails,
showUnairedNextUp = _uiState.value.showUnairedNextUp,
blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch,
), ),

View file

@ -4,7 +4,12 @@ import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingContentRef
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
internal const val WatchProgressCompletionPercentThreshold = 99.5f internal const val WatchProgressCompletionPercentThreshold = 90f
internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f
internal const val WatchProgressSourceLocal = "local"
internal const val WatchProgressSourceTraktPlayback = "trakt_playback"
internal const val WatchProgressSourceTraktHistory = "trakt_history"
internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress"
@Serializable @Serializable
enum class ContinueWatchingSectionStyle { enum class ContinueWatchingSectionStyle {
@ -37,6 +42,7 @@ data class WatchProgressEntry(
val lastSourceUrl: String? = null, val lastSourceUrl: String? = null,
val isCompleted: Boolean = false, val isCompleted: Boolean = false,
val progressPercent: Float? = null, val progressPercent: Float? = null,
val source: String = WatchProgressSourceLocal,
) { ) {
val normalizedProgressPercent: Float? val normalizedProgressPercent: Float?
get() = progressPercent?.coerceIn(0f, 100f) get() = progressPercent?.coerceIn(0f, 100f)
@ -150,6 +156,7 @@ data class ContinueWatchingItem(
val episodeTitle: String? = null, val episodeTitle: String? = null,
val episodeThumbnail: String? = null, val episodeThumbnail: String? = null,
val pauseDescription: String? = null, val pauseDescription: String? = null,
val released: String? = null,
val isNextUp: Boolean = false, val isNextUp: Boolean = false,
val nextUpSeedSeasonNumber: Int? = null, val nextUpSeedSeasonNumber: Int? = null,
val nextUpSeedEpisodeNumber: Int? = null, val nextUpSeedEpisodeNumber: Int? = null,
@ -163,6 +170,9 @@ 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 useEpisodeThumbnails: Boolean = true,
val showUnairedNextUp: Boolean = true,
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(), val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true, val showResumePromptOnLaunch: Boolean = true,
) )
@ -204,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
episodeTitle = normalizedEntry.episodeTitle, episodeTitle = normalizedEntry.episodeTitle,
episodeThumbnail = normalizedEntry.episodeThumbnail, episodeThumbnail = normalizedEntry.episodeThumbnail,
pauseDescription = normalizedEntry.pauseDescription, pauseDescription = normalizedEntry.pauseDescription,
released = null,
isNextUp = false, isNextUp = false,
nextUpSeedSeasonNumber = null, nextUpSeedSeasonNumber = null,
nextUpSeedEpisodeNumber = null, nextUpSeedEpisodeNumber = null,
@ -241,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
episodeTitle = nextEpisode.title, episodeTitle = nextEpisode.title,
episodeThumbnail = nextEpisode.thumbnail, episodeThumbnail = nextEpisode.thumbnail,
pauseDescription = nextEpisode.overview, pauseDescription = nextEpisode.overview,
released = nextEpisode.released,
isNextUp = true, isNextUp = true,
nextUpSeedSeasonNumber = seasonNumber, nextUpSeedSeasonNumber = seasonNumber,
nextUpSeedEpisodeNumber = episodeNumber, nextUpSeedEpisodeNumber = episodeNumber,

View file

@ -126,7 +126,9 @@ object WatchProgressRepository {
TraktProgressRepository.ensureLoaded() TraktProgressRepository.ensureLoaded()
currentProfileId = profileId currentProfileId = profileId
if (shouldUseTraktProgress()) { val useTraktProgress = shouldUseTraktProgress()
if (useTraktProgress) {
runCatching { TraktProgressRepository.refreshNow() } runCatching { TraktProgressRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
publish() publish()
@ -419,8 +421,9 @@ object WatchProgressRepository {
private fun publish() { private fun publish() {
val entries = currentEntries() val entries = currentEntries()
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
_uiState.value = WatchProgressUiState( _uiState.value = WatchProgressUiState(
entries = entries.sortedByDescending { it.lastUpdatedEpochMs }, entries = sortedEntries,
) )
} }

View file

@ -67,15 +67,50 @@ internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): Watc
internal fun List<WatchProgressEntry>.continueWatchingEntries( internal fun List<WatchProgressEntry>.continueWatchingEntries(
limit: Int = ContinueWatchingLimit, limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> { ): List<WatchProgressEntry> {
val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() }
val domainEntries = continueWatchingProgressEntries( val domainEntries = continueWatchingProgressEntries(
progressRecords = map(WatchProgressEntry::toDomainProgressRecord), progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord),
limit = limit, limit = limit,
) )
val ids = domainEntries.map { record -> record.videoId }.toSet() val ids = domainEntries.map { record -> record.videoId }.toSet()
return filter { entry -> entry.videoId in ids } return inProgressEntries.filter { entry -> entry.videoId in ids }
.sortedByDescending { it.lastUpdatedEpochMs } .sortedByDescending { it.lastUpdatedEpochMs }
} }
internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Boolean {
val entry = normalizedCompletion()
if (entry.isEffectivelyCompleted) return false
val hasStartedPlayback = entry.lastPositionMs > 0L ||
entry.normalizedProgressPercent?.let { it > 0f } == true
if (!hasStartedPlayback) return false
return entry.source != WatchProgressSourceTraktHistory &&
entry.source != WatchProgressSourceTraktShowProgress
}
internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean {
val entry = normalizedCompletion()
if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false
if (!entry.isEffectivelyCompleted) return false
if (entry.source != WatchProgressSourceTraktPlayback) return true
val explicitPercent = entry.normalizedProgressPercent ?: return false
return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold
}
internal fun String?.isSeriesTypeForContinueWatching(): Boolean =
equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)
internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean {
val trimmed = contentId?.trim().orEmpty()
if (trimmed.isEmpty()) return true
return when (trimmed.lowercase()) {
"tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true
else -> false
}
}
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
normalizedCompletion().let { entry -> normalizedCompletion().let { entry ->
WatchingProgressRecord( WatchingProgressRecord(

View file

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

View file

@ -0,0 +1,44 @@
package com.nuvio.app.features.watched
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.trakt.WatchProgressSource
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WatchedModelsTest {
@Test
fun `compact watched timestamp normalizes to epoch millis`() {
val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z")
assertEquals(expected, normalizeWatchedMarkedAtEpochMs(20260425100200L))
}
@Test
fun `epoch watched timestamp is kept unchanged`() {
assertEquals(1_778_060_222_000L, normalizeWatchedMarkedAtEpochMs(1_778_060_222_000L))
}
@Test
fun `Trakt watched sync follows selected watch progress source`() {
assertTrue(
shouldUseTraktWatchedSync(
isAuthenticated = true,
source = WatchProgressSource.TRAKT,
),
)
assertFalse(
shouldUseTraktWatchedSync(
isAuthenticated = true,
source = WatchProgressSource.NUVIO_SYNC,
),
)
assertFalse(
shouldUseTraktWatchedSync(
isAuthenticated = false,
source = WatchProgressSource.TRAKT,
),
)
}
}

View file

@ -0,0 +1,104 @@
package com.nuvio.app.features.watching.application
import com.nuvio.app.features.trakt.TraktPlatformClock
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class WatchingStateTest {
@Test
fun `latest completed ignores Trakt playback below next up seed threshold`() {
val almostCompletePlayback = entry(
videoId = "show:1:4",
seasonNumber = 1,
episodeNumber = 4,
progressPercent = 94f,
source = WatchProgressSourceTraktPlayback,
)
val result = WatchingState.latestCompletedBySeries(
progressEntries = listOf(almostCompletePlayback),
watchedItems = emptyList(),
)
assertTrue(result.isEmpty())
}
@Test
fun `visible continue watching keeps active resume when newer episode is completed`() {
val resume = entry(
videoId = "show:1:4",
seasonNumber = 1,
episodeNumber = 4,
lastUpdatedEpochMs = 10L,
)
val completed = entry(
videoId = "show:1:5",
seasonNumber = 1,
episodeNumber = 5,
lastUpdatedEpochMs = 20L,
isCompleted = true,
)
val latestCompleted = WatchingState.latestCompletedBySeries(
progressEntries = listOf(resume, completed),
watchedItems = emptyList(),
)
val result = WatchingState.visibleContinueWatchingEntries(
progressEntries = listOf(resume, completed),
latestCompletedBySeries = latestCompleted,
)
assertEquals(listOf("show:1:4"), result.map { it.videoId })
}
@Test
fun `latest completed normalizes compact watched timestamps before sorting`() {
val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z")
val result = WatchingState.latestCompletedBySeries(
progressEntries = emptyList(),
watchedItems = listOf(
WatchedItem(
id = "show",
type = "series",
name = "Show",
season = 3,
episode = 1,
markedAtEpochMs = 20260425100200L,
),
),
preferFurthestEpisode = false,
)
assertEquals(expected, result.values.single().markedAtEpochMs)
}
private fun entry(
videoId: String,
seasonNumber: Int?,
episodeNumber: Int?,
lastUpdatedEpochMs: Long = 1L,
isCompleted: Boolean = false,
progressPercent: Float? = null,
source: String = "local",
): WatchProgressEntry =
WatchProgressEntry(
contentType = "series",
parentMetaId = "show",
parentMetaType = "series",
videoId = videoId,
title = "Show",
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastPositionMs = 120_000L,
durationMs = 1_000_000L,
lastUpdatedEpochMs = lastUpdatedEpochMs,
isCompleted = isCompleted,
progressPercent = progressPercent,
source = source,
)
}

View file

@ -118,6 +118,61 @@ class WatchProgressRulesTest {
assertEquals(listOf("movie-progress"), result.map { it.videoId }) assertEquals(listOf("movie-progress"), result.map { it.videoId })
} }
@Test
fun `continue watching keeps active resume even when a newer episode is completed`() {
val inProgress = entry(
videoId = "show:1:4",
parentMetaId = "show",
seasonNumber = 1,
episodeNumber = 4,
lastUpdatedEpochMs = 10L,
)
val completed = entry(
videoId = "show:1:5",
parentMetaId = "show",
seasonNumber = 1,
episodeNumber = 5,
lastUpdatedEpochMs = 20L,
isCompleted = true,
)
val result = listOf(inProgress, completed).continueWatchingEntries()
assertEquals(listOf("show:1:4"), result.map { it.videoId })
}
@Test
fun `Trakt playback next up seeds require TV percent threshold`() {
val belowSeedThreshold = entry(
videoId = "show:1:4",
parentMetaId = "show",
seasonNumber = 1,
episodeNumber = 4,
progressPercent = 94f,
source = WatchProgressSourceTraktPlayback,
)
val seed = belowSeedThreshold.copy(progressPercent = 95f)
assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching())
assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching())
}
@Test
fun `Trakt history is not treated as active resume`() {
val history = entry(
videoId = "show:1:4",
parentMetaId = "show",
seasonNumber = 1,
episodeNumber = 4,
lastPositionMs = 1L,
durationMs = 0L,
progressPercent = 50f,
source = WatchProgressSourceTraktHistory,
)
assertFalse(history.shouldTreatAsInProgressForContinueWatching())
}
@Test @Test
fun `codec normalizes completed entries inferred from percent`() { fun `codec normalizes completed entries inferred from percent`() {
val payload = WatchProgressCodec.encodeEntries( val payload = WatchProgressCodec.encodeEntries(
@ -174,6 +229,7 @@ class WatchProgressRulesTest {
durationMs: Long = 1_000_000L, durationMs: Long = 1_000_000L,
isCompleted: Boolean = false, isCompleted: Boolean = false,
progressPercent: Float? = null, progressPercent: Float? = null,
source: String = WatchProgressSourceLocal,
): WatchProgressEntry = ): WatchProgressEntry =
WatchProgressEntry( WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
@ -188,5 +244,6 @@ class WatchProgressRulesTest {
lastUpdatedEpochMs = lastUpdatedEpochMs, lastUpdatedEpochMs = lastUpdatedEpochMs,
isCompleted = isCompleted, isCompleted = isCompleted,
progressPercent = progressPercent, progressPercent = progressPercent,
source = source,
) )
} }