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();