mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-11-07 03:04:50 +01:00
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:
parent
826bbad49c
commit
1f3a861c47
7 changed files with 85 additions and 30 deletions
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue