feat(jellyfin): cycle media backdrops

close #1568
This commit is contained in:
edde746
2026-07-14 07:24:11 +02:00
parent 30c978719b
commit 8c88385977
15 changed files with 991 additions and 417 deletions
+44 -4
View File
@@ -45,8 +45,10 @@ sealed class MediaItem with _$MediaItem {
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
List<String>? grandparentBackdropPaths,
String? thumbPath,
String? artPath,
List<String>? backdropPaths,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
@@ -105,8 +107,10 @@ sealed class MediaItem with _$MediaItem {
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
grandparentBackdropPaths: grandparentBackdropPaths,
thumbPath: thumbPath,
artPath: artPath,
backdropPaths: backdropPaths,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
@@ -164,8 +168,10 @@ sealed class MediaItem with _$MediaItem {
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
grandparentBackdropPaths: grandparentBackdropPaths,
thumbPath: thumbPath,
artPath: artPath,
backdropPaths: backdropPaths,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
@@ -230,8 +236,10 @@ sealed class MediaItem with _$MediaItem {
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
List<String>? grandparentBackdropPaths,
String? thumbPath,
String? artPath,
List<String>? backdropPaths,
String? clearLogoPath,
String? backgroundSquarePath,
@JsonKey(fromJson: flexibleInt) int? durationMs,
@@ -305,8 +313,10 @@ sealed class MediaItem with _$MediaItem {
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
List<String>? grandparentBackdropPaths,
String? thumbPath,
String? artPath,
List<String>? backdropPaths,
String? clearLogoPath,
String? backgroundSquarePath,
@JsonKey(fromJson: flexibleInt) int? durationMs,
@@ -600,6 +610,34 @@ sealed class MediaItem with _$MediaItem {
return usesWideAspectRatio(mode, mixedHubContext: mixedHubContext) ? CardShape.wide : CardShape.poster;
}
/// Every own-item backdrop in Jellyfin display order. Older persisted
/// objects and backends with one backdrop fall back to [artPath].
List<String> get resolvedBackdropPaths {
final paths = backdropPaths;
if (paths != null && paths.isNotEmpty) return paths;
final primary = artPath;
return primary == null || primary.isEmpty ? const [] : [primary];
}
/// Every inherited series backdrop in Jellyfin display order. Older
/// persisted objects fall back to [grandparentArtPath].
List<String> get resolvedGrandparentBackdropPaths {
final paths = grandparentBackdropPaths;
if (paths != null && paths.isNotEmpty) return paths;
final primary = grandparentArtPath;
return primary == null || primary.isEmpty ? const [] : [primary];
}
/// Backdrops eligible for rotation. Episodes prefer inherited series art;
/// other kinds rotate only their own artwork.
List<String> get heroBackdropPaths {
if (kind == MediaKind.episode) {
final inherited = resolvedGrandparentBackdropPaths;
if (inherited.isNotEmpty) return inherited;
}
return resolvedBackdropPaths;
}
/// Returns the best hero art path based on the container's aspect ratio.
String? heroArt({required double containerAspectRatio}) {
final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio);
@@ -609,11 +647,13 @@ sealed class MediaItem with _$MediaItem {
/// Returns hero art candidates in display-preference order.
List<String> heroArtCandidates({required double containerAspectRatio}) {
final own = resolvedBackdropPaths;
final inherited = resolvedGrandparentBackdropPaths;
final preferred = switch (kind) {
MediaKind.episode when containerAspectRatio < 1.39 => [backgroundSquarePath, grandparentArtPath, artPath],
MediaKind.episode => [grandparentArtPath, artPath, backgroundSquarePath],
_ when containerAspectRatio < 1.39 => [backgroundSquarePath, artPath],
_ => [artPath, backgroundSquarePath],
MediaKind.episode when containerAspectRatio < 1.39 => <String?>[backgroundSquarePath, ...inherited, ...own],
MediaKind.episode => <String?>[...inherited, ...own, backgroundSquarePath],
_ when containerAspectRatio < 1.39 => <String?>[backgroundSquarePath, ...own],
_ => <String?>[...own, backgroundSquarePath],
};
final candidates = <String>[];
File diff suppressed because one or more lines are too long
+18
View File
@@ -30,8 +30,15 @@ PlexMediaItem _$PlexMediaItemFromJson(Map<String, dynamic> json) =>
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumbPath: json['grandparentThumbPath'] as String?,
grandparentArtPath: json['grandparentArtPath'] as String?,
grandparentBackdropPaths:
(json['grandparentBackdropPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
thumbPath: json['thumbPath'] as String?,
artPath: json['artPath'] as String?,
backdropPaths: (json['backdropPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
clearLogoPath: json['clearLogoPath'] as String?,
backgroundSquarePath: json['backgroundSquarePath'] as String?,
durationMs: flexibleInt(json['durationMs']),
@@ -100,8 +107,10 @@ Map<String, dynamic> _$PlexMediaItemToJson(PlexMediaItem instance) =>
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumbPath': ?instance.grandparentThumbPath,
'grandparentArtPath': ?instance.grandparentArtPath,
'grandparentBackdropPaths': ?instance.grandparentBackdropPaths,
'thumbPath': ?instance.thumbPath,
'artPath': ?instance.artPath,
'backdropPaths': ?instance.backdropPaths,
'clearLogoPath': ?instance.clearLogoPath,
'backgroundSquarePath': ?instance.backgroundSquarePath,
'durationMs': ?instance.durationMs,
@@ -169,8 +178,15 @@ JellyfinMediaItem _$JellyfinMediaItemFromJson(Map<String, dynamic> json) =>
grandparentTitle: json['grandparentTitle'] as String?,
grandparentThumbPath: json['grandparentThumbPath'] as String?,
grandparentArtPath: json['grandparentArtPath'] as String?,
grandparentBackdropPaths:
(json['grandparentBackdropPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
thumbPath: json['thumbPath'] as String?,
artPath: json['artPath'] as String?,
backdropPaths: (json['backdropPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
clearLogoPath: json['clearLogoPath'] as String?,
backgroundSquarePath: json['backgroundSquarePath'] as String?,
durationMs: flexibleInt(json['durationMs']),
@@ -229,8 +245,10 @@ Map<String, dynamic> _$JellyfinMediaItemToJson(JellyfinMediaItem instance) =>
'grandparentTitle': ?instance.grandparentTitle,
'grandparentThumbPath': ?instance.grandparentThumbPath,
'grandparentArtPath': ?instance.grandparentArtPath,
'grandparentBackdropPaths': ?instance.grandparentBackdropPaths,
'thumbPath': ?instance.thumbPath,
'artPath': ?instance.artPath,
'backdropPaths': ?instance.backdropPaths,
'clearLogoPath': ?instance.clearLogoPath,
'backgroundSquarePath': ?instance.backgroundSquarePath,
'durationMs': ?instance.durationMs,
+12 -31
View File
@@ -22,6 +22,7 @@ import '../media/media_server_client.dart';
import '../media/media_hub.dart';
import '../utils/media_image_helper.dart';
import '../utils/content_utils.dart';
import '../widgets/cycling_media_backdrop.dart';
import '../widgets/optimized_media_image.dart' show blurArtwork;
import '../widgets/rasterized_gradient.dart';
import '../providers/discover_provider.dart';
@@ -1351,6 +1352,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final isEpisode = heroItem.isEpisode;
final showName = heroItem.grandparentTitle ?? heroItem.displayTitle;
final screenWidth = MediaQuery.sizeOf(context).width;
final heroArtPaths = heroItem.heroArtCandidates(containerAspectRatio: screenWidth / heroHeight);
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
final isTv = PlatformDetector.isTV();
final alignLeft = isTv || isLargeScreen;
@@ -1390,9 +1392,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
clipBehavior: Clip.none,
children: [
// Background Image with fade/zoom animation and parallax
if (heroItem.artPath != null ||
heroItem.backgroundSquarePath != null ||
heroItem.grandparentArtPath != null)
if (heroArtPaths.isNotEmpty)
ClipRect(
child: AnimatedBuilder(
animation: _scrollController,
@@ -1415,35 +1415,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// heroClient resolves to the actual server's client
// (Plex or Jellyfin) so each backend's transcoder
// builds sized URLs.
final size = MediaQuery.sizeOf(context);
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final containerAspect = screenWidth / heroHeight;
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: heroClient,
thumbPath:
heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArtPath,
maxWidth: size.width,
maxHeight: size.height * 0.7,
devicePixelRatio: dpr,
imageType: ImageType.art,
);
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (screenWidth * dpr).round(),
displayHeight: (heroHeight * dpr).round(),
imageType: ImageType.art,
);
return blurArtwork(
CachedNetworkImage(
imageUrl: imageUrl,
cacheManager: PlexImageCacheManager.instance,
fit: BoxFit.cover,
memCacheHeight: memHeight,
placeholder: (context, url) =>
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
errorBuilder: (context, error, stackTrace) =>
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
CyclingMediaBackdrop(
mediaKey: heroItem.globalKey,
imagePaths: heroItem.heroBackdropPaths,
fallbackImagePaths: heroArtPaths,
client: heroClient,
active: _isTabVisible,
width: screenWidth,
height: heroHeight,
fallbackColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
);
},
+12 -74
View File
@@ -40,6 +40,7 @@ import '../widgets/media_card.dart';
import '../widgets/media_rating_badge.dart';
import '../i18n/strings.g.dart';
import '../theme/mono_tokens.dart';
import '../widgets/cycling_media_backdrop.dart';
import '../widgets/optimized_media_image.dart';
import '../utils/media_image_helper.dart';
import '../utils/media_quality_labels.dart';
@@ -1202,55 +1203,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return localPath;
}
Widget _buildHeroNetworkArtwork(
BuildContext context, {
required MediaServerClient? client,
required List<String> artworkPaths,
required Size mediaSize,
required double dpr,
required int memCacheHeight,
int index = 0,
}) {
if (index >= artworkPaths.length) return const PlaceholderContainer();
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: artworkPaths[index],
maxWidth: mediaSize.width,
maxHeight: mediaSize.height * 0.6,
devicePixelRatio: dpr,
imageType: ImageType.art,
);
if (imageUrl.isEmpty) {
return _buildHeroNetworkArtwork(
context,
client: client,
artworkPaths: artworkPaths,
mediaSize: mediaSize,
dpr: dpr,
memCacheHeight: memCacheHeight,
index: index + 1,
);
}
return CachedNetworkImage(
imageUrl: imageUrl,
cacheManager: PlexImageCacheManager.instance,
fit: BoxFit.cover,
memCacheHeight: memCacheHeight,
placeholder: (context, url) => const PlaceholderContainer(),
errorBuilder: (context, error, stackTrace) => _buildHeroNetworkArtwork(
context,
client: client,
artworkPaths: artworkPaths,
mediaSize: mediaSize,
dpr: dpr,
memCacheHeight: memCacheHeight,
index: index + 1,
),
);
}
String _syncRuleKeyForMetadata(BuildContext context, DownloadProvider downloadProvider, MediaItem metadata) {
final serverId = metadata.serverId;
final client = _getMediaClientForMetadata(context);
@@ -3427,6 +3379,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
client: _getArtworkMediaClient(context),
showInfo: false,
localArtworkPathResolver: widget.isOffline ? (path) => _offlineArtworkLocalPath(context, path) : null,
allowNetwork: !widget.isOffline,
),
_buildTvDetailRevealGate(revealContent, handleBack),
],
@@ -4159,32 +4112,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final heroArtPaths = metadata.heroArtCandidates(containerAspectRatio: containerAspect);
if (heroArtPaths.isEmpty) return const PlaceholderContainer();
final localArtwork = _buildOfflineArtworkIfAvailable(
context,
artworkPaths: heroArtPaths,
fit: BoxFit.cover,
imageType: ImageType.art,
errorWidget: (context, url, error) => const PlaceholderContainer(),
);
if (localArtwork != null) return localArtwork;
final client = _getArtworkMediaClient(context);
final mqSize = MediaQuery.sizeOf(context);
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (mqSize.width * dpr).round(),
displayHeight: (headerHeight * dpr).round(),
imageType: ImageType.art,
);
return blurArtwork(
_buildHeroNetworkArtwork(
context,
client: client,
artworkPaths: heroArtPaths,
mediaSize: mqSize,
dpr: dpr,
memCacheHeight: memHeight,
CyclingMediaBackdrop(
mediaKey: metadata.globalKey,
imagePaths: metadata.heroBackdropPaths,
fallbackImagePaths: heroArtPaths,
client: _getArtworkMediaClient(context),
localArtworkPathResolver: widget.isOffline ? (path) => _offlineArtworkLocalPath(context, path) : null,
allowNetwork: !widget.isOffline,
width: size.width,
height: headerHeight,
fallbackColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
);
},
+44 -27
View File
@@ -101,14 +101,22 @@ class JellyfinImageAbsolutizer {
/// absolute, self-authenticated form. Cheap — touches a handful of
/// nullable strings and reuses the existing [MediaItem.copyWith].
MediaItem applyTo(MediaItem item) {
final backdropPaths = item.backdropPaths?.map((path) => absolutize(path)!).toList(growable: false);
final grandparentBackdropPaths = item.grandparentBackdropPaths
?.map((path) => absolutize(path)!)
.toList(growable: false);
return item.copyWith(
thumbPath: absolutize(item.thumbPath),
artPath: absolutize(item.artPath),
artPath: backdropPaths == null || backdropPaths.isEmpty ? absolutize(item.artPath) : backdropPaths.first,
backdropPaths: backdropPaths,
clearLogoPath: absolutize(item.clearLogoPath),
backgroundSquarePath: absolutize(item.backgroundSquarePath),
parentThumbPath: absolutize(item.parentThumbPath),
grandparentThumbPath: absolutize(item.grandparentThumbPath),
grandparentArtPath: absolutize(item.grandparentArtPath),
grandparentArtPath: grandparentBackdropPaths == null || grandparentBackdropPaths.isEmpty
? absolutize(item.grandparentArtPath)
: grandparentBackdropPaths.first,
grandparentBackdropPaths: grandparentBackdropPaths,
// Cast headshots come from the same /Items/{personId}/Images/Primary
// endpoint and need the same absolutize+api_key treatment, otherwise
// they get routed through Plex's photo proxy and 404.
@@ -157,6 +165,14 @@ class JellyfinMappers {
// folders so folder browsing never falls back to raw-map sniffing.
final kind = type == null && item['IsFolder'] == true ? MediaKind.folder : MediaKind.fromString(type);
final albumPrimaryImage = kind == MediaKind.track ? _albumPrimaryImage(item) : null;
final backdropPaths = _backdropImagePaths(id, item['BackdropImageTags']);
final parentBackdropPaths = _parentBackdropImagePaths(item);
final seriesBackdropPath = _seriesBackdropImage(item);
final grandparentBackdropPaths = parentBackdropPaths.isNotEmpty
? parentBackdropPaths
: seriesBackdropPath == null
? const <String>[]
: <String>[seriesBackdropPath];
final mapped = JellyfinMediaItem(
id: id,
@@ -196,9 +212,11 @@ class JellyfinMappers {
grandparentTitle:
item['SeriesName'] as String? ?? (kind == MediaKind.track ? item['AlbumArtist'] as String? : null),
grandparentThumbPath: _seriesPrimaryImage(item),
grandparentArtPath: _parentBackdropImage(item) ?? _seriesBackdropImage(item),
grandparentArtPath: grandparentBackdropPaths.firstOrNull,
grandparentBackdropPaths: grandparentBackdropPaths.isEmpty ? null : grandparentBackdropPaths,
thumbPath: _selfImagePath(id, item, 'Primary') ?? albumPrimaryImage,
artPath: _selfImagePath(id, item, 'Backdrop'),
artPath: backdropPaths.firstOrNull,
backdropPaths: backdropPaths.isEmpty ? null : backdropPaths,
// Episodes/seasons don't carry their own logo — Jellyfin exposes the
// parent's logo via ParentLogoItemId/ParentLogoImageTag, which is
// what JF web renders on the hero card.
@@ -477,20 +495,24 @@ class JellyfinMappers {
static String? _selfImagePath(String id, Map<String, dynamic> item, String type) {
final tags = item['ImageTags'];
final backdropTags = item['BackdropImageTags'];
String? tag;
if (type == 'Backdrop' && backdropTags is List && backdropTags.isNotEmpty) {
tag = backdropTags.first as String?;
return tag != null ? _itemImagePath(id, 'Backdrop', tag: tag, imageIndex: 0) : null;
}
if (tags is Map<String, dynamic>) {
final value = tags[type];
if (value is String) tag = value;
}
if (tag == null) return null;
if (tags is! Map<String, dynamic>) return null;
final tag = tags[type];
if (tag is! String || tag.isEmpty) return null;
return _itemImagePath(id, type, tag: tag);
}
static List<String> _backdropImagePaths(String id, Object? rawTags) {
if (rawTags is! List) return const [];
final paths = <String>[];
final seenTags = <String>{};
for (var index = 0; index < rawTags.length; index++) {
final tag = rawTags[index];
if (tag is! String || tag.isEmpty || !seenTags.add(tag)) continue;
paths.add(_itemImagePath(id, 'Backdrop', tag: tag, imageIndex: index));
}
return paths;
}
/// First album-artist id for Audio/MusicAlbum rows — the music counterpart
/// of `SeriesId` in the parent hierarchy.
static String? _firstAlbumArtistId(Map<String, dynamic> item) {
@@ -536,19 +558,14 @@ class JellyfinMappers {
}
/// Parent backdrop helper — works for episodes (parent = series) and
/// seasons (parent = series). Pulls the explicit
/// `ParentBackdropItemId`/`ParentBackdropImageTags` pair Jellyfin
/// inherits onto child items, falling back to a tagless URL when only
/// the id is present.
static String? _parentBackdropImage(Map<String, dynamic> item) {
/// seasons (parent = series). Pulls every explicit
/// `ParentBackdropItemId`/`ParentBackdropImageTags` pair Jellyfin inherits
/// onto child items, falling back to a tagless URL when only the id exists.
static List<String> _parentBackdropImagePaths(Map<String, dynamic> item) {
final parentId = item['ParentBackdropItemId'] as String?;
if (parentId == null) return null;
final tags = item['ParentBackdropImageTags'];
if (tags is List && tags.isNotEmpty) {
final tag = tags.first as String?;
if (tag != null) return _itemImagePath(parentId, 'Backdrop', tag: tag, imageIndex: 0);
}
return _itemImagePath(parentId, 'Backdrop', imageIndex: 0);
if (parentId == null || parentId.isEmpty) return const [];
final paths = _backdropImagePaths(parentId, item['ParentBackdropImageTags']);
return paths.isEmpty ? [_itemImagePath(parentId, 'Backdrop', imageIndex: 0)] : paths;
}
/// Parent logo helper — episodes/seasons inherit the series' logo via
+4 -2
View File
@@ -2,10 +2,12 @@ import '../media/library_query.dart';
import '../media/media_kind.dart';
import 'plex_constants.dart';
/// Limit browse payload image tags to the artwork types the UI maps.
/// Browse responses retain up to three backdrops so hero surfaces can rotate
/// artwork without allowing image-tag payloads to grow without bound.
const jellyfinBackdropImageLimit = 3;
const jellyfinImageQueryParameters = <String, String>{
'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo',
'ImageTypeLimit': '1',
'ImageTypeLimit': '$jellyfinBackdropImageLimit',
};
/// Translates a backend-neutral [LibraryQuery] into the per-backend
+432
View File
@@ -0,0 +1,432 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../media/media_server_client.dart';
import '../services/device_performance.dart';
import '../utils/media_image_helper.dart';
/// Displays server artwork and rotates through multiple backdrops in order.
///
/// The settled image remains visible until the incoming provider produces a
/// frame. Failed candidates are skipped without flashing an empty frame.
class CyclingMediaBackdrop extends StatefulWidget {
const CyclingMediaBackdrop({
super.key,
required this.mediaKey,
required this.imagePaths,
required this.client,
required this.width,
required this.height,
required this.fallbackColor,
this.fallbackImagePaths = const [],
this.localArtworkPathResolver,
this.imageProviderResolver,
this.allowNetwork = true,
this.active = true,
this.fit = BoxFit.cover,
this.alignment = Alignment.center,
this.rotationInterval = const Duration(seconds: 10),
this.fadeDuration = const Duration(milliseconds: 280),
});
final Object? mediaKey;
final List<String> imagePaths;
final List<String> fallbackImagePaths;
final MediaServerClient? client;
final String? Function(String artworkPath)? localArtworkPathResolver;
/// Overrides provider construction for deterministic widget tests.
@visibleForTesting
final ImageProvider? Function(String artworkPath)? imageProviderResolver;
final bool allowNetwork;
final bool active;
final double width;
final double height;
final BoxFit fit;
final Alignment alignment;
final Color fallbackColor;
final Duration rotationInterval;
final Duration fadeDuration;
@override
State<CyclingMediaBackdrop> createState() => _CyclingMediaBackdropState();
}
class _CyclingMediaBackdropState extends State<CyclingMediaBackdrop> with WidgetsBindingObserver {
Timer? _rotationTimer;
late List<String> _rotationPaths;
late List<String> _fallbackPaths;
final Set<String> _failedPaths = <String>{};
final Set<String> _pendingProviderFailures = <String>{};
int _rotationIndex = 0;
int _fallbackIndex = 0;
bool _lifecycleResumed = true;
bool _tickerEnabled = true;
bool _disableAnimations = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_lifecycleResumed = switch (WidgetsBinding.instance.lifecycleState) {
null || AppLifecycleState.resumed => true,
_ => false,
};
_replacePaths();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_tickerEnabled = TickerMode.valuesOf(context).enabled;
final disableAnimations = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
if (_disableAnimations != disableAnimations) {
_disableAnimations = disableAnimations;
}
_restartRotationTimer();
}
@override
void didUpdateWidget(covariant CyclingMediaBackdrop oldWidget) {
super.didUpdateWidget(oldWidget);
final pathsChanged =
widget.mediaKey != oldWidget.mediaKey ||
!listEquals(widget.imagePaths, oldWidget.imagePaths) ||
!listEquals(widget.fallbackImagePaths, oldWidget.fallbackImagePaths);
if (pathsChanged) {
_replacePaths();
}
if (pathsChanged || widget.active != oldWidget.active || widget.rotationInterval != oldWidget.rotationInterval) {
_restartRotationTimer();
}
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleResumed = state == AppLifecycleState.resumed;
if (_lifecycleResumed) {
_restartRotationTimer();
} else {
_rotationTimer?.cancel();
_rotationTimer = null;
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_rotationTimer?.cancel();
super.dispose();
}
void _replacePaths() {
_rotationPaths = _uniquePaths(widget.imagePaths);
final rotating = _rotationPaths.toSet();
_fallbackPaths = _uniquePaths(widget.fallbackImagePaths).where((path) => !rotating.contains(path)).toList();
_failedPaths.clear();
_pendingProviderFailures.clear();
_rotationIndex = 0;
_fallbackIndex = 0;
}
static List<String> _uniquePaths(List<String> paths) {
if (paths.isEmpty) return const [];
final unique = <String>[];
for (final path in paths) {
if (path.isEmpty || unique.contains(path)) continue;
unique.add(path);
}
return unique;
}
int get _usableRotationCount => _rotationPaths.where((path) => !_failedPaths.contains(path)).length;
bool get _canRotate =>
widget.active && _lifecycleResumed && _tickerEnabled && !_disableAnimations && _usableRotationCount > 1;
void _restartRotationTimer() {
_rotationTimer?.cancel();
_rotationTimer = null;
if (!_canRotate) return;
_rotationTimer = Timer(widget.rotationInterval, _handleRotationTimer);
}
void _handleRotationTimer() {
_rotationTimer = null;
if (!mounted) return;
if (!_canRotate) return;
_advanceRotation();
_restartRotationTimer();
}
void _advanceRotation() {
if (_rotationPaths.isEmpty) return;
for (var offset = 1; offset <= _rotationPaths.length; offset++) {
final next = (_rotationIndex + offset) % _rotationPaths.length;
if (_failedPaths.contains(_rotationPaths[next])) continue;
if (next == _rotationIndex) return;
setState(() => _rotationIndex = next);
return;
}
}
String? get _currentPath {
if (_rotationPaths.isNotEmpty && !_failedPaths.contains(_rotationPaths[_rotationIndex])) {
return _rotationPaths[_rotationIndex];
}
for (var offset = 0; offset < _rotationPaths.length; offset++) {
final index = (_rotationIndex + offset) % _rotationPaths.length;
if (!_failedPaths.contains(_rotationPaths[index])) return _rotationPaths[index];
}
if (_fallbackPaths.isNotEmpty && !_failedPaths.contains(_fallbackPaths[_fallbackIndex])) {
return _fallbackPaths[_fallbackIndex];
}
for (var offset = 0; offset < _fallbackPaths.length; offset++) {
final index = (_fallbackIndex + offset) % _fallbackPaths.length;
if (!_failedPaths.contains(_fallbackPaths[index])) return _fallbackPaths[index];
}
return null;
}
void _handleImageError(Object? key) {
final path = key is String ? key : null;
if (!mounted || path == null || _failedPaths.contains(path)) return;
setState(() {
_failedPaths.add(path);
_pendingProviderFailures.remove(path);
final rotationPosition = _rotationPaths.indexOf(path);
if (rotationPosition >= 0) {
for (var offset = 1; offset <= _rotationPaths.length; offset++) {
final next = (rotationPosition + offset) % _rotationPaths.length;
if (_failedPaths.contains(_rotationPaths[next])) continue;
_rotationIndex = next;
break;
}
} else {
final fallbackPosition = _fallbackPaths.indexOf(path);
if (fallbackPosition >= 0) {
for (var offset = 1; offset <= _fallbackPaths.length; offset++) {
final next = (fallbackPosition + offset) % _fallbackPaths.length;
if (_failedPaths.contains(_fallbackPaths[next])) continue;
_fallbackIndex = next;
break;
}
}
}
});
_restartRotationTimer();
}
ImageProvider? _providerFor(BuildContext context, String path) {
final providerOverride = widget.imageProviderResolver;
if (providerOverride != null) return providerOverride(path);
final size = MediaQuery.sizeOf(context);
final width = widget.width.isFinite && widget.width > 0 ? widget.width : size.width;
final height = widget.height.isFinite && widget.height > 0 ? widget.height : size.height;
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (width * dpr).round(),
displayHeight: (height * dpr).round(),
imageType: ImageType.art,
);
final localPath = widget.localArtworkPathResolver?.call(path);
if (localPath != null) {
final file = File(localPath);
if (file.existsSync()) {
return MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight);
}
}
if (!widget.allowNetwork) return null;
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: widget.client,
thumbPath: path,
maxWidth: width,
maxHeight: height,
devicePixelRatio: dpr,
imageType: ImageType.art,
);
if (imageUrl.isEmpty) return null;
return MediaImageHelper.serverArtworkProvider(imageUrl: imageUrl, memWidth: memWidth, memHeight: memHeight);
}
void _reportMissingProvider(String path) {
if (!_pendingProviderFailures.add(path)) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _handleImageError(path);
});
}
@override
Widget build(BuildContext context) {
final path = _currentPath;
final provider = path == null ? null : _providerFor(context, path);
if (path != null && provider == null) _reportMissingProvider(path);
final fadeDuration = _disableAnimations ? Duration.zero : DevicePerformance.reducedDuration(widget.fadeDuration);
return _BackdropArtworkCrossfade(
artworkKey: (widget.mediaKey, path),
imageErrorKey: path,
image: provider,
duration: fadeDuration,
fit: widget.fit,
alignment: widget.alignment,
fallbackColor: widget.fallbackColor,
onImageError: _handleImageError,
);
}
}
class _BackdropArtworkCrossfade extends StatefulWidget {
const _BackdropArtworkCrossfade({
required this.artworkKey,
required this.imageErrorKey,
required this.image,
required this.duration,
required this.fit,
required this.alignment,
required this.fallbackColor,
required this.onImageError,
});
final Object? artworkKey;
final Object? imageErrorKey;
final ImageProvider? image;
final Duration duration;
final BoxFit fit;
final Alignment alignment;
final Color fallbackColor;
final ValueChanged<Object?> onImageError;
@override
State<_BackdropArtworkCrossfade> createState() => _BackdropArtworkCrossfadeState();
}
class _BackdropArtworkCrossfadeState extends State<_BackdropArtworkCrossfade> with SingleTickerProviderStateMixin {
late final AnimationController _fade;
late Object? _currentKey = widget.artworkKey;
late ImageProvider? _base = widget.image;
late Object? _baseErrorKey = widget.imageErrorKey;
ImageProvider? _incoming;
Object? _incomingErrorKey;
bool _incomingIsColor = false;
bool _fadeStarted = false;
@override
void initState() {
super.initState();
_fade = AnimationController(vsync: this, duration: widget.duration);
}
@override
void didUpdateWidget(covariant _BackdropArtworkCrossfade oldWidget) {
super.didUpdateWidget(oldWidget);
_fade.duration = widget.duration;
if (widget.artworkKey == _currentKey) {
if (widget.image != null && widget.image != _base && _incoming == null) {
_base = widget.image;
_baseErrorKey = widget.imageErrorKey;
}
return;
}
_currentKey = widget.artworkKey;
if (widget.image != null && widget.image == _base) {
_baseErrorKey = widget.imageErrorKey;
_dropIncoming();
return;
}
setState(() {
_fade.stop();
_fade.value = 0;
_fadeStarted = false;
_incoming = widget.image;
_incomingErrorKey = widget.imageErrorKey;
_incomingIsColor = widget.image == null;
if (_incoming == null) _startFade();
});
}
@override
void dispose() {
_fade.dispose();
super.dispose();
}
void _startFade() {
if (_fadeStarted) return;
_fadeStarted = true;
_fade.forward().whenComplete(_promoteIncoming);
}
void _promoteIncoming() {
if (!mounted) return;
setState(() {
_base = _incoming;
_baseErrorKey = _incomingErrorKey;
_dropIncoming();
});
}
void _dropIncoming() {
_incoming = null;
_incomingErrorKey = null;
_incomingIsColor = false;
_fadeStarted = false;
_fade.value = 0;
}
void _reportError(Object? imageErrorKey) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onImageError(imageErrorKey);
});
}
Widget _image(ImageProvider provider, Object? imageErrorKey, {Animation<double>? opacity}) {
final incoming = opacity != null;
return Image(
key: incoming ? ValueKey<ImageProvider>(provider) : null,
image: provider,
fit: widget.fit,
alignment: widget.alignment,
excludeFromSemantics: true,
gaplessPlayback: true,
opacity: opacity,
frameBuilder: !incoming
? null
: (context, child, frame, wasSynchronouslyLoaded) {
if (frame != null || wasSynchronouslyLoaded) {
_startFade();
}
return child;
},
errorBuilder: (context, error, stackTrace) {
_reportError(imageErrorKey);
return incoming ? const SizedBox.shrink() : ColoredBox(color: widget.fallbackColor);
},
);
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (_base != null) _image(_base!, _baseErrorKey) else ColoredBox(color: widget.fallbackColor),
if (_incoming != null) _image(_incoming!, _incomingErrorKey, opacity: _fade),
if (_incomingIsColor)
AnimatedBuilder(
animation: _fade,
builder: (context, _) =>
ColoredBox(color: widget.fallbackColor.withValues(alpha: widget.fallbackColor.a * _fade.value)),
),
],
);
}
}
+24 -234
View File
@@ -16,6 +16,7 @@ import '../utils/formatters.dart';
import '../utils/layout_constants.dart';
import '../utils/media_image_helper.dart';
import 'app_icon.dart';
import 'cycling_media_backdrop.dart';
import 'fitting_title_text.dart';
import 'media_rating_badge.dart';
import 'optimized_media_image.dart' show blurArtwork;
@@ -34,6 +35,7 @@ class TvSpotlightBackground extends StatelessWidget {
final bool showPrimaryAction;
final bool showInfo;
final String? Function(String? artworkPath)? localArtworkPathResolver;
final bool allowNetwork;
const TvSpotlightBackground({
super.key,
@@ -49,6 +51,7 @@ class TvSpotlightBackground extends StatelessWidget {
this.showPrimaryAction = true,
this.showInfo = true,
this.localArtworkPathResolver,
this.allowNetwork = true,
});
double _scale(BuildContext context) => TvLayoutConstants.scaleOf(context);
@@ -59,21 +62,33 @@ class TvSpotlightBackground extends StatelessWidget {
final bgColor = Theme.of(context).scaffoldBackgroundColor;
// The gradients never differ between spotlight items, so only the artwork
// cross-fades by image paint alpha, not widget opacity. The former
// whole-stack AnimatedSwitcher kept two full-screen saveLayers (each with
// a backdrop + two full-screen gradient fills) blending per frame on
// every focus move, which alone saturated low-end TV GPUs while browsing.
// cross-fades by image paint alpha. Keeping the gradients outside the
// rotating layer avoids full-screen saveLayers on low-end TVs.
final size = MediaQuery.sizeOf(context);
final containerAspect = size.width / size.height;
final fallbackPaths = media == null
? const <String>[]
: <String>[
...media.heroArtCandidates(containerAspectRatio: containerAspect),
?media.thumbPath,
];
return Stack(
fit: StackFit.expand,
children: [
RepaintBoundary(
child: blurArtwork(
_SpotlightArtworkCrossfade(
CyclingMediaBackdrop(
mediaKey: media?.globalKey,
image: media == null ? null : _artworkProvider(context, media),
duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)),
fallbackColor: Theme.of(context).colorScheme.surfaceContainerHighest,
emptyColor: bgColor,
imagePaths: media?.heroBackdropPaths ?? const [],
fallbackImagePaths: fallbackPaths,
client: client,
localArtworkPathResolver: localArtworkPathResolver == null
? null
: (path) => localArtworkPathResolver!(path),
allowNetwork: allowNetwork,
width: size.width,
height: size.height,
fallbackColor: media == null ? bgColor : Theme.of(context).colorScheme.surfaceContainerHighest,
),
),
),
@@ -127,56 +142,6 @@ class TvSpotlightBackground extends StatelessWidget {
);
}
/// Resolves the backdrop image provider for [media]; null means "no art"
/// (the crossfade shows [_SpotlightArtworkCrossfade.fallbackColor]).
ImageProvider? _artworkProvider(BuildContext context, MediaItem media) {
final size = MediaQuery.sizeOf(context);
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final containerAspect = size.width / size.height;
final artCandidates = <String?>[
media.heroArt(containerAspectRatio: containerAspect) ??
media.grandparentArtPath ??
media.artPath ??
media.backgroundSquarePath ??
media.thumbPath,
media.grandparentArtPath,
media.artPath,
media.backgroundSquarePath,
media.thumbPath,
];
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (size.width * dpr).round(),
displayHeight: (size.height * dpr).round(),
imageType: ImageType.art,
);
for (final candidate in artCandidates) {
final localPath = localArtworkPathResolver?.call(candidate);
if (localPath != null && File(localPath).existsSync()) {
// Local originals skipped the server transcode entirely, so the
// decode bound is the only thing between a full-resolution art file
// and the GPU on a low-RAM TV.
return MediaImageHelper.boundedDecode(FileImage(File(localPath)), memWidth: memWidth, memHeight: memHeight);
}
}
final artPath = artCandidates.firstWhere((path) => path != null && path.isNotEmpty, orElse: () => null);
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: artPath,
maxWidth: size.width,
maxHeight: size.height,
devicePixelRatio: dpr,
imageType: ImageType.art,
);
if (imageUrl.isEmpty) return null;
final provider = CachedNetworkImageProvider(imageUrl, cacheManager: PlexImageCacheManager.instance);
return MediaImageHelper.boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
}
Widget _buildHorizontalScrim(Color bgColor) {
return RasterizedGradient(
gradient: LinearGradient(
@@ -419,178 +384,3 @@ class TvSpotlightBackground extends StatelessWidget {
);
}
}
/// Cross-fades full-screen backdrop art without saveLayers: the previous
/// image stays fully opaque underneath while the incoming one fades in via
/// `Image.opacity` (paint alpha in RawImage). The fade only starts once the
/// incoming image has a frame, so swaps never flash a placeholder — the old
/// backdrop simply stays until the new one is ready.
class _SpotlightArtworkCrossfade extends StatefulWidget {
const _SpotlightArtworkCrossfade({
required this.mediaKey,
required this.image,
required this.duration,
required this.fallbackColor,
required this.emptyColor,
});
/// Identity of the current spotlight item; fades trigger on changes.
final String? mediaKey;
/// null with a non-null [mediaKey] means "item without art" (fallback box);
/// null with a null [mediaKey] means "no item" (empty box).
final ImageProvider? image;
final Duration duration;
final Color fallbackColor;
final Color emptyColor;
@override
State<_SpotlightArtworkCrossfade> createState() => _SpotlightArtworkCrossfadeState();
}
class _SpotlightArtworkCrossfadeState extends State<_SpotlightArtworkCrossfade> with SingleTickerProviderStateMixin {
late final AnimationController _fade = AnimationController(vsync: this, duration: widget.duration);
late String? _currentKey = widget.mediaKey;
late ImageProvider? _base = widget.image;
late Color _baseColor = widget.mediaKey == null ? widget.emptyColor : widget.fallbackColor;
ImageProvider? _incoming;
bool _incomingIsColor = false;
Color _incomingColor = Colors.transparent;
bool _incomingErrored = false;
bool _incomingHasFrame = false;
bool _fadeStarted = false;
@override
void didUpdateWidget(covariant _SpotlightArtworkCrossfade oldWidget) {
super.didUpdateWidget(oldWidget);
_fade.duration = widget.duration;
if (widget.mediaKey == _currentKey) {
// Same item, possibly a re-resolved provider (size change): update the
// settled base silently — gaplessPlayback covers the swap.
if (widget.image != null && widget.image != _base && _incoming == null && !_incomingIsColor) {
_base = widget.image;
}
return;
}
_currentKey = widget.mediaKey;
final incomingColor = widget.mediaKey == null ? widget.emptyColor : widget.fallbackColor;
if (widget.image != null && widget.image == _base) {
// Same artwork (e.g. episodes sharing show art): nothing to fade.
_dropIncoming();
return;
}
setState(() {
_fade.stop();
_fade.value = 0;
_fadeStarted = false;
_incomingErrored = false;
_incomingHasFrame = false;
if (widget.image != null) {
_incoming = widget.image;
_incomingIsColor = false;
} else {
_incoming = null;
_incomingIsColor = true;
_incomingColor = incomingColor;
_startFade(); // no frame to wait for
}
});
}
@override
void dispose() {
_fade.dispose();
super.dispose();
}
void _startFade() {
if (_fadeStarted) return;
_fadeStarted = true;
_fade.forward().whenComplete(_promoteIncoming);
}
void _promoteIncoming() {
if (!mounted || (_incoming == null && !_incomingIsColor)) return;
setState(() {
if (_incomingIsColor) {
_base = null;
_baseColor = _incomingColor;
} else if (_incomingErrored || !_incomingHasFrame) {
// Never promote a provider that produced no frame: the base would
// re-resolve (and re-fail) it. Settle on the fallback box instead.
_base = null;
_baseColor = widget.fallbackColor;
} else {
_base = _incoming;
}
_dropIncoming();
});
}
void _dropIncoming() {
_incoming = null;
_incomingIsColor = false;
_incomingErrored = false;
_incomingHasFrame = false;
_fadeStarted = false;
_fade.value = 0;
}
Widget _image(ImageProvider provider, {Animation<double>? opacity}) {
final isIncoming = opacity != null;
return Image(
// Keyed by provider so a replaced incoming gets a fresh element — the
// framework never clears a retained error on provider swap, which would
// flash the previous item's failure at the next fade.
key: isIncoming ? ValueKey<ImageProvider>(provider) : null,
image: provider,
fit: BoxFit.cover,
excludeFromSemantics: true,
// Keeps the previous frame on provider promotion instead of flashing.
gaplessPlayback: true,
opacity: opacity,
frameBuilder: !isIncoming
? null
: (context, child, frame, wasSynchronouslyLoaded) {
if (frame != null || wasSynchronouslyLoaded) {
_incomingHasFrame = true;
_startFade();
}
return child;
},
errorBuilder: !isIncoming
// The settled base must show a static fallback: anything riding
// _fade here would flash transparent when the controller resets
// for the next swap.
? (context, error, stackTrace) => ColoredBox(color: widget.fallbackColor)
: (context, error, stackTrace) {
// Broken incoming art: fade a plain box in instead (bounded to
// the error case); promotion then settles on the color, not
// the dead provider.
_incomingErrored = true;
_startFade();
return FadeTransition(
opacity: _fade,
child: ColoredBox(color: widget.fallbackColor),
);
},
);
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (_base != null) _image(_base!) else ColoredBox(color: _baseColor),
if (_incoming != null) _image(_incoming!, opacity: _fade),
if (_incomingIsColor)
AnimatedBuilder(
animation: _fade,
builder: (context, _) =>
ColoredBox(color: _incomingColor.withValues(alpha: _incomingColor.a * _fade.value)),
),
],
);
}
}
+59
View File
@@ -21,6 +21,7 @@ MediaItem _movie({
int? durationMs,
int? viewOffsetMs,
String? artPath,
List<String>? backdropPaths,
String? backgroundSquarePath,
MediaBackend backend = MediaBackend.plex,
}) => testMediaItem(
@@ -34,6 +35,7 @@ MediaItem _movie({
durationMs: durationMs,
viewOffsetMs: viewOffsetMs,
artPath: artPath,
backdropPaths: backdropPaths,
backgroundSquarePath: backgroundSquarePath,
serverId: 's1',
);
@@ -125,6 +127,46 @@ void main() {
expect(episode.heroArt(containerAspectRatio: 16 / 9), '/show-art');
expect(episode.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/show-art', '/episode-art']);
});
test('Jellyfin movies expose every backdrop in display order', () {
final movie = _movie(
backend: MediaBackend.jellyfin,
artPath: '/art-0',
backdropPaths: ['/art-0', '/art-1', '/art-2'],
backgroundSquarePath: '/square',
);
expect(movie.heroBackdropPaths, ['/art-0', '/art-1', '/art-2']);
expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art-0', '/art-1', '/art-2', '/square']);
});
test('episodes prefer inherited backdrops over their own art', () {
final episode = testMediaItem(
id: 'e-multi',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1', '/show-2'],
);
expect(episode.heroBackdropPaths, ['/show-0', '/show-1', '/show-2']);
expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), [
'/show-0',
'/show-1',
'/show-2',
'/episode-0',
'/episode-1',
]);
});
test('legacy scalar art remains a single static backdrop', () {
final movie = _movie(artPath: '/legacy-art');
expect(movie.resolvedBackdropPaths, ['/legacy-art']);
expect(movie.heroBackdropPaths, ['/legacy-art']);
});
});
group('MediaItem.isPartiallyWatched', () {
@@ -338,6 +380,23 @@ void main() {
expect((decoded as JellyfinMediaItem).playlistItemId, 'entry-1');
});
test('round-trips Jellyfin backdrop lists', () {
const original = JellyfinMediaItem(
id: 'j-backdrops',
kind: MediaKind.episode,
artPath: '/episode-0',
backdropPaths: ['/episode-0', '/episode-1'],
grandparentArtPath: '/show-0',
grandparentBackdropPaths: ['/show-0', '/show-1'],
);
final decoded = MediaItem.fromJson(original.toJson());
expect(decoded.backdropPaths, ['/episode-0', '/episode-1']);
expect(decoded.grandparentBackdropPaths, ['/show-0', '/show-1']);
expect(decoded.heroBackdropPaths, ['/show-0', '/show-1']);
});
test('missing backend keeps legacy Plex fallback', () {
final decoded = MediaItem.fromJson({'id': 'legacy', 'kind': 'movie'});
+26 -16
View File
@@ -219,7 +219,7 @@ void main() {
});
expect(requests.every((uri) => uri.queryParameters['userId'] == 'user-1'), isTrue);
expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Thumb,Logo'), isTrue);
expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '1'), isTrue);
expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '3'), isTrue);
expect(extras.map((item) => item.id).toList(), ['trailer-1', 'featurette-1']);
expect(extras.every((item) => item.kind.isVideo), isTrue);
expect(extras.every((item) => item.serverId == 'srv-1'), isTrue);
@@ -1724,7 +1724,12 @@ void main() {
return http.Response(
jsonEncode({
'Items': [
{'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'},
{
'Id': 'movie-1',
'Type': 'Movie',
'Name': 'Movie',
'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'],
},
],
'TotalRecordCount': 123,
}),
@@ -1741,6 +1746,11 @@ void main() {
);
expect(page.items.single.id, 'movie-1');
expect(page.items.single.backdropPaths!.map((url) => Uri.parse(url).path).toList(), [
'/Items/movie-1/Images/Backdrop/0',
'/Items/movie-1/Images/Backdrop/1',
'/Items/movie-1/Images/Backdrop/2',
]);
expect(page.totalCount, 123);
expect(captured, isNotNull);
expect(captured!.path, '/Items');
@@ -1751,7 +1761,7 @@ void main() {
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie');
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
});
test('music browse and detail requests use leaf-appropriate fields', () async {
@@ -2113,7 +2123,7 @@ void main() {
expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Ascending');
expect(captured!.queryParameters['CollapseBoxSetItems'], 'false');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
});
test('fetchItemWithOnDeck keeps resumable NextUp semantics for show detail lookup', () async {
@@ -2143,7 +2153,7 @@ void main() {
expect(capturedNextUp!.queryParameters['seriesId'], 'show-1');
expect(capturedNextUp!.queryParameters['Limit'], '1');
expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '1');
expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '3');
expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse);
expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2267,14 +2277,14 @@ void main() {
expect(resume.queryParameters['Recursive'], 'true');
expect(resume.queryParameters['EnableTotalRecordCount'], 'false');
expect(resume.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(resume.queryParameters['ImageTypeLimit'], '1');
expect(resume.queryParameters['ImageTypeLimit'], '3');
final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp');
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '3');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2541,7 +2551,7 @@ void main() {
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2582,7 +2592,7 @@ void main() {
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
@@ -2632,7 +2642,7 @@ void main() {
expect(captured!.queryParameters['Limit'], '80');
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
client.close();
});
@@ -2650,7 +2660,7 @@ void main() {
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
client.close();
});
@@ -2668,7 +2678,7 @@ void main() {
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
client.close();
});
@@ -2682,7 +2692,7 @@ void main() {
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['Limit'], '30');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
// ParentId-scoped Latest should NOT also pin IncludeItemTypes (the
// library already constrains the kinds returned).
expect(captured!.queryParameters.containsKey('IncludeItemTypes'), isFalse);
@@ -2701,7 +2711,7 @@ void main() {
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
client.close();
});
@@ -2717,7 +2727,7 @@ void main() {
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '1');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
client.close();
});
@@ -2902,7 +2912,7 @@ void main() {
);
expect(itemsRequest.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(itemsRequest.queryParameters['ImageTypeLimit'], '1');
expect(itemsRequest.queryParameters['ImageTypeLimit'], '3');
});
test('fetchCollectionsPage uses requested collection page bounds', () async {
+47 -1
View File
@@ -41,7 +41,7 @@ void main() {
'DateCreated': '2025-01-15T10:00:00.0000000Z',
'DateLastSaved': '2026-03-01T10:00:00.0000000Z',
'ImageTags': {'Primary': 'thumbtag', 'Logo': 'logotag'},
'BackdropImageTags': ['backtag'],
'BackdropImageTags': ['backtag', 'backtag-2', 'backtag-3'],
};
final item = JellyfinMappers.mediaItem(
@@ -79,6 +79,11 @@ void main() {
// Image paths.
expect(item.thumbPath, '/Items/abc123/Images/Primary?tag=thumbtag');
expect(item.artPath, '/Items/abc123/Images/Backdrop/0?tag=backtag');
expect(item.backdropPaths, [
'/Items/abc123/Images/Backdrop/0?tag=backtag',
'/Items/abc123/Images/Backdrop/1?tag=backtag-2',
'/Items/abc123/Images/Backdrop/2?tag=backtag-3',
]);
expect(item.clearLogoPath, '/Items/abc123/Images/Logo?tag=logotag');
// Multi-server fields.
@@ -86,6 +91,25 @@ void main() {
expect(item.serverName, 'Home');
});
test('preserves backdrop indices, deduplicates tags, and absolutizes every valid path', () {
const absolutizer = JellyfinImageAbsolutizer(baseUrl: 'https://jellyfin.example', accessToken: 'secret');
final item = JellyfinMappers.mediaItem(
{
'Id': 'movie-1',
'Type': 'Movie',
'BackdropImageTags': ['first', 42, '', 'first', 'fifth'],
},
serverId: ServerId(_serverId),
absolutizer: absolutizer,
)!;
expect(item.artPath, 'https://jellyfin.example/Items/movie-1/Images/Backdrop/0?tag=first&api_key=secret');
expect(item.backdropPaths, [
'https://jellyfin.example/Items/movie-1/Images/Backdrop/0?tag=first&api_key=secret',
'https://jellyfin.example/Items/movie-1/Images/Backdrop/4?tag=fifth&api_key=secret',
]);
});
test('does not treat Jellyfin PlayCount as watched when Played is false', () {
final json = {
'Id': 'started-only',
@@ -174,6 +198,28 @@ void main() {
expect(item.grandparentArtPath, '/Items/series-1/Images/Backdrop/0');
});
test('episode maps every inherited series backdrop', () {
final item = JellyfinMappers.mediaItem(
{
'Id': 'ep-parent-art',
'Type': 'Episode',
'SeriesId': 'series-fallback',
'ParentBackdropItemId': 'series-parent',
'ParentBackdropImageTags': ['parent-0', 'parent-1', 'parent-2'],
},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.grandparentArtPath, '/Items/series-parent/Images/Backdrop/0?tag=parent-0');
expect(item.grandparentBackdropPaths, [
'/Items/series-parent/Images/Backdrop/0?tag=parent-0',
'/Items/series-parent/Images/Backdrop/1?tag=parent-1',
'/Items/series-parent/Images/Backdrop/2?tag=parent-2',
]);
expect(item.heroBackdropPaths, item.grandparentBackdropPaths);
});
test('episode season poster falls back to series poster when season image tag is absent', () {
final json = {
'Id': 'ep1',
@@ -85,7 +85,7 @@ void main() {
expect(params['IncludeItemTypes'], isNotEmpty);
expect(params['EnableTotalRecordCount'], 'true');
expect(params['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
expect(params['ImageTypeLimit'], '1');
expect(params['ImageTypeLimit'], '3');
});
test('movie kind maps to IncludeItemTypes=Movie', () {
+4
View File
@@ -32,8 +32,10 @@ MediaItem testMediaItem({
String? grandparentTitle,
String? grandparentThumbPath,
String? grandparentArtPath,
List<String>? grandparentBackdropPaths,
String? thumbPath,
String? artPath,
List<String>? backdropPaths,
String? clearLogoPath,
String? backgroundSquarePath,
int? durationMs,
@@ -92,8 +94,10 @@ MediaItem testMediaItem({
grandparentTitle: grandparentTitle,
grandparentThumbPath: grandparentThumbPath,
grandparentArtPath: grandparentArtPath,
grandparentBackdropPaths: grandparentBackdropPaths,
thumbPath: thumbPath,
artPath: artPath,
backdropPaths: backdropPaths,
clearLogoPath: clearLogoPath,
backgroundSquarePath: backgroundSquarePath,
durationMs: durationMs,
@@ -0,0 +1,227 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/widgets/cycling_media_backdrop.dart';
import 'package:plezy/widgets/tv_spotlight_background.dart';
const _png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
const _rotationInterval = Duration(seconds: 1);
const _fadeDuration = Duration(milliseconds: 20);
void main() {
late Directory directory;
late File first;
late File second;
late File third;
late Map<String, MemoryImage> imageProviders;
setUp(() {
directory = Directory.systemTemp.createTempSync('plezy-backdrop-cycle');
final bytes = base64Decode(_png);
first = File('${directory.path}/first.png')..writeAsBytesSync(bytes);
second = File('${directory.path}/second.png')..writeAsBytesSync(bytes);
third = File('${directory.path}/third.png')..writeAsBytesSync(bytes);
imageProviders = {
first.path: MemoryImage(base64Decode(_png)),
second.path: MemoryImage(base64Decode(_png)),
third.path: MemoryImage(base64Decode(_png)),
};
});
tearDown(() {
directory.deleteSync(recursive: true);
});
Widget buildBackdrop(
List<String> paths, {
bool active = true,
bool disableAnimations = false,
bool tickerEnabled = true,
Object? mediaKey = 'movie-1',
}) {
return MaterialApp(
home: MediaQuery(
data: MediaQueryData(size: const Size(320, 180), devicePixelRatio: 1, disableAnimations: disableAnimations),
child: TickerMode(
enabled: tickerEnabled,
child: SizedBox(
width: 320,
height: 180,
child: CyclingMediaBackdrop(
mediaKey: mediaKey,
imagePaths: paths,
client: null,
localArtworkPathResolver: (path) => path,
imageProviderResolver: (path) => imageProviders[path],
allowNetwork: false,
active: active,
width: 320,
height: 180,
fallbackColor: Colors.black,
rotationInterval: _rotationInterval,
fadeDuration: _fadeDuration,
),
),
),
),
);
}
String pathForProvider(ImageProvider provider) {
while (provider is ResizeImage) {
provider = provider.imageProvider;
}
if (provider case FileImage(:final file)) return file.path;
return imageProviders.entries.singleWhere((entry) => identical(entry.value, provider)).key;
}
List<String> renderedFilePaths(WidgetTester tester) {
return tester.widgetList<Image>(find.byType(Image)).map((image) {
return pathForProvider(image.image);
}).toList();
}
void expectVisibleBackdrop(WidgetTester tester, String path) {
final image = tester.widgetList<Image>(find.byType(Image)).last;
expect(pathForProvider(image.image), path);
expect(image.opacity?.value ?? 1, 1);
}
Future<void> finishImageTransition(WidgetTester tester, {Duration fadeDuration = _fadeDuration}) async {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 200)));
await tester.pump();
await tester.pump(fadeDuration);
await tester.pump(fadeDuration);
await tester.pump();
}
testWidgets('rotates loaded backdrops in order and wraps', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path, second.path, third.path]));
expect(renderedFilePaths(tester), [first.path]);
await tester.pump(_rotationInterval);
expect(renderedFilePaths(tester).last, second.path);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, third.path);
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, first.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('skips a missing incoming image without dropping the settled backdrop', (tester) async {
final missing = '${directory.path}/missing.png';
await tester.pumpWidget(buildBackdrop([first.path, missing, third.path]));
await tester.pump(_rotationInterval);
expect(renderedFilePaths(tester), [first.path]);
await tester.pump();
await finishImageTransition(tester);
expectVisibleBackdrop(tester, third.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('pauses while the application is not resumed', (tester) async {
addTearDown(() => tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
await tester.pumpWidget(buildBackdrop([first.path, second.path]));
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump(_rotationInterval - const Duration(milliseconds: 1));
expect(renderedFilePaths(tester), [first.path]);
await tester.pump(const Duration(milliseconds: 1));
await finishImageTransition(tester);
expect(renderedFilePaths(tester), [second.path]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('pauses while its TickerMode subtree is hidden', (tester) async {
final paths = [first.path, second.path];
await tester.pumpWidget(buildBackdrop(paths, tickerEnabled: false));
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
await tester.pumpWidget(buildBackdrop(paths));
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('keeps one backdrop static', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path]));
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('resets to the first backdrop when media changes', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path, second.path]));
await tester.pump(_rotationInterval);
await finishImageTransition(tester);
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(buildBackdrop([third.path, first.path], mediaKey: 'movie-2'));
await finishImageTransition(tester);
expectVisibleBackdrop(tester, third.path);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('does not auto-rotate when reduced motion is requested', (tester) async {
await tester.pumpWidget(buildBackdrop([first.path, second.path], disableAnimations: true));
await tester.pump(_rotationInterval * 3);
expect(renderedFilePaths(tester), [first.path]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('TV spotlight rotates the Jellyfin item backdrop list', (tester) async {
final item = JellyfinMediaItem(
id: 'show-1',
kind: MediaKind.show,
artPath: first.path,
backdropPaths: [first.path, second.path],
serverId: 'server-1',
);
await tester.pumpWidget(
MaterialApp(
home: TvSpotlightBackground(
item: item,
client: null,
showInfo: false,
allowNetwork: false,
localArtworkPathResolver: (path) => path,
),
),
);
expect(renderedFilePaths(tester), [first.path]);
await tester.pump(const Duration(seconds: 10));
expect(renderedFilePaths(tester).last, second.path);
await finishImageTransition(tester, fadeDuration: const Duration(milliseconds: 280));
expectVisibleBackdrop(tester, second.path);
await tester.pumpWidget(const SizedBox.shrink());
});
}