mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 15:49:30 +00:00
feat: refactor watched state management by introducing WatchingActions and WatchingState for improved episode tracking and UI integration
This commit is contained in:
parent
d2200fe5ab
commit
d00b4ae2e1
24 changed files with 1284 additions and 497 deletions
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) } },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) } },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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?,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = "",
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue