Add source ratings model and Seerr ratings service matching the PWA
This commit is contained in:
parent
d78a23a80a
commit
e92ade99df
10 changed files with 1032 additions and 40 deletions
|
|
@ -0,0 +1,223 @@
|
|||
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
|
||||
|
||||
/**
|
||||
* Which Rotten Tomatoes mark to draw. Chosen only from the label Seerr reports, never from a percentage.
|
||||
*/
|
||||
enum class RtMark {
|
||||
FRESH,
|
||||
ROTTEN,
|
||||
UPRIGHT,
|
||||
SPILLED,
|
||||
|
||||
/** Seerr sent a score without a recognised label */
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Certified Fresh/Fresh -> [FRESH], Rotten -> [ROTTEN], Upright -> [UPRIGHT], Spilled -> [SPILLED], anything else null
|
||||
*/
|
||||
fun fromLabel(label: String?): RtMark? =
|
||||
when (label) {
|
||||
"Certified Fresh", "Fresh" -> FRESH
|
||||
"Rotten" -> ROTTEN
|
||||
"Upright" -> UPRIGHT
|
||||
"Spilled" -> SPILLED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class RtScore(
|
||||
/** 0-100 */
|
||||
val percent: Int,
|
||||
val mark: RtMark,
|
||||
)
|
||||
|
||||
/**
|
||||
* One displayable rating
|
||||
*/
|
||||
@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
|
||||
|
||||
data class RottenTomatoes(
|
||||
val score: RtScore,
|
||||
/** Critics (Tomatometer) or audience (Popcornmeter) */
|
||||
val critics: Boolean,
|
||||
) : RatingEntry {
|
||||
override val text: String get() = "${score.percent}%"
|
||||
override val inlineContentId: String
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
data class Imdb(
|
||||
val score: Double,
|
||||
) : RatingEntry {
|
||||
override val text: String get() = formatScore(score)
|
||||
override val inlineContentId: String get() = INLINE_IMDB
|
||||
}
|
||||
|
||||
data class Tmdb(
|
||||
val score: Double,
|
||||
) : RatingEntry {
|
||||
override val text: String get() = formatScore(score)
|
||||
override val inlineContentId: String get() = INLINE_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)
|
||||
|
||||
/**
|
||||
* Ratings for a single title from the same sources as the Wolfskin PWA: Rotten Tomatoes critics & audience,
|
||||
* IMDb (movies only), and TMDB.
|
||||
*
|
||||
* Only scores that were actually observed are present. Use [of] to apply the PWA's rules.
|
||||
*/
|
||||
@Immutable
|
||||
data class SourceRatings(
|
||||
val rtCritics: RtScore? = null,
|
||||
val rtAudience: RtScore? = null,
|
||||
val imdb: Double? = null,
|
||||
val tmdb: Double? = null,
|
||||
) {
|
||||
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null
|
||||
|
||||
/**
|
||||
* Rotten Tomatoes entries in PWA order: critics then audience
|
||||
*/
|
||||
val criticEntries: List<RatingEntry.RottenTomatoes>
|
||||
get() =
|
||||
listOfNotNull(
|
||||
rtCritics?.let { RatingEntry.RottenTomatoes(it, true) },
|
||||
rtAudience?.let { RatingEntry.RottenTomatoes(it, false) },
|
||||
)
|
||||
|
||||
/**
|
||||
* IMDb or TMDB
|
||||
*/
|
||||
val communityEntries: List<RatingEntry>
|
||||
get() =
|
||||
listOfNotNull(
|
||||
imdb?.let { RatingEntry.Imdb(it) },
|
||||
tmdb?.let { RatingEntry.Tmdb(it) },
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val MAX_PERCENT = 100.0
|
||||
const val MAX_SCORE = 10.0
|
||||
|
||||
/**
|
||||
* Builds ratings using the PWA's rules:
|
||||
* - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped
|
||||
* - A zero-vote IMDb/TMDB score is unrated, never 0
|
||||
* - RT icon comes from the label Seerr reports
|
||||
* - TMDB is kept only when no other source has a score
|
||||
*
|
||||
* @param rtCriticsLabel Seerr's critics status: Certified Fresh, Fresh, or Rotten
|
||||
* @param rtAudienceLabel Seerr's audience status: Upright or Spilled
|
||||
*/
|
||||
fun of(
|
||||
rtCriticsScore: Number? = null,
|
||||
rtCriticsLabel: String? = null,
|
||||
rtAudienceScore: Number? = null,
|
||||
rtAudienceLabel: String? = null,
|
||||
imdbScore: Number? = null,
|
||||
imdbVotes: Long? = null,
|
||||
tmdbScore: Number? = null,
|
||||
tmdbVotes: Long? = null,
|
||||
): SourceRatings {
|
||||
val critics = rtScore(rtCriticsScore, rtCriticsLabel, RtMark.UNKNOWN)
|
||||
val audience = rtScore(rtAudienceScore, rtAudienceLabel, RtMark.UPRIGHT)
|
||||
val imdb = userScore(imdbScore, imdbVotes)
|
||||
val tmdb = userScore(tmdbScore, tmdbVotes)
|
||||
return if (critics != null || audience != null || imdb != null) {
|
||||
SourceRatings(critics, audience, imdb, null)
|
||||
} else {
|
||||
SourceRatings(tmdb = tmdb)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rtScore(
|
||||
score: Number?,
|
||||
label: String?,
|
||||
defaultMark: RtMark,
|
||||
): RtScore? {
|
||||
val value = score?.toDouble() ?: return null
|
||||
if (value.isNaN() || value < 0.0 || value > MAX_PERCENT) return null
|
||||
return RtScore(value.toInt(), RtMark.fromLabel(label) ?: defaultMark)
|
||||
}
|
||||
|
||||
private fun userScore(
|
||||
score: Number?,
|
||||
votes: Long?,
|
||||
): Double? {
|
||||
val value = score?.toDouble() ?: return null
|
||||
if (value.isNaN() || value < 0.0 || value > MAX_SCORE) return null
|
||||
if (votes != null && votes <= 0L) return null
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
|
||||
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.data.model.SourceRatings
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The TMDB user vote average & count as reported by Seerr's movie/TV details
|
||||
*/
|
||||
data class TmdbVote(
|
||||
val average: Double?,
|
||||
val count: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Results are cached by media type and TMDB id because movie and TV ids are separate namespaces. Failures are never cached.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Singleton
|
||||
class SeerrRatingsService
|
||||
@Inject
|
||||
constructor(
|
||||
private val seerrService: SeerrService,
|
||||
) {
|
||||
private data class Key(
|
||||
val type: SeerrItemType,
|
||||
val tmdbId: Int,
|
||||
)
|
||||
|
||||
private val cache =
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(CACHE_SIZE)
|
||||
.build<Key, SourceRatings>()
|
||||
|
||||
fun cached(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
fun forItem(item: BaseItem): Flow<SourceRatings?> {
|
||||
val type =
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
|
||||
BaseItemKind.SERIES -> SeerrItemType.TV
|
||||
else -> null
|
||||
}
|
||||
val tmdbId =
|
||||
item.data.providerIds
|
||||
?.get("Tmdb")
|
||||
?.toIntOrNull()
|
||||
return if (type == null || tmdbId == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
seerrService.active
|
||||
.distinctUntilChanged()
|
||||
.mapLatest { active -> if (active) get(type, tmdbId) else null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request
|
||||
* @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now
|
||||
*/
|
||||
suspend fun get(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
knownTmdb: TmdbVote? = null,
|
||||
): SourceRatings? {
|
||||
val key = Key(type, tmdbId)
|
||||
cache.getIfPresent(key)?.let { return it }
|
||||
if (type != SeerrItemType.MOVIE && type != SeerrItemType.TV) return null
|
||||
|
||||
val combined =
|
||||
try {
|
||||
fetchCombined(type, tmdbId)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error fetching ratings for %s %s", type, tmdbId)
|
||||
return null
|
||||
}
|
||||
var ratings = build(combined, null)
|
||||
if (ratings.isEmpty) {
|
||||
// Nothing else has a score, so TMDB is the only possible source
|
||||
val vote =
|
||||
knownTmdb ?: try {
|
||||
fetchTmdb(type, tmdbId)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error fetching TMDB rating for %s %s", type, tmdbId)
|
||||
return null
|
||||
}
|
||||
ratings = build(combined, vote)
|
||||
}
|
||||
cache.put(key, ratings)
|
||||
return ratings
|
||||
}
|
||||
|
||||
private suspend fun fetchCombined(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
): Combined =
|
||||
try {
|
||||
when (type) {
|
||||
SeerrItemType.MOVIE -> {
|
||||
Combined.Movie(seerrService.api.moviesApi.movieMovieIdRatingscombinedGet(movieId = tmdbId))
|
||||
}
|
||||
|
||||
else -> {
|
||||
Combined.Tv(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = tmdbId))
|
||||
}
|
||||
}
|
||||
} catch (ex: ClientException) {
|
||||
if (ex.statusCode == 404) {
|
||||
// Seerr has no RT/IMDb data for this title, that is an answer rather than a failure
|
||||
Timber.d("No ratings for %s %s", type, tmdbId)
|
||||
Combined.None
|
||||
} else {
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchTmdb(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
): TmdbVote =
|
||||
when (type) {
|
||||
SeerrItemType.MOVIE -> {
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdGet(movieId = tmdbId)
|
||||
.let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdGet(tvId = tmdbId)
|
||||
.let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(
|
||||
combined: Combined,
|
||||
tmdb: TmdbVote?,
|
||||
): SourceRatings =
|
||||
when (combined) {
|
||||
is Combined.Movie -> buildMovie(combined.response, tmdb)
|
||||
is Combined.Tv -> {
|
||||
val rt = combined.response
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = rt.criticsScore,
|
||||
rtCriticsLabel = rt.criticsRating?.value,
|
||||
rtAudienceScore = rt.audienceScore,
|
||||
rtAudienceLabel = rt.audienceRating?.value,
|
||||
tmdbScore = tmdb?.average,
|
||||
tmdbVotes = tmdb?.count,
|
||||
)
|
||||
}
|
||||
|
||||
Combined.None -> {
|
||||
SourceRatings.of(tmdbScore = tmdb?.average, tmdbVotes = tmdb?.count)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMovie(
|
||||
response: MovieMovieIdRatingscombinedGet200Response,
|
||||
tmdb: TmdbVote?,
|
||||
): SourceRatings =
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = response.rt?.criticsScore,
|
||||
rtCriticsLabel = response.rt?.criticsRating?.value,
|
||||
rtAudienceScore = response.rt?.audienceScore,
|
||||
rtAudienceLabel = response.rt?.audienceRating?.value,
|
||||
imdbScore = response.imdb?.criticsScore,
|
||||
imdbVotes = response.imdb?.criticsScoreCount,
|
||||
tmdbScore = tmdb?.average,
|
||||
tmdbVotes = tmdb?.count,
|
||||
)
|
||||
|
||||
private sealed interface Combined {
|
||||
data class Movie(
|
||||
val response: MovieMovieIdRatingscombinedGet200Response,
|
||||
) : Combined
|
||||
|
||||
data class Tv(
|
||||
val response: TvTvIdRatingsGet200Response,
|
||||
) : Combined
|
||||
|
||||
data object None : Combined
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CACHE_SIZE = 300L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
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
|
||||
|
|
@ -10,7 +12,9 @@ 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.Color
|
||||
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
|
||||
|
|
@ -23,6 +27,13 @@ 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.QuickDetailsData
|
||||
import com.github.damontecres.wholphin.preferences.DisplayToggle
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
|
|
@ -84,15 +95,26 @@ fun QuickDetailsText(
|
|||
@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(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
InlineTextContent(square) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
tint = FilledStarColor,
|
||||
|
|
@ -100,36 +122,13 @@ fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typograp
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
"rotten" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
"fresh" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue