From d00b4ae2e168fc60de8736cd27a4966fe103e993 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:01:55 +0530 Subject: [PATCH] feat: refactor watched state management by introducing WatchingActions and WatchingState for improved episode tracking and UI integration --- .../commonMain/kotlin/com/nuvio/app/App.kt | 48 +--- .../app/features/details/MetaDetailsScreen.kt | 99 +++------ .../details/SeriesPlaybackResolver.kt | 209 ++++++++---------- .../details/components/DetailSeriesContent.kt | 13 +- .../com/nuvio/app/features/home/HomeScreen.kt | 91 +++----- .../home/components/HomeCatalogSection.kt | 7 +- .../features/search/SearchDiscoverContent.kt | 7 +- .../features/watched/WatchedEpisodeActions.kt | 51 ++++- .../app/features/watched/WatchedModels.kt | 8 +- .../app/features/watched/WatchedRepository.kt | 89 +------- .../watching/application/WatchingActions.kt | 137 ++++++++++++ .../watching/application/WatchingState.kt | 110 +++++++++ .../watching/domain/SeriesContinuity.kt | 150 +++++++++++++ .../watching/domain/WatchingModels.kt | 56 +++++ .../watching/domain/WatchingPolicies.kt | 126 +++++++++++ .../watching/sync/ProgressSyncAdapter.kt | 28 +++ .../sync/SupabaseProgressSyncAdapter.kt | 90 ++++++++ .../sync/SupabaseWatchedSyncAdapter.kt | 108 +++++++++ .../watching/sync/WatchedSyncAdapter.kt | 20 ++ .../watchprogress/WatchProgressModels.kt | 13 +- .../watchprogress/WatchProgressRepository.kt | 85 +------ .../watchprogress/WatchProgressRules.kt | 71 +++--- .../watching/domain/SeriesContinuityTest.kt | 78 +++++++ .../watching/domain/WatchingPoliciesTest.kt | 87 ++++++++ 24 files changed, 1284 insertions(+), 497 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index fe5111e3..047132e5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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), - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 6ca5d805..140c77a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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, + ) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 2fd07397..f374a3ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -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 = 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, watchedItems: List, 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, watchedItems: List, -): 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, + ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 3f87c1ba..14f23d5e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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) }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index b4ed5b2b..a97551f9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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>>(emptyMap()) } val continueWatchingItems = remember( @@ -175,15 +143,15 @@ fun HomeScreen( val resolvedItems = mutableMapOf>() 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index 7f113924..937a33fd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -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) } }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index c12889c7..4b6ff407 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -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) } }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt index f8899a57..99389a70 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedEpisodeActions.kt @@ -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 = - sortedPlayableEpisodes().filter { episode -> episode.isReleasedBy(todayIsoDate) } +fun MetaDetails.releasedPlayableEpisodes(todayIsoDate: String): List { + 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 = - 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, + ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 1cb90f42..6ade3728 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 0a7b99ff..e02769ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -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 = 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 = 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() - 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() - 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" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt new file mode 100644 index 00000000..2482b800 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -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, + areCurrentlyWatched: Boolean, + ) { + toggleEpisodesWatched( + meta = meta, + episodes = episodes, + areCurrentlyWatched = areCurrentlyWatched, + ) + } + + fun toggleSeasonWatched( + meta: MetaDetails, + episodes: Collection, + 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, + 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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt new file mode 100644 index 00000000..f2f48c2f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -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, + item: MetaPreview, + ): Boolean = watchedKeys.contains(watchedItemKey(item.type, item.id)) + + fun isEpisodeWatched( + watchedKeys: Set, + metaType: String, + metaId: String, + episode: MetaVideo, + ): Boolean = watchedKeys.contains( + watchedItemKey( + type = metaType, + id = metaId, + season = episode.season, + episode = episode.episode, + ), + ) + + fun areEpisodesWatched( + watchedKeys: Set, + metaType: String, + metaId: String, + episodes: Collection, + ): Boolean = episodes.isNotEmpty() && episodes.all { episode -> + isEpisodeWatched( + watchedKeys = watchedKeys, + metaType = metaType, + metaId = metaId, + episode = episode, + ) + } + + fun latestCompletedBySeries( + progressEntries: List, + watchedItems: List, + ): Map { + 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, + latestCompletedBySeries: Map, + ): List { + 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, + ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt new file mode 100644 index 00000000..bb3aed52 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -0,0 +1,150 @@ +package com.nuvio.app.features.watching.domain + +const val DefaultContinueWatchingLimit = 20 + +fun resumeProgressForSeries( + content: WatchingContentRef, + progressRecords: List, +): WatchingProgressRecord? = progressRecords + .filter { record -> record.content == content && !record.isCompleted } + .maxByOrNull { record -> record.lastUpdatedEpochMs } + +fun continueWatchingProgressEntries( + progressRecords: List, + limit: Int = DefaultContinueWatchingLimit, +): List { + 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, + seasonNumber: Int?, + episodeNumber: Int?, + todayIsoDate: String, +): WatchingReleasedEpisode? { + val sortedEpisodes = episodes.sortedWith( + compareBy({ 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, + progressRecords: List, + watchedRecords: List, + 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({ 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, + ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt new file mode 100644 index 00000000..6040e2d7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt @@ -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?, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt new file mode 100644 index 00000000..162d0fb1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -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, + todayIsoDate: String, +): List = episodes.filter { episode -> + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) +} + +fun releasedMainSeasonEpisodes( + episodes: List, + todayIsoDate: String, +): List = releasedEpisodes( + episodes = episodes, + todayIsoDate = todayIsoDate, +).filter { episode -> + normalizeSeasonNumber(episode.seasonNumber) > 0 +} + +fun hasWatchedAllMainSeasonEpisodes( + episodes: List, + 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, + watchedRecords: List, +): 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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt new file mode 100644 index 00000000..d8449cce --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt @@ -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 + + suspend fun push( + profileId: Int, + entries: Collection, + ) + + suspend fun delete( + profileId: Int, + entries: Collection, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt new file mode 100644 index 00000000..63307daf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -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 { + val params = buildJsonObject { put("p_profile_id", profileId) } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) + return result.decodeList().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, + ) { + 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, + ) { + 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 = "", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt new file mode 100644 index 00000000..9bba34a0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt @@ -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 { + val serverItems = mutableListOf() + 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() + 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, + ) { + 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, + ) { + 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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt new file mode 100644 index 00000000..f86349c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/WatchedSyncAdapter.kt @@ -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 + + suspend fun push( + profileId: Int, + items: Collection, + ) + + suspend fun delete( + profileId: Int, + items: Collection, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index ed8d6ca9..ab5aa99d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 5f7559c6..fe1c0449 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -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 = _uiState.asStateFlow() @@ -49,6 +27,7 @@ object WatchProgressRepository { private var hasLoaded = false private var currentProfileId: Int = 1 private var entriesByVideoId: MutableMap = 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() + val serverEntries = syncAdapter.pull(profileId = profileId) val oldLocal = entriesByVideoId.toMap() val newMap = mutableMapOf() @@ -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 - } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index 6fd0841a..b5f1c273 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -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.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.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List { - 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, + ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt new file mode 100644 index 00000000..38781346 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -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) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt new file mode 100644 index 00000000..0df28b1b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt @@ -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) + } +}