From 1f3a861c47047be035f042588e01f5aeca8d0771 Mon Sep 17 00:00:00 2001 From: Tal Sarid <85511949+tal-sarid@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:47:06 +0300 Subject: [PATCH] Localized formatting for season and episode display (#1884) ## Description Added localized formatting for season/episode numbers across the UI because it was in English-only in every UI language. ### Related issues None ### Testing Tested on a physical Google TV Streamer 4K device. Checked manually relevant screens while browsing and during playback. ## Screenshots No frontend changes are visible in English and new translations will be contributed through Weblate for other languages later, as usual. ## AI or LLM usage Generated initial code with AI, then manually passed over it while cleaning and refactoring where needed. --------- Co-authored-by: Damontecres --- .../wholphin/data/model/BaseItem.kt | 5 +- .../damontecres/wholphin/ui/Formatting.kt | 81 ++++++++++++++----- .../ui/detail/discover/RequestSeasons.kt | 4 +- .../wholphin/ui/detail/livetv/Components.kt | 10 ++- .../ui/detail/livetv/LiveTvViewModel.kt | 3 +- .../ui/detail/series/SeriesOverviewContent.kt | 5 +- app/src/main/res/values/strings.xml | 7 ++ 7 files changed, 85 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 50df6071..dd1236a4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatEpisodeNumber import com.github.damontecres.wholphin.ui.getDateFormatter import com.github.damontecres.wholphin.ui.joinNotBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -111,7 +112,7 @@ data class BaseItem( val ui = BaseItemUi( episodeCornerText = - data.indexNumber?.let { "E$it" } + data.indexNumber?.let { formatEpisodeNumber(it) } ?: data.premiereDate?.let(::formatDateTime), episodeUnplayedCornerText = if (type == BaseItemKind.SERIES || @@ -119,7 +120,7 @@ data class BaseItem( type == BaseItemKind.EPISODE || type == BaseItemKind.BOX_SET ) { - data.indexNumber?.let { "E$it" } + data.indexNumber?.let { formatEpisodeNumber(it) } ?: data.userData ?.unplayedItemCount ?.takeIf { it > 0 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 4b2f0b8b..282c161e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -79,36 +79,74 @@ fun toLocalDate(date: String?): LocalDate? = } } +// Falls back to the default format if the application is not initialized (e.g. unit tests) +private fun formatStringWithFallback( + @StringRes resId: Int, + defaultFormat: String, + vararg args: Any, +): String { + val resources = + try { + WholphinApplication.instance.resources + } catch (_: UninitializedPropertyAccessException) { + null + } + return resources?.getString(resId, *args) + ?: String.format(Locale.getDefault(), defaultFormat, *args) +} + /** - * If the item has season & episode info, format as `S# E#` + * Format season & episode numbers using localized string resources. + * Returns null if [season] or [episode] is missing. + */ +fun formatSeasonEpisode( + season: Int?, + episode: Int?, + episodeEnd: Int? = null, + padded: Boolean = false, +): String? { + if (season == null || episode == null) return null + return if (episodeEnd != null) { + if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded_range, "S%1\$02dE%2\$02d-E%3\$02d", season, episode, episodeEnd) + } else { + formatStringWithFallback(R.string.season_episode_number_range, "S%1\$d E%2\$d-E%3\$d", season, episode, episodeEnd) + } + } else if (padded) { + formatStringWithFallback(R.string.season_episode_number_padded, "S%1\$02dE%2\$02d", season, episode) + } else { + formatStringWithFallback(R.string.season_episode_number, "S%1\$d E%2\$d", season, episode) + } +} + +/** Compact episode-only label, e.g. `E3`. */ +fun formatEpisodeNumber(episode: Int): String = formatStringWithFallback(R.string.episode_number_short, "E%1\$d", episode) + +/** Fallback season title when the library has no season name, e.g. `Season 2`. */ +fun formatSeasonNumber(season: Int): String = formatStringWithFallback(R.string.season_number, "Season %1\$d", season) + +/** + * If the item has season & episode info, format as localized `S# E#` */ val BaseItemDto.seasonEpisode: String? get() = - if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) { - "S$parentIndexNumber E$indexNumber-E$indexNumberEnd" - } else if (parentIndexNumber != null && indexNumber != null) { - "S$parentIndexNumber E$indexNumber" - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + ) /** - * If the item has season & episode info, format padded as `S## E##` + * If the item has season & episode info, format padded as localized `S##E##` */ val BaseItemDto.seasonEpisodePadded: String? get() = - if (parentIndexNumber != null && indexNumber != null) { - val season = parentIndexNumber?.toString()?.padStart(2, '0') - val episode = indexNumber?.toString()?.padStart(2, '0') - val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0') - if (endEpisode != null) { - "S${season}E$episode-E$endEpisode" - } else { - "S${season}E$episode" - } - } else { - null - } + formatSeasonEpisode( + parentIndexNumber, + indexNumber, + indexNumberEnd, + padded = true, + ) val BaseItemDto.seriesProductionYears: String? get() = @@ -274,6 +312,7 @@ fun Resources.formatDuration(duration: Duration): String = when { hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds) hours > 0 && minutes > 0 -> getString(R.string.duration_hours_minutes, hours.toInt(), minutes) + hours > 0 && seconds > 0 -> getString(R.string.duration_hours_seconds, hours.toInt(), seconds) hours > 0 -> getString(R.string.duration_hours, hours.toInt()) minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds) minutes > 0 -> getString(R.string.duration_minutes, minutes) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt index c482484e..bc11825f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.PendingIndicator import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState @@ -277,7 +278,8 @@ fun SeasonListItem( Text( text = season.season.name - ?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), + ?: season.season.seasonNumber?.let { formatSeasonNumber(it) } + ?: "", ) }, supportingContent = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt index 41d53fd5..e5bf4e8f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/Components.kt @@ -29,6 +29,7 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.formatSeasonEpisode import java.time.LocalDateTime @Composable @@ -109,10 +110,15 @@ fun Program( overflow = TextOverflow.Ellipsis, modifier = Modifier, ) + val seasonEpisodeLabel = + formatSeasonEpisode( + program.seasonEpisode?.season, + program.seasonEpisode?.episode, + ) val subtitle = - remember(program) { + remember(program, seasonEpisodeLabel) { listOfNotNull( - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, + seasonEpisodeLabel, program.subtitle, ).joinToString(" - ").ifBlank { null } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 987aeebb..470f33c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDuration +import com.github.damontecres.wholphin.ui.formatSeasonEpisode import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -636,7 +637,7 @@ data class TvProgram( .roundMinutes .let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) } } - seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { + formatSeasonEpisode(seasonEpisode?.season, seasonEpisode?.episode)?.let { dot() append(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index a3ca1358..5fee7b24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams @@ -60,6 +59,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TabDetails import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TitleOrLogo +import com.github.damontecres.wholphin.ui.formatSeasonNumber import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp @@ -121,7 +121,6 @@ fun SeriesOverviewContent( val scrollConnection = rememberDelayedNestedScroll() var requestFocusAfterSeason by remember { mutableStateOf(false) } - val seasonStr = stringResource(R.string.tv_season) val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val tabs = @@ -130,7 +129,7 @@ fun SeriesOverviewContent( title = StringStringProvider( season?.name - ?: season?.data?.indexNumber?.let { "$seasonStr $it" } + ?: season?.data?.indexNumber?.let { formatSeasonNumber(it) } ?: "", ), tabFocusRequester = tabFocusRequesters[index], diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 549614c7..e8ae0187 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ %.2f seconds %1$dh %2$dm %3$ds %1$dh %2$dm + %1$dh %2$ds %1$dm %2$ds %1$dh %1$dm @@ -158,6 +159,12 @@ Guide Season Seasons + S%1$d E%2$d + S%1$d E%2$d-E%3$d + S%1$02dE%2$02d + S%1$02dE%2$02d-E%3$02d + E%1$d + Season %1$d TV Shows Interface Unknown