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, ) }