Updates series details, adds movie genres, & adds critic rating (#358)

## Details

Updates the series details page to match style & layout of the new movie
details page (from #279 & #327)

Adds genre list to movie details, it was already on series details

Adds critic rating using fresh or rotten tomato icons, if available

## Screenshots
### Movie

![movie](https://github.com/user-attachments/assets/9bb9cb2e-921c-4f4b-8ea1-a1e73b1842f1)
### TV Show

![series](https://github.com/user-attachments/assets/542989da-bf39-46a4-aefb-7728613a97b1)

## Issues
Closes #357
Closes #347

## Attribution
The fresh & rotten tomato images were sourced from:
- https://commons.wikimedia.org/wiki/File:Rotten_Tomatoes.svg
- https://commons.wikimedia.org/wiki/File:Rotten_Tomatoes_rotten.svg
Credit: Emily Oberman from Pentagram, modified by Throast, Public
domain, via Wikimedia Commons
This commit is contained in:
damontecres 2025-12-01 18:04:40 -05:00 committed by GitHub
parent fc330a3400
commit 8b33dd6d1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 225 additions and 64 deletions

View file

@ -39,7 +39,8 @@ import com.github.damontecres.wholphin.ui.theme.WholphinTheme
@Composable
fun DotSeparatedRow(
texts: List<String>,
rating: Float? = null,
communityRating: Float? = null,
criticRating: Float? = null,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
) {
@ -54,27 +55,38 @@ fun DotSeparatedRow(
style = textStyle,
color = MaterialTheme.colorScheme.onSurface,
)
if (rating != null || index != texts.lastIndex) {
Box(
modifier =
Modifier
.padding(horizontal = 8.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f))
.size(4.dp),
)
if (communityRating != null || criticRating != null || index != texts.lastIndex) {
Dot()
}
}
rating?.let {
communityRating?.let {
SimpleStarRating(
communityRating = it,
modifier = Modifier,
)
if (criticRating != null) {
Dot()
}
}
criticRating?.let {
TomatoRating(it)
}
}
}
}
@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() {
@ -82,13 +94,22 @@ private fun DotSeparatedRowPreview() {
Column {
DotSeparatedRow(
texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"),
rating = 7.5f,
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"),
rating = 7.5f,
communityRating = 7.5f,
criticRating = .45f,
modifier = Modifier,
textStyle = MaterialTheme.typography.titleLarge,
)

View file

@ -0,0 +1,38 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
private const val MAX_TO_SHOW = 4
@Composable
fun GenreText(
genres: List<String>,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.bodyMedium,
color: Color = MaterialTheme.colorScheme.onSurface,
) {
val text =
remember(genres) {
if (genres.size <= MAX_TO_SHOW) {
genres.joinToString(", ")
} else {
genres
.subList(0, MAX_TO_SHOW)
.joinToString(", ") + ", +${genres.size - MAX_TO_SHOW}"
}
}
Text(
text = text,
style = textStyle,
color = color,
overflow = TextOverflow.Ellipsis,
modifier = modifier,
)
}

View file

@ -30,7 +30,8 @@ fun MovieQuickDetails(
DotSeparatedRow(
texts = details,
rating = dto?.communityRating,
communityRating = dto?.communityRating,
criticRating = dto?.criticRating,
textStyle = MaterialTheme.typography.titleSmall,
modifier = modifier,
)

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.ui.components
import android.R.attr.textStyle
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
@ -37,6 +36,7 @@ import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
@ -44,6 +44,7 @@ import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -114,6 +115,42 @@ fun SimpleStarRating(
}
}
@Composable
fun TomatoRating(
rating: Float?,
modifier: Modifier = Modifier,
threshold: Float = 60f,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
if (rating != null) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Text(
text = rating.toInt().toString() + "%",
modifier = Modifier,
)
}
val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() * .8f }
Icon(
painter =
painterResource(
if (rating >= threshold) {
R.drawable.ic_rotten_tomatoes_fresh
} else {
R.drawable.ic_rotten_tomatoes_rotten
},
),
contentDescription = null,
modifier = Modifier.height(height),
tint = Color.Unspecified,
)
}
}
}
/**
* Shows a rating out of 5 stars
*/
@ -269,8 +306,12 @@ private fun SimpleStarRatingPreview() {
WholphinTheme {
Column {
SimpleStarRating(7.5f, Modifier.height(32.dp))
TomatoRating(75f)
SimpleStarRating(7.5f, Modifier.height(20.dp))
SimpleStarRating("", Modifier.height(32.dp))
TomatoRating(75f)
TomatoRating(40f)
TomatoRating(3f)
}
}
}

View file

@ -70,7 +70,8 @@ fun EpisodeQuickDetails(
}
DotSeparatedRow(
texts = details,
rating = dto?.communityRating,
communityRating = dto?.communityRating,
criticRating = dto?.criticRating,
textStyle = MaterialTheme.typography.titleSmall,
modifier = modifier,
)

View file

@ -28,6 +28,7 @@ import java.util.Locale
data class ItemDetailsDialogInfo(
val title: String,
val overview: String?,
val genres: List<String>,
val files: List<MediaSourceInfo>,
)
@ -45,6 +46,14 @@ fun ItemDetailsDialog(
style = MaterialTheme.typography.titleLarge,
)
}
if (info.genres.isNotEmpty()) {
item {
Text(
text = info.genres.joinToString(", "),
style = MaterialTheme.typography.titleSmall,
)
}
}
if (info.overview.isNotNullOrBlank()) {
item {
Text(

View file

@ -212,6 +212,7 @@ fun PersonPage(
ItemDetailsDialogInfo(
title = name,
overview = person.data.overview,
genres = listOf(),
files = listOf(),
),
showFilePath = false,

View file

@ -136,6 +136,7 @@ fun EpisodeDetails(
ItemDetailsDialogInfo(
title = ep.name ?: context.getString(R.string.unknown),
overview = ep.data.overview,
genres = ep.data.genres.orEmpty(),
files = ep.data.mediaSources.orEmpty(),
)
},

View file

@ -182,6 +182,7 @@ fun MovieDetails(
ItemDetailsDialogInfo(
title = movie.name ?: context.getString(R.string.unknown),
overview = movie.data.overview,
genres = movie.data.genres.orEmpty(),
files = movie.data.mediaSources.orEmpty(),
)
},

View file

@ -1,17 +1,15 @@
package com.github.damontecres.wholphin.ui.detail.movie
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
@ -24,10 +22,14 @@ import com.github.damontecres.wholphin.R
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.VideoStreamDetails
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.PersonKind
@Composable
@ -60,9 +62,13 @@ fun MovieDetailsHeader(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 8.dp
val padding = 4.dp
MovieQuickDetails(dto, Modifier.padding(bottom = padding))
dto.genres?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding))
}
VideoStreamDetails(
preferences = preferences,
dto = dto,
@ -80,17 +86,19 @@ fun MovieDetailsHeader(
// Description
dto.overview?.let { overview ->
val interactionSource = remember { MutableInteractionSource() }
val focused = interactionSource.collectIsFocusedAsState().value
LaunchedEffect(focused) {
if (focused) bringIntoViewRequester.bringIntoView()
}
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
interactionSource = interactionSource,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}

View file

@ -1,6 +1,7 @@
package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -25,6 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@ -57,6 +59,7 @@ import com.github.damontecres.wholphin.ui.components.DotSeparatedRow
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.ExpandableFaButton
import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton
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
@ -174,6 +177,7 @@ fun SeriesDetails(
ItemDetailsDialogInfo(
title = item.name ?: context.getString(R.string.unknown),
overview = item.data.overview,
genres = item.data.genres.orEmpty(),
files = listOf(),
)
},
@ -303,6 +307,7 @@ fun SeriesDetailsContent(
var position by rememberInt()
val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } }
val playFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
focusRequesters.getOrNull(position)?.tryRequestFocus()
}
@ -321,7 +326,7 @@ fun SeriesDetailsContent(
) {
LazyColumn(
contentPadding = PaddingValues(bottom = 80.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = Modifier,
) {
item {
@ -330,9 +335,9 @@ fun SeriesDetailsContent(
overviewOnClick = overviewOnClick,
modifier =
Modifier
.fillMaxWidth(.7f)
.fillMaxWidth()
.bringIntoViewRequester(bringIntoViewRequester)
.padding(bottom = 8.dp),
.padding(top = 32.dp, bottom = 16.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
@ -340,7 +345,9 @@ fun SeriesDetailsContent(
Modifier
.padding(start = 16.dp)
.focusRequester(focusRequesters[HEADER_ROW])
.padding(bottom = 80.dp),
.focusRestorer(playFocusRequester)
.focusGroup()
.padding(bottom = 16.dp),
) {
ExpandablePlayButton(
title = R.string.play,
@ -351,13 +358,15 @@ fun SeriesDetailsContent(
playOnClick.invoke(false)
},
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
Modifier
.focusRequester(playFocusRequester)
.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
}
},
},
)
ExpandableFaButton(
title = R.string.shuffle,
@ -406,7 +415,7 @@ fun SeriesDetailsContent(
}
item {
ItemRow(
title = stringResource(R.string.tv_seasons),
title = stringResource(R.string.tv_seasons) + " (${seasons.size})",
items = seasons,
onClickItem = { index, item ->
position = SEASONS_ROW
@ -564,13 +573,15 @@ fun SeriesDetailsHeader(
val scope = rememberCoroutineScope()
val dto = series.data
val details =
buildList {
dto.productionYear?.let { add(it.toString()) }
dto.runTimeTicks
?.ticks
?.roundMinutes
?.let { add(it.toString()) }
dto.officialRating?.let(::add)
remember(series) {
buildList {
dto.productionYear?.let { add(it.toString()) }
dto.runTimeTicks
?.ticks
?.roundMinutes
?.let { add(it.toString()) }
dto.officialRating?.let(::add)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -579,29 +590,32 @@ fun SeriesDetailsHeader(
Text(
text = series.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.fillMaxWidth(),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(.75f),
)
DotSeparatedRow(
texts = details,
rating = dto.communityRating,
textStyle = MaterialTheme.typography.titleMedium,
)
dto.genres?.letNotEmpty {
Text(
text = it.joinToString(", "),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
overflow = TextOverflow.Ellipsis,
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(.60f),
) {
DotSeparatedRow(
texts = details,
communityRating = dto.communityRating,
criticRating = dto.criticRating,
textStyle = MaterialTheme.typography.titleSmall,
modifier = Modifier,
)
}
dto.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
)
dto.genres?.letNotEmpty {
GenreText(it)
}
dto.overview?.let { overview ->
OverviewText(
overview = overview,
maxLines = 3,
onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified,
)
}
}
}
}

View file

@ -332,6 +332,7 @@ fun SeriesOverview(
ItemDetailsDialogInfo(
title = it.name ?: context.getString(R.string.unknown),
overview = it.data.overview,
genres = it.data.genres.orEmpty(),
files = it.data.mediaSources.orEmpty(),
)
}

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="138.75dp"
android:height="141.25dp"
android:viewportWidth="138.75"
android:viewportHeight="141.25">
<path
android:pathData="m20.15,40.83c-28.15,27.62 -13.66,61.01 -5.73,71.93 35.25,41.95 92.79,25.34 111.89,-5.91 4.76,-8.2 22.55,-53.47 -23.98,-78.01z"
android:fillColor="#f93208"/>
<path
android:pathData="m39.61,39.26 l4.78,-8.86 28.41,-5.04 11.12,9.21z"
android:fillColor="#f93208"/>
<path
android:pathData="m39.44,8.57 l8.97,-5.28 6.76,15.48c3.79,-6.32 13.79,-16.32 24.94,-4.67 -4.73,1.26 -7.52,3.86 -7.74,8.48 15.15,-4.17 31.34,3.21 33.54,9.09 -10.95,-4.31 -27.69,10.38 -41.77,2.33 0.01,15.05 -12.62,16.64 -19.9,17.08 2.08,-5 5.59,-9.99 1.47,-14.99 -7.62,8.17 -13.87,10.67 -33.17,4.67 4.88,-1.68 14.84,-11.39 24.45,-11.43 -6.78,-2.47 -12.29,-2.09 -17.81,-1.48 2.92,-3.96 12.15,-15.2 28.63,-8.48z"
android:fillColor="#02902e"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="145dp"
android:height="140dp"
android:viewportWidth="145"
android:viewportHeight="140">
<path
android:pathData="M47.4,35.34c-13.61,-7.93 -12.32,-25.2 2.1,-31.88 26.12,-6.53 29.12,13.78 22.65,30.41 -6.54,24.11 18.09,23.66 19.92,10.07 3.61,-18.41 19.39,-26.69 31.67,-16.36 12.6,12.14 7.07,36.58 -17.83,34.19 -16.03,-1.54 -19.55,19.58 0.84,21.18 32.23,1.91 42.49,22.17 31.04,35.87 -15.99,15.15 -37.69,-4.44 -45.51,-19.5 -6.8,-9.31 -17.32,0.11 -13.42,6.5 12.98,19.47 2.92,31.23 -10.91,30.62 -13.37,-0.85 -20.96,-9.06 -13.21,-29.15 3.9,-12.48 -8.6,-15.39 -16.57,-5.45 -11.71,19.61 -28.86,13.68 -33.98,4.19 -3.24,-7.62 -2.92,-25.85 24.12,-23.7 16.69,4.14 11.78,-12.56 -0.63,-13.63 -9.24,-0.44 -30.5,-7.3 -22.86,-24.54 7.34,-11.06 24.96,-11.77 33.35,6.29 3.04,4.23 8.36,11.04 18.04,5.03 3.51,-5.2 1.21,-13.9 -8.81,-20.14z"
android:fillColor="#0fc755"/>
</vector>