feat: refactor watched state management by introducing WatchingActions and WatchingState for improved episode tracking and UI integration

This commit is contained in:
tapframe 2026-03-30 20:01:55 +05:30
parent d2200fe5ab
commit d00b4ae2e1
24 changed files with 1284 additions and 497 deletions

View file

@ -98,15 +98,10 @@ import com.nuvio.app.features.streams.StreamsRepository
import com.nuvio.app.features.streams.StreamsScreen
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.releasedPlayableEpisodes
import com.nuvio.app.features.watched.toEpisodeWatchedItem
import com.nuvio.app.features.watched.toSeriesWatchedItem
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.toWatchedItem
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import nuvio.composeapp.generated.resources.Res
@ -693,7 +688,10 @@ private fun MainAppContent(
LibraryRepository.isSaved(preview.id)
} == true,
isWatched = selectedPosterForActions?.let { preview ->
watchedUiState.watchedKeys.contains(watchedItemKey(preview.type, preview.id))
WatchingState.isPosterWatched(
watchedKeys = watchedUiState.watchedKeys,
item = preview,
)
} == true,
onDismiss = { selectedPosterForActions = null },
onToggleLibrary = {
@ -704,7 +702,7 @@ private fun MainAppContent(
onToggleWatched = {
selectedPosterForActions?.let { preview ->
coroutineScope.launch {
togglePosterWatched(preview)
WatchingActions.togglePosterWatched(preview)
}
}
},
@ -839,35 +837,3 @@ private fun BoxScope.keepAliveTab(
content()
}
}
private suspend fun togglePosterWatched(preview: MetaPreview) {
if (preview.type != "series") {
WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
return
}
val isCurrentlyWatched = WatchedRepository.isWatched(
id = preview.id,
type = preview.type,
)
val meta = MetaDetailsRepository.fetch(type = preview.type, id = preview.id)
if (meta == null) {
WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
return
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val seriesItems = buildList {
add(meta.toSeriesWatchedItem())
addAll(meta.releasedPlayableEpisodes(todayIsoDate).map(meta::toEpisodeWatchedItem))
}
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(seriesItems)
} else {
WatchedRepository.markWatched(seriesItems)
WatchProgressRepository.clearProgress(
meta.releasedPlayableEpisodes(todayIsoDate).map(meta::episodePlaybackId),
)
}
}

View file

@ -46,13 +46,13 @@ import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.library.toLibraryItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.previousReleasedEpisodesBefore
import com.nuvio.app.features.watched.releasedEpisodesForSeason
import com.nuvio.app.features.watched.toEpisodeWatchedItem
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.application.WatchingState
@Composable
fun MetaDetailsScreen(
@ -162,17 +162,6 @@ 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 ->
@ -334,29 +323,14 @@ 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,
id = meta.id,
season = selectedEpisode.season,
episode = selectedEpisode.episode,
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys) {
WatchingState.isEpisodeWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = selectedEpisode,
)
}
val isSelectedEpisodeWatched = remember(watchedUiState.watchedKeys, selectedEpisodeWatchedKey) {
watchedUiState.watchedKeys.contains(selectedEpisodeWatchedKey)
}
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
meta.previousReleasedEpisodesBefore(
target = selectedEpisode,
@ -370,10 +344,20 @@ fun MetaDetailsScreen(
)
}
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys) {
previousEpisodes.isNotEmpty() && previousEpisodes.all(isEpisodeWatched)
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episodes = previousEpisodes,
)
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys) {
seasonEpisodes.isNotEmpty() && seasonEpisodes.all(isEpisodeWatched)
WatchingState.areEpisodesWatched(
watchedKeys = watchedUiState.watchedKeys,
metaType = meta.type,
metaId = meta.id,
episodes = seasonEpisodes,
)
}
EpisodeWatchedActionSheet(
episode = selectedEpisode,
@ -384,38 +368,25 @@ fun MetaDetailsScreen(
isSeasonWatched = isSeasonWatched,
onDismiss = { selectedEpisodeForActions = null },
onToggleWatched = {
val watchedItem = meta.toEpisodeWatchedItem(selectedEpisode)
if (isSelectedEpisodeWatched) {
WatchedRepository.unmarkWatched(watchedItem)
} else {
WatchedRepository.markWatched(watchedItem)
WatchProgressRepository.clearProgress(meta.episodePlaybackId(selectedEpisode))
}
syncSeriesWatchedState()
WatchingActions.toggleEpisodeWatched(
meta = meta,
episode = selectedEpisode,
isCurrentlyWatched = isSelectedEpisodeWatched,
)
},
onTogglePreviousWatched = {
val watchedItems = previousEpisodes.map(meta::toEpisodeWatchedItem)
if (arePreviousEpisodesWatched) {
WatchedRepository.unmarkWatched(watchedItems)
} else {
WatchedRepository.markWatched(watchedItems)
WatchProgressRepository.clearProgress(
previousEpisodes.map(meta::episodePlaybackId),
)
}
syncSeriesWatchedState()
WatchingActions.togglePreviousEpisodesWatched(
meta = meta,
episodes = previousEpisodes,
areCurrentlyWatched = arePreviousEpisodesWatched,
)
},
onToggleSeasonWatched = {
val watchedItems = seasonEpisodes.map(meta::toEpisodeWatchedItem)
if (isSeasonWatched) {
WatchedRepository.unmarkWatched(watchedItems)
} else {
WatchedRepository.markWatched(watchedItems)
WatchProgressRepository.clearProgress(
seasonEpisodes.map(meta::episodePlaybackId),
)
}
syncSeriesWatchedState()
WatchingActions.toggleSeasonWatched(
meta = meta,
episodes = seasonEpisodes,
areCurrentlyWatched = isSeasonWatched,
)
},
)
}

View file

