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 15b373b8..8905f087 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -1192,4 +1192,22 @@
Ko
Mo
Go
+ 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 92ee5269..7acbaaa0 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -1397,4 +1397,20 @@
KB
MB
GB
+ 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(':'),