From b8b6a697bb7cf3d3b3ea4ff696f54d81008fdcd9 Mon Sep 17 00:00:00 2001 From: Vlad Jerca Date: Mon, 18 May 2026 17:36:38 +0200 Subject: [PATCH 1/5] fix: hide stale mid-season episode tags on up-next cards Mid-season finale and premiere tags previously stuck around past the mid-season cut. Once a newer episode aired they stopped being actionable but still read as "Finale" / "Premiere" on the up-next surfaces, making it look like the show had ended. Gate the tag on whether the episode is the latest aired episode of the show, derived from the progress endpoint's `last_episode`. Series and season finales / premieres are unaffected. Refs trakt-web#2367 --- .../features/all/ui/AllUpNextShowView.kt | 4 ++-- .../home/sections/upnext/model/UpNextShow.kt | 1 + .../sections/upnext/ui/HomeUpNextShowView.kt | 4 ++-- .../upnext/usecases/GetUpNextUseCase.kt | 15 +++++++++------ .../tv/trakt/trakt/common/model/Episode.kt | 19 +++++++++++++------ .../common/model/IsLatestAiredEpisode.kt | 10 ++++++++++ .../sections/shows/upnext/HomeUpNextView.kt | 4 ++-- .../shows/upnext/model/ProgressShow.kt | 1 + .../upnext/usecases/GetShowsUpNextUseCase.kt | 15 +++++++++------ .../upnext/viewall/UpNextViewAllScreen.kt | 4 ++-- 10 files changed, 51 insertions(+), 26 deletions(-) create mode 100644 common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/all/ui/AllUpNextShowView.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/all/ui/AllUpNextShowView.kt index 11b5fdf91..6f6cf8891 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/all/ui/AllUpNextShowView.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/all/ui/AllUpNextShowView.kt @@ -39,8 +39,8 @@ internal fun AllUpNextShowView( onShowClick: () -> Unit, modifier: Modifier = Modifier, ) { - val isPremiere = item.progress.nextEpisode?.isPremiere() == true - val isFinale = item.progress.nextEpisode?.isFinale() == true + val isPremiere = item.progress.nextEpisode?.isPremiere(item.progress.isLatestAired) == true + val isFinale = item.progress.nextEpisode?.isFinale(item.progress.isLatestAired) == true PanelMediaCard( title = item.show.title, diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt index 520931396..1697ff2a9 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt @@ -38,6 +38,7 @@ internal data class Progress( val stats: Stats?, val nextEpisode: Episode?, val lastEpisode: Episode?, + val isLatestAired: Boolean = false, ) { @Immutable internal data class Stats( diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt index f68c1881c..b2ec62749 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt @@ -67,7 +67,7 @@ internal fun HomeUpNextShowView( verticalArrangement = Arrangement.spacedBy(3.dp), ) { when { - item.progress.nextEpisode?.isPremiere() == true -> PremiereChip( + item.progress.nextEpisode?.isPremiere(item.progress.isLatestAired) == true -> PremiereChip( contentTextStyle = TraktTheme.typography.meta.copy( fontSize = 10.sp, ), @@ -75,7 +75,7 @@ internal fun HomeUpNextShowView( .shadow(2.dp, RoundedCornerShape(100)) .height(20.dp), ) - item.progress.nextEpisode?.isFinale() == true -> FinaleChip( + item.progress.nextEpisode?.isFinale(item.progress.isLatestAired) == true -> FinaleChip( contentTextStyle = TraktTheme.typography.meta.copy( fontSize = 10.sp, ), diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt index 953cd8995..0533f3696 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt @@ -12,6 +12,7 @@ import tv.trakt.trakt.common.model.Episode import tv.trakt.trakt.common.model.Movie import tv.trakt.trakt.common.model.Show import tv.trakt.trakt.common.model.fromDto +import tv.trakt.trakt.common.model.isLatestAiredEpisode import tv.trakt.trakt.core.home.sections.upnext.data.local.HomeUpNextLocalDataSource import tv.trakt.trakt.core.home.sections.upnext.model.Progress import tv.trakt.trakt.core.home.sections.upnext.model.UpNextItem @@ -76,6 +77,8 @@ internal class GetUpNextUseCase( return remoteItems .asyncMap { item -> + val nextEpisode = item.progress.nextEpisode?.let { Episode.fromDto(it) } + val lastEpisode = item.progress.lastEpisode?.let { Episode.fromDto(it) } UpNextShow( show = Show.fromDto(item.show), progress = Progress( @@ -89,12 +92,12 @@ internal class GetUpNextUseCase( minutesLeft = it.minutesLeft, ) }, - lastEpisode = item.progress.lastEpisode?.let { - Episode.fromDto(it) - }, - nextEpisode = item.progress.nextEpisode?.let { - Episode.fromDto(it) - }, + lastEpisode = lastEpisode, + nextEpisode = nextEpisode, + isLatestAired = isLatestAiredEpisode( + episode = nextEpisode?.seasonEpisode, + latest = lastEpisode?.seasonEpisode, + ), ), ) } diff --git a/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt b/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt index 9e07c8b19..588eede95 100644 --- a/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt +++ b/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt @@ -64,16 +64,23 @@ data class Episode( } @Composable - fun isPremiere(): Boolean = - remember(episodeType) { - episodeType?.contains("premiere") == true + fun isPremiere(isLatestAired: Boolean? = null): Boolean = + remember(episodeType, isLatestAired) { + val premiere = episodeType?.contains("premiere") == true + premiere && !isMidSeasonHidden(isLatestAired) } @Composable - fun isFinale(): Boolean = - remember(episodeType) { - episodeType?.contains("finale") == true + fun isFinale(isLatestAired: Boolean? = null): Boolean = + remember(episodeType, isLatestAired) { + val finale = episodeType?.contains("finale") == true + finale && !isMidSeasonHidden(isLatestAired) } + + private fun isMidSeasonHidden(isLatestAired: Boolean?): Boolean { + if (isLatestAired != false) return false + return episodeType?.contains("mid_season") == true + } } fun Episode.Companion.fromDto(dto: EpisodeDto): Episode { diff --git a/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt b/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt new file mode 100644 index 000000000..7f402670c --- /dev/null +++ b/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt @@ -0,0 +1,10 @@ +package tv.trakt.trakt.common.model + +fun isLatestAiredEpisode( + episode: SeasonEpisode?, + latest: SeasonEpisode?, +): Boolean { + if (episode == null) return false + if (latest == null) return true + return episode.id >= latest.id +} diff --git a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/HomeUpNextView.kt b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/HomeUpNextView.kt index d87f6a380..2fce08813 100644 --- a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/HomeUpNextView.kt +++ b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/HomeUpNextView.kt @@ -223,8 +223,8 @@ private fun ContentShowListItem( verticalArrangement = spacedBy(3.dp), ) { when { - item.progress.nextEpisode?.isPremiere() == true -> PremiereChip() - item.progress.nextEpisode?.isFinale() == true -> FinaleChip() + item.progress.nextEpisode?.isPremiere(item.progress.isLatestAired) == true -> PremiereChip() + item.progress.nextEpisode?.isFinale(item.progress.isLatestAired) == true -> FinaleChip() } Row( diff --git a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt index 542a0813c..cdde49ce5 100644 --- a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt +++ b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt @@ -30,6 +30,7 @@ internal data class ProgressShow( val stats: Stats?, val nextEpisode: Episode?, val lastEpisode: Episode?, + val isLatestAired: Boolean = false, ) { @Immutable internal data class Stats( diff --git a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt index 7e5b0dd4d..4be4be2a1 100644 --- a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt +++ b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt @@ -13,6 +13,7 @@ import tv.trakt.trakt.common.helpers.extensions.toZonedDateTime import tv.trakt.trakt.common.model.Episode import tv.trakt.trakt.common.model.Show import tv.trakt.trakt.common.model.fromDto +import tv.trakt.trakt.common.model.isLatestAiredEpisode internal class GetShowsUpNextUseCase( private val remoteShowsSource: ShowsSyncRemoteDataSource, @@ -32,6 +33,8 @@ internal class GetShowsUpNextUseCase( ) return remoteItems .asyncMap { item -> + val nextEpisode = item.progress.nextEpisode?.let { Episode.fromDto(it) } + val lastEpisode = item.progress.lastEpisode?.let { Episode.fromDto(it) } ProgressShow( show = Show.fromDto(item.show), progress = Progress( @@ -45,12 +48,12 @@ internal class GetShowsUpNextUseCase( minutesLeft = it.minutesLeft, ) }, - lastEpisode = item.progress.lastEpisode?.let { - Episode.fromDto(it) - }, - nextEpisode = item.progress.nextEpisode?.let { - Episode.fromDto(it) - }, + lastEpisode = lastEpisode, + nextEpisode = nextEpisode, + isLatestAired = isLatestAiredEpisode( + episode = nextEpisode?.seasonEpisode, + latest = lastEpisode?.seasonEpisode, + ), ), ) } diff --git a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/viewall/UpNextViewAllScreen.kt b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/viewall/UpNextViewAllScreen.kt index a9b4ba3d2..072ddfca6 100644 --- a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/viewall/UpNextViewAllScreen.kt +++ b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/viewall/UpNextViewAllScreen.kt @@ -249,14 +249,14 @@ private fun UpNextViewAllContent( verticalArrangement = spacedBy(3.dp), ) { when { - item.progress.nextEpisode?.isPremiere() == true -> { + item.progress.nextEpisode?.isPremiere(item.progress.isLatestAired) == true -> { PremiereChip( contentTextStyle = TraktTheme.typography.meta.copy( fontSize = 10.sp, ), ) } - item.progress.nextEpisode?.isFinale() == true -> { + item.progress.nextEpisode?.isFinale(item.progress.isLatestAired) == true -> { FinaleChip( contentTextStyle = TraktTheme.typography.meta.copy( fontSize = 10.sp, From ff29878ac20327ded62bc38d391b9d0c40bcd0c2 Mon Sep 17 00:00:00 2001 From: Vlad Jerca Date: Mon, 18 May 2026 17:37:11 +0200 Subject: [PATCH 2/5] feat: surface specific episode type in details meta info The episode tag on cards intentionally collapses series/season/mid-season finales and premieres into a single "Finale" / "Premiere" label so the card UI stays minimal. The exact variant (mid-season finale, season premiere, etc.) was not surfaced anywhere on the client, so users had no way to recover the full metadata once the tag was generic or hidden. Add a new "Type" row to the episode details meta info that renders the specific variant via a new `episodeTypeStringRes` helper on `Episode`. Six new string resources cover the finale / premiere variants. Refs trakt-web#2367 --- .../trakt/core/summary/ui/DetailsMetaInfo.kt | 16 ++++++++++++++++ .../java/tv/trakt/trakt/common/model/Episode.kt | 11 +++++++++++ resources/src/main/res/values/strings.xml | 7 +++++++ 3 files changed, 34 insertions(+) diff --git a/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt b/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt index e387e9ecd..090038790 100644 --- a/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt +++ b/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt @@ -1,6 +1,7 @@ package tv.trakt.trakt.core.summary.ui import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -77,6 +78,7 @@ internal fun DetailsMetaInfo( episode.releasedAt?.toLocal()?.toLocalDate() }, runtime = episode.runtime, + episodeTypeRes = episode.episodeTypeStringRes, directors = episodeDirectors, writers = episodeWriters, episodeRowsOnly = true, @@ -120,6 +122,7 @@ private fun DetailsMetaInfo( network: String? = null, titleOriginal: String? = null, episodesCount: Int? = null, + episodeTypeRes: Int? = null, languages: ImmutableList = EmptyImmutableList, genres: ImmutableList = EmptyImmutableList, studios: ImmutableList? = null, @@ -201,6 +204,19 @@ private fun DetailsMetaInfo( } } + if (episodeTypeRes != null) { + Row( + horizontalArrangement = spacedBy(16.dp), + ) { + DetailsMeta( + title = stringResource(R.string.header_episode_type), + values = listOf(stringResource(episodeTypeRes)), + modifier = Modifier.weight(1F), + ) + Box(modifier = Modifier.weight(1F)) + } + } + Row( horizontalArrangement = spacedBy(16.dp), ) { diff --git a/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt b/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt index 588eede95..2d5d95c2d 100644 --- a/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt +++ b/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt @@ -81,6 +81,17 @@ data class Episode( if (isLatestAired != false) return false return episodeType?.contains("mid_season") == true } + + val episodeTypeStringRes: Int? + get() = when (episodeType) { + "series_premiere" -> R.string.tag_text_series_premiere + "season_premiere" -> R.string.tag_text_season_premiere + "mid_season_premiere" -> R.string.tag_text_mid_season_premiere + "series_finale" -> R.string.tag_text_series_finale + "season_finale" -> R.string.tag_text_season_finale + "mid_season_finale" -> R.string.tag_text_mid_season_finale + else -> null + } } fun Episode.Companion.fromDto(dto: EpisodeDto): Episode { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 77bf96c62..195b132c0 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -90,6 +90,7 @@ Scan the QR code, or sign in at: The activation was denied, expired, or something unforeseeable happened. Please try again. Director + Type Genre Get Notified! Discover @@ -200,8 +201,14 @@ Plays Watchers Finale + Mid-Season Finale + Mid-Season Premiere %d eps. Premiere + Season Finale + Season Premiere + Series Finale + Series Premiere %s left %d left Watch count From 72b7be92e94471c0e02bf9fafd1c24b42ff69b9d Mon Sep 17 00:00:00 2001 From: Vlad Jerca Date: Mon, 18 May 2026 19:45:37 +0200 Subject: [PATCH 3/5] refactor: compare season/episode tuples directly in isLatestAiredEpisode Mirrors the web reference implementation and removes reliance on `SeasonEpisode.id`'s `season * 10_000` packing, which would silently break for any season with >= 10000 episodes (impossible in practice, but the tuple compare is clearer and matches the source PR). --- .../java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt b/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt index 7f402670c..38deadae1 100644 --- a/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt +++ b/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt @@ -6,5 +6,8 @@ fun isLatestAiredEpisode( ): Boolean { if (episode == null) return false if (latest == null) return true - return episode.id >= latest.id + + if (episode.season > latest.season) return true + if (episode.season < latest.season) return false + return episode.episode >= latest.episode } From 360983439c2f651e82f0d1543135832a4124149d Mon Sep 17 00:00:00 2001 From: Vlad Jerca Date: Tue, 19 May 2026 10:26:21 +0200 Subject: [PATCH 4/5] refactor: derive isLatestAired from remaining episode count progress.last_episode is the user's furthest watched episode, not the show's latest aired episode. Comparing next_episode against it is effectively always true (next is the first unwatched after last_watched by definition), so the previous gate never actually hid a stale tag. As a proxy, treat the next episode as the latest aired when the remaining count (aired - completed) is 1 or less - meaning no further aired episode exists beyond the displayed one. Drops the isLatestAiredEpisode helper, with a FIXME to replace once the API exposes an absolute "latest aired episode" reference. --- .../sections/upnext/usecases/GetUpNextUseCase.kt | 11 ++++++----- .../trakt/common/model/IsLatestAiredEpisode.kt | 13 ------------- .../shows/upnext/usecases/GetShowsUpNextUseCase.kt | 11 ++++++----- 3 files changed, 12 insertions(+), 23 deletions(-) delete mode 100644 common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt index 0533f3696..09b93ee9a 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/usecases/GetUpNextUseCase.kt @@ -12,7 +12,6 @@ import tv.trakt.trakt.common.model.Episode import tv.trakt.trakt.common.model.Movie import tv.trakt.trakt.common.model.Show import tv.trakt.trakt.common.model.fromDto -import tv.trakt.trakt.common.model.isLatestAiredEpisode import tv.trakt.trakt.core.home.sections.upnext.data.local.HomeUpNextLocalDataSource import tv.trakt.trakt.core.home.sections.upnext.model.Progress import tv.trakt.trakt.core.home.sections.upnext.model.UpNextItem @@ -94,10 +93,12 @@ internal class GetUpNextUseCase( }, lastEpisode = lastEpisode, nextEpisode = nextEpisode, - isLatestAired = isLatestAiredEpisode( - episode = nextEpisode?.seasonEpisode, - latest = lastEpisode?.seasonEpisode, - ), + // FIXME: progress.last_episode is the user's furthest watched episode, not the + // show's latest aired episode, so we can't compare directly. As a proxy, treat + // the next episode as the latest aired when remaining (aired - completed) is 1 + // or less - i.e. no further aired episode exists beyond the displayed one. + // Replace once the API surfaces an absolute "latest aired episode" reference. + isLatestAired = (item.progress.aired - item.progress.completed) <= 1, ), ) } diff --git a/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt b/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt deleted file mode 100644 index 38deadae1..000000000 --- a/common/src/main/java/tv/trakt/trakt/common/model/IsLatestAiredEpisode.kt +++ /dev/null @@ -1,13 +0,0 @@ -package tv.trakt.trakt.common.model - -fun isLatestAiredEpisode( - episode: SeasonEpisode?, - latest: SeasonEpisode?, -): Boolean { - if (episode == null) return false - if (latest == null) return true - - if (episode.season > latest.season) return true - if (episode.season < latest.season) return false - return episode.episode >= latest.episode -} diff --git a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt index 4be4be2a1..f8e3cf4c1 100644 --- a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt +++ b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/usecases/GetShowsUpNextUseCase.kt @@ -13,7 +13,6 @@ import tv.trakt.trakt.common.helpers.extensions.toZonedDateTime import tv.trakt.trakt.common.model.Episode import tv.trakt.trakt.common.model.Show import tv.trakt.trakt.common.model.fromDto -import tv.trakt.trakt.common.model.isLatestAiredEpisode internal class GetShowsUpNextUseCase( private val remoteShowsSource: ShowsSyncRemoteDataSource, @@ -50,10 +49,12 @@ internal class GetShowsUpNextUseCase( }, lastEpisode = lastEpisode, nextEpisode = nextEpisode, - isLatestAired = isLatestAiredEpisode( - episode = nextEpisode?.seasonEpisode, - latest = lastEpisode?.seasonEpisode, - ), + // FIXME: progress.last_episode is the user's furthest watched episode, not the + // show's latest aired episode, so we can't compare directly. As a proxy, treat + // the next episode as the latest aired when remaining (aired - completed) is 1 + // or less - i.e. no further aired episode exists beyond the displayed one. + // Replace once the API surfaces an absolute "latest aired episode" reference. + isLatestAired = (item.progress.aired - item.progress.completed) <= 1, ), ) } From d3743349ee2fafe0d0fd536df3dfcef077ff369e Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 25 May 2026 10:46:15 +0200 Subject: [PATCH 5/5] refactor: extracted EpisodeType enum and simplify --- .../features/context/UpNextItemContextView.kt | 1 + .../home/sections/upnext/model/UpNextShow.kt | 2 +- .../sections/upnext/ui/HomeUpNextShowView.kt | 43 +++++++++-------- .../trakt/core/summary/ui/DetailsMetaInfo.kt | 9 ++-- .../common/helpers/preview/PreviewData.kt | 2 +- .../tv/trakt/trakt/common/model/Episode.kt | 48 ++++++++----------- .../trakt/trakt/common/model/EpisodeType.kt | 27 +++++++++++ .../shows/upnext/model/ProgressShow.kt | 2 +- 8 files changed, 79 insertions(+), 55 deletions(-) create mode 100644 common/src/main/java/tv/trakt/trakt/common/model/EpisodeType.kt diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/context/UpNextItemContextView.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/context/UpNextItemContextView.kt index 51ddcacbf..06a51e737 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/context/UpNextItemContextView.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/features/context/UpNextItemContextView.kt @@ -226,6 +226,7 @@ private fun Preview() { ), nextEpisode = PreviewData.episode1, lastEpisode = null, + isLatestAired = false, ), show = PreviewData.show1, ), diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt index 1697ff2a9..8ef1a3ccc 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/model/UpNextShow.kt @@ -38,7 +38,7 @@ internal data class Progress( val stats: Stats?, val nextEpisode: Episode?, val lastEpisode: Episode?, - val isLatestAired: Boolean = false, + val isLatestAired: Boolean, ) { @Immutable internal data class Stats( diff --git a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt index b2ec62749..f1faba2b0 100644 --- a/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt +++ b/app/src/main/java/tv/trakt/trakt/core/home/sections/upnext/ui/HomeUpNextShowView.kt @@ -66,26 +66,31 @@ internal fun HomeUpNextShowView( Column( verticalArrangement = Arrangement.spacedBy(3.dp), ) { + val isLatestAired = item.progress.isLatestAired when { - item.progress.nextEpisode?.isPremiere(item.progress.isLatestAired) == true -> PremiereChip( - contentTextStyle = TraktTheme.typography.meta.copy( - fontSize = 10.sp, - ), - modifier = Modifier - .shadow(2.dp, RoundedCornerShape(100)) - .height(20.dp), - ) - item.progress.nextEpisode?.isFinale(item.progress.isLatestAired) == true -> FinaleChip( - contentTextStyle = TraktTheme.typography.meta.copy( - fontSize = 10.sp, - ), - modifier = Modifier - .shadow( - 2.dp, - androidx.compose.foundation.shape.RoundedCornerShape(100), - ) - .height(20.dp), - ) + item.progress.nextEpisode?.isPremiere(isLatestAired) == true -> { + PremiereChip( + contentTextStyle = TraktTheme.typography.meta.copy( + fontSize = 10.sp, + ), + modifier = Modifier + .shadow(2.dp, RoundedCornerShape(100)) + .height(20.dp), + ) + } + item.progress.nextEpisode?.isFinale(isLatestAired) == true -> { + FinaleChip( + contentTextStyle = TraktTheme.typography.meta.copy( + fontSize = 10.sp, + ), + modifier = Modifier + .shadow( + 2.dp, + androidx.compose.foundation.shape.RoundedCornerShape(100), + ) + .height(20.dp), + ) + } } EpisodeProgressBar( diff --git a/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt b/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt index 090038790..b4e9251c4 100644 --- a/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt +++ b/app/src/main/java/tv/trakt/trakt/core/summary/ui/DetailsMetaInfo.kt @@ -22,6 +22,7 @@ import tv.trakt.trakt.common.helpers.extensions.rememberDurationFormat import tv.trakt.trakt.common.helpers.extensions.toLocal import tv.trakt.trakt.common.helpers.preview.PreviewData import tv.trakt.trakt.common.model.Episode +import tv.trakt.trakt.common.model.EpisodeType import tv.trakt.trakt.common.model.MediaGenre import tv.trakt.trakt.common.model.MediaStatus import tv.trakt.trakt.common.model.Movie @@ -78,7 +79,7 @@ internal fun DetailsMetaInfo( episode.releasedAt?.toLocal()?.toLocalDate() }, runtime = episode.runtime, - episodeTypeRes = episode.episodeTypeStringRes, + episodeType = episode.type, directors = episodeDirectors, writers = episodeWriters, episodeRowsOnly = true, @@ -122,7 +123,7 @@ private fun DetailsMetaInfo( network: String? = null, titleOriginal: String? = null, episodesCount: Int? = null, - episodeTypeRes: Int? = null, + episodeType: EpisodeType? = null, languages: ImmutableList = EmptyImmutableList, genres: ImmutableList = EmptyImmutableList, studios: ImmutableList? = null, @@ -204,13 +205,13 @@ private fun DetailsMetaInfo( } } - if (episodeTypeRes != null) { + if (episodeType != null) { Row( horizontalArrangement = spacedBy(16.dp), ) { DetailsMeta( title = stringResource(R.string.header_episode_type), - values = listOf(stringResource(episodeTypeRes)), + values = listOf(stringResource(episodeType.stringRes)), modifier = Modifier.weight(1F), ) Box(modifier = Modifier.weight(1F)) diff --git a/common/src/main/java/tv/trakt/trakt/common/helpers/preview/PreviewData.kt b/common/src/main/java/tv/trakt/trakt/common/helpers/preview/PreviewData.kt index 371b41fae..9eba3317b 100644 --- a/common/src/main/java/tv/trakt/trakt/common/helpers/preview/PreviewData.kt +++ b/common/src/main/java/tv/trakt/trakt/common/helpers/preview/PreviewData.kt @@ -148,7 +148,7 @@ object PreviewData { rating = Rating(rating = 4.34f, votes = 5394), commentCount = 4424, runtime = 24.minutes, - episodeType = null, + type = null, originalTitle = "Episode Original Title", images = Images( screenshot = listOf( diff --git a/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt b/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt index 2d5d95c2d..bb5745c4d 100644 --- a/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt +++ b/common/src/main/java/tv/trakt/trakt/common/model/Episode.kt @@ -7,6 +7,8 @@ import androidx.compose.ui.res.stringResource import kotlinx.collections.immutable.toImmutableList import tv.trakt.trakt.common.helpers.extensions.nowUtcInstant import tv.trakt.trakt.common.helpers.extensions.toInstant +import tv.trakt.trakt.common.model.EpisodeType.MID_SEASON_FINALE +import tv.trakt.trakt.common.model.EpisodeType.MID_SEASON_PREMIERE import tv.trakt.trakt.common.networking.EpisodeDto import tv.trakt.trakt.common.networking.EpisodeLikesDto import tv.trakt.trakt.common.networking.LastEpisodeDto @@ -18,6 +20,7 @@ import kotlin.time.Duration.Companion.minutes @Immutable data class Episode( val ids: Ids, + val type: EpisodeType?, val number: Int, val season: Int, val title: String, @@ -26,7 +29,6 @@ data class Episode( val rating: Rating, val commentCount: Int, val runtime: Duration?, - val episodeType: String?, val originalTitle: String, val images: Images?, val updatedAt: Instant?, @@ -56,7 +58,11 @@ data class Episode( @Composable fun seasonEpisodeString(): String { - val string = stringResource(R.string.episode_footer_season_episode, this.season, this.number) + val string = stringResource( + R.string.episode_footer_season_episode, + this.season, + this.number, + ) return when { title.isNotBlank() -> "$string - $title" else -> string @@ -64,39 +70,24 @@ data class Episode( } @Composable - fun isPremiere(isLatestAired: Boolean? = null): Boolean = - remember(episodeType, isLatestAired) { - val premiere = episodeType?.contains("premiere") == true - premiere && !isMidSeasonHidden(isLatestAired) + fun isPremiere(isLatestAired: Boolean = false): Boolean = + remember(type, isLatestAired) { + if (type?.isPremiere == true) return@remember true + type == MID_SEASON_PREMIERE && isLatestAired } @Composable - fun isFinale(isLatestAired: Boolean? = null): Boolean = - remember(episodeType, isLatestAired) { - val finale = episodeType?.contains("finale") == true - finale && !isMidSeasonHidden(isLatestAired) - } - - private fun isMidSeasonHidden(isLatestAired: Boolean?): Boolean { - if (isLatestAired != false) return false - return episodeType?.contains("mid_season") == true - } - - val episodeTypeStringRes: Int? - get() = when (episodeType) { - "series_premiere" -> R.string.tag_text_series_premiere - "season_premiere" -> R.string.tag_text_season_premiere - "mid_season_premiere" -> R.string.tag_text_mid_season_premiere - "series_finale" -> R.string.tag_text_series_finale - "season_finale" -> R.string.tag_text_season_finale - "mid_season_finale" -> R.string.tag_text_mid_season_finale - else -> null + fun isFinale(isLatestAired: Boolean = false): Boolean = + remember(type, isLatestAired) { + if (type?.isFinale == true) return@remember true + type == MID_SEASON_FINALE && isLatestAired } } fun Episode.Companion.fromDto(dto: EpisodeDto): Episode { return Episode( ids = Ids.fromDto(dto.ids), + type = dto.episodeType?.let { EpisodeType.fromValue(it.value) }, number = dto.number, season = dto.season, title = dto.title ?: "N/A", @@ -108,7 +99,6 @@ fun Episode.Companion.fromDto(dto: EpisodeDto): Episode { ), commentCount = dto.commentCount ?: 0, runtime = dto.runtime?.minutes, - episodeType = dto.episodeType?.value, originalTitle = dto.originalTitle ?: "", images = Images( screenshot = (dto.images?.screenshot ?: emptyList()).toImmutableList(), @@ -122,6 +112,7 @@ fun Episode.Companion.fromDto(dto: EpisodeDto): Episode { fun Episode.Companion.fromDto(dto: LastEpisodeDto): Episode { return Episode( ids = Ids.fromDto(dto.ids), + type = dto.episodeType?.let { EpisodeType.fromValue(it.value) }, number = dto.number, season = dto.season, title = dto.title ?: "N/A", @@ -133,7 +124,6 @@ fun Episode.Companion.fromDto(dto: LastEpisodeDto): Episode { ), commentCount = dto.commentCount ?: 0, runtime = dto.runtime?.minutes, - episodeType = dto.episodeType?.value, originalTitle = dto.originalTitle ?: "", images = Images( screenshot = (dto.images?.screenshot ?: emptyList()).toImmutableList(), @@ -147,6 +137,7 @@ fun Episode.Companion.fromDto(dto: LastEpisodeDto): Episode { fun Episode.Companion.fromDto(dto: EpisodeLikesDto): Episode { return Episode( ids = Ids.fromDto(dto.ids), + type = dto.episodeType?.let { EpisodeType.fromValue(it.value) }, number = dto.number, season = dto.season, title = dto.title ?: "N/A", @@ -158,7 +149,6 @@ fun Episode.Companion.fromDto(dto: EpisodeLikesDto): Episode { ), commentCount = dto.commentCount ?: 0, runtime = dto.runtime?.minutes, - episodeType = dto.episodeType?.value, originalTitle = dto.originalTitle ?: "", images = Images( screenshot = (dto.images?.screenshot ?: emptyList()).toImmutableList(), diff --git a/common/src/main/java/tv/trakt/trakt/common/model/EpisodeType.kt b/common/src/main/java/tv/trakt/trakt/common/model/EpisodeType.kt new file mode 100644 index 000000000..1663ace9e --- /dev/null +++ b/common/src/main/java/tv/trakt/trakt/common/model/EpisodeType.kt @@ -0,0 +1,27 @@ +package tv.trakt.trakt.common.model + +import androidx.annotation.StringRes +import tv.trakt.trakt.resources.R + +enum class EpisodeType( + val value: String, + @param:StringRes val stringRes: Int, +) { + SERIES_PREMIERE("series_premiere", R.string.tag_text_series_premiere), + SERIES_FINALE("series_finale", R.string.tag_text_series_finale), + SEASON_PREMIERE("season_premiere", R.string.tag_text_season_premiere), + SEASON_FINALE("season_finale", R.string.tag_text_season_finale), + MID_SEASON_PREMIERE("mid_season_premiere", R.string.tag_text_mid_season_premiere), + MID_SEASON_FINALE("mid_season_finale", R.string.tag_text_mid_season_finale), + ; + + val isPremiere: Boolean + get() = this == SERIES_PREMIERE || this == SEASON_PREMIERE + + val isFinale: Boolean + get() = this == SERIES_FINALE || this == SEASON_FINALE + + companion object { + fun fromValue(value: String): EpisodeType? = entries.find { it.value == value } + } +} diff --git a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt index cdde49ce5..4e199eae3 100644 --- a/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt +++ b/tv/src/main/java/tv/trakt/trakt/app/core/home/sections/shows/upnext/model/ProgressShow.kt @@ -30,7 +30,7 @@ internal data class ProgressShow( val stats: Stats?, val nextEpisode: Episode?, val lastEpisode: Episode?, - val isLatestAired: Boolean = false, + val isLatestAired: Boolean, ) { @Immutable internal data class Stats(