Add localized duration formatting and related string resources (#1571)

## Description
Instead of using English-only duration format (e.g. `HHh MMm SSs`) in
various places, I've added a format function that is based on several
new localized resources keys.

That includes:
1. Media runtime in Item view
2. Time remaining in Item view
3. End time in Player view
4. Time passed and time remained in Player view

### Related issues
None

### Testing
I installed the app on my Google TV Streamer, switched to my language,
browsed all item views that I could think of to see that the duration
labels are all localized in my language. I didn't commit those strings,
I will do it through the Weblate after it will get merged. I also played
a few items and saw that the labels there are localized as well.

## Screenshots
It looks the same in English as before, currently no other language has
custom resources for it yet.

## AI or LLM usage
Used Claude Sonnet to detect places that I missed that are related to
this localization change. Also it helped me to get access to my format
function from various places in the code.

---------

Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
Tal Sarid 2026-07-06 15:43:06 +03:00 • committed by GitHub
parent 1d7ddd713b
commit f6e00c7820
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 86 additions and 25 deletions

View file

@ -5,12 +5,15 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.music.artistsString
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.getDateFormatter
import com.github.damontecres.wholphin.ui.joinNotBlank
import com.github.damontecres.wholphin.ui.nav.Destination
@ -161,10 +164,18 @@ data class BaseItem(
?.ticks
?.roundMinutes
?.takeIf { it > Duration.ZERO }
?.let { add(it.toString()) }
?.let { add(WholphinApplication.instance.resources.formatDuration(it)) }
data.timeRemaining
?.roundMinutes
?.let { add("$it left") }
?.let {
val resources = WholphinApplication.instance.resources
add(
resources.getString(
R.string.time_left,
resources.formatDuration(it),
),
)
}
}
details.forEachIndexed { index, string ->
append(string)

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui
import android.content.Context
import android.content.res.Resources
import android.text.format.DateFormat
import androidx.annotation.StringRes
import androidx.compose.foundation.text.appendInlineContent
@ -21,6 +22,7 @@ import java.time.format.DateTimeParseException
import java.time.format.FormatStyle
import java.util.Date
import java.util.Locale
import kotlin.time.Duration
/**
* Format the time-of-day of [time] honouring the device's 12/24-hour setting and the current
@ -259,3 +261,18 @@ fun formatTypeName(type: BaseItemKind): Int =
BaseItemKind.LIVE_TV_PROGRAM -> TODO()
BaseItemKind.PLAYLISTS_FOLDER -> TODO()
}
/**
* Format a [Duration] into a localized "Xh Ym Zs" string using string resources.
*/
fun Resources.formatDuration(duration: Duration): String =
duration.toComponents { hours, minutes, seconds, _ ->
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 -> 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)
else -> getString(R.string.duration_seconds, seconds)
}
}

View file

