Show PWA source ratings on every surface through one shared rating row
All checks were successful
review/code-claude Claude code review: pass, no blocking findings

This commit is contained in:
justinzeus 2026-10-29 21:04:39 +01:00
parent 853a3989b2
commit 2ccce98dc1
26 changed files with 944 additions and 568 deletions

View file

@ -45,6 +45,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
@ -119,6 +121,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var imageUrlService: ImageUrlService
@Inject
lateinit var seerrRatingsService: SeerrRatingsService
@Inject
lateinit var refreshRateService: RefreshRateService
@ -263,7 +268,10 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
}
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
CompositionLocalProvider(
LocalImageUrlService provides imageUrlService,
LocalSeerrRatingsService provides seerrRatingsService,
) {
WholphinTheme(
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,

View file

@ -1,6 +1,5 @@
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
@ -32,7 +31,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID
import kotlin.time.Duration
@ -234,26 +232,6 @@ data class BaseItem(
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) {
appendInlineContent(id = "fresh")
} else {
appendInlineContent(id = "rotten")
}
}
},
),
)
@ -369,12 +347,6 @@ data class BaseItemUi(
data class QuickDetailsData(
val basic: AnnotatedString,
val officialRating: AnnotatedString? = null,
val criticRating: AnnotatedString? = null,
val communityRating: AnnotatedString? = null,
/**
* Show [criticRating] (Rotten Tomatoes) before [communityRating] (IMDb/TMDB) like the PWA, instead of the Jellyfin order
*/
val criticRatingFirst: Boolean = false,
)
/**

View file

@ -1,10 +1,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.dot
import java.util.Locale
/**
@ -43,16 +39,27 @@ data class RtScore(
)
/**
* One displayable rating
* The badge drawn in front of a rating's number. One per PWA icon.
*/
enum class RatingBadge {
RT_FRESH,
RT_ROTTEN,
RT_UPRIGHT,
RT_SPILLED,
/** Rotten Tomatoes without a recognised label, drawn muted */
RT_UNKNOWN,
IMDB,
TMDB,
}
/**
* One displayable rating: a badge and its number
*/
@Immutable
sealed interface RatingEntry {
val text: String
/**
* The id of the inline content (see [com.github.damontecres.wholphin.ui.components.rememberQuickDetailsContentMap]) for this entry's badge
*/
val inlineContentId: String
val badge: RatingBadge
data class RottenTomatoes(
val score: RtScore,
@ -60,14 +67,14 @@ sealed interface RatingEntry {
val critics: Boolean,
) : RatingEntry {
override val text: String get() = "${score.percent}%"
override val inlineContentId: String
override val badge: RatingBadge
get() =
when (score.mark) {
RtMark.FRESH -> INLINE_RT_FRESH
RtMark.ROTTEN -> INLINE_RT_ROTTEN
RtMark.UPRIGHT -> INLINE_RT_UPRIGHT
RtMark.SPILLED -> INLINE_RT_SPILLED
RtMark.UNKNOWN -> INLINE_RT_UNKNOWN
RtMark.FRESH -> RatingBadge.RT_FRESH
RtMark.ROTTEN -> RatingBadge.RT_ROTTEN
RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT
RtMark.SPILLED -> RatingBadge.RT_SPILLED
RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN
}
}
@ -75,25 +82,17 @@ sealed interface RatingEntry {
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val inlineContentId: String get() = INLINE_IMDB
override val badge: RatingBadge get() = RatingBadge.IMDB
}
data class Tmdb(
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val inlineContentId: String get() = INLINE_TMDB
override val badge: RatingBadge get() = RatingBadge.TMDB
}
}
const val INLINE_RT_FRESH = "fresh"
const val INLINE_RT_ROTTEN = "rotten"
const val INLINE_RT_UPRIGHT = "rt_upright"
const val INLINE_RT_SPILLED = "rt_spilled"
const val INLINE_RT_UNKNOWN = "rt_unknown"
const val INLINE_IMDB = "imdb"
const val INLINE_TMDB = "tmdb"
private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score)
/**
@ -131,6 +130,16 @@ data class SourceRatings(
tmdb?.let { RatingEntry.Tmdb(it) },
)
/**
* The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB).
*
* Rotten Tomatoes is controlled by the critic rating display setting and IMDb/TMDB by the community rating setting.
*/
fun visibleEntries(
community: Boolean,
critic: Boolean,
): List<RatingEntry> = (if (critic) criticEntries else emptyList()) + (if (community) communityEntries else emptyList())
companion object {
const val MAX_PERCENT = 100.0
const val MAX_SCORE = 10.0
@ -187,38 +196,3 @@ data class SourceRatings(
}
}
}
/**
* Appends each entry as a dot, its badge, and its score
*/
fun AnnotatedString.Builder.appendRatings(entries: List<RatingEntry>) {
entries.forEach {
dot()
appendInlineContent(it.inlineContentId)
append(" ")
append(it.text)
}
}
/**
* Replaces the Jellyfin community & critic ratings with these ratings.
*
* IMDb/TMDB are shown with the community rating setting, Rotten Tomatoes with the critic rating setting.
* If there are no ratings, the Jellyfin values are kept.
*/
fun QuickDetailsData.withSourceRatings(ratings: SourceRatings?): QuickDetailsData =
if (ratings == null || ratings.isEmpty) {
this
} else {
copy(
communityRating =
ratings.communityEntries.takeIf { it.isNotEmpty() }?.let {
buildAnnotatedString { appendRatings(it) }
},
criticRating =
ratings.criticEntries.takeIf { it.isNotEmpty() }?.let {
buildAnnotatedString { appendRatings(it) }
},
criticRatingFirst = true,
)
}

