Merge remote-tracking branch 'origin/cmp-rewrite' into feat/extract-hardcoded-strings-pass4

# Conflicts:
#	composeApp/src/commonMain/composeResources/values-fr/strings.xml
#	composeApp/src/commonMain/composeResources/values/strings.xml
This commit is contained in:
Stéphane 2026-05-25 19:47:53 +02:00
commit fa9528d3a7
25 changed files with 544 additions and 26 deletions

View file

@ -1242,4 +1242,6 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_new_episode">Nový díl</string>
<string name="cw_new_season">Nová sezóna</string>
</resources>

View file

@ -1196,4 +1196,20 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_airs_date">Ausstrahlung am %1$s</string>
<string name="cw_airs_today">Ausstrahlung heute</string>
<string name="cw_airs_tomorrow">Ausstrahlung morgen</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Ausstrahlung in %1$d Tag</item>
<item quantity="other">Ausstrahlung in %1$d Tagen</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Heute</string>
<string name="cw_airs_tomorrow_short">Morgen</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">In %1$d Tag</item>
<item quantity="other">In %1$d Tagen</item>
</plurals>
<string name="cw_new_episode">Neue Folge</string>
<string name="cw_new_season">Neue Staffel</string>
</resources>

View file

@ -1040,4 +1040,20 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_airs_date">Πρεμιέρα %1$s</string>
<string name="cw_airs_today">Πρεμιέρα σήμερα</string>
<string name="cw_airs_tomorrow">Πρεμιέρα αύριο</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Πρεμιέρα σε %1$d ημέρα</item>
<item quantity="other">Πρεμιέρα σε %1$d ημέρες</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Σήμερα</string>
<string name="cw_airs_tomorrow_short">Αύριο</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">Σε %1$d ημέρα</item>
<item quantity="other">Σε %1$d ημέρες</item>
</plurals>
<string name="cw_new_episode">Νέο επεισόδιο</string>
<string name="cw_new_season">Νέα σεζόν</string>
</resources>

View file

@ -1158,4 +1158,22 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_airs_date">Se transmite %1$s</string>
<string name="cw_airs_today">Se transmite hoy</string>
<string name="cw_airs_tomorrow">Se transmite mañana</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Se transmite dentro de %1$d día</item>
<item quantity="many">Se transmite dentro de %1$d días</item>
<item quantity="other">Se transmite dentro de %1$d días</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Hoy</string>
<string name="cw_airs_tomorrow_short">Mañana</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">En %1$d día</item>
<item quantity="many">En %1$d días</item>
<item quantity="other">En %1$d días</item>
</plurals>
<string name="cw_new_episode">Nuevo episodio</string>
<string name="cw_new_season">Nueva temporada</string>
</resources>

View file

@ -1737,4 +1737,23 @@
<string name="updates_github_api_error">Erreur API releases GitHub : %1$d</string>
<string name="updates_no_channel_release">Aucune mise à jour na encore été publiée.</string>
<string name="updates_release_missing_title">La release na pas de tag ni de nom</string>
<!-- Continue Watching air date display -->
<string name="cw_airs_date">Diffusion le %1$s</string>
<string name="cw_airs_today">Diffusion aujourdhui</string>
<string name="cw_airs_tomorrow">Diffusion demain</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Diffusion dans %1$d jour</item>
<item quantity="many">Diffusion dans %1$d jours</item>
<item quantity="other">Diffusion dans %1$d jours</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Aujourdhui</string>
<string name="cw_airs_tomorrow_short">Demain</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">Dans %1$d jour</item>
<item quantity="many">Dans %1$d jours</item>
<item quantity="other">Dans %1$d jours</item>
</plurals>
<string name="cw_new_episode">Nouvel épisode</string>
<string name="cw_new_season">Nouvelle saison</string>
</resources>

View file

@ -1274,4 +1274,18 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_new_episode">Episode Baru</string>
<string name="cw_new_season">Musim Baru</string>
<string name="cw_airs_date">Tayang %1$s</string>
<string name="cw_airs_today">Tayang hari ini</string>
<string name="cw_airs_tomorrow">Tayang besok</string>
<plurals name="cw_airs_in_days">
<item quantity="other">Tayang dalam %d hari</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Hari ini</string>
<string name="cw_airs_tomorrow_short">Besok</string>
<plurals name="cw_airs_in_days_short">
<item quantity="other">Dalam %d hari</item>
</plurals>
</resources>

View file

@ -1170,4 +1170,22 @@
<string name="collections_editor_tmdb_subtitle_person">Persona</string>
<string name="collections_editor_tmdb_subtitle_director">Regista</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
<string name="cw_airs_date">In onda il %1$s</string>
<string name="cw_airs_today">In onda oggi</string>
<string name="cw_airs_tomorrow">In onda domani</string>
<plurals name="cw_airs_in_days">
<item quantity="one">In onda tra %1$d giorno</item>
<item quantity="many">In onda tra %1$d giorni</item>
<item quantity="other">In onda tra %1$d giorni</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Oggi</string>
<string name="cw_airs_tomorrow_short">Domani</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">Tra %1$d giorno</item>
<item quantity="many">Tra %1$d giorni</item>
<item quantity="other">Tra %1$d giorni</item>
</plurals>
<string name="cw_new_episode">Nuovo episodio</string>
<string name="cw_new_season">Nuova stagione</string>
</resources>

