fix(tv): let the detail hero and rail describe the focused episode once
The TV show detail page spent its hero rows on information that never changes while browsing episodes (the show's genres, the file's stream quality) while the rail cards repeated the show name on every card and pushed the episode title into a truncated subtitle. The Play button also kept naming the on-deck episode while the hero described the focused one. Rail cards inside the show now headline the episode title with `S1 E3 · 23min` as subtitle; genres and quality labels move from the hero to the details sheet (which now also names the episode and takes the show's genres explicitly); Play follows the hero's episode and falls back to on-deck when nothing is focused. The freed rows go back to the logo and the third description line. close #2217
This commit is contained in:
@@ -42,14 +42,15 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
: null;
|
||||
|
||||
Future<void> onPlayPressed() async {
|
||||
// For TV shows, play the OnDeck episode if available
|
||||
// Otherwise, play the first episode of the first season
|
||||
// For TV shows, play the episode the hero describes (focused on TV,
|
||||
// otherwise on-deck); with neither, the first episode of the first season.
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
appLogger.d('Playing on deck episode: ${_onDeckEpisode!.title}');
|
||||
final episode = _showPlayEpisode();
|
||||
if (episode != null) {
|
||||
appLogger.d('Playing episode: ${episode.title}');
|
||||
await navigateToVideoPlayerWithRefresh(
|
||||
context,
|
||||
metadata: _onDeckEpisode!,
|
||||
metadata: episode,
|
||||
isOffline: widget.isOffline,
|
||||
onRefresh: _refreshWatchState,
|
||||
);
|
||||
|
||||
@@ -3492,6 +3492,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
initialHubId: _tvDetailInitialHubId(metadata),
|
||||
initialItemId: _tvDetailInitialItemId(metadata),
|
||||
episodePosterModeForHub: _tvDetailEpisodePosterModeForHub,
|
||||
showTitleImpliedForHub: _isTvDetailEpisodeHub,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -3553,8 +3554,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final episodeTitleLineHeight = 30 * scale;
|
||||
final episodeTitleGap = 4 * scale;
|
||||
final metadataLineHeight = 22 * scale;
|
||||
final genreLineHeight = 22 * scale;
|
||||
final genreGap = 8 * scale;
|
||||
final logoMetadataGap = 14 * scale;
|
||||
final summaryGap = 10 * scale;
|
||||
final summaryFontSize = availableHeight < 260 * scale ? 16.2 * scale : 18 * scale;
|
||||
@@ -3564,10 +3563,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final hasDescription = description != null && description.isNotEmpty;
|
||||
final hasEpisodeTitle = episodeTitle != null && episodeTitle.isNotEmpty;
|
||||
final episodeTitleBlockHeight = hasEpisodeTitle ? episodeTitleLineHeight + episodeTitleGap : 0.0;
|
||||
// Genres come from the show/movie, not the focused episode, so the line
|
||||
// stays stable as episode rows gain focus.
|
||||
// Genres belong to the show, not the focused episode, and do not change
|
||||
// while browsing; they live in the details sheet (#2217).
|
||||
final genres = metadata.genres ?? const <String>[];
|
||||
final genreBlockHeight = genres.isEmpty ? 0.0 : genreGap + genreLineHeight;
|
||||
var summaryMaxLines = 0;
|
||||
var logoHeight = 0.0;
|
||||
|
||||
@@ -3577,7 +3575,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
logoMetadataGap +
|
||||
episodeTitleBlockHeight +
|
||||
metadataLineHeight +
|
||||
genreBlockHeight +
|
||||
descriptionHeight +
|
||||
actionGap +
|
||||
actionHeight;
|
||||
@@ -3595,7 +3592,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
(showLogo ? logoHeight + logoMetadataGap : 0) +
|
||||
episodeTitleBlockHeight +
|
||||
metadataLineHeight +
|
||||
genreBlockHeight +
|
||||
descriptionHeight +
|
||||
actionGap +
|
||||
actionHeight;
|
||||
@@ -3697,26 +3693,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
child: _buildTvDetailMetadataLine(context, metadata, scale),
|
||||
),
|
||||
),
|
||||
if (genres.isNotEmpty) ...[
|
||||
SizedBox(height: genreGap),
|
||||
SizedBox(
|
||||
height: genreLineHeight,
|
||||
child: Align(
|
||||
alignment: .centerLeft,
|
||||
child: Text(
|
||||
genres.join(' • '),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: TextStyle(
|
||||
color: mutedForegroundColor,
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: .w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (hasDescription && summaryMaxLines > 0) ...[
|
||||
SizedBox(height: summaryGap),
|
||||
SizedBox(
|
||||
@@ -3754,14 +3730,15 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
|
||||
/// Opens the full-information sheet for the TV hero: the complete metadata
|
||||
/// fields and every rating badge the fitted line may have shed, plus the
|
||||
/// untruncated description (#2042).
|
||||
/// fields, the quality labels and every rating badge the fitted line may
|
||||
/// have shed, the genres, plus the untruncated description (#2042).
|
||||
void _openTvDetailsSheet(BuildContext context, MediaItem metadata, {required bool hideSpoilers}) {
|
||||
final item = _tvDetailFocusedEpisode.value ?? metadata;
|
||||
final description = _tvDetailDescription(metadata, hideSpoilers: hideSpoilers);
|
||||
final genres = metadata.genres ?? const <String>[];
|
||||
unawaited(
|
||||
OverlaySheetController.of(context).show<void>(
|
||||
builder: (_) => MediaDetailsSheet(item: item, description: description),
|
||||
builder: (_) => MediaDetailsSheet(item: item, description: description, genres: genres),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3769,8 +3746,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
/// The ordered metadata fields the TV detail line renders and its announcement
|
||||
/// reads: year first (the desktop hero's chip order) and every score in one
|
||||
/// trailing slot. Drop priorities let the fitted line shed surplus rating
|
||||
/// badges, then quality labels, before the fields that identify the item
|
||||
/// (#1893).
|
||||
/// badges before the fields that identify the item (#1893). Stream quality
|
||||
/// labels stay off the hero line: they describe the file, not the title,
|
||||
/// and remain on the rail cards and in the details sheet (#2217).
|
||||
List<MetadataLinePart> _tvDetailMetadataParts(MediaItem metadata) {
|
||||
final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata;
|
||||
final parts = <MetadataLinePart>[];
|
||||
@@ -3788,9 +3766,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (lineMetadata.durationMs != null) {
|
||||
parts.add(MetadataLineText(formatDurationTextual(lineMetadata.durationMs!), dropPriority: 1));
|
||||
}
|
||||
for (final label in buildMediaQualityLabels(lineMetadata)) {
|
||||
parts.add(MetadataLineText(label, dropPriority: 3));
|
||||
}
|
||||
final ratings = mediaRatingsFor(lineMetadata, fallbackItem: metadata);
|
||||
if (ratings.isNotEmpty) parts.add(MetadataLineRatings(ratings, dropPriority: 4));
|
||||
|
||||
@@ -4662,11 +4637,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// The episode a show's Play button plays. On TV the hero follows the rail's
|
||||
/// focused episode, so Play must agree with what the hero describes (#2217);
|
||||
/// with nothing focused (and everywhere off TV) it is the on-deck episode.
|
||||
MediaItem? _showPlayEpisode() => _tvDetailFocusedEpisode.value ?? _onDeckEpisode;
|
||||
|
||||
String _getPlayButtonLabel(MediaItem metadata) {
|
||||
// For TV shows - use compact S1E1 format
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
final episode = _onDeckEpisode!;
|
||||
final episode = _showPlayEpisode();
|
||||
if (episode != null) {
|
||||
final seasonNum = episode.parentIndex ?? 0;
|
||||
final episodeNum = episode.index ?? 0;
|
||||
|
||||
@@ -4686,10 +4666,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
IconData _getPlayButtonIcon(MediaItem metadata) {
|
||||
// For TV shows
|
||||
if (metadata.isShow) {
|
||||
if (_onDeckEpisode != null) {
|
||||
final episode = _fresh(_onDeckEpisode!);
|
||||
final episode = _showPlayEpisode();
|
||||
if (episode != null) {
|
||||
final fresh = _fresh(episode);
|
||||
// Check if episode has been partially watched
|
||||
if (episode.viewOffsetMs != null && episode.viewOffsetMs! > 0) {
|
||||
if (fresh.viewOffsetMs != null && fresh.viewOffsetMs! > 0) {
|
||||
return Symbols.resume_rounded; // Resume icon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,12 @@ class MediaCard extends StatefulWidget {
|
||||
final EpisodePosterMode? episodePosterModeOverride;
|
||||
final bool fullBleedImage;
|
||||
|
||||
/// The card sits inside its own show, as in the TV detail rail. The show
|
||||
/// name is implied by the page, so an episode's own title is the headline
|
||||
/// and the subtitle carries the episode number and runtime instead of
|
||||
/// repeating the show on every card (#2217).
|
||||
final bool showTitleImplied;
|
||||
|
||||
/// Paint-time black tint amount for the artwork, from 0 (clear) to 1 (black).
|
||||
final Animation<double>? artworkDim;
|
||||
|
||||
@@ -262,6 +268,7 @@ class MediaCard extends StatefulWidget {
|
||||
this.libraryName,
|
||||
this.episodePosterModeOverride,
|
||||
this.fullBleedImage = false,
|
||||
this.showTitleImplied = false,
|
||||
this.artworkDim,
|
||||
this.cardShapeOverride,
|
||||
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
|
||||
@@ -665,7 +672,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
SizedBox(
|
||||
height: posterHeight != null ? 2 : context.settingsRead(SettingsService.gridSpacing).posterTitleGap,
|
||||
),
|
||||
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
|
||||
if (widget.onTap == null && item is MediaItem && !_impliesShowTitle(item) && _hasClickableTitle(item))
|
||||
_ClickableText(
|
||||
text: item.displayTitle,
|
||||
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
|
||||
@@ -674,7 +681,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
else
|
||||
ExcludeSemantics(
|
||||
child: Text(
|
||||
item is MediaPlaylist ? item.title : (item as MediaItem).displayTitle,
|
||||
switch (item) {
|
||||
MediaPlaylist(:final title) => title,
|
||||
final MediaItem episode when _impliesShowTitle(episode) => episode.title ?? episode.displayTitle,
|
||||
_ => (item as MediaItem).displayTitle,
|
||||
},
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
|
||||
@@ -688,6 +699,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
item,
|
||||
isOffline: widget.isOffline,
|
||||
enableDetailLinks: widget.onTap == null,
|
||||
showTitleImplied: _impliesShowTitle(item),
|
||||
catalogItem: _catalogItem,
|
||||
),
|
||||
],
|
||||
@@ -696,6 +708,8 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _impliesShowTitle(MediaItem item) => widget.showTitleImplied && item.isEpisode;
|
||||
}
|
||||
|
||||
class _MediaCardList extends StatelessWidget {
|
||||
@@ -1189,6 +1203,7 @@ class _MediaCardHelpers {
|
||||
MediaItem mi, {
|
||||
bool isOffline = false,
|
||||
bool enableDetailLinks = true,
|
||||
bool showTitleImplied = false,
|
||||
CatalogItem? catalogItem,
|
||||
}) {
|
||||
final subtitleStyle = Theme.of(
|
||||
@@ -1234,6 +1249,17 @@ class _MediaCardHelpers {
|
||||
}
|
||||
|
||||
if (mi.isEpisode && mi.parentIndex != null) {
|
||||
if (showTitleImplied) {
|
||||
// The headline already names the episode; identify it by number and
|
||||
// runtime instead of repeating the title.
|
||||
final parts = [
|
||||
'S${mi.parentIndex}${_episodeNumberSuffix(mi)}',
|
||||
if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs),
|
||||
];
|
||||
return ExcludeSemantics(
|
||||
child: Text(parts.join(' · '), maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||
);
|
||||
}
|
||||
if (enableDetailLinks && mi.parentId != null) {
|
||||
return _buildEpisodeSubtitleRow(
|
||||
context,
|
||||
|
||||
@@ -27,7 +27,12 @@ class MediaDetailsSheet extends StatefulWidget {
|
||||
/// more than the hero was allowed to.
|
||||
final String? description;
|
||||
|
||||
const MediaDetailsSheet({super.key, required this.item, this.description});
|
||||
/// Genres of the title the hero belongs to. Episodes rarely carry their
|
||||
/// own, so a hero that follows an episode passes the show's; the TV hero
|
||||
/// itself no longer renders them (#2217).
|
||||
final List<String> genres;
|
||||
|
||||
const MediaDetailsSheet({super.key, required this.item, this.description, this.genres = const []});
|
||||
|
||||
@override
|
||||
State<MediaDetailsSheet> createState() => _MediaDetailsSheetState();
|
||||
@@ -77,7 +82,8 @@ class _MediaDetailsSheetState extends State<MediaDetailsSheet> {
|
||||
final item = widget.item;
|
||||
final labels = _metadataLabels(item);
|
||||
final ratings = mediaRatingsFor(item);
|
||||
final genres = item.genres ?? const <String>[];
|
||||
final genres = widget.genres;
|
||||
final episodeTitle = item.isEpisode ? item.title : null;
|
||||
final description = widget.description;
|
||||
final metadataStyle = theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.1);
|
||||
|
||||
@@ -99,6 +105,10 @@ class _MediaDetailsSheetState extends State<MediaDetailsSheet> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (episodeTitle != null && episodeTitle.isNotEmpty) ...[
|
||||
Text(episodeTitle, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
if (labels.isNotEmpty || ratings.isNotEmpty)
|
||||
// The hero line's free-flowing look, but wrapping instead
|
||||
// of shedding: plain bulleted text with the badge run
|
||||
|
||||
@@ -350,6 +350,11 @@ class TvBrowseRail extends StatefulWidget {
|
||||
final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub;
|
||||
final double Function(MediaHub hub)? widePosterScaleForHub;
|
||||
|
||||
/// Hubs whose episode cards belong to the show this rail sits under, so
|
||||
/// cards headline the episode title instead of the show name
|
||||
/// ([MediaCard.showTitleImplied]).
|
||||
final bool Function(MediaHub hub)? showTitleImpliedForHub;
|
||||
|
||||
/// Explicit background bleed override. When null, the bleed target is read
|
||||
/// from [MainScreenFocusScope] (offset aspect) inside the bleed widget
|
||||
/// itself, so sidebar flips never rebuild the rail — only the bleed layer.
|
||||
@@ -383,6 +388,7 @@ class TvBrowseRail extends StatefulWidget {
|
||||
this.autofocus = false,
|
||||
this.episodePosterModeForHub,
|
||||
this.widePosterScaleForHub,
|
||||
this.showTitleImpliedForHub,
|
||||
this.backgroundBleedLeft,
|
||||
});
|
||||
|
||||
@@ -1718,6 +1724,7 @@ class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixi
|
||||
usesContinueWatchingAction: _usesContinueWatchingAction(hub),
|
||||
mixedHubContext: metrics.isMixedHub,
|
||||
episodePosterModeOverride: episodePosterMode,
|
||||
showTitleImplied: widget.showTitleImpliedForHub?.call(hub) ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -328,7 +328,7 @@ void main() {
|
||||
expect(find.byType(SvgPicture), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TV detail metadata line keeps quality labels visible beside a full set of scores', (tester) async {
|
||||
testWidgets('TV detail metadata line keeps quality labels for the sheet and every field on screen', (tester) async {
|
||||
await SettingsService.getInstance();
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
@@ -370,17 +370,16 @@ void main() {
|
||||
// Year opens the line; the type label is gone.
|
||||
expect(find.text('Movie'), findsNothing);
|
||||
expect(find.text('2017'), findsOneWidget);
|
||||
expect(find.text('1080p'), findsOneWidget);
|
||||
// Stream quality describes the file, not the title: off the hero line,
|
||||
// reachable in the details sheet (#2217).
|
||||
expect(find.text('1080p'), findsNothing);
|
||||
|
||||
// The quality label sits fully on screen. Under the old clip-at-the-edge
|
||||
// line the widget still existed but was painted past the right edge.
|
||||
expect(tester.getBottomRight(find.text('1080p')).dx, lessThanOrEqualTo(1280));
|
||||
|
||||
// Desktop chip order: year, certification, runtime, quality.
|
||||
// Desktop chip order: year, certification, runtime.
|
||||
final fieldXs = [
|
||||
for (final text in ['2017', 'PG-13', '1h 46min', '1080p']) tester.getTopLeft(find.text(text)).dx,
|
||||
for (final text in ['2017', 'PG-13', '1h 46min']) tester.getTopLeft(find.text(text)).dx,
|
||||
];
|
||||
expect(fieldXs, orderedEquals([...fieldXs]..sort()));
|
||||
expect(tester.getBottomRight(find.text('1h 46min')).dx, lessThanOrEqualTo(1280));
|
||||
|
||||
// The test font's 1 em/char advance roughly doubles text width, so at this
|
||||
// viewport most of the ratings slot legitimately gives way — shed as the
|
||||
@@ -389,6 +388,16 @@ void main() {
|
||||
for (final rating in find.byType(SvgPicture).evaluate()) {
|
||||
expect(tester.getBottomRight(find.byWidget(rating.widget)).dx, lessThanOrEqualTo(1280));
|
||||
}
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_detail_info');
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(MediaDetailsSheet), findsOneWidget);
|
||||
expect(find.descendant(of: find.byType(MediaDetailsSheet), matching: find.textContaining('1080p')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TV detail defaults to first regular season when specials precede it', (tester) async {
|
||||
@@ -991,7 +1000,7 @@ void main() {
|
||||
expect(find.descendant(of: find.byType(MediaCard), matching: find.text('Episode 2')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TV detail hero names the focused episode above its metadata line', (tester) async {
|
||||
testWidgets('TV detail hero, rail cards, and Play all follow the focused episode', (tester) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
await SettingsService.getInstance();
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
@@ -1005,6 +1014,7 @@ void main() {
|
||||
kind: MediaKind.show,
|
||||
title: 'The Show',
|
||||
summary: 'The show summary.',
|
||||
genres: ['Drama', 'Mystery'],
|
||||
serverId: 'server_1',
|
||||
serverName: 'Server',
|
||||
);
|
||||
@@ -1025,6 +1035,21 @@ void main() {
|
||||
title: 'The One Where the Title Matters',
|
||||
summary: 'The episode summary.',
|
||||
index: 1,
|
||||
durationMs: 1380000,
|
||||
parentId: season.id,
|
||||
parentIndex: season.index,
|
||||
grandparentId: show.id,
|
||||
grandparentTitle: show.title,
|
||||
serverId: show.serverId,
|
||||
serverName: show.serverName,
|
||||
);
|
||||
final episode2 = testMediaItem(
|
||||
id: 'episode_2',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.episode,
|
||||
title: 'The One After',
|
||||
index: 2,
|
||||
durationMs: 1500000,
|
||||
parentId: season.id,
|
||||
parentIndex: season.index,
|
||||
grandparentId: show.id,
|
||||
@@ -1036,7 +1061,7 @@ void main() {
|
||||
show: show,
|
||||
childrenByParent: {
|
||||
show.id: [season],
|
||||
season.id: [episode],
|
||||
season.id: [episode, episode2],
|
||||
},
|
||||
);
|
||||
final provider = testMultiServer(clients: [client]).provider;
|
||||
@@ -1065,7 +1090,7 @@ void main() {
|
||||
final heroTitle = find.byKey(const ValueKey('tv_detail_episode_title'));
|
||||
final information = find.bySemanticsIdentifier('tv_detail_information');
|
||||
|
||||
// The card truncates long titles; the hero line is the readable copy (#2217).
|
||||
// The hero line is the readable copy of the title (#2217).
|
||||
expect(heroTitle, findsOneWidget);
|
||||
expect(tester.widget<Text>(heroTitle).data, 'The One Where the Title Matters');
|
||||
expect(find.text('The episode summary.'), findsOneWidget);
|
||||
@@ -1078,6 +1103,30 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.getSemantics(information).label, contains('The Show, The One Where the Title Matters, S1 E1'));
|
||||
|
||||
// Genres are the show's and never change while browsing: not a hero row.
|
||||
// They stay in the announcement because the sheet the block opens has them.
|
||||
expect(find.text('Drama • Mystery'), findsNothing);
|
||||
expect(tester.getSemantics(information).label, contains('Drama, Mystery'));
|
||||
|
||||
// Rail cards sit inside their own show: the episode title is the headline
|
||||
// and the subtitle identifies it by number and runtime — no show name.
|
||||
final cards = find.byType(MediaCard);
|
||||
expect(find.descendant(of: cards, matching: find.text('The One Where the Title Matters')), findsOneWidget);
|
||||
expect(find.descendant(of: cards, matching: find.text('S1 E1 · 23min')), findsOneWidget);
|
||||
expect(find.descendant(of: cards, matching: find.text('The One After')), findsOneWidget);
|
||||
expect(find.descendant(of: cards, matching: find.text('S1 E2 · 25min')), findsOneWidget);
|
||||
expect(find.descendant(of: cards, matching: find.text('The Show')), findsNothing);
|
||||
|
||||
// Play agrees with the hero: the focused episode, not a stale on-deck.
|
||||
expect(find.text('S1E1'), findsOneWidget);
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(tester.widget<Text>(heroTitle).data, 'The One After');
|
||||
expect(find.text('S1E2'), findsOneWidget);
|
||||
expect(find.text('S1E1'), findsNothing);
|
||||
semantics.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ void main() {
|
||||
WidgetTester tester, {
|
||||
required MediaItem item,
|
||||
String? description,
|
||||
List<String> genres = const [],
|
||||
Size size = const Size(700, 800),
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
@@ -34,7 +35,7 @@ void main() {
|
||||
child: MaterialApp(
|
||||
home: InputModeTracker(
|
||||
child: Scaffold(
|
||||
body: MediaDetailsSheet(item: item, description: description),
|
||||
body: MediaDetailsSheet(item: item, description: description, genres: genres),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -87,7 +88,7 @@ void main() {
|
||||
|
||||
// Narrow enough that the TV hero's fitted line would shed the trailing
|
||||
// rating badges; the sheet must still show every one of them.
|
||||
await pumpSheet(tester, item: movie, description: summary, size: const Size(500, 800));
|
||||
await pumpSheet(tester, item: movie, description: summary, genres: movie.genres!, size: const Size(500, 800));
|
||||
|
||||
expect(find.text('The Last House'), findsOneWidget);
|
||||
// One free-flowing bulleted line (the trailing placeholder is the inline
|
||||
@@ -107,22 +108,30 @@ void main() {
|
||||
expect(descriptionText.overflow, isNot(TextOverflow.ellipsis));
|
||||
});
|
||||
|
||||
testWidgets('labels an episode with its number and air date instead of the year', (tester) async {
|
||||
testWidgets('names an episode and labels it with its number and air date instead of the year', (tester) async {
|
||||
const episode = MediaItem.plex(
|
||||
id: 'episode_1',
|
||||
kind: MediaKind.episode,
|
||||
title: 'Chapter Three',
|
||||
grandparentTitle: 'The Show',
|
||||
parentIndex: 1,
|
||||
index: 3,
|
||||
originallyAvailableAt: '2026-02-14',
|
||||
year: 2026,
|
||||
);
|
||||
|
||||
await pumpSheet(tester, item: episode);
|
||||
await pumpSheet(tester, item: episode, genres: const ['Drama']);
|
||||
|
||||
// Header is the show; the episode's own title leads the body, above the
|
||||
// metadata line, and the show's genres follow it (#2217).
|
||||
expect(find.text('The Show'), findsOneWidget);
|
||||
final title = find.text('Chapter Three');
|
||||
final metadataLine = tester.widget<Text>(find.textContaining('S1 E3'));
|
||||
expect(title, findsOneWidget);
|
||||
expect(tester.getBottomLeft(title).dy, lessThanOrEqualTo(tester.getTopLeft(find.textContaining('S1 E3')).dy));
|
||||
// Episode label and air date only — no appended standalone year.
|
||||
expect(metadataLine.textSpan!.toPlainText(), 'S1 E3 • ${formatAbbreviatedDate('2026-02-14')}');
|
||||
expect(find.text('Drama'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('D-pad up and down page a description taller than the sheet', (tester) async {
|
||||
|
||||
Reference in New Issue
Block a user