@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user