diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index 25e45c20..cf9347a1 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -112,6 +112,9 @@ class PlexMetadata with MultiServerFields { // Clear logo URL (extracted from Image array, but serialized for offline storage) final String? clearLogo; + // Square background art URL (extracted from Image array, used for near-square hero layouts) + final String? backgroundSquare; + /// Global unique identifier across all servers (serverId:ratingKey) String get globalKey => serverId != null ? buildGlobalKey(serverId!, ratingKey) : ratingKey; @@ -186,6 +189,7 @@ class PlexMetadata with MultiServerFields { this.serverId, this.serverName, this.clearLogo, + this.backgroundSquare, }); /// Create a copy of this metadata with optional field overrides @@ -242,6 +246,7 @@ class PlexMetadata with MultiServerFields { String? serverId, String? serverName, String? clearLogo, + String? backgroundSquare, }) { return PlexMetadata( ratingKey: ratingKey ?? this.ratingKey, @@ -296,35 +301,48 @@ class PlexMetadata with MultiServerFields { serverId: serverId ?? this.serverId, serverName: serverName ?? this.serverName, clearLogo: clearLogo ?? this.clearLogo, + backgroundSquare: backgroundSquare ?? this.backgroundSquare, ); } - /// Extract clearLogo from Image array in raw JSON - static String? _extractClearLogoFromJson(Map json) { + /// Extract an image URL by type from the Image array in raw JSON + static String? _extractImageFromJson(Map json, String imageType) { if (!json.containsKey('Image')) return null; final images = json['Image'] as List?; if (images == null) return null; for (var image in images) { - if (image is Map && image['type'] == 'clearLogo') { + if (image is Map && image['type'] == imageType) { return image['url'] as String?; } } return null; } - /// Create from JSON with clearLogo extracted from Image array + /// Create from JSON with Image array fields extracted factory PlexMetadata.fromJsonWithImages(Map json) { - // Extract clearLogo before parsing - final clearLogoUrl = _extractClearLogoFromJson(json); - // Add it to the json so it gets parsed + final clearLogoUrl = _extractImageFromJson(json, 'clearLogo'); if (clearLogoUrl != null) { json['clearLogo'] = clearLogoUrl; } + final backgroundSquareUrl = _extractImageFromJson(json, 'backgroundSquare'); + if (backgroundSquareUrl != null) { + json['backgroundSquare'] = backgroundSquareUrl; + } return PlexMetadata.fromJson(json); } + /// Returns the best hero art path based on the container's aspect ratio. + /// Uses backgroundSquare when the container is closer to 1:1 than 16:9. + String? heroArt({required double containerAspectRatio}) { + // Threshold = midpoint of 1:1 (1.0) and 16:9 (~1.78) ≈ 1.39 + if (containerAspectRatio < 1.39 && backgroundSquare != null) { + return backgroundSquare; + } + return art; + } + // Helper to get the display title (show name for episodes/seasons, title otherwise) String get displayTitle { final itemType = type.toLowerCase(); diff --git a/lib/models/plex_metadata.g.dart b/lib/models/plex_metadata.g.dart index eb2a598e..d934f63b 100644 --- a/lib/models/plex_metadata.g.dart +++ b/lib/models/plex_metadata.g.dart @@ -57,6 +57,7 @@ PlexMetadata _$PlexMetadataFromJson(Map json) => PlexMetadata( extraType: (json['extraType'] as num?)?.toInt(), primaryExtraKey: json['primaryExtraKey'] as String?, clearLogo: json['clearLogo'] as String?, + backgroundSquare: json['backgroundSquare'] as String?, ); Map _$PlexMetadataToJson(PlexMetadata instance) => { @@ -110,4 +111,5 @@ Map _$PlexMetadataToJson(PlexMetadata instance) => } }, itemBuilder: (context, index) { - return _buildHeroItem(_onDeck[index]); + return _buildHeroItem(_onDeck[index], heroHeight); }, ), // Bottom gradient that extends past hero bounds to ensure seamless blend @@ -1380,7 +1380,7 @@ class _DiscoverScreenState extends State ); } - Widget _buildHeroItem(PlexMetadata heroItem) { + Widget _buildHeroItem(PlexMetadata heroItem, double heroHeight) { final isEpisode = heroItem.isEpisode; final showName = heroItem.grandparentTitle ?? heroItem.title; final screenWidth = MediaQuery.of(context).size.width; @@ -1406,7 +1406,7 @@ class _DiscoverScreenState extends State clipBehavior: Clip.none, children: [ // Background Image with fade/zoom animation and parallax - if (heroItem.art != null || heroItem.grandparentArt != null) + if (heroItem.art != null || heroItem.backgroundSquare != null || heroItem.grandparentArt != null) ClipRect( child: AnimatedBuilder( animation: _scrollController, @@ -1429,9 +1429,10 @@ class _DiscoverScreenState extends State final client = _getClientForItem(heroItem); final mediaQuery = MediaQuery.of(context); final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); + final containerAspect = screenWidth / heroHeight; final imageUrl = PlexImageHelper.getOptimizedImageUrl( client: client, - thumbPath: heroItem.art ?? heroItem.grandparentArt, + thumbPath: heroItem.heroArt(containerAspectRatio: containerAspect) ?? heroItem.grandparentArt, maxWidth: mediaQuery.size.width, maxHeight: mediaQuery.size.height * 0.7, devicePixelRatio: dpr, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0f357a37..48033a14 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1853,14 +1853,17 @@ class _MediaDetailScreenState extends State with WatchStateAw SizedBox( height: headerHeight, width: double.infinity, - child: metadata.art != null + child: (metadata.art != null || metadata.backgroundSquare != null) ? Builder( builder: (context) { + final containerAspect = size.width / headerHeight; + final heroArtPath = metadata.heroArt(containerAspectRatio: containerAspect); + // Check for offline local file first if (widget.isOffline && widget.metadata.serverId != null) { final localPath = context.read().getArtworkLocalPath( widget.metadata.serverId!, - metadata.art, + heroArtPath, ); if (localPath != null && File(localPath).existsSync()) { return Image.file( @@ -1879,7 +1882,7 @@ class _MediaDetailScreenState extends State with WatchStateAw final dpr = PlexImageHelper.effectiveDevicePixelRatio(context); final imageUrl = PlexImageHelper.getOptimizedImageUrl( client: client, - thumbPath: metadata.art, + thumbPath: heroArtPath, maxWidth: mediaQuery.size.width, maxHeight: mediaQuery.size.height * 0.6, devicePixelRatio: dpr, diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index e0119170..cff9e81b 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -916,6 +916,11 @@ class DownloadManagerService { await _downloadSingleArtwork(serverId, metadata.art!, client); } + // Download square background art + if (metadata.backgroundSquare != null) { + await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client); + } + // Store thumb reference in database (primary artwork for display) await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: metadata.thumb); @@ -977,6 +982,11 @@ class DownloadManagerService { if (metadata.art != null) { await _downloadSingleArtwork(serverId, metadata.art!, client); } + + // Download square background art + if (metadata.backgroundSquare != null) { + await _downloadSingleArtwork(serverId, metadata.backgroundSquare!, client); + } } /// Download chapter thumbnail images for a media item