@ -2,8 +2,19 @@ package com.nuvio.app.features.details
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watchprogress.resumeEntryForSeries
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.WatchingReleasedEpisode
import com.nuvio.app.features.watching.domain.WatchingSeriesPrimaryAction
import com.nuvio.app.features.watching.domain.WatchingWatchedRecord
import com.nuvio.app.features.watching.domain.buildPlaybackVideoId
import com.nuvio.app.features.watching.domain.decideSeriesPrimaryAction
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
import com.nuvio.app.features.watching.domain.playLabel
import com.nuvio.app.features.watching.domain.resumeLabel
import com.nuvio.app.features.watching.domain.upNextLabel
internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
videos
@ -14,18 +25,19 @@ internal fun MetaDetails.firstPlayableEpisode(): MetaVideo? =
sortedPlayableEpisodes().firstOrNull()
internal fun MetaDetails.firstReleasedPlayableEpisode(todayIsoDate: String): MetaVideo? =
sortedPlayableEpisodes().firstOrNull { it.isReleasedBy(todayIsoDate) }
sortedPlayableEpisodes().firstOrNull { video ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = video.released)
}
internal fun MetaDetails.nextReleasedEpisodeAfter(
completedEntry: WatchProgressEntry,
todayIsoDate: String,
): MetaVideo? {
return nextReleasedEpisodeAfter(
): MetaVideo? =
nextReleasedEpisodeAfter(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate,
)
}
internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber: Int?,
@ -34,21 +46,23 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
): MetaVideo? {
val sortedEpisodes = sortedPlayableEpisodes()
val watchedVideoId = buildPlaybackVideoId(
parentMetaId = id,
content = WatchingContentRef(type = type, id = id),
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
)
return sortedEpisodes
.dropWhile { episode ->
buildPlaybackVideoId(
parentMetaId = id,
content = WatchingContentRef(type = type, id = id),
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
) != watchedVideoId
}
.drop(1)
.firstOrNull { it.isReleasedBy(todayIsoDate) }
.firstOrNull { episode ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released)
}
}
internal data class SeriesPrimaryAction(
@ -65,86 +79,26 @@ internal fun MetaDetails.seriesPrimaryAction(
entries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
todayIsoDate: String,
): SeriesPrimaryAction? {
val resumeEntry = entries.resumeEntryForSeries(id)
val latestCompleted = latestCompletedSeriesEpisode(
parentMetaId = id,
parentMetaType = type,
progressEntries = entries,
watchedItems = watchedItems,
)
val shouldPreferResume = resumeEntry != null &&
(latestCompleted == null || resumeEntry.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs)
if (shouldPreferResume) {
return SeriesPrimaryAction(
label = resumeEntry.resumeLabel(),
videoId = resumeEntry.videoId,
seasonNumber = resumeEntry.seasonNumber,
episodeNumber = resumeEntry.episodeNumber,
episodeTitle = resumeEntry.episodeTitle,
episodeThumbnail = resumeEntry.episodeThumbnail,
resumePositionMs = resumeEntry.lastPositionMs,
)
}
val nextEpisode = if (latestCompleted != null) {
nextReleasedEpisodeAfter(
seasonNumber = latestCompleted.seasonNumber,
episodeNumber = latestCompleted.episodeNumber,
todayIsoDate = todayIsoDate,
)
} else {
firstReleasedPlayableEpisode(todayIsoDate)
}
return nextEpisode?.let { episode ->
SeriesPrimaryAction(
label = if (latestCompleted != null) episode.upNextLabel() else episode.playLabel(),
videoId = buildPlaybackVideoId(
parentMetaId = id,
seasonNumber = episode.season,
episodeNumber = episode.episode,
fallbackVideoId = episode.id,
),
seasonNumber = episode.season,
episodeNumber = episode.episode,
episodeTitle = episode.title,
episodeThumbnail = episode.thumbnail,
resumePositionMs = null,
)
}
}
): SeriesPrimaryAction? =
decideSeriesPrimaryAction(
content = WatchingContentRef(type = type, id = id),
episodes = videos.map(MetaVideo::toDomainReleasedEpisode),
progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord),
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
todayIsoDate = todayIsoDate,
)?.toLegacySeriesPrimaryAction()
internal fun MetaVideo.playLabel(): String =
if (season != null && episode != null) {
"Play S${season}E${episode}"
} else {
"Play"
}
playLabel(seasonNumber = season, episodeNumber = episode)
internal fun MetaVideo.upNextLabel(): String =
if (season != null && episode != null) {
"Up Next S${season}E${episode}"
} else {
"Up Next"
}
upNextLabel(seasonNumber = season, episodeNumber = episode)
internal fun WatchProgressEntry.resumeLabel(): String =
if (seasonNumber != null && episodeNumber != null) {
"Resume S${seasonNumber}E${episodeNumber}"
} else {
"Resume"
}
resumeLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber)
internal fun MetaVideo.isReleasedBy(todayIsoDate: String): Boolean {
val releaseDate = released
?.substringBefore('T')
?.takeIf { it.length == 10 }
?: return true
return releaseDate <= todayIsoDate
}
internal fun MetaVideo.isReleasedBy(todayIsoDate: String): Boolean =
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released)
internal data class CompletedSeriesEpisode(
val seasonNumber: Int,
@ -157,43 +111,58 @@ internal fun latestCompletedSeriesEpisode(
parentMetaType: String,
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
): CompletedSeriesEpisode? {
val progressMarker = progressEntries
.asSequence()
.filter { entry ->
entry.parentMetaId == parentMetaId &&
entry.isCompleted &&
entry.seasonNumber != null &&
entry.episodeNumber != null
}
.map { entry ->
CompletedSeriesEpisode(
seasonNumber = entry.seasonNumber ?: return@map null,
episodeNumber = entry.episodeNumber ?: return@map null,
markedAtEpochMs = entry.lastUpdatedEpochMs,
)
}
.filterNotNull()
.maxByOrNull { marker -> marker.markedAtEpochMs }
): CompletedSeriesEpisode? =
latestCompletedSeriesEpisode(
content = WatchingContentRef(type = parentMetaType, id = parentMetaId),
progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord),
watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord),
)?.toLegacyCompletedEpisode()
val watchedMarker = watchedItems
.asSequence()
.filter { item ->
item.id == parentMetaId &&
item.type == parentMetaType &&
item.season != null &&
item.episode != null
}
.map { item ->
CompletedSeriesEpisode(
seasonNumber = item.season ?: return@map null,
episodeNumber = item.episode ?: return@map null,
markedAtEpochMs = item.markedAtEpochMs,
)
}
.filterNotNull()
.maxByOrNull { marker -> marker.markedAtEpochMs }
private fun MetaVideo.toDomainReleasedEpisode(): WatchingReleasedEpisode =
WatchingReleasedEpisode(
videoId = id,
seasonNumber = season,
episodeNumber = episode,
title = title,
thumbnail = thumbnail,
releasedDate = released,
)
return listOfNotNull(progressMarker, watchedMarker)
.maxByOrNull { marker -> marker.markedAtEpochMs }
}
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
WatchingProgressRecord(
content = WatchingContentRef(type = parentMetaType, id = parentMetaId),
videoId = videoId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastUpdatedEpochMs = lastUpdatedEpochMs,
lastPositionMs = lastPositionMs,
isCompleted = isCompleted,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
)
private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
WatchingWatchedRecord(
content = WatchingContentRef(type = type, id = id),
seasonNumber = season,
episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs,
)
private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction =
SeriesPrimaryAction(
label = label,
videoId = videoId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
resumePositionMs = resumePositionMs,
)
private fun WatchingCompletedEpisode.toLegacyCompletedEpisode(): CompletedSeriesEpisode =
CompletedSeriesEpisode(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
markedAtEpochMs = markedAtEpochMs,
)

View file

@ -49,6 +49,7 @@ import com.nuvio.app.features.details.normalizeSeasonNumber
import com.nuvio.app.features.details.seasonSortKey
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.application.WatchingState
private val log = Logger.withTag("SeriesContent")
@ -210,13 +211,11 @@ fun DetailSeriesContent(
fallbackImage = meta.background ?: meta.poster,
progressEntry = progressByVideoId[episodeVideoId],
isWatched = progressByVideoId[episodeVideoId]?.isCompleted == true ||
watchedKeys.contains(
com.nuvio.app.features.watched.watchedItemKey(
type = meta.type,
id = meta.id,
season = episode.season,
episode = episode.episode,
),
WatchingState.isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = meta.type,
metaId = meta.id,
episode = episode,
),
sizing = sizing,
onClick = { onEpisodeClick?.invoke(episode) },

View file

@ -22,13 +22,15 @@ import com.nuvio.app.features.home.components.HomeHeroSection
import com.nuvio.app.features.home.components.HomeSkeletonHero
import com.nuvio.app.features.home.components.HomeSkeletonRow
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.isEpisode
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.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
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
@Composable
fun HomeScreen(
@ -54,64 +56,30 @@ fun HomeScreen(
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val latestResumableBySeries = remember(watchProgressUiState.entries) {
watchProgressUiState.entries
.filter { !it.isCompleted && it.isEpisode }
.groupBy { it.parentMetaId }
.mapValues { (_, entries) -> entries.maxByOrNull { it.lastUpdatedEpochMs } }
}
val latestCompletedBySeries = remember(watchProgressUiState.entries, watchedUiState.items) {
val progressCandidates = watchProgressUiState.entries
.filter { it.isCompleted && it.isEpisode }
.map { entry ->
CompletedSeriesCandidate(
parentMetaId = entry.parentMetaId,
parentMetaType = entry.parentMetaType,
seasonNumber = entry.seasonNumber ?: return@map null,
episodeNumber = entry.episodeNumber ?: return@map null,
markedAtEpochMs = entry.lastUpdatedEpochMs,
)
}
.filterNotNull()
val watchedCandidates = watchedUiState.items
.filter { it.isEpisode }
.map { item ->
CompletedSeriesCandidate(
parentMetaId = item.id,
parentMetaType = item.type,
seasonNumber = item.season ?: return@map null,
episodeNumber = item.episode ?: return@map null,
markedAtEpochMs = item.markedAtEpochMs,
)
}
.filterNotNull()
(progressCandidates + watchedCandidates)
.groupBy { it.parentMetaId }
.mapNotNull { (parentMetaId, entries) ->
entries.maxByOrNull { it.markedAtEpochMs }?.let { parentMetaId to it }
}
.toMap()
WatchingState.latestCompletedBySeries(
progressEntries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
)
}
val completedSeriesCandidates = remember(latestCompletedBySeries, latestResumableBySeries) {
latestCompletedBySeries.values.filter { completed ->
val latestResume = latestResumableBySeries[completed.parentMetaId]
latestResume == null || latestResume.lastUpdatedEpochMs <= completed.markedAtEpochMs
val completedSeriesCandidates = remember(latestCompletedBySeries) {
latestCompletedBySeries.map { (content, completed) ->
CompletedSeriesCandidate(
content = content,
seasonNumber = completed.seasonNumber,
episodeNumber = completed.episodeNumber,
markedAtEpochMs = completed.markedAtEpochMs,
)
}
}
val visibleContinueWatchingEntries = remember(
watchProgressUiState.continueWatchingEntries,
watchProgressUiState.entries,
latestCompletedBySeries,
) {
watchProgressUiState.continueWatchingEntries.filter { entry ->
if (!entry.isEpisode) {
true
} else {
val latestCompleted = latestCompletedBySeries[entry.parentMetaId]
latestCompleted == null || entry.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs
}
}
WatchingState.visibleContinueWatchingEntries(
progressEntries = watchProgressUiState.entries,
latestCompletedBySeries = latestCompletedBySeries,
)
}
var nextUpItemsBySeries by remember { mutableStateOf<Map<String, Pair<Long, ContinueWatchingItem>>>(emptyMap()) }
val continueWatchingItems = remember(
@ -175,15 +143,15 @@ fun HomeScreen(
val resolvedItems = mutableMapOf<String, Pair<Long, ContinueWatchingItem>>()
completedSeriesCandidates.forEach { completedEntry ->
val meta = MetaDetailsRepository.fetch(
type = completedEntry.parentMetaType,
id = completedEntry.parentMetaId,
type = completedEntry.content.type,
id = completedEntry.content.id,
) ?: return@forEach
val nextEpisode = meta.nextReleasedEpisodeAfter(
seasonNumber = completedEntry.seasonNumber,
episodeNumber = completedEntry.episodeNumber,
todayIsoDate = todayIsoDate,
) ?: return@forEach
resolvedItems[completedEntry.parentMetaId] =
resolvedItems[completedEntry.content.id] =
completedEntry.markedAtEpochMs to completedEntry.toContinueWatchingSeed(meta).toUpNextContinueWatchingItem(nextEpisode)
}
nextUpItemsBySeries = resolvedItems
@ -316,19 +284,18 @@ fun HomeScreen(
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
private data class CompletedSeriesCandidate(
val parentMetaId: String,
val parentMetaType: String,
val content: WatchingContentRef,
val seasonNumber: Int,
val episodeNumber: Int,
val markedAtEpochMs: Long,
)
private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.features.details.MetaDetails) =
com.nuvio.app.features.watchprogress.WatchProgressEntry(
contentType = parentMetaType,
parentMetaId = parentMetaId,
parentMetaType = parentMetaType,
videoId = "${parentMetaId}:${seasonNumber}:${episodeNumber}",
WatchProgressEntry(
contentType = content.type,
parentMetaId = content.id,
parentMetaType = content.type,
videoId = "${content.id}:${seasonNumber}:${episodeNumber}",
title = meta.name,
logo = meta.logo,
poster = meta.poster,

View file

@ -8,7 +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
import com.nuvio.app.features.watching.application.WatchingState
@Composable
fun HomeCatalogRowSection(
@ -32,7 +32,10 @@ fun HomeCatalogRowSection(
) { item ->
HomePosterCard(
item = item,
isWatched = watchedKeys.contains(watchedItemKey(item.type, item.id)),
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)

View file

@ -46,7 +46,7 @@ import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.PosterShape
import com.nuvio.app.features.home.components.HomeEmptyStateCard
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watching.application.WatchingState
internal fun LazyListScope.discoverContent(
state: DiscoverUiState,
@ -247,7 +247,10 @@ private fun DiscoverGridRow(
DiscoverPosterTile(
item = item,
modifier = Modifier.weight(1f),
isWatched = watchedKeys.contains(watchedItemKey(item.type, item.id)),
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)

View file

@ -2,10 +2,14 @@ package com.nuvio.app.features.watched
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.details.isReleasedBy
import com.nuvio.app.features.details.normalizeSeasonNumber
import com.nuvio.app.features.details.sortedPlayableEpisodes
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.WatchingReleasedEpisode
import com.nuvio.app.features.watching.domain.buildPlaybackVideoId
import com.nuvio.app.features.watching.domain.hasWatchedAllMainSeasonEpisodes as domainHasWatchedAllMainSeasonEpisodes
import com.nuvio.app.features.watching.domain.releasedEpisodes
import com.nuvio.app.features.watching.domain.releasedMainSeasonEpisodes as domainReleasedMainSeasonEpisodes
fun MetaDetails.toSeriesWatchedItem(markedAtEpochMs: Long = 0L): WatchedItem =
WatchedItem(
@ -32,12 +36,24 @@ fun MetaDetails.toEpisodeWatchedItem(
markedAtEpochMs = markedAtEpochMs,
)
fun MetaDetails.releasedPlayableEpisodes(todayIsoDate: String): List<MetaVideo> =
sortedPlayableEpisodes().filter { episode -> episode.isReleasedBy(todayIsoDate) }
fun MetaDetails.releasedPlayableEpisodes(todayIsoDate: String): List<MetaVideo> {
val domainEpisodes = releasedEpisodes(
episodes = sortedPlayableEpisodes().map(MetaVideo::toDomainReleasedEpisode),
todayIsoDate = todayIsoDate,
)
val releasedIds = domainEpisodes.mapTo(linkedSetOf()) { episode -> episode.videoId }
return sortedPlayableEpisodes().filter { episode -> episode.id in releasedIds }
}
fun MetaDetails.releasedMainSeasonEpisodes(todayIsoDate: String): List<MetaVideo> =
releasedPlayableEpisodes(todayIsoDate)
.filter { episode -> normalizeSeasonNumber(episode.season) > 0 }
run {
val domainEpisodes = domainReleasedMainSeasonEpisodes(
episodes = sortedPlayableEpisodes().map(MetaVideo::toDomainReleasedEpisode),
todayIsoDate = todayIsoDate,
)
val releasedIds = domainEpisodes.mapTo(linkedSetOf()) { episode -> episode.videoId }
sortedPlayableEpisodes().filter { episode -> episode.id in releasedIds }
}
fun MetaDetails.previousReleasedEpisodesBefore(
target: MetaVideo,
@ -60,15 +76,28 @@ fun MetaDetails.releasedEpisodesForSeason(
fun MetaDetails.hasWatchedAllMainSeasonEpisodes(
todayIsoDate: String,
isEpisodeWatched: (MetaVideo) -> Boolean,
): Boolean {
val mainSeasonEpisodes = releasedMainSeasonEpisodes(todayIsoDate)
return mainSeasonEpisodes.isNotEmpty() && mainSeasonEpisodes.all(isEpisodeWatched)
}
): Boolean = domainHasWatchedAllMainSeasonEpisodes(
episodes = sortedPlayableEpisodes().map(MetaVideo::toDomainReleasedEpisode),
todayIsoDate = todayIsoDate,
isEpisodeWatched = { domainEpisode ->
sortedPlayableEpisodes().firstOrNull { episode -> episode.id == domainEpisode.videoId }?.let(isEpisodeWatched) == true
},
)
fun MetaDetails.episodePlaybackId(video: MetaVideo): String =
buildPlaybackVideoId(
parentMetaId = id,
content = WatchingContentRef(type = type, id = id),
seasonNumber = video.season,
episodeNumber = video.episode,
fallbackVideoId = video.id,
)
private fun MetaVideo.toDomainReleasedEpisode(): WatchingReleasedEpisode =
WatchingReleasedEpisode(
videoId = id,
seasonNumber = season,
episodeNumber = episode,
title = title,
thumbnail = thumbnail,
releasedDate = released,
)

View file

@ -1,6 +1,8 @@
package com.nuvio.app.features.watched
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.watchedKey
import kotlinx.serialization.Serializable
@Serializable
@ -39,5 +41,9 @@ fun watchedItemKey(
id: String,
season: Int? = null,
episode: Int? = null,
): String = "${type.trim()}:${id.trim()}:${season ?: -1}:${episode ?: -1}"
): String = watchedKey(
content = WatchingContentRef(type = type, id = id),
seasonNumber = season,
episodeNumber = episode,
)

View file

@ -1,11 +1,10 @@
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
import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter
import com.nuvio.app.features.watching.sync.WatchedSyncAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -13,37 +12,16 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
@Serializable
private data class StoredWatchedPayload(
val items: List<WatchedItem> = emptyList(),
)
@Serializable
private data class WatchedSyncItem(
@SerialName("content_id") val contentId: String,
@SerialName("content_type") val contentType: String,
val title: String = "",
val season: Int? = null,
val episode: Int? = null,
@SerialName("watched_at") val watchedAt: Long = 0,
)
@Serializable
private data class WatchedDeleteKey(
@SerialName("content_id") val contentId: String,
val season: Int? = null,
val episode: Int? = null,
)
object WatchedRepository {
private const val watchedItemsPageSize = 900
@ -60,6 +38,7 @@ object WatchedRepository {
private var hasLoaded = false
private var currentProfileId: Int = 1
private var itemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter
fun ensureLoaded() {
if (hasLoaded) return
@ -97,33 +76,14 @@ object WatchedRepository {
suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId
runCatching {
val serverItems = mutableListOf<WatchedSyncItem>()
var page = 1
val serverItems = syncAdapter.pull(
profileId = profileId,
pageSize = watchedItemsPageSize,
)
while (true) {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_page", page)
put("p_page_size", watchedItemsPageSize)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watched_items", params)
val pageItems = result.decodeList<WatchedSyncItem>()
serverItems += pageItems
if (pageItems.size < watchedItemsPageSize) break
page += 1
}
itemsByKey = serverItems.map { syncItem ->
WatchedItem(
id = syncItem.contentId,
type = syncItem.contentType,
name = syncItem.title,
season = syncItem.season,
episode = syncItem.episode,
markedAtEpochMs = syncItem.watchedAt,
)
}.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }.toMutableMap()
itemsByKey = serverItems
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
hasLoaded = true
publish()
persist()
@ -238,21 +198,7 @@ object WatchedRepository {
runCatching {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
val syncItems = items.map { item ->
WatchedSyncItem(
contentId = item.id,
contentType = item.type,
title = item.name,
season = item.season,
episode = item.episode,
watchedAt = item.markedAtEpochMs,
)
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_items", json.encodeToJsonElement(syncItems))
}
SupabaseProvider.client.postgrest.rpc("sync_push_watched_items", params)
syncAdapter.push(profileId = profileId, items = items)
}.onFailure { e ->
log.e(e) { "Failed to push watched items" }
}
@ -264,18 +210,7 @@ object WatchedRepository {
runCatching {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
val keys = items.map { item ->
WatchedDeleteKey(
contentId = item.id,
season = item.season,
episode = item.episode,
)
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", json.encodeToJsonElement(keys))
}
SupabaseProvider.client.postgrest.rpc("sync_delete_watched_items", params)
syncAdapter.delete(profileId = profileId, items = items)
}.onFailure { e ->
log.e(e) { "Failed to push watched item delete" }
}

View file

@ -0,0 +1,137 @@
package com.nuvio.app.features.watching.application
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.releasedPlayableEpisodes
import com.nuvio.app.features.watched.toEpisodeWatchedItem
import com.nuvio.app.features.watched.toSeriesWatchedItem
import com.nuvio.app.features.watched.toWatchedItem
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
object WatchingActions {
private val actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
suspend fun togglePosterWatched(preview: MetaPreview) {
if (preview.type != "series") {
WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
return
}
val isCurrentlyWatched = WatchedRepository.isWatched(
id = preview.id,
type = preview.type,
)
val meta = MetaDetailsRepository.fetch(type = preview.type, id = preview.id)
if (meta == null) {
WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
return
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val seriesItems = buildList {
add(meta.toSeriesWatchedItem())
addAll(meta.releasedPlayableEpisodes(todayIsoDate).map(meta::toEpisodeWatchedItem))
}
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(seriesItems)
} else {
WatchedRepository.markWatched(seriesItems)
WatchProgressRepository.clearProgress(
meta.releasedPlayableEpisodes(todayIsoDate).map(meta::episodePlaybackId),
)
}
}
fun toggleEpisodeWatched(
meta: MetaDetails,
episode: MetaVideo,
isCurrentlyWatched: Boolean,
) {
val watchedItem = meta.toEpisodeWatchedItem(episode)
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(watchedItem)
} else {
WatchedRepository.markWatched(watchedItem)
WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode))
}
reconcileSeriesWatchedState(meta)
}
fun togglePreviousEpisodesWatched(
meta: MetaDetails,
episodes: Collection<MetaVideo>,
areCurrentlyWatched: Boolean,
) {
toggleEpisodesWatched(
meta = meta,
episodes = episodes,
areCurrentlyWatched = areCurrentlyWatched,
)
}
fun toggleSeasonWatched(
meta: MetaDetails,
episodes: Collection<MetaVideo>,
areCurrentlyWatched: Boolean,
) {
toggleEpisodesWatched(
meta = meta,
episodes = episodes,
areCurrentlyWatched = areCurrentlyWatched,
)
}
fun reconcileSeriesWatchedState(
meta: MetaDetails,
todayIsoDate: String = CurrentDateProvider.todayIsoDate(),
) {
WatchedRepository.reconcileSeriesWatchedState(
meta = meta,
todayIsoDate = todayIsoDate,
isEpisodeCompleted = { episode ->
WatchProgressRepository.progressForVideo(meta.episodePlaybackId(episode))?.isCompleted == true
},
)
}
fun onProgressEntryUpdated(entry: WatchProgressEntry) {
if (!entry.isCompleted || !entry.isEpisode) return
actionScope.launch {
val meta = runCatching {
MetaDetailsRepository.fetch(
type = entry.parentMetaType,
id = entry.parentMetaId,
)
}.getOrNull() ?: return@launch
reconcileSeriesWatchedState(meta = meta)
}
}
private fun toggleEpisodesWatched(
meta: MetaDetails,
episodes: Collection<MetaVideo>,
areCurrentlyWatched: Boolean,
) {
if (episodes.isEmpty()) return
val watchedItems = episodes.map(meta::toEpisodeWatchedItem)
if (areCurrentlyWatched) {
WatchedRepository.unmarkWatched(watchedItems)
} else {
WatchedRepository.markWatched(watchedItems)
WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId))
}
reconcileSeriesWatchedState(meta)
}
}

View file

@ -0,0 +1,110 @@
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.watchedItemKey
import com.nuvio.app.features.watchprogress.WatchProgressEntry
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 {
fun isPosterWatched(
watchedKeys: Set<String>,
item: MetaPreview,
): Boolean = watchedKeys.contains(watchedItemKey(item.type, item.id))
fun isEpisodeWatched(
watchedKeys: Set<String>,
metaType: String,
metaId: String,
episode: MetaVideo,
): Boolean = watchedKeys.contains(
watchedItemKey(
type = metaType,
id = metaId,
season = episode.season,
episode = episode.episode,
),
)
fun areEpisodesWatched(
watchedKeys: Set<String>,
metaType: String,
metaId: String,
episodes: Collection<MetaVideo>,
): Boolean = episodes.isNotEmpty() && episodes.all { episode ->
isEpisodeWatched(
watchedKeys = watchedKeys,
metaType = metaType,
metaId = metaId,
episode = episode,
)
}
fun latestCompletedBySeries(
progressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
): Map<WatchingContentRef, WatchingCompletedEpisode> {
val contentRefs = buildSet {
progressEntries.forEach { entry ->
add(WatchingContentRef(type = entry.parentMetaType, id = entry.parentMetaId))
}
watchedItems.forEach { item ->
add(WatchingContentRef(type = item.type, id = item.id))
}
}
val progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord)
val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord)
return contentRefs.mapNotNull { content ->
latestCompletedSeriesEpisode(
content = content,
progressRecords = progressRecords,
watchedRecords = watchedRecords,
)?.let { completed -> content to completed }
}.toMap()
}
fun visibleContinueWatchingEntries(
progressEntries: List<WatchProgressEntry>,
latestCompletedBySeries: Map<WatchingContentRef, WatchingCompletedEpisode>,
): List<WatchProgressEntry> {
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 }
}
}
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
WatchingProgressRecord(
content = WatchingContentRef(type = parentMetaType, id = parentMetaId),
videoId = videoId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastUpdatedEpochMs = lastUpdatedEpochMs,
lastPositionMs = lastPositionMs,
isCompleted = isCompleted,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
)
private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord =
WatchingWatchedRecord(
content = WatchingContentRef(type = type, id = id),
seasonNumber = season,
episodeNumber = episode,
markedAtEpochMs = markedAtEpochMs,
)

View file

@ -0,0 +1,150 @@
package com.nuvio.app.features.watching.domain
const val DefaultContinueWatchingLimit = 20
fun resumeProgressForSeries(
content: WatchingContentRef,
progressRecords: List<WatchingProgressRecord>,
): WatchingProgressRecord? = progressRecords
.filter { record -> record.content == content && !record.isCompleted }
.maxByOrNull { record -> record.lastUpdatedEpochMs }
fun continueWatchingProgressEntries(
progressRecords: List<WatchingProgressRecord>,
limit: Int = DefaultContinueWatchingLimit,
): List<WatchingProgressRecord> {
val inProgress = progressRecords.filterNot { record -> record.isCompleted }
val (episodes, nonEpisodes) = inProgress.partition { record ->
record.seasonNumber != null && record.episodeNumber != null
}
val latestPerSeries = episodes
.sortedByDescending { record -> record.lastUpdatedEpochMs }
.distinctBy { record -> record.content.id }
return (nonEpisodes + latestPerSeries)
.sortedByDescending { record -> record.lastUpdatedEpochMs }
.take(limit)
}
fun shouldPreferResume(
resumeRecord: WatchingProgressRecord?,
latestCompletedEpisode: WatchingCompletedEpisode?,
): Boolean = resumeRecord != null &&
(latestCompletedEpisode == null || resumeRecord.lastUpdatedEpochMs > latestCompletedEpisode.markedAtEpochMs)
fun nextReleasedEpisodeAfter(
content: WatchingContentRef,
episodes: List<WatchingReleasedEpisode>,
seasonNumber: Int?,
episodeNumber: Int?,
todayIsoDate: String,
): WatchingReleasedEpisode? {
val sortedEpisodes = episodes.sortedWith(
compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }),
)
val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber)
return sortedEpisodes
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
.drop(1)
.firstOrNull { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
}
fun decideSeriesPrimaryAction(
content: WatchingContentRef,
episodes: List<WatchingReleasedEpisode>,
progressRecords: List<WatchingProgressRecord>,
watchedRecords: List<WatchingWatchedRecord>,
todayIsoDate: String,
): WatchingSeriesPrimaryAction? {
val resumeRecord = resumeProgressForSeries(
content = content,
progressRecords = progressRecords,
)
val latestCompletedEpisode = latestCompletedSeriesEpisode(
content = content,
progressRecords = progressRecords,
watchedRecords = watchedRecords,
)
if (shouldPreferResume(resumeRecord = resumeRecord, latestCompletedEpisode = latestCompletedEpisode)) {
return resumeRecord?.toResumeAction()
}
val nextEpisode = if (latestCompletedEpisode != null) {
nextReleasedEpisodeAfter(
content = content,
episodes = episodes,
seasonNumber = latestCompletedEpisode.seasonNumber,
episodeNumber = latestCompletedEpisode.episodeNumber,
todayIsoDate = todayIsoDate,
)
} else {
episodes
.sortedWith(compareBy<WatchingReleasedEpisode>({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }))
.firstOrNull { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
}
return nextEpisode?.let { episode ->
WatchingSeriesPrimaryAction(
label = if (latestCompletedEpisode != null) {
upNextLabel(episode.seasonNumber, episode.episodeNumber)
} else {
playLabel(episode.seasonNumber, episode.episodeNumber)
},
videoId = buildPlaybackVideoId(
content = content,
seasonNumber = episode.seasonNumber,
episodeNumber = episode.episodeNumber,
fallbackVideoId = episode.videoId,
),
seasonNumber = episode.seasonNumber,
episodeNumber = episode.episodeNumber,
episodeTitle = episode.title,
episodeThumbnail = episode.thumbnail,
resumePositionMs = null,
)
}
}
fun buildPlaybackVideoId(
content: WatchingContentRef,
seasonNumber: Int?,
episodeNumber: Int?,
fallbackVideoId: String? = null,
): String =
if (seasonNumber != null && episodeNumber != null) {
"${content.id}:$seasonNumber:$episodeNumber"
} else {
fallbackVideoId?.takeIf { it.isNotBlank() } ?: content.id
}
fun playLabel(seasonNumber: Int?, episodeNumber: Int?): String =
if (seasonNumber != null && episodeNumber != null) {
"Play S${seasonNumber}E${episodeNumber}"
} else {
"Play"
}
fun upNextLabel(seasonNumber: Int?, episodeNumber: Int?): String =
if (seasonNumber != null && episodeNumber != null) {
"Up Next S${seasonNumber}E${episodeNumber}"
} else {
"Up Next"
}
fun resumeLabel(seasonNumber: Int?, episodeNumber: Int?): String =
if (seasonNumber != null && episodeNumber != null) {
"Resume S${seasonNumber}E${episodeNumber}"
} else {
"Resume"
}
private fun WatchingProgressRecord.toResumeAction(): WatchingSeriesPrimaryAction =
WatchingSeriesPrimaryAction(
label = resumeLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber),
videoId = videoId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
resumePositionMs = lastPositionMs,
)

View file

@ -0,0 +1,56 @@
package com.nuvio.app.features.watching.domain
data class WatchingContentRef(
val type: String,
val id: String,
)
data class WatchingEpisodeRef(
val content: WatchingContentRef,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
)
data class WatchingWatchedRecord(
val content: WatchingContentRef,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val markedAtEpochMs: Long,
)
data class WatchingProgressRecord(
val content: WatchingContentRef,
val videoId: String,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val lastUpdatedEpochMs: Long,
val lastPositionMs: Long = 0L,
val isCompleted: Boolean = false,
val episodeTitle: String? = null,
val episodeThumbnail: String? = null,
)
data class WatchingReleasedEpisode(
val videoId: String,
val seasonNumber: Int? = null,
val episodeNumber: Int? = null,
val title: String? = null,
val thumbnail: String? = null,
val releasedDate: String? = null,
)
data class WatchingCompletedEpisode(
val seasonNumber: Int,
val episodeNumber: Int,
val markedAtEpochMs: Long,
)
data class WatchingSeriesPrimaryAction(
val label: String,
val videoId: String,
val seasonNumber: Int?,
val episodeNumber: Int?,
val episodeTitle: String?,
val episodeThumbnail: String?,
val resumePositionMs: Long?,
)

View file

@ -0,0 +1,126 @@
package com.nuvio.app.features.watching.domain
private const val InProgressStartThresholdFraction = 0.02f
private const val CompletionThresholdFraction = 0.85
private const val InProgressStartThresholdMinMs = 30_000L
fun watchedKey(
content: WatchingContentRef,
seasonNumber: Int? = null,
episodeNumber: Int? = null,
): String = "${content.type.trim()}:${content.id.trim()}:${seasonNumber ?: -1}:${episodeNumber ?: -1}"
fun shouldStoreProgress(
positionMs: Long,
durationMs: Long,
): Boolean {
val thresholdMs = if (durationMs > 0L) {
maxOf(
InProgressStartThresholdMinMs,
(durationMs * InProgressStartThresholdFraction).toLong(),
)
} else {
1L
}
return positionMs >= thresholdMs
}
fun isProgressComplete(
positionMs: Long,
durationMs: Long,
isEnded: Boolean,
): Boolean {
if (isEnded) return true
if (durationMs <= 0L) return false
val watchedFraction = positionMs.toDouble() / durationMs.toDouble()
return watchedFraction >= CompletionThresholdFraction
}
fun isReleasedBy(
todayIsoDate: String,
releasedDate: String?,
): Boolean {
val isoDate = releasedDate
?.substringBefore('T')
?.takeIf { it.length == 10 }
?: return true
return isoDate <= todayIsoDate
}
fun releasedEpisodes(
episodes: List<WatchingReleasedEpisode>,
todayIsoDate: String,
): List<WatchingReleasedEpisode> = episodes.filter { episode ->
isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate)
}
fun releasedMainSeasonEpisodes(
episodes: List<WatchingReleasedEpisode>,
todayIsoDate: String,
): List<WatchingReleasedEpisode> = releasedEpisodes(
episodes = episodes,
todayIsoDate = todayIsoDate,
).filter { episode ->
normalizeSeasonNumber(episode.seasonNumber) > 0
}
fun hasWatchedAllMainSeasonEpisodes(
episodes: List<WatchingReleasedEpisode>,
todayIsoDate: String,
isEpisodeWatched: (WatchingReleasedEpisode) -> Boolean,
): Boolean {
val mainSeasonEpisodes = releasedMainSeasonEpisodes(
episodes = episodes,
todayIsoDate = todayIsoDate,
)
return mainSeasonEpisodes.isNotEmpty() && mainSeasonEpisodes.all(isEpisodeWatched)
}
fun latestCompletedSeriesEpisode(
content: WatchingContentRef,
progressRecords: List<WatchingProgressRecord>,
watchedRecords: List<WatchingWatchedRecord>,
): WatchingCompletedEpisode? {
val progressMarker = progressRecords
.asSequence()
.filter { record ->
record.content == content &&
record.isCompleted &&
record.seasonNumber != null &&
record.episodeNumber != null
}
.mapNotNull { record ->
val seasonNumber = record.seasonNumber ?: return@mapNotNull null
val episodeNumber = record.episodeNumber ?: return@mapNotNull null
WatchingCompletedEpisode(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
markedAtEpochMs = record.lastUpdatedEpochMs,
)
}
.maxByOrNull { marker -> marker.markedAtEpochMs }
val watchedMarker = watchedRecords
.asSequence()
.filter { record ->
record.content == content &&
record.seasonNumber != null &&
record.episodeNumber != null
}
.mapNotNull { record ->
val seasonNumber = record.seasonNumber ?: return@mapNotNull null
val episodeNumber = record.episodeNumber ?: return@mapNotNull null
WatchingCompletedEpisode(
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
markedAtEpochMs = record.markedAtEpochMs,
)
}
.maxByOrNull { marker -> marker.markedAtEpochMs }
return listOfNotNull(progressMarker, watchedMarker)
.maxByOrNull { marker -> marker.markedAtEpochMs }
}
fun normalizeSeasonNumber(seasonNumber: Int?): Int = seasonNumber?.coerceAtLeast(0) ?: 0

View file

@ -0,0 +1,28 @@
package com.nuvio.app.features.watching.sync
import com.nuvio.app.features.watchprogress.WatchProgressEntry
data class ProgressSyncRecord(
val contentId: String,
val contentType: String,
val videoId: String,
val season: Int? = null,
val episode: Int? = null,
val position: Long = 0L,
val duration: Long = 0L,
val lastWatched: Long = 0L,
)
interface ProgressSyncAdapter {
suspend fun pull(profileId: Int): List<ProgressSyncRecord>
suspend fun push(
profileId: Int,
entries: Collection<WatchProgressEntry>,
)
suspend fun delete(
profileId: Int,
entries: Collection<WatchProgressEntry>,
)
}

View file

@ -0,0 +1,90 @@
package com.nuvio.app.features.watching.sync
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
object SupabaseProgressSyncAdapter : ProgressSyncAdapter {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override suspend fun pull(profileId: Int): List<ProgressSyncRecord> {
val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
return result.decodeList<WatchProgressSyncEntry>().map { entry ->
ProgressSyncRecord(
contentId = entry.contentId,
contentType = entry.contentType,
videoId = entry.videoId,
season = entry.season,
episode = entry.episode,
position = entry.position,
duration = entry.duration,
lastWatched = entry.lastWatched,
)
}
}
override suspend fun push(
profileId: Int,
entries: Collection<WatchProgressEntry>,
) {
val syncEntries = entries.map { entry ->
WatchProgressSyncEntry(
contentId = entry.parentMetaId,
contentType = entry.contentType,
videoId = entry.videoId,
season = entry.seasonNumber,
episode = entry.episodeNumber,
position = entry.lastPositionMs,
duration = entry.durationMs,
lastWatched = entry.lastUpdatedEpochMs,
)
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_entries", json.encodeToJsonElement(syncEntries))
}
SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params)
}
override suspend fun delete(
profileId: Int,
entries: Collection<WatchProgressEntry>,
) {
val progressKeys = entries.map { entry ->
if (entry.seasonNumber != null && entry.episodeNumber != null) {
"${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}"
} else {
entry.parentMetaId
}
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", json.encodeToJsonElement(progressKeys))
}
SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params)
}
}
@Serializable
private data class WatchProgressSyncEntry(
@SerialName("content_id") val contentId: String,
@SerialName("content_type") val contentType: String,
@SerialName("video_id") val videoId: String,
val season: Int? = null,
val episode: Int? = null,
val position: Long = 0,
val duration: Long = 0,
@SerialName("last_watched") val lastWatched: Long = 0,
@SerialName("progress_key") val progressKey: String = "",
)

View file

@ -0,0 +1,108 @@
package com.nuvio.app.features.watching.sync
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.watched.WatchedItem
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
object SupabaseWatchedSyncAdapter : WatchedSyncAdapter {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
override suspend fun pull(
profileId: Int,
pageSize: Int,
): List<WatchedItem> {
val serverItems = mutableListOf<WatchedSyncItem>()
var page = 1
while (true) {
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_page", page)
put("p_page_size", pageSize)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watched_items", params)
val pageItems = result.decodeList<WatchedSyncItem>()
serverItems += pageItems
if (pageItems.size < pageSize) break
page += 1
}
return serverItems.map { syncItem ->
WatchedItem(
id = syncItem.contentId,
type = syncItem.contentType,
name = syncItem.title,
season = syncItem.season,
episode = syncItem.episode,
markedAtEpochMs = syncItem.watchedAt,
)
}
}
override suspend fun push(
profileId: Int,
items: Collection<WatchedItem>,
) {
val syncItems = items.map { item ->
WatchedSyncItem(
contentId = item.id,
contentType = item.type,
title = item.name,
season = item.season,
episode = item.episode,
watchedAt = item.markedAtEpochMs,
)
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_items", json.encodeToJsonElement(syncItems))
}
SupabaseProvider.client.postgrest.rpc("sync_push_watched_items", params)
}
override suspend fun delete(
profileId: Int,
items: Collection<WatchedItem>,
) {
val keys = items.map { item ->
WatchedDeleteKey(
contentId = item.id,
season = item.season,
episode = item.episode,
)
}
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", json.encodeToJsonElement(keys))
}
SupabaseProvider.client.postgrest.rpc("sync_delete_watched_items", params)
}
}
@Serializable
private data class WatchedSyncItem(
@SerialName("content_id") val contentId: String,
@SerialName("content_type") val contentType: String,
val title: String = "",
val season: Int? = null,
val episode: Int? = null,
@SerialName("watched_at") val watchedAt: Long = 0,
)
@Serializable
private data class WatchedDeleteKey(
@SerialName("content_id") val contentId: String,
val season: Int? = null,
val episode: Int? = null,
)

View file

@ -0,0 +1,20 @@
package com.nuvio.app.features.watching.sync
import com.nuvio.app.features.watched.WatchedItem
interface WatchedSyncAdapter {
suspend fun pull(
profileId: Int,
pageSize: Int,
): List<WatchedItem>
suspend fun push(
profileId: Int,
items: Collection<WatchedItem>,
)
suspend fun delete(
profileId: Int,
items: Collection<WatchedItem>,
)
}

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.watching.domain.WatchingContentRef
import kotlinx.serialization.Serializable
@Serializable
@ -189,9 +190,9 @@ fun buildPlaybackVideoId(
seasonNumber: Int?,
episodeNumber: Int?,
fallbackVideoId: String? = null,
): String =
if (seasonNumber != null && episodeNumber != null) {
"$parentMetaId:$seasonNumber:$episodeNumber"
} else {
fallbackVideoId?.takeIf { it.isNotBlank() } ?: parentMetaId
}
): String = com.nuvio.app.features.watching.domain.buildPlaybackVideoId(
content = WatchingContentRef(type = "", id = parentMetaId),
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
fallbackVideoId = fallbackVideoId,
)

View file

@ -1,15 +1,13 @@
package com.nuvio.app.features.watchprogress
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
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 com.nuvio.app.features.watching.application.WatchingActions
import com.nuvio.app.features.watching.sync.ProgressSyncAdapter
import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -18,30 +16,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
@Serializable
private data class WatchProgressSyncEntry(
@SerialName("content_id") val contentId: String,
@SerialName("content_type") val contentType: String,
@SerialName("video_id") val videoId: String,
val season: Int? = null,
val episode: Int? = null,
val position: Long = 0,
val duration: Long = 0,
@SerialName("last_watched") val lastWatched: Long = 0,
@SerialName("progress_key") val progressKey: String = "",
)
object WatchProgressRepository {
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("WatchProgressRepository")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val _uiState = MutableStateFlow(WatchProgressUiState())
val uiState: StateFlow<WatchProgressUiState> = _uiState.asStateFlow()
@ -49,6 +27,7 @@ object WatchProgressRepository {
private var hasLoaded = false
private var currentProfileId: Int = 1
private var entriesByVideoId: MutableMap<String, WatchProgressEntry> = mutableMapOf()
internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter
fun ensureLoaded() {
if (hasLoaded) return
@ -84,9 +63,7 @@ object WatchProgressRepository {
suspend fun pullFromServer(profileId: Int) {
currentProfileId = profileId
runCatching {
val params = buildJsonObject { put("p_profile_id", profileId) }
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params)
val serverEntries = result.decodeList<WatchProgressSyncEntry>()
val serverEntries = syncAdapter.pull(profileId = profileId)
val oldLocal = entriesByVideoId.toMap()
val newMap = mutableMapOf<String, WatchProgressEntry>()
@ -271,30 +248,14 @@ object WatchProgressRepository {
publish()
if (persist) persist()
pushScrobbleToServer(entry)
if (entry.isCompleted && entry.isEpisode) {
reconcileCompletedSeriesWatchedState(entry)
}
WatchingActions.onProgressEntryUpdated(entry)
}
private fun pushScrobbleToServer(entry: WatchProgressEntry) {
syncScope.launch {
runCatching {
val profileId = ProfileRepository.activeProfileId
val syncEntry = WatchProgressSyncEntry(
contentId = entry.parentMetaId,
contentType = entry.contentType,
videoId = entry.videoId,
season = entry.seasonNumber,
episode = entry.episodeNumber,
position = entry.lastPositionMs,
duration = entry.durationMs,
lastWatched = entry.lastUpdatedEpochMs,
)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_entries", json.encodeToJsonElement(listOf(syncEntry)))
}
SupabaseProvider.client.postgrest.rpc("sync_push_watch_progress", params)
syncAdapter.push(profileId = profileId, entries = listOf(entry))
}.onFailure { e ->
log.e(e) { "Failed to push watch progress scrobble" }
}
@ -306,12 +267,7 @@ object WatchProgressRepository {
runCatching {
if (entries.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
val progressKeys = entries.map(::progressKeyForEntry)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_keys", json.encodeToJsonElement(progressKeys))
}
SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params)
syncAdapter.delete(profileId = profileId, entries = entries)
}.onFailure { e ->
log.e(e) { "Failed to push watch progress delete" }
}
@ -331,29 +287,4 @@ 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}"
} else {
entry.parentMetaId
}
}

View file

@ -1,13 +1,18 @@
package com.nuvio.app.features.watchprogress
import com.nuvio.app.features.watching.domain.DefaultContinueWatchingLimit
import com.nuvio.app.features.watching.domain.WatchingContentRef
import com.nuvio.app.features.watching.domain.WatchingProgressRecord
import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries
import com.nuvio.app.features.watching.domain.isProgressComplete
import com.nuvio.app.features.watching.domain.resumeProgressForSeries
import com.nuvio.app.features.watching.domain.shouldStoreProgress
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
internal const val ContinueWatchingLimit = 20
private const val InProgressStartThresholdFraction = 0.02f
private const val CompletionThresholdFraction = 0.85
internal const val ContinueWatchingLimit = DefaultContinueWatchingLimit
@Serializable
private data class StoredWatchProgressPayload(
@ -36,40 +41,52 @@ internal object WatchProgressCodec {
internal fun shouldStoreWatchProgress(
positionMs: Long,
durationMs: Long,
): Boolean {
val thresholdMs = if (durationMs > 0L) {
(durationMs * InProgressStartThresholdFraction).toLong()
} else {
1L
}
return positionMs >= thresholdMs
}
): Boolean = shouldStoreProgress(positionMs = positionMs, durationMs = durationMs)
internal fun isWatchProgressComplete(
positionMs: Long,
durationMs: Long,
isEnded: Boolean,
): Boolean {
if (isEnded) return true
if (durationMs <= 0L) return false
val watchedFraction = positionMs.toDouble() / durationMs.toDouble()
return watchedFraction >= CompletionThresholdFraction
}
): Boolean = isProgressComplete(
positionMs = positionMs,
durationMs = durationMs,
isEnded = isEnded,
)
internal fun List<WatchProgressEntry>.resumeEntryForSeries(metaId: String): WatchProgressEntry? =
filter { it.parentMetaId == metaId && !it.isCompleted }
.maxByOrNull { it.lastUpdatedEpochMs }
firstOrNull { entry -> entry.parentMetaId == metaId }?.let { seed ->
resumeProgressForSeries(
content = WatchingContentRef(type = seed.parentMetaType, id = metaId),
progressRecords = map(WatchProgressEntry::toDomainProgressRecord),
)?.let { record ->
firstOrNull { entry -> entry.videoId == record.videoId }
}
}
internal fun List<WatchProgressEntry>.continueWatchingEntries(
limit: Int = ContinueWatchingLimit,
): List<WatchProgressEntry> {
val inProgress = filterNot { it.isCompleted }
val (episodes, nonEpisodes) = inProgress.partition { it.isEpisode }
val latestPerSeries = episodes
val domainEntries = continueWatchingProgressEntries(
progressRecords = map(WatchProgressEntry::toDomainProgressRecord),
limit = limit,
)
val ids = domainEntries.map { record -> record.videoId }.toSet()
return filter { entry -> entry.videoId in ids }
.sortedByDescending { it.lastUpdatedEpochMs }
.distinctBy { it.parentMetaId }
return (nonEpisodes + latestPerSeries)
.sortedByDescending { it.lastUpdatedEpochMs }
.take(limit)
}
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
WatchingProgressRecord(
content = WatchingContentRef(
type = parentMetaType,
id = parentMetaId,
),
videoId = videoId,
seasonNumber = seasonNumber,
episodeNumber = episodeNumber,
lastUpdatedEpochMs = lastUpdatedEpochMs,
lastPositionMs = lastPositionMs,
isCompleted = isCompleted,
episodeTitle = episodeTitle,
episodeThumbnail = episodeThumbnail,
)

View file

@ -0,0 +1,78 @@
package com.nuvio.app.features.watching.domain
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class SeriesContinuityTest {
private val show = WatchingContentRef(type = "series", id = "show")
private val episodes = listOf(
WatchingReleasedEpisode(videoId = "ep1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-03-01"),
WatchingReleasedEpisode(videoId = "ep2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-03-08"),
WatchingReleasedEpisode(videoId = "ep3", seasonNumber = 1, episodeNumber = 3, title = "Episode 3", releasedDate = "2026-03-15"),
)
@Test
fun decideSeriesPrimaryAction_prefers_up_next_when_completed_is_newer_than_resume() {
val action = decideSeriesPrimaryAction(
content = show,
episodes = episodes,
progressRecords = listOf(
WatchingProgressRecord(
content = show,
videoId = "show:1:2",
seasonNumber = 1,
episodeNumber = 2,
lastUpdatedEpochMs = 100L,
lastPositionMs = 1_000L,
),
),
watchedRecords = listOf(
WatchingWatchedRecord(
content = show,
seasonNumber = 1,
episodeNumber = 2,
markedAtEpochMs = 200L,
),
),
todayIsoDate = "2026-03-30",
)
assertNotNull(action)
assertEquals("Up Next S1E3", action.label)
assertEquals("show:1:3", action.videoId)
assertEquals(3, action.episodeNumber)
}
@Test
fun decideSeriesPrimaryAction_prefers_resume_when_resume_is_newer_than_completed() {
val action = decideSeriesPrimaryAction(
content = show,
episodes = episodes,
progressRecords = listOf(
WatchingProgressRecord(
content = show,
videoId = "show:1:2",
seasonNumber = 1,
episodeNumber = 2,
lastUpdatedEpochMs = 300L,
lastPositionMs = 1_500L,
),
),
watchedRecords = listOf(
WatchingWatchedRecord(
content = show,
seasonNumber = 1,
episodeNumber = 1,
markedAtEpochMs = 200L,
),
),
todayIsoDate = "2026-03-30",
)
assertNotNull(action)
assertEquals("Resume S1E2", action.label)
assertEquals("show:1:2", action.videoId)
assertEquals(1_500L, action.resumePositionMs)
}
}

View file

@ -0,0 +1,87 @@
package com.nuvio.app.features.watching.domain
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class WatchingPoliciesTest {
private val show = WatchingContentRef(type = "series", id = "show")
@Test
fun hasWatchedAllMainSeasonEpisodes_ignores_specials() {
val episodes = listOf(
WatchingReleasedEpisode(videoId = "special", seasonNumber = 0, episodeNumber = 1, releasedDate = "2026-03-01"),
WatchingReleasedEpisode(videoId = "ep1", seasonNumber = 1, episodeNumber = 1, releasedDate = "2026-03-08"),
WatchingReleasedEpisode(videoId = "ep2", seasonNumber = 1, episodeNumber = 2, releasedDate = "2026-03-15"),
)
val result = hasWatchedAllMainSeasonEpisodes(
episodes = episodes,
todayIsoDate = "2026-03-30",
isEpisodeWatched = { episode -> episode.seasonNumber == 1 },
)
assertTrue(result)
}
@Test
fun latestCompletedSeriesEpisode_prefers_newer_manual_watch_marker() {
val latestCompleted = latestCompletedSeriesEpisode(
content = show,
progressRecords = listOf(
WatchingProgressRecord(
content = show,
videoId = "show:1:2",
seasonNumber = 1,
episodeNumber = 2,
lastUpdatedEpochMs = 100L,
isCompleted = true,
),
),
watchedRecords = listOf(
WatchingWatchedRecord(
content = show,
seasonNumber = 1,
episodeNumber = 3,
markedAtEpochMs = 200L,
),
),
)
assertNotNull(latestCompleted)
assertEquals(1, latestCompleted.seasonNumber)
assertEquals(3, latestCompleted.episodeNumber)
assertEquals(200L, latestCompleted.markedAtEpochMs)
}
@Test
fun latestCompletedSeriesEpisode_prefers_newer_progress_marker() {
val latestCompleted = latestCompletedSeriesEpisode(
content = show,
progressRecords = listOf(
WatchingProgressRecord(
content = show,
videoId = "show:1:3",
seasonNumber = 1,
episodeNumber = 3,
lastUpdatedEpochMs = 300L,
isCompleted = true,
),
),
watchedRecords = listOf(
WatchingWatchedRecord(
content = show,
seasonNumber = 1,
episodeNumber = 2,
markedAtEpochMs = 200L,
),
),
)
assertNotNull(latestCompleted)
assertEquals(1, latestCompleted.seasonNumber)
assertEquals(3, latestCompleted.episodeNumber)
assertEquals(300L, latestCompleted.markedAtEpochMs)
}
}