diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 61b2119a4..a2683957c 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -42,14 +42,15 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { : null; Future 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, ); diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index f9d355c48..4608ec378 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3492,6 +3492,7 @@ class _MediaDetailScreenState extends State initialHubId: _tvDetailInitialHubId(metadata), initialItemId: _tvDetailInitialItemId(metadata), episodePosterModeForHub: _tvDetailEpisodePosterModeForHub, + showTitleImpliedForHub: _isTvDetailEpisodeHub, ), ), ], @@ -3553,8 +3554,6 @@ class _MediaDetailScreenState extends State 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 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 []; - final genreBlockHeight = genres.isEmpty ? 0.0 : genreGap + genreLineHeight; var summaryMaxLines = 0; var logoHeight = 0.0; @@ -3577,7 +3575,6 @@ class _MediaDetailScreenState extends State logoMetadataGap + episodeTitleBlockHeight + metadataLineHeight + - genreBlockHeight + descriptionHeight + actionGap + actionHeight; @@ -3595,7 +3592,6 @@ class _MediaDetailScreenState extends State (showLogo ? logoHeight + logoMetadataGap : 0) + episodeTitleBlockHeight + metadataLineHeight + - genreBlockHeight + descriptionHeight + actionGap + actionHeight; @@ -3697,26 +3693,6 @@ class _MediaDetailScreenState extends State 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 } /// 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 []; unawaited( OverlaySheetController.of(context).show( - builder: (_) => MediaDetailsSheet(item: item, description: description), + builder: (_) => MediaDetailsSheet(item: item, description: description, genres: genres), ), ); } @@ -3769,8 +3746,9 @@ class _MediaDetailScreenState extends State /// 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 _tvDetailMetadataParts(MediaItem metadata) { final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata; final parts = []; @@ -3788,9 +3766,6 @@ class _MediaDetailScreenState extends State 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 ); } + /// 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 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 } } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 4408c337f..e2fb5af8b 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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? 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 with ContextMenuTapMixin with ContextMenuTapMixin 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 with ContextMenuTapMixin with ContextMenuTapMixin 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, diff --git a/lib/widgets/media_details_sheet.dart b/lib/widgets/media_details_sheet.dart index de6060a18..53a908231 100644 --- a/lib/widgets/media_details_sheet.dart +++ b/lib/widgets/media_details_sheet.dart @@ -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 genres; + + const MediaDetailsSheet({super.key, required this.item, this.description, this.genres = const []}); @override State createState() => _MediaDetailsSheetState(); @@ -77,7 +82,8 @@ class _MediaDetailsSheetState extends State { final item = widget.item; final labels = _metadataLabels(item); final ratings = mediaRatingsFor(item); - final genres = item.genres ?? const []; + 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 { 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 diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 705d8b723..08fd87e70 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -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 with TickerProviderStateMixi usesContinueWatchingAction: _usesContinueWatchingAction(hub), mixedHubContext: metrics.isMixedHub, episodePosterModeOverride: episodePosterMode, + showTitleImplied: widget.showTitleImpliedForHub?.call(hub) ?? false, ); } diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 8fe0ca8ec..45f4be2f0 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -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(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(heroTitle).data, 'The One After'); + expect(find.text('S1E2'), findsOneWidget); + expect(find.text('S1E1'), findsNothing); semantics.dispose(); }); diff --git a/test/widgets/media_details_sheet_test.dart b/test/widgets/media_details_sheet_test.dart index 703f26913..2d6aac7c5 100644 --- a/test/widgets/media_details_sheet_test.dart +++ b/test/widgets/media_details_sheet_test.dart @@ -23,6 +23,7 @@ void main() { WidgetTester tester, { required MediaItem item, String? description, + List 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(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 {