diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 3ce92a4b..61e5724b 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 2fb907fc..ed1aa3be 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index fe3cb049..f8a26614 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 9048acf3..d66218ed 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 0307252c..203a2100 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "Favorited by ${who}", "unairedEpisodes": "${n} not aired yet", "recommendedByPercent": "Recommended by ${percent} of viewers", - "gallery": "Gallery", + "relatedTitles": "Related titles", "background": "Background" }, "totalResults": { diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 30cb4ddf..3961dfcf 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 9b2dd53e..8bb2aabf 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index e86a0131..272ea514 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 2bbddb10..e81cd9f4 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 6c409a90..5177255a 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 86809b42..62ea7585 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index f09d81ec..36e209d4 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 49b234d3..f2969b60 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 7da2b63b..c0653ce9 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 0493bbf4..05a1d97c 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1155,7 +1155,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 82b8f630..d4324459 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 0925bf24..5b3fe329 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1155,7 +1155,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index a14275d5..aff496c9 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -5445,8 +5445,8 @@ class Translations$explore$detail$en { /// en: 'Recommended by ${percent} of viewers' String recommendedByPercent({required Object percent}) => 'Recommended by ${percent} of viewers'; - /// en: 'Gallery' - String get gallery => 'Gallery'; + /// en: 'Related titles' + String get relatedTitles => 'Related titles'; /// en: 'Background' String get background => 'Background'; @@ -6966,7 +6966,7 @@ extension on Translations { 'explore.detail.favoritedBy' => ({required Object who}) => 'Favorited by ${who}', 'explore.detail.unairedEpisodes' => ({required Object n}) => '${n} not aired yet', 'explore.detail.recommendedByPercent' => ({required Object percent}) => 'Recommended by ${percent} of viewers', - 'explore.detail.gallery' => 'Gallery', + 'explore.detail.relatedTitles' => 'Related titles', 'explore.detail.background' => 'Background', 'explore.totalResults' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} result', other: '${n} results', ), 'explore.relation.prequel' => 'Prequel', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 3cf09fd0..67bc1ce3 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index e487304c..73ce29b6 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index b73e060c..8f3d530c 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 894e68e1..12619eba 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 5efed546..aca8f07d 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart index 18c06a4e..c502067b 100644 --- a/lib/models/catalog/catalog_item.dart +++ b/lib/models/catalog/catalog_item.dart @@ -318,11 +318,6 @@ class CatalogItem { /// Provider search relevance. Ordering input only — never rendered. final double? relevance; - /// Extra artwork the provider ships alongside the poster (MAL `pictures`). - /// Plain URLs: these providers publish no pixel widths, so they cannot go - /// through [posterVariants], whose keys must be real widths. - final List? gallery; - /// Production/background prose (MAL `background`) — trivia about how the /// title came to exist, distinct from the plot [overview]. final String? background; @@ -387,7 +382,6 @@ class CatalogItem { this.recommendationPercent, this.playState, this.relevance, - this.gallery, this.background, this.cast, this.unairedEpisodeCount, @@ -461,7 +455,6 @@ class CatalogItem { recommendationCount: recommendationCount ?? detail.recommendationCount, recommendationPercent: recommendationPercent ?? detail.recommendationPercent, relevance: relevance ?? detail.relevance, - gallery: detail.gallery ?? gallery, background: detail.background ?? background, cast: detail.cast ?? cast, recommenders: recommenders ?? detail.recommenders, @@ -563,7 +556,6 @@ class CatalogItem { if (recommendationPercent != null) 'recommendationPercent': recommendationPercent, if (playState != null) 'playState': playState!.toJson(), if (relevance != null) 'relevance': relevance, - if (gallery != null) 'gallery': gallery, if (background != null) 'background': background, if (cast != null) 'cast': [for (final c in cast!) c.toJson()], }; @@ -634,7 +626,6 @@ class CatalogItem { unairedEpisodeCount: json['unairedEpisodeCount'] as int?, recommenders: decodeCatalogList(json['recommenders'], CatalogRecommender.fromJson), relevance: (json['relevance'] as num?)?.toDouble(), - gallery: (json['gallery'] as List?)?.cast(), background: json['background'] as String?, cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson), ); diff --git a/lib/models/mal/mal_anime.dart b/lib/models/mal/mal_anime.dart index 6723fa6a..13f3ce39 100644 --- a/lib/models/mal/mal_anime.dart +++ b/lib/models/mal/mal_anime.dart @@ -283,7 +283,6 @@ class MalAnimeDetail { final List recommendations; final List relations; final MalStatistics? statistics; - final List pictures; final String? background; const MalAnimeDetail({ @@ -291,7 +290,6 @@ class MalAnimeDetail { this.recommendations = const [], this.relations = const [], this.statistics, - this.pictures = const [], this.background, }); @@ -299,7 +297,6 @@ class MalAnimeDetail { final recommendations = json['recommendations']; final relations = json['related_anime']; final statistics = json['statistics']; - final pictures = json['pictures']; final rawBackground = json['background']; final background = rawBackground is String ? rawBackground.trim() : null; return MalAnimeDetail( @@ -313,11 +310,6 @@ class MalAnimeDetail { for (final raw in relations.take(relatedLimit)) ?MalAnimeRelation.fromEntry(raw), ], statistics: statistics is Map ? MalStatistics.fromJson(statistics) : null, - pictures: [ - if (pictures is List) - for (final raw in pictures) - if (raw is Map) MalPicture.fromJson(raw.cast()), - ], background: background == null || background.isEmpty ? null : background, ); } diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index 12a4935c..03b3c11f 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -9,6 +10,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../focus/focusable_action_bar.dart'; import '../focus/focusable_button.dart'; +import '../focus/focusable_wrapper.dart'; import '../focus/dpad_navigator.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; @@ -25,18 +27,19 @@ import '../services/catalog/catalog_library_matcher.dart'; import '../services/catalog/catalog_source.dart'; import '../services/catalog/seerr_catalog_source.dart'; import '../utils/app_logger.dart'; +import '../utils/catalog_navigation_helper.dart'; import '../utils/desktop_window_padding.dart'; import '../utils/formatters.dart'; import '../utils/country_codes.dart'; import '../utils/language_codes.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/platform_detector.dart'; +import '../utils/rating_utils.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/app_bar_back_button.dart'; import '../widgets/app_icon.dart'; import '../widgets/backend_badge.dart'; import '../widgets/cast_member_strip.dart'; -import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/focusable_list_tile.dart'; import '../widgets/hub_section.dart'; import '../widgets/optimized_media_image.dart'; @@ -68,7 +71,7 @@ class _CatalogItemDetailScreenState extends State { final ScrollController _scrollController = ScrollController(); final _spoilerTagFocusNode = FocusNode(debugLabel: 'catalog_spoiler_tags'); List _linkFocusNodes = const []; - List> _relationSectionKeys = const []; + List _relationFocusNodes = const []; List _orderedTags = const []; List _streamingLinks = const []; List _otherLinks = const []; @@ -91,8 +94,10 @@ class _CatalogItemDetailScreenState extends State { /// row only renders once loaded non-empty). List? _related; - /// Labelled franchise edges, kept separate from taste-based recommendations. - List _relations = const []; + /// Labelled franchise edges, flattened to one entry per title. Providers + /// routinely return a single sequel or spin-off per label, and a shelf per + /// label spent a whole hub row — header, scroll row, one card — on it. + List<({CatalogRelationType type, CatalogItem item})> _relationEntries = const []; @override void initState() { @@ -132,6 +137,9 @@ class _CatalogItemDetailScreenState extends State { for (final node in _libraryMatchFocusNodes) { node.dispose(); } + for (final node in _relationFocusNodes) { + node.dispose(); + } _scrollController.dispose(); super.dispose(); } @@ -201,23 +209,32 @@ class _CatalogItemDetailScreenState extends State { try { final detail = await source.fetchDetail(widget.item); if (!mounted) return; - final relations = [ + final relationEntries = [ for (final relation in detail.relations) - if (relation.items.isNotEmpty) relation, + for (final item in relation.items) (type: relation.type, item: item), ]; _syncDetailCollections(detail.item); + _replaceRelationFocusNodes(relationEntries.length); setState(() { _detailItem = detail.item; _cast = detail.cast; _related = detail.related; - _relations = relations; - _relationSectionKeys = [for (var index = 0; index < relations.length; index++) GlobalKey()]; + _relationEntries = relationEntries; }); } catch (e) { appLogger.d('Catalog detail load failed for ${widget.item.identityKey}', error: e); } } + void _replaceRelationFocusNodes(int count) { + for (final node in _relationFocusNodes) { + node.dispose(); + } + _relationFocusNodes = [ + for (var index = 0; index < count; index++) FocusNode(debugLabel: 'catalog_relation_$index'), + ]; + } + bool get _hasTrailer => _item.trailerUrl?.trim().isNotEmpty ?? false; bool get _hasActions => _watchlistSource != null || _requestSource != null || _hasTrailer; @@ -230,7 +247,13 @@ class _CatalogItemDetailScreenState extends State { bool get _hasDetailActions => _hasSpoilerReveal || _linkFocusNodes.isNotEmpty; - bool get _hasHubRows => _relations.isNotEmpty || (_related?.isNotEmpty ?? false); + bool get _hasCast => _cast?.isNotEmpty ?? false; + + bool get _hasRelations => _relationEntries.isNotEmpty; + + bool get _hasRelated => _related?.isNotEmpty ?? false; + + bool get _hasSectionsBelowCast => _hasRelations || _hasRelated; void _revealFocusNode(FocusNode? node, {double alignment = 0.3}) { final focusContext = node?.context; @@ -299,16 +322,18 @@ class _CatalogItemDetailScreenState extends State { } void _requestRelationFocus(int index) { - if (index < 0 || index >= _relationSectionKeys.length) return; - _relationSectionKeys[index].currentState?.requestFocusFromMemory(); + if (index < 0 || index >= _relationFocusNodes.length) return; + final node = _relationFocusNodes[index]; + node.requestFocus(); + _revealFocusNode(node); } void _requestRelatedFocus() { _relatedSectionKey.currentState?.requestFocusFromMemory(); } - void _requestFirstHubFocus() { - if (_relations.isNotEmpty) { + void _focusSectionBelowCast() { + if (_hasRelations) { _requestRelationFocus(0); } else { _requestRelatedFocus(); @@ -316,12 +341,12 @@ class _CatalogItemDetailScreenState extends State { } bool _focusSectionBelowLibraryMatches() { - if (_cast?.isNotEmpty ?? false) { + if (_hasCast) { _requestCastFocus(); return true; } - if (_hasHubRows) { - _requestFirstHubFocus(); + if (_hasSectionsBelowCast) { + _focusSectionBelowCast(); return true; } return false; @@ -330,10 +355,10 @@ class _CatalogItemDetailScreenState extends State { void _focusSectionBelowDetailActions() { if (_hasLibraryMatches) { _requestLibraryMatchFocus(0); - } else if (_cast?.isNotEmpty ?? false) { + } else if (_hasCast) { _requestCastFocus(); - } else { - _requestFirstHubFocus(); + } else if (_hasSectionsBelowCast) { + _focusSectionBelowCast(); } } @@ -363,23 +388,19 @@ class _CatalogItemDetailScreenState extends State { } } - void _focusSectionAboveFirstHub() { - if (_cast?.isNotEmpty ?? false) { + void _focusSectionAboveRelations() { + if (_hasCast) { _requestCastFocus(); - } else if (_hasLibraryMatches) { - _requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1); - } else if (_hasDetailActions) { - _requestLastDetailActionFocus(); } else { - _requestActionBarFocus(); + _focusSectionAboveCast(); } } void _focusSectionAboveRelated() { - if (_relations.isNotEmpty) { - _requestRelationFocus(_relations.length - 1); + if (_hasRelations) { + _requestRelationFocus(_relationEntries.length - 1); } else { - _focusSectionAboveFirstHub(); + _focusSectionAboveRelations(); } } @@ -750,17 +771,29 @@ class _CatalogItemDetailScreenState extends State { return Wrap(spacing: 8, runSpacing: 8, children: chips); } + /// Attributed scores, each behind its own brand badge where the source has + /// one — the same Rotten Tomatoes/IMDb/TMDB marks the media detail screen + /// draws. Sources without a mark (`critic`, `audience`, tracker scores) + /// keep their written label. Widget? _buildRatingsSection(ThemeData theme) { final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName); final chips = []; for (final rating in _item.ratings ?? const []) { final source = _ratingSourceLabel(rating.source); - if (source == null) continue; - var label = '$source ${rating.value.toStringAsFixed(1)}'; + final badge = catalogRatingInfo(rating.source, rating.value); + if (source == null && badge == null) continue; + var label = badge == null ? '$source ${rating.value.toStringAsFixed(1)}' : badge.formattedValue; if (rating.votes case final votes?) { label = '$label (${t.explore.stats.votes(n: compact.format(votes))})'; } - chips.add(StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: label)); + chips.add( + badge == null + ? StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: label) + : StatChip( + leading: SvgPicture.asset(badge.assetPath, width: 14, height: 14, semanticsLabel: source), + label: label, + ), + ); } if (chips.isEmpty) return null; return Column( @@ -833,6 +866,45 @@ class _CatalogItemDetailScreenState extends State { ); } + /// Gap between definition-grid columns, and between relation tiles. + static const double _factColumnSpacing = 24; + static const double _relationTileSpacing = 12; + + /// Definition rows flow into columns once there is room for them: budget, + /// box office and the rest are short values, and one pair per line leaves + /// most of a desktop window empty. + Widget _buildFactGrid(ThemeData theme, List<({String label, String value})> facts) { + return LayoutBuilder( + builder: (context, constraints) { + final columns = _gridColumns(constraints.maxWidth, facts.length); + if (columns < 2) { + return Column( + crossAxisAlignment: .start, + children: [for (final fact in facts) _buildFactRow(theme, fact.label, fact.value)], + ); + } + final columnWidth = (constraints.maxWidth - _factColumnSpacing * (columns - 1)) / columns; + return Wrap( + spacing: _factColumnSpacing, + children: [ + for (final fact in facts) SizedBox(width: columnWidth, child: _buildFactRow(theme, fact.label, fact.value)), + ], + ); + }, + ); + } + + /// Columns that fit [width], never more than there are entries: two facts + /// on a wide window stay two columns instead of stretching to three. + static int _gridColumns(double width, int count) { + final fits = width >= 1100 + ? 3 + : width >= 700 + ? 2 + : 1; + return fits < count ? fits : count; + } + Widget? _buildFactsSection(ThemeData theme) { final item = _item; final locale = LocaleSettings.currentLocale.intlLocaleName; @@ -858,10 +930,7 @@ class _CatalogItemDetailScreenState extends State { if (item.revenue case final revenue?) add(t.explore.detail.revenue, currency.format(revenue)); add(t.explore.detail.contentAdvisory, item.contentAdvisory); if (facts.isEmpty) return null; - return Column( - crossAxisAlignment: .start, - children: [for (final fact in facts) _buildFactRow(theme, fact.label, fact.value)], - ); + return _buildFactGrid(theme, facts); } Widget? _buildRecommendersSection(ThemeData theme) { @@ -890,10 +959,10 @@ class _CatalogItemDetailScreenState extends State { Widget? _buildCrewSection(ThemeData theme) { final credits = _item.credits; if (credits == null || credits.isEmpty) return null; - final rows = []; + final rows = <({String label, String value})>[]; for (final role in CatalogCreditRole.values) { final names = _joinValues(credits.where((credit) => credit.role == role).map((credit) => credit.name)); - if (names != null) rows.add(_buildFactRow(theme, _creditRoleLabel(role), names)); + if (names != null) rows.add((label: _creditRoleLabel(role), value: names)); } if (rows.isEmpty) return null; return Column( @@ -901,7 +970,7 @@ class _CatalogItemDetailScreenState extends State { children: [ Text(t.explore.detail.crew, style: theme.textTheme.titleMedium), const SizedBox(height: 8), - ...rows, + _buildFactGrid(theme, rows), ], ); } @@ -1001,42 +1070,6 @@ class _CatalogItemDetailScreenState extends State { ); } - Widget _buildGallerySection(ThemeData theme, List gallery) { - final cardWidth = CastMemberStrip.responsiveCardWidth(context); - final imageHeight = cardWidth * 1.5; - return Column( - crossAxisAlignment: .start, - children: [ - Text(t.explore.detail.gallery, style: theme.textTheme.titleMedium), - const SizedBox(height: 4), - ExcludeFocus( - child: SizedBox( - height: imageHeight + 10, - child: HorizontalScrollWithArrows( - builder: (scrollController) => ListView.builder( - key: const Key('catalog_detail_gallery'), - addAutomaticKeepAlives: false, - addSemanticIndexes: false, - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: const EdgeInsets.symmetric(vertical: 5), - itemCount: gallery.length, - itemBuilder: (context, index) => Padding( - padding: const EdgeInsets.only(right: 4), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: OptimizedMediaImage.poster(imagePath: gallery[index], width: cardWidth, height: imageHeight), - ), - ), - ), - ), - ), - ), - ], - ); - } - /// Horizontal cast strip — the same [CastMemberStrip] cards as the media /// detail screen. Trakt serves actors with their character; MAL serves /// characters with their role, so the section is titled accordingly. @@ -1058,48 +1091,109 @@ class _CatalogItemDetailScreenState extends State { for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl), ], onNavigateUp: _hasLibraryMatches || _hasDetailActions || _hasActions ? _focusSectionAboveCast : null, - onNavigateDown: _hasHubRows ? _requestFirstHubFocus : null, + onNavigateDown: _hasSectionsBelowCast ? _focusSectionBelowCast : null, debugLabel: 'catalog_cast_row', ), ], ); } - Widget _buildRelationSection(CatalogRelation relation, int index) { - return HubSection( - key: _relationSectionKeys[index], - hub: MediaHub( - id: 'catalog-relation:${_item.source.name}:${_item.identityKey}:${relation.type.name}:$index', - identifier: 'explore.relation.${relation.type.name}', - title: _relationLabel(relation.type), - type: 'mixed', - items: [for (final item in relation.items) item.toMediaItem()], - size: relation.items.length, - ), - focusMemory: _hubFocusMemory, - icon: Symbols.link_rounded, - inset: true, - onNavigateUp: index == 0 ? _focusSectionAboveFirstHub : () => _requestRelationFocus(index - 1), - onVerticalNavigation: (isUp) { - if (isUp) { - if (index == 0) { - _focusSectionAboveFirstHub(); - } else { - _requestRelationFocus(index - 1); - } - return true; - } - if (index + 1 < _relations.length) { - _requestRelationFocus(index + 1); - return true; - } - if (_related?.isNotEmpty ?? false) { - _requestRelatedFocus(); - return true; - } - return false; + /// Labelled franchise edges as compact rows rather than one hub per label: + /// a provider that returns a single sequel used to spend an entire shelf on + /// it. Rows flow into columns on wide viewports, like the facts above. + Widget _buildRelationsSection(ThemeData theme) { + return LayoutBuilder( + builder: (context, constraints) { + final count = _relationEntries.length; + final columns = _gridColumns(constraints.maxWidth, count); + final tileWidth = (constraints.maxWidth - _relationTileSpacing * (columns - 1)) / columns; + return Column( + crossAxisAlignment: .start, + children: [ + Text(t.explore.detail.relatedTitles, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Wrap( + spacing: _relationTileSpacing, + runSpacing: _relationTileSpacing, + children: [ + for (var index = 0; index < count; index++) + SizedBox( + width: tileWidth, + child: _buildRelationTile(theme, index, columns: columns, count: count), + ), + ], + ), + ], + ); }, - cardSizing: HubCardSizing.grid, + ); + } + + Widget _buildRelationTile(ThemeData theme, int index, {required int columns, required int count}) { + final entry = _relationEntries[index]; + final item = entry.item; + final label = _relationLabel(entry.type); + final year = item.year; + void open() => unawaited(navigateToCatalogItem(context, item)); + return FocusableWrapper( + focusNode: _relationFocusNodes[index], + borderRadius: 12, + semanticLabel: '$label: ${item.title}', + onSelect: open, + onNavigateUp: index >= columns ? () => _requestRelationFocus(index - columns) : _focusSectionAboveRelations, + onNavigateDown: index + columns < count + ? () => _requestRelationFocus(index + columns) + : _hasRelated + ? _requestRelatedFocus + : null, + onNavigateLeft: index % columns == 0 ? null : () => _requestRelationFocus(index - 1), + onNavigateRight: (index + 1) % columns == 0 || index + 1 >= count ? null : () => _requestRelationFocus(index + 1), + child: GestureDetector( + onTap: open, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 40, height: 60), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: .start, + mainAxisSize: .min, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 2), + Text( + year == null ? item.title : '${item.title} • $year', + style: theme.textTheme.bodyMedium, + maxLines: 2, + overflow: .ellipsis, + ), + ], + ), + ), + AppIcon( + Symbols.chevron_right_rounded, + fill: 1, + size: 18, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ], + ), + ), + ), ); } @@ -1284,10 +1378,6 @@ class _CatalogItemDetailScreenState extends State { const SizedBox(height: 24), _buildBackgroundSection(theme, background), ], - if (item.gallery case final List gallery when gallery.isNotEmpty) ...[ - const SizedBox(height: 24), - _buildGallerySection(theme, gallery), - ], if (_buildFactsSection(theme) case final Widget facts) ...[ const SizedBox(height: 24), facts, @@ -1320,10 +1410,7 @@ class _CatalogItemDetailScreenState extends State { const SizedBox(height: 28), _buildCastSection(theme, cast), ], - for (var index = 0; index < _relations.length; index++) ...[ - const SizedBox(height: 20), - _buildRelationSection(_relations[index], index), - ], + if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)], if (_related case final List related when related.isNotEmpty) ...[ const SizedBox(height: 20), _buildRelatedSection(related), diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart index 0a906974..3e47eb00 100644 --- a/lib/services/catalog/mal_catalog_source.dart +++ b/lib/services/catalog/mal_catalog_source.dart @@ -131,14 +131,7 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { .add(relationItems[i]); } - final detailPosterUrl = detail.anime.mainPicture?.primary ?? item.posterUrl; - final detailItem = _toCatalogItem( - detail.anime, - null, - statistics: detail.statistics, - gallery: _galleryFor(detail.pictures, detailPosterUrl), - background: detail.background, - ); + final detailItem = _toCatalogItem(detail.anime, null, statistics: detail.statistics, background: detail.background); return CatalogDetail( item: item.enrichedWith(detailItem), cast: cast, @@ -193,7 +186,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { CatalogRankScope? rankingScope, int? recommendationCount, MalStatistics? statistics, - List? gallery, String? background, }) { final title = anime.displayTitle; @@ -247,7 +239,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { sourceMaterial: _sourceMaterialFor(anime.source), isAdult: _isAdultFor(anime.nsfw), recommendationCount: recommendationCount, - gallery: gallery, background: background, ); } @@ -358,15 +349,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource { static DateTime? _dateFor(String? value) => value == null ? null : DateTime.tryParse(value); - static List? _galleryFor(List pictures, String? posterUrl) { - final urls = {}; - for (final picture in pictures) { - final url = picture.primary; - if (url != null && url != posterUrl) urls.add(url); - } - return urls.isEmpty ? null : urls.toList(growable: false); - } - static CatalogRelationType _relationTypeFor(String? type) => switch (type) { 'prequel' => CatalogRelationType.prequel, 'sequel' => CatalogRelationType.sequel, diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index ea5afb66..509a8b24 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -91,10 +91,11 @@ class MalClient implements DisposableTrackerClient { 'nsfw,source,end_date'; /// The existing detail request widened by +22.3% in the measured sample - /// (18,624 -> 22,772 bytes), still with no additional round trip. + /// (18,624 -> 22,772 bytes) — less since the unused `pictures` gallery came + /// back out — still with no additional round trip. static const String detailFields = '$catalogFields,recommendations%7B$catalogFields%7D,' - 'related_anime%7B$catalogFields%7D,statistics,pictures,background'; + 'related_anime%7B$catalogFields%7D,statistics,background'; static const String _characterFields = 'role,main_picture,first_name,last_name'; diff --git a/lib/utils/rating_utils.dart b/lib/utils/rating_utils.dart index d86307d0..2fe2931a 100644 --- a/lib/utils/rating_utils.dart +++ b/lib/utils/rating_utils.dart @@ -1,5 +1,6 @@ -/// Parses Plex ratingImage / audienceRatingImage URIs and returns -/// the corresponding local asset path and a display-formatted value. +/// Resolves the brand badge shown beside a score: Plex's +/// ratingImage/audienceRatingImage URIs, and the attributed source keys the +/// Explore catalog carries instead of them. library; class RatingInfo { @@ -18,22 +19,21 @@ RatingInfo? parseRatingImage(String? imageUri, double? value) { 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), + 'ripe' => RatingInfo(_rtFreshAsset, _percent(value)), + 'rotten' => RatingInfo(_rtRottenAsset, _percent(value)), + 'upright' => RatingInfo(_rtUprightAsset, _percent(value)), + 'spilled' => RatingInfo(_rtSpilledAsset, _percent(value)), _ => null, }; } if (imageUri.startsWith('imdb://')) { - return RatingInfo('assets/rating_icons/imdb.svg', value.toStringAsFixed(1)); + return RatingInfo(_imdbAsset, value.toStringAsFixed(1)); } if (imageUri.startsWith('themoviedb://')) { - return RatingInfo('assets/rating_icons/tmdb.svg', '${(value * 10).toStringAsFixed(0)}%'); + return RatingInfo(_tmdbAsset, _percent(value)); } return null; @@ -41,3 +41,37 @@ RatingInfo? parseRatingImage(String? imageUri, double? value) { /// Whether the URI is a Rotten Tomatoes rating source. bool isRottenTomatoes(String? imageUri) => imageUri != null && imageUri.startsWith('rottentomatoes://'); + +/// The same badge for a [CatalogRatingSource]-style source key. +/// +/// Catalog providers attribute their scores by name (`imdb`, `tmdb`, +/// `rottenTomatoesCritic`, …) and publish no badge URI, so the icon is chosen +/// from the key. Rotten Tomatoes picks fresh/rotten and upright/spilled by the +/// 60% threshold the tomatometer itself uses — the same state Plex encodes in +/// `image.rating.ripe` / `.rotten`. +/// +/// Returns null for keys with no brand badge (`critic`, `audience`, `simkl`, +/// `mal`, `anilist`, `trakt`); those stay labelled with their source name. +RatingInfo? catalogRatingInfo(String source, double value) => switch (source) { + 'imdb' => RatingInfo(_imdbAsset, value.toStringAsFixed(1)), + 'tmdb' => RatingInfo(_tmdbAsset, _percent(value)), + 'rottenTomatoes' || + 'rottenTomatoesCritic' => RatingInfo(value >= _rottenTomatoesFresh ? _rtFreshAsset : _rtRottenAsset, _percent(value)), + 'rottenTomatoesAudience' => RatingInfo( + value >= _rottenTomatoesFresh ? _rtUprightAsset : _rtSpilledAsset, + _percent(value), + ), + _ => null, +}; + +const String _imdbAsset = 'assets/rating_icons/imdb.svg'; +const String _tmdbAsset = 'assets/rating_icons/tmdb.svg'; +const String _rtFreshAsset = 'assets/rating_icons/rt_fresh.svg'; +const String _rtRottenAsset = 'assets/rating_icons/rt_rotten.svg'; +const String _rtUprightAsset = 'assets/rating_icons/rt_upright.svg'; +const String _rtSpilledAsset = 'assets/rating_icons/rt_spilled.svg'; + +/// Ratings are normalized to a 0-10 scale; Rotten Tomatoes is a percentage. +const double _rottenTomatoesFresh = 6.0; + +String _percent(double value) => '${(value * 10).toStringAsFixed(0)}%'; diff --git a/lib/widgets/stat_chip.dart b/lib/widgets/stat_chip.dart index 7a39ac18..c4761419 100644 --- a/lib/widgets/stat_chip.dart +++ b/lib/widgets/stat_chip.dart @@ -9,12 +9,16 @@ class StatChip extends StatelessWidget { final Color? iconColor; final String label; + /// Leading artwork for chips whose source has a brand mark (rating badges). + /// Takes precedence over [icon]. + final Widget? leading; + /// Overrides the default fill. Needed where the chip sits on a surface that /// already uses `surfaceContainerHigh` — the mono theme collapses several /// container roles onto one colour, so the default would be invisible. final Color? backgroundColor; - const StatChip({super.key, this.icon, this.iconColor, required this.label, this.backgroundColor}); + const StatChip({super.key, this.icon, this.iconColor, required this.label, this.leading, this.backgroundColor}); @override Widget build(BuildContext context) { @@ -28,7 +32,13 @@ class StatChip extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (icon != null) ...[AppIcon(icon!, size: 14, fill: 1, color: iconColor), const SizedBox(width: 4)], + if (leading case final leading?) ...[ + leading, + const SizedBox(width: 4), + ] else if (icon != null) ...[ + AppIcon(icon!, size: 14, fill: 1, color: iconColor), + const SizedBox(width: 4), + ], Text(label, style: theme.textTheme.labelMedium), ], ), diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index 3101e5e2..b35bdd36 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_kind.dart'; @@ -20,6 +21,7 @@ import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:plezy/widgets/hub_section.dart'; import 'package:plezy/widgets/media_card.dart'; import 'package:plezy/widgets/optimized_media_image.dart'; import 'package:provider/provider.dart'; @@ -267,7 +269,7 @@ void main() { expect(find.text(t.explore.detail.revealSpoilerTags), findsNothing); }); - testWidgets('ratings row labels every supported score source and its vote count', (tester) async { + testWidgets('ratings row labels every score source without a brand mark', (tester) async { const item = CatalogItem( source: CatalogSourceId.trakt, kind: MediaKind.movie, @@ -275,7 +277,6 @@ void main() { ids: CatalogItemIds(tmdb: 11), ratings: [ CatalogRatingSource(source: 'simkl', value: 8.1, votes: 11), - CatalogRatingSource(source: 'imdb', value: 7.9, votes: 12), CatalogRatingSource(source: 'mal', value: 8.3, votes: 13), CatalogRatingSource(source: 'critic', value: 7.2, votes: 14), CatalogRatingSource(source: 'audience', value: 8.8, votes: 15), @@ -287,12 +288,50 @@ void main() { expect(find.text(t.explore.detail.ratings), findsOneWidget); expect(find.text('Simkl 8.1 (11 votes)'), findsOneWidget); - expect(find.text('IMDb 7.9 (12 votes)'), findsOneWidget); expect(find.text('MyAnimeList 8.3 (13 votes)'), findsOneWidget); expect(find.text('Critics 7.2 (14 votes)'), findsOneWidget); expect(find.text('Audience 8.8 (15 votes)'), findsOneWidget); }); + testWidgets('scores whose source owns a logo render the mark and that source scale', (tester) async { + const item = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'Attributed Movie', + ids: CatalogItemIds(tmdb: 21), + ratings: [ + CatalogRatingSource(source: 'rottenTomatoesCritic', value: 8.4), + CatalogRatingSource(source: 'rottenTomatoesAudience', value: 4.1), + CatalogRatingSource(source: 'imdb', value: 7.9, votes: 12), + CatalogRatingSource(source: 'tmdb', value: 7.5), + ], + ); + final source = _FakeCatalogSource(detail: const CatalogDetail(item: item)); + + await _pumpDetail(tester, source, item: item); + + expect( + tester + .widgetList(find.byType(SvgPicture)) + .map((picture) => picture.bytesLoader) + .whereType() + .map((loader) => loader.assetName), + containsAll(const [ + 'assets/rating_icons/rt_fresh.svg', + 'assets/rating_icons/rt_spilled.svg', + 'assets/rating_icons/imdb.svg', + 'assets/rating_icons/tmdb.svg', + ]), + ); + // The mark carries the attribution, so the chip keeps only the score, on + // the scale that source publishes. + expect(find.text('84%'), findsOneWidget); + expect(find.text('41%'), findsOneWidget); + expect(find.text('7.9 (12 votes)'), findsOneWidget); + expect(find.text('75%'), findsOneWidget); + expect(find.text('${t.explore.ratingSource.rottenTomatoesCritic} 8.4'), findsNothing); + }); + testWidgets('seasonal rank keeps its season window instead of claiming all-time rank', (tester) async { const item = CatalogItem( source: CatalogSourceId.trakt, @@ -371,17 +410,12 @@ void main() { expect(find.byTooltip(t.explore.detail.watchTrailer), findsOneWidget); }); - testWidgets('gallery and background sections render when populated', (tester) async { - const galleryUrls = [ - 'https://cdn.myanimelist.net/images/anime/gallery-1.jpg', - 'https://cdn.myanimelist.net/images/anime/gallery-2.jpg', - ]; + testWidgets('background prose renders as its own section', (tester) async { const item = CatalogItem( source: CatalogSourceId.trakt, kind: MediaKind.movie, title: 'Production Movie', ids: CatalogItemIds(tmdb: 15), - gallery: galleryUrls, background: 'Filmed over three winters.', ); final source = _FakeCatalogSource(detail: const CatalogDetail(item: item)); @@ -390,14 +424,36 @@ void main() { expect(find.text(t.explore.detail.background), findsOneWidget); expect(find.text('Filmed over three winters.'), findsOneWidget); - expect(find.text(t.explore.detail.gallery), findsOneWidget); - final galleryFinder = find.byKey(const Key('catalog_detail_gallery')); - expect(galleryFinder, findsOneWidget); - expect(tester.widget(galleryFinder).scrollDirection, Axis.horizontal); - final images = tester - .widgetList(find.descendant(of: galleryFinder, matching: find.byType(OptimizedMediaImage))) - .map((image) => image.imagePath); - expect(images, galleryUrls); + }); + + testWidgets('budget and box office pair into columns on a wide window and stack on a narrow one', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1400, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + const item = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'Expensive Movie', + ids: CatalogItemIds(tmdb: 22), + budget: 165000000, + revenue: 675000000, + ); + final source = _FakeCatalogSource(detail: const CatalogDetail(item: item)); + + await _pumpDetail(tester, source, item: item); + + final budget = find.text(t.explore.detail.budget); + final revenue = find.text(t.explore.detail.revenue); + expect(tester.getTopLeft(revenue).dy, tester.getTopLeft(budget).dy); + expect(tester.getTopLeft(revenue).dx, greaterThan(tester.getTopLeft(budget).dx)); + + tester.view.physicalSize = const Size(420, 900); + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(revenue).dy, greaterThan(tester.getTopLeft(budget).dy)); + expect(tester.getTopLeft(revenue).dx, tester.getTopLeft(budget).dx); }); testWidgets('all-null metadata renders without an empty optional section header', (tester) async { @@ -420,17 +476,25 @@ void main() { expect(find.text(t.explore.detail.watchOn), findsNothing); expect(find.text(t.explore.cast), findsNothing); expect(find.text(t.discover.moreLikeThis), findsNothing); - expect(find.text(t.explore.detail.gallery), findsNothing); + expect(find.text(t.explore.detail.relatedTitles), findsNothing); expect(find.text(t.explore.detail.background), findsNothing); }); - testWidgets('franchise relations keep their labelled shelf separate from recommendations', (tester) async { - const relationItem = CatalogItem( + testWidgets('single-entry relations share one labelled section instead of a shelf each', (tester) async { + const sequel = CatalogItem( source: CatalogSourceId.trakt, kind: MediaKind.movie, title: 'The Sequel', + year: 2019, + posterUrl: 'https://example.com/sequel.jpg', ids: CatalogItemIds(tmdb: 17), ); + const spinOff = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'The Spin-off', + ids: CatalogItemIds(tmdb: 20), + ); const recommendation = CatalogItem( source: CatalogSourceId.trakt, kind: MediaKind.movie, @@ -442,17 +506,117 @@ void main() { item: _item, related: [recommendation], relations: [ - CatalogRelation(type: CatalogRelationType.sequel, items: [relationItem]), + CatalogRelation(type: CatalogRelationType.sequel, items: [sequel]), + CatalogRelation(type: CatalogRelationType.spinOff, items: [spinOff]), ], ), ); await _pumpDetail(tester, source); - expect(find.text(t.explore.relation.sequel), findsOneWidget); + // Recommendations keep their shelf; two one-title relations do not get one + // each. + expect(find.byType(HubSection), findsOneWidget); expect(find.text(t.discover.moreLikeThis), findsOneWidget); - expect(find.text('The Sequel'), findsOneWidget); expect(find.text('A Similar Movie'), findsOneWidget); + + expect(find.text(t.explore.detail.relatedTitles), findsOneWidget); + expect(find.text(t.explore.relation.sequel), findsOneWidget); + expect(find.text(t.explore.relation.spinOff), findsOneWidget); + expect(find.text('The Sequel • 2019'), findsOneWidget); + expect(find.text('The Spin-off'), findsOneWidget); + expect( + tester.widgetList(find.byType(OptimizedMediaImage)).map((image) => image.imagePath), + contains('https://example.com/sequel.jpg'), + ); + }); + + testWidgets('a relation row opens the catalog detail screen of that title', (tester) async { + const sequel = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'The Sequel', + ids: CatalogItemIds(tmdb: 17), + ); + final source = _FakeCatalogSource( + detail: const CatalogDetail( + item: _item, + relations: [ + CatalogRelation(type: CatalogRelationType.sequel, items: [sequel]), + ], + ), + ); + + await _pumpDetail(tester, source); + await tester.tap(find.text('The Sequel')); + await tester.pumpAndSettle(); + + expect(find.byType(CatalogItemDetailScreen, skipOffstage: false), findsNWidgets(2)); + expect(find.text('The Sequel'), findsOneWidget); + }); + + testWidgets('D-pad walks the relation rows between the cast strip and recommendations', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + const prequel = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'The Prequel', + ids: CatalogItemIds(tmdb: 16), + ); + const sequel = CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'The Sequel', + ids: CatalogItemIds(tmdb: 17), + ); + final source = _FakeCatalogSource( + detail: const CatalogDetail( + item: _item, + cast: [CatalogCastMember(name: 'First Actor', secondary: 'Lead')], + related: [ + CatalogItem( + source: CatalogSourceId.trakt, + kind: MediaKind.movie, + title: 'Related Movie', + ids: CatalogItemIds(tmdb: 2), + ), + ], + relations: [ + CatalogRelation(type: CatalogRelationType.prequel, items: [prequel]), + CatalogRelation(type: CatalogRelationType.sequel, items: [sequel]), + ], + ), + ); + + await _pumpDetail(tester, source); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_cast_row'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_relation_0'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_relation_1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, startsWith('hub_catalog-related:')); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_relation_1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_cast_row'); }); testWidgets('social recommendation keeps its person, reason, and note', (tester) async { diff --git a/test/services/catalog/mal_catalog_source_test.dart b/test/services/catalog/mal_catalog_source_test.dart index 5749d5d9..64b34846 100644 --- a/test/services/catalog/mal_catalog_source_test.dart +++ b/test/services/catalog/mal_catalog_source_test.dart @@ -90,7 +90,7 @@ const _expectedCatalogFields = 'nsfw,source,end_date'; const _expectedDetailFields = '$_expectedCatalogFields,recommendations{$_expectedCatalogFields},' - 'related_anime{$_expectedCatalogFields},statistics,pictures,background'; + 'related_anime{$_expectedCatalogFields},statistics,background'; void main() { // Attack on Titan: split-cour show — one Fribb row per season, same tvdb id. @@ -456,19 +456,6 @@ void main() { 'plan_to_watch': '350000', }, }, - 'pictures': [ - { - 'medium': 'https://cdn.myanimelist.net/images/anime/16498-poster-medium.jpg', - 'large': 'https://cdn.myanimelist.net/images/anime/16498.jpg', - }, - { - 'medium': 'https://cdn.myanimelist.net/images/anime/16498-gallery-medium.jpg', - 'large': 'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg', - }, - {'medium': 'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg'}, - {'medium': 'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg', 'large': ''}, - {'medium': '', 'large': ''}, - ], 'background': 'Created from the original manga.', }), 200, @@ -504,11 +491,6 @@ void main() { expect(detail.item.audience?.onHold, 40000); expect(detail.item.audience?.dropped, 90000); expect(detail.item.audience?.planning, 350000); - expect(detail.item.gallery, [ - 'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg', - 'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg', - 'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg', - ]); expect(detail.item.background, 'Created from the original manga.'); expect(detail.item.posterVariants, isNull); @@ -530,20 +512,13 @@ void main() { expect(detail.relations.single.items.single.title, 'Shingeki no Kyojin Season 3'); }); - test('fetchDetail normalizes a blank background and pictures without URLs to null', () async { + test('fetchDetail normalizes a blank background to null', () async { http.Response respond(http.Request request) { if (request.url.path.endsWith('/characters')) { return http.Response(json.encode({'data': [], 'paging': {}}), 200); } return http.Response( - json.encode({ - ..._node(id: 16498, title: 'Shingeki no Kyojin'), - 'pictures': [ - {}, - {'medium': ' ', 'large': ''}, - ], - 'background': ' \n\t ', - }), + json.encode({..._node(id: 16498, title: 'Shingeki no Kyojin'), 'background': ' \n\t '}), 200, ); } @@ -560,7 +535,6 @@ void main() { final detail = await source.fetchDetail(item); - expect(detail.item.gallery, isNull); expect(detail.item.background, isNull); expect(detail.item.posterVariants, isNull); });