feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor took `rating` and fell back to `audienceRating` only when it was null, so a Plex movie carrying four attributed scores surfaced one, and which one was whatever the server happened to put in the scalar slot. #1755 asked for a setting to choose the source; showing all of them answers it without one. The data was already on the wire and being thrown away. `/library/metadata/ {id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, but PlexMetadataDto declared no field for it, so json_serializable dropped the key. The identical parse already existed in plex_catalog_source for the Explore tab and had simply never been wired to library items. Model the scores as a list rather than widening the scalar pair. The neutral MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage and audienceRatingImage, which the list subsumes — Plex sends those images on listings too, so the same field covers both response shapes and no caller narrows to a backend type to read a score any more. CatalogRatingSource is promoted to lib/media as MediaRatingSource instead of growing a second near-identical type beside it, and plex_catalog_source's _ratingsFor becomes the shared plexRatingSources so one implementation serves both paths. There is no persistence to migrate: MediaItem.toJson has no production caller, the offline path re-parses raw Plex JSON through the same mapper, and Plex's audienceRating sort is server-supplied data, not a model read. Cards and the dashboard still show fewer scores than detail screens, and that part is a real Plex limit rather than a shortcut. Section listings send only the scalar pair; includeRatings, includeElements=Rating, includeFields=Rating, includeChildren and includeExtras were each probed against a live server and none surfaced the array, while includeGuids=1 demonstrably does add Guid[] — the probe works, the parameter does not exist. Hydrating every card would be one request per row, so listings render whatever their own response carried, which is one or two attributed scores rather than the single one they showed before. Jellyfin has no per-source array at all: the server collapses whatever its fetchers found into CommunityRating and CriticRating. CommunityRating's provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or a local NFO, last writer wins — so it stays the generic `audience` source with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a 0-100 percent and is divided by ten explicitly rather than folded by magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin reuses CommunityRating for the EXIF 0-5 star. The badges share one slot on every surface. On the phone hero the scores go in a single pill because that chip row is a height-clipped Wrap and a chip per source would push year, certification and runtime out of the visible band on short heroes; on the TV detail line and the dashboard spotlight the group occupies the one metadata slot so bullet separators do not multiply. The group announces itself as a single semantics node naming each source, because a bare row of four percentages tells a screen reader nothing about which score is which. rating_utils drops parseRatingImage and isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper — and the source-key resolver and label map, previously private to the Explore detail screen, become the shared pair both screens use. The label strings move from explore.ratingSource to common.ratingSource accordingly, which costs no translations because every non-English value was empty; running clean_translations also scaffolds startup.quitPlezy and startup.restartRequiredBody, which were already drifted. Verified against the live server the probes came from: a detail response now yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the production mapper and badge resolver, and the listing response for the same title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures. Coverage adds mapper ordering, dedupe against the array's repeat of the scalar, out-of-range rejection, the Jellyfin scale and photo guard, the CatalogItem conversion that feeds Explore's dashboard hubs, and the three render surfaces including the semantics announcement. close #1755
This commit is contained in:
@@ -3,132 +3,168 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_rating.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/rating_utils.dart';
|
||||
import 'app_icon.dart';
|
||||
|
||||
enum MediaRatingBadgeVariant { chip, inline }
|
||||
|
||||
class MediaRatingBadge extends StatelessWidget {
|
||||
const MediaRatingBadge.chip({
|
||||
/// Every attributed score for [item], falling back to [fallbackItem] as a
|
||||
/// whole rather than per-source — a show's ratings never mix with an
|
||||
/// episode's.
|
||||
List<MediaRatingSource> mediaRatingsFor(MediaItem item, {MediaItem? fallbackItem}) {
|
||||
final ratings = _ratingsFor(item);
|
||||
if (ratings.isNotEmpty || fallbackItem == null) return ratings;
|
||||
return _ratingsFor(fallbackItem);
|
||||
}
|
||||
|
||||
/// Formatted value for [rating] — the brand badge's own formatting where the
|
||||
/// source has one, otherwise the neutral 0-10 rendering.
|
||||
String mediaRatingLabel(MediaRatingSource rating) =>
|
||||
ratingInfoForSource(rating.source, rating.value)?.formattedValue ?? formatRating(rating.value);
|
||||
|
||||
List<MediaRatingSource> _ratingsFor(MediaItem item) {
|
||||
final ratings = item.ratings;
|
||||
if (ratings != null && ratings.isNotEmpty) return ratings;
|
||||
// Backends that report a bare score with no provenance (and cached rows
|
||||
// written before the attributed list existed) still get one badge.
|
||||
final rating = item.rating;
|
||||
return rating == null ? const [] : [MediaRatingSource(source: '', value: rating)];
|
||||
}
|
||||
|
||||
/// Every attributed score an item carries, side by side.
|
||||
///
|
||||
/// A Plex detail response yields up to four (Rotten Tomatoes critic and
|
||||
/// audience, IMDb, TMDB); a library listing yields the one or two the server
|
||||
/// sends with it; Jellyfin yields its community score and Tomatometer. The
|
||||
/// group renders whatever is there and collapses to a single badge when that
|
||||
/// is all the response carried — no extra requests are made to lengthen it.
|
||||
///
|
||||
/// `chip` wraps the whole set in one pill so a badge row's element count does
|
||||
/// not grow with the number of sources. `inline` is the bare row, for
|
||||
/// single-line metadata strips that already own their own separators.
|
||||
class MediaRatingBadgeGroup extends StatelessWidget {
|
||||
const MediaRatingBadgeGroup.chip({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.fallbackIcon,
|
||||
this.imageUri,
|
||||
this.fallbackText,
|
||||
required this.item,
|
||||
this.fallbackItem,
|
||||
this.textStyle,
|
||||
this.foregroundColor,
|
||||
this.backgroundColor,
|
||||
this.iconSize,
|
||||
this.padding,
|
||||
this.spacing,
|
||||
this.entrySpacing,
|
||||
}) : variant = MediaRatingBadgeVariant.chip;
|
||||
|
||||
const MediaRatingBadge.inline({
|
||||
const MediaRatingBadgeGroup.inline({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.fallbackIcon,
|
||||
this.imageUri,
|
||||
this.fallbackText,
|
||||
required this.item,
|
||||
this.fallbackItem,
|
||||
this.textStyle,
|
||||
this.foregroundColor,
|
||||
this.iconSize,
|
||||
this.spacing,
|
||||
this.entrySpacing,
|
||||
}) : variant = MediaRatingBadgeVariant.inline,
|
||||
backgroundColor = null,
|
||||
padding = EdgeInsets.zero;
|
||||
|
||||
final String? imageUri;
|
||||
final double value;
|
||||
final IconData fallbackIcon;
|
||||
final String? fallbackText;
|
||||
final MediaItem item;
|
||||
|
||||
/// Consulted only when [item] carries no ratings at all — an episode row
|
||||
/// borrowing its show's scores.
|
||||
final MediaItem? fallbackItem;
|
||||
final MediaRatingBadgeVariant variant;
|
||||
final TextStyle? textStyle;
|
||||
final Color? foregroundColor;
|
||||
final Color? backgroundColor;
|
||||
final double? iconSize;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// Gap between a badge's icon and its value.
|
||||
final double? spacing;
|
||||
|
||||
static MediaRatingBadge? inlineForMedia({
|
||||
/// Gap between adjacent scores.
|
||||
final double? entrySpacing;
|
||||
|
||||
/// Null when the item has no score at all, so callers can omit the slot
|
||||
/// instead of rendering an empty pill.
|
||||
static MediaRatingBadgeGroup? inlineForMedia({
|
||||
required MediaItem item,
|
||||
MediaItem? fallbackItem,
|
||||
TextStyle? textStyle,
|
||||
Color? foregroundColor,
|
||||
double? iconSize,
|
||||
double? spacing,
|
||||
double? entrySpacing,
|
||||
}) {
|
||||
final data = _ratingDataFor(item) ?? (fallbackItem == null ? null : _ratingDataFor(fallbackItem));
|
||||
if (data == null) return null;
|
||||
|
||||
return MediaRatingBadge.inline(
|
||||
imageUri: data.imageUri,
|
||||
value: data.value,
|
||||
fallbackIcon: data.fallbackIcon,
|
||||
fallbackText: data.fallbackText,
|
||||
if (mediaRatingsFor(item, fallbackItem: fallbackItem).isEmpty) return null;
|
||||
return MediaRatingBadgeGroup.inline(
|
||||
item: item,
|
||||
fallbackItem: fallbackItem,
|
||||
textStyle: textStyle,
|
||||
foregroundColor: foregroundColor,
|
||||
iconSize: iconSize,
|
||||
spacing: spacing,
|
||||
textStyle: textStyle,
|
||||
entrySpacing: entrySpacing,
|
||||
);
|
||||
}
|
||||
|
||||
/// The text exposed by the rendered badge, without building its icon row.
|
||||
/// Every score read out as `<source> <value>`, for the metadata-line
|
||||
/// announcement. Falls back to the bare value where the source is unnamed.
|
||||
static String? semanticLabelForMedia(MediaItem item, {MediaItem? fallbackItem}) {
|
||||
final data = _ratingDataFor(item) ?? (fallbackItem == null ? null : _ratingDataFor(fallbackItem));
|
||||
if (data == null) return null;
|
||||
return parseRatingImage(data.imageUri, data.value)?.formattedValue ?? data.fallbackText;
|
||||
final ratings = mediaRatingsFor(item, fallbackItem: fallbackItem);
|
||||
return ratings.isEmpty ? null : _semanticLabelFor(ratings);
|
||||
}
|
||||
|
||||
static _MediaRatingBadgeData? _ratingDataFor(MediaItem item) {
|
||||
final plex = item is PlexMediaItem ? item : null;
|
||||
final rating = item.rating;
|
||||
if (rating != null) {
|
||||
return _MediaRatingBadgeData(
|
||||
imageUri: plex?.ratingImage,
|
||||
value: rating,
|
||||
fallbackIcon: Symbols.star_rounded,
|
||||
fallbackText: formatRating(rating),
|
||||
);
|
||||
}
|
||||
|
||||
final audienceRating = plex?.audienceRating;
|
||||
if (audienceRating != null) {
|
||||
return _MediaRatingBadgeData(
|
||||
imageUri: plex?.audienceRatingImage,
|
||||
value: audienceRating,
|
||||
fallbackIcon: Symbols.people_rounded,
|
||||
fallbackText: '${(audienceRating * 10).toStringAsFixed(0)}%',
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
static String _semanticLabelFor(List<MediaRatingSource> ratings) => [
|
||||
for (final rating in ratings)
|
||||
switch (ratingSourceLabel(rating.source)) {
|
||||
final label? => '$label ${mediaRatingLabel(rating)}',
|
||||
null => mediaRatingLabel(rating),
|
||||
},
|
||||
].join(', ');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratings = mediaRatingsFor(item, fallbackItem: fallbackItem);
|
||||
if (ratings.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isInline = variant == MediaRatingBadgeVariant.inline;
|
||||
final foreground = foregroundColor ?? (isInline ? colorScheme.onSurface : colorScheme.onSecondaryContainer);
|
||||
final style =
|
||||
(textStyle ??
|
||||
TextStyle(color: foreground, fontSize: 13, fontWeight: isInline ? FontWeight.w700 : FontWeight.w600))
|
||||
.copyWith(color: textStyle?.color ?? foreground);
|
||||
final size = iconSize ?? style.fontSize ?? 13;
|
||||
final info = parseRatingImage(imageUri, value);
|
||||
final label = info?.formattedValue ?? fallbackText ?? '${(value * 10).toStringAsFixed(0)}%';
|
||||
final content = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (info != null)
|
||||
SvgPicture.asset(info.assetPath, width: size, height: size)
|
||||
else
|
||||
AppIcon(fallbackIcon, fill: 1, color: foreground, size: size),
|
||||
SizedBox(width: spacing ?? (isInline ? 4 : 4)),
|
||||
Text(label, maxLines: 1, overflow: TextOverflow.clip, style: style),
|
||||
],
|
||||
);
|
||||
final style = _resolveTextStyle(textStyle, foreground, isInline);
|
||||
final gap = entrySpacing ?? 10;
|
||||
|
||||
final children = <Widget>[];
|
||||
for (final rating in ratings) {
|
||||
if (children.isNotEmpty) children.add(SizedBox(width: gap));
|
||||
children.add(
|
||||
_buildContent(
|
||||
source: rating.source,
|
||||
value: rating.value,
|
||||
// Plex's own critic/audience split is the only place the generic
|
||||
// icons still carry meaning; every branded source draws its logo.
|
||||
fallbackIcon: rating.source == 'audience' ? Symbols.people_rounded : Symbols.star_rounded,
|
||||
foreground: foreground,
|
||||
style: style,
|
||||
iconSize: iconSize,
|
||||
spacing: spacing,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// A bare row reads as a run of unattributed numbers once more than one
|
||||
// source is present, so the whole group announces itself as one node
|
||||
// naming each source. Parents that build their own merged announcement
|
||||
// (the TV metadata line) exclude this subtree anyway.
|
||||
final content = Semantics(
|
||||
label: _semanticLabelFor(ratings),
|
||||
excludeSemantics: true,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: children),
|
||||
);
|
||||
if (isInline) return content;
|
||||
|
||||
return Container(
|
||||
@@ -142,16 +178,32 @@ class MediaRatingBadge extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MediaRatingBadgeData {
|
||||
const _MediaRatingBadgeData({
|
||||
required this.value,
|
||||
required this.fallbackIcon,
|
||||
required this.fallbackText,
|
||||
this.imageUri,
|
||||
});
|
||||
TextStyle _resolveTextStyle(TextStyle? textStyle, Color foreground, bool isInline) {
|
||||
return (textStyle ??
|
||||
TextStyle(color: foreground, fontSize: 13, fontWeight: isInline ? FontWeight.w700 : FontWeight.w600))
|
||||
.copyWith(color: textStyle?.color ?? foreground);
|
||||
}
|
||||
|
||||
final String? imageUri;
|
||||
final double value;
|
||||
final IconData fallbackIcon;
|
||||
final String fallbackText;
|
||||
Widget _buildContent({
|
||||
required String? source,
|
||||
required double value,
|
||||
required IconData fallbackIcon,
|
||||
required Color foreground,
|
||||
required TextStyle style,
|
||||
required double? iconSize,
|
||||
required double? spacing,
|
||||
}) {
|
||||
final size = iconSize ?? style.fontSize ?? 13;
|
||||
final info = source == null ? null : ratingInfoForSource(source, value);
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (info != null)
|
||||
SvgPicture.asset(info.assetPath, width: size, height: size)
|
||||
else
|
||||
AppIcon(fallbackIcon, fill: 1, color: foreground, size: size),
|
||||
SizedBox(width: spacing ?? 4),
|
||||
Text(info?.formattedValue ?? formatRating(value), maxLines: 1, overflow: TextOverflow.clip, style: style),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -328,11 +328,15 @@ class TvSpotlightBackground extends StatelessWidget {
|
||||
} else if (media.isShow) {
|
||||
addTextPart(t.discover.tvShow);
|
||||
}
|
||||
final ratingBadge = MediaRatingBadge.inlineForMedia(
|
||||
// Hub listings carry the scalar rating pair, so the dashboard spotlight
|
||||
// shows every score the shelf request already returned — no per-item
|
||||
// hydration to lengthen it.
|
||||
final ratingBadge = MediaRatingBadgeGroup.inlineForMedia(
|
||||
item: media,
|
||||
foregroundColor: textStyle.color,
|
||||
iconSize: textStyle.fontSize,
|
||||
spacing: 4 * scale,
|
||||
entrySpacing: 12 * scale,
|
||||
textStyle: textStyle,
|
||||
);
|
||||
if (ratingBadge != null) {
|
||||
|
||||
Reference in New Issue
Block a user