feat: enhance series watched state management with reconciliation logic and UI updates for episode actions

This commit is contained in:
tapframe 2026-03-30 19:10:38 +05:30
parent 9ed285f29b
commit d2200fe5ab
7 changed files with 154 additions and 17 deletions

View file

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

View file

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

View file

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

View file

@ -35,6 +35,10 @@ fun MetaDetails.toEpisodeWatchedItem(
fun MetaDetails.releasedPlayableEpisodes(todayIsoDate: String): List<MetaVideo> =
sortedPlayableEpisodes().filter { episode -> episode.isReleasedBy(todayIsoDate) }
fun MetaDetails.releasedMainSeasonEpisodes(todayIsoDate: String): List<MetaVideo> =
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,

View file

@ -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<WatchedItem>) {
syncScope.launch {
runCatching {

View file

@ -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}"

View file

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