diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/RatingEntry.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/RatingEntry.kt index c3df1043db..855f65f0e6 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/RatingEntry.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/RatingEntry.kt @@ -29,7 +29,8 @@ import org.prairieserver.prairie.model.catalog.ExternalRatings * `RT 93%`. The mark is the source's name as plain text, never a star or the * source's artwork, except TMDB, whose approved logo its terms allow. The * score is [DisplayRating.display] as the server formatted it. Screen readers - * hear "IMDb 8.5". + * hear "IMDb 8.5". The community rating (Prairie's own, not external) uses a + * star mark and is read as "Community rating 4.2 · 7 ratings". * * [style] is the score's text style and should carry a color and font size; * the mark is drawn from it semibold and dimmed, at [markFontSize] or a @@ -58,7 +59,11 @@ fun RatingEntry( ) Row( modifier = modifier.clearAndSetSemantics { - contentDescription = "${rating.name} ${rating.display}" + contentDescription = if (rating.source == ExternalRatings.SOURCE_COMMUNITY) { + "Community rating ${rating.display}" + } else { + "${rating.name} ${rating.display}" + } }, horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt index d54a264430..c217849904 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/CatalogModels.kt @@ -229,6 +229,8 @@ data class ItemDetail( @SerialName("user_data") val userData: LeafItemUserData? = null, /** The current user's personal star rating (1-5), when set. */ @SerialName("user_rating") val userRating: Int? = null, + /** Aggregate star rating across profiles; omitted by the server below 3 ratings. */ + @SerialName("community_rating") val communityRating: CommunityRating? = null, val versions: List = emptyList(), @SerialName("playback_variants") val playbackVariants: List = emptyList(), val subtitles: List = emptyList(), @@ -568,6 +570,13 @@ data class EpisodesResponse( // --- User Data --- +/** Mean of all profiles' 1-5 star ratings ([average], one decimal) and how many there are. */ +@Serializable +data class CommunityRating( + val average: Double = 0.0, + val count: Int = 0, +) + @Serializable data class LeafItemUserData( val played: Boolean = false, diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatings.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatings.kt index fb0b74a4f5..66d30c5269 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatings.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/model/catalog/ExternalRatings.kt @@ -63,6 +63,9 @@ object ExternalRatings { const val SOURCE_IMDB = "imdb" const val SOURCE_TMDB = "tmdb" + /** Prairie's own aggregate of profile star ratings, not an external source. */ + const val SOURCE_COMMUNITY = "community" + /** * The most ratings a phone-width title page shows. Web and iOS use the * same limit. The server already sends at most three, so this only guards @@ -91,7 +94,29 @@ object ExternalRatings { * of [ratings], in the server's order. Wider layouts pass the server's * list as is. */ - fun forPhone(ratings: List): List = ratings.take(PHONE_LIMIT) + fun forPhone(ratings: List): List { + val (community, external) = ratings.partition { it.source == SOURCE_COMMUNITY } + // The community rating rides after the limit so three external + // ratings never push it off a phone-width page. + return external.take(PHONE_LIMIT) + community + } + + /** + * The community entry: `★ 4.2 · 7 ratings`. Null when the server omits + * `community_rating` (an older server, or fewer than three ratings) or + * sends a value outside 1-5. + */ + fun community(rating: CommunityRating?): DisplayRating? { + if (rating == null || rating.count <= 0 || !rating.average.isFinite()) return null + if (rating.average < 1.0 || rating.average > 5.0) return null + val noun = if (rating.count == 1) "rating" else "ratings" + return DisplayRating( + source = SOURCE_COMMUNITY, + name = "★", + score = rating.average * 20.0, + display = "${formatOneDecimal(rating.average)} · ${rating.count} $noun", + ) + } /** The one rating a hero shows from card fields: IMDb, else TMDB. */ fun primary(ratingImdb: Double?, ratingTmdb: Double?): DisplayRating? = @@ -123,6 +148,10 @@ object ExternalRatings { } } -/** This title's ratings row; see [ExternalRatings.forTitle]. */ +/** + * This title's ratings row: the external ratings (see + * [ExternalRatings.forTitle]) then, when present, the community rating. + */ fun ItemDetail.titleRatings(): List = - ExternalRatings.forTitle(ratings, ratingImdb, ratingTmdb) + ExternalRatings.forTitle(ratings, ratingImdb, ratingTmdb) + + listOfNotNull(ExternalRatings.community(communityRating)) diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt index c3e029a644..95c40cebdc 100644 --- a/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/network/apiv2/CatalogDetailReadV2Models.kt @@ -68,6 +68,7 @@ internal data class ItemDetailReadV2( @SerialName("is_specials") val isSpecials: Boolean? = null, @SerialName("user_data") val userData: LeafItemUserDataReadV2? = null, @SerialName("user_rating") val userRating: Int? = null, + @SerialName("community_rating") val communityRating: CommunityRating? = null, val versions: List, @SerialName("playback_variants") val playbackVariants: List = emptyList(), val subtitles: List, @@ -133,6 +134,7 @@ internal data class ItemDetailReadV2( isSpecials = isSpecials, userData = userData?.toDomain(), userRating = userRating, + communityRating = communityRating, versions = versions.map { it.toDomain() }, playbackVariants = playbackVariants.map { it.toDomain() }, subtitles = subtitles, diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/MediaSurfaceContractSerializationTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/MediaSurfaceContractSerializationTest.kt index 483c93b32c..c746f18f93 100644 --- a/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/MediaSurfaceContractSerializationTest.kt +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/model/catalog/MediaSurfaceContractSerializationTest.kt @@ -131,4 +131,24 @@ class MediaSurfaceContractSerializationTest { ) assertNull(detail.userRating) } + + @Test + fun decodesOptionalCommunityRatingOnItemDetail() { + val rated = json.decodeFromString( + """ + { "content_id": "m3", "type": "movie", "title": "Popular Movie", + "community_rating": { "average": 4.2, "count": 7 } } + """.trimIndent(), + ) + assertEquals(CommunityRating(average = 4.2, count = 7), rated.communityRating) + assertEquals("4.2 · 7 ratings", rated.titleRatings().single().display) + + val unrated = json.decodeFromString( + """ + { "content_id": "m4", "type": "movie", "title": "Quiet Movie" } + """.trimIndent(), + ) + assertNull(unrated.communityRating) + assertEquals(emptyList(), unrated.titleRatings()) + } }