@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user