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

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString 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.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@ -234,26 +232,6 @@ data class BaseItem(
append(it) 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( data class QuickDetailsData(
val basic: AnnotatedString, val basic: AnnotatedString,
val officialRating: AnnotatedString? = null, 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 package com.github.damontecres.wholphin.data.model
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Immutable 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 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 @Immutable
sealed interface RatingEntry { sealed interface RatingEntry {
val text: String val text: String
val badge: RatingBadge
/**
* The id of the inline content (see [com.github.damontecres.wholphin.ui.components.rememberQuickDetailsContentMap]) for this entry's badge
*/
val inlineContentId: String
data class RottenTomatoes( data class RottenTomatoes(
val score: RtScore, val score: RtScore,
@ -60,14 +67,14 @@ sealed interface RatingEntry {
val critics: Boolean, val critics: Boolean,
) : RatingEntry { ) : RatingEntry {
override val text: String get() = "${score.percent}%" override val text: String get() = "${score.percent}%"
override val inlineContentId: String override val badge: RatingBadge
get() = get() =
when (score.mark) { when (score.mark) {
RtMark.FRESH -> INLINE_RT_FRESH RtMark.FRESH -> RatingBadge.RT_FRESH
RtMark.ROTTEN -> INLINE_RT_ROTTEN RtMark.ROTTEN -> RatingBadge.RT_ROTTEN
RtMark.UPRIGHT -> INLINE_RT_UPRIGHT RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT
RtMark.SPILLED -> INLINE_RT_SPILLED RtMark.SPILLED -> RatingBadge.RT_SPILLED
RtMark.UNKNOWN -> INLINE_RT_UNKNOWN RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN
} }
} }
@ -75,25 +82,17 @@ sealed interface RatingEntry {
val score: Double, val score: Double,
) : RatingEntry { ) : RatingEntry {
override val text: String get() = formatScore(score) override val text: String get() = formatScore(score)
override val inlineContentId: String get() = INLINE_IMDB override val badge: RatingBadge get() = RatingBadge.IMDB
} }
data class Tmdb( data class Tmdb(
val score: Double, val score: Double,
) : RatingEntry { ) : RatingEntry {
override val text: String get() = formatScore(score) 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) private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score)
/** /**
@ -131,6 +130,16 @@ data class SourceRatings(
tmdb?.let { RatingEntry.Tmdb(it) }, 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 { companion object {
const val MAX_PERCENT = 100.0 const val MAX_PERCENT = 100.0
const val MAX_SCORE = 10.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 package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem 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.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.WholphinDispatchers import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -58,7 +58,7 @@ class LatestNextUpService
val request = val request =
GetResumeItemsRequest( GetResumeItemsRequest(
userId = userId, userId = userId,
fields = SlimItemFields, fields = HomeSlimItemFields,
limit = limit, limit = limit,
mediaTypes = listOf(MediaType.VIDEO), mediaTypes = listOf(MediaType.VIDEO),
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
@ -90,7 +90,7 @@ class LatestNextUpService
val request = val request =
GetNextUpRequest( GetNextUpRequest(
userId = userId, userId = userId,
fields = SlimItemFields, fields = HomeSlimItemFields,
imageTypeLimit = 1, imageTypeLimit = 1,
parentId = null, parentId = null,
limit = limit, limit = limit,

View file

@ -9,12 +9,19 @@ import com.github.damontecres.wholphin.data.model.SourceRatings
import com.google.common.cache.CacheBuilder import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest 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 org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
import java.util.Optional
import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 * 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. * `voteAverage`/`voteCount` and is only requested when no other source has a score.
@ -41,6 +63,7 @@ class SeerrRatingsService
@Inject @Inject
constructor( constructor(
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val api: ApiClient,
) { ) {
private data class Key( private data class Key(
val type: SeerrItemType, val type: SeerrItemType,
@ -54,43 +77,118 @@ class SeerrRatingsService
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<Key, SourceRatings>() .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( fun cached(
type: SeerrItemType, type: SeerrItemType,
tmdbId: Int, tmdbId: Int,
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId)) ): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
private fun target(item: BaseItem): Pair<SeerrItemType, Int>? { /**
val type = * The Jellyfin item whose provider ids give the TMDB id, null if the item never has ratings.
when (item.type) { * An episode's or season's own provider ids are for the episode or season, so those use the series.
BaseItemKind.MOVIE -> SeerrItemType.MOVIE */
BaseItemKind.SERIES -> SeerrItemType.TV private fun ownerId(item: BaseItem): UUID? =
else -> return null when (item.type) {
} BaseItemKind.MOVIE, BaseItemKind.SERIES -> item.id
val tmdbId = BaseItemKind.SEASON, BaseItemKind.EPISODE -> item.data.seriesId
item.data.providerIds else -> null
?.get("Tmdb") }
?.toIntOrNull() ?: return null
return type to tmdbId /**
* 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 * Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand.
* doesn't show the Jellyfin values first * 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 * The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty
* 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.
*/ */
fun forItem(item: BaseItem): Flow<SourceRatings?> { fun cachedFor(item: BaseItem): SourceRatings? = knownTarget(item)?.let { cached(it.type, it.tmdbId) }
val (type, tmdbId) = target(item) ?: return flowOf(null)
return seerrService.active /**
.distinctUntilChanged() * Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established
.mapLatest { active -> if (active) get(type, tmdbId) else null } * 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 { companion object {
private const val CACHE_SIZE = 300L private const val CACHE_SIZE = 300L
private const val CACHE_HOURS = 6L 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 androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication 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.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
@ -233,19 +231,12 @@ val MediaSegmentType.skipStringRes: Int
fun AnnotatedString.Builder.dot() = append(" \u2022 ") fun AnnotatedString.Builder.dot() = append(" \u2022 ")
fun listToDotString( fun listToDotString(strings: List<String>): AnnotatedString =
strings: List<String>,
ratings: SourceRatings?,
): AnnotatedString =
buildAnnotatedString { buildAnnotatedString {
strings.forEachIndexed { index, string -> strings.forEachIndexed { index, string ->
append(string) append(string)
if (index != strings.lastIndex) dot() if (index != strings.lastIndex) dot()
} }
ratings?.let {
appendRatings(it.criticEntries)
appendRatings(it.communityEntries)
}
} }
@get:StringRes @get:StringRes

View file

@ -75,12 +75,22 @@ val SlimItemFields =
*/ */
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO 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 = val HomeItemFields =
listOf( listOf(
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.CAN_DELETE, 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 = val PhotoItemFields =
DetailItemFields + DetailItemFields +
listOf( listOf(

View file

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

View file

@ -1,41 +1,24 @@
package com.github.damontecres.wholphin.ui.components 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.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.Composable
import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString 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.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.INLINE_IMDB import com.github.damontecres.wholphin.data.model.BaseItem
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.QuickDetailsData 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.preferences.DisplayToggle
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.formatTime
import com.github.damontecres.wholphin.ui.util.LocalClock 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 org.jellyfin.sdk.model.DateTime
import kotlin.time.Duration 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 @Composable
fun QuickDetails( fun QuickDetails(
details: QuickDetailsData?, details: QuickDetailsData?,
@ -50,24 +39,16 @@ fun QuickDetails(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall, textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null, endsAt: DateTime? = null,
ratings: SourceRatings? = null,
) { ) {
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
Row(modifier = modifier) { Row(modifier = modifier) {
if (details != null) { if (details != null) {
QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap) QuickDetailsText(details.basic, Modifier, textStyle)
if (DisplayToggle.OFFICIAL_RATING in enabled) { if (DisplayToggle.OFFICIAL_RATING in enabled) {
QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap) QuickDetailsText(details.officialRating, Modifier, textStyle)
}
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)
} }
SourceRatingsRow(ratings, Modifier, textStyle)
} }
if (timeRemaining != null) { if (timeRemaining != null) {
TimeRemaining(timeRemaining, textStyle = textStyle) 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 @NonRestartableComposable
@Composable @Composable
fun QuickDetailsText( fun QuickDetailsText(
str: AnnotatedString?, str: AnnotatedString?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall, textStyle: TextStyle = MaterialTheme.typography.titleSmall,
inlineContentMap: Map<String, InlineTextContent> = rememberQuickDetailsContentMap(textStyle),
) = str?.let { ) = str?.let {
Text( Text(
text = str, text = str,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
style = textStyle, style = textStyle,
inlineContent = inlineContentMap,
maxLines = 1, maxLines = 1,
modifier = modifier, 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 @Composable
fun TimeRemaining( fun TimeRemaining(
remaining: Duration, remaining: Duration,

View file

@ -1,93 +1,29 @@
package com.github.damontecres.wholphin.ui.components 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.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.Icons
import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.compose.ui.unit.dp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.LocalTextStyle import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.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 FilledStarColor = Color(0xFFFFC700)
val EmptyStarColor = Color(0xFF5D4A0A) val EmptyStarColor = Color(0xFF5D4A0A)
val ratingBarHeight: Dp = 32.dp /**
* A minimum rating such as "7+" with a star, for the rating filter menus.
@Composable *
fun SimpleStarRating( * This is not for showing a title's rating, see [SourceRatingsRow] for that.
communityRating: Float?, */
modifier: Modifier = Modifier,
) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
SimpleStarRating(
text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) },
modifier = modifier,
)
}
@Composable @Composable
fun SimpleStarRating( fun SimpleStarRating(
text: String?, 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 androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails 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.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.jellyfinId 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.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText 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.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -79,7 +80,7 @@ fun DiscoverMovieDetailsHeader(
) { ) {
val resources = LocalResources.current val resources = LocalResources.current
val details = val details =
remember(movie, rating, resources) { remember(movie, resources) {
buildList { buildList {
movie.releaseDate?.let(::add) movie.releaseDate?.let(::add)
movie.runtime movie.runtime
@ -105,14 +106,11 @@ fun DiscoverMovieDetailsHeader(
?.takeIf { it.isNotNullOrBlank() } ?.takeIf { it.isNotNullOrBlank() }
?.let(::add) ?.let(::add)
}.let { }.let {
listToDotString( QuickDetailsData(listToDotString(it))
it,
rating,
)
} }
} }
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
movie.genres?.mapNotNull { it.name }?.letNotEmpty { movie.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) 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.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem 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.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission 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.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText 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.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -453,7 +454,7 @@ fun DiscoverSeriesDetailsHeader(
) { ) {
val resources = LocalResources.current val resources = LocalResources.current
val details = val details =
remember(series, rating, resources) { remember(series, resources) {
buildList { buildList {
series.firstAirDate?.let(::add) series.firstAirDate?.let(::add)
series.episodeRunTime series.episodeRunTime
@ -464,14 +465,11 @@ fun DiscoverSeriesDetailsHeader(
?.let { add(resources.formatDuration(it)) } ?.let { add(resources.formatDuration(it)) }
// TODO // TODO
}.let { }.let {
listToDotString( QuickDetailsData(listToDotString(it))
it,
rating,
)
} }
} }
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
series.genres?.mapNotNull { it.name }?.letNotEmpty { series.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
} }

View file

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

View file

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

View file

@ -20,8 +20,6 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.HeaderUtils
@ -43,10 +41,8 @@ fun MovieDetailsHeader(
bringIntoViewRequester: BringIntoViewRequester, bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sourceRatings: SourceRatings? = null,
) { ) {
val dto = movie.data val dto = movie.data
val quickDetails = remember(movie, sourceRatings) { movie.ui.quickDetails.withSourceRatings(sourceRatings) }
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(
@ -68,7 +64,7 @@ fun MovieDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f), modifier = Modifier.fillMaxWidth(.60f),
) { ) {
QuickDetails( QuickDetails(
quickDetails, movie,
movie.timeRemainingOrRuntime, movie.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding), 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.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person 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.Trailer
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService 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.MediaManagementService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.SeerrService
import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
@ -59,7 +57,6 @@ class MovieViewModel
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager, private val navigationManager: NavigationManager,
val serverRepository: ServerRepository, val serverRepository: ServerRepository,
@ -103,8 +100,6 @@ class MovieViewModel
return item return item
} }
private var ratingsJob: Job? = null
fun init(): Job = fun init(): Job =
viewModelScope.launchDefault { viewModelScope.launchDefault {
val movie = val movie =
@ -129,7 +124,6 @@ class MovieViewModel
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
trailers = remoteTrailers, trailers = remoteTrailers,
chapters = chapters, chapters = chapters,
sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie),
) )
} }
backdropService.submit(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 { viewModelScope.launchIO {
val results = seerrService.similar(movie).orEmpty() val results = seerrService.similar(movie).orEmpty()
_state.update { _state.update {
@ -320,7 +306,6 @@ data class MovieState(
val similar: List<BaseItem> = emptyList(), val similar: List<BaseItem> = emptyList(),
val discovered: List<DiscoverItem> = emptyList(), val discovered: List<DiscoverItem> = emptyList(),
/** Seerr ratings, null until they load or if unavailable */ /** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null, val chosenStreams: ChosenStreams? = null,
val canDelete: Boolean = false, val canDelete: Boolean = false,
) { ) {

View file

@ -35,10 +35,10 @@ fun FocusedEpisodeHeader(
) { ) {
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding)) EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
ep?.ui?.quickDetails?.let { ep?.let {
QuickDetails( QuickDetails(
it, it,
ep.timeRemainingOrRuntime, it.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding), 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.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person 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.Trailer
import com.github.damontecres.wholphin.data.model.studioNames 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
@ -255,7 +253,6 @@ fun SeriesDetails(
} }
}, },
discovered = state.discovered, discovered = state.discovered,
sourceRatings = state.sourceRatings,
onClickDiscover = { index, item -> onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination) viewModel.navigateTo(item.destination)
}, },
@ -333,7 +330,6 @@ fun SeriesDetailsContent(
extras: List<ExtrasItem>, extras: List<ExtrasItem>,
people: List<Person>, people: List<Person>,
discovered: List<DiscoverItem>, discovered: List<DiscoverItem>,
sourceRatings: SourceRatings?,
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
canDelete: Boolean, canDelete: Boolean,
@ -381,7 +377,6 @@ fun SeriesDetailsContent(
showLogo = preferences.appPreferences.interfacePreferences.showLogos, showLogo = preferences.appPreferences.interfacePreferences.showLogos,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
bringIntoViewRequester = bringIntoViewRequester, bringIntoViewRequester = bringIntoViewRequester,
sourceRatings = sourceRatings,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -659,11 +654,9 @@ fun SeriesDetailsHeader(
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
bringIntoViewRequester: BringIntoViewRequester, bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sourceRatings: SourceRatings? = null,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val dto = series.data val dto = series.data
val quickDetails = remember(series, sourceRatings) { series.ui.quickDetails.withSourceRatings(sourceRatings) }
Column( Column(
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier, modifier = modifier,
@ -681,7 +674,7 @@ fun SeriesDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f), modifier = Modifier.fillMaxWidth(.60f),
) { ) {
QuickDetails( QuickDetails(
quickDetails, series,
null, null,
Modifier.padding(start = HeaderUtils.startPadding), 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.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person 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.Trailer
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService 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.MediaManagementService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.SeerrService
import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
@ -102,7 +100,6 @@ class SeriesViewModel
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
private val mediaManagementService: MediaManagementService, private val mediaManagementService: MediaManagementService,
@Assisted val seriesId: UUID, @Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@ -221,7 +218,6 @@ class SeriesViewModel
seasons = seasons, seasons = seasons,
episodes = episodes, episodes = episodes,
trailers = remoteTrailers, trailers = remoteTrailers,
sourceRatings = ratingsService.cachedFor(series),
) )
} }
@ -255,11 +251,6 @@ class SeriesViewModel
_state.update { it.copy(similar = similar) } _state.update { it.copy(similar = similar) }
} }
} }
viewModelScope.launchIO {
ratingsService.forItem(series).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
viewModelScope.launchIO { viewModelScope.launchIO {
val results = seerrService.similar(series).orEmpty() val results = seerrService.similar(series).orEmpty()
_state.update { it.copy(discovered = results) } _state.update { it.copy(discovered = results) }
@ -905,6 +896,5 @@ data class SeriesState(
val discovered: List<DiscoverItem> = emptyList(), val discovered: List<DiscoverItem> = emptyList(),
val discoverSeries: DiscoverItem? = null, val discoverSeries: DiscoverItem? = null,
/** Seerr ratings, null until they load or if unavailable */ /** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null, val chosenStreams: ChosenStreams? = null,
) )

View file

@ -389,7 +389,7 @@ fun SeerrDiscoverPage(
modifier = modifier, modifier = modifier,
) { ) {
val details = val details =
remember(focusedItem, ratingMap) { remember(focusedItem) {
(focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
buildList { buildList {
focusedItem.item focusedItem.item
@ -398,17 +398,18 @@ fun SeerrDiscoverPage(
?.toString() ?.toString()
?.let(::add) ?.let(::add)
}.let { }.let {
val rating = ratingMap[focusedItem.item.type to focusedItem.id] QuickDetailsData(listToDotString(it))
QuickDetailsData(listToDotString(it, rating))
} }
} }
} }
val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] }
HomePageHeader( HomePageHeader(
title = focusedItem?.title?.getString(), title = focusedItem?.title?.getString(),
subtitle = focusedItem?.subtitle, subtitle = focusedItem?.subtitle,
overview = focusedItem?.overview, overview = focusedItem?.overview,
overviewTwoLines = false, overviewTwoLines = false,
quickDetails = details, quickDetails = details,
ratings = focusedRatings,
timeRemaining = null, timeRemaining = null,
endsAt = null, endsAt = null,
showLogo = preferences.appPreferences.interfacePreferences.showLogos, 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.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.QuickDetailsData 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard 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.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl 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.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -571,6 +573,7 @@ fun HomePageHeader(
overview = item?.data?.overview, overview = item?.data?.overview,
overviewTwoLines = isEpisode, overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails, quickDetails = item?.ui?.quickDetails,
ratings = rememberSourceRatings(item),
timeRemaining = item?.timeRemainingOrRuntime, timeRemaining = item?.timeRemainingOrRuntime,
endsAt = item?.data?.endDate, endsAt = item?.data?.endDate,
showLogo = showLogo, showLogo = showLogo,
@ -586,6 +589,7 @@ fun HomePageHeader(
overview: String?, overview: String?,
overviewTwoLines: Boolean, overviewTwoLines: Boolean,
quickDetails: QuickDetailsData?, quickDetails: QuickDetailsData?,
ratings: SourceRatings?,
timeRemaining: Duration?, timeRemaining: Duration?,
endsAt: DateTime?, endsAt: DateTime?,
showLogo: Boolean, showLogo: Boolean,
@ -611,7 +615,7 @@ fun HomePageHeader(
if (subtitle != null) { if (subtitle != null) {
EpisodeName(subtitle) EpisodeName(subtitle)
} }
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt) QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings)
val overviewModifier = val overviewModifier =
Modifier Modifier
.padding(0.dp) .padding(0.dp)

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@ -104,44 +102,59 @@ class SourceRatingsTest {
imdbVotes = 1, imdbVotes = 1,
) )
assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text }) assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text })
assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId }) assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge })
assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text }) assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text })
assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId }) assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge })
} }
private val jellyfinDetails = @Test
QuickDetailsData( fun everyRtMarkHasItsOwnBadge() {
basic = AnnotatedString("2020"), val badges =
communityRating = AnnotatedString("7.8 jellyfin"), listOf(
criticRating = AnnotatedString("59% jellyfin"), RtMark.FRESH to RatingBadge.RT_FRESH,
RtMark.ROTTEN to RatingBadge.RT_ROTTEN,
RtMark.UPRIGHT to RatingBadge.RT_UPRIGHT,
RtMark.SPILLED to RatingBadge.RT_SPILLED,
RtMark.UNKNOWN to RatingBadge.RT_UNKNOWN,
)
badges.forEach { (mark, badge) ->
assertEquals(badge, RatingEntry.RottenTomatoes(RtScore(50, mark), true).badge)
}
}
private val movie =
SourceRatings.of(
rtCriticsScore = 96,
rtCriticsLabel = "Certified Fresh",
rtAudienceScore = 59,
rtAudienceLabel = "Spilled",
imdbScore = 8.8,
imdbVotes = 10,
) )
@Test @Test
fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() { fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() {
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null)) // Rotten Tomatoes (critic rating setting) before IMDb (community rating setting)
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings())) assertEquals(
listOf("96%", "59%", "8.8"),
movie.visibleEntries(community = true, critic = true).map { it.text },
)
assertEquals(listOf("8.8"), movie.visibleEntries(community = true, critic = false).map { it.text })
assertEquals(listOf("96%", "59%"), movie.visibleEntries(community = false, critic = true).map { it.text })
assertTrue(movie.visibleEntries(community = false, critic = false).isEmpty())
} }
@Test @Test
fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() { fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() {
val details = val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true)
jellyfinDetails.withSourceRatings( assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge })
SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10), assertEquals(listOf("7.6"), entries.map { it.text })
)
// COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes
assertTrue(details.communityRating!!.text.endsWith("8.8"))
assertTrue(details.criticRating!!.text.endsWith("96%"))
assertEquals(jellyfinDetails.basic, details.basic)
// PWA order: Rotten Tomatoes before IMDb
assertTrue(details.criticRatingFirst)
assertFalse(jellyfinDetails.criticRatingFirst)
} }
@Test @Test
fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() { fun noEntriesWithoutRatings() {
// Seerr has only RT: the Jellyfin star is not shown next to it assertTrue(SourceRatings().isEmpty)
val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh")) assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty())
assertNull(details.communityRating) assertFalse(movie.isEmpty)
assertTrue(details.criticRating!!.text.endsWith("50%"))
} }
} }

View file

@ -0,0 +1,268 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.MoviesApi
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.TvApi
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.ui.successResponse
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.IOException
import java.util.UUID
/**
* Which Jellyfin items have ratings, how their TMDB id is found, and when Seerr is asked
*/
class SeerrRatingsItemsTest {
private val moviesApi = mockk<MoviesApi>()
private val tvApi = mockk<TvApi>()
private val seerrApi =
mockk<SeerrApiClient> {
every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi
every { tvApi } returns this@SeerrRatingsItemsTest.tvApi
}
private val active = MutableStateFlow(true)
private val seerrService =
mockk<SeerrService> {
every { api } returns seerrApi
every { this@mockk.active } returns this@SeerrRatingsItemsTest.active
}
private val userLibraryApi = mockk<UserLibraryApi>()
private val jellyfin =
mockk<ApiClient> {
every { userLibraryApi } returns this@SeerrRatingsItemsTest.userLibraryApi
}
private val service = SeerrRatingsService(seerrService, jellyfin)
private val seriesId = UUID.randomUUID()
private val seriesTmdb = 1396
private val rtOnly = MovieMovieIdRatingscombinedGet200Response(rt = MovieMovieIdRatingsGet200Response(criticsScore = 90, criticsRating = "Fresh"))
private val tvRatings = TvTvIdRatingsGet200Response(criticsScore = 80, criticsRating = "Fresh")
private fun item(
type: BaseItemKind,
providerIds: Map<String, String>? = null,
seriesId: UUID? = null,
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds, seriesId = seriesId))
private fun jellyfinHasSeries(tmdb: Int? = seriesTmdb) {
coEvery { userLibraryApi.getItem(seriesId) } returns
successResponse(
BaseItemDto(
id = seriesId,
type = BaseItemKind.SERIES,
providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) } ?: mapOf("Tvdb" to "1"),
),
)
}
@Test
fun moviesAndSeriesUseTheirOwnProviderIdsWithoutAskingJellyfin() =
runTest {
assertEquals(
RatingsTarget(SeerrItemType.MOVIE, 11),
service.resolveTarget(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "11"))),
)
assertEquals(
RatingsTarget(SeerrItemType.TV, 22),
service.resolveTarget(item(BaseItemKind.SERIES, mapOf("Tmdb" to "22"))),
)
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
}
@Test
fun episodesAndSeasonsShowTheirSeriesRatingsNotTheirOwnTmdbId() =
runTest {
jellyfinHasSeries()
// The Tmdb id on an episode or season identifies the episode or season, never the series
val episode = item(BaseItemKind.EPISODE, mapOf("Tmdb" to "999999"), seriesId)
val season = item(BaseItemKind.SEASON, mapOf("Tmdb" to "888888"), seriesId)
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode))
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(season))
}
@Test
fun itemsThatArePartOfNoPwaTitleHaveNoRatingsAndNeverCallAnything() =
runTest {
val none =
listOf(
BaseItemKind.BOX_SET,
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.AUDIO,
BaseItemKind.PHOTO,
BaseItemKind.PHOTO_ALBUM,
BaseItemKind.PROGRAM,
BaseItemKind.PLAYLIST,
BaseItemKind.VIDEO,
)
none.forEach {
val item = item(it, mapOf("Tmdb" to "5"), seriesId)
assertNull(it.name, service.resolveTarget(item))
assertNull(it.name, service.forItem(item).first())
assertNull(it.name, service.cachedFor(item))
}
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
coVerify(exactly = 0) { tvApi.tvTvIdRatingsGet(any()) }
}
@Test
fun episodeWithoutASeriesHasNoRatings() =
runTest {
assertNull(service.resolveTarget(item(BaseItemKind.EPISODE, mapOf("Tmdb" to "3"), null)))
}
@Test
fun homeItemsWithoutProviderIdsAreResolvedThroughJellyfinOnceAndCached() =
runTest {
jellyfinHasSeries()
val first = item(BaseItemKind.EPISODE, null, seriesId)
val second = item(BaseItemKind.EPISODE, null, seriesId)
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(first))
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(second))
// every other episode of the series is known without a request
coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) }
}
@Test
fun homeMovieWithoutProviderIdsLooksItUp() =
runTest {
val movie = item(BaseItemKind.MOVIE, null)
coEvery { userLibraryApi.getItem(movie.id) } returns
successResponse(BaseItemDto(id = movie.id, type = BaseItemKind.MOVIE, providerIds = mapOf("Tmdb" to "77")))
assertEquals(RatingsTarget(SeerrItemType.MOVIE, 77), service.resolveTarget(movie))
}
@Test
fun seriesWithoutTmdbIdHasNoRatingsAndIsNotLookedUpAgain() =
runTest {
jellyfinHasSeries(tmdb = null)
val episode = item(BaseItemKind.EPISODE, null, seriesId)
assertNull(service.resolveTarget(episode))
assertNull(service.forItem(episode).first())
coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) }
}
@Test
fun failedLookupIsRetriedLater() =
runTest {
coEvery { userLibraryApi.getItem(seriesId) } throws IOException("down")
val episode = item(BaseItemKind.EPISODE, null, seriesId)
assertNull(service.resolveTarget(episode))
jellyfinHasSeries()
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode))
}
@Test
fun episodeRatingsAreTheSeriesRatingsAndSeedLaterEpisodes() =
runTest {
jellyfinHasSeries()
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings
val episode = item(BaseItemKind.EPISODE, null, seriesId)
val other = item(BaseItemKind.EPISODE, null, seriesId)
assertNull(service.cachedFor(episode))
val ratings = service.forItem(episode).first()
assertEquals(80, ratings?.rtCritics?.percent)
// moving to another episode of the same series needs no wait and no request
assertEquals(ratings, service.cachedFor(other))
assertEquals(ratings, service.forItem(other).first())
coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) }
}
@Test
fun scrollingPastItemsNeverAsksSeerr() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns rtOnly
val jobs =
(1..20).map { id ->
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "$id"))).collect { } }
runCurrent()
// focus moves on before the wait has passed
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50)
job.cancel()
job
}
jobs.forEach(Job::cancel)
runCurrent()
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
}
@Test
fun restingOnAnItemAsksSeerrOnceAfterTheWait() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } returns rtOnly
var result: Any? = "unset"
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "7"))).collect { result = it } }
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 1)
runCurrent()
assertEquals("unset", result)
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
advanceTimeBy(2)
runCurrent()
assertNotNull(result)
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) }
job.cancel()
}
@Test
fun movingOnWhileTheRequestIsRunningCancelsIt() =
runTest {
var cancelled = false
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } coAnswers {
try {
awaitCancellation()
} finally {
cancelled = true
}
}
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "8"))).collect { } }
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS + 1)
runCurrent()
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) }
job.cancel()
runCurrent()
assertEquals(true, cancelled)
assertNull(service.cached(SeerrItemType.MOVIE, 8))
}
}

View file

@ -21,6 +21,7 @@ import io.mockk.mockk
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -45,7 +46,7 @@ class SeerrRatingsServiceTest {
every { api } returns apiClient every { api } returns apiClient
every { active } answers { activeFlow } every { active } answers { activeFlow }
} }
private val service = SeerrRatingsService(seerrService) private val service = SeerrRatingsService(seerrService, mockk<ApiClient>())
private fun movieDetails( private fun movieDetails(
average: Double?, average: Double?,
@ -253,8 +254,7 @@ class SeerrRatingsServiceTest {
// no TMDB id // no TMDB id
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first()) assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first())
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first()) assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first())
// not a movie/series // not something that has ratings
assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first())
assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first()) assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first())
// call fails // call fails
assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first()) assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first())

View file

@ -0,0 +1,239 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.github.damontecres.wholphin.api.seerr.MoviesApi
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.TvApi
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem
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.services.SeerrService
import com.github.damontecres.wholphin.ui.successResponse
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.operations.UserLibraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.EnumSet
import java.util.UUID
/**
* What every surface shows: the rating row comes only from Seerr, per the PWA's rules for the kind of item, and
* the Jellyfin rating of an item is never drawn
*/
@Config(sdk = [34])
@RunWith(RobolectricTestRunner::class)
class SourceRatingsRowTest {
@get:Rule val rule = createComposeRule()
private val moviesApi = mockk<MoviesApi>()
private val tvApi = mockk<TvApi>()
private val seerrApi =
mockk<SeerrApiClient> {
every { moviesApi } returns this@SourceRatingsRowTest.moviesApi
every { tvApi } returns this@SourceRatingsRowTest.tvApi
}
private val seerrService =
mockk<SeerrService> {
every { api } returns seerrApi
every { active } returns flowOf(true)
}
private val userLibraryApi = mockk<UserLibraryApi>()
private val jellyfin = mockk<ApiClient> { every { userLibraryApi } returns this@SourceRatingsRowTest.userLibraryApi }
private val service = SeerrRatingsService(seerrService, jellyfin)
private val seriesId = UUID.randomUUID()
private val seriesTmdb = 1396
/** Every item carries Jellyfin ratings that must never be drawn */
private fun item(
type: BaseItemKind,
tmdb: Int? = null,
seriesId: UUID? = null,
) = BaseItem(
BaseItemDto(
id = UUID.randomUUID(),
type = type,
name = "Title",
providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) },
seriesId = seriesId,
communityRating = 7.8f,
criticRating = 59f,
),
)
private fun movieRatings() {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns
MovieMovieIdRatingscombinedGet200Response(
rt = MovieMovieIdRatingsGet200Response(criticsScore = 96, criticsRating = "Certified Fresh", audienceScore = 90, audienceRating = "Upright"),
imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 10),
)
}
private fun seriesRatings() {
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns
TvTvIdRatingsGet200Response(criticsScore = 81, criticsRating = "Fresh", audienceScore = 45, audienceRating = "Spilled")
coEvery { userLibraryApi.getItem(seriesId) } returns
successResponse(BaseItemDto(id = seriesId, type = BaseItemKind.SERIES, providerIds = mapOf("Tmdb" to "$seriesTmdb")))
}
/**
* Shows the quick details of the items, after what a screen's wait for Seerr would have loaded
*/
private fun show(
vararg items: BaseItem,
toggles: EnumSet<DisplayToggle> = EnumSet.allOf(DisplayToggle::class.java),
) {
runBlocking {
items.forEach { service.forItem(it, 0).collect { } }
}
rule.setContent {
WholphinTheme {
CompositionLocalProvider(
LocalSeerrRatingsService provides service,
LocalInterfaceCustomization provides InterfaceCustomization(toggles),
) {
Column {
items.forEach { QuickDetails(it, null) }
}
}
}
}
rule.waitForIdle()
}
private fun assertShown(text: String) = rule.onNodeWithText(text, substring = true).assertExists()
private fun assertNotShown(text: String) = rule.onNodeWithText(text, substring = true).assertDoesNotExist()
private fun assertJellyfinRatingNotShown() {
assertNotShown("7.8")
assertNotShown("59%")
}
@Test
fun movieShowsRottenTomatoesThenImdbAndNotItsJellyfinRating() {
movieRatings()
show(item(BaseItemKind.MOVIE, 603))
assertShown("96%")
assertShown("90%")
assertShown("8.8")
assertJellyfinRatingNotShown()
}
@Test
fun seriesShowsItsRatings() {
seriesRatings()
show(item(BaseItemKind.SERIES, seriesTmdb))
assertShown("81%")
assertShown("45%")
assertJellyfinRatingNotShown()
}
@Test
fun episodeAndSeasonShowTheirSeriesRatings() {
seriesRatings()
show(item(BaseItemKind.EPISODE, seriesId = seriesId))
assertShown("81%")
assertJellyfinRatingNotShown()
}
@Test
fun seasonShowsItsSeriesRatings() {
seriesRatings()
show(item(BaseItemKind.SEASON, seriesId = seriesId))
assertShown("45%")
assertJellyfinRatingNotShown()
}
@Test
fun surfacesWithoutAPwaEquivalentShowNoRatingNotTheJellyfinStar() {
movieRatings()
seriesRatings()
show(
*listOf(
BaseItemKind.BOX_SET,
BaseItemKind.MUSIC_ALBUM,
BaseItemKind.MUSIC_ARTIST,
BaseItemKind.PHOTO,
BaseItemKind.PROGRAM,
).map { item(it, 603, seriesId) }.toTypedArray(),
)
assertNotShown("96%")
assertNotShown("81%")
assertJellyfinRatingNotShown()
}
@Test
fun titleSeerrHasNoRatingsForShowsNoRatingNotTheJellyfinStar() {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns MovieMovieIdRatingscombinedGet200Response()
coEvery { moviesApi.movieMovieIdGet(movieId = 5) } returns
mockk {
every { voteAverage } returns null
every { voteCount } returns null
}
show(item(BaseItemKind.MOVIE, 5))
assertJellyfinRatingNotShown()
}
@Test
fun theDisplaySettingsStillControlWhichSourcesAreDrawn() {
movieRatings()
show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING))
assertShown("8.8")
assertNotShown("96%")
}
@Test
fun criticSettingHidesImdb() {
movieRatings()
show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING))
assertShown("96%")
assertNotShown("8.8")
}
@Test
fun ratingsTextHasOneBadgeAndNumberPerEntryInOrder() {
val ratings =
SourceRatings.of(
rtCriticsScore = 96,
rtCriticsLabel = "Certified Fresh",
rtAudienceScore = 30,
rtAudienceLabel = "Spilled",
imdbScore = 8.8,
imdbVotes = 10,
)
val entries = ratings.visibleEntries(community = true, critic = true)
val text = ratingsText(entries, leadingDot = false)
assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%"))
assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8"))
// dots only between entries, or also in front when something precedes the ratings
assertEquals(2, text.text.split("\u2022").size - 1)
assertEquals(3, ratingsText(entries, leadingDot = true).text.split("\u2022").size - 1)
}
}