Better date, time, & bitrate formatting, other small UI improvements (#1139)

## Description

Use localized date & time formatting such as using 24 hour clock or
Day-Month-Year date formatting. The localization is based on the
device's locale/language setting. Some pages require reloading if you
switch the locale while Wholphin is running.

Format bitrates using SI 1000 magnitudes instead of 1024. File size
still uses 1024 but formats with "MiB"/"GiB" suffixes now. This aligns
with the server web UI and is more in line with standard practices for
display as well.

Fixes the playback overlay layout being messed up by extremely long
episode names.

Finally, adds jumping by letter using keyboard letter keys on grids if
letter jumping is enabled. I doubt many users have a full keyboard
attached to their TV, but this saves me time using the emulator when I
need to repeatedly open the same item for testing.

### Related issues
Fixes #1132

### Testing
Emulator

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-24 17:48:48 -04:00 committed by GitHub
parent f2570d4ab0
commit 8f9b9813b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 85 additions and 59 deletions

View file

@ -5,13 +5,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.DateFormatter
import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.abbreviateNumber
import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.music.artistsString import com.github.damontecres.wholphin.ui.detail.music.artistsString
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds 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.getDateFormatter
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.roundMinutes
@ -118,7 +118,7 @@ data class BaseItem(
buildList { buildList {
if (type == BaseItemKind.EPISODE) { if (type == BaseItemKind.EPISODE) {
data.seasonEpisode?.let(::add) data.seasonEpisode?.let(::add)
data.premiereDate?.let { add(DateFormatter.format(it)) } data.premiereDate?.let { add(getDateFormatter().format(it)) }
} else if (type == BaseItemKind.SERIES) { } else if (type == BaseItemKind.SERIES) {
data.seriesProductionYears?.let(::add) data.seriesProductionYears?.let(::add)
} else if (type == BaseItemKind.PHOTO) { } else if (type == BaseItemKind.PHOTO) {

View file

@ -16,8 +16,25 @@ import java.time.format.DateTimeParseException
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private var timeFormatter: DateTimeFormatter =
val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(Locale.getDefault())
fun getTimeFormatter(): DateTimeFormatter {
if (timeFormatter.locale != Locale.getDefault()) {
timeFormatter = timeFormatter.withLocale(Locale.getDefault())
}
return timeFormatter
}
private var dateFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault())
fun getDateFormatter(): DateTimeFormatter {
if (dateFormatter.locale != Locale.getDefault()) {
dateFormatter = dateFormatter.withLocale(Locale.getDefault())
}
return dateFormatter
}
// TODO server returns in UTC, but sdk converts to local time // TODO server returns in UTC, but sdk converts to local time
// eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020 // eg 2020-02-14T00:00:00.0000000Z => 2020-02-13T17:00:00 PT => Feb 13, 2020
@ -25,9 +42,9 @@ val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy"
/** /**
* Format a [LocalDateTime] as `Aug 24, 2000` * Format a [LocalDateTime] as `Aug 24, 2000`
*/ */
fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateTime) fun formatDateTime(dateTime: LocalDateTime): String = getDateFormatter().format(dateTime)
fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) fun formatDate(dateTime: LocalDate): String = getDateFormatter().format(dateTime)
fun toLocalDate(date: String?): LocalDate? = fun toLocalDate(date: String?): LocalDate? =
date?.let { date?.let {
@ -109,30 +126,25 @@ fun abbreviateNumber(number: Int): String {
return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit]) return String.format(Locale.getDefault(), "%.1f%s", count, abbrevSuffixes[unit])
} }
val byteSuffixes = listOf("B", "KB", "MB", "GB", "TB") val byteSuffixes = listOf("B", "KiB", "MiB", "GiB", "TiB")
val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps") val byteRateSuffixes = listOf("bps", "kbps", "mbps", "gbps", "tbps")
/**
* Format bytes
*/
fun formatBytes(
bytes: Int,
suffixes: List<String> = byteSuffixes,
) = formatBytes(bytes.toLong(), suffixes)
fun formatBytes( fun formatBytes(
bytes: Long, bytes: Long,
suffixes: List<String> = byteSuffixes, suffixes: List<String> = byteSuffixes,
divisor: Int = 1024,
): String { ): String {
var unit = 0 var unit = 0
var count = bytes.toDouble() var count = bytes.toDouble()
while (count >= 1024 && unit + 1 < suffixes.size) { while (count >= divisor && unit + 1 < suffixes.size) {
count /= 1024 count /= divisor
unit++ unit++
} }
return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit]) return String.format(Locale.getDefault(), "%.2f %s", count, suffixes[unit])
} }
fun formatBitrate(bitrate: Int) = formatBytes(bitrate.toLong(), byteRateSuffixes, 1000)
@get:StringRes @get:StringRes
val MediaSegmentType.stringRes: Int val MediaSegmentType.stringRes: Int
get() = get() =

