diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index fb207241..446ceb3f 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 /// 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 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 } /// 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 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 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 = []; - 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), ); } diff --git a/lib/widgets/media_rating_badge.dart b/lib/widgets/media_rating_badge.dart new file mode 100644 index 00000000..2eaaffd0 --- /dev/null +++ b/lib/widgets/media_rating_badge.dart @@ -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; +} diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index adcc6d18..5c4f38c7 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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 = []; + + 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), ); } diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 87f06ccf..85875066 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -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(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();