From c9d12440571932d89d376a6d7c894cb9fb0c61f8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:07:17 -0500 Subject: [PATCH] Optimize quick details UI (#674) ## Description This PR optimizes the rendering of "quick details" in headers. That's the dot separated row of Season/Episode/Date/Runtime, etc The UI is slightly different because I moved the "Ends at" part to the end of the row. The dots & spacing is also slightly different. In testing this on my CCwGTV HD, a pretty low powered device, the PR improved the time to render by over 3x (59.42ms->17.72ms). Note: these times are during profiling and are not real world performance, but the relative change should be applicable in the real world. ### Dev details - Changes the dot separated row of multiple Text & Box composables into a row of two Texts - The text is an `AnnotatedString` which is created on the IO thread when `BaseItem` is created - A separate composable creates the "Ends at" to prevent recompositions of the other strings --- .../wholphin/data/model/BaseItem.kt | 69 ++++++++++ .../damontecres/wholphin/ui/Formatting.kt | 31 +++++ .../wholphin/ui/components/DotSeparatedRow.kt | 123 ------------------ .../wholphin/ui/components/MovieComponents.kt | 60 --------- .../wholphin/ui/components/QuickDetails.kt | 122 +++++++++++++++++ .../ui/components/SeriesComponents.kt | 58 --------- .../discover/DiscoverMovieDetailsHeader.kt | 17 +-- .../detail/discover/DiscoverQuickDetails.kt | 38 ------ .../detail/discover/DiscoverSeriesDetails.kt | 17 +-- .../ui/detail/episode/EpisodeDetailsHeader.kt | 4 +- .../ui/detail/livetv/LiveTvViewModel.kt | 51 ++++++++ .../ui/detail/livetv/TvGuideHeader.kt | 57 +------- .../ui/detail/movie/MovieDetailsHeader.kt | 8 +- .../ui/detail/series/FocusedEpisodeHeader.kt | 6 +- .../ui/detail/series/SeriesDetails.kt | 4 +- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 27 +++- .../damontecres/wholphin/ui/main/HomePage.kt | 20 ++- .../wholphin/ui/util/LocalClock.kt | 13 +- 18 files changed, 339 insertions(+), 386 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt 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 b5ed3259..d1893dbb 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 @@ -1,20 +1,29 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.foundation.text.appendInlineContent +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.detail.CardGridItem 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.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears +import com.github.damontecres.wholphin.ui.timeRemaining import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks +import java.util.Locale import kotlin.time.Duration @Serializable @@ -73,6 +82,61 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false + @Transient + val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks + + @Transient + val ui = + BaseItemUi( + quickDetails = + buildAnnotatedString { + val details = + buildList { + if (type == BaseItemKind.EPISODE) { + data.seasonEpisode?.let(::add) + data.premiereDate?.let { add(DateFormatter.format(it)) } + } else if (type == BaseItemKind.SERIES) { + data.seriesProductionYears?.let(::add) + } else { + data.productionYear?.let { add(it.toString()) } + } + data.runTimeTicks + ?.ticks + ?.roundMinutes + ?.let { add(it.toString()) } + data.timeRemaining + ?.roundMinutes + ?.let { add("$it left") } + } + details.forEachIndexed { index, string -> + append(string) + if (index != details.lastIndex) { + dot() + } + } + // TODO time remaining + + data.officialRating?.let { + dot() + append(it) + } + data.communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + data.criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") + } + } + }, + ) + private fun dateAsIndex(): Int? = data.premiereDate ?.let { @@ -124,3 +188,8 @@ data class BaseItem( } val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } } + +@Immutable +data class BaseItemUi( + val quickDetails: AnnotatedString, +) 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 2461b642..e4f107b4 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 @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin.ui import androidx.annotation.StringRes +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType @@ -152,3 +155,31 @@ val MediaSegmentType.skipStringRes: Int MediaSegmentType.OUTRO -> R.string.skip_segment_outro MediaSegmentType.INTRO -> R.string.skip_segment_intro } + +fun AnnotatedString.Builder.dot() = append(" \u2022 ") + +fun listToDotString( + strings: List, + communityRating: Float?, + criticRating: Float?, +): AnnotatedString = + buildAnnotatedString { + strings.forEachIndexed { index, string -> + append(string) + if (index != strings.lastIndex) dot() + communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt deleted file mode 100644 index 2c9b1d78..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.tv.material3.LocalTextStyle -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.theme.WholphinTheme - -@Composable -fun DotSeparatedRow( - texts: List, - communityRating: Float? = null, - criticRating: Float? = null, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, -) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - texts.forEachIndexed { index, text -> - Text( - text = text, - style = textStyle, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - ) - if (communityRating != null || criticRating != null || index != texts.lastIndex) { - Dot() - } - } - val height = with(LocalDensity.current) { textStyle.fontSize.toDp() } - communityRating?.let { - SimpleStarRating( - communityRating = it, - modifier = Modifier.height(height), - ) - if (criticRating != null) { - Dot() - } - } - criticRating?.let { - TomatoRating(it, Modifier.height(height)) - } - } - } -} - -@Composable -fun Dot(modifier: Modifier = Modifier) { - Box( - modifier = - modifier - .padding(horizontal = 8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f)) - .size(4.dp), - ) -} - -@PreviewTvSpec -@Composable -private fun DotSeparatedRowPreview() { - WholphinTheme { - Column { - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"), - communityRating = null, - criticRating = .75f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleMedium, - ) - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"), - communityRating = 7.5f, - criticRating = .75f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleMedium, - ) - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left 7.5"), - communityRating = 7.5f, - criticRating = .45f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleLarge, - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt deleted file mode 100644 index 6d9984dc..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.TimeFormatter -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.timeRemaining -import com.github.damontecres.wholphin.ui.util.LocalClock -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.extensions.ticks -import java.time.LocalDateTime - -@Composable -fun MovieQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val now by LocalClock.current.now - val details = - remember(dto, now) { - buildList { - dto?.productionYear?.let { add(it.toString()) } - addRuntimeDetails(context, now, dto) - dto?.officialRating?.let(::add) - } - } - - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} - -fun MutableList.addRuntimeDetails( - context: Context, - now: LocalDateTime, - dto: BaseItemDto?, -) { - val runtime = dto?.runTimeTicks?.ticks - runtime?.let { duration -> - add(duration.roundMinutes.toString()) - } - dto?.timeRemaining?.roundMinutes?.let { - add("$it left") - } - (dto?.timeRemaining ?: runtime)?.let { remaining -> - val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) - add(context.getString(R.string.ends_at, endTimeStr)) - } -} 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 new file mode 100644 index 00000000..cf5b0697 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +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.util.LocalClock +import kotlin.time.Duration + +@Composable +fun QuickDetails( + details: AnnotatedString, + timeRemaining: Duration?, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val inlineContentMap = + remember(textStyle) { + mapOf( + "star" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + imageVector = Icons.Filled.Star, + tint = FilledStarColor, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + }, + "rotten" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + tint = Color.Unspecified, + ) + }, + "fresh" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + tint = Color.Unspecified, + ) + }, + ) + } + + Row(modifier = modifier) { + Text( + text = details, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, + inlineContent = inlineContentMap, + maxLines = 1, + modifier = Modifier, + ) + timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) } + } +} + +@Composable +fun TimeRemaining( + remaining: Duration, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val context = LocalContext.current + val now by LocalClock.current.now + val remainingStr = + remember(remaining, now) { + val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) + buildAnnotatedString { + dot() + append(context.getString(R.string.ends_at, endTimeStr)) + } + } + Text( + text = remainingStr, + style = textStyle, + maxLines = 1, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index 031d4cb9..0ea3d74f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -1,22 +1,13 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.seriesProductionYears -import com.github.damontecres.wholphin.ui.util.LocalClock import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.extensions.ticks @Composable fun SeriesName( @@ -55,52 +46,3 @@ fun EpisodeName( episode: BaseItemDto?, modifier: Modifier = Modifier, ) = EpisodeName(episode?.episodeTitle ?: episode?.name, modifier) - -@Composable -fun EpisodeQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val now by LocalClock.current.now - val details = - remember(dto, now) { - buildList { - dto?.seasonEpisode?.let(::add) - dto?.premiereDate?.let { add(formatDateTime(it)) } - addRuntimeDetails(context, now, dto) - dto?.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} - -@Composable -fun SeriesQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val details = - remember(dto) { - buildList { - dto?.seriesProductionYears?.let(::add) - dto?.runTimeTicks?.ticks?.roundMinutes?.let { - add(it.toString()) - } - dto?.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index c25f3ff0..4da6f938 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -22,10 +22,11 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch @@ -88,16 +89,16 @@ fun DiscoverMovieDetailsHeader( ?.firstOrNull() ?.certification ?.let(::add) + }.let { + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) } } - DotSeparatedRow( - texts = details, - communityRating = rating?.audienceRating, - criticRating = rating?.criticRating?.toFloat(), - textStyle = MaterialTheme.typography.titleSmall, - modifier = Modifier, - ) + QuickDetails(details, null) movie.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(bottom = padding)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt deleted file mode 100644 index 458a1f43..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverQuickDetails.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.github.damontecres.wholphin.ui.detail.discover - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow -import timber.log.Timber - -@Composable -fun DiscoverQuickDetails( - item: DiscoverItem?, - rating: DiscoverRating?, - modifier: Modifier = Modifier, -) { - Timber.v("id=${item?.id}, rating=$rating") - val context = LocalContext.current - val details = - remember(item) { - buildList { - item - ?.releaseDate - ?.year - ?.toString() - ?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = rating?.audienceRating, - criticRating = rating?.criticRating?.toFloat(), - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 1e6edcc3..22bd8df2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -53,14 +53,15 @@ import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup -import com.github.damontecres.wholphin.ui.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.roundMinutes @@ -478,16 +479,16 @@ fun DiscoverSeriesDetailsHeader( ?.toString() ?.let(::add) // TODO + }.let { + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) } } - DotSeparatedRow( - texts = details, - communityRating = rating?.audienceRating, - criticRating = rating?.criticRating?.toFloat(), - textStyle = MaterialTheme.typography.titleSmall, - modifier = Modifier, - ) + QuickDetails(details, null) series.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(bottom = padding)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index 09c8ed0f..e8872180 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -24,8 +24,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -55,7 +55,7 @@ fun EpisodeDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 8.dp - EpisodeQuickDetails(dto) + QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime) VideoStreamDetails( chosenStreams = chosenStreams, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 9bce1dfe..4276330d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -1,7 +1,10 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.content.Context +import android.text.format.DateUtils +import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -15,8 +18,10 @@ import com.github.damontecres.wholphin.services.NavigationManager 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.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler @@ -50,6 +55,7 @@ import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.time.LocalDateTime +import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.UUID import javax.inject.Inject @@ -58,6 +64,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration const val MAX_HOURS = 48L @@ -528,6 +535,7 @@ data class TvChannel( val imageUrl: String?, ) +@Stable data class TvProgram( val id: UUID, val channelId: UUID, @@ -548,6 +556,49 @@ data class TvProgram( ) { val isFake = category == ProgramCategory.FAKE + val quickDetails by lazy { + val now = LocalDateTime.now() + buildAnnotatedString { + val differentDay = start.toLocalDate() != now.toLocalDate() + val time = + DateUtils.formatDateRange( + WholphinApplication.instance, + start + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + end + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, + ) + append(time) + dot() + + if (!isFake) { + duration + .roundMinutes + .toString() + .let(::append) + dot() + if (now.isAfter(start) && now.isBefore(end)) { + java.time.Duration + .between(now, end) + .toKotlinDuration() + .roundMinutes + .let { append("$it left") } + dot() + } + seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { + append(it) + dot() + } + officialRating?.let(::append) + } + } + } + companion object { private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt index 73b9b3c2..1c39ec71 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideHeader.kt @@ -1,15 +1,12 @@ package com.github.damontecres.wholphin.ui.detail.livetv -import android.text.format.DateUtils import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -17,59 +14,15 @@ 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.components.DotSeparatedRow import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.StreamLabel -import com.github.damontecres.wholphin.ui.roundMinutes -import java.time.LocalDateTime -import java.time.ZoneId -import kotlin.time.toKotlinDuration @Composable fun TvGuideHeader( program: TvProgram?, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - val now = LocalDateTime.now() - val details = - remember(program) { - buildList { - program?.let { - val differentDay = it.start.toLocalDate() != now.toLocalDate() - val time = - DateUtils.formatDateRange( - context, - it.start - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - it.end - .atZone(ZoneId.systemDefault()) - .toInstant() - .epochSecond * 1000, - DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, - ) - add(time) - } - if (program?.isFake == false) { - program - .duration - .roundMinutes - .toString() - .let(::add) - if (now.isAfter(program.start) && now.isBefore(program.end)) { - java.time.Duration - .between(now, program.end) - .toKotlinDuration() - .roundMinutes - .let { add("$it left") } - } - program.seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let(::add) - program.officialRating?.let(::add) - } - } - } Column( verticalArrangement = Arrangement.spacedBy(4.dp), horizontalAlignment = Alignment.Start, @@ -100,13 +53,7 @@ fun TvGuideHeader( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - DotSeparatedRow( - texts = details, - communityRating = null, - criticRating = null, - textStyle = MaterialTheme.typography.titleSmall, - modifier = Modifier, - ) + program?.quickDetails?.let { QuickDetails(it, null) } if (program?.isRepeat == true) { StreamLabel(stringResource(R.string.live_tv_repeat)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index df30078c..60451804 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -24,8 +24,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText -import com.github.damontecres.wholphin.ui.components.MovieQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -65,7 +65,11 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 4.dp - MovieQuickDetails(dto, Modifier.padding(bottom = padding)) + QuickDetails( + movie.ui.quickDetails, + movie.timeRemainingOrRuntime, + Modifier.padding(bottom = padding), + ) dto.genres?.letNotEmpty { GenreText(it, Modifier.padding(bottom = padding)) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 97791749..ff0ddade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails @Composable @@ -33,7 +33,9 @@ fun FocusedEpisodeHeader( ) { EpisodeName(dto, modifier = Modifier) - EpisodeQuickDetails(dto) + ep?.ui?.quickDetails?.let { + QuickDetails(it, ep.timeRemainingOrRuntime) + } if (dto != null) { VideoStreamDetails( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index c63ead92..b938af8e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -63,7 +63,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.TrailerButton import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog @@ -615,7 +615,7 @@ fun SeriesDetailsHeader( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - SeriesQuickDetails(dto) + QuickDetails(series.ui.quickDetails, null) dto.genres?.letNotEmpty { GenreText(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index ac307f21..fc58bf2d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -42,8 +42,8 @@ import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.discover.DiscoverQuickDetails import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -218,17 +218,30 @@ fun SeerrDiscoverPage( Column( modifier = modifier, ) { + val details = + remember(focusedItem, ratingMap) { + buildList { + focusedItem + ?.releaseDate + ?.year + ?.toString() + ?.let(::add) + }.let { + val rating = focusedItem?.id?.let { ratingMap[it] } + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + } + } HomePageHeader( title = focusedItem?.title, subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, overviewTwoLines = true, - quickDetails = { - DiscoverQuickDetails( - item = focusedItem, - rating = focusedItem?.id?.let { ratingMap[it] }, - ) - }, + quickDetails = details, + timeRemaining = null, modifier = Modifier .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 4a95b4e1..3b943f00 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -55,11 +56,9 @@ import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.MovieQuickDetails -import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumnSaver @@ -79,6 +78,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID +import kotlin.time.Duration @Composable fun HomePage( @@ -451,13 +451,8 @@ fun HomePageHeader( subtitle = if (isEpisode) dto.name else null, overview = dto.overview, overviewTwoLines = isEpisode, - quickDetails = { - when (item.type) { - BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier) - BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier) - else -> MovieQuickDetails(dto, Modifier) - } - }, + quickDetails = item.ui.quickDetails, + timeRemaining = item.timeRemainingOrRuntime, modifier = modifier, ) } @@ -469,7 +464,8 @@ fun HomePageHeader( subtitle: String?, overview: String?, overviewTwoLines: Boolean, - quickDetails: (@Composable () -> Unit)?, + quickDetails: AnnotatedString, + timeRemaining: Duration?, modifier: Modifier = Modifier, ) { Column( @@ -496,7 +492,7 @@ fun HomePageHeader( subtitle?.let { EpisodeName(it) } - quickDetails?.invoke() + QuickDetails(quickDetails, timeRemaining) val overviewModifier = Modifier .padding(0.dp) 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 5c5d2a60..ec634ab3 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 @@ -12,7 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import java.time.LocalDateTime -val LocalClock = compositionLocalOf { throw IllegalStateException() } +val LocalClock = compositionLocalOf { Clock() } /** * Represents the current time @@ -21,21 +21,16 @@ data class Clock( /** * The current [LocalDateTime] */ - val now: MutableState, + val now: MutableState = mutableStateOf(LocalDateTime.now()), /** * The current time formatted as a string with [TimeFormatter] */ - val timeString: MutableState, + val timeString: MutableState = mutableStateOf(TimeFormatter.format(now.value)), ) @Composable fun ProvideLocalClock(content: @Composable () -> Unit) { - val clock = - remember { - LocalDateTime - .now() - .let { Clock(mutableStateOf(it), mutableStateOf(TimeFormatter.format(it))) } - } + val clock = remember { Clock() } LaunchedEffect(Unit) { while (isActive) { val now = LocalDateTime.now()