fix(artwork): show square background art on portrait heroes

Cycling backdrops reach a fallback path only once every rotating path
has failed to load, but every hero passed the rotation-agnostic backdrop
list as the rotation set and the aspect-ordered candidates as the
fallback. One servable wide backdrop was therefore enough to hide the
square background for good, so phone detail and Discover heroes
cover-fitted a 16:9 backdrop into a portrait box instead of showing the
square image Plex supplies.

Give the rotation set the same aspect-aware preference the candidate
list already has: near-square containers rotate the square background
alone and keep the backdrops behind it as fallbacks.

close #1700
This commit is contained in:
edde746
2026-07-28 10:31:18 +02:00
parent 9a0e96114f
commit 3b019c8fe2
6 changed files with 126 additions and 6 deletions
+61
View File
@@ -228,6 +228,67 @@ void main() {
});
});
group('MediaItem.heroRotationPaths', () {
/// The order `CyclingMediaBackdrop` attempts paths as each one fails:
/// every rotating path, then the fallbacks it is not already rotating.
/// Only the head of this list is ever displayed by a healthy server.
List<String> displayOrder(MediaItem item, double aspect) {
final rotation = item.heroRotationPaths(containerAspectRatio: aspect);
final candidates = item.heroArtCandidates(containerAspectRatio: aspect);
return [...rotation, ...candidates.where((path) => !rotation.contains(path))];
}
test('near-square containers hold on square art instead of rotating backdrops', () {
final movie = _movie(
backend: MediaBackend.jellyfin,
artPath: '/art-0',
backdropPaths: ['/art-0', '/art-1'],
backgroundSquarePath: '/square',
);
expect(movie.heroRotationPaths(containerAspectRatio: 1.0), ['/square']);
});
test('near-square containers rotate backdrops when there is no square art', () {
final movie = _movie(backend: MediaBackend.jellyfin, artPath: '/art-0', backdropPaths: ['/art-0', '/art-1']);
expect(movie.heroRotationPaths(containerAspectRatio: 1.0), ['/art-0', '/art-1']);
});
test('wide containers rotate backdrops and leave square art behind them', () {
final movie = _movie(
backend: MediaBackend.jellyfin,
artPath: '/art-0',
backdropPaths: ['/art-0', '/art-1'],
backgroundSquarePath: '/square',
);
expect(movie.heroRotationPaths(containerAspectRatio: 16 / 9), ['/art-0', '/art-1']);
});
test('rotation before fallback reproduces the candidate order at every aspect', () {
final episode = testMediaItem(
id: 'e-order',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1'],
backgroundSquarePath: '/square',
serverId: 's1',
);
for (final aspect in [0.75, 1.0, 1.38, 1.39, 16 / 9, 2.4]) {
expect(
displayOrder(episode, aspect),
episode.heroArtCandidates(containerAspectRatio: aspect),
reason: 'aspect $aspect',
);
}
});
});
group('MediaItem.isPartiallyWatched', () {
test('show with some leaves watched is partially watched', () {
final show = testMediaItem(