diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index b6f26e87f..6fedd1f7f 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -508,6 +508,8 @@
Resume prompt on launch
Blur next episode thumbnails in Continue Watching to avoid spoilers.
Blur Unwatched in Continue Watching
+ Include upcoming episodes in Continue Watching before they air.
+ Show Unaired Next Up Episodes
Poster Card Style
ON LAUNCH
UP NEXT BEHAVIOR
@@ -520,6 +522,8 @@
Info-dense horizontal card
Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead.
Up Next From Furthest Episode
+ Use episode thumbnails as default image. When disabled, uses backdrop.
+ Use Episode Thumbnails in Continue Watching
HOME
SOURCES
Install, remove, refresh, and sort your content sources.
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
index bf4b67442..ac964731c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
@@ -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 =
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
index d0144ead3..b7fa91344 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt
@@ -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
@@ -189,6 +191,7 @@ fun HomeScreen(
continueWatchingPreferences.dismissedNextUpKeys,
completedSeriesContentIds,
isTraktProgressActive,
+ continueWatchingPreferences.showUnairedNextUp,
watchedUiState.isLoaded,
) {
cachedSnapshots.first.mapNotNull { cached ->
@@ -202,6 +205,9 @@ fun HomeScreen(
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()
@@ -280,7 +286,11 @@ fun HomeScreen(
HomeCatalogSettingsRepository.syncCollections(collections)
}
- LaunchedEffect(completedSeriesCandidates, metaProviderKey, isTraktProgressActive) {
+ LaunchedEffect(
+ completedSeriesCandidates,
+ metaProviderKey,
+ continueWatchingPreferences.showUnairedNextUp,
+ ) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
return@LaunchedEffect
@@ -301,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)
@@ -329,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,
@@ -447,6 +461,7 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
+ useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
@@ -471,6 +486,7 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
+ useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
@@ -514,6 +530,7 @@ fun HomeScreen(
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
+ useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails,
blurNextUp = continueWatchingPreferences.blurNextUp,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
@@ -641,9 +658,6 @@ internal fun buildHomeContinueWatchingItems(
.map(HomeContinueWatchingCandidate::item)
}
-private fun String?.isSeriesTypeForContinueWatching(): Boolean =
- equals("series", ignoreCase = true) || equals("tv", ignoreCase = true)
-
private data class CompletedSeriesCandidate(
val content: WatchingContentRef,
val seasonNumber: Int,
@@ -698,6 +712,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? {
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
pauseDescription = pauseDescription,
+ released = released,
isNextUp = true,
nextUpSeedSeasonNumber = seedSeason,
nextUpSeedEpisodeNumber = seedEpisode,
@@ -764,5 +779,6 @@ private fun ContinueWatchingItem.withFallbackMetadata(
episodeTitle = episodeTitle ?: fallback.episodeTitle,
episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail,
pauseDescription = pauseDescription ?: fallback.pauseDescription,
+ released = released ?: fallback.released,
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
index dc06dd62f..c3f449472 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt
@@ -51,10 +51,43 @@ 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,
style: ContinueWatchingSectionStyle,
+ useEpisodeThumbnails: Boolean = true,
blurNextUp: Boolean = false,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
@@ -68,6 +101,7 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent(
items = items,
style = style,
+ useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
@@ -80,6 +114,7 @@ internal fun HomeContinueWatchingSection(
HomeContinueWatchingSectionContent(
items = items,
style = style,
+ useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
@@ -95,6 +130,7 @@ internal fun HomeContinueWatchingSection(
private fun HomeContinueWatchingSectionContent(
items: List,
style: ContinueWatchingSectionStyle,
+ useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
modifier: Modifier,
sectionPadding: Dp,
@@ -115,6 +151,7 @@ private fun HomeContinueWatchingSectionContent(
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
+ useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
@@ -122,6 +159,7 @@ private fun HomeContinueWatchingSectionContent(
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
+ useEpisodeThumbnails = useEpisodeThumbnails,
blurNextUp = blurNextUp,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
@@ -280,6 +318,7 @@ private fun PosterCardPreview() {
private fun ContinueWatchingWideCard(
item: ContinueWatchingItem,
layout: ContinueWatchingLayout,
+ useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
@@ -301,12 +340,8 @@ private fun ContinueWatchingWideCard(
onLongClick = onLongClick,
),
) {
- val shouldBlurArtwork = blurNextUp && item.isNextUp
- val artworkUrl = if (shouldBlurArtwork) {
- item.episodeThumbnail ?: item.imageUrl ?: item.background ?: item.poster
- } else {
- item.poster ?: item.background ?: item.imageUrl
- }
+ val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
+ val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
ArtworkPanel(
imageUrl = artworkUrl,
width = layout.widePosterStripWidth,
@@ -398,6 +433,7 @@ private fun ContinueWatchingWideCard(
private fun ContinueWatchingPosterCard(
item: ContinueWatchingItem,
layout: ContinueWatchingLayout,
+ useEpisodeThumbnails: Boolean,
blurNextUp: Boolean,
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
@@ -419,12 +455,8 @@ private fun ContinueWatchingPosterCard(
)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
) {
- val shouldBlurArtwork = blurNextUp && item.isNextUp
- val imageUrl = if (shouldBlurArtwork) {
- item.episodeThumbnail ?: item.imageUrl ?: item.poster
- } else {
- item.poster ?: item.imageUrl
- }
+ val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
+ val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails)
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt
index bada31ad2..c3d813544 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt
@@ -30,6 +30,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_resume_pr
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
@@ -42,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
@@ -50,6 +54,8 @@ internal fun LazyListScope.continueWatchingSettingsContent(
isVisible: Boolean,
style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean,
+ useEpisodeThumbnails: Boolean,
+ showUnairedNextUp: Boolean,
blurNextUp: Boolean,
showResumePromptOnLaunch: Boolean,
) {
@@ -87,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),
@@ -96,12 +110,22 @@ internal fun LazyListScope.continueWatchingSettingsContent(
)
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,
+ 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::setBlurNextUp,
+ 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,
+ )
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt
index 4ff8d7abc..b8cd870d9 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt
@@ -127,6 +127,8 @@ fun ContinueWatchingSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
+ useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
+ showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
index a180d66d7..9818b247a 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt
@@ -376,6 +376,8 @@ private fun MobileSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
+ useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
+ showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
@@ -615,6 +617,8 @@ private fun TabletSettingsScreen(
isVisible = continueWatchingPreferencesUiState.isVisible,
style = continueWatchingPreferencesUiState.style,
upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode,
+ useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails,
+ showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp,
blurNextUp = continueWatchingPreferencesUiState.blurNextUp,
showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch,
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
index 6ca02f0b9..de3e429f5 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt
@@ -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,
+ ): List = 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>(payload)
+ watchedShows
+ .mapNotNull { item ->
+ mapWatchedShowSeed(
+ item = item,
+ useFurthestEpisode = useFurthestEpisode,
+ )
+ }
+ .sortedByDescending { entry -> entry.lastUpdatedEpochMs }
+ }
+
private fun mergeNewestByVideoId(entries: List): List {
val mergedByVideoId = linkedMapOf()
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,
hydrated: List,
@@ -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(
+ { it.season },
+ { it.episode },
+ { it.watchedAt },
+ )
+ } else {
+ compareBy(
+ { 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? = null,
+)
+
+@Serializable
+private data class TraktWatchedShowSeason(
+ @SerialName("number") val number: Int? = null,
+ @SerialName("episodes") val episodes: List? = 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,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt
index 6ade37280..3f778d810 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt
@@ -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
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
index 8cc9056cd..c2ae8997f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt
@@ -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 = 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(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,
+ ) {
+ 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,
+ ) {
+ 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,
+)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt
index 9e29639a1..c0f1474f6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt
@@ -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,
+ @Suppress("UNUSED_PARAMETER")
latestCompletedBySeries: Map,
- ): List {
- 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 = 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),
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt
index 63307dafd..cb2dc9400 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt
@@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
override suspend fun pull(profileId: Int): List {
val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
- return result.decodeList().map { entry ->
+ val serverEntries = result.decodeList()
+ 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(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt
index 9bba34a06..cab9e5535 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt
@@ -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 {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
index 5a63de88a..ac647c892 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt
@@ -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()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt
index 551c78bdf..6152fae8f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt
@@ -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,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt
index f0d70f498..9845b680f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt
@@ -14,6 +14,10 @@ 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 = emptySet(),
@@ -49,6 +53,8 @@ object ContinueWatchingPreferencesRepository {
isVisible: Boolean,
style: ContinueWatchingSectionStyle,
upNextFromFurthestEpisode: Boolean,
+ useEpisodeThumbnails: Boolean = true,
+ showUnairedNextUp: Boolean = true,
blurNextUp: Boolean = false,
dismissedNextUpKeys: Set,
) {
@@ -57,6 +63,8 @@ object ContinueWatchingPreferencesRepository {
isVisible = isVisible,
style = style,
upNextFromFurthestEpisode = upNextFromFurthestEpisode,
+ useEpisodeThumbnails = useEpisodeThumbnails,
+ showUnairedNextUp = showUnairedNextUp,
blurNextUp = blurNextUp,
dismissedNextUpKeys = dismissedNextUpKeys
.map(String::trim)
@@ -84,6 +92,8 @@ 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,
@@ -111,6 +121,18 @@ 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)
@@ -151,6 +173,8 @@ 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,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt
index 92cbfc06b..1c27213d3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt
@@ -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,8 @@ 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 = emptySet(),
val showResumePromptOnLaunch: Boolean = true,
@@ -205,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,
@@ -242,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,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt
index d46c40c68..239910571 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt
@@ -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,
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt
index d12f80c2e..302beece3 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt
@@ -67,15 +67,50 @@ internal fun List.resumeEntryForSeries(metaId: String): Watc
internal fun List.continueWatchingEntries(
limit: Int = ContinueWatchingLimit,
): List {
+ 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(
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt
new file mode 100644
index 000000000..a9664e045
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt
@@ -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,
+ ),
+ )
+ }
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt
new file mode 100644
index 000000000..e615cbc64
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt
@@ -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,
+ )
+}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt
index 658bdb662..bed674ef8 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt
@@ -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,
)
}