View file

@ -1352,4 +1352,20 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_new_episode">Ny episode</string>
<string name="cw_new_season">Ny sesong</string>
<string name="cw_airs_date">Sendes %1$s</string>
<string name="cw_airs_today">Sendes i dag</string>
<string name="cw_airs_tomorrow">Sendes i morgen</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Sendes om %d dag</item>
<item quantity="other">Sendes om %d dager</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">I dag</string>
<string name="cw_airs_tomorrow_short">I morgen</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">Om %d dag</item>
<item quantity="other">Om %d dager</item>
</plurals>
</resources>

View file

@ -1341,4 +1341,24 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_airs_date">Emisja %1$s</string>
<string name="cw_airs_today">Emisja dzisiaj</string>
<string name="cw_airs_tomorrow">Emisja jutro</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Emisja za %1$d dzień</item>
<item quantity="few">Emisja za %1$d dni</item>
<item quantity="many">Emisja za %1$d dni</item>
<item quantity="other">Emisja za %1$d dni</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Dzisiaj</string>
<string name="cw_airs_tomorrow_short">Jutro</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">Za %1$d dzień</item>
<item quantity="few">Za %1$d dni</item>
<item quantity="many">Za %1$d dni</item>
<item quantity="other">Za %1$d dni</item>
</plurals>
<string name="cw_new_episode">Nowy odcinek</string>
<string name="cw_new_season">Nowy sezon</string>
</resources>

View file

@ -1170,4 +1170,22 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_airs_date">Disponível em %1$s</string>
<string name="cw_airs_today">Disponível hoje</string>
<string name="cw_airs_tomorrow">Disponível amanhã</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Disponível em %1$d dia</item>
<item quantity="many">Disponível em %1$d dias</item>
<item quantity="other">Disponível em %1$d dias</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Hoje</string>
<string name="cw_airs_tomorrow_short">Amanhã</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">Em %1$d dia</item>
<item quantity="many">Em %1$d dias</item>
<item quantity="other">Em %1$d dias</item>
</plurals>
<string name="cw_new_episode">Novo Episódio</string>
<string name="cw_new_season">Nova Temporada</string>
</resources>

View file

@ -1040,4 +1040,20 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="cw_airs_date">Yayın tarihi: %1$s</string>
<string name="cw_airs_today">Bugün yayında</string>
<string name="cw_airs_tomorrow">Yarın yayında</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Yayın tarihi: %1$d gün sonra</item>
<item quantity="other">Yayın tarihi: %1$d gün sonra</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Bugün</string>
<string name="cw_airs_tomorrow_short">Yarın</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">%1$d gün sonra</item>
<item quantity="other">%1$d gün sonra</item>
</plurals>
<string name="cw_new_episode">Yeni Bölüm</string>
<string name="cw_new_season">Yeni Sezon</string>
</resources>

View file

@ -1735,4 +1735,21 @@
<string name="updates_github_api_error">GitHub releases API error: %1$d</string>
<string name="updates_no_channel_release">No update has been published yet.</string>
<string name="updates_release_missing_title">Release has no tag or name</string>
<!-- Continue Watching air date display -->
<string name="cw_airs_date">Airs %1$s</string>
<string name="cw_airs_today">Airs today</string>
<string name="cw_airs_tomorrow">Airs tomorrow</string>
<plurals name="cw_airs_in_days">
<item quantity="one">Airs in %1$d day</item>
<item quantity="other">Airs in %1$d days</item>
</plurals>
<string name="cw_airs_date_short">%1$s</string>
<string name="cw_airs_today_short">Today</string>
<string name="cw_airs_tomorrow_short">Tomorrow</string>
<plurals name="cw_airs_in_days_short">
<item quantity="one">In %1$d day</item>
<item quantity="other">In %1$d days</item>
</plurals>
<string name="cw_new_episode">New Episode</string>
<string name="cw_new_season">New Season</string>
</resources>

View file

@ -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).
*/

View file

@ -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 ->

View file

@ -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<String, Pair<Long, ContinueWatchingItem>>,
visibleContinueWatchingEntries: List<WatchProgressEntry>,
todayIsoDate: String,
seedLastWatchedMap: Map<String, Long>,
) {
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,
)
}

View file

@ -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,

View file

@ -58,6 +58,8 @@ object TraktProgressRepository {
private val _uiState = MutableStateFlow(TraktProgressUiState())
val uiState: StateFlow<TraktProgressUiState> = _uiState.asStateFlow()
private val hiddenProgressShowIds = MutableStateFlow<Set<String>>(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<String, String>): Set<String> {
val allIds = mutableSetOf<String>()
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<List<TraktHiddenItem>>(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<String, String>): List<WatchProgressEntry> = 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,
)

View file

@ -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()

View file

@ -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
)
}

View file

@ -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

View file

@ -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,
)
}

View file

@ -585,4 +585,8 @@ object WatchProgressRepository {
}
}
fun isDroppedShow(contentId: String): Boolean {
return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId)
}
}

View file

@ -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"))
}
}

View file

@ -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"))
}
}

View file

@ -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(':'),