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_continue_watching_resume_prompt_description">Show a popup to continue where you left off when opening the app after leaving from the player.</string>
<string name="settings_continue_watching_resume_prompt_title">Resume prompt on launch</string>
<string name="settings_continue_watching_blur_next_up_description">Blur next episode thumbnails in Continue Watching to avoid spoilers.</string>
<string name="settings_continue_watching_blur_next_up_title">Blur Unwatched in Continue Watching</string>
<string name="settings_continue_watching_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_on_launch">ON LAUNCH</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_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_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_sources">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_episodes">Episodes</string>
<string name="settings_meta_episodes_description">Seasons and episode list for series.</string>
<string name="settings_meta_blur_unwatched_episodes">Blur Unwatched Episodes</string>
<string name="settings_meta_blur_unwatched_episodes_description">Blur episode thumbnails until watched to avoid spoilers.</string>
<string name="settings_meta_group_label">Group %1$d</string>
<string name="settings_meta_more_like_this">More like this</string>
<string name="settings_meta_more_like_this_description">TMDB recommendation backdrops on detail page</string>

View file

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

View file

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

View file

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

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.ContinueWatchingPreferencesRepository
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.WatchProgressClock
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.watching.application.WatchingState
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.profiles.ProfileRepository
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(
effectiveWatchProgressEntries,
latestCompletedBySeries,
@ -181,11 +186,28 @@ fun HomeScreen(
var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() }
val cachedNextUpItems = remember(cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys) {
val cachedNextUpItems = remember(
cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys,
completedSeriesContentIds,
isTraktProgressActive,
continueWatchingPreferences.showUnairedNextUp,
watchedUiState.isLoaded,
) {
cachedSnapshots.first.mapNotNull { cached ->
if (
!isTraktProgressActive &&
watchedUiState.isLoaded &&
cached.contentId !in completedSeriesContentIds
) {
return@mapNotNull null
}
if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) {
return@mapNotNull null
}
if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) {
return@mapNotNull null
}
val item = cached.toContinueWatchingItem() ?: return@mapNotNull null
cached.contentId to (cached.sortTimestamp to item)
}.toMap()
@ -264,7 +286,11 @@ fun HomeScreen(
HomeCatalogSettingsRepository.syncCollections(collections)
}
LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) {
LaunchedEffect(
completedSeriesCandidates,
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
return@LaunchedEffect
@ -285,7 +311,7 @@ fun HomeScreen(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate,
showUnairedNextUp = isTraktProgressActive,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
) ?: return@withPermit null
val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode)
@ -313,6 +339,10 @@ fun HomeScreen(
episodeTitle = item.episodeTitle,
episodeThumbnail = item.episodeThumbnail,
pauseDescription = item.pauseDescription,
released = item.released,
hasAired = item.released?.let { released ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
} ?: true,
lastWatched = pair.first,
sortTimestamp = pair.first,
seedSeason = item.nextUpSeedSeasonNumber,
@ -431,6 +461,8 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
@ -454,6 +486,8 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
@ -496,6 +530,8 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
@ -584,6 +620,13 @@ internal fun buildHomeContinueWatchingItems(
cachedInProgressByVideoId: Map<String, ContinueWatchingItem> = emptyMap(),
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
): List<ContinueWatchingItem> {
val inProgressSeriesIds = visibleEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
return buildList {
addAll(
visibleEntries.map { entry ->
@ -596,7 +639,8 @@ internal fun buildHomeContinueWatchingItems(
},
)
addAll(
nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) ->
nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) ->
if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null
HomeContinueWatchingCandidate(
lastUpdatedEpochMs = lastUpdatedEpochMs,
item = item,
@ -610,7 +654,7 @@ internal fun buildHomeContinueWatchingItems(
.thenByDescending { it.isProgressEntry },
)
.filter { candidate -> candidate.item.shouldDisplayInContinueWatching() }
.distinctBy { it.item.videoId }
.distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } }
.map(HomeContinueWatchingCandidate::item)
}
@ -668,6 +712,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
released = released,
isNextUp = true,
nextUpSeedSeasonNumber = seedSeason,
nextUpSeedEpisodeNumber = seedEpisode,
@ -734,5 +779,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
episodeTitle = episodeTitle ?: fallback.episodeTitle,
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@ -50,10 +51,44 @@ import org.jetbrains.compose.resources.stringResource
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
(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
internal fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle,
useEpisodeThumbnails: Boolean = true,
blurNextUp: Boolean = false,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
layout: ContinueWatchingLayout? = null,
@ -66,6 +101,8 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent(
items = items,
style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
layout = layout,
@ -77,6 +114,8 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent(
items = items,
style = style,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
layout = rememberContinueWatchingLayout(maxWidth.value),
@ -91,6 +130,8 @@ internal fun HomeContinueWatchingSection(
private fun HomeContinueWatchingSectionContent(
items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
modifier: Modifier,
sectionPadding: Dp,
layout: ContinueWatchingLayout,
@ -110,12 +151,16 @@ private fun HomeContinueWatchingSectionContent(
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
@ -273,6 +318,8 @@ private fun PosterCardPreview() {
private fun ContinueWatchingWideCard(
item: ContinueWatchingItem,
layout: ContinueWatchingLayout,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
) {
@ -293,10 +340,12 @@ private fun ContinueWatchingWideCard(
onLongClick = onLongClick,
),
) {
val artworkUrl = item.poster ?: item.background ?: item.imageUrl
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
ArtworkPanel(
imageUrl = artworkUrl,
width = layout.widePosterStripWidth,
blurred = shouldBlurArtwork,
modifier = Modifier.fillMaxHeight(),
)
Column(
@ -384,6 +433,8 @@ private fun ContinueWatchingWideCard(
private fun ContinueWatchingPosterCard(
item: ContinueWatchingItem,
layout: ContinueWatchingLayout,
useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
) {
@ -404,12 +455,15 @@ private fun ContinueWatchingPosterCard(
)
.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) {
AsyncImage(
model = imageUrl,
contentDescription = item.title,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
}
@ -489,6 +543,7 @@ private fun ContinueWatchingPosterCard(
private fun ArtworkPanel(
imageUrl: String?,
width: Dp,
blurred: Boolean = false,
modifier: Modifier = Modifier,
) {
Box(
@ -500,7 +555,9 @@ private fun ArtworkPanel(
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(if (blurred) Modifier.blur(18.dp) else Modifier),
contentScale = ContentScale.Crop,
)
}

View file

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

View file

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

View file

@ -28,6 +28,10 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description
import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description
import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title
import nuvio.composeapp.generated.resources.settings_continue_watching_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_on_launch
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_up_next_description
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
@ -48,6 +54,9 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isVisible: Boolean,
style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean,
useEpisodeThumbnails: Boolean,
showUnairedNextUp: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean,
) {
item {
@ -84,6 +93,14 @@ internal fun LazyListScope.continueWatchingSettingsContent(
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(
title = stringResource(Res.string.settings_continue_watching_up_next_title),
description = stringResource(Res.string.settings_continue_watching_up_next_description),
@ -91,6 +108,24 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isTablet = isTablet,
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_episodes
import nuvio.composeapp.generated.resources.settings_meta_episodes_description
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes
import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description
import nuvio.composeapp.generated.resources.settings_meta_group_label
import nuvio.composeapp.generated.resources.settings_meta_more_like_this
import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description
@ -130,6 +132,14 @@ internal fun LazyListScope.metaScreenSettingsContent(
selectedStyle = uiState.episodeCardStyle,
onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_meta_blur_unwatched_episodes),
description = stringResource(Res.string.settings_meta_blur_unwatched_episodes_description),
checked = uiState.blurUnwatchedEpisodes,
isTablet = isTablet,
onCheckedChange = { MetaScreenSettingsRepository.setBlurUnwatchedEpisodes(it) },
)
}
}
}

View file

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

View file

@ -376,6 +376,9 @@ private fun MobileSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
SettingsPage.PosterCustomization -> posterCustomizationSettingsContent(
@ -614,6 +617,9 @@ private fun TabletSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
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.httpRequestRaw
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.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.shouldTreatAsInProgressForContinueWatching
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -29,7 +34,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
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 METADATA_FETCH_TIMEOUT_MS = 3_500L
private const val METADATA_FETCH_CONCURRENCY = 5
@ -113,8 +118,8 @@ object TraktProgressRepository {
}
scope.launch {
val historyEntries = runCatching {
fetchHistoryEntries(headers)
val completedEntries = runCatching {
fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Failed to fetch Trakt history snapshot: ${error.message}" }
@ -122,7 +127,7 @@ object TraktProgressRepository {
if (!isLatestRefreshRequest(requestId)) return@launch
val merged = mergeNewestByVideoId(playbackEntries + historyEntries)
val merged = mergeNewestByVideoId(playbackEntries + completedEntries)
_uiState.value = _uiState.value.copy(
entries = merged.sortedByDescending { it.lastUpdatedEpochMs },
isLoading = false,
@ -345,12 +350,32 @@ object TraktProgressRepository {
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> {
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
entries.forEach { rawEntry ->
val entry = rawEntry.normalizedCompletion()
val existing = mergedByVideoId[entry.videoId]
if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) {
if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) {
mergedByVideoId[entry.videoId] = entry
}
}
@ -360,6 +385,18 @@ object TraktProgressRepository {
.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(
current: List<WatchProgressEntry>,
hydrated: List<WatchProgressEntry>,
@ -499,6 +536,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
progressPercent = progressPercent,
source = WatchProgressSourceTraktPlayback,
).normalizedCompletion()
}
@ -533,6 +571,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex),
isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD,
progressPercent = progressPercent,
source = WatchProgressSourceTraktPlayback,
).normalizedCompletion()
}
@ -564,6 +603,7 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
isCompleted = true,
progressPercent = 100f,
source = WatchProgressSourceTraktHistory,
)
}
@ -583,6 +623,73 @@ object TraktProgressRepository {
lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex),
isCompleted = true,
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 {
val compactDigits = isoDate
?.filter(Char::isDigit)
?.take(14)
?.takeIf { it.length >= 8 }
?.padEnd(14, '0')
?.toLongOrNull()
if (compactDigits != null) return compactDigits
isoDate
?.takeIf { it.isNotBlank() }
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
?.let { return it }
return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L)
}
}
@ -632,6 +735,32 @@ private data class TraktHistoryMovieItem(
@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
private data class TraktMedia(
@SerialName("title") val title: String? = null,

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.watched
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.watchedKey
import kotlinx.serialization.Serializable
@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem =
val WatchedItem.isEpisode: Boolean
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(
type: String,
id: String,
@ -47,3 +85,5 @@ fun watchedItemKey(
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.profiles.ProfileRepository
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.TraktWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
@ -42,8 +45,8 @@ object WatchedRepository {
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
private fun activeSyncAdapter(): WatchedSyncAdapter =
if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter
private fun activePullSyncAdapter(): WatchedSyncAdapter =
if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter
fun ensureLoaded() {
if (hasLoaded) return
@ -72,21 +75,27 @@ object WatchedRepository {
val items = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload).items
}.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()
}
suspend fun pullFromServer(profileId: Int) {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
currentProfileId = profileId
runCatching {
val serverItems = activeSyncAdapter().pull(
val serverItems = activePullSyncAdapter().pull(
profileId = profileId,
pageSize = watchedItemsPageSize,
)
itemsByKey = serverItems
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
hasLoaded = true
@ -203,7 +212,7 @@ object WatchedRepository {
runCatching {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
activeSyncAdapter().push(profileId = profileId, items = items)
pushToActiveTargets(profileId = profileId, items = items)
}.onFailure { e ->
log.e(e) { "Failed to push watched items" }
}
@ -215,7 +224,7 @@ object WatchedRepository {
runCatching {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
activeSyncAdapter().delete(profileId = profileId, items = items)
deleteFromActiveTargets(profileId = profileId, items = items)
}.onFailure { e ->
log.e(e) { "Failed to push watched item delete" }
}
@ -223,7 +232,9 @@ object WatchedRepository {
}
private fun publish() {
val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }
val items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs }
_uiState.value = WatchedUiState(
items = items,
watchedKeys = items.mapTo(linkedSetOf()) {
@ -238,9 +249,55 @@ object WatchedRepository {
currentProfileId,
json.encodeToString(
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.home.MetaPreview
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.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.WatchingContentRef
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
object WatchingState {
@ -59,7 +61,9 @@ object WatchingState {
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)
return contentRefs.mapNotNull { content ->
latestCompletedSeriesEpisode(
@ -73,21 +77,9 @@ object WatchingState {
fun visibleContinueWatchingEntries(
progressEntries: List<WatchProgressEntry>,
@Suppress("UNUSED_PARAMETER")
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
): List<WatchProgressEntry> {
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 }
}
): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
}
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
content = WatchingContentRef(type = type, id = id),
seasonNumber = season,
episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs,
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs),
)

View file

@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
val params = buildJsonObject { put("p_profile_id", profileId) }
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(
contentId = entry.contentId,
contentType = entry.contentType,
@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
lastWatched = entry.lastWatched,
)
}
return records
}
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.features.watched.WatchedItem
import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.serialization.SerialName
@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
name = syncItem.title,
season = syncItem.season,
episode = syncItem.episode,
markedAtEpochMs = syncItem.watchedAt,
markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt),
)
}
}
@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
title = item.name,
season = item.season,
episode = item.episode,
watchedAt = item.markedAtEpochMs,
watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs),
)
}
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.trakt.TraktAuthRepository
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.normalizeWatchedMarkedAtEpochMs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@ -472,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
}
private fun rankedTimestamp(isoDate: String?): Long {
val digits = isoDate
?.filter(Char::isDigit)
?.take(14)
?.takeIf { it.length >= 8 }
?.padEnd(14, '0')
?.toLongOrNull()
return digits ?: 0L
return isoDate
?.takeIf { it.isNotBlank() }
?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs)
?: 0L
}
private fun epochMsToIso(epochMs: Long): String {
// Convert to a compact ISO 8601 UTC string.
// Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places,
// or a real epoch-ms. We only send when it looks like real epoch-ms.
if (epochMs <= 0L) return "unknown"
if (epochMs < 10_000_000_000L) {
// Looks like seconds-based or ranked timestamp — send unknown
return "unknown"
}
val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs)
if (normalizedEpochMs <= 0L) return "unknown"
if (normalizedEpochMs < 10_000_000_000L) return "unknown"
// Real epoch ms → simple ISO via arithmetic
val totalSeconds = epochMs / 1000
val totalSeconds = normalizedEpochMs / 1000
val s = (totalSeconds % 60).toInt()
val m = ((totalSeconds / 60) % 60).toInt()
val h = ((totalSeconds / 3600) % 24).toInt()

View file

@ -19,6 +19,8 @@ data class CachedNextUpItem(
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val pauseDescription: String? = null,
val released: String? = null,
val hasAired: Boolean = true,
val lastWatched: Long,
val sortTimestamp: Long,
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@ -13,6 +14,12 @@ private data class StoredContinueWatchingPreferences(
val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
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 showResumePromptOnLaunch: Boolean = true,
)
@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository {
isVisible: Boolean,
style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean,
useEpisodeThumbnails: Boolean = true,
showUnairedNextUp: Boolean = true,
blurNextUp: Boolean = false,
dismissedNextUpKeys: Set<String>,
) {
ensureLoaded()
@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = isVisible,
style = style,
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
useEpisodeThumbnails = useEpisodeThumbnails,
showUnairedNextUp = showUnairedNextUp,
blurNextUp = blurNextUp,
dismissedNextUpKeys = dismissedNextUpKeys
.map(String::trim)
.filter(String::isNotBlank)
@ -79,6 +92,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = stored.isVisible,
style = stored.style,
upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode,
useEpisodeThumbnails = stored.useEpisodeThumbnails,
showUnairedNextUp = stored.showUnairedNextUp,
blurNextUp = stored.blurNextUp,
dismissedNextUpKeys = stored.dismissedNextUpKeys,
showResumePromptOnLaunch = stored.showResumePromptOnLaunch,
)
@ -105,6 +121,24 @@ object ContinueWatchingPreferencesRepository {
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) {
ensureLoaded()
val normalizedKey = key.trim()
@ -139,6 +173,9 @@ object ContinueWatchingPreferencesRepository {
isVisible = _uiState.value.isVisible,
style = _uiState.value.style,
upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode,
useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails,
showUnairedNextUp = _uiState.value.showUnairedNextUp,
blurNextUp = _uiState.value.blurNextUp,
dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys,
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 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
enum class ContinueWatchingSectionStyle {
@ -37,6 +42,7 @@ data class WatchProgressEntry(
val lastSourceUrl: String? = null,
val isCompleted: Boolean = false,
val progressPercent: Float? = null,
val source: String = WatchProgressSourceLocal,
) {
val normalizedProgressPercent: Float?
get() = progressPercent?.coerceIn(0f, 100f)
@ -150,6 +156,7 @@ data class ContinueWatchingItem(
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
val pauseDescription: String? = null,
val released: String? = null,
val isNextUp: Boolean = false,
val nextUpSeedSeasonNumber: Int? = null,
val nextUpSeedEpisodeNumber: Int? = null,
@ -163,6 +170,9 @@ data class ContinueWatchingPreferencesUiState(
val isVisible: Boolean = true,
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
val upNextFromFurthestEpisode: Boolean = true,
val useEpisodeThumbnails: Boolean = true,
val showUnairedNextUp: Boolean = true,
val blurNextUp: Boolean = false,
val dismissedNextUpKeys: Set<String> = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
)
@ -204,6 +214,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem {
episodeTitle = normalizedEntry.episodeTitle,
episodeThumbnail = normalizedEntry.episodeThumbnail,
pauseDescription = normalizedEntry.pauseDescription,
released = null,
isNextUp = false,
nextUpSeedSeasonNumber = null,
nextUpSeedEpisodeNumber = null,
@ -241,6 +252,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem(
episodeTitle = nextEpisode.title,
episodeThumbnail = nextEpisode.thumbnail,
pauseDescription = nextEpisode.overview,
released = nextEpisode.released,
isNextUp = true,
nextUpSeedSeasonNumber = seasonNumber,
nextUpSeedEpisodeNumber = episodeNumber,

View file

@ -126,7 +126,9 @@ object WatchProgressRepository {
TraktProgressRepository.ensureLoaded()
currentProfileId = profileId
if (shouldUseTraktProgress()) {
val useTraktProgress = shouldUseTraktProgress()
if (useTraktProgress) {
runCatching { TraktProgressRepository.refreshNow() }
.onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } }
publish()
@ -419,8 +421,9 @@ object WatchProgressRepository {
private fun publish() {
val entries = currentEntries()
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
_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(
limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> {
val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() }
val domainEntries = continueWatchingProgressEntries(
progressRecords = map(WatchProgressEntry::toDomainProgressRecord),
progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord),
limit = limit,
)
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 }
}
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 =
normalizedCompletion().let { entry ->
WatchingProgressRecord(

View file

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

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 })
}
@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
fun `codec normalizes completed entries inferred from percent`() {
val payload = WatchProgressCodec.encodeEntries(
@ -174,6 +229,7 @@ class WatchProgressRulesTest {
durationMs: Long = 1_000_000L,
isCompleted: Boolean = false,
progressPercent: Float? = null,
source: String = WatchProgressSourceLocal,
): WatchProgressEntry =
WatchProgressEntry(
contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie",
@ -188,5 +244,6 @@ class WatchProgressRulesTest {
lastUpdatedEpochMs = lastUpdatedEpochMs,
isCompleted = isCompleted,
progressPercent = progressPercent,
source = source,
)
}