From 33d845dcddf3fa2dd97ab150f324cc4af69cce93 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:29:31 +0200 Subject: [PATCH] feat(tv): show the focused episode's title in the detail hero On the TV detail screen the hero follows the focused episode: its metadata line and description update, but the episode's own title is nowhere in the hero. The only copy is the rail card's subtitle, which truncates for most titles, so the viewer cannot read what they are about to play. Add a title line between the show logo and the episode metadata line, reserved in the hero's height budget like the other rows, announced in the hero's accessibility label, and inside the focusable info block that opens the details sheet. The hide-spoilers path no longer substitutes the episode title for a missing summary, since the title line already names it. close #2217 --- lib/screens/media_detail_screen.dart | 66 +++++++++++---- test/screens/media_detail_screen_test.dart | 98 +++++++++++++++++++++- 2 files changed, 143 insertions(+), 21 deletions(-) diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 82bc91472..f9d355c48 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -3535,6 +3535,10 @@ class _MediaDetailScreenState extends State }) { final theme = Theme.of(context); final description = _tvDetailDescription(metadata, hideSpoilers: hideSpoilers); + // The focused episode's own title. The logo/title slot above keeps the + // show's name, so without this line the episode title exists only on the + // (usually truncated) rail card (#2217). + final episodeTitle = _tvDetailFocusedEpisode.value?.title; final foregroundColor = _tvDetailForegroundColor(context); final mutedForegroundColor = foregroundColor.withValues(alpha: 0.78); @@ -3546,6 +3550,8 @@ class _MediaDetailScreenState extends State final desiredLogoHeight = 220 * scale; final minLogoHeight = 60 * scale; final desiredLogoWidth = 790 * scale; + final episodeTitleLineHeight = 30 * scale; + final episodeTitleGap = 4 * scale; final metadataLineHeight = 22 * scale; final genreLineHeight = 22 * scale; final genreGap = 8 * scale; @@ -3556,6 +3562,8 @@ class _MediaDetailScreenState extends State final actionHeight = _tvDetailActionSize * scale; final actionGap = 16 * scale; 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. final genres = metadata.genres ?? const []; @@ -3566,7 +3574,13 @@ class _MediaDetailScreenState extends State for (var lines = hasDescription ? 3 : 0; lines >= 0; lines--) { final descriptionHeight = lines > 0 ? summaryGap + (summaryLineHeight * lines) : 0.0; final reservedHeight = - logoMetadataGap + metadataLineHeight + genreBlockHeight + descriptionHeight + actionGap + actionHeight; + logoMetadataGap + + episodeTitleBlockHeight + + metadataLineHeight + + genreBlockHeight + + descriptionHeight + + actionGap + + actionHeight; final remainingForLogo = availableHeight - reservedHeight; if (remainingForLogo >= minLogoHeight || lines == 0) { summaryMaxLines = lines; @@ -3579,6 +3593,7 @@ class _MediaDetailScreenState extends State final descriptionHeight = summaryMaxLines > 0 ? summaryGap + (summaryLineHeight * summaryMaxLines) : 0.0; final contentHeight = (showLogo ? logoHeight + logoMetadataGap : 0) + + episodeTitleBlockHeight + metadataLineHeight + genreBlockHeight + descriptionHeight + @@ -3654,6 +3669,27 @@ class _MediaDetailScreenState extends State mainAxisSize: .min, crossAxisAlignment: .start, children: [ + if (hasEpisodeTitle) ...[ + SizedBox( + height: episodeTitleLineHeight, + child: Align( + alignment: .centerLeft, + child: Text( + episodeTitle, + key: const ValueKey('tv_detail_episode_title'), + maxLines: 1, + overflow: .ellipsis, + style: TextStyle( + color: foregroundColor, + fontSize: 24 * scale, + fontWeight: .w700, + height: 1.2, + ), + ), + ), + ), + SizedBox(height: episodeTitleGap), + ], SizedBox( height: metadataLineHeight, child: Align( @@ -3775,7 +3811,10 @@ class _MediaDetailScreenState extends State } add(metadata.displayTitle); - if (!identical(lineMetadata, metadata)) add(lineMetadata.displayTitle); + if (!identical(lineMetadata, metadata)) { + add(lineMetadata.displayTitle); + add(lineMetadata.title); + } for (final part in _tvDetailMetadataParts(metadata)) { add(switch (part) { @@ -3873,30 +3912,21 @@ class _MediaDetailScreenState extends State final focusedEpisode = _tvDetailFocusedEpisode.value; if (focusedEpisode == null) return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); - final episodeDescription = _tvDetailItemDescription( - focusedEpisode, - hideSpoilers: hideSpoilers, - showSpoilerFallback: false, - ); + final episodeDescription = _tvDetailItemDescription(focusedEpisode, hideSpoilers: hideSpoilers); if (episodeDescription != null) return episodeDescription; final season = _tvDetailSeasonForEpisode(focusedEpisode, metadata); final seasonDescription = season == null ? null : _tvDetailItemDescription(season, hideSpoilers: hideSpoilers); if (seasonDescription != null) return seasonDescription; - final showDescription = _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); - if (showDescription != null) return showDescription; - - if (hideSpoilers && focusedEpisode.shouldHideSpoiler) return focusedEpisode.title; - return null; + return _tvDetailItemDescription(metadata, hideSpoilers: hideSpoilers); } - String? _tvDetailItemDescription(MediaItem item, {required bool hideSpoilers, bool showSpoilerFallback = true}) { - final shouldHideSpoiler = hideSpoilers && item.shouldHideSpoiler; - final summary = shouldHideSpoiler ? null : item.summary; - if (summary != null && summary.isNotEmpty) return summary; - if (showSpoilerFallback && shouldHideSpoiler && item.isEpisode) return item.title; - return null; + /// Spoiler-hidden episodes get no summary; the episode title line in the + /// hero already names them, so nothing else stands in for the text. + String? _tvDetailItemDescription(MediaItem item, {required bool hideSpoilers}) { + final summary = hideSpoilers && item.shouldHideSpoiler ? null : item.summary; + return summary != null && summary.isNotEmpty ? summary : null; } MediaItem? _tvDetailSeasonForEpisode(MediaItem episode, MediaItem metadata) { diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 8cae084b5..8fe0ca8ec 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -42,8 +42,10 @@ import 'package:plezy/utils/video_player_navigation.dart'; import 'package:plezy/widgets/collapsible_text.dart'; import 'package:plezy/widgets/cycling_media_backdrop.dart'; import 'package:plezy/widgets/episode_card.dart'; +import 'package:plezy/widgets/fitted_metadata_line.dart'; import 'package:plezy/widgets/fitting_title_text.dart'; import 'package:plezy/widgets/tv_browse_rail.dart'; +import 'package:plezy/widgets/media_card.dart'; import 'package:plezy/widgets/media_details_sheet.dart'; import 'package:provider/provider.dart'; @@ -980,13 +982,103 @@ void main() { await tester.pump(); await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); - expect(find.text('Episode 2'), findsNothing); + expect(find.descendant(of: find.byType(MediaCard), matching: find.text('Episode 2')), findsNothing); season2Completer.complete([episode2]); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Episode 2'), findsOneWidget); + 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 { + final semantics = tester.ensureSemantics(); + await SettingsService.getInstance(); + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final show = testMediaItem( + id: 'show_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + title: 'The Show', + summary: 'The show summary.', + serverId: 'server_1', + serverName: 'Server', + ); + final season = testMediaItem( + id: 'season_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 1', + index: 1, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final episode = testMediaItem( + id: 'episode_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'The One Where the Title Matters', + summary: 'The episode summary.', + index: 1, + parentId: season.id, + parentIndex: season.index, + grandparentId: show.id, + grandparentTitle: show.title, + serverId: show.serverId, + serverName: show.serverName, + ); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season], + season.id: [episode], + }, + ); + final provider = testMultiServer(clients: [client]).provider; + + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: withProfileNavigationScope( + child: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + + 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). + expect(heroTitle, findsOneWidget); + expect(tester.widget(heroTitle).data, 'The One Where the Title Matters'); + expect(find.text('The episode summary.'), findsOneWidget); + // The title sits above the episode's metadata line, inside the block that + // opens the details sheet. + final metadataLine = find.byType(FittedMetadataLine); + expect(tester.getBottomLeft(heroTitle).dy, lessThanOrEqualTo(tester.getTopLeft(metadataLine).dy)); + expect( + find.descendant(of: find.byKey(const ValueKey('tv_detail_information_semantics')), matching: heroTitle), + findsOneWidget, + ); + expect(tester.getSemantics(information).label, contains('The Show, The One Where the Title Matters, S1 E1')); + semantics.dispose(); }); testWidgets('TV detail episode activation bypasses the open-details preference', (tester) async { @@ -1082,7 +1174,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Episode 1'), findsOneWidget); + expect(find.descendant(of: find.byType(MediaCard), matching: find.text('Episode 1')), findsOneWidget); observer.pushedRouteNames.clear(); tester.state(find.byType(TvBrowseRail)).requestFocus(); await tester.pump();