From a5a72147c778864039bb72e2a9b76e7408ef2dfa Mon Sep 17 00:00:00 2001
From: edde746 <86283021+edde746@users.noreply.github.com>
Date: Sun, 8 Feb 2026 16:03:23 +0100
Subject: [PATCH] feat: show ratings
close #410
---
assets/rating_icons/imdb.svg | 3 +
assets/rating_icons/rt_fresh.svg | 20 +++++
assets/rating_icons/rt_rotten.svg | 1 +
assets/rating_icons/rt_spilled.svg | 46 +++++++++++
assets/rating_icons/rt_upright.svg | 1 +
assets/rating_icons/tmdb.svg | 1 +
lib/models/plex_metadata.dart | 8 ++
lib/models/plex_metadata.g.dart | 4 +
lib/screens/media_detail_screen.dart | 118 ++++++++++++++++++++-------
lib/utils/rating_utils.dart | 45 ++++++++++
10 files changed, 217 insertions(+), 30 deletions(-)
create mode 100644 assets/rating_icons/imdb.svg
create mode 100644 assets/rating_icons/rt_fresh.svg
create mode 100644 assets/rating_icons/rt_rotten.svg
create mode 100644 assets/rating_icons/rt_spilled.svg
create mode 100644 assets/rating_icons/rt_upright.svg
create mode 100644 assets/rating_icons/tmdb.svg
create mode 100644 lib/utils/rating_utils.dart
diff --git a/assets/rating_icons/imdb.svg b/assets/rating_icons/imdb.svg
new file mode 100644
index 00000000..b2a908bc
--- /dev/null
+++ b/assets/rating_icons/imdb.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/assets/rating_icons/rt_fresh.svg b/assets/rating_icons/rt_fresh.svg
new file mode 100644
index 00000000..977253a9
--- /dev/null
+++ b/assets/rating_icons/rt_fresh.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/assets/rating_icons/rt_rotten.svg b/assets/rating_icons/rt_rotten.svg
new file mode 100644
index 00000000..04bef05b
--- /dev/null
+++ b/assets/rating_icons/rt_rotten.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/rating_icons/rt_spilled.svg b/assets/rating_icons/rt_spilled.svg
new file mode 100644
index 00000000..af36063b
--- /dev/null
+++ b/assets/rating_icons/rt_spilled.svg
@@ -0,0 +1,46 @@
+
+
+
diff --git a/assets/rating_icons/rt_upright.svg b/assets/rating_icons/rt_upright.svg
new file mode 100644
index 00000000..bf1cc85a
--- /dev/null
+++ b/assets/rating_icons/rt_upright.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/rating_icons/tmdb.svg b/assets/rating_icons/tmdb.svg
new file mode 100644
index 00000000..62a66055
--- /dev/null
+++ b/assets/rating_icons/tmdb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart
index c2365ac3..3fe60263 100644
--- a/lib/models/plex_metadata.dart
+++ b/lib/models/plex_metadata.dart
@@ -77,6 +77,8 @@ class PlexMetadata with MultiServerFields {
final int? playlistItemID; // Playlist item ID (for dumb playlists only)
final int? playQueueItemID; // Play queue item ID (unique even for duplicates)
final int? librarySectionID; // Library section ID this item belongs to
+ final String? ratingImage; // Rating source URI (e.g. rottentomatoes://image.rating.ripe)
+ final String? audienceRatingImage; // Audience rating source URI
// Multi-server support fields (from MultiServerFields mixin)
@override
@@ -151,6 +153,8 @@ class PlexMetadata with MultiServerFields {
this.playlistItemID,
this.playQueueItemID,
this.librarySectionID,
+ this.ratingImage,
+ this.audienceRatingImage,
this.serverId,
this.serverName,
this.clearLogo,
@@ -198,6 +202,8 @@ class PlexMetadata with MultiServerFields {
int? playlistItemID,
int? playQueueItemID,
int? librarySectionID,
+ String? ratingImage,
+ String? audienceRatingImage,
String? serverId,
String? serverName,
String? clearLogo,
@@ -243,6 +249,8 @@ class PlexMetadata with MultiServerFields {
playlistItemID: playlistItemID ?? this.playlistItemID,
playQueueItemID: playQueueItemID ?? this.playQueueItemID,
librarySectionID: librarySectionID ?? this.librarySectionID,
+ ratingImage: ratingImage ?? this.ratingImage,
+ audienceRatingImage: audienceRatingImage ?? this.audienceRatingImage,
serverId: serverId ?? this.serverId,
serverName: serverName ?? this.serverName,
clearLogo: clearLogo ?? this.clearLogo,
diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart
index e6f54d75..d67a5fe4 100644
--- a/lib/models/plex_metadata.g.dart
+++ b/lib/models/plex_metadata.g.dart
@@ -47,6 +47,8 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata(
playlistItemID: (json['playlistItemID'] as num?)?.toInt(),
playQueueItemID: (json['playQueueItemID'] as num?)?.toInt(),
librarySectionID: (json['librarySectionID'] as num?)?.toInt(),
+ ratingImage: json['ratingImage'] as String?,
+ audienceRatingImage: json['audienceRatingImage'] as String?,
clearLogo: json['clearLogo'] as String?,
);
@@ -91,5 +93,7 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => with WatchStateAw
);
}
- /// Build a metadata chip with optional leading icon
- Widget _buildMetadataChip(String text, {IconData? icon}) {
+ /// Build a metadata chip with optional leading icon or widget
+ Widget _buildMetadataChip(String text, {IconData? icon, Widget? leading}) {
+ final textWidget = Text(
+ text,
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onSecondaryContainer,
+ fontSize: 13,
+ fontWeight: FontWeight.w500,
+ ),
+ );
+
+ final hasLeading = leading != null || icon != null;
+
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(100),
),
- child: icon != null
+ child: hasLeading
? Row(
mainAxisSize: MainAxisSize.min,
children: [
- AppIcon(icon, fill: 1, color: Theme.of(context).colorScheme.onSecondaryContainer, size: 16),
+ if (leading != null)
+ leading
+ else
+ AppIcon(icon!, fill: 1, color: Theme.of(context).colorScheme.onSecondaryContainer, size: 16),
const SizedBox(width: 4),
- Text(
- text,
- style: TextStyle(
- color: Theme.of(context).colorScheme.onSecondaryContainer,
- fontSize: 13,
- fontWeight: FontWeight.w500,
- ),
- ),
+ textWidget,
],
)
- : Text(
- text,
- style: TextStyle(
- color: Theme.of(context).colorScheme.onSecondaryContainer,
- fontSize: 13,
- fontWeight: FontWeight.w500,
- ),
- ),
+ : textWidget,
+ );
+ }
+
+ /// Build a rating chip that shows a source icon when available,
+ /// falling back to a generic Material icon.
+ Widget _buildRatingChip(String? imageUri, double value, IconData fallbackIcon) {
+ final info = parseRatingImage(imageUri, value);
+ if (info != null) {
+ return _buildMetadataChip(info.formattedValue, leading: SvgPicture.asset(info.assetPath, width: 16, height: 16));
+ }
+ return _buildMetadataChip('${(value * 10).toStringAsFixed(0)}%', icon: fallbackIcon);
+ }
+
+ /// Build all rating chips for the metadata.
+ /// When both critic and audience ratings are from Rotten Tomatoes,
+ /// they are combined into a single badge.
+ List _buildRatingChips(PlexMetadata metadata) {
+ final chips = [];
+ final bothRT =
+ metadata.rating != null &&
+ metadata.audienceRating != null &&
+ isRottenTomatoes(metadata.ratingImage) &&
+ isRottenTomatoes(metadata.audienceRatingImage);
+
+ if (bothRT) {
+ final critic = parseRatingImage(metadata.ratingImage, metadata.rating)!;
+ final audience = parseRatingImage(metadata.audienceRatingImage, metadata.audienceRating)!;
+ chips.add(_buildCombinedRtChip(critic, audience));
+ } else {
+ if (metadata.rating != null) {
+ chips.add(_buildRatingChip(metadata.ratingImage, metadata.rating!, Symbols.star_rounded));
+ }
+ if (metadata.audienceRating != null) {
+ chips.add(_buildRatingChip(metadata.audienceRatingImage, metadata.audienceRating!, Symbols.people_rounded));
+ }
+ }
+ return chips;
+ }
+
+ /// Build a combined RT chip showing critic + audience side by side.
+ Widget _buildCombinedRtChip(RatingInfo critic, RatingInfo audience) {
+ final textStyle = TextStyle(
+ color: Theme.of(context).colorScheme.onSecondaryContainer,
+ fontSize: 13,
+ fontWeight: FontWeight.w500,
+ );
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.8),
+ borderRadius: BorderRadius.circular(100),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SvgPicture.asset(critic.assetPath, width: 16, height: 16),
+ const SizedBox(width: 4),
+ Text(critic.formattedValue, style: textStyle),
+ const SizedBox(width: 10),
+ SvgPicture.asset(audience.assetPath, width: 16, height: 16),
+ const SizedBox(width: 4),
+ Text(audience.formattedValue, style: textStyle),
+ ],
+ ),
);
}
@@ -1514,16 +1581,7 @@ class _MediaDetailScreenState extends State with WatchStateAw
_buildMetadataChip(formatContentRating(metadata.contentRating!)),
if (metadata.duration != null)
_buildMetadataChip(formatDurationTextual(metadata.duration!)),
- if (metadata.rating != null)
- _buildMetadataChip(
- '${(metadata.rating! * 10).toStringAsFixed(0)}%',
- icon: Symbols.star_rounded,
- ),
- if (metadata.audienceRating != null)
- _buildMetadataChip(
- '${(metadata.audienceRating! * 10).toStringAsFixed(0)}%',
- icon: Symbols.people_rounded,
- ),
+ ..._buildRatingChips(metadata),
],
),
const SizedBox(height: 16),
diff --git a/lib/utils/rating_utils.dart b/lib/utils/rating_utils.dart
new file mode 100644
index 00000000..939bf0a1
--- /dev/null
+++ b/lib/utils/rating_utils.dart
@@ -0,0 +1,45 @@
+/// Parses Plex ratingImage / audienceRatingImage URIs and returns
+/// the corresponding local asset path and a display-formatted value.
+
+class RatingInfo {
+ final String assetPath;
+ final String formattedValue;
+
+ const RatingInfo(this.assetPath, this.formattedValue);
+}
+
+/// Parse a ratingImage URI (e.g. "rottentomatoes://image.rating.ripe")
+/// together with the numeric rating value into a [RatingInfo].
+///
+/// Returns null if the URI is unrecognised.
+RatingInfo? parseRatingImage(String? imageUri, double? value) {
+ if (imageUri == null || value == null) return null;
+
+ // Rotten Tomatoes critic ratings
+ if (imageUri.startsWith('rottentomatoes://image.rating.')) {
+ final suffix = imageUri.substring('rottentomatoes://image.rating.'.length);
+ final percent = '${(value * 10).toStringAsFixed(0)}%';
+ return switch (suffix) {
+ 'ripe' => RatingInfo('assets/rating_icons/rt_fresh.svg', percent),
+ 'rotten' => RatingInfo('assets/rating_icons/rt_rotten.svg', percent),
+ 'upright' => RatingInfo('assets/rating_icons/rt_upright.svg', percent),
+ 'spilled' => RatingInfo('assets/rating_icons/rt_spilled.svg', percent),
+ _ => null,
+ };
+ }
+
+ // IMDb
+ if (imageUri.startsWith('imdb://')) {
+ return RatingInfo('assets/rating_icons/imdb.svg', value.toStringAsFixed(1));
+ }
+
+ // TMDB
+ if (imageUri.startsWith('themoviedb://')) {
+ return RatingInfo('assets/rating_icons/tmdb.svg', '${(value * 10).toStringAsFixed(0)}%');
+ }
+
+ return null;
+}
+
+/// Whether the URI is a Rotten Tomatoes rating source.
+bool isRottenTomatoes(String? imageUri) => imageUri != null && imageUri.startsWith('rottentomatoes://');