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