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.dot
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.formatDuration 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.getDateFormatter
import com.github.damontecres.wholphin.ui.joinNotBlank import com.github.damontecres.wholphin.ui.joinNotBlank
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -111,7 +112,7 @@ data class BaseItem(
val ui = val ui =
BaseItemUi( BaseItemUi(
episodeCornerText = episodeCornerText =
data.indexNumber?.let { "E$it" } data.indexNumber?.let { formatEpisodeNumber(it) }
?: data.premiereDate?.let(::formatDateTime), ?: data.premiereDate?.let(::formatDateTime),
episodeUnplayedCornerText = episodeUnplayedCornerText =
if (type == BaseItemKind.SERIES || if (type == BaseItemKind.SERIES ||
@ -119,7 +120,7 @@ data class BaseItem(
type == BaseItemKind.EPISODE || type == BaseItemKind.EPISODE ||
type == BaseItemKind.BOX_SET type == BaseItemKind.BOX_SET
) { ) {
data.indexNumber?.let { "E$it" } data.indexNumber?.let { formatEpisodeNumber(it) }
?: data.userData ?: data.userData
?.unplayedItemCount ?.unplayedItemCount
?.takeIf { it > 0 } ?.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? val BaseItemDto.seasonEpisode: String?
get() = get() =
if (parentIndexNumber != null && indexNumber != null && indexNumberEnd != null) { formatSeasonEpisode(
"S$parentIndexNumber E$indexNumber-E$indexNumberEnd" parentIndexNumber,
} else if (parentIndexNumber != null && indexNumber != null) { indexNumber,
"S$parentIndexNumber E$indexNumber" indexNumberEnd,
} else { )
null
}
/** /**
* 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? val BaseItemDto.seasonEpisodePadded: String?
get() = get() =
if (parentIndexNumber != null && indexNumber != null) { formatSeasonEpisode(
val season = parentIndexNumber?.toString()?.padStart(2, '0') parentIndexNumber,
val episode = indexNumber?.toString()?.padStart(2, '0') indexNumber,
val endEpisode = indexNumberEnd?.toString()?.padStart(2, '0') indexNumberEnd,
if (endEpisode != null) { padded = true,
"S${season}E$episode-E$endEpisode" )
} else {
"S${season}E$episode"
}
} else {
null
}
val BaseItemDto.seriesProductionYears: String? val BaseItemDto.seriesProductionYears: String?
get() = get() =
@ -274,6 +312,7 @@ fun Resources.formatDuration(duration: Duration): String =
when { when {
hours > 0 && minutes > 0 && seconds > 0 -> getString(R.string.duration_hours_minutes_seconds, hours.toInt(), minutes, seconds) 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 && 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()) hours > 0 -> getString(R.string.duration_hours, hours.toInt())
minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds) minutes > 0 && seconds > 0 -> getString(R.string.duration_minutes_seconds, minutes, seconds)
minutes > 0 -> getString(R.string.duration_minutes, minutes) 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.BasicDialog
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
@ -277,7 +278,8 @@ fun SeasonListItem(
Text( Text(
text = text =
season.season.name season.season.name
?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), ?: season.season.seasonNumber?.let { formatSeasonNumber(it) }
?: "",
) )
}, },
supportingContent = { supportingContent = {

View file

@ -29,6 +29,7 @@ import androidx.tv.material3.surfaceColorAtElevation
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.formatSeasonEpisode
import java.time.LocalDateTime import java.time.LocalDateTime
@Composable @Composable
@ -109,10 +110,15 @@ fun Program(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier, modifier = Modifier,
) )
val seasonEpisodeLabel =
formatSeasonEpisode(
program.seasonEpisode?.season,
program.seasonEpisode?.episode,
)
val subtitle = val subtitle =
remember(program) { remember(program, seasonEpisodeLabel) {
listOfNotNull( listOfNotNull(
program.seasonEpisode?.let { "S${it.season} E${it.episode}" }, seasonEpisodeLabel,
program.subtitle, program.subtitle,
).joinToString(" - ").ifBlank { null } ).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.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatDuration 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -636,7 +637,7 @@ data class TvProgram(
.roundMinutes .roundMinutes
.let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) } .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() dot()
append(it) append(it)
} }

View file

@ -42,7 +42,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams 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.TabDetails
import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.TabRow
import com.github.damontecres.wholphin.ui.components.TitleOrLogo 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.ifElse
import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.logTab
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
@ -121,7 +121,6 @@ fun SeriesOverviewContent(
val scrollConnection = rememberDelayedNestedScroll() val scrollConnection = rememberDelayedNestedScroll()
var requestFocusAfterSeason by remember { mutableStateOf(false) } var requestFocusAfterSeason by remember { mutableStateOf(false) }
val seasonStr = stringResource(R.string.tv_season)
val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val tabFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } val contentFocusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } }
val tabs = val tabs =
@ -130,7 +129,7 @@ fun SeriesOverviewContent(
title = title =
StringStringProvider( StringStringProvider(
season?.name season?.name
?: season?.data?.indexNumber?.let { "$seasonStr $it" } ?: season?.data?.indexNumber?.let { formatSeasonNumber(it) }
?: "", ?: "",
), ),
tabFocusRequester = tabFocusRequesters[index], tabFocusRequester = tabFocusRequesters[index],

View file

@ -28,6 +28,7 @@
<string name="decimal_seconds">%.2f seconds</string> <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_seconds">%1$dh %2$dm %3$ds</string>
<string name="duration_hours_minutes">%1$dh %2$dm</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_minutes_seconds">%1$dm %2$ds</string>
<string name="duration_hours">%1$dh</string> <string name="duration_hours">%1$dh</string>
<string name="duration_minutes">%1$dm</string> <string name="duration_minutes">%1$dm</string>
@ -158,6 +159,12 @@
<string name="tv_guide">Guide</string> <string name="tv_guide">Guide</string>
<string name="tv_season">Season</string> <string name="tv_season">Season</string>
<string name="tv_seasons">Seasons</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="tv_shows_title">TV Shows</string>
<string name="ui_interface">Interface</string> <string name="ui_interface">Interface</string>
<string name="unknown">Unknown</string> <string name="unknown">Unknown</string>