fix(tv): polish media detail rails

close #1117
This commit is contained in:
edde746
2026-05-24 19:06:06 +02:00
parent 8fe51075be
commit c8cded70ef
4 changed files with 299 additions and 27 deletions
+58 -15
View File
@@ -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<MediaDetailScreen>
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<MediaDetailScreen>
}
}
/// 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<MediaItem> seasons) {
// Prefer explicit initial season (from navigation)
if (widget.initialSeasonIndex != null && seasons.isNotEmpty) {
@@ -1603,14 +1607,32 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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<MediaItem> seasons) {
if (seasons.isEmpty) return 0;
final regularSeasonIndex = seasons.indexWhere((season) => (season.index ?? 0) > 0);
return regularSeasonIndex == -1 ? 0 : regularSeasonIndex;
}
MediaItem? _defaultPlaybackSeason(List<MediaItem> 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<MediaDetailScreen>
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<MediaItem> episodes;
@@ -3170,6 +3191,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}) {
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<MediaDetailScreen>
fontSize: 56 * scale,
fontWeight: FontWeight.w800,
shadowBlur: 12,
color: foregroundColor,
shadowColor: _tvDetailTitleShadowColor(context),
),
),
SizedBox(height: logoMetadataGap),
@@ -3254,7 +3279,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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<MediaDetailScreen>
);
}
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<MediaDetailScreen>
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<MediaDetailScreen>
}
double _tvDetailWidePosterScaleForHub(MediaHub hub) {
return _isTvDetailEpisodeHub(hub) ? _tvDetailEpisodeThumbnailScale : 1.0;
return _isTvDetailEpisodeHub(hub) || hub.id == _tvDetailExtrasHubId ? _tvDetailEpisodeThumbnailScale : 1.0;
}
List<MediaHub> _tvDetailHubs(MediaItem metadata) {
@@ -3494,7 +3531,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
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<MediaDetailScreen>
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<MediaDetailScreen>
// (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');
}
}
+18 -12
View File
@@ -311,6 +311,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<String, ScrollController> _scrollControllers = {};
@@ -932,16 +933,21 @@ class TvBrowseRailState extends State<TvBrowseRail> {
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<TvBrowseRail> {
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),
+126
View File
@@ -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<List<MediaItem>>();
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<MultiServerProvider>.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<Text>(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();
+97
View File
@@ -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<MultiServerProvider>(
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<Text>(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<MultiServerProvider>(
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<AnimatedOpacity>(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 = <String>[];