From d2200fe5abfea383b6a8d4d8b3f09bb5b398bc5f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:10:38 +0530 Subject: [PATCH] feat: enhance series watched state management with reconciliation logic and UI updates for episode actions --- .../app/features/details/MetaDetailsScreen.kt | 62 ++++++++++++++++--- .../components/EpisodeWatchedActionSheet.kt | 22 +++++-- .../home/components/HomeCatalogSection.kt | 3 +- .../features/watched/WatchedEpisodeActions.kt | 12 ++++ .../app/features/watched/WatchedRepository.kt | 25 ++++++++ .../watchprogress/WatchProgressRepository.kt | 24 +++++++ .../features/watched/WatchedRepositoryTest.kt | 23 +++++++ 7 files changed, 154 insertions(+), 17 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d5f5450f..6ca5d805 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -162,6 +162,17 @@ fun MetaDetailsScreen( }?.overview } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val syncSeriesWatchedState = remember(meta, todayIsoDate) { + { + WatchedRepository.reconcileSeriesWatchedState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeCompleted = { episode -> + WatchProgressRepository.progressForVideo(meta.episodePlaybackId(episode))?.isCompleted == true + }, + ) + } + } val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) { when { (meta.type == "series" || hasEpisodes) && seriesAction != null -> @@ -323,6 +334,18 @@ fun MetaDetailsScreen( ) selectedEpisodeForActions?.let { selectedEpisode -> + val isEpisodeWatched: (com.nuvio.app.features.details.MetaVideo) -> Boolean = remember(meta, watchedUiState.watchedKeys) { + { episode -> + watchedUiState.watchedKeys.contains( + com.nuvio.app.features.watched.watchedItemKey( + type = meta.type, + id = meta.id, + season = episode.season, + episode = episode.episode, + ), + ) + } + } val selectedEpisodeWatchedKey = remember(meta, selectedEpisode) { com.nuvio.app.features.watched.watchedItemKey( type = meta.type, @@ -346,11 +369,19 @@ fun MetaDetailsScreen( todayIsoDate = todayIsoDate, ) } + val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) { + previousEpisodes.isNotEmpty() && previousEpisodes.all(isEpisodeWatched) + } + val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) { + seasonEpisodes.isNotEmpty() && seasonEpisodes.all(isEpisodeWatched) + } EpisodeWatchedActionSheet( episode = selectedEpisode, seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials", isEpisodeWatched = isSelectedEpisodeWatched, canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(), + arePreviousEpisodesWatched = arePreviousEpisodesWatched, + isSeasonWatched = isSeasonWatched, onDismiss = { selectedEpisodeForActions = null }, onToggleWatched = { val watchedItem = meta.toEpisodeWatchedItem(selectedEpisode) @@ -360,20 +391,31 @@ fun MetaDetailsScreen( WatchedRepository.markWatched(watchedItem) WatchProgressRepository.clearProgress(meta.episodePlaybackId(selectedEpisode)) } + syncSeriesWatchedState() }, - onMarkPreviousWatched = { + onTogglePreviousWatched = { val watchedItems = previousEpisodes.map(meta::toEpisodeWatchedItem) - WatchedRepository.markWatched(watchedItems) - WatchProgressRepository.clearProgress( - previousEpisodes.map(meta::episodePlaybackId), - ) + if (arePreviousEpisodesWatched) { + WatchedRepository.unmarkWatched(watchedItems) + } else { + WatchedRepository.markWatched(watchedItems) + WatchProgressRepository.clearProgress( + previousEpisodes.map(meta::episodePlaybackId), + ) + } + syncSeriesWatchedState() }, - onMarkSeasonWatched = { + onToggleSeasonWatched = { val watchedItems = seasonEpisodes.map(meta::toEpisodeWatchedItem) - WatchedRepository.markWatched(watchedItems) - WatchProgressRepository.clearProgress( - seasonEpisodes.map(meta::episodePlaybackId), - ) + if (isSeasonWatched) { + WatchedRepository.unmarkWatched(watchedItems) + } else { + WatchedRepository.markWatched(watchedItems) + WatchProgressRepository.clearProgress( + seasonEpisodes.map(meta::episodePlaybackId), + ) + } + syncSeriesWatchedState() }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt index 296736a4..66c300ba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt @@ -42,10 +42,12 @@ fun EpisodeWatchedActionSheet( seasonLabel: String, isEpisodeWatched: Boolean, canMarkPreviousEpisodes: Boolean, + arePreviousEpisodesWatched: Boolean, + isSeasonWatched: Boolean, onDismiss: () -> Unit, onToggleWatched: () -> Unit, - onMarkPreviousWatched: () -> Unit, - onMarkSeasonWatched: () -> Unit, + onTogglePreviousWatched: () -> Unit, + onToggleSeasonWatched: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() @@ -94,9 +96,13 @@ fun EpisodeWatchedActionSheet( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) EpisodeActionSheetRow( icon = Icons.Default.DoneAll, - title = "Mark previous as watched", + title = if (arePreviousEpisodesWatched) { + "Mark previous as unwatched" + } else { + "Mark previous as watched" + }, onClick = { - onMarkPreviousWatched() + onTogglePreviousWatched() coroutineScope.launch { dismissEpisodeActionSheet(sheetState = sheetState, onDismiss = onDismiss) } @@ -106,9 +112,13 @@ fun EpisodeWatchedActionSheet( HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) EpisodeActionSheetRow( icon = Icons.Default.PlaylistAddCheckCircle, - title = "Mark $seasonLabel as watched", + title = if (isSeasonWatched) { + "Mark $seasonLabel as unwatched" + } else { + "Mark $seasonLabel as watched" + }, onClick = { - onMarkSeasonWatched() + onToggleSeasonWatched() coroutineScope.launch { dismissEpisodeActionSheet(sheetState = sheetState, onDismiss = onDismiss) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index d87f05ba..7f113924 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -8,6 +8,7 @@ import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.watched.watchedItemKey @Composable fun HomeCatalogRowSection( @@ -31,7 +32,7 @@ fun HomeCatalogRowSection( ) { item -> HomePosterCard( item = item, - isWatched = watchedKeys.contains("${item.type}:${item.id}"), + isWatched = watchedKeys.contains(watchedItemKey(item.type, item.id)), onClick = onPosterClick?.let { { it(item) } }, onLongClick = onPosterLongClick?.let { { it(item) } }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt index 9933ef8a..f8899a57 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt @@ -35,6 +35,10 @@ fun MetaDetails.toEpisodeWatchedItem( fun MetaDetails.releasedPlayableEpisodes(todayIsoDate: String): List = sortedPlayableEpisodes().filter { episode -> episode.isReleasedBy(todayIsoDate) } +fun MetaDetails.releasedMainSeasonEpisodes(todayIsoDate: String): List = + releasedPlayableEpisodes(todayIsoDate) + .filter { episode -> normalizeSeasonNumber(episode.season) > 0 } + fun MetaDetails.previousReleasedEpisodesBefore( target: MetaVideo, todayIsoDate: String, @@ -53,6 +57,14 @@ fun MetaDetails.releasedEpisodesForSeason( .filter { episode -> normalizeSeasonNumber(episode.season) == normalizedSeason } } +fun MetaDetails.hasWatchedAllMainSeasonEpisodes( + todayIsoDate: String, + isEpisodeWatched: (MetaVideo) -> Boolean, +): Boolean { + val mainSeasonEpisodes = releasedMainSeasonEpisodes(todayIsoDate) + return mainSeasonEpisodes.isNotEmpty() && mainSeasonEpisodes.all(isEpisodeWatched) +} + fun MetaDetails.episodePlaybackId(video: MetaVideo): String = buildPlaybackVideoId( parentMetaId = id, 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 de4d1fa1..0a7b99ff 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 @@ -2,6 +2,7 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc @@ -208,6 +209,30 @@ object WatchedRepository { return itemsByKey.containsKey(watchedItemKey(type, id, season, episode)) } + fun reconcileSeriesWatchedState( + meta: MetaDetails, + todayIsoDate: String, + isEpisodeCompleted: (com.nuvio.app.features.details.MetaVideo) -> Boolean = { false }, + ) { + ensureLoaded() + val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode -> + isWatched( + id = meta.id, + type = meta.type, + season = episode.season, + episode = episode.episode, + ) || isEpisodeCompleted(episode) + } + val seriesWatchedItem = meta.toSeriesWatchedItem() + if (shouldMarkSeriesWatched) { + if (!isWatched(id = meta.id, type = meta.type)) { + markWatched(seriesWatchedItem) + } + } else if (isWatched(id = meta.id, type = meta.type)) { + unmarkWatched(seriesWatchedItem) + } + } + private fun pushMarksToServer(items: Collection) { syncScope.launch { runCatching { 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 378ae5d4..5f7559c6 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 @@ -6,6 +6,8 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watched.episodePlaybackId import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.CoroutineScope @@ -269,6 +271,9 @@ object WatchProgressRepository { publish() if (persist) persist() pushScrobbleToServer(entry) + if (entry.isCompleted && entry.isEpisode) { + reconcileCompletedSeriesWatchedState(entry) + } } private fun pushScrobbleToServer(entry: WatchProgressEntry) { @@ -326,6 +331,25 @@ object WatchProgressRepository { ) } + private fun reconcileCompletedSeriesWatchedState(entry: WatchProgressEntry) { + syncScope.launch { + val meta = runCatching { + MetaDetailsRepository.fetch( + type = entry.parentMetaType, + id = entry.parentMetaId, + ) + }.getOrNull() ?: return@launch + + WatchedRepository.reconcileSeriesWatchedState( + meta = meta, + todayIsoDate = CurrentDateProvider.todayIsoDate(), + isEpisodeCompleted = { episode -> + progressForVideo(meta.episodePlaybackId(episode))?.isCompleted == true + }, + ) + } + } + private fun progressKeyForEntry(entry: WatchProgressEntry): String = if (entry.seasonNumber != null && entry.episodeNumber != null) { "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 64daf342..ca489fe2 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -1,7 +1,10 @@ package com.nuvio.app.features.watched +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.MetaVideo import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class WatchedRepositoryTest { @Test @@ -21,5 +24,25 @@ class WatchedRepositoryTest { watchedItemKey(type = "series", id = "show", season = 2, episode = 5), ) } + + @Test + fun fullyWatchedSeries_ignores_specials() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + videos = listOf( + MetaVideo(id = "special", title = "Special", season = 0, episode = 1, released = "2026-03-01"), + MetaVideo(id = "ep1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-08"), + MetaVideo(id = "ep2", title = "Episode 2", season = 1, episode = 2, released = "2026-03-15"), + ), + ) + + val result = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate = "2026-03-30") { episode -> + episode.season == 1 + } + + assertTrue(result) + } }