View file

@ -6,7 +6,7 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.HomeSlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.async
@ -58,7 +58,7 @@ class LatestNextUpService
val request =
GetResumeItemsRequest(
userId = userId,
fields = SlimItemFields,
fields = HomeSlimItemFields,
limit = limit,
mediaTypes = listOf(MediaType.VIDEO),
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
@ -90,7 +90,7 @@ class LatestNextUpService
val request =
GetNextUpRequest(
userId = userId,
fields = SlimItemFields,
fields = HomeSlimItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,

View file

@ -9,12 +9,19 @@ import com.github.damontecres.wholphin.data.model.SourceRatings
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.util.Optional
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@ -28,7 +35,22 @@ data class TmdbVote(
)
/**
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses
* The Seerr title whose ratings are shown for a Jellyfin item
*/
data class RatingsTarget(
val type: SeerrItemType,
val tmdbId: Int,
)
/**
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses. This is the only way ratings
* reach the UI, see [com.github.damontecres.wholphin.ui.components.rememberSourceRatings].
*
* Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else:
* - movies show their own ratings
* - series show their own ratings
* - seasons and episodes show their series' ratings, as the PWA does for the title page an episode belongs to
* - everything else (collections, music, photos, programs, ...) has none
*
* Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from
* `voteAverage`/`voteCount` and is only requested when no other source has a score.
@ -41,6 +63,7 @@ class SeerrRatingsService
@Inject
constructor(
private val seerrService: SeerrService,
private val api: ApiClient,
) {
private data class Key(
val type: SeerrItemType,
@ -54,43 +77,118 @@ class SeerrRatingsService
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<Key, SourceRatings>()
/**
* The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page
* don't carry their series' provider ids, so this is how they are found.
*/
private val tmdbIds =
CacheBuilder
.newBuilder()
.maximumSize(CACHE_SIZE)
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<UUID, Optional<Int>>()
fun cached(
type: SeerrItemType,
tmdbId: Int,
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
private fun target(item: BaseItem): Pair<SeerrItemType, Int>? {
val type =
when (item.type) {
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
BaseItemKind.SERIES -> SeerrItemType.TV
else -> return null
}
val tmdbId =
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull() ?: return null
return type to tmdbId
/**
* The Jellyfin item whose provider ids give the TMDB id, null if the item never has ratings.
* An episode's or season's own provider ids are for the episode or season, so those use the series.
*/
private fun ownerId(item: BaseItem): UUID? =
when (item.type) {
BaseItemKind.MOVIE, BaseItemKind.SERIES -> item.id
BaseItemKind.SEASON, BaseItemKind.EPISODE -> item.data.seriesId
else -> null
}
/**
* The TMDB id if it is known without a request: the item's own provider ids or an earlier lookup.
* Present but empty means the item definitely has no TMDB id, null means unknown.
*/
private fun knownTmdbId(
item: BaseItem,
ownerId: UUID,
): Optional<Int>? =
item.data.providerIds
?.takeIf { ownerId == item.id && it.isNotEmpty() }
?.let { Optional.ofNullable(it["Tmdb"]?.toIntOrNull()) }
?: tmdbIds.getIfPresent(ownerId)
private fun type(item: BaseItem): SeerrItemType? =
when (item.type) {
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> SeerrItemType.TV
else -> null
}
private fun knownTarget(item: BaseItem): RatingsTarget? {
val type = type(item) ?: return null
val owner = ownerId(item) ?: return null
return knownTmdbId(item, owner)?.orElse(null)?.let { RatingsTarget(type, it) }
}
/**
* The already cached ratings for a Jellyfin movie or series, used to seed a screen's initial state so it
* doesn't show the Jellyfin values first
* Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand.
* Null if the item has no ratings, no TMDB id or the lookup failed.
*/
fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) }
suspend fun resolveTarget(item: BaseItem): RatingsTarget? {
val type = type(item) ?: return null
val owner = ownerId(item) ?: return null
val tmdb =
knownTmdbId(item, owner) ?: try {
Optional
.ofNullable(
api.userLibraryApi
.getItem(owner)
.content.providerIds
?.get("Tmdb")
?.toIntOrNull(),
).also { tmdbIds.put(owner, it) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.w(ex, "Error fetching provider ids for %s", owner)
return null
}
return tmdb.orElse(null)?.let { RatingsTarget(type, it) }
}
/**
* Ratings for a Jellyfin movie or series using its TMDB id. Follows whether Seerr is active, since the
* connection is established asynchronously after login.
*
* Emits null while Seerr is not active, the item isn't a movie/series with a TMDB id, or the lookup failed.
* Callers should keep showing the Jellyfin values for null.
* The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty
*/
fun forItem(item: BaseItem): Flow<SourceRatings?> {
val (type, tmdbId) = target(item) ?: return flowOf(null)
return seerrService.active
.distinctUntilChanged()
.mapLatest { active -> if (active) get(type, tmdbId) else null }
fun cachedFor(item: BaseItem): SourceRatings? = knownTarget(item)?.let { cached(it.type, it.tmdbId) }
/**
* Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established
* asynchronously after login.
*
* Already cached ratings are emitted at once. Otherwise nothing is requested until [debounceMillis] has passed, so
* collecting for each focused item while scrolling quickly only asks Seerr about the item the user stops on:
* collecting is cancelled for the previous item, which cancels the wait or the request in progress.
*
* Emits null while Seerr is not active, the item has no ratings (see above) or no TMDB id, or the lookup failed.
*/
fun forItem(
item: BaseItem?,
debounceMillis: Long = DEBOUNCE_MILLIS,
): Flow<SourceRatings?> {
if (item == null || type(item) == null) return flowOf(null)
cachedFor(item)?.let { return flowOf(it) }
val owner = ownerId(item) ?: return flowOf(null)
if (knownTmdbId(item, owner)?.isPresent == false) return flowOf(null)
return flow {
delay(debounceMillis)
emitAll(
seerrService.active
.distinctUntilChanged()
.mapLatest { active ->
if (active) resolveTarget(item)?.let { get(it.type, it.tmdbId) } else null
},
)
}
}
/**
@ -228,5 +326,6 @@ class SeerrRatingsService
companion object {
private const val CACHE_SIZE = 300L
private const val CACHE_HOURS = 6L
const val DEBOUNCE_MILLIS = 350L
}
}

View file

@ -8,8 +8,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.appendRatings
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
@ -233,19 +231,12 @@ val MediaSegmentType.skipStringRes: Int
fun AnnotatedString.Builder.dot() = append(" \u2022 ")
fun listToDotString(
strings: List<String>,
ratings: SourceRatings?,
): AnnotatedString =
fun listToDotString(strings: List<String>): AnnotatedString =
buildAnnotatedString {
strings.forEachIndexed { index, string ->
append(string)
if (index != strings.lastIndex) dot()
}
ratings?.let {
appendRatings(it.criticEntries)
appendRatings(it.communityEntries)
}
}
@get:StringRes

View file

@ -75,12 +75,22 @@ val SlimItemFields =
*/
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
/**
* [ItemFields] for the home page's rows. [ItemFields.PROVIDER_IDS] is needed so a focused movie or series has its TMDB id
* for [com.github.damontecres.wholphin.services.SeerrRatingsService] without another request.
*/
val HomeItemFields =
listOf(
ItemFields.OVERVIEW,
ItemFields.CAN_DELETE,
ItemFields.PROVIDER_IDS,
)
/**
* [SlimItemFields] for the home page's continue watching & next up rows, see [HomeItemFields]
*/
val HomeSlimItemFields = SlimItemFields + ItemFields.PROVIDER_IDS
val PhotoItemFields =
DetailItemFields +
listOf(

View file

@ -250,7 +250,7 @@ fun CollectionFolderListDetails(
)
item?.let {
QuickDetails(item.ui.quickDetails, null)
QuickDetails(item, null)
}
OverviewText(

View file

@ -1,41 +1,24 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
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.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
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.data.model.INLINE_IMDB
import com.github.damontecres.wholphin.data.model.INLINE_RT_FRESH
import com.github.damontecres.wholphin.data.model.INLINE_RT_ROTTEN
import com.github.damontecres.wholphin.data.model.INLINE_RT_SPILLED
import com.github.damontecres.wholphin.data.model.INLINE_RT_UNKNOWN
import com.github.damontecres.wholphin.data.model.INLINE_RT_UPRIGHT
import com.github.damontecres.wholphin.data.model.INLINE_TMDB
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatTime
import com.github.damontecres.wholphin.ui.util.LocalClock
@ -43,6 +26,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import org.jellyfin.sdk.model.DateTime
import kotlin.time.Duration
/**
* The line of details under a title: year, runtime, etc, then the ratings, then when it ends.
*
* Ratings are only drawn for [ratings], never from the Jellyfin item. Pass the result of [rememberSourceRatings] or null
* for surfaces without ratings.
*/
@Composable
fun QuickDetails(
details: QuickDetailsData?,
@ -50,24 +39,16 @@ fun QuickDetails(
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null,
ratings: SourceRatings? = null,
) {
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
Row(modifier = modifier) {
if (details != null) {
QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap)
QuickDetailsText(details.basic, Modifier, textStyle)
if (DisplayToggle.OFFICIAL_RATING in enabled) {
QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap)
}
val community = DisplayToggle.COMMUNITY_RATING in enabled
val critic = DisplayToggle.CRITIC_RATING in enabled
if (details.criticRatingFirst) {
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
} else {
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
QuickDetailsText(details.officialRating, Modifier, textStyle)
}
SourceRatingsRow(ratings, Modifier, textStyle)
}
if (timeRemaining != null) {
TimeRemaining(timeRemaining, textStyle = textStyle)
@ -77,64 +58,41 @@ fun QuickDetails(
}
}
/**
* [QuickDetails] for a Jellyfin item with the ratings the PWA shows for it, if any (see [SeerrRatingsService]).
*/
@Composable
fun QuickDetails(
item: BaseItem,
timeRemaining: Duration?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null,
) = QuickDetails(
details = item.ui.quickDetails,
timeRemaining = timeRemaining,
modifier = modifier,
textStyle = textStyle,
endsAt = endsAt,
ratings = rememberSourceRatings(item),
)
@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) {
val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
// Wordmark badges are wider than tall, like in the PWA
val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
fun badge(
placeholder: Placeholder,
@DrawableRes resId: Int,
muted: Boolean = false,
) = InlineTextContent(placeholder) {
Image(
painter = painterResource(resId),
contentDescription = null,
contentScale = ContentScale.Fit,
alpha = if (muted) 0.55f else 1f,
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null,
modifier = Modifier.fillMaxSize(),
)
}
mapOf(
"star" to
InlineTextContent(square) {
Icon(
imageVector = Icons.Filled.Star,
tint = FilledStarColor,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
},
INLINE_RT_ROTTEN to badge(square, R.drawable.ic_rotten_tomatoes_rotten),
INLINE_RT_FRESH to badge(square, R.drawable.ic_rotten_tomatoes_fresh),
INLINE_RT_UPRIGHT to badge(square, R.drawable.ic_rt_upright),
INLINE_RT_SPILLED to badge(square, R.drawable.ic_rt_spilled),
INLINE_RT_UNKNOWN to badge(square, R.drawable.ic_rotten_tomatoes_fresh, muted = true),
INLINE_IMDB to badge(wide, R.drawable.ic_rating_imdb),
INLINE_TMDB to badge(wide, R.drawable.ic_rating_tmdb),
)
}
@Composable
fun TimeRemaining(
remaining: Duration,

View file

@ -1,93 +1,29 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
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
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
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.Locale
enum class StarRatingPrecision {
FULL,
HALF,
QUARTER,
;
companion object {
fun fromFloat(value: Float): StarRatingPrecision =
if (value <= .25f) {
QUARTER
} else if (value <= .5f) {
HALF
} else {
FULL
}
}
}
val FilledStarColor = Color(0xFFFFC700)
val EmptyStarColor = Color(0xFF5D4A0A)
val ratingBarHeight: Dp = 32.dp
@Composable
fun SimpleStarRating(
communityRating: Float?,
modifier: Modifier = Modifier,
) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
SimpleStarRating(
text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) },
modifier = modifier,
)
}
/**
* A minimum rating such as "7+" with a star, for the rating filter menus.
*
* This is not for showing a title's rating, see [SourceRatingsRow] for that.
*/
@Composable
fun SimpleStarRating(
text: String?,
@ -115,206 +51,3 @@ 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() + "%",
maxLines = 1,
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
*/
@Composable
fun StarRating(
rating100: Int,
onRatingChange: (Int) -> Unit,
precision: StarRatingPrecision,
enabled: Boolean,
playSoundOnFocus: Boolean,
modifier: Modifier = Modifier,
bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background,
) {
val context = LocalContext.current
var tempRating by remember(rating100) { mutableIntStateOf(rating100) }
val percentage = (if (enabled) tempRating else rating100) / 100f
val focusRequesters = remember { List(5) { FocusRequester() } }
Box(
modifier =
modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor),
) {
LazyRow(
modifier =
Modifier
.selectableGroup()
.padding(4.dp)
.drawWithCache {
onDrawWithContent {
drawContent()
if (percentage in 0f..<1f) {
drawRect(
color = bgColor,
topLeft = Offset(size.width * percentage, 0f),
blendMode = BlendMode.SrcAtop,
)
}
}
}.focusGroup()
.focusProperties {
onEnter = {
val index =
if (rating100 <= 20) {
0
} else if (rating100 <= 40) {
1
} else if (rating100 <= 60) {
2
} else if (rating100 <= 80) {
3
} else {
4
}
focusRequesters[index].tryRequestFocus()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
for (i in 1..5) {
item {
val isRated = (if (enabled) tempRating else rating100) >= (i * 20)
val icon = Icons.Filled.Star
var focused by remember { mutableStateOf(false) }
val focusedColor =
if (focused) {
MaterialTheme.colorScheme.border
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(focusedColor),
) {
Icon(
imageVector = icon,
tint = FilledStarColor,
contentDescription = null,
modifier =
if (enabled) {
Modifier
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) {
tempRating = i * 20
} else {
tempRating = rating100
}
}.playSoundOnFocus(playSoundOnFocus)
.focusRequester(focusRequesters[i - 1])
.focusProperties {
left =
if (i == 1) focusRequesters.last() else FocusRequester.Default
right =
if (i == 5) focusRequesters.first() else FocusRequester.Default
}.selectable(
selected = isRated,
onClick = {
if (playSoundOnFocus) playOnClickSound(context)
val newRating100 =
when (precision) {
StarRatingPrecision.FULL -> {
if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20
}
StarRatingPrecision.HALF -> {
if (rating100 > i * 20) {
i * 20
} else if (rating100 == i * 20) {
i * 20 - 10
} else if (rating100 == i * 20 - 10) {
(i - 1) * 20
} else if (i == 1 && rating100 == 0) {
20
} else if (i == 1 && rating100 > 10) {
10
} else if (i == 1 && rating100 <= 10) {
0
} else {
(i) * 20
}
}
StarRatingPrecision.QUARTER -> {
// TODO
null
}
}
if (newRating100 != null) {
tempRating = newRating100
onRatingChange(newRating100)
}
},
)
} else {
Modifier
}.fillMaxHeight()
.aspectRatio(1f),
)
}
}
}
}
}
}
@PreviewTvSpec
@Composable
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

@ -0,0 +1,147 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.layout.ContentScale
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.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.RatingBadge
import com.github.damontecres.wholphin.data.model.RatingEntry
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
/**
* Provides the [SeerrRatingsService] to [rememberSourceRatings]
*/
val LocalSeerrRatingsService =
staticCompositionLocalOf<SeerrRatingsService> { throw IllegalStateException("LocalSeerrRatingsService not set") }
/**
* The ratings for an item from the same sources as the PWA. This is the only data path for ratings in the UI, every
* screen that shows ratings calls this and draws the result with [SourceRatingsRow] (usually via [QuickDetails]).
*
* Which items have ratings is decided by [SeerrRatingsService]. Returns null while loading, or if there are none, and
* never the previous item's ratings.
*/
@Composable
fun rememberSourceRatings(item: BaseItem?): SourceRatings? {
val service = LocalSeerrRatingsService.current
val key = item?.id
var ratings by remember(key) { mutableStateOf(item?.let { service.cachedFor(it) }) }
LaunchedEffect(key) {
// Leaving the composition or changing the item cancels this collection, and so a Seerr request that is still waiting or running
service.forItem(item).collect { ratings = it }
}
return ratings
}
@DrawableRes
fun RatingBadge.drawable(): Int =
when (this) {
RatingBadge.RT_FRESH -> R.drawable.ic_rotten_tomatoes_fresh
RatingBadge.RT_ROTTEN -> R.drawable.ic_rotten_tomatoes_rotten
RatingBadge.RT_UPRIGHT -> R.drawable.ic_rt_upright
RatingBadge.RT_SPILLED -> R.drawable.ic_rt_spilled
RatingBadge.RT_UNKNOWN -> R.drawable.ic_rotten_tomatoes_fresh
RatingBadge.IMDB -> R.drawable.ic_rating_imdb
RatingBadge.TMDB -> R.drawable.ic_rating_tmdb
}
/**
* The badges for the ratings as inline text content. IMDb and TMDB are wordmarks, wider than tall like in the PWA.
*/
@Composable
private fun rememberBadges(textStyle: TextStyle): Map<String, InlineTextContent> =
remember(textStyle) {
val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
RatingBadge.entries.associate { badge ->
val muted = badge == RatingBadge.RT_UNKNOWN
val placeholder = if (badge == RatingBadge.IMDB || badge == RatingBadge.TMDB) wide else square
badge.name to
InlineTextContent(placeholder) {
Image(
painter = painterResource(badge.drawable()),
contentDescription = null,
contentScale = ContentScale.Fit,
alpha = if (muted) 0.55f else 1f,
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* The text for the ratings: each as a badge and its number, separated by dots
*
* @param leadingDot whether a dot is put in front of the first rating because something precedes it
*/
fun ratingsText(
entries: List<RatingEntry>,
leadingDot: Boolean,
): AnnotatedString =
buildAnnotatedString {
entries.forEachIndexed { index, entry ->
if (index > 0 || leadingDot) dot()
appendInlineContent(entry.badge.name)
append(" ")
append(entry.text)
}
}
/**
* The one way ratings are drawn: badge and number pairs from [SourceRatings] in the PWA's order, Rotten Tomatoes then IMDb or TMDB.
*
* Respects the community rating (IMDb/TMDB) and critic rating (Rotten Tomatoes) display settings. Draws nothing if
* there is nothing to show.
*/
@Composable
fun SourceRatingsRow(
ratings: SourceRatings?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
leadingDot: Boolean = true,
) {
if (ratings == null) return
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val community = DisplayToggle.COMMUNITY_RATING in enabled
val critic = DisplayToggle.CRITIC_RATING in enabled
val text =
remember(ratings, community, critic, leadingDot) {
ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot) }
} ?: return
val badges = rememberBadges(textStyle)
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
style = textStyle,
inlineContent = badges,
maxLines = 1,
modifier = modifier,
)
}

View file

@ -20,6 +20,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.jellyfinId
@ -27,7 +28,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -79,7 +80,7 @@ fun DiscoverMovieDetailsHeader(
) {
val resources = LocalResources.current
val details =
remember(movie, rating, resources) {
remember(movie, resources) {
buildList {
movie.releaseDate?.let(::add)
movie.runtime
@ -105,14 +106,11 @@ fun DiscoverMovieDetailsHeader(
?.takeIf { it.isNotNullOrBlank() }
?.let(::add)
}.let {
listToDotString(
it,
rating,
)
QuickDetailsData(listToDotString(it))
}
}
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
}

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
@ -55,7 +56,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -453,7 +454,7 @@ fun DiscoverSeriesDetailsHeader(
) {
val resources = LocalResources.current
val details =
remember(series, rating, resources) {
remember(series, resources) {
buildList {
series.firstAirDate?.let(::add)
series.episodeRunTime
@ -464,14 +465,11 @@ fun DiscoverSeriesDetailsHeader(
?.let { add(resources.formatDuration(it)) }
// TODO
}.let {
listToDotString(
it,
rating,
)
QuickDetailsData(listToDotString(it))
}
}
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
series.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
}

View file

@ -55,7 +55,7 @@ fun EpisodeDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 8.dp
QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime)
QuickDetails(ep, ep.timeRemainingOrRuntime)
VideoStreamDetails(
chosenStreams = chosenStreams,

View file

@ -339,7 +339,6 @@ fun MovieDetailsContent(
chosenStreams = state.chosenStreams,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
sourceRatings = state.sourceRatings,
modifier =
Modifier
.fillMaxWidth()

View file

@ -20,8 +20,6 @@ import androidx.tv.material3.Text
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.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.withSourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
@ -43,10 +41,8 @@ fun MovieDetailsHeader(
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
sourceRatings: SourceRatings? = null,
) {
val dto = movie.data
val quickDetails = remember(movie, sourceRatings) { movie.ui.quickDetails.withSourceRatings(sourceRatings) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
@ -68,7 +64,7 @@ fun MovieDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
quickDetails,
movie,
movie.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding),
)

View file

@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
@ -20,7 +19,6 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService
@ -59,7 +57,6 @@ class MovieViewModel
constructor(
private val api: ApiClient,
private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
@ -103,8 +100,6 @@ class MovieViewModel
return item
}
private var ratingsJob: Job? = null
fun init(): Job =
viewModelScope.launchDefault {
val movie =
@ -129,7 +124,6 @@ class MovieViewModel
chosenStreams = chosenStreams,
trailers = remoteTrailers,
chapters = chapters,
sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie),
)
}
backdropService.submit(movie)
@ -158,14 +152,6 @@ class MovieViewModel
)
}
}
// init() runs again on resume and the flow never completes, so don't stack collectors
ratingsJob?.cancel()
ratingsJob =
viewModelScope.launchIO {
ratingsService.forItem(movie).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
viewModelScope.launchIO {
val results = seerrService.similar(movie).orEmpty()
_state.update {
@ -320,7 +306,6 @@ data class MovieState(
val similar: List<BaseItem> = emptyList(),
val discovered: List<DiscoverItem> = emptyList(),
/** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null,
val canDelete: Boolean = false,
) {

View file

@ -35,10 +35,10 @@ fun FocusedEpisodeHeader(
) {
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
ep?.ui?.quickDetails?.let {
ep?.let {
QuickDetails(
it,
ep.timeRemainingOrRuntime,
it.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding),
)
}

View file

@ -44,10 +44,8 @@ import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.studioNames
import com.github.damontecres.wholphin.data.model.withSourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
@ -255,7 +253,6 @@ fun SeriesDetails(
}
},
discovered = state.discovered,
sourceRatings = state.sourceRatings,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
@ -333,7 +330,6 @@ fun SeriesDetailsContent(
extras: List<ExtrasItem>,
people: List<Person>,
discovered: List<DiscoverItem>,
sourceRatings: SourceRatings?,
played: Boolean,
favorite: Boolean,
canDelete: Boolean,
@ -381,7 +377,6 @@ fun SeriesDetailsContent(
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
overviewOnClick = overviewOnClick,
bringIntoViewRequester = bringIntoViewRequester,
sourceRatings = sourceRatings,
modifier =
Modifier
.fillMaxWidth()
@ -659,11 +654,9 @@ fun SeriesDetailsHeader(
overviewOnClick: () -> Unit,
bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier = Modifier,
sourceRatings: SourceRatings? = null,
) {
val scope = rememberCoroutineScope()
val dto = series.data
val quickDetails = remember(series, sourceRatings) { series.ui.quickDetails.withSourceRatings(sourceRatings) }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
@ -681,7 +674,7 @@ fun SeriesDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
quickDetails,
series,
null,
Modifier.padding(start = HeaderUtils.startPadding),
)

View file

@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
@ -21,7 +20,6 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService
@ -102,7 +100,6 @@ class SeriesViewModel
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
private val mediaManagementService: MediaManagementService,
@Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@ -221,7 +218,6 @@ class SeriesViewModel
seasons = seasons,
episodes = episodes,
trailers = remoteTrailers,
sourceRatings = ratingsService.cachedFor(series),
)
}
@ -255,11 +251,6 @@ class SeriesViewModel
_state.update { it.copy(similar = similar) }
}
}
viewModelScope.launchIO {
ratingsService.forItem(series).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
viewModelScope.launchIO {
val results = seerrService.similar(series).orEmpty()
_state.update { it.copy(discovered = results) }
@ -905,6 +896,5 @@ data class SeriesState(
val discovered: List<DiscoverItem> = emptyList(),
val discoverSeries: DiscoverItem? = null,
/** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null,
)

View file

@ -389,7 +389,7 @@ fun SeerrDiscoverPage(
modifier = modifier,
) {
val details =
remember(focusedItem, ratingMap) {
remember(focusedItem) {
(focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
buildList {
focusedItem.item
@ -398,17 +398,18 @@ fun SeerrDiscoverPage(
?.toString()
?.let(::add)
}.let {
val rating = ratingMap[focusedItem.item.type to focusedItem.id]
QuickDetailsData(listToDotString(it, rating))
QuickDetailsData(listToDotString(it))
}
}
}
val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] }
HomePageHeader(
title = focusedItem?.title?.getString(),
subtitle = focusedItem?.subtitle,
overview = focusedItem?.overview,
overviewTwoLines = false,
quickDetails = details,
ratings = focusedRatings,
timeRemaining = null,
endsAt = null,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,

View file

@ -52,6 +52,7 @@ 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.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
@ -72,6 +73,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
import com.github.damontecres.wholphin.ui.components.rememberSourceRatings
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -571,6 +573,7 @@ fun HomePageHeader(
overview = item?.data?.overview,
overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails,
ratings = rememberSourceRatings(item),
timeRemaining = item?.timeRemainingOrRuntime,
endsAt = item?.data?.endDate,
showLogo = showLogo,
@ -586,6 +589,7 @@ fun HomePageHeader(
overview: String?,
overviewTwoLines: Boolean,
quickDetails: QuickDetailsData?,
ratings: SourceRatings?,
timeRemaining: Duration?,
endsAt: DateTime?,
showLogo: Boolean,
@ -611,7 +615,7 @@ fun HomePageHeader(
if (subtitle != null) {
EpisodeName(subtitle)
}
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt)
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings)
val overviewModifier =
Modifier
.padding(0.dp)