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
This commit is contained in:
Ray 2026-01-14 17:07:17 -05:00 committed by GitHub
parent 3215326515
commit c9d1244057
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 339 additions and 386 deletions

View file

@ -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,
)

View file

@ -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<String>,
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")
}
}
}
}

View file

@ -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<String>,
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,
)
}
}
}

View file

@ -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<String>.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))
}
}

View file

@ -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,
)
}

View file

@ -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,
)
}

View file

@ -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))
}

View file

@ -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,
)
}

View file

@ -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))
}

View file

@ -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,

View file

@ -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)

View file

@ -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))
}

View file

@ -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))

View file

@ -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(

View file

@ -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)
}

View file

@ -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)

View file

@ -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)

View file

@ -12,7 +12,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import java.time.LocalDateTime
val LocalClock = compositionLocalOf<Clock> { throw IllegalStateException() }
val LocalClock = compositionLocalOf<Clock> { Clock() }
/**
* Represents the current time
@ -21,21 +21,16 @@ data class Clock(
/**
* The current [LocalDateTime]
*/
val now: MutableState<LocalDateTime>,
val now: MutableState<LocalDateTime> = mutableStateOf(LocalDateTime.now()),
/**
* The current time formatted as a string with [TimeFormatter]
*/
val timeString: MutableState<String>,
val timeString: MutableState<String> = 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()