From 8c883859774a67854bca7278b3265b99798752cd Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:20:41 +0200 Subject: [PATCH] feat(jellyfin): cycle media backdrops close #1568 --- lib/media/media_item.dart | 48 +- lib/media/media_item.freezed.dart | 64 +-- lib/media/media_item.g.dart | 18 + lib/screens/discover_screen.dart | 43 +- lib/screens/media_detail_screen.dart | 86 +--- lib/services/jellyfin_mappers.dart | 71 +-- lib/services/library_query_translator.dart | 6 +- lib/widgets/cycling_media_backdrop.dart | 432 ++++++++++++++++++ lib/widgets/tv_spotlight_background.dart | 258 +---------- test/media/media_item_test.dart | 59 +++ test/services/jellyfin_client_urls_test.dart | 42 +- test/services/jellyfin_mappers_test.dart | 48 +- .../library_query_translator_test.dart | 2 +- test/test_helpers/media_items.dart | 4 + test/widgets/cycling_media_backdrop_test.dart | 227 +++++++++ 15 files changed, 991 insertions(+), 417 deletions(-) create mode 100644 lib/widgets/cycling_media_backdrop.dart create mode 100644 test/widgets/cycling_media_backdrop_test.dart diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index afe10dca..fba642cf 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -45,8 +45,10 @@ sealed class MediaItem with _$MediaItem { String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, + List? grandparentBackdropPaths, String? thumbPath, String? artPath, + List? 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? grandparentBackdropPaths, String? thumbPath, String? artPath, + List? 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? grandparentBackdropPaths, String? thumbPath, String? artPath, + List? 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 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 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 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 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 => [backgroundSquarePath, ...inherited, ...own], + MediaKind.episode => [...inherited, ...own, backgroundSquarePath], + _ when containerAspectRatio < 1.39 => [backgroundSquarePath, ...own], + _ => [...own, backgroundSquarePath], }; final candidates = []; diff --git a/lib/media/media_item.freezed.dart b/lib/media/media_item.freezed.dart index 211bf96c..ee9b951a 100644 --- a/lib/media/media_item.freezed.dart +++ b/lib/media/media_item.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$MediaItem { -@JsonKey(readValue: readStringField, defaultValue: '') String get id;@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind get kind; String? get guid; String? get title; String? get titleSort; String? get summary; String? get tagline; String? get originalTitle; String? get studio;@JsonKey(fromJson: flexibleInt) int? get year; String? get originallyAvailableAt; String? get contentRating; String? get parentId; String? get parentTitle; String? get parentThumbPath;@JsonKey(fromJson: flexibleInt) int? get parentIndex;@JsonKey(fromJson: flexibleInt) int? get index; String? get grandparentId; String? get grandparentTitle; String? get grandparentThumbPath; String? get grandparentArtPath; String? get thumbPath; String? get artPath; String? get clearLogoPath; String? get backgroundSquarePath;@JsonKey(fromJson: flexibleInt) int? get durationMs;@JsonKey(fromJson: flexibleInt) int? get viewOffsetMs;@JsonKey(fromJson: flexibleInt) int? get viewCount;@JsonKey(fromJson: flexibleInt) int? get lastViewedAt;@JsonKey(fromJson: flexibleInt) int? get leafCount;@JsonKey(fromJson: flexibleInt) int? get viewedLeafCount;@JsonKey(fromJson: flexibleInt) int? get childCount;@JsonKey(fromJson: flexibleInt) int? get addedAt;@JsonKey(fromJson: flexibleInt) int? get updatedAt;@JsonKey(fromJson: flexibleDouble) double? get rating;@JsonKey(fromJson: flexibleDouble) double? get userRating; bool? get isFavorite;@JsonKey(fromJson: _mediaItemStringList) List? get genres;@JsonKey(fromJson: _mediaItemStringList) List? get directors;@JsonKey(fromJson: _mediaItemStringList) List? get writers;@JsonKey(fromJson: _mediaItemStringList) List? get producers;@JsonKey(fromJson: _mediaItemStringList) List? get countries;@JsonKey(fromJson: _mediaItemStringList) List? get collections;@JsonKey(fromJson: _mediaItemStringList) List? get labels;@JsonKey(fromJson: _mediaItemStringList) List? get styles;@JsonKey(fromJson: _mediaItemStringList) List? get moods;@JsonKey(fromJson: _mediaItemRolesFromJson) List? get roles;@JsonKey(fromJson: _mediaItemVersionsFromJson) List? get mediaVersions; String? get libraryId; String? get libraryTitle; String? get audioLanguage;/// Jellyfin playlist entry id used by playlist write endpoints. +@JsonKey(readValue: readStringField, defaultValue: '') String get id;@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind get kind; String? get guid; String? get title; String? get titleSort; String? get summary; String? get tagline; String? get originalTitle; String? get studio;@JsonKey(fromJson: flexibleInt) int? get year; String? get originallyAvailableAt; String? get contentRating; String? get parentId; String? get parentTitle; String? get parentThumbPath;@JsonKey(fromJson: flexibleInt) int? get parentIndex;@JsonKey(fromJson: flexibleInt) int? get index; String? get grandparentId; String? get grandparentTitle; String? get grandparentThumbPath; String? get grandparentArtPath; List? get grandparentBackdropPaths; String? get thumbPath; String? get artPath; List? get backdropPaths; String? get clearLogoPath; String? get backgroundSquarePath;@JsonKey(fromJson: flexibleInt) int? get durationMs;@JsonKey(fromJson: flexibleInt) int? get viewOffsetMs;@JsonKey(fromJson: flexibleInt) int? get viewCount;@JsonKey(fromJson: flexibleInt) int? get lastViewedAt;@JsonKey(fromJson: flexibleInt) int? get leafCount;@JsonKey(fromJson: flexibleInt) int? get viewedLeafCount;@JsonKey(fromJson: flexibleInt) int? get childCount;@JsonKey(fromJson: flexibleInt) int? get addedAt;@JsonKey(fromJson: flexibleInt) int? get updatedAt;@JsonKey(fromJson: flexibleDouble) double? get rating;@JsonKey(fromJson: flexibleDouble) double? get userRating; bool? get isFavorite;@JsonKey(fromJson: _mediaItemStringList) List? get genres;@JsonKey(fromJson: _mediaItemStringList) List? get directors;@JsonKey(fromJson: _mediaItemStringList) List? get writers;@JsonKey(fromJson: _mediaItemStringList) List? get producers;@JsonKey(fromJson: _mediaItemStringList) List? get countries;@JsonKey(fromJson: _mediaItemStringList) List? get collections;@JsonKey(fromJson: _mediaItemStringList) List? get labels;@JsonKey(fromJson: _mediaItemStringList) List? get styles;@JsonKey(fromJson: _mediaItemStringList) List? get moods;@JsonKey(fromJson: _mediaItemRolesFromJson) List? get roles;@JsonKey(fromJson: _mediaItemVersionsFromJson) List? get mediaVersions; String? get libraryId; String? get libraryTitle; String? get audioLanguage;/// Jellyfin playlist entry id used by playlist write endpoints. @JsonKey(fromJson: flexibleInt) Object? get playlistItemId; String? get serverId; String? get serverName;/// Relative folder key (`/library/sections/{id}/folder?parent=…`) for /// [MediaKind.folder] rows — what [MediaServerClient.fetchFolderChildren] /// tunes into. Stamped by the folder fetchers, null elsewhere. @@ -31,7 +31,7 @@ $MediaItemCopyWith get copyWith => _$MediaItemCopyWithImpl @override String toString() { - return 'MediaItem(id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, thumbPath: $thumbPath, artPath: $artPath, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, userRating: $userRating, isFavorite: $isFavorite, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, playlistItemId: $playlistItemId, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; + return 'MediaItem(id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, grandparentBackdropPaths: $grandparentBackdropPaths, thumbPath: $thumbPath, artPath: $artPath, backdropPaths: $backdropPaths, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, userRating: $userRating, isFavorite: $isFavorite, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, playlistItemId: $playlistItemId, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; } @@ -42,7 +42,7 @@ abstract mixin class $MediaItemCopyWith<$Res> { factory $MediaItemCopyWith(MediaItem value, $Res Function(MediaItem) _then) = _$MediaItemCopyWithImpl; @useResult $Res call({ -@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw +@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw }); @@ -59,7 +59,7 @@ class _$MediaItemCopyWithImpl<$Res> /// Create a copy of MediaItem /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? userRating = freezed,Object? isFavorite = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? grandparentBackdropPaths = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? backdropPaths = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? userRating = freezed,Object? isFavorite = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable @@ -82,9 +82,11 @@ as int?,grandparentId: freezed == grandparentId ? _self.grandparentId : grandpar as String?,grandparentTitle: freezed == grandparentTitle ? _self.grandparentTitle : grandparentTitle // ignore: cast_nullable_to_non_nullable as String?,grandparentThumbPath: freezed == grandparentThumbPath ? _self.grandparentThumbPath : grandparentThumbPath // ignore: cast_nullable_to_non_nullable as String?,grandparentArtPath: freezed == grandparentArtPath ? _self.grandparentArtPath : grandparentArtPath // ignore: cast_nullable_to_non_nullable -as String?,thumbPath: freezed == thumbPath ? _self.thumbPath : thumbPath // ignore: cast_nullable_to_non_nullable +as String?,grandparentBackdropPaths: freezed == grandparentBackdropPaths ? _self.grandparentBackdropPaths : grandparentBackdropPaths // ignore: cast_nullable_to_non_nullable +as List?,thumbPath: freezed == thumbPath ? _self.thumbPath : thumbPath // ignore: cast_nullable_to_non_nullable as String?,artPath: freezed == artPath ? _self.artPath : artPath // ignore: cast_nullable_to_non_nullable -as String?,clearLogoPath: freezed == clearLogoPath ? _self.clearLogoPath : clearLogoPath // ignore: cast_nullable_to_non_nullable +as String?,backdropPaths: freezed == backdropPaths ? _self.backdropPaths : backdropPaths // ignore: cast_nullable_to_non_nullable +as List?,clearLogoPath: freezed == clearLogoPath ? _self.clearLogoPath : clearLogoPath // ignore: cast_nullable_to_non_nullable as String?,backgroundSquarePath: freezed == backgroundSquarePath ? _self.backgroundSquarePath : backgroundSquarePath // ignore: cast_nullable_to_non_nullable as String?,durationMs: freezed == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable as int?,viewOffsetMs: freezed == viewOffsetMs ? _self.viewOffsetMs : viewOffsetMs // ignore: cast_nullable_to_non_nullable @@ -201,11 +203,11 @@ return jellyfin(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? audienceRating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? audienceRating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,required TResult orElse(),}) {final _that = this; switch (_that) { case PlexMediaItem() when plex != null: -return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.thumbPath,_that.artPath,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.audienceRating,_that.userRating,_that.isFavorite,_that.ratingImage,_that.audienceRatingImage,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem() when jellyfin != null: -return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.thumbPath,_that.artPath,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: +return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.audienceRating,_that.userRating,_that.isFavorite,_that.ratingImage,_that.audienceRatingImage,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem() when jellyfin != null: +return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: return orElse(); } @@ -223,11 +225,11 @@ return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? audienceRating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) plex,required TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) jellyfin,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? audienceRating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) plex,required TResult Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw) jellyfin,}) {final _that = this; switch (_that) { case PlexMediaItem(): -return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.thumbPath,_that.artPath,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.audienceRating,_that.userRating,_that.isFavorite,_that.ratingImage,_that.audienceRatingImage,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem(): -return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.thumbPath,_that.artPath,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);} +return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.audienceRating,_that.userRating,_that.isFavorite,_that.ratingImage,_that.audienceRatingImage,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem(): +return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);} } /// A variant of `when` that fallback to returning `null` /// @@ -241,11 +243,11 @@ return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? audienceRating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult? Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? audienceRating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage, @JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey, @JsonKey(fromJson: flexibleInt) int? playlistItemId, @JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype, @JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? plex,TResult? Function(@JsonKey(readValue: readStringField, defaultValue: '') String id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio, @JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath, @JsonKey(fromJson: flexibleInt) int? parentIndex, @JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath, @JsonKey(fromJson: flexibleInt) int? durationMs, @JsonKey(fromJson: flexibleInt) int? viewOffsetMs, @JsonKey(fromJson: flexibleInt) int? viewCount, @JsonKey(fromJson: flexibleInt) int? lastViewedAt, @JsonKey(fromJson: flexibleInt) int? leafCount, @JsonKey(fromJson: flexibleInt) int? viewedLeafCount, @JsonKey(fromJson: flexibleInt) int? childCount, @JsonKey(fromJson: flexibleInt) int? addedAt, @JsonKey(fromJson: flexibleInt) int? updatedAt, @JsonKey(fromJson: flexibleDouble) double? rating, @JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, @JsonKey(fromJson: _mediaItemStringList) List? genres, @JsonKey(fromJson: _mediaItemStringList) List? directors, @JsonKey(fromJson: _mediaItemStringList) List? writers, @JsonKey(fromJson: _mediaItemStringList) List? producers, @JsonKey(fromJson: _mediaItemStringList) List? countries, @JsonKey(fromJson: _mediaItemStringList) List? collections, @JsonKey(fromJson: _mediaItemStringList) List? labels, @JsonKey(fromJson: _mediaItemStringList) List? styles, @JsonKey(fromJson: _mediaItemStringList) List? moods, @JsonKey(fromJson: _mediaItemRolesFromJson) List? roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) Map? raw)? jellyfin,}) {final _that = this; switch (_that) { case PlexMediaItem() when plex != null: -return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.thumbPath,_that.artPath,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.audienceRating,_that.userRating,_that.isFavorite,_that.ratingImage,_that.audienceRatingImage,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem() when jellyfin != null: -return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.thumbPath,_that.artPath,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: +return plex(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.editionTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.audienceRating,_that.userRating,_that.isFavorite,_that.ratingImage,_that.audienceRatingImage,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.subtitleLanguage,_that.subtitleMode,_that.trailerKey,_that.playlistItemId,_that.playQueueItemId,_that.subtype,_that.extraType,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case JellyfinMediaItem() when jellyfin != null: +return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that.summary,_that.tagline,_that.originalTitle,_that.studio,_that.year,_that.originallyAvailableAt,_that.contentRating,_that.parentId,_that.parentTitle,_that.parentThumbPath,_that.parentIndex,_that.index,_that.grandparentId,_that.grandparentTitle,_that.grandparentThumbPath,_that.grandparentArtPath,_that.grandparentBackdropPaths,_that.thumbPath,_that.artPath,_that.backdropPaths,_that.clearLogoPath,_that.backgroundSquarePath,_that.durationMs,_that.viewOffsetMs,_that.viewCount,_that.lastViewedAt,_that.leafCount,_that.viewedLeafCount,_that.childCount,_that.addedAt,_that.updatedAt,_that.rating,_that.userRating,_that.isFavorite,_that.genres,_that.directors,_that.writers,_that.producers,_that.countries,_that.collections,_that.labels,_that.styles,_that.moods,_that.roles,_that.mediaVersions,_that.libraryId,_that.libraryTitle,_that.audioLanguage,_that.playlistItemId,_that.serverId,_that.serverName,_that.backendFolderKey,_that.raw);case _: return null; } @@ -257,7 +259,7 @@ return jellyfin(_that.id,_that.kind,_that.guid,_that.title,_that.titleSort,_that @JsonSerializable(includeIfNull: false, explicitToJson: true) class PlexMediaItem extends MediaItem { - const PlexMediaItem({@JsonKey(readValue: readStringField, defaultValue: '') required this.id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required this.kind, this.guid, this.title, this.titleSort, this.summary, this.tagline, this.originalTitle, this.editionTitle, this.studio, @JsonKey(fromJson: flexibleInt) this.year, this.originallyAvailableAt, this.contentRating, this.parentId, this.parentTitle, this.parentThumbPath, @JsonKey(fromJson: flexibleInt) this.parentIndex, @JsonKey(fromJson: flexibleInt) this.index, this.grandparentId, this.grandparentTitle, this.grandparentThumbPath, this.grandparentArtPath, this.thumbPath, this.artPath, this.clearLogoPath, this.backgroundSquarePath, @JsonKey(fromJson: flexibleInt) this.durationMs, @JsonKey(fromJson: flexibleInt) this.viewOffsetMs, @JsonKey(fromJson: flexibleInt) this.viewCount, @JsonKey(fromJson: flexibleInt) this.lastViewedAt, @JsonKey(fromJson: flexibleInt) this.leafCount, @JsonKey(fromJson: flexibleInt) this.viewedLeafCount, @JsonKey(fromJson: flexibleInt) this.childCount, @JsonKey(fromJson: flexibleInt) this.addedAt, @JsonKey(fromJson: flexibleInt) this.updatedAt, @JsonKey(fromJson: flexibleDouble) this.rating, @JsonKey(fromJson: flexibleDouble) this.audienceRating, @JsonKey(fromJson: flexibleDouble) this.userRating, this.isFavorite, this.ratingImage, this.audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) this.genres, @JsonKey(fromJson: _mediaItemStringList) this.directors, @JsonKey(fromJson: _mediaItemStringList) this.writers, @JsonKey(fromJson: _mediaItemStringList) this.producers, @JsonKey(fromJson: _mediaItemStringList) this.countries, @JsonKey(fromJson: _mediaItemStringList) this.collections, @JsonKey(fromJson: _mediaItemStringList) this.labels, @JsonKey(fromJson: _mediaItemStringList) this.styles, @JsonKey(fromJson: _mediaItemStringList) this.moods, @JsonKey(fromJson: _mediaItemRolesFromJson) this.roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) this.mediaVersions, this.libraryId, this.libraryTitle, this.audioLanguage, this.subtitleLanguage, @JsonKey(fromJson: flexibleInt) this.subtitleMode, this.trailerKey, @JsonKey(fromJson: flexibleInt) this.playlistItemId, @JsonKey(fromJson: flexibleInt) this.playQueueItemId, this.subtype, @JsonKey(fromJson: flexibleInt) this.extraType, this.serverId, this.serverName, this.backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) this.raw}): super._(); + const PlexMediaItem({@JsonKey(readValue: readStringField, defaultValue: '') required this.id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required this.kind, this.guid, this.title, this.titleSort, this.summary, this.tagline, this.originalTitle, this.editionTitle, this.studio, @JsonKey(fromJson: flexibleInt) this.year, this.originallyAvailableAt, this.contentRating, this.parentId, this.parentTitle, this.parentThumbPath, @JsonKey(fromJson: flexibleInt) this.parentIndex, @JsonKey(fromJson: flexibleInt) this.index, this.grandparentId, this.grandparentTitle, this.grandparentThumbPath, this.grandparentArtPath, this.grandparentBackdropPaths, this.thumbPath, this.artPath, this.backdropPaths, this.clearLogoPath, this.backgroundSquarePath, @JsonKey(fromJson: flexibleInt) this.durationMs, @JsonKey(fromJson: flexibleInt) this.viewOffsetMs, @JsonKey(fromJson: flexibleInt) this.viewCount, @JsonKey(fromJson: flexibleInt) this.lastViewedAt, @JsonKey(fromJson: flexibleInt) this.leafCount, @JsonKey(fromJson: flexibleInt) this.viewedLeafCount, @JsonKey(fromJson: flexibleInt) this.childCount, @JsonKey(fromJson: flexibleInt) this.addedAt, @JsonKey(fromJson: flexibleInt) this.updatedAt, @JsonKey(fromJson: flexibleDouble) this.rating, @JsonKey(fromJson: flexibleDouble) this.audienceRating, @JsonKey(fromJson: flexibleDouble) this.userRating, this.isFavorite, this.ratingImage, this.audienceRatingImage, @JsonKey(fromJson: _mediaItemStringList) this.genres, @JsonKey(fromJson: _mediaItemStringList) this.directors, @JsonKey(fromJson: _mediaItemStringList) this.writers, @JsonKey(fromJson: _mediaItemStringList) this.producers, @JsonKey(fromJson: _mediaItemStringList) this.countries, @JsonKey(fromJson: _mediaItemStringList) this.collections, @JsonKey(fromJson: _mediaItemStringList) this.labels, @JsonKey(fromJson: _mediaItemStringList) this.styles, @JsonKey(fromJson: _mediaItemStringList) this.moods, @JsonKey(fromJson: _mediaItemRolesFromJson) this.roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) this.mediaVersions, this.libraryId, this.libraryTitle, this.audioLanguage, this.subtitleLanguage, @JsonKey(fromJson: flexibleInt) this.subtitleMode, this.trailerKey, @JsonKey(fromJson: flexibleInt) this.playlistItemId, @JsonKey(fromJson: flexibleInt) this.playQueueItemId, this.subtype, @JsonKey(fromJson: flexibleInt) this.extraType, this.serverId, this.serverName, this.backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) this.raw}): super._(); @override@JsonKey(readValue: readStringField, defaultValue: '') final String id; @@ -283,8 +285,10 @@ class PlexMediaItem extends MediaItem { @override final String? grandparentTitle; @override final String? grandparentThumbPath; @override final String? grandparentArtPath; +@override final List? grandparentBackdropPaths; @override final String? thumbPath; @override final String? artPath; +@override final List? backdropPaths; @override final String? clearLogoPath; @override final String? backgroundSquarePath; @override@JsonKey(fromJson: flexibleInt) final int? durationMs; @@ -343,7 +347,7 @@ $PlexMediaItemCopyWith get copyWith => _$PlexMediaItemCopyWithImp @override String toString() { - return 'MediaItem.plex(id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, editionTitle: $editionTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, thumbPath: $thumbPath, artPath: $artPath, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, audienceRating: $audienceRating, userRating: $userRating, isFavorite: $isFavorite, ratingImage: $ratingImage, audienceRatingImage: $audienceRatingImage, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, subtitleLanguage: $subtitleLanguage, subtitleMode: $subtitleMode, trailerKey: $trailerKey, playlistItemId: $playlistItemId, playQueueItemId: $playQueueItemId, subtype: $subtype, extraType: $extraType, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; + return 'MediaItem.plex(id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, editionTitle: $editionTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, grandparentBackdropPaths: $grandparentBackdropPaths, thumbPath: $thumbPath, artPath: $artPath, backdropPaths: $backdropPaths, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, audienceRating: $audienceRating, userRating: $userRating, isFavorite: $isFavorite, ratingImage: $ratingImage, audienceRatingImage: $audienceRatingImage, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, subtitleLanguage: $subtitleLanguage, subtitleMode: $subtitleMode, trailerKey: $trailerKey, playlistItemId: $playlistItemId, playQueueItemId: $playQueueItemId, subtype: $subtype, extraType: $extraType, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; } @@ -354,7 +358,7 @@ abstract mixin class $PlexMediaItemCopyWith<$Res> implements $MediaItemCopyWith< factory $PlexMediaItemCopyWith(PlexMediaItem value, $Res Function(PlexMediaItem) _then) = _$PlexMediaItemCopyWithImpl; @override @useResult $Res call({ -@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? audienceRating,@JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage,@JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey,@JsonKey(fromJson: flexibleInt) int? playlistItemId,@JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype,@JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw +@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? editionTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? audienceRating,@JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite, String? ratingImage, String? audienceRatingImage,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? subtitleLanguage,@JsonKey(fromJson: flexibleInt) int? subtitleMode, String? trailerKey,@JsonKey(fromJson: flexibleInt) int? playlistItemId,@JsonKey(fromJson: flexibleInt) int? playQueueItemId, String? subtype,@JsonKey(fromJson: flexibleInt) int? extraType, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw }); @@ -371,7 +375,7 @@ class _$PlexMediaItemCopyWithImpl<$Res> /// Create a copy of MediaItem /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? editionTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? audienceRating = freezed,Object? userRating = freezed,Object? isFavorite = freezed,Object? ratingImage = freezed,Object? audienceRatingImage = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? subtitleLanguage = freezed,Object? subtitleMode = freezed,Object? trailerKey = freezed,Object? playlistItemId = freezed,Object? playQueueItemId = freezed,Object? subtype = freezed,Object? extraType = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? editionTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? grandparentBackdropPaths = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? backdropPaths = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? audienceRating = freezed,Object? userRating = freezed,Object? isFavorite = freezed,Object? ratingImage = freezed,Object? audienceRatingImage = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? subtitleLanguage = freezed,Object? subtitleMode = freezed,Object? trailerKey = freezed,Object? playlistItemId = freezed,Object? playQueueItemId = freezed,Object? subtype = freezed,Object? extraType = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { return _then(PlexMediaItem( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable @@ -395,9 +399,11 @@ as int?,grandparentId: freezed == grandparentId ? _self.grandparentId : grandpar as String?,grandparentTitle: freezed == grandparentTitle ? _self.grandparentTitle : grandparentTitle // ignore: cast_nullable_to_non_nullable as String?,grandparentThumbPath: freezed == grandparentThumbPath ? _self.grandparentThumbPath : grandparentThumbPath // ignore: cast_nullable_to_non_nullable as String?,grandparentArtPath: freezed == grandparentArtPath ? _self.grandparentArtPath : grandparentArtPath // ignore: cast_nullable_to_non_nullable -as String?,thumbPath: freezed == thumbPath ? _self.thumbPath : thumbPath // ignore: cast_nullable_to_non_nullable +as String?,grandparentBackdropPaths: freezed == grandparentBackdropPaths ? _self.grandparentBackdropPaths : grandparentBackdropPaths // ignore: cast_nullable_to_non_nullable +as List?,thumbPath: freezed == thumbPath ? _self.thumbPath : thumbPath // ignore: cast_nullable_to_non_nullable as String?,artPath: freezed == artPath ? _self.artPath : artPath // ignore: cast_nullable_to_non_nullable -as String?,clearLogoPath: freezed == clearLogoPath ? _self.clearLogoPath : clearLogoPath // ignore: cast_nullable_to_non_nullable +as String?,backdropPaths: freezed == backdropPaths ? _self.backdropPaths : backdropPaths // ignore: cast_nullable_to_non_nullable +as List?,clearLogoPath: freezed == clearLogoPath ? _self.clearLogoPath : clearLogoPath // ignore: cast_nullable_to_non_nullable as String?,backgroundSquarePath: freezed == backgroundSquarePath ? _self.backgroundSquarePath : backgroundSquarePath // ignore: cast_nullable_to_non_nullable as String?,durationMs: freezed == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable as int?,viewOffsetMs: freezed == viewOffsetMs ? _self.viewOffsetMs : viewOffsetMs // ignore: cast_nullable_to_non_nullable @@ -450,7 +456,7 @@ as Map?, @JsonSerializable(includeIfNull: false, explicitToJson: true) class JellyfinMediaItem extends MediaItem { - const JellyfinMediaItem({@JsonKey(readValue: readStringField, defaultValue: '') required this.id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required this.kind, this.guid, this.title, this.titleSort, this.summary, this.tagline, this.originalTitle, this.studio, @JsonKey(fromJson: flexibleInt) this.year, this.originallyAvailableAt, this.contentRating, this.parentId, this.parentTitle, this.parentThumbPath, @JsonKey(fromJson: flexibleInt) this.parentIndex, @JsonKey(fromJson: flexibleInt) this.index, this.grandparentId, this.grandparentTitle, this.grandparentThumbPath, this.grandparentArtPath, this.thumbPath, this.artPath, this.clearLogoPath, this.backgroundSquarePath, @JsonKey(fromJson: flexibleInt) this.durationMs, @JsonKey(fromJson: flexibleInt) this.viewOffsetMs, @JsonKey(fromJson: flexibleInt) this.viewCount, @JsonKey(fromJson: flexibleInt) this.lastViewedAt, @JsonKey(fromJson: flexibleInt) this.leafCount, @JsonKey(fromJson: flexibleInt) this.viewedLeafCount, @JsonKey(fromJson: flexibleInt) this.childCount, @JsonKey(fromJson: flexibleInt) this.addedAt, @JsonKey(fromJson: flexibleInt) this.updatedAt, @JsonKey(fromJson: flexibleDouble) this.rating, @JsonKey(fromJson: flexibleDouble) this.userRating, this.isFavorite, @JsonKey(fromJson: _mediaItemStringList) this.genres, @JsonKey(fromJson: _mediaItemStringList) this.directors, @JsonKey(fromJson: _mediaItemStringList) this.writers, @JsonKey(fromJson: _mediaItemStringList) this.producers, @JsonKey(fromJson: _mediaItemStringList) this.countries, @JsonKey(fromJson: _mediaItemStringList) this.collections, @JsonKey(fromJson: _mediaItemStringList) this.labels, @JsonKey(fromJson: _mediaItemStringList) this.styles, @JsonKey(fromJson: _mediaItemStringList) this.moods, @JsonKey(fromJson: _mediaItemRolesFromJson) this.roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) this.mediaVersions, this.libraryId, this.libraryTitle, this.audioLanguage, this.playlistItemId, this.serverId, this.serverName, this.backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) this.raw}): super._(); + const JellyfinMediaItem({@JsonKey(readValue: readStringField, defaultValue: '') required this.id, @JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) required this.kind, this.guid, this.title, this.titleSort, this.summary, this.tagline, this.originalTitle, this.studio, @JsonKey(fromJson: flexibleInt) this.year, this.originallyAvailableAt, this.contentRating, this.parentId, this.parentTitle, this.parentThumbPath, @JsonKey(fromJson: flexibleInt) this.parentIndex, @JsonKey(fromJson: flexibleInt) this.index, this.grandparentId, this.grandparentTitle, this.grandparentThumbPath, this.grandparentArtPath, this.grandparentBackdropPaths, this.thumbPath, this.artPath, this.backdropPaths, this.clearLogoPath, this.backgroundSquarePath, @JsonKey(fromJson: flexibleInt) this.durationMs, @JsonKey(fromJson: flexibleInt) this.viewOffsetMs, @JsonKey(fromJson: flexibleInt) this.viewCount, @JsonKey(fromJson: flexibleInt) this.lastViewedAt, @JsonKey(fromJson: flexibleInt) this.leafCount, @JsonKey(fromJson: flexibleInt) this.viewedLeafCount, @JsonKey(fromJson: flexibleInt) this.childCount, @JsonKey(fromJson: flexibleInt) this.addedAt, @JsonKey(fromJson: flexibleInt) this.updatedAt, @JsonKey(fromJson: flexibleDouble) this.rating, @JsonKey(fromJson: flexibleDouble) this.userRating, this.isFavorite, @JsonKey(fromJson: _mediaItemStringList) this.genres, @JsonKey(fromJson: _mediaItemStringList) this.directors, @JsonKey(fromJson: _mediaItemStringList) this.writers, @JsonKey(fromJson: _mediaItemStringList) this.producers, @JsonKey(fromJson: _mediaItemStringList) this.countries, @JsonKey(fromJson: _mediaItemStringList) this.collections, @JsonKey(fromJson: _mediaItemStringList) this.labels, @JsonKey(fromJson: _mediaItemStringList) this.styles, @JsonKey(fromJson: _mediaItemStringList) this.moods, @JsonKey(fromJson: _mediaItemRolesFromJson) this.roles, @JsonKey(fromJson: _mediaItemVersionsFromJson) this.mediaVersions, this.libraryId, this.libraryTitle, this.audioLanguage, this.playlistItemId, this.serverId, this.serverName, this.backendFolderKey, @JsonKey(fromJson: _mediaItemRawFromJson) this.raw}): super._(); @override@JsonKey(readValue: readStringField, defaultValue: '') final String id; @@ -474,8 +480,10 @@ class JellyfinMediaItem extends MediaItem { @override final String? grandparentTitle; @override final String? grandparentThumbPath; @override final String? grandparentArtPath; +@override final List? grandparentBackdropPaths; @override final String? thumbPath; @override final String? artPath; +@override final List? backdropPaths; @override final String? clearLogoPath; @override final String? backgroundSquarePath; @override@JsonKey(fromJson: flexibleInt) final int? durationMs; @@ -525,7 +533,7 @@ $JellyfinMediaItemCopyWith get copyWith => _$JellyfinMediaIte @override String toString() { - return 'MediaItem.jellyfin(id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, thumbPath: $thumbPath, artPath: $artPath, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, userRating: $userRating, isFavorite: $isFavorite, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, playlistItemId: $playlistItemId, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; + return 'MediaItem.jellyfin(id: $id, kind: $kind, guid: $guid, title: $title, titleSort: $titleSort, summary: $summary, tagline: $tagline, originalTitle: $originalTitle, studio: $studio, year: $year, originallyAvailableAt: $originallyAvailableAt, contentRating: $contentRating, parentId: $parentId, parentTitle: $parentTitle, parentThumbPath: $parentThumbPath, parentIndex: $parentIndex, index: $index, grandparentId: $grandparentId, grandparentTitle: $grandparentTitle, grandparentThumbPath: $grandparentThumbPath, grandparentArtPath: $grandparentArtPath, grandparentBackdropPaths: $grandparentBackdropPaths, thumbPath: $thumbPath, artPath: $artPath, backdropPaths: $backdropPaths, clearLogoPath: $clearLogoPath, backgroundSquarePath: $backgroundSquarePath, durationMs: $durationMs, viewOffsetMs: $viewOffsetMs, viewCount: $viewCount, lastViewedAt: $lastViewedAt, leafCount: $leafCount, viewedLeafCount: $viewedLeafCount, childCount: $childCount, addedAt: $addedAt, updatedAt: $updatedAt, rating: $rating, userRating: $userRating, isFavorite: $isFavorite, genres: $genres, directors: $directors, writers: $writers, producers: $producers, countries: $countries, collections: $collections, labels: $labels, styles: $styles, moods: $moods, roles: $roles, mediaVersions: $mediaVersions, libraryId: $libraryId, libraryTitle: $libraryTitle, audioLanguage: $audioLanguage, playlistItemId: $playlistItemId, serverId: $serverId, serverName: $serverName, backendFolderKey: $backendFolderKey, raw: $raw)'; } @@ -536,7 +544,7 @@ abstract mixin class $JellyfinMediaItemCopyWith<$Res> implements $MediaItemCopyW factory $JellyfinMediaItemCopyWith(JellyfinMediaItem value, $Res Function(JellyfinMediaItem) _then) = _$JellyfinMediaItemCopyWithImpl; @override @useResult $Res call({ -@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, String? thumbPath, String? artPath, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw +@JsonKey(readValue: readStringField, defaultValue: '') String id,@JsonKey(fromJson: _mediaKindFromJson, toJson: _mediaKindToJson) MediaKind kind, String? guid, String? title, String? titleSort, String? summary, String? tagline, String? originalTitle, String? studio,@JsonKey(fromJson: flexibleInt) int? year, String? originallyAvailableAt, String? contentRating, String? parentId, String? parentTitle, String? parentThumbPath,@JsonKey(fromJson: flexibleInt) int? parentIndex,@JsonKey(fromJson: flexibleInt) int? index, String? grandparentId, String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, List? grandparentBackdropPaths, String? thumbPath, String? artPath, List? backdropPaths, String? clearLogoPath, String? backgroundSquarePath,@JsonKey(fromJson: flexibleInt) int? durationMs,@JsonKey(fromJson: flexibleInt) int? viewOffsetMs,@JsonKey(fromJson: flexibleInt) int? viewCount,@JsonKey(fromJson: flexibleInt) int? lastViewedAt,@JsonKey(fromJson: flexibleInt) int? leafCount,@JsonKey(fromJson: flexibleInt) int? viewedLeafCount,@JsonKey(fromJson: flexibleInt) int? childCount,@JsonKey(fromJson: flexibleInt) int? addedAt,@JsonKey(fromJson: flexibleInt) int? updatedAt,@JsonKey(fromJson: flexibleDouble) double? rating,@JsonKey(fromJson: flexibleDouble) double? userRating, bool? isFavorite,@JsonKey(fromJson: _mediaItemStringList) List? genres,@JsonKey(fromJson: _mediaItemStringList) List? directors,@JsonKey(fromJson: _mediaItemStringList) List? writers,@JsonKey(fromJson: _mediaItemStringList) List? producers,@JsonKey(fromJson: _mediaItemStringList) List? countries,@JsonKey(fromJson: _mediaItemStringList) List? collections,@JsonKey(fromJson: _mediaItemStringList) List? labels,@JsonKey(fromJson: _mediaItemStringList) List? styles,@JsonKey(fromJson: _mediaItemStringList) List? moods,@JsonKey(fromJson: _mediaItemRolesFromJson) List? roles,@JsonKey(fromJson: _mediaItemVersionsFromJson) List? mediaVersions, String? libraryId, String? libraryTitle, String? audioLanguage, String? playlistItemId, String? serverId, String? serverName, String? backendFolderKey,@JsonKey(fromJson: _mediaItemRawFromJson) Map? raw }); @@ -553,7 +561,7 @@ class _$JellyfinMediaItemCopyWithImpl<$Res> /// Create a copy of MediaItem /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? userRating = freezed,Object? isFavorite = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? playlistItemId = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? kind = null,Object? guid = freezed,Object? title = freezed,Object? titleSort = freezed,Object? summary = freezed,Object? tagline = freezed,Object? originalTitle = freezed,Object? studio = freezed,Object? year = freezed,Object? originallyAvailableAt = freezed,Object? contentRating = freezed,Object? parentId = freezed,Object? parentTitle = freezed,Object? parentThumbPath = freezed,Object? parentIndex = freezed,Object? index = freezed,Object? grandparentId = freezed,Object? grandparentTitle = freezed,Object? grandparentThumbPath = freezed,Object? grandparentArtPath = freezed,Object? grandparentBackdropPaths = freezed,Object? thumbPath = freezed,Object? artPath = freezed,Object? backdropPaths = freezed,Object? clearLogoPath = freezed,Object? backgroundSquarePath = freezed,Object? durationMs = freezed,Object? viewOffsetMs = freezed,Object? viewCount = freezed,Object? lastViewedAt = freezed,Object? leafCount = freezed,Object? viewedLeafCount = freezed,Object? childCount = freezed,Object? addedAt = freezed,Object? updatedAt = freezed,Object? rating = freezed,Object? userRating = freezed,Object? isFavorite = freezed,Object? genres = freezed,Object? directors = freezed,Object? writers = freezed,Object? producers = freezed,Object? countries = freezed,Object? collections = freezed,Object? labels = freezed,Object? styles = freezed,Object? moods = freezed,Object? roles = freezed,Object? mediaVersions = freezed,Object? libraryId = freezed,Object? libraryTitle = freezed,Object? audioLanguage = freezed,Object? playlistItemId = freezed,Object? serverId = freezed,Object? serverName = freezed,Object? backendFolderKey = freezed,Object? raw = freezed,}) { return _then(JellyfinMediaItem( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,kind: null == kind ? _self.kind : kind // ignore: cast_nullable_to_non_nullable @@ -576,9 +584,11 @@ as int?,grandparentId: freezed == grandparentId ? _self.grandparentId : grandpar as String?,grandparentTitle: freezed == grandparentTitle ? _self.grandparentTitle : grandparentTitle // ignore: cast_nullable_to_non_nullable as String?,grandparentThumbPath: freezed == grandparentThumbPath ? _self.grandparentThumbPath : grandparentThumbPath // ignore: cast_nullable_to_non_nullable as String?,grandparentArtPath: freezed == grandparentArtPath ? _self.grandparentArtPath : grandparentArtPath // ignore: cast_nullable_to_non_nullable -as String?,thumbPath: freezed == thumbPath ? _self.thumbPath : thumbPath // ignore: cast_nullable_to_non_nullable +as String?,grandparentBackdropPaths: freezed == grandparentBackdropPaths ? _self.grandparentBackdropPaths : grandparentBackdropPaths // ignore: cast_nullable_to_non_nullable +as List?,thumbPath: freezed == thumbPath ? _self.thumbPath : thumbPath // ignore: cast_nullable_to_non_nullable as String?,artPath: freezed == artPath ? _self.artPath : artPath // ignore: cast_nullable_to_non_nullable -as String?,clearLogoPath: freezed == clearLogoPath ? _self.clearLogoPath : clearLogoPath // ignore: cast_nullable_to_non_nullable +as String?,backdropPaths: freezed == backdropPaths ? _self.backdropPaths : backdropPaths // ignore: cast_nullable_to_non_nullable +as List?,clearLogoPath: freezed == clearLogoPath ? _self.clearLogoPath : clearLogoPath // ignore: cast_nullable_to_non_nullable as String?,backgroundSquarePath: freezed == backgroundSquarePath ? _self.backgroundSquarePath : backgroundSquarePath // ignore: cast_nullable_to_non_nullable as String?,durationMs: freezed == durationMs ? _self.durationMs : durationMs // ignore: cast_nullable_to_non_nullable as int?,viewOffsetMs: freezed == viewOffsetMs ? _self.viewOffsetMs : viewOffsetMs // ignore: cast_nullable_to_non_nullable diff --git a/lib/media/media_item.g.dart b/lib/media/media_item.g.dart index 67bb36a0..267dc4a2 100644 --- a/lib/media/media_item.g.dart +++ b/lib/media/media_item.g.dart @@ -30,8 +30,15 @@ PlexMediaItem _$PlexMediaItemFromJson(Map json) => grandparentTitle: json['grandparentTitle'] as String?, grandparentThumbPath: json['grandparentThumbPath'] as String?, grandparentArtPath: json['grandparentArtPath'] as String?, + grandparentBackdropPaths: + (json['grandparentBackdropPaths'] as List?) + ?.map((e) => e as String) + .toList(), thumbPath: json['thumbPath'] as String?, artPath: json['artPath'] as String?, + backdropPaths: (json['backdropPaths'] as List?) + ?.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 _$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 json) => grandparentTitle: json['grandparentTitle'] as String?, grandparentThumbPath: json['grandparentThumbPath'] as String?, grandparentArtPath: json['grandparentArtPath'] as String?, + grandparentBackdropPaths: + (json['grandparentBackdropPaths'] as List?) + ?.map((e) => e as String) + .toList(), thumbPath: json['thumbPath'] as String?, artPath: json['artPath'] as String?, + backdropPaths: (json['backdropPaths'] as List?) + ?.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 _$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, diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 93f22b30..2bd853c6 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -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 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 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 // 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, ), ); }, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 9f1ac7b5..a8be580b 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 return localPath; } - Widget _buildHeroNetworkArtwork( - BuildContext context, { - required MediaServerClient? client, - required List 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 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 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, ), ); }, diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index f8b2fe6e..d8e8e207 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -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 [] + : [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 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) { - final value = tags[type]; - if (value is String) tag = value; - } - if (tag == null) return null; + if (tags is! Map) return null; + final tag = tags[type]; + if (tag is! String || tag.isEmpty) return null; return _itemImagePath(id, type, tag: tag); } + static List _backdropImagePaths(String id, Object? rawTags) { + if (rawTags is! List) return const []; + final paths = []; + final seenTags = {}; + 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 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 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 _parentBackdropImagePaths(Map 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 diff --git a/lib/services/library_query_translator.dart b/lib/services/library_query_translator.dart index 901c4713..2cf682cb 100644 --- a/lib/services/library_query_translator.dart +++ b/lib/services/library_query_translator.dart @@ -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 = { 'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo', - 'ImageTypeLimit': '1', + 'ImageTypeLimit': '$jellyfinBackdropImageLimit', }; /// Translates a backend-neutral [LibraryQuery] into the per-backend diff --git a/lib/widgets/cycling_media_backdrop.dart b/lib/widgets/cycling_media_backdrop.dart new file mode 100644 index 00000000..b5ff232a --- /dev/null +++ b/lib/widgets/cycling_media_backdrop.dart @@ -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 imagePaths; + final List 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 createState() => _CyclingMediaBackdropState(); +} + +class _CyclingMediaBackdropState extends State with WidgetsBindingObserver { + Timer? _rotationTimer; + late List _rotationPaths; + late List _fallbackPaths; + final Set _failedPaths = {}; + final Set _pendingProviderFailures = {}; + 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 _uniquePaths(List paths) { + if (paths.isEmpty) return const []; + final unique = []; + 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 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? opacity}) { + final incoming = opacity != null; + return Image( + key: incoming ? ValueKey(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)), + ), + ], + ); + } +} diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index b68d462a..f8221b81 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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 [] + : [ + ...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 = [ - 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? 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(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)), - ), - ], - ); - } -} diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index da9326f1..2c0817e1 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -21,6 +21,7 @@ MediaItem _movie({ int? durationMs, int? viewOffsetMs, String? artPath, + List? 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'}); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index e8f751c0..40d1f28d 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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 { diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index 29a68d3a..bd77d6af 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -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', diff --git a/test/services/library_query_translator_test.dart b/test/services/library_query_translator_test.dart index 04551a70..3ebc6247 100644 --- a/test/services/library_query_translator_test.dart +++ b/test/services/library_query_translator_test.dart @@ -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', () { diff --git a/test/test_helpers/media_items.dart b/test/test_helpers/media_items.dart index ce99b46c..cb6ea92e 100644 --- a/test/test_helpers/media_items.dart +++ b/test/test_helpers/media_items.dart @@ -32,8 +32,10 @@ MediaItem testMediaItem({ String? grandparentTitle, String? grandparentThumbPath, String? grandparentArtPath, + List? grandparentBackdropPaths, String? thumbPath, String? artPath, + List? 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, diff --git a/test/widgets/cycling_media_backdrop_test.dart b/test/widgets/cycling_media_backdrop_test.dart new file mode 100644 index 00000000..76147f73 --- /dev/null +++ b/test/widgets/cycling_media_backdrop_test.dart @@ -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 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 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 renderedFilePaths(WidgetTester tester) { + return tester.widgetList(find.byType(Image)).map((image) { + return pathForProvider(image.image); + }).toList(); + } + + void expectVisibleBackdrop(WidgetTester tester, String path) { + final image = tester.widgetList(find.byType(Image)).last; + expect(pathForProvider(image.image), path); + expect(image.opacity?.value ?? 1, 1); + } + + Future finishImageTransition(WidgetTester tester, {Duration fadeDuration = _fadeDuration}) async { + await tester.runAsync(() => Future.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()); + }); +}