Improve watched marking parity

This commit is contained in:
tapframe 2026-05-22 02:57:48 +05:30
parent 4e5a32510b
commit efddba9df2
6 changed files with 326 additions and 33 deletions

View file

@ -1019,6 +1019,7 @@
<string name="episode_mark_previous_watched">Mark previous as watched</string>
<string name="episode_mark_season_unwatched">Mark %1$s as unwatched</string>
<string name="episode_mark_season_watched">Mark %1$s as watched</string>
<string name="episode_mark_previous_seasons_watched">Mark previous seasons as watched</string>
<string name="episode_mark_unwatched">Mark as unwatched</string>
<string name="episode_mark_watched">Mark as watched</string>
<string name="home_continue_watching_up_next">Up next</string>

View file

@ -75,6 +75,7 @@ import com.nuvio.app.features.details.components.DetailProductionSection
import com.nuvio.app.features.details.components.DetailSeriesContent
import com.nuvio.app.features.details.components.DetailTrailersSection
import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
import com.nuvio.app.features.details.components.SeasonWatchedActionSheet
import com.nuvio.app.features.details.components.TrailerPlayerPopup
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.library.LibraryRepository
@ -92,6 +93,7 @@ import com.nuvio.app.features.trailer.TrailerPlaybackResolver
import com.nuvio.app.features.trailer.TrailerPlaybackSource
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
import com.nuvio.app.features.watched.releasedPlayableEpisodes
import com.nuvio.app.features.watched.releasedEpisodesForSeason
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
@ -151,6 +153,7 @@ fun MetaDetailsScreen(
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
var selectedSeasonForActions by remember(type, id) { mutableStateOf<Int?>(null) }
val commentsEnabled by remember {
TraktCommentsSettings.ensureLoaded()
TraktCommentsSettings.enabled
@ -337,7 +340,10 @@ fun MetaDetailsScreen(
LibraryRepository.toggleSaved(meta.toLibraryItem(savedAtEpochMs = 0L))
}
}
val movieProgress = watchProgressUiState.byVideoId[meta.id]
val progressByVideoId = remember(watchProgressUiState.entries) {
watchProgressUiState.byVideoId
}
val movieProgress = progressByVideoId[meta.id]
?.takeUnless { it.isCompleted }
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val seriesAction = remember(watchProgressUiState.entries, watchedUiState.items, meta, todayIsoDate, cwPrefs.upNextFromFurthestEpisode) {
@ -715,11 +721,12 @@ fun MetaDetailsScreen(
},
onCommentClick = { review -> selectedComment = review },
onTrailerClick = resolveTrailer,
progressByVideoId = watchProgressUiState.byVideoId,
progressByVideoId = progressByVideoId,
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onSeasonLongPress = { season -> selectedSeasonForActions = season },
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
onCompanyClick = onCompanyClick,
@ -776,12 +783,12 @@ fun MetaDetailsScreen(
)
selectedEpisodeForActions?.let { selectedEpisode ->
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
WatchingState.isEpisodeWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) {
isEpisodeWatchedForActions(
meta = meta,
episode = selectedEpisode,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
@ -796,20 +803,20 @@ fun MetaDetailsScreen(
todayIsoDate = todayIsoDate,
)
}
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = previousEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = seasonEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
EpisodeWatchedActionSheet(
@ -850,6 +857,62 @@ fun MetaDetailsScreen(
)
}
selectedSeasonForActions?.let { selectedSeason ->
val seasonLabel = selectedSeasonLabel(selectedSeason)
val seasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
meta.releasedEpisodesForSeason(
seasonNumber = selectedSeason,
todayIsoDate = todayIsoDate,
)
}
val previousSeasonEpisodes = remember(meta, selectedSeason, todayIsoDate) {
val normalizedSelectedSeason = selectedSeason.coerceAtLeast(0)
meta.releasedPlayableEpisodes(todayIsoDate)
.filter { episode ->
val season = episode.season?.coerceAtLeast(0) ?: 0
season > 0 && season < normalizedSelectedSeason
}
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = seasonEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val canMarkPreviousSeasons = remember(previousSeasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
previousSeasonEpisodes.any { episode ->
!isEpisodeWatchedForActions(
meta = meta,
episode = episode,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
}
SeasonWatchedActionSheet(
seasonLabel = seasonLabel,
isSeasonWatched = isSeasonWatched,
canMarkPreviousSeasons = canMarkPreviousSeasons,
onDismiss = { selectedSeasonForActions = null },
onToggleSeasonWatched = {
WatchingActions.toggleSeasonWatched(
meta = meta,
episodes = seasonEpisodes,
areCurrentlyWatched = isSeasonWatched,
)
},
onMarkPreviousSeasonsWatched = {
WatchingActions.togglePreviousEpisodesWatched(
meta = meta,
episodes = previousSeasonEpisodes,
areCurrentlyWatched = false,
)
},
)
}
if (inAppTrailerPlaybackEnabled) {
TrailerPlayerPopup(
visible = selectedTrailer != null,
@ -970,6 +1033,49 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
}
@Composable
private fun selectedSeasonLabel(season: Int): String =
if (season == 0) {
stringResource(Res.string.episodes_specials)
} else {
stringResource(Res.string.episodes_season, season)
}
private fun isEpisodeWatchedForActions(
meta: MetaDetails,
episode: MetaVideo,
watchedKeys: Set<String>,
progressByVideoId: Map<String, WatchProgressEntry>,
): Boolean {
val episodeVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
)
return progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true ||
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
)
}
private fun areEpisodesWatchedForActions(
meta: MetaDetails,
episodes: Collection<MetaVideo>,
watchedKeys: Set<String>,
progressByVideoId: Map<String, WatchProgressEntry>,
): Boolean = episodes.isNotEmpty() && episodes.all { episode ->
isEpisodeWatchedForActions(
meta = meta,
episode = episode,
watchedKeys = watchedKeys,
progressByVideoId = progressByVideoId,
)
}
private fun extractImdbId(value: String?): String? =
value
?.trim()
@ -1026,6 +1132,7 @@ private fun ConfiguredMetaSections(
blurUnwatchedEpisodes: Boolean,
onEpisodeClick: (MetaVideo) -> Unit,
onEpisodeLongPress: (MetaVideo) -> Unit,
onSeasonLongPress: (Int) -> Unit,
onOpenMeta: ((MetaPreview) -> Unit)?,
onCastClick: ((MetaPerson, String?) -> Unit)?,
onCompanyClick: ((MetaCompany, String) -> Unit)?,
@ -1120,6 +1227,7 @@ private fun ConfiguredMetaSections(
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
onEpisodeClick = onEpisodeClick,
onEpisodeLongPress = onEpisodeLongPress,
onSeasonLongPress = onSeasonLongPress,
)
}
}

View file

@ -100,6 +100,7 @@ fun DetailSeriesContent(
blurUnwatchedEpisodes: Boolean = false,
onEpisodeClick: ((MetaVideo) -> Unit)? = null,
onEpisodeLongPress: ((MetaVideo) -> Unit)? = null,
onSeasonLongPress: ((Int) -> Unit)? = null,
) {
val hasVideos = meta.videos.isNotEmpty()
if (meta.type != "series" && !hasVideos) return
@ -230,12 +231,14 @@ fun DetailSeriesContent(
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
onLongPress = onSeasonLongPress,
)
SeasonViewMode.Text -> SeasonTextChipScrollRow(
seasons = seasons,
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
onLongPress = onSeasonLongPress,
)
}
}
@ -245,6 +248,7 @@ fun DetailSeriesContent(
currentSeason = currentSeason,
sizing = sizing,
onSelect = { selectedSeasonOverride = it },
onLongPress = onSeasonLongPress,
)
}
}
@ -372,12 +376,14 @@ private fun SeasonViewModeToggle(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SeasonTextChipScrollRow(
seasons: List<Int>,
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
onLongPress: ((Int) -> Unit)?,
) {
val seasonListState = rememberLazyListState()
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@ -411,7 +417,10 @@ private fun SeasonTextChipScrollRow(
Color.Transparent
},
)
.clickable { onSelect(season) }
.combinedClickable(
onClick = { onSelect(season) },
onLongClick = onLongPress?.let { handler -> { handler(season) } },
)
.padding(
horizontal = sizing.seasonChipHorizontalPadding,
vertical = sizing.seasonChipVerticalPadding,
@ -443,6 +452,7 @@ private fun SeasonPosterScrollRow(
currentSeason: Int,
sizing: SeriesContentSizing,
onSelect: (Int) -> Unit,
onLongPress: ((Int) -> Unit)?,
) {
val seasonListState = rememberLazyListState()
var hasPositionedSeasonRow by remember(seasons) { mutableStateOf(false) }
@ -475,11 +485,13 @@ private fun SeasonPosterScrollRow(
isSelected = season == currentSeason,
sizing = sizing,
onClick = { onSelect(season) },
onLongClick = onLongPress?.let { handler -> { handler(season) } },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SeasonPosterButton(
label: String,
@ -487,11 +499,15 @@ private fun SeasonPosterButton(
isSelected: Boolean,
sizing: SeriesContentSizing,
onClick: () -> Unit,
onLongClick: (() -> Unit)?,
) {
Column(
modifier = Modifier
.width(sizing.seasonPosterWidth)
.clickable(onClick = onClick),
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(

View file

@ -1,14 +1,9 @@
package com.nuvio.app.features.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.DoneAll
@ -135,6 +130,73 @@ fun EpisodeWatchedActionSheet(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SeasonWatchedActionSheet(
seasonLabel: String,
isSeasonWatched: Boolean,
canMarkPreviousSeasons: Boolean,
onDismiss: () -> Unit,
onToggleSeasonWatched: () -> Unit,
onMarkPreviousSeasonsWatched: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(
onDismissRequest = {
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = nuvioSafeBottomPadding(16.dp)),
) {
Text(
text = seasonLabel,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
)
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.PlaylistAddCheckCircle,
title = if (isSeasonWatched) {
stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
} else {
stringResource(Res.string.episode_mark_season_watched, seasonLabel)
},
onClick = {
onToggleSeasonWatched()
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
)
if (canMarkPreviousSeasons) {
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = Icons.Default.DoneAll,
title = stringResource(Res.string.episode_mark_previous_seasons_watched),
onClick = {
onMarkPreviousSeasonsWatched()
coroutineScope.launch {
dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss)
}
},
)
}
}
}
}
@Composable
private fun EpisodeActionSheetHeader(
episode: MetaVideo,

View file

@ -25,6 +25,7 @@ import kotlinx.serialization.json.Json
@Serializable
private data class StoredWatchedPayload(
val items: List<WatchedItem> = emptyList(),
val lastSuccessfulPushEpochMs: Long = 0L,
)
object WatchedRepository {
@ -43,6 +44,7 @@ object WatchedRepository {
private var hasLoaded = false
private var currentProfileId: Int = 1
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
private var lastSuccessfulPushEpochMs: Long = 0L
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
private fun activePullSyncAdapter(): WatchedSyncAdapter =
@ -62,6 +64,7 @@ object WatchedRepository {
hasLoaded = false
currentProfileId = 1
itemsByKey.clear()
lastSuccessfulPushEpochMs = 0L
_uiState.value = WatchedUiState()
}
@ -72,13 +75,16 @@ object WatchedRepository {
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
if (payload.isNotEmpty()) {
val items = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload).items
}.getOrDefault(emptyList())
itemsByKey = items
val storedPayload = runCatching {
json.decodeFromString<StoredWatchedPayload>(payload)
}.getOrDefault(StoredWatchedPayload())
lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs
itemsByKey = storedPayload.items
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
} else {
lastSuccessfulPushEpochMs = 0L
}
publish()
@ -88,16 +94,23 @@ object WatchedRepository {
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
currentProfileId = profileId
val pullStartedEpochMs = WatchedClock.nowEpochMs()
val localBeforePull = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.toList()
val lastPushEpochMs = lastSuccessfulPushEpochMs
runCatching {
val serverItems = activePullSyncAdapter().pull(
profileId = profileId,
pageSize = watchedItemsPageSize,
)
itemsByKey = serverItems
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
itemsByKey = mergeWatchedItemsPreservingUnsynced(
serverItems = serverItems,
localItems = localBeforePull,
lastSuccessfulPushEpochMs = lastPushEpochMs,
pullStartedEpochMs = pullStartedEpochMs,
).toMutableMap()
hasLoaded = true
publish()
persist()
@ -213,6 +226,7 @@ object WatchedRepository {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
pushToActiveTargets(profileId = profileId, items = items)
recordSuccessfulPush(profileId = profileId, items = items)
}.onFailure { e ->
log.e(e) { "Failed to push watched items" }
}
@ -252,11 +266,24 @@ object WatchedRepository {
items = itemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
),
),
)
}
private fun recordSuccessfulPush(profileId: Int, items: Collection<WatchedItem>) {
if (profileId != currentProfileId) return
val latestPushed = items
.asSequence()
.map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) }
.maxOrNull()
?: return
if (latestPushed <= lastSuccessfulPushEpochMs) return
lastSuccessfulPushEpochMs = latestPushed
persist()
}
private fun shouldUseTraktWatchedSync(): Boolean =
shouldUseTraktWatchedSync(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
@ -294,6 +321,33 @@ object WatchedRepository {
}
}
internal fun mergeWatchedItemsPreservingUnsynced(
serverItems: Collection<WatchedItem>,
localItems: Collection<WatchedItem>,
lastSuccessfulPushEpochMs: Long,
pullStartedEpochMs: Long,
): Map<String, WatchedItem> {
val merged = serverItems
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
localItems
.map(WatchedItem::normalizedMarkedAt)
.forEach { localItem ->
val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode)
if (key in merged) return@forEach
val markedAt = localItem.markedAtEpochMs
val wasMarkedAfterLastPush = lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs
val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs
if (wasMarkedAfterLastPush || wasMarkedDuringPull) {
merged[key] = localItem
}
}
return merged
}
internal fun shouldUseTraktWatchedSync(
isAuthenticated: Boolean,
source: WatchProgressSource,

View file

@ -44,5 +44,57 @@ class WatchedRepositoryTest {
assertTrue(result)
}
}
@Test
fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() {
val serverItem = WatchedItem(
id = "show",
type = "series",
name = "Episode 1",
season = 1,
episode = 1,
markedAtEpochMs = 1_000L,
)
val unsyncedLocalItem = WatchedItem(
id = "show",
type = "series",
name = "Episode 2",
season = 1,
episode = 2,
markedAtEpochMs = 3_000L,
)
val merged = mergeWatchedItemsPreservingUnsynced(
serverItems = listOf(serverItem),
localItems = listOf(serverItem, unsyncedLocalItem),
lastSuccessfulPushEpochMs = 2_000L,
pullStartedEpochMs = 4_000L,
)
assertEquals(
setOf("series:show:1:1", "series:show:1:2"),
merged.keys,
)
}
@Test
fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() {
val oldLocalItem = WatchedItem(
id = "show",
type = "series",
name = "Episode 1",
season = 1,
episode = 1,
markedAtEpochMs = 1_000L,
)
val merged = mergeWatchedItemsPreservingUnsynced(
serverItems = emptyList(),
localItems = listOf(oldLocalItem),
lastSuccessfulPushEpochMs = 2_000L,
pullStartedEpochMs = 4_000L,
)
assertTrue(merged.isEmpty())
}
}