mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 15:49:30 +00:00
feat: enhance series watched state management with reconciliation logic and UI updates for episode actions
This commit is contained in:
parent
9ed285f29b
commit
d2200fe5ab
7 changed files with 154 additions and 17 deletions
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) } },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue