diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 9e4cfd7d..a04e0122 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -442,6 +442,14 @@ sealed class MediaItem { return thumbPath; } + /// Secondary poster path to try when [posterThumb] returns an image URL that + /// exists syntactically but the server cannot serve it. + String? posterThumbFallback({EpisodePosterMode mode = EpisodePosterMode.seriesPoster, bool mixedHubContext = false}) { + if (kind != MediaKind.episode || mode != EpisodePosterMode.seasonPoster) return null; + final fallback = grandparentThumbPath ?? thumbPath; + return fallback != null && fallback != posterThumb(mode: mode, mixedHubContext: mixedHubContext) ? fallback : null; + } + /// True when the item should render in 16:9. /// - Clips are always 16:9. /// - Episodes are 16:9 in `episodeThumbnail` mode. diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index a4b5f492..51edd0df 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -661,13 +661,15 @@ Widget _buildPosterImage( final shouldBlur = hideSpoilers && item.shouldHideSpoiler && episodePosterMode == EpisodePosterMode.episodeThumbnail; posterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext); + final posterFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext); + final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId); Widget image; // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) if (item.usesWideAspectRatio(episodePosterMode, mixedHubContext: mixedHubContext)) { image = OptimizedMediaImage.thumb( - client: isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId), + client: mediaClient, imagePath: posterUrl, width: knownWidth ?? double.infinity, height: knownHeight ?? double.infinity, @@ -676,11 +678,20 @@ Widget _buildPosterImage( ); } else { image = OptimizedMediaImage.poster( - client: isOffline ? null : context.tryGetMediaClientWithFallback(item.serverId), + client: mediaClient, imagePath: posterUrl, width: knownWidth ?? double.infinity, height: knownHeight ?? double.infinity, fit: BoxFit.cover, + errorWidget: posterFallbackUrl == null + ? null + : (_, _, _) => OptimizedMediaImage.poster( + client: mediaClient, + imagePath: posterFallbackUrl, + width: knownWidth ?? double.infinity, + height: knownHeight ?? double.infinity, + fit: BoxFit.cover, + ), localFilePath: localPosterPath, ); } diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index afa8a3e5..946e3391 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -319,6 +319,9 @@ class OptimizedMediaImage extends StatelessWidget { _imageFailureCount = 0; _lastFailureLog = now; } + if (errorWidget != null) { + return errorWidget!(context, imageUrl, error); + } return _buildErrorWidget(context, error); }, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index 59fdf187..7fe2b527 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -3,6 +3,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_stream.dart'; import 'package:plezy/services/jellyfin_mappers.dart'; +import 'package:plezy/services/settings_service.dart' show EpisodePosterMode; const _serverId = 'jf-machine-1'; @@ -123,6 +124,29 @@ void main() { expect(item.grandparentArtPath, '/Items/series-1/Images/Backdrop/0'); }); + test('episode season poster falls back to series poster when season image tag is absent', () { + final json = { + 'Id': 'ep1', + 'Name': 'Pilot', + 'Type': 'Episode', + 'SeriesId': 'series-1', + 'SeriesName': 'Breaking Bad', + 'SeriesPrimaryImageTag': 'seriesPrimary', + 'SeasonId': 'season-1', + 'SeasonName': 'Season 1', + }; + + final item = JellyfinMappers.mediaItem(json, serverId: _serverId, absolutizer: null)!; + + expect(item.parentThumbPath, '/Items/season-1/Images/Primary'); + expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary'); + expect(item.posterThumb(mode: EpisodePosterMode.seasonPoster), '/Items/season-1/Images/Primary'); + expect( + item.posterThumbFallback(mode: EpisodePosterMode.seasonPoster), + '/Items/series-1/Images/Primary?tag=seriesPrimary', + ); + }); + test('series viewedLeafCount derived from total - UnplayedItemCount', () { final json = { 'Id': 's1',