From 8f9b9813b7bd2145200f65ebe4b2710e77481db5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:48:48 -0400 Subject: [PATCH] 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 --- .../wholphin/data/model/BaseItem.kt | 4 +- .../damontecres/wholphin/ui/Formatting.kt | 44 +++++++++------ .../wholphin/ui/components/QuickDetails.kt | 4 +- .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 8 +-- .../wholphin/ui/detail/CardGrid.kt | 56 +++++++++++-------- .../wholphin/ui/detail/PlaylistDetails.kt | 4 +- .../ui/playback/PlaybackDebugOverlay.kt | 5 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 11 +++- .../wholphin/ui/util/LocalClock.kt | 8 +-- 9 files changed, 85 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 7ab11e4b..41c1fd45 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -5,13 +5,13 @@ 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.ui.DateFormatter 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.getDateFormatter import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.roundMinutes @@ -118,7 +118,7 @@ data class BaseItem( buildList { if (type == BaseItemKind.EPISODE) { data.seasonEpisode?.let(::add) - data.premiereDate?.let { add(DateFormatter.format(it)) } + data.premiereDate?.let { add(getDateFormatter().format(it)) } } else if (type == BaseItemKind.SERIES) { data.seriesProductionYears?.let(::add) } else if (type == BaseItemKind.PHOTO) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 8f4066bb..0a9b9a2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -16,8 +16,25 @@ import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Locale -val TimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) -val DateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("MMM d, yyyy") +private var timeFormatter: DateTimeFormatter = + 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 // 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` */ -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? = date?.let { @@ -109,30 +126,25 @@ fun abbreviateNumber(number: Int): String { 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") -/** - * Format bytes - */ -fun formatBytes( - bytes: Int, - suffixes: List = byteSuffixes, -) = formatBytes(bytes.toLong(), suffixes) - fun formatBytes( bytes: Long, suffixes: List = byteSuffixes, + divisor: Int = 1024, ): String { var unit = 0 var count = bytes.toDouble() - while (count >= 1024 && unit + 1 < suffixes.size) { - count /= 1024 + while (count >= divisor && unit + 1 < suffixes.size) { + count /= divisor 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 val MediaSegmentType.stringRes: Int get() = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index cf5b0697..d4931d77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -21,8 +21,8 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text 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.getTimeFormatter import com.github.damontecres.wholphin.ui.util.LocalClock import kotlin.time.Duration @@ -107,7 +107,7 @@ fun TimeRemaining( val now by LocalClock.current.now val remainingStr = remember(remaining, now) { - val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) + val endTimeStr = getTimeFormatter().format(now.plusSeconds(remaining.inWholeSeconds)) buildAnnotatedString { dot() append(context.getString(R.string.ends_at, endTimeStr)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index fa32c595..072de4e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text 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.formatBitrate import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -116,7 +116,7 @@ fun ItemDetailsDialog( } source.bitrate?.let { add( - bitrateLabel to formatBytes(it, byteRateSuffixes), + bitrateLabel to formatBitrate(it), ) } source.runTimeTicks?.let { @@ -297,7 +297,7 @@ private fun buildVideoStreamInfo( val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) add(aspectRatioLabel to aspectRatio) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } + stream.bitRate?.let { add(bitrateLabel to formatBitrate(it)) } stream.averageFrameRate?.let { add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) } @@ -405,7 +405,7 @@ private fun buildAudioStreamInfo( stream.channelLayout?.let { add(layoutLabel to it) } stream.channels?.let { add(channelsLabel to it.toString()) } 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.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index ac72a144..a2b9e923 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.detail +import android.view.KeyEvent import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi 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.type import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -199,6 +199,25 @@ fun 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()) { Box( contentAlignment = Alignment.Center, @@ -257,6 +276,12 @@ fun CardGrid( } else if (useJumpRemoteButtons && isBackwardButton(it)) { jump(-jump1) 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 { return@onKeyEvent false } @@ -372,8 +397,7 @@ fun CardGrid( } } } - val context = LocalContext.current - val letters = context.getString(R.string.jump_letters) + val letters = stringResource(R.string.jump_letters) // Letters val currentLetter = remember(focusedIndex) { @@ -383,12 +407,10 @@ fun CardGrid( ?.firstOrNull() ?.uppercaseChar() ?.let { - if (it >= '0' && it <= '9') { - '#' - } else if (it >= 'A' && it <= 'Z') { - it - } else { - null + when (it) { + in '0'..'9' -> '#' + in 'A'..'Z' -> it + else -> null } } ?: letters[0] @@ -402,21 +424,7 @@ fun CardGrid( .align(Alignment.CenterVertically) .padding(start = 16.dp), // Add end padding to push away from edge - letterClicked = { letter -> - 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 - } - } - }, + letterClicked = jumpToLetter, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 9c237d8c..bd50ce59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -70,7 +70,6 @@ import com.github.damontecres.wholphin.services.MusicService import com.github.damontecres.wholphin.services.MusicServiceState import com.github.damontecres.wholphin.services.NavigationManager 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.components.BasicDialog 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.equalsNotNull 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.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -810,7 +810,7 @@ fun PlaylistItem( val endTimeStr = remember(item, now) { val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) - TimeFormatter.format(endTime) + getTimeFormatter().format(endTime) } Column { Text( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index ce31d865..e756a9df 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -19,8 +19,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import com.github.damontecres.wholphin.preferences.PlayerBackend -import com.github.damontecres.wholphin.ui.byteRateSuffixes -import com.github.damontecres.wholphin.ui.formatBytes +import com.github.damontecres.wholphin.ui.formatBitrate import com.github.damontecres.wholphin.ui.letNotEmpty import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -104,7 +103,7 @@ fun TranscodeInfo( "Reason:" to info.transcodeReasons.joinToString(", "), "HW Accel:" to info.hardwareAccelerationType?.toString(), "Container:" to info.container, - "Bitrate:" to info.bitrate?.let { formatBytes(it, byteRateSuffixes) }, + "Bitrate:" to info.bitrate?.let { formatBitrate(it) }, ), ) SimpleTable( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 2c536d9f..3bc4ddb0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset 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.AspectRatios 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.SeasonCard 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.isNotNullOrBlank import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -625,6 +626,9 @@ fun Controller( text = it, style = MaterialTheme.typography.titleLarge, fontSize = titleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), ) } Row( @@ -639,6 +643,9 @@ fun Controller( text = it, style = MaterialTheme.typography.titleMedium, fontSize = subtitleTextSize, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = Modifier.fillMaxWidth(.75f), ) } @@ -651,7 +658,7 @@ fun Controller( .toLong() .milliseconds val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = TimeFormatter.format(endTime) + endTimeStr = getTimeFormatter().format(endTime) delay(1.seconds) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index bca5ae64..213dc725 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -7,7 +7,7 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.mutableStateOf 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.delay import kotlinx.coroutines.isActive @@ -25,9 +25,9 @@ data class Clock( */ val now: MutableState = mutableStateOf(LocalDateTime.now()), /** - * The current time formatted as a string with [TimeFormatter] + * The current time formatted as a string with [getTimeFormatter] */ - val timeString: MutableState = mutableStateOf(TimeFormatter.format(now.value)), + val timeString: MutableState = mutableStateOf(getTimeFormatter().format(now.value)), ) @Composable @@ -37,7 +37,7 @@ fun ProvideLocalClock(content: @Composable () -> Unit) { withContext(Dispatchers.Default) { while (isActive) { val now = LocalDateTime.now() - val time = TimeFormatter.format(now) + val time = getTimeFormatter().format(now) clock.now.value = now clock.timeString.value = time delay(2_000)