Customize which ratings to display (#1407)

## Description
Adds a setting, Settings->Advanced->"Customize which ratings to
display", which allows for toggling parental, community, or critic
ratings to be displayed in the quick details.

This doesn't apply to discover yet.

### Related issues
Related to #1020

### Testing
Emulator

## Screenshots

### Just community rating
<img width="1280" height="720" alt="ratings Large"
src="https://github.com/user-attachments/assets/d6f9ad8a-6cc5-4680-9d2f-66d16a44b41e"
/>


## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-05-20 12:40:58 -04:00 committed by GitHub
parent 45d466d76f
commit 366483599c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 333 additions and 206 deletions

View file

@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -36,6 +37,8 @@ import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
@Composable
fun MainContent(
@ -55,6 +58,9 @@ fun MainContent(
) {
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
val interfaceCustomization =
remember(appPreferences) { InterfaceCustomization(appPreferences) }
CompositionLocalProvider(LocalInterfaceCustomization provides interfaceCustomization) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
@ -139,3 +145,4 @@ fun MainContent(
}
}
}
}

View file

@ -117,6 +117,8 @@ data class BaseItem(
null
},
quickDetails =
QuickDetailsData(
basic =
buildAnnotatedString {
val details =
buildList {
@ -151,18 +153,25 @@ data class BaseItem(
dot()
}
}
// TODO time remaining
},
officialRating =
data.officialRating?.let {
buildAnnotatedString {
dot()
append(it)
}
},
communityRating =
data.communityRating?.let {
buildAnnotatedString {
dot()
append(String.format(Locale.getDefault(), "%.1f", it))
appendInlineContent(id = "star")
}
},
criticRating =
data.criticRating?.let {
buildAnnotatedString {
dot()
append("${it.toInt()}%")
if (it >= 60f) {
@ -172,6 +181,7 @@ data class BaseItem(
}
}
},
),
)
private fun dateAsIndex(): Int? =
@ -254,7 +264,14 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let {
data class BaseItemUi(
val episodeCornerText: String?,
val episodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString,
val quickDetails: QuickDetailsData,
)
data class QuickDetailsData(
val basic: AnnotatedString,
val officialRating: AnnotatedString? = null,
val criticRating: AnnotatedString? = null,
val communityRating: AnnotatedString? = null,
)
/**

View file

@ -516,6 +516,25 @@ sealed interface AppPreference<Pref, T> {
summaryOff = R.string.disabled,
)
val DisplayTogglesPref =
AppMultiChoicePreference<AppPreferences, DisplayToggle>(
title = R.string.display_toggles_title,
summary = R.string.display_toggles_summary,
defaultValue = DisplayToggle.entries.filterNot { it == DisplayToggle.UNRECOGNIZED },
allValues = DisplayToggle.entries.filterNot { it == DisplayToggle.UNRECOGNIZED },
displayValues = R.array.display_toggle_types,
displayValuesSubtitles = R.array.display_toggle_types_subtitles,
getter = {
it.interfacePreferences.displayTogglesList
},
setter = { prefs, value ->
prefs.updateInterfacePreferences {
clearDisplayToggles()
addAllDisplayToggles(value)
}
},
)
val InstalledVersion =
AppClickablePreference<AppPreferences>(
title = R.string.installed_version,
@ -1058,8 +1077,8 @@ val basicPreferences =
preferences =
listOf(
AppPreference.SignInAuto,
AppPreference.PlayThemeMusic,
AppPreference.RememberSelectedTab,
AppPreference.PlayThemeMusic,
AppPreference.SubtitleStyle,
AppPreference.ThemeColors,
AppPreference.ScreensaverSettings,
@ -1172,13 +1191,13 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.ShowClock,
AppPreference.BackdropStylePref,
AppPreference.ShowLogos,
AppPreference.ManageMedia,
AppPreference.CombineContinueNext,
AppPreference.DisplayTogglesPref,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,
AppPreference.ControllerTimeout,
AppPreference.BackdropStylePref,
AppPreference.SlideshowDuration,
AppPreference.SlideshowPlayVideos,
),
@ -1190,6 +1209,7 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.OneClickPause,
AppPreference.ControllerTimeout,
AppPreference.CinemaMode,
AppPreference.GlobalContentScale,
AppPreference.SkipSegments,
@ -1337,6 +1357,7 @@ data class AppMultiChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> List<T>,
override val setter: (prefs: Pref, value: List<T>) -> Pref,
@param:StringRes val summary: Int? = null,
@param:ArrayRes val displayValuesSubtitles: Int? = null,
) : AppPreference<Pref, List<T>>
data class AppClickablePreference<Pref>(

View file

@ -6,6 +6,7 @@ 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.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@ -21,19 +22,60 @@ 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.data.model.QuickDetailsData
import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.getTimeFormatter
import com.github.damontecres.wholphin.ui.util.LocalClock
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import kotlin.time.Duration
@Composable
fun QuickDetails(
details: AnnotatedString,
details: QuickDetailsData?,
timeRemaining: Duration?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
) {
val inlineContentMap =
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
Row(modifier = modifier) {
if (details != null) {
QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap)
if (DisplayToggle.OFFICIAL_RATING in enabled) {
QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap)
}
if (DisplayToggle.COMMUNITY_RATING in enabled) {
QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
}
if (DisplayToggle.CRITIC_RATING in enabled) {
QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
}
}
timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) }
}
}
@NonRestartableComposable
@Composable
fun QuickDetailsText(
str: AnnotatedString?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
inlineContentMap: Map<String, InlineTextContent> = rememberQuickDetailsContentMap(textStyle),
) = str?.let {
Text(
text = str,
color = MaterialTheme.colorScheme.onSurface,
style = textStyle,
inlineContent = inlineContentMap,
maxLines = 1,
modifier = modifier,
)
}
@Composable
fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typography.titleSmall) =
remember(textStyle) {
mapOf(
"star" to
@ -84,19 +126,6 @@ fun QuickDetails(
)
}
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,

View file

@ -24,7 +24,7 @@ import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.preferences.UserPreferences
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.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.listToDotString
@ -100,7 +100,7 @@ fun DiscoverMovieDetailsHeader(
}
}
QuickDetails(details, null)
QuickDetailsText(details)
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding))
}

View file

@ -56,7 +56,7 @@ 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.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
import com.github.damontecres.wholphin.ui.letNotEmpty
@ -477,7 +477,7 @@ fun DiscoverSeriesDetailsHeader(
}
}
QuickDetails(details, null)
QuickDetailsText(details)
series.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(bottom = padding))
}

View file

@ -21,7 +21,7 @@ import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.components.EpisodeName
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.StreamLabel
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import kotlin.time.Duration.Companion.milliseconds
@ -65,9 +65,8 @@ fun TvGuideHeader(
modifier = Modifier.padding(start = HeaderUtils.startPadding),
) {
program?.quickDetails?.let {
QuickDetails(
QuickDetailsText(
it,
null,
modifier = Modifier.padding(vertical = 4.dp),
)
}

View file

@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
@ -275,11 +276,13 @@ fun SeerrDiscoverPage(
?.let(::add)
}.let {
val rating = focusedItem?.id?.let { ratingMap[it] }
val str =
listToDotString(
it,
rating?.audienceRating,
rating?.criticRating?.toFloat(),
)
QuickDetailsData(str)
}
}
HomePageHeader(

View file

@ -40,7 +40,6 @@ import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
@ -52,6 +51,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
@ -546,7 +546,7 @@ fun HomePageHeader(
subtitle = if (isEpisode) dto?.name else null,
overview = dto?.overview,
overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""),
quickDetails = item?.ui?.quickDetails,
timeRemaining = item?.timeRemainingOrRuntime,
showLogo = showLogo,
logoImageUrl = rememberLogoUrl(item),
@ -560,7 +560,7 @@ fun HomePageHeader(
subtitle: String?,
overview: String?,
overviewTwoLines: Boolean,
quickDetails: AnnotatedString?,
quickDetails: QuickDetailsData?,
timeRemaining: Duration?,
showLogo: Boolean,
logoImageUrl: String?,
@ -585,7 +585,7 @@ fun HomePageHeader(
if (subtitle != null) {
EpisodeName(subtitle)
}
QuickDetails(quickDetails ?: AnnotatedString(""), timeRemaining)
QuickDetails(quickDetails, timeRemaining)
val overviewModifier =
Modifier
.padding(0.dp)

View file

@ -199,6 +199,7 @@ fun <T> ComposablePreference(
is AppMultiChoicePreference<*, *> -> {
val values = stringArrayResource(preference.displayValues)
val subtitles = preference.displayValuesSubtitles?.let { stringArrayResource(it) }
val summary =
preference.summary?.let { stringResource(it) }
?: preference.summary(context, value)
@ -219,6 +220,9 @@ fun <T> ComposablePreference(
valueDisplay = { index, _ ->
Text(values[index])
},
subtitleDisplay = { index, _ ->
subtitles?.getOrNull(index)?.let { Text(it) }
},
)
}

View file

@ -25,6 +25,7 @@ fun <T> MultiChoicePreference(
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) },
subtitleDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> },
) {
// val values = stringArrayResource(preference.displayValues).toList()
// val summary =
@ -60,6 +61,7 @@ fun <T> MultiChoicePreference(
possibleValues.mapIndexed { index, item ->
DialogItem(
headlineContent = { valueDisplay.invoke(index, item) },
supportingContent = { subtitleDisplay.invoke(index, item) },
trailingContent = {
Switch(
checked = selectedValues.contains(item),

View file

@ -24,7 +24,6 @@ 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.components.VideoStreamDetails
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.MediaType
@OptIn(UnstableApi::class)
@ -64,11 +63,7 @@ fun ImageDetailsHeader(
modifier = Modifier.fillMaxWidth(.75f),
)
}
if (image.image.ui.quickDetails
.isNotNullOrBlank()
) {
QuickDetails(image.image.ui.quickDetails, null)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {

View file

@ -0,0 +1,28 @@
package com.github.damontecres.wholphin.ui.util
import androidx.compose.runtime.staticCompositionLocalOf
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.DisplayToggle
import java.util.EnumSet
/**
* Represents various UI preferences made available via [LocalInterfaceCustomization]
*/
data class InterfaceCustomization(
val enabledDisplayToggles: EnumSet<DisplayToggle>,
) {
constructor(prefs: AppPreferences) : this(
prefs.interfacePreferences.displayTogglesList.let {
if (it.isEmpty()) {
EnumSet.noneOf(DisplayToggle::class.java)
} else {
EnumSet.copyOf(it)
}
},
)
}
val LocalInterfaceCustomization =
staticCompositionLocalOf<InterfaceCustomization> {
InterfaceCustomization(EnumSet.allOf(DisplayToggle::class.java))
}

View file

@ -170,6 +170,12 @@ message SearchPreferences {
bool combined_search_results = 1;
}
enum DisplayToggle{
OFFICIAL_RATING = 0;
CRITIC_RATING = 1;
COMMUNITY_RATING = 2;
}
message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2;
@ -185,6 +191,7 @@ message InterfacePreferences {
bool enable_media_management = 12;
bool show_logos = 13;
SearchPreferences search_preferences = 14;
repeated DisplayToggle display_toggles = 15;
}
message AdvancedPreferences {

View file

@ -726,6 +726,17 @@
<item>@string/photos</item>
</string-array>
<string-array name="display_toggle_types">
<item>@string/official_rating</item>
<item>@string/critic_rating</item>
<item>@string/community_rating</item>
</string-array>
<string-array name="display_toggle_types_subtitles">
<item></item><!-- Intentionally blank -->
<item>@string/tomatoes</item>
<item>@string/stars</item>
</string-array>
<string name="search_for">Search %s</string>
<string name="actor">Actor</string>
@ -779,6 +790,8 @@
<string name="skip_behavior_summary">For intros, credits, etc</string>
<string name="play_with">Play with</string>
<string name="transcoding">Transcoding</string>
<string name="display_toggles_title">Ratings display</string>
<string name="display_toggles_summary">Customize which to display</string>
<string name="profile_protection">Profile protection</string>
<string name="continue_watching_click_behavior">Continue watching/Next up click behavior</string>
<string name="continue_watching_click_summary_on">Play on click</string>
@ -793,4 +806,6 @@
<item></item><!-- Intentionally blank -->
<item></item><!-- Intentionally blank -->
</array>
<string name="stars">Stars</string>
<string name="tomatoes">Tomatoes</string>
</resources>