View file

@ -21,8 +21,8 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.util.LocalClock import com.github.damontecres.wholphin.ui.util.LocalClock
import kotlin.time.Duration import kotlin.time.Duration
@ -107,7 +107,7 @@ fun TimeRemaining(
val now by LocalClock.current.now val now by LocalClock.current.now
val remainingStr = val remainingStr =
remember(remaining, now) { remember(remaining, now) {
val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds))
buildAnnotatedString { buildAnnotatedString {
dot() dot()
append(context.getString(R.string.ends_at, endTimeStr)) append(context.getString(R.string.ends_at, endTimeStr))

View file

@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.byteRateSuffixes
import com.github.damontecres.wholphin.ui.components.ScrollableDialog 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.formatBytes
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
@ -116,7 +116,7 @@ fun ItemDetailsDialog(
} }
source.bitrate?.let { source.bitrate?.let {
add( add(
bitrateLabel to formatBytes(it, byteRateSuffixes), bitrateLabel to formatBitrate(it),
) )
} }
source.runTimeTicks?.let { source.runTimeTicks?.let {
@ -297,7 +297,7 @@ private fun buildVideoStreamInfo(
val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!)
add(aspectRatioLabel to aspectRatio) add(aspectRatioLabel to aspectRatio)
} }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
stream.averageFrameRate?.let { stream.averageFrameRate?.let {
add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it))
} }
@ -405,7 +405,7 @@ private fun buildAudioStreamInfo(
stream.channelLayout?.let { add(layoutLabel to it) } stream.channelLayout?.let { add(layoutLabel to it) }
stream.channels?.let { add(channelsLabel to it.toString()) } stream.channels?.let { add(channelsLabel to it.toString()) }
stream.profile?.let { add(profileLabel to it) } stream.profile?.let { add(profileLabel to it) }
stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) }
stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") }
stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) }
} }

View file

@ -1,5 +1,6 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import android.view.KeyEvent
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -51,7 +52,6 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -199,6 +199,25 @@ fun <T : CardGridItem> CardGrid(
} }
} }
val jumpToLetter: (Char) -> Unit =
remember {
{ letter: Char ->
scope.launch(ExceptionHandler()) {
val jumpPosition =
withContext(Dispatchers.IO) {
letterPosition.invoke(letter)
}
Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) {
pager.getOrNull(jumpPosition)
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
}
}
}
}
if (pager.isEmpty()) { if (pager.isEmpty()) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@ -257,6 +276,12 @@ fun <T : CardGridItem> CardGrid(
} else if (useJumpRemoteButtons && isBackwardButton(it)) { } else if (useJumpRemoteButtons && isBackwardButton(it)) {
jump(-jump1) jump(-jump1)
return@onKeyEvent true return@onKeyEvent true
} else if (showLetterButtons && pager.isNotEmpty() &&
it.nativeKeyEvent.keyCode in (KeyEvent.KEYCODE_A..KeyEvent.KEYCODE_Z)
) {
val letter = it.nativeKeyEvent.unicodeChar.toChar()
jumpToLetter.invoke(letter)
return@onKeyEvent true
} else { } else {
return@onKeyEvent false return@onKeyEvent false
} }
@ -372,8 +397,7 @@ fun <T : CardGridItem> CardGrid(
} }
} }
} }
val context = LocalContext.current val letters = stringResource(R.string.jump_letters)
val letters = context.getString(R.string.jump_letters)
// Letters // Letters
val currentLetter = val currentLetter =
remember(focusedIndex) { remember(focusedIndex) {
@ -383,12 +407,10 @@ fun <T : CardGridItem> CardGrid(
?.firstOrNull() ?.firstOrNull()
?.uppercaseChar() ?.uppercaseChar()
?.let { ?.let {
if (it >= '0' && it <= '9') { when (it) {
'#' in '0'..'9' -> '#'
} else if (it >= 'A' && it <= 'Z') { in 'A'..'Z' -> it
it else -> null
} else {
null
} }
} }
?: letters[0] ?: letters[0]
@ -402,21 +424,7 @@ fun <T : CardGridItem> CardGrid(
.align(Alignment.CenterVertically) .align(Alignment.CenterVertically)
.padding(start = 16.dp), .padding(start = 16.dp),
// Add end padding to push away from edge // Add end padding to push away from edge
letterClicked = { letter -> letterClicked = jumpToLetter,
scope.launch(ExceptionHandler()) {
val jumpPosition =
withContext(Dispatchers.IO) {
letterPosition.invoke(letter)
}
Timber.d("Alphabet jump to $jumpPosition")
if (jumpPosition >= 0) {
pager.getOrNull(jumpPosition)
gridState.scrollToItem(jumpPosition)
focusOn(jumpPosition)
alphabetFocus = true
}
}
},
) )
} }
} }

