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 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + 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 @@ + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ +Asset 4 \ 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://');