@ -9,15 +9,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.roundSeconds
import kotlin.time.Duration
@ -62,8 +65,10 @@ fun ChapterCard(
text = it,
)
}
val resources = LocalResources.current
val positionText = remember(position) { resources.formatDuration(position.roundSeconds) }
Text(
text = position.roundSeconds.toString(),
text = positionText,
)
}
}

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.ListItemDefaults
@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.cards.WatchedIcon
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.AlphabetButtons
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.rememberInt
@ -357,7 +359,7 @@ fun ListItemGeneric(
?.runTimeTicks
?.ticks
?.roundMinutes
?.toString() ?: "",
?.let { LocalResources.current.formatDuration(it) } ?: "",
)
},
scale = scale,
@ -419,7 +421,7 @@ fun ListItemMovie(
?.runTimeTicks
?.ticks
?.roundMinutes
?.toString() ?: "",
?.let { LocalResources.current.formatDuration(it) } ?: "",
)
if (item?.played == true) {
WatchedIcon(padding = 0.dp)
@ -546,7 +548,7 @@ fun ListItemAlbum(
.runTimeTicks
?.ticks
?.roundMinutes
?.toString() ?: "",
?.let { LocalResources.current.formatDuration(it) } ?: "",
)
}
} else {

View file

@ -25,6 +25,7 @@ import com.github.damontecres.wholphin.ui.components.ScrollableDialog
import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec
@ -153,7 +154,7 @@ fun ItemDetailsDialog(
)
}
source.runTimeTicks?.let {
add(runtimeLabel to it.ticks.toString())
add(runtimeLabel to resources.formatDuration(it.ticks))
}
},
)

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -94,6 +95,7 @@ import com.github.damontecres.wholphin.ui.detail.music.MusicViewModel
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.formatTime
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.launchDefault
@ -809,9 +811,11 @@ fun PlaylistItem(
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
formatTime(context, endTime)
}
val resources = LocalResources.current
val durationText = remember(duration) { resources.formatDuration(duration) }
Column {
Text(
text = duration.toString(),
text = durationText,
)
if (item.type != BaseItemKind.AUDIO) {
Text(

View file

@ -11,6 +11,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
@ -25,6 +26,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.listToDotString
@ -64,16 +66,16 @@ fun DiscoverMovieDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 4.dp
val resources = LocalResources.current
val details =
remember(movie, rating) {
remember(movie, rating, resources) {
buildList {
movie.releaseDate?.let(::add)
movie.runtime
?.toDouble()
?.minutes
?.roundMinutes
?.toString()
?.let(::add)
?.let { add(resources.formatDuration(it)) }
val release =
movie.releases
?.results

View file

@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.nav.Destination
@ -446,8 +447,9 @@ fun DiscoverSeriesDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 4.dp
val resources = LocalResources.current
val details =
remember(series, rating) {
remember(series, rating, resources) {
buildList {
series.firstAirDate?.let(::add)
series.episodeRunTime
@ -455,8 +457,7 @@ fun DiscoverSeriesDetailsHeader(
?.takeIf { !it.isNaN() && it > 0 }
?.minutes
?.roundMinutes
?.toString()
?.let(::add)
?.let { add(resources.formatDuration(it)) }
// TODO
}.let {
listToDotString(

View file

@ -20,6 +20,7 @@ import com.github.damontecres.wholphin.ui.AppColors
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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -643,18 +644,16 @@ data class TvProgram(
append(time)
if (!isFake) {
val resources = WholphinApplication.instance.resources
dot()
duration
.roundMinutes
.toString()
.let(::append)
append(resources.formatDuration(duration.roundMinutes))
if (now.isAfter(start) && now.isBefore(end)) {
dot()
java.time.Duration
.between(now, end)
.toKotlinDuration()
.roundMinutes
.let { append("$it left") }
.let { append(resources.getString(R.string.time_left, resources.formatDuration(it))) }
}
seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let {
dot()

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.tv.material3.DenseListItem
@ -32,6 +33,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.roundSeconds
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@ -115,7 +117,7 @@ fun SongListItem(
}
val trailingContent = @Composable {
Text(
text = runtime.toString(),
text = runtime?.let { LocalResources.current.formatDuration(it) } ?: "",
)
}

View file

@ -215,7 +215,7 @@ fun Controller(
}
}
Text(
text = "Ends $endTimeStr",
text = stringResource(R.string.ends_at, endTimeStr),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =

View file

@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -70,6 +71,7 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Button
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.components.TextButton
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
import com.github.damontecres.wholphin.ui.playback.PlaybackDialogType
@ -274,9 +276,13 @@ fun SeekBar(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
val remaining = ((player.duration - position) / 1000).seconds
val resources = LocalResources.current
val positionSec = position / 1000
val remainingSec = (player.duration - position) / 1000
val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) }
val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" }
Text(
text = (position / 1000).seconds.toString(),
text = positionText,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =
@ -284,7 +290,7 @@ fun SeekBar(
.padding(8.dp),
)
Text(
text = "-$remaining",
text = remainingText,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =

View file

@ -41,6 +41,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
@ -56,6 +57,7 @@ import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.AnalyticsState
import com.github.damontecres.wholphin.ui.playback.ControllerViewState
@ -304,8 +306,10 @@ fun PlaybackOverlay(
)
}
}
val resources = LocalResources.current
val seekText = remember(seekProgressMs / 1000L) { resources.formatDuration((seekProgressMs / 1000L).seconds) }
Text(
text = (seekProgressMs / 1000L).seconds.toString(),
text = seekText,
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
modifier =

View file

@ -26,6 +26,12 @@
<string name="continue_watching">Continue watching</string>
<string name="critic_rating">Critic Rating</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">%1$dh %2$dm</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>
<string name="duration_seconds">%1$ds</string>
<string name="default_track">Default</string>
<string name="delete">Delete</string>
<string name="died">Died</string>
@ -144,6 +150,7 @@
<string name="suggestions">Suggestions</string>
<string name="switch_servers">Switch servers</string>
<string name="switch_user">Switch</string>
<string name="time_left">%1$s left</string>
<string name="top_unwatched">Top Rated Unwatched</string>
<string name="trailers_title">Trailers</string>
<string name="tv_dvr_schedule">DVR Schedule</string>