View file

@ -70,7 +70,6 @@ import com.github.damontecres.wholphin.services.MusicService
import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.MusicServiceState
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ItemCardImage import com.github.damontecres.wholphin.ui.cards.ItemCardImage
import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
@ -95,6 +94,7 @@ import com.github.damontecres.wholphin.ui.detail.music.buildMoreDialogForMusic
import com.github.damontecres.wholphin.ui.enableMarquee import com.github.damontecres.wholphin.ui.enableMarquee
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
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
@ -810,7 +810,7 @@ fun PlaylistItem(
val endTimeStr = val endTimeStr =
remember(item, now) { remember(item, now) {
val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds)
TimeFormatter.format(endTime) getTimeFormatter().format(endTime)
} }
Column { Column {
Text( Text(

View file

@ -19,8 +19,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.ProvideTextStyle
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.PlayerBackend
import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.formatBitrate
import com.github.damontecres.wholphin.ui.formatBytes
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -104,7 +103,7 @@ fun TranscodeInfo(
"Reason:" to info.transcodeReasons.joinToString(", "), "Reason:" to info.transcodeReasons.joinToString(", "),
"HW Accel:" to info.hardwareAccelerationType?.toString(), "HW Accel:" to info.hardwareAccelerationType?.toString(),
"Container:" to info.container, "Container:" to info.container,
"Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) }, "Bitrate:" to info.bitrate?.let { formatBitrate(it) },
), ),
) )
SimpleTable( SimpleTable(

View file

@ -57,6 +57,7 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
@ -74,10 +75,10 @@ import com.github.damontecres.wholphin.data.model.aspectRatioFloat
import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.TimeFormatter
import com.github.damontecres.wholphin.ui.cards.ChapterCard import com.github.damontecres.wholphin.ui.cards.ChapterCard
import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.components.TimeDisplay
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -625,6 +626,9 @@ fun Controller(
text = it, text = it,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontSize = titleTextSize, fontSize = titleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
) )
} }
Row( Row(
@ -639,6 +643,9 @@ fun Controller(
text = it, text = it,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontSize = subtitleTextSize, fontSize = subtitleTextSize,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.fillMaxWidth(.75f),
) )
} }
@ -651,7 +658,7 @@ fun Controller(
.toLong() .toLong()
.milliseconds .milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = TimeFormatter.format(endTime) endTimeStr = getTimeFormatter().format(endTime)
delay(1.seconds) delay(1.seconds)
} }
} }

View file

@ -7,7 +7,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.github.damontecres.wholphin.ui.TimeFormatter import com.github.damontecres.wholphin.ui.getTimeFormatter
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
@ -25,9 +25,9 @@ data class Clock(
*/ */
val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()), val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()),
/** /**
* The current time formatted as a string with [TimeFormatter] * The current time formatted as a string with [getTimeFormatter]
*/ */
val timeString: MutableState<String> = mutableStateOf(TimeFormatter.format(now.value)), val timeString: MutableState<String> = mutableStateOf(getTimeFormatter().format(now.value)),
) )
@Composable @Composable
@ -37,7 +37,7 @@ fun ProvideLocalClock(content: @Composable () -> Unit) {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
while (isActive) { while (isActive) {
val now = LocalDateTime.now() val now = LocalDateTime.now()
val time = TimeFormatter.format(now) val time = getTimeFormatter().format(now)
clock.now.value = now clock.now.value = now
clock.timeString.value = time clock.timeString.value = time
delay(2_000) delay(2_000)