fix(media): show source rating badges on TV

close #1327
This commit is contained in:
edde746
2026-06-13 15:58:36 +02:00
parent 2876e1bf78
commit b56608f94f
4 changed files with 365 additions and 59 deletions
+94 -38
View File
@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import '../main.dart' show routeObserver;
import '../services/image_cache_service.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:flutter/services.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/app_icon.dart';
@@ -33,6 +32,7 @@ import '../media/media_kind.dart';
import '../media/media_role.dart';
import '../media/paged_media_list_state.dart';
import '../widgets/media_card.dart';
import '../widgets/media_rating_badge.dart';
import '../i18n/strings.g.dart';
import '../widgets/optimized_media_image.dart';
import '../utils/media_image_helper.dart';
@@ -880,11 +880,19 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
/// 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);
final colorScheme = Theme.of(context).colorScheme;
final isTv = PlatformDetector.isTV();
return MediaRatingBadge.chip(
imageUri: imageUri,
value: value,
fallbackIcon: fallbackIcon,
foregroundColor: colorScheme.onSecondaryContainer,
backgroundColor: colorScheme.secondaryContainer.withValues(alpha: 0.8),
iconSize: isTv ? 20 : 16,
spacing: isTv ? 6 : 4,
padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6),
textStyle: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: .w600),
);
}
/// Build all rating chips for the metadata.
@@ -906,9 +914,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
isRottenTomatoes(audienceRatingImage);
if (bothRT) {
final critic = parseRatingImage(ratingImage, metadata.rating)!;
final audience = parseRatingImage(audienceRatingImage, audienceRating)!;
chips.add(_buildCombinedRtChip(critic, audience));
chips.add(_buildCombinedRtChip(ratingImage, metadata.rating!, audienceRatingImage, audienceRating));
} else {
if (metadata.rating != null) {
chips.add(_buildRatingChip(ratingImage, metadata.rating!, Symbols.star_rounded));
@@ -1016,7 +1022,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
/// Build a combined RT chip showing critic + audience side by side.
Widget _buildCombinedRtChip(RatingInfo critic, RatingInfo audience) {
Widget _buildCombinedRtChip(
String? criticImageUri,
double criticValue,
String? audienceImageUri,
double audienceValue,
) {
final colorScheme = Theme.of(context).colorScheme;
final textStyle = TextStyle(color: colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: .w500);
@@ -1029,13 +1040,25 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: Row(
mainAxisSize: .min,
children: [
SvgPicture.asset(critic.assetPath, width: 16, height: 16),
const SizedBox(width: 4),
Text(critic.formattedValue, style: textStyle),
MediaRatingBadge.inline(
imageUri: criticImageUri,
value: criticValue,
fallbackIcon: Symbols.star_rounded,
foregroundColor: colorScheme.onSecondaryContainer,
iconSize: 16,
spacing: 4,
textStyle: textStyle,
),
const SizedBox(width: 10),
SvgPicture.asset(audience.assetPath, width: 16, height: 16),
const SizedBox(width: 4),
Text(audience.formattedValue, style: textStyle),
MediaRatingBadge.inline(
imageUri: audienceImageUri,
value: audienceValue,
fallbackIcon: Symbols.people_rounded,
foregroundColor: colorScheme.onSecondaryContainer,
iconSize: 16,
spacing: 4,
textStyle: textStyle,
),
],
),
);
@@ -3511,29 +3534,62 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final lineMetadata = _tvDetailFocusedEpisode ?? metadata;
final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index);
final qualityLabels = buildMediaQualityLabels(lineMetadata);
final parts = [
if (lineMetadata.isEpisode && episodeLabel != null) episodeLabel,
if (lineMetadata.isMovie) t.discover.movie else if (lineMetadata.isShow) t.discover.tvShow,
if (lineMetadata.rating != null) '${formatRating(lineMetadata.rating!)}',
if (lineMetadata.contentRating != null) formatContentRating(lineMetadata.contentRating!),
if (lineMetadata.durationMs != null) formatDurationTextual(lineMetadata.durationMs!),
if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null)
formatFullDate(lineMetadata.originallyAvailableAt!)
else if (lineMetadata.year != null)
lineMetadata.year.toString(),
...qualityLabels,
];
final textStyle = TextStyle(
color: _tvDetailForegroundColor(context),
fontSize: 18 * scale,
fontWeight: .w700,
letterSpacing: 0.1,
);
final children = <Widget>[];
return Text(
parts.join(''),
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(
color: _tvDetailForegroundColor(context),
fontSize: 18 * scale,
fontWeight: .w700,
letterSpacing: 0.1,
),
void addSeparator() {
if (children.isNotEmpty) children.add(Text('', maxLines: 1, style: textStyle));
}
void addTextPart(String text) {
addSeparator();
children.add(Text(text, maxLines: 1, style: textStyle));
}
void addWidgetPart(Widget widget) {
addSeparator();
children.add(widget);
}
if (lineMetadata.isEpisode && episodeLabel != null) addTextPart(episodeLabel);
if (lineMetadata.isMovie) {
addTextPart(t.discover.movie);
} else if (lineMetadata.isShow) {
addTextPart(t.discover.tvShow);
}
final ratingBadge = MediaRatingBadge.inlineForMedia(
item: lineMetadata,
fallbackItem: metadata,
foregroundColor: textStyle.color,
iconSize: textStyle.fontSize,
spacing: 4 * scale,
textStyle: textStyle,
);
if (ratingBadge != null) {
addWidgetPart(ratingBadge);
}
if (lineMetadata.contentRating != null) addTextPart(formatContentRating(lineMetadata.contentRating!));
if (lineMetadata.durationMs != null) addTextPart(formatDurationTextual(lineMetadata.durationMs!));
if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) {
addTextPart(formatFullDate(lineMetadata.originallyAvailableAt!));
} else if (lineMetadata.year != null) {
addTextPart(lineMetadata.year.toString());
}
for (final label in qualityLabels) {
addTextPart(label);
}
if (children.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
);
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../media/media_item.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({
super.key,
required this.value,
required this.fallbackIcon,
this.imageUri,
this.fallbackText,
this.textStyle,
this.foregroundColor,
this.backgroundColor,
this.iconSize,
this.padding,
this.spacing,
}) : variant = MediaRatingBadgeVariant.chip;
const MediaRatingBadge.inline({
super.key,
required this.value,
required this.fallbackIcon,
this.imageUri,
this.fallbackText,
this.textStyle,
this.foregroundColor,
this.iconSize,
this.spacing,
}) : variant = MediaRatingBadgeVariant.inline,
backgroundColor = null,
padding = EdgeInsets.zero;
final String? imageUri;
final double value;
final IconData fallbackIcon;
final String? fallbackText;
final MediaRatingBadgeVariant variant;
final TextStyle? textStyle;
final Color? foregroundColor;
final Color? backgroundColor;
final double? iconSize;
final EdgeInsetsGeometry? padding;
final double? spacing;
static MediaRatingBadge? inlineForMedia({
required MediaItem item,
MediaItem? fallbackItem,
TextStyle? textStyle,
Color? foregroundColor,
double? iconSize,
double? spacing,
}) {
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,
foregroundColor: foregroundColor,
iconSize: iconSize,
spacing: spacing,
textStyle: textStyle,
);
}
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;
}
@override
Widget build(BuildContext context) {
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),
],
);
if (isInline) return content;
return Container(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: backgroundColor ?? colorScheme.secondaryContainer.withValues(alpha: 0.8),
borderRadius: const BorderRadius.all(Radius.circular(100)),
),
child: content,
);
}
}
class _MediaRatingBadgeData {
const _MediaRatingBadgeData({
required this.value,
required this.fallbackIcon,
required this.fallbackText,
this.imageUri,
});
final String? imageUri;
final double value;
final IconData fallbackIcon;
final String fallbackText;
}
+53 -21
View File
@@ -17,6 +17,7 @@ import '../utils/layout_constants.dart';
import '../utils/media_image_helper.dart';
import 'app_icon.dart';
import 'fitting_title_text.dart';
import 'media_rating_badge.dart';
import 'optimized_media_image.dart' show blurArtwork;
class TvSpotlightBackground extends StatelessWidget {
@@ -312,27 +313,58 @@ class TvSpotlightBackground extends StatelessWidget {
final scale = _scale(context);
final colorScheme = Theme.of(context).colorScheme;
final episodeLabel = formatSeasonEpisodeLabel(media.parentIndex, media.index);
final parts = [
if (media.isEpisode && episodeLabel != null) episodeLabel,
if (media.isMovie) t.discover.movie else if (media.isShow) t.discover.tvShow,
if (media.rating != null) '${formatRating(media.rating!)}',
if (media.contentRating != null) formatContentRating(media.contentRating!),
if (media.durationMs != null) formatDurationTextual(media.durationMs!),
if (media.isEpisode && media.originallyAvailableAt != null)
formatFullDate(media.originallyAvailableAt!)
else if (media.year != null)
media.year.toString(),
];
return Text(
parts.join(''),
maxLines: 1,
overflow: .ellipsis,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: _metadataFontSize(scale),
fontWeight: .w700,
letterSpacing: 0.1,
),
final textStyle = TextStyle(
color: colorScheme.onSurface,
fontSize: _metadataFontSize(scale),
fontWeight: .w700,
letterSpacing: 0.1,
);
final children = <Widget>[];
void addSeparator() {
if (children.isNotEmpty) children.add(Text('', maxLines: 1, style: textStyle));
}
void addTextPart(String text) {
addSeparator();
children.add(Text(text, maxLines: 1, style: textStyle));
}
void addWidgetPart(Widget widget) {
addSeparator();
children.add(widget);
}
if (media.isEpisode && episodeLabel != null) addTextPart(episodeLabel);
if (media.isMovie) {
addTextPart(t.discover.movie);
} else if (media.isShow) {
addTextPart(t.discover.tvShow);
}
final ratingBadge = MediaRatingBadge.inlineForMedia(
item: media,
foregroundColor: textStyle.color,
iconSize: textStyle.fontSize,
spacing: 4 * scale,
textStyle: textStyle,
);
if (ratingBadge != null) {
addWidgetPart(ratingBadge);
}
if (media.contentRating != null) addTextPart(formatContentRating(media.contentRating!));
if (media.durationMs != null) addTextPart(formatDurationTextual(media.durationMs!));
if (media.isEpisode && media.originallyAvailableAt != null) {
addTextPart(formatFullDate(media.originallyAvailableAt!));
} else if (media.year != null) {
addTextPart(media.year.toString());
}
if (children.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
);
}
@@ -5,6 +5,7 @@ import 'package:plezy/media/ids.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
@@ -117,6 +118,73 @@ void main() {
expect(tester.widget<AnimatedOpacity>(revealGate).opacity, 1);
});
testWidgets('TV detail shows Rotten Tomatoes rating badge in metadata line', (tester) async {
await SettingsService.getInstance();
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const movie = MediaItem.plex(
id: 'movie_1',
kind: MediaKind.movie,
title: 'Rotten Tomatoes Movie',
summary: 'The TV detail metadata line should use the rating source badge.',
rating: 6.2,
ratingImage: 'rottentomatoes://image.rating.ripe',
);
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
theme: monoTheme(dark: true),
home: MediaDetailScreen(metadata: movie),
),
),
);
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('62%'), findsOneWidget);
expect(find.byType(SvgPicture), findsOneWidget);
expect(find.textContaining('★ 6.2', findRichText: true), findsNothing);
});
testWidgets('TV detail falls back to Rotten Tomatoes audience rating in metadata line', (tester) async {
await SettingsService.getInstance();
tester.view.physicalSize = const Size(1280, 720);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const movie = MediaItem.plex(
id: 'movie_1',
kind: MediaKind.movie,
title: 'Audience Rating Movie',
summary: 'The TV detail metadata line should use the available audience source badge.',
audienceRating: 8.7,
audienceRatingImage: 'rottentomatoes://image.rating.upright',
);
await tester.pumpWidget(
TranslationProvider(
child: MaterialApp(
theme: monoTheme(dark: true),
home: MediaDetailScreen(metadata: movie),
),
),
);
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('87%'), findsOneWidget);
expect(find.byType(SvgPicture), findsOneWidget);
});
testWidgets('TV detail defaults to first regular season when specials precede it', (tester) async {
await SettingsService.getInstance();