diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml index ca8498e3..5e186e5f 100644 --- a/composeApp/src/commonMain/composeResources/values-cs/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -1242,4 +1242,6 @@ KB MB GB + Nový díl + Nová sezóna diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 1722ae84..3afb1161 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -1196,4 +1196,20 @@ KB MB GB + Ausstrahlung am %1$s + Ausstrahlung heute + Ausstrahlung morgen + + Ausstrahlung in %1$d Tag + Ausstrahlung in %1$d Tagen + + %1$s + Heute + Morgen + + In %1$d Tag + In %1$d Tagen + + Neue Folge + Neue Staffel diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 2282628b..55a061cd 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -1040,4 +1040,20 @@ KB MB GB + Πρεμιέρα %1$s + Πρεμιέρα σήμερα + Πρεμιέρα αύριο + + Πρεμιέρα σε %1$d ημέρα + Πρεμιέρα σε %1$d ημέρες + + %1$s + Σήμερα + Αύριο + + Σε %1$d ημέρα + Σε %1$d ημέρες + + Νέο επεισόδιο + Νέα σεζόν diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index b52d5056..1fe611f6 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -1158,4 +1158,22 @@ KB MB GB + Se transmite %1$s + Se transmite hoy + Se transmite mañana + + Se transmite dentro de %1$d día + Se transmite dentro de %1$d días + Se transmite dentro de %1$d días + + %1$s + Hoy + Mañana + + En %1$d día + En %1$d días + En %1$d días + + Nuevo episodio + Nueva temporada diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index c71a7cb0..6b4f604f 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -1737,4 +1737,23 @@ Erreur API releases GitHub : %1$d Aucune mise à jour n’a encore été publiée. La release n’a pas de tag ni de nom + + Diffusion le %1$s + Diffusion aujourd’hui + Diffusion demain + + Diffusion dans %1$d jour + Diffusion dans %1$d jours + Diffusion dans %1$d jours + + %1$s + Aujourd’hui + Demain + + Dans %1$d jour + Dans %1$d jours + Dans %1$d jours + + Nouvel épisode + Nouvelle saison diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 80539baa..89fb7100 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -1274,4 +1274,18 @@ KB MB GB + Episode Baru + Musim Baru + Tayang %1$s + Tayang hari ini + Tayang besok + + Tayang dalam %d hari + + %1$s + Hari ini + Besok + + Dalam %d hari + diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 0c4c6ee8..cac996a1 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1170,4 +1170,22 @@ Persona Regista TMDB Discover + In onda il %1$s + In onda oggi + In onda domani + + In onda tra %1$d giorno + In onda tra %1$d giorni + In onda tra %1$d giorni + + %1$s + Oggi + Domani + + Tra %1$d giorno + Tra %1$d giorni + Tra %1$d giorni + + Nuovo episodio + Nuova stagione diff --git a/composeApp/src/commonMain/composeResources/values-nb/strings.xml b/composeApp/src/commonMain/composeResources/values-nb/strings.xml index 15e482d2..253a32cc 100644 --- a/composeApp/src/commonMain/composeResources/values-nb/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nb/strings.xml @@ -1352,4 +1352,20 @@ KB MB GB + Ny episode + Ny sesong + Sendes %1$s + Sendes i dag + Sendes i morgen + + Sendes om %d dag + Sendes om %d dager + + %1$s + I dag + I morgen + + Om %d dag + Om %d dager + diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 330c2cd6..6805c407 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1341,4 +1341,24 @@ KB MB GB + Emisja %1$s + Emisja dzisiaj + Emisja jutro + + Emisja za %1$d dzień + Emisja za %1$d dni + Emisja za %1$d dni + Emisja za %1$d dni + + %1$s + Dzisiaj + Jutro + + Za %1$d dzień + Za %1$d dni + Za %1$d dni + Za %1$d dni + + Nowy odcinek + Nowy sezon diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 02896473..2fdffa86 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -1170,4 +1170,22 @@ KB MB GB + Disponível em %1$s + Disponível hoje + Disponível amanhã + + Disponível em %1$d dia + Disponível em %1$d dias + Disponível em %1$d dias + + %1$s + Hoje + Amanhã + + Em %1$d dia + Em %1$d dias + Em %1$d dias + + Novo Episódio + Nova Temporada diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 5d2b049f..308c234c 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -1040,4 +1040,20 @@ KB MB GB + Yayın tarihi: %1$s + Bugün yayında + Yarın yayında + + Yayın tarihi: %1$d gün sonra + Yayın tarihi: %1$d gün sonra + + %1$s + Bugün + Yarın + + %1$d gün sonra + %1$d gün sonra + + Yeni Bölüm + Yeni Sezon diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index ac57c467..911b00d6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1735,4 +1735,21 @@ GitHub releases API error: %1$d No update has been published yet. Release has no tag or name + + Airs %1$s + Airs today + Airs tomorrow + + Airs in %1$d day + Airs in %1$d days + + %1$s + Today + Tomorrow + + In %1$d day + In %1$d days + + New Episode + New Season diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt index 9af52b9e..73de69e8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt @@ -18,6 +18,17 @@ fun formatReleaseDateForDisplay(raw: String): String { return "$year ${localizedMonthName(month)} $day" } +fun formatReleaseDateWithoutYear(raw: String): String { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return raw + val datePart = trimmed.substringBefore('T').trim() + val parts = datePart.split('-') + if (parts.size != 3) return raw + val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw + val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw + return "${localizedMonthName(month)} $day" +} + /** * Parses a release/air string (ISO date, year-only, or timestamp prefix) for compact UI (e.g. year chips). */ diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt index f99e90b7..7496d260 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt @@ -7,18 +7,14 @@ import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @Composable -fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String { +fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem, compact: Boolean = false): String { val seasonNumber = item.seasonNumber val episodeNumber = item.episodeNumber val episodeTitle = item.episodeTitle?.takeIf { it.isNotBlank() } val base = when { - seasonNumber != null && episodeNumber != null && item.isNextUp -> - stringResource(Res.string.continue_watching_up_next_episode, seasonNumber, episodeNumber) seasonNumber != null && episodeNumber != null -> stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) - item.isNextUp -> - stringResource(Res.string.continue_watching_up_next) item.parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) -> stringResource(Res.string.library_source_cloud) else -> 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 efb9d71b..67986c8a 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 @@ -172,8 +172,13 @@ fun HomeScreen( isTraktProgressActive, traktSettingsUiState.continueWatchingDaysCap, ) { + val filtered = if (isTraktProgressActive) { + watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) } + } else { + watchProgressUiState.entries + } filterEntriesForTraktContinueWatchingWindow( - entries = watchProgressUiState.entries, + entries = filtered, isTraktProgressActive = isTraktProgressActive, daysCap = traktSettingsUiState.continueWatchingDaysCap, nowEpochMs = WatchProgressClock.nowEpochMs(), @@ -186,9 +191,19 @@ fun HomeScreen( isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { + val filteredEntries = if (isTraktProgressActive) { + watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) } + } else { + watchProgressUiState.entries + } + val filteredWatchedItems = if (isTraktProgressActive) { + watchedUiState.items.filter { !WatchProgressRepository.isDroppedShow(it.id) } + } else { + watchedUiState.items + } buildHomeNextUpSeedCandidates( - progressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, + progressEntries = filteredEntries, + watchedItems = filteredWatchedItems, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), @@ -312,14 +327,25 @@ fun HomeScreen( if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } + if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { + return@mapNotNull null + } val item = cached.toContinueWatchingItem() ?: return@mapNotNull null - cached.contentId to (cached.sortTimestamp to item) + val sortTimestamp = if (item.isReleaseAlert) { + com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs(item.released) ?: cached.lastWatched + } else { + cached.lastWatched + } + cached.contentId to (sortTimestamp to item) }.toMap() } - val cachedInProgressItems = remember(cachedSnapshots.second) { - cachedSnapshots.second.associate { cached -> + val cachedInProgressItems = remember(cachedSnapshots.second, isTraktProgressActive) { + cachedSnapshots.second.mapNotNull { cached -> + if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { + return@mapNotNull null + } cached.videoId to cached.toContinueWatchingItem() - } + }.toMap() } val effectivNextUpItems = remember( @@ -413,7 +439,6 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, - cachedNextUpItems, visibleContinueWatchingEntries, metaProviderKey, continueWatchingPreferences.showUnairedNextUp, @@ -452,6 +477,7 @@ fun HomeScreen( candidate.content.id !in cachedResolvedNextUpItems } val resolutionCandidates = candidatesToResolve.take(NEXT_UP_INITIAL_RESOLUTION_LIMIT) + val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { nextUpItemsBySeries = cachedResolvedNextUpItems processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> @@ -461,6 +487,7 @@ fun HomeScreen( nextUpItemsBySeries = cachedResolvedNextUpItems, visibleContinueWatchingEntries = visibleContinueWatchingEntries, todayIsoDate = CurrentDateProvider.todayIsoDate(), + seedLastWatchedMap = seedLastWatchedMap, ) return@LaunchedEffect } @@ -523,6 +550,7 @@ fun HomeScreen( nextUpItemsBySeries = results, visibleContinueWatchingEntries = visibleContinueWatchingEntries, todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, ) } @@ -891,7 +919,12 @@ private suspend fun resolveHomeNextUpCandidate( return null } - return contentId to (completedEntry.markedAtEpochMs to item) + val sortTimestamp = if (item.isReleaseAlert) { + com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs(item.released) ?: completedEntry.markedAtEpochMs + } else { + completedEntry.markedAtEpochMs + } + return contentId to (sortTimestamp to item) } private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { @@ -1065,6 +1098,7 @@ private fun saveContinueWatchingSnapshots( nextUpItemsBySeries: Map>, visibleContinueWatchingEntries: List, todayIsoDate: String, + seedLastWatchedMap: Map, ) { val nextUpCache = nextUpItemsBySeries.mapNotNull { (contentId, pair) -> val item = pair.second @@ -1085,10 +1119,12 @@ private fun saveContinueWatchingSnapshots( hasAired = item.released?.let { released -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) } ?: true, - lastWatched = pair.first, + lastWatched = seedLastWatchedMap[contentId] ?: pair.first, sortTimestamp = pair.first, seedSeason = item.nextUpSeedSeasonNumber, seedEpisode = item.nextUpSeedEpisodeNumber, + isReleaseAlert = item.isReleaseAlert, + isNewSeasonRelease = item.isNewSeasonRelease, ) } val inProgressCache = visibleContinueWatchingEntries.map { entry -> @@ -1139,6 +1175,12 @@ private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean = isNextUp || progressFraction < 0.995f private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { + val alertState = com.nuvio.app.features.watchprogress.calculateReleaseAlertState( + seedLastUpdatedEpochMs = lastWatched, + seedSeasonNumber = seedSeason, + nextSeasonNumber = season, + releasedIso = released, + ) return ContinueWatchingItem( parentMetaId = contentId, parentMetaType = contentType, @@ -1166,6 +1208,8 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { resumeProgressFraction = null, durationMs = 0L, progressFraction = 0f, + isReleaseAlert = alertState.isReleaseAlert, + isNewSeasonRelease = alertState.isNewSeasonRelease, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index f70408b1..8f85e8ba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -50,6 +50,8 @@ import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle +import com.nuvio.app.features.watchprogress.CurrentDateProvider +import com.nuvio.app.features.watchprogress.computeAirDateBadgeText import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -60,12 +62,8 @@ private fun continueWatchingProgressPercent(progressFraction: Float): Int = @Composable private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): String = when { - item.seasonNumber != null && item.episodeNumber != null && item.isNextUp -> - stringResource(Res.string.continue_watching_up_next_episode, item.seasonNumber, item.episodeNumber) item.seasonNumber != null && item.episodeNumber != null -> stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber) - item.isNextUp -> - stringResource(Res.string.continue_watching_up_next) item.isCloudLibraryItem() -> stringResource(Res.string.library_source_cloud) else -> @@ -430,6 +428,7 @@ private fun ContinueWatchingWideCard( .padding(layout.wideContentPadding), verticalArrangement = Arrangement.SpaceBetween, ) { + val isCompact = layout.wideCardWidth < 350.dp val wideMetaLine = localizedContinueWatchingMetaLine(item) val episodeTitle = item.episodeTitle?.trim()?.takeIf { it.isNotBlank() } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -450,7 +449,18 @@ private fun ContinueWatchingWideCard( overflow = TextOverflow.Ellipsis, ) if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { - UpNextBadge(compact = false, textSize = layout.wideBadgeTextSize) + val todayIsoDate = CurrentDateProvider.todayIsoDate() + val badgeText = when { + item.isReleaseAlert -> { + if (item.isNewSeasonRelease) stringResource(Res.string.cw_new_season) + else stringResource(Res.string.cw_new_episode) + } + else -> { + computeAirDateBadgeText(item.released, todayIsoDate, compact = isCompact) + ?: stringResource(Res.string.home_continue_watching_up_next) + } + } + UpNextBadge(text = badgeText, compact = isCompact, textSize = layout.wideBadgeTextSize) } } Text( @@ -550,7 +560,18 @@ private fun ContinueWatchingPosterCard( .align(Alignment.TopEnd) .padding(8.dp), ) { - UpNextBadge(compact = true, textSize = layout.posterBadgeTextSize) + val todayIsoDate = CurrentDateProvider.todayIsoDate() + val badgeText = when { + item.isReleaseAlert -> { + if (item.isNewSeasonRelease) stringResource(Res.string.cw_new_season) + else stringResource(Res.string.cw_new_episode) + } + else -> { + computeAirDateBadgeText(item.released, todayIsoDate, compact = true) + ?: stringResource(Res.string.home_continue_watching_up_next) + } + } + UpNextBadge(text = badgeText, compact = true, textSize = layout.posterBadgeTextSize) } } if (item.progressFraction > 0f) { @@ -644,6 +665,7 @@ private fun ArtworkPanel( @Composable private fun UpNextBadge( + text: String, compact: Boolean, textSize: androidx.compose.ui.unit.TextUnit, ) { @@ -660,7 +682,7 @@ private fun UpNextBadge( ), ) { Text( - text = stringResource(Res.string.home_continue_watching_up_next), + text = text, style = MaterialTheme.typography.labelSmall.copy( fontSize = textSize, fontWeight = FontWeight.Bold, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index e5361ea2..7aa83580 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -58,6 +58,8 @@ object TraktProgressRepository { private val _uiState = MutableStateFlow(TraktProgressUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val hiddenProgressShowIds = MutableStateFlow>(emptySet()) + private var hasLoaded = false private var refreshRequestId: Long = 0L private val refreshJobMutex = Mutex() @@ -68,9 +70,23 @@ object TraktProgressRepository { hasLoaded = true } + fun isShowHiddenFromProgress(contentId: String): Boolean { + val ids = hiddenProgressShowIds.value + if (ids.isEmpty()) return false + val parsed = parseTraktContentIds(contentId) + val keys = buildList { + add(contentId) + parsed.imdb?.takeIf { it.isNotBlank() }?.let { add(it) } + parsed.tmdb?.let { add("tmdb:$it") } + parsed.trakt?.let { add("trakt:$it") } + } + return keys.any { ids.contains(it) } + } + fun onProfileChanged() { invalidateInFlightRefreshes() hasLoaded = false + hiddenProgressShowIds.value = emptySet() _uiState.value = TraktProgressUiState() ensureLoaded() } @@ -78,6 +94,7 @@ object TraktProgressRepository { fun clearLocalState() { invalidateInFlightRefreshes() hasLoaded = false + hiddenProgressShowIds.value = emptySet() _uiState.value = TraktProgressUiState() } @@ -147,7 +164,11 @@ object TraktProgressRepository { coroutineScope { val history = async { fetchHistoryEntries(headers) } val watchedShowSeeds = async { fetchWatchedShowSeedEntries(headers) } - history.await() + watchedShowSeeds.await() + val hiddenShows = async { fetchHiddenShowIds(headers) } + + val list = history.await() + watchedShowSeeds.await() + hiddenProgressShowIds.value = hiddenShows.await() + list } }.onFailure { error -> if (error is CancellationException) throw error @@ -321,6 +342,35 @@ object TraktProgressRepository { refreshNow() } + private suspend fun fetchHiddenShowIds(headers: Map): Set { + val allIds = mutableSetOf() + var page = 1 + val limit = 1000 + while (true) { + val payload = runCatching { + httpGetTextWithHeaders( + url = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit", + headers = headers, + ) + }.getOrNull() ?: break + + val items = runCatching { + json.decodeFromString>(payload) + }.getOrNull() ?: break + + if (items.isEmpty()) break + for (item in items) { + val ids = item.show?.ids ?: continue + ids.imdb?.takeIf { it.isNotBlank() }?.let { allIds.add(it) } + ids.tmdb?.let { allIds.add("tmdb:$it") } + ids.trakt?.let { allIds.add("trakt:$it") } + } + if (items.size < limit) break + page++ + } + return allIds + } + private suspend fun fetchPlaybackEntries(headers: Map): List = withContext(Dispatchers.Default) { val payloads = coroutineScope { val moviesPayload = async { @@ -862,3 +912,11 @@ private data class TraktEpisode( @SerialName("number") val number: Int? = null, @SerialName("ids") val ids: TraktExternalIds? = null, ) + +@Serializable +private data class TraktHiddenItem( + @SerialName("hidden_at") val hiddenAt: String? = null, + @SerialName("type") val type: String? = null, + @SerialName("show") val show: TraktMedia? = null, + @SerialName("movie") val movie: TraktMedia? = null, +) 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 0612a6b5..60320f56 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 @@ -73,7 +73,7 @@ private fun isExplicitlyReleasedBy( return isoDate <= todayIsoDate } -private fun daysUntilExplicitRelease( +internal fun daysUntilExplicitRelease( todayIsoDate: String, releasedDate: String?, ): Int? { @@ -82,7 +82,7 @@ private fun daysUntilExplicitRelease( return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() } -private fun isoCalendarDateOrNull(value: String?): String? { +internal fun isoCalendarDateOrNull(value: String?): String? { val datePart = value ?.trim() ?.substringBefore('T') @@ -99,7 +99,7 @@ private fun isoCalendarDateOrNull(value: String?): String? { return "$normalizedYear-$normalizedMonth-$normalizedDay" } -private fun isoEpochDay(date: String): Long { +internal 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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt new file mode 100644 index 00000000..13e2ebd5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -0,0 +1,112 @@ +package com.nuvio.app.features.watchprogress + +import androidx.compose.runtime.Composable +import com.nuvio.app.core.format.formatReleaseDateWithoutYear +import com.nuvio.app.features.watching.domain.daysUntilExplicitRelease +import com.nuvio.app.features.watching.domain.isoCalendarDateOrNull +import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.pluralStringResource +import org.jetbrains.compose.resources.stringResource +import co.touchlab.kermit.Logger + +@Composable +fun computeAirDateBadgeText( + releasedIso: String?, + todayIsoDate: String, + compact: Boolean +): String? { + if (releasedIso.isNullOrBlank() || todayIsoDate.isBlank()) { + return null + } + + val releaseEpoch = parseTraktIsoDateTimeToEpochMs(releasedIso) + if (releaseEpoch != null && WatchProgressClock.nowEpochMs() >= releaseEpoch) { + return null + } + + val daysUntil = daysUntilExplicitRelease( + todayIsoDate = todayIsoDate, + releasedDate = releasedIso, + ) ?: return null + + return when { + daysUntil < 0 -> null + daysUntil == 0 -> { + if (compact) stringResource(Res.string.cw_airs_today_short) + else stringResource(Res.string.cw_airs_today) + } + daysUntil == 1 -> { + if (compact) stringResource(Res.string.cw_airs_tomorrow_short) + else stringResource(Res.string.cw_airs_tomorrow) + } + daysUntil in 2..7 -> { + if (compact) pluralStringResource(Res.plurals.cw_airs_in_days_short, daysUntil, daysUntil) + else pluralStringResource(Res.plurals.cw_airs_in_days, daysUntil, daysUntil) + } + else -> { + val formattedDate = formatReleaseDateWithoutYear(releasedIso) + if (compact) stringResource(Res.string.cw_airs_date_short, formattedDate) + else stringResource(Res.string.cw_airs_date, formattedDate) + } + } +} + +fun parseReleaseDateToEpochMs(raw: String?): Long? { + if (raw.isNullOrBlank()) return null + val trimmed = raw.trim() + val epochMs = parseTraktIsoDateTimeToEpochMs(trimmed) + if (epochMs != null) return epochMs + + val datePart = isoCalendarDateOrNull(trimmed) ?: return null + return parseTraktIsoDateTimeToEpochMs("${datePart}T00:00:00Z") +} + +class ReleaseAlertState( + val isReleaseAlert: Boolean, + val isNewSeasonRelease: Boolean, +) + +fun calculateReleaseAlertState( + seedLastUpdatedEpochMs: Long, + seedSeasonNumber: Int?, + nextSeasonNumber: Int?, + releasedIso: String?, +): ReleaseAlertState { + val releaseEpoch = parseReleaseDateToEpochMs(releasedIso) + val nowMs = WatchProgressClock.nowEpochMs() + + val log = Logger.withTag("ReleaseAlert") + log.d { + "calculateReleaseAlertState inputs: releasedIso=$releasedIso, " + + "releaseEpoch=$releaseEpoch, seedLastUpdatedEpochMs=$seedLastUpdatedEpochMs, " + + "seedSeasonNumber=$seedSeasonNumber, nextSeasonNumber=$nextSeasonNumber, nowMs=$nowMs" + } + + if (releaseEpoch == null) { + log.d { "calculateReleaseAlertState failed: releaseEpoch is null" } + return ReleaseAlertState(false, false) + } + + val hasAired = nowMs >= releaseEpoch + val sixtyDaysMs = 60L * 24 * 60 * 60 * 1000 + val isReleaseAlert = hasAired && + releaseEpoch > seedLastUpdatedEpochMs && + (nowMs - releaseEpoch) < sixtyDaysMs + + val isNewSeasonRelease = isReleaseAlert && + seedSeasonNumber != null && + nextSeasonNumber != null && + nextSeasonNumber != seedSeasonNumber + + log.d { + "calculateReleaseAlertState result: isReleaseAlert=$isReleaseAlert (hasAired=$hasAired, " + + "epoch>seed=${releaseEpoch > seedLastUpdatedEpochMs}, ageMs=${nowMs - releaseEpoch}), " + + "isNewSeasonRelease=$isNewSeasonRelease" + } + + return ReleaseAlertState( + isReleaseAlert = isReleaseAlert, + isNewSeasonRelease = isNewSeasonRelease + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index c247eeda..fbdf59c0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -25,6 +25,8 @@ data class CachedNextUpItem( val sortTimestamp: Long, val seedSeason: Int? = null, val seedEpisode: Int? = null, + val isReleaseAlert: Boolean = false, + val isNewSeasonRelease: Boolean = false, ) @Serializable 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 7783f353..013d1c44 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 @@ -173,6 +173,8 @@ data class ContinueWatchingItem( val resumeProgressFraction: Float? = null, val durationMs: Long, val progressFraction: Float, + val isReleaseAlert: Boolean = false, + val isNewSeasonRelease: Boolean = false, ) data class ContinueWatchingPreferencesUiState( @@ -233,6 +235,8 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { resumeProgressFraction = explicitResumeProgressFraction, durationMs = normalizedEntry.durationMs, progressFraction = normalizedEntry.progressFraction, + isReleaseAlert = false, + isNewSeasonRelease = false, ) } @@ -249,6 +253,12 @@ private fun WatchProgressEntry.cloudLibraryPosterFallbackUrl(): String? { internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextEpisode: MetaVideo, ): ContinueWatchingItem { + val alertState = calculateReleaseAlertState( + seedLastUpdatedEpochMs = lastUpdatedEpochMs, + seedSeasonNumber = seasonNumber, + nextSeasonNumber = nextEpisode.season, + releasedIso = nextEpisode.released, + ) return ContinueWatchingItem( parentMetaId = parentMetaId, parentMetaType = parentMetaType, @@ -281,6 +291,8 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( resumeProgressFraction = null, durationMs = 0L, progressFraction = 0f, + isReleaseAlert = alertState.isReleaseAlert, + isNewSeasonRelease = alertState.isNewSeasonRelease, ) } 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 96e5bf6d..52274d18 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 @@ -585,4 +585,8 @@ object WatchProgressRepository { } } + fun isDroppedShow(contentId: String): Boolean { + return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId) + } + } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt index 749f07d0..f9544ecb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt @@ -33,4 +33,24 @@ class ReleaseDateDisplayTest { fun extractsYearFromYearOnly() { assertEquals(2024, extractReleaseYearForDisplay("2024")) } + + @Test + fun formatsIsoDateWithoutYear() { + assertEquals("February 1", formatReleaseDateWithoutYear("2025-02-01")) + } + + @Test + fun formatReleaseDateWithoutYearStripsTimePortion() { + assertEquals("January 15", formatReleaseDateWithoutYear("2024-01-15T12:30:00Z")) + } + + @Test + fun formatReleaseDateWithoutYearLeavesYearOnlyUnchanged() { + assertEquals("2024", formatReleaseDateWithoutYear("2024")) + } + + @Test + fun formatReleaseDateWithoutYearLeavesNonIsoUnchanged() { + assertEquals("TBA", formatReleaseDateWithoutYear("TBA")) + } } 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 index 7d5b6d62..5f1dc7cc 100644 --- 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 @@ -143,4 +143,38 @@ class WatchingPoliciesTest { assertEquals(2, latestCompleted.seasonNumber) assertEquals(3, latestCompleted.episodeNumber) } + + @Test + fun `isoCalendarDateOrNull parses various date formats`() { + assertEquals("2026-05-23", isoCalendarDateOrNull("2026-05-23T12:00:00Z")) + assertEquals("2026-05-23", isoCalendarDateOrNull(" 2026-05-23 ")) + assertEquals(null, isoCalendarDateOrNull("2026-5-23")) + assertEquals(null, isoCalendarDateOrNull("2026-5-9")) + assertEquals(null, isoCalendarDateOrNull("2026-05")) + assertEquals(null, isoCalendarDateOrNull("invalid")) + assertEquals(null, isoCalendarDateOrNull(null)) + } + + @Test + fun `daysUntilExplicitRelease calculates correct offset`() { + assertEquals(0, daysUntilExplicitRelease("2026-05-23", "2026-05-23")) + assertEquals(1, daysUntilExplicitRelease("2026-05-23", "2026-05-24")) + assertEquals(7, daysUntilExplicitRelease("2026-05-23", "2026-05-30")) + assertEquals(-3, daysUntilExplicitRelease("2026-05-23", "2026-05-20")) + assertEquals(null, daysUntilExplicitRelease("invalid", "2026-05-23")) + assertEquals(null, daysUntilExplicitRelease("2026-05-23", null)) + } + + @Test + fun `isoEpochDay handles leap years and standard calculations`() { + // Epoch reference (1970-01-01 is epoch day 0) + assertEquals(0L, isoEpochDay("1970-01-01")) + assertEquals(20596L, isoEpochDay("2026-05-23")) + // Leap year: 2024-02-29 + assertEquals(19782L, isoEpochDay("2024-02-29")) + assertEquals(19783L, isoEpochDay("2024-03-01")) + // Non-leap year: 2023-02-28 + assertEquals(19416L, isoEpochDay("2023-02-28")) + assertEquals(19417L, isoEpochDay("2023-03-01")) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index 883e47e6..eefc7b90 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -265,6 +265,19 @@ class WatchProgressRulesTest { assertEquals("kitsu:244:2", item.videoId) } + @Test + fun `parseReleaseDateToEpochMs handles ISO and date-only formats`() { + val t1 = parseReleaseDateToEpochMs("2026-05-24T15:00:00Z") + assertEquals(1779634800000L, t1) + + val t2 = parseReleaseDateToEpochMs("2026-05-24") + assertEquals(1779580800000L, t2) // 2026-05-24T00:00:00Z is 1779580800 seconds + + assertNull(parseReleaseDateToEpochMs(null)) + assertNull(parseReleaseDateToEpochMs(" ")) + assertNull(parseReleaseDateToEpochMs("invalid-date")) + } + private fun entry( videoId: String, parentMetaId: String = videoId.substringBefore(':'),