diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index c22b95d4..655a02c0 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -85,6 +85,7 @@ const double _tvDetailEpisodeThumbnailScale = TvBrowseRailLayout.compactEpisodeT const double _tvDetailActionSize = 46; const double _tvDetailActionRailGap = 4; const String _tvDetailSeasonHubIdPrefix = 'detail_season_'; +const String _tvDetailExtrasHubId = 'detail_extras'; const String _tvDetailActorsHubId = 'detail_actors'; const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId'; @@ -848,12 +849,14 @@ class _MediaDetailScreenState extends State double? fontSize, FontWeight fontWeight = FontWeight.bold, double shadowBlur = 8, + Color? color, + Color? shadowColor, }) { final baseStyle = (Theme.of(context).textTheme.displaySmall ?? const TextStyle()).copyWith( - color: Colors.white, + color: color ?? Colors.white, fontWeight: fontWeight, fontSize: fontSize, - shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)], + shadows: [Shadow(color: shadowColor ?? Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)], ); return FittingTitleText(title, style: baseStyle); @@ -1595,7 +1598,8 @@ class _MediaDetailScreenState extends State } } - /// Find the season index matching the initial selection or on-deck episode, or fall back to 0 + /// Find the season index matching the initial selection or on-deck episode, + /// then fall back to the same season the Play button would use. int _findOnDeckSeasonIndex(List seasons) { // Prefer explicit initial season (from navigation) if (widget.initialSeasonIndex != null && seasons.isNotEmpty) { @@ -1603,14 +1607,32 @@ class _MediaDetailScreenState extends State if (idx != -1) return idx; } // Fall back to on-deck episode's season - if (_onDeckEpisode != null && seasons.isNotEmpty) { - final onDeckParentIndex = _onDeckEpisode!.parentIndex; + final onDeckEpisode = _onDeckEpisode; + if (onDeckEpisode != null && seasons.isNotEmpty) { + final onDeckParentId = onDeckEpisode.parentId; + if (onDeckParentId != null) { + final idx = seasons.indexWhere((s) => s.id == onDeckParentId); + if (idx != -1) return idx; + } + + final onDeckParentIndex = onDeckEpisode.parentIndex; if (onDeckParentIndex != null) { final idx = seasons.indexWhere((s) => s.index == onDeckParentIndex); if (idx != -1) return idx; } } - return 0; + return _defaultPlaybackSeasonIndex(seasons); + } + + int _defaultPlaybackSeasonIndex(List seasons) { + if (seasons.isEmpty) return 0; + final regularSeasonIndex = seasons.indexWhere((season) => (season.index ?? 0) > 0); + return regularSeasonIndex == -1 ? 0 : regularSeasonIndex; + } + + MediaItem? _defaultPlaybackSeason(List seasons) { + if (seasons.isEmpty) return null; + return seasons[_defaultPlaybackSeasonIndex(seasons)]; } /// Fetch episodes for a specific season by index, using cache when available @@ -2730,8 +2752,7 @@ class _MediaDetailScreenState extends State return; } - // Skip Season 0 (Specials) — prefer the first regular season - final firstSeason = _seasons.firstWhere((s) => (s.index ?? 0) > 0, orElse: () => _seasons.first); + final firstSeason = _defaultPlaybackSeason(_seasons)!; // Get episodes of the first season List episodes; @@ -3170,6 +3191,8 @@ class _MediaDetailScreenState extends State }) { final theme = Theme.of(context); final description = _tvDetailDescription(metadata, hideSpoilers: hideSpoilers); + final foregroundColor = _tvDetailForegroundColor(context); + final mutedForegroundColor = foregroundColor.withValues(alpha: 0.78); return LayoutBuilder( builder: (context, constraints) { @@ -3234,6 +3257,8 @@ class _MediaDetailScreenState extends State fontSize: 56 * scale, fontWeight: FontWeight.w800, shadowBlur: 12, + color: foregroundColor, + shadowColor: _tvDetailTitleShadowColor(context), ), ), SizedBox(height: logoMetadataGap), @@ -3254,7 +3279,7 @@ class _MediaDetailScreenState extends State maxLines: summaryMaxLines, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyLarge?.copyWith( - color: Colors.white.withValues(alpha: 0.78), + color: mutedForegroundColor, fontSize: summaryFontSize, height: 1.35, ), @@ -3273,6 +3298,13 @@ class _MediaDetailScreenState extends State ); } + Color _tvDetailForegroundColor(BuildContext context) => Theme.of(context).colorScheme.onSurface; + + Color _tvDetailTitleShadowColor(BuildContext context) { + final brightness = Theme.of(context).colorScheme.brightness; + return brightness == Brightness.dark ? Colors.black.withValues(alpha: 0.5) : Colors.white.withValues(alpha: 0.55); + } + Widget _buildDetailLogoOrTitle( BuildContext context, MediaItem metadata, { @@ -3352,7 +3384,12 @@ class _MediaDetailScreenState extends State parts.join(' • '), maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: Colors.white, fontSize: 18 * scale, fontWeight: FontWeight.w700, letterSpacing: 0.1), + style: TextStyle( + color: _tvDetailForegroundColor(context), + fontSize: 18 * scale, + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), ); } @@ -3454,7 +3491,7 @@ class _MediaDetailScreenState extends State } double _tvDetailWidePosterScaleForHub(MediaHub hub) { - return _isTvDetailEpisodeHub(hub) ? _tvDetailEpisodeThumbnailScale : 1.0; + return _isTvDetailEpisodeHub(hub) || hub.id == _tvDetailExtrasHubId ? _tvDetailEpisodeThumbnailScale : 1.0; } List _tvDetailHubs(MediaItem metadata) { @@ -3494,7 +3531,13 @@ class _MediaDetailScreenState extends State } if (_extras != null && _extras!.isNotEmpty) { hubs.add( - MediaHub(id: 'detail_extras', title: t.discover.extras, type: 'clip', items: _extras!, size: _extras!.length), + MediaHub( + id: _tvDetailExtrasHubId, + title: t.discover.extras, + type: 'clip', + items: _extras!, + size: _extras!.length, + ), ); } hubs.addAll(_relatedHubs.where((hub) => hub.items.isNotEmpty)); @@ -3620,7 +3663,7 @@ class _MediaDetailScreenState extends State IconData _getTvDetailHubIcon(MediaHub hub, int index) { if (hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return Symbols.tv_rounded; if (hub.id == 'detail_episodes') return Symbols.tv_rounded; - if (hub.id == 'detail_extras') return Symbols.theaters_rounded; + if (hub.id == _tvDetailExtrasHubId) return Symbols.theaters_rounded; if (hub.id == _tvDetailActorsHubId) return Symbols.group_rounded; return _getRelatedHubIcon(hub); } @@ -4023,8 +4066,8 @@ class _MediaDetailScreenState extends State // (icon will indicate the difference) return t.discover.playEpisode(season: seasonNum.toString(), episode: episodeNum.toString()); } else { - // No on deck episode, will play first episode - return t.discover.playEpisode(season: '1', episode: '1'); + final seasonNum = _defaultPlaybackSeason(_seasons)?.index ?? 1; + return t.discover.playEpisode(season: seasonNum.toString(), episode: '1'); } } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 8da1d2a9..c91aa8e7 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -311,6 +311,7 @@ class TvBrowseRailState extends State { static const _navigationScrollDuration = Duration(milliseconds: 130); static const _repeatNavigationScrollDuration = Duration(milliseconds: 65); static const _scrollCatchUpViewportDistance = 2.5; + static const _inactiveHubContentOpacity = 0.7; final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); final Map _scrollControllers = {}; @@ -932,16 +933,21 @@ class TvBrowseRailState extends State { children: [ _buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale), SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)), - _buildHubRail( - hub: hub, - hubIndex: hubIndex, - hasFocus: hasFocus, - episodePosterMode: modes[hubIndex], - metrics: metrics, - scale: scale, - leftOverflow: leftOverflow, - interactionExpansion: interactionExpansion, - railViewportWidth: railViewportWidth, + AnimatedOpacity( + opacity: isActive ? 1 : _inactiveHubContentOpacity, + duration: FocusTheme.getAnimationDuration(context), + curve: Curves.easeOutCubic, + child: _buildHubRail( + hub: hub, + hubIndex: hubIndex, + hasFocus: hasFocus, + episodePosterMode: modes[hubIndex], + metrics: metrics, + scale: scale, + leftOverflow: leftOverflow, + interactionExpansion: interactionExpansion, + railViewportWidth: railViewportWidth, + ), ), ], ), @@ -958,8 +964,8 @@ class TvBrowseRailState extends State { required double scale, }) { final colorScheme = Theme.of(context).colorScheme; - final titleColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.54); - final iconColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.42); + final titleColor = isActive ? colorScheme.onSurface : colorScheme.onSurface.withValues(alpha: 0.54); + final iconColor = isActive ? colorScheme.onSurface : colorScheme.onSurface.withValues(alpha: 0.42); return SizedBox( height: TvBrowseRailLayout.hubStripHeightForScale(scale), diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index a7f27149..abf587ac 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -70,6 +70,132 @@ void main() { expect(titleText.style!.fontSize!, lessThan(baseFontSize)); }); + testWidgets('TV detail defaults to first regular season when specials precede it', (tester) async { + await SettingsService.getInstance(); + + final show = MediaItem( + id: 'show_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + title: 'The Show', + serverId: 'server_1', + serverName: 'Server', + ); + final specials = MediaItem( + id: 'season_0', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Specials', + index: 0, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final season1 = MediaItem( + id: 'season_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 1', + index: 1, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final specialEpisode = MediaItem( + id: 'episode_special_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Special 1', + index: 1, + parentId: specials.id, + parentIndex: specials.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final episode1 = MediaItem( + id: 'episode_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode 1', + index: 1, + parentId: season1.id, + parentIndex: season1.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + + final descendantsCompleter = Completer>(); + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [specials, season1], + specials.id: [specialEpisode], + season1.id: [episode1], + }, + pendingPlayableDescendants: descendantsCompleter.future, + ); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(provider.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: 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)); + + expect(find.text('Season 1'), findsOneWidget); + expect(find.text('Specials'), findsNothing); + expect(find.text('S1E1'), findsOneWidget); + }); + + testWidgets('TV detail summary uses light theme foreground color', (tester) async { + await SettingsService.getInstance(); + tester.view.physicalSize = const Size(1280, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const summary = 'Light theme detail text should stay readable.'; + final movie = MediaItem( + id: 'movie_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Readable Movie', + summary: summary, + ); + final theme = monoTheme(dark: false); + + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + theme: theme, + home: MediaDetailScreen(metadata: movie), + ), + ), + ); + + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final summaryText = tester.widget(find.text(summary)); + expect(summaryText.style?.color, theme.colorScheme.onSurface.withValues(alpha: 0.78)); + }); + testWidgets('TV detail reveals selected season before remaining episode caches load', (tester) async { await SettingsService.getInstance(); diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index c34dd4df..c557453b 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -176,6 +176,46 @@ void main() { expect(compactHeight, lessThan(defaultHeight)); }); + test('compact wide poster scale makes clips match compact episode thumbnails', () { + final episode = MediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + thumbPath: '/episode-thumb', + ); + final clip = MediaItem( + id: 'clip_1', + backend: MediaBackend.plex, + kind: MediaKind.clip, + title: 'Trailer', + thumbPath: '/trailer-thumb', + ); + final episodeHub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: [episode], size: 1); + final clipHub = MediaHub(id: 'detail_extras', title: 'Extras', type: 'clip', items: [clip], size: 1); + + final episodeMetrics = TvBrowseRailLayout.metricsForHub( + hub: episodeHub, + availableWidth: 1040, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.episodeThumbnail, + scale: 0.85, + widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale, + ); + final clipMetrics = TvBrowseRailLayout.metricsForHub( + hub: clipHub, + availableWidth: 1040, + density: LibraryDensity.defaultValue, + episodePosterMode: EpisodePosterMode.seriesPoster, + scale: 0.85, + widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale, + ); + + expect(clipMetrics.useWideLayout, isTrue); + expect(clipMetrics.cardWidth, closeTo(episodeMetrics.cardWidth, 0.001)); + expect(clipMetrics.posterHeight, closeTo(episodeMetrics.posterHeight, 0.001)); + }); + test('multi-hub estimate reserves next hub peek height', () { final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final movieHub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1); @@ -207,6 +247,63 @@ void main() { await SettingsService.getInstance(); }); + testWidgets('active hub header uses theme foreground in light mode', (tester) async { + final serverManager = MultiServerManager(); + final theme = monoTheme(dark: false); + final episode = MediaItem(id: 'episode_1', backend: MediaBackend.plex, kind: MediaKind.episode, title: 'Episode 1'); + final hub = MediaHub(id: 'season_1', title: 'Season 1', type: 'episode', items: [episode], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: theme, + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [hub], iconForHub: (_, _) => Icons.tv_rounded), + ), + ), + ), + ), + ); + + await tester.pump(); + + final headerText = tester.widget(find.text('Season 1')); + expect(headerText.style?.color, theme.colorScheme.onSurface); + }); + + testWidgets('inactive hub contents render at reduced opacity', (tester) async { + final serverManager = MultiServerManager(); + final firstItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); + final secondItem = MediaItem(id: 'movie_2', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 2'); + final firstHub = MediaHub(id: 'hub_1', title: 'First Hub', type: 'movie', items: [firstItem], size: 1); + final secondHub = MediaHub(id: 'hub_2', title: 'Second Hub', type: 'movie', items: [secondItem], size: 1); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [firstHub, secondHub], iconForHub: (_, _) => Icons.movie_rounded), + ), + ), + ), + ), + ); + + await tester.pump(); + + final opacities = tester.widgetList(find.byType(AnimatedOpacity)).map((widget) => widget.opacity); + expect(opacities, contains(0.7)); + }); + testWidgets('selects preferred hub when hubs are inserted asynchronously', (tester) async { final activeHubIds = [];