From 410c1d48d8d468b74999e0d7d3b8bd8567db60e2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:55:19 +0530 Subject: [PATCH] ref: adjust episode release logic --- .../details/SeriesPlaybackResolver.kt | 23 +++++- .../com/nuvio/app/features/home/HomeScreen.kt | 40 +--------- .../watching/domain/SeriesContinuity.kt | 10 ++- .../watching/domain/WatchingPolicies.kt | 73 +++++++++++++++++++ 4 files changed, 105 insertions(+), 41 deletions(-) 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 4fa7f3d8..3c3374fa 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 @@ -14,6 +14,7 @@ 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.shouldSurfaceNextEpisode import com.nuvio.app.features.watching.domain.upNextLabel internal fun MetaDetails.sortedPlayableEpisodes(): List = @@ -63,6 +64,20 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( seasonNumber: Int?, episodeNumber: Int?, todayIsoDate: String, +): MetaVideo? { + return nextReleasedEpisodeAfter( + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + todayIsoDate = todayIsoDate, + showUnairedNextUp = false, + ) +} + +internal fun MetaDetails.nextReleasedEpisodeAfter( + seasonNumber: Int?, + episodeNumber: Int?, + todayIsoDate: String, + showUnairedNextUp: Boolean, ): MetaVideo? { val sortedEpisodes = sortedPlayableEpisodes() val watchedVideoId = buildPlaybackVideoId( @@ -81,7 +96,13 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( } .drop(1) .filter { episode -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released) + shouldSurfaceNextEpisode( + watchedSeasonNumber = seasonNumber, + candidateSeasonNumber = episode.season, + todayIsoDate = todayIsoDate, + releasedDate = episode.released, + showUnairedNextUp = showUnairedNextUp, + ) } return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 } } 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 75a07bd4..cfc6da38 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 @@ -20,8 +20,7 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.filterUnavailableFutureSeasons -import com.nuvio.app.features.details.sortedPlayableEpisodes +import com.nuvio.app.features.details.nextReleasedEpisodeAfter import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -45,10 +44,8 @@ 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 -import com.nuvio.app.features.watching.domain.buildPlaybackVideoId import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection -import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -605,41 +602,6 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app. isCompleted = true, ) -private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter( - seasonNumber: Int?, - episodeNumber: Int?, - todayIsoDate: String, - showUnairedNextUp: Boolean, -): com.nuvio.app.features.details.MetaVideo? { - val content = WatchingContentRef(type = type, id = id) - val watchedVideoId = buildPlaybackVideoId( - content = content, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - ) - - val ordered = sortedPlayableEpisodes() - .dropWhile { episode -> - buildPlaybackVideoId( - content = content, - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = episode.id, - ) != watchedVideoId - } - .drop(1) - .filter { episode -> (episode.season ?: 0) > 0 } - .filterUnavailableFutureSeasons(todayIsoDate = todayIsoDate) - - if (showUnairedNextUp) { - return ordered.firstOrNull() - } - - return ordered.firstOrNull { episode -> - isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released) - } -} - private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean = isNextUp || progressFraction < 0.995f 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 index f77e2740..8d117ba6 100644 --- 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 @@ -45,7 +45,15 @@ fun nextReleasedEpisodeAfter( val candidates = sortedEpisodes .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } .drop(1) - .filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) } + .filter { episode -> + shouldSurfaceNextEpisode( + watchedSeasonNumber = seasonNumber, + candidateSeasonNumber = episode.seasonNumber, + todayIsoDate = todayIsoDate, + releasedDate = episode.releasedDate, + showUnairedNextUp = false, + ) + } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } } 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 index d79c3bc2..b96eb543 100644 --- 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 @@ -3,6 +3,7 @@ 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 +private const val UpcomingNextSeasonWindowDays = 7 fun watchedKey( content: WatchingContentRef, @@ -48,6 +49,78 @@ fun isReleasedBy( return isoDate <= todayIsoDate } +internal fun shouldSurfaceNextEpisode( + watchedSeasonNumber: Int?, + candidateSeasonNumber: Int?, + todayIsoDate: String, + releasedDate: String?, + showUnairedNextUp: Boolean, +): Boolean { + val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber) + if (!isSeasonRollover) { + if (showUnairedNextUp) return true + return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate) + } + + if (isExplicitlyReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)) { + return true + } + if (!showUnairedNextUp) { + return false + } + + val daysUntilRelease = daysUntilExplicitRelease( + todayIsoDate = todayIsoDate, + releasedDate = releasedDate, + ) ?: return false + return daysUntilRelease in 0..UpcomingNextSeasonWindowDays +} + +private fun isExplicitlyReleasedBy( + todayIsoDate: String, + releasedDate: String?, +): Boolean { + val isoDate = isoCalendarDateOrNull(releasedDate) ?: return false + return isoDate <= todayIsoDate +} + +private fun daysUntilExplicitRelease( + todayIsoDate: String, + releasedDate: String?, +): Int? { + val startDate = isoCalendarDateOrNull(todayIsoDate) ?: return null + val targetDate = isoCalendarDateOrNull(releasedDate) ?: return null + return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() +} + +private fun isoCalendarDateOrNull(value: String?): String? { + val datePart = value + ?.trim() + ?.substringBefore('T') + ?.takeIf { it.length == 10 } + ?: return null + val parts = datePart.split('-') + if (parts.size != 3) return null + val year = parts[0].toIntOrNull() ?: return null + val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null + return "%04d-%02d-%02d".format(year, month, day) +} + +private fun isoEpochDay(date: String): Long { + val year = date.substring(0, 4).toLong() + val month = date.substring(5, 7).toLong() + val day = date.substring(8, 10).toLong() + + val adjustedYear = year - if (month <= 2L) 1L else 0L + val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L + val yearOfEra = adjustedYear - era * 400L + val adjustedMonth = month + if (month > 2L) -3L else 9L + val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L + val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear + return era * 146_097L + dayOfEra - 719_468L +} + fun releasedEpisodes( episodes: List, todayIsoDate: String,