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 <damontecres@gmail.com>
This commit is contained in:
Tal Sarid 2026-09-04 19:47:06 +03:00 • committed by GitHub
parent 826bbad49c
commit 1f3a861c47
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 85 additions and 30 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -28,6 +28,7 @@
<string name="decimal_seconds">%.2f seconds</string>
<string name="duration_hours_minutes_seconds">%1$dh %2$dm %3$ds</string>
<string name="duration_hours_minutes">%1$dh %2$dm</string>
<string name="duration_hours_seconds">%1$dh %2$ds</string>
<string name="duration_minutes_seconds">%1$dm %2$ds</string>
<string name="duration_hours">%1$dh</string>
<string name="duration_minutes">%1$dm</string>
@ -158,6 +159,12 @@
<string name="tv_guide">Guide</string>
<string name="tv_season">Season</string>
<string name="tv_seasons">Seasons</string>
<string name="season_episode_number">S%1$d E%2$d</string>
<string name="season_episode_number_range">S%1$d E%2$d-E%3$d</string>
<string name="season_episode_number_padded">S%1$02dE%2$02d</string>
<string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string>
<string name="episode_number_short">E%1$d</string>
<string name="season_number">Season %1$d</string>
<string name="tv_shows_title">TV Shows</string>
<string name="ui_interface">Interface</string>
<string name="unknown">Unknown</string>