feat: square art in hero sections

This commit is contained in:
edde746
2026-02-26 13:00:55 +01:00
parent 5cf5b2f0a0
commit 2b5923d80b
5 changed files with 48 additions and 14 deletions
+25 -7
View File
@@ -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<String, dynamic> json) {
/// Extract an image URL by type from the Image array in raw JSON
static String? _extractImageFromJson(Map<String, dynamic> 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<String, dynamic> 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();
+2
View File
@@ -57,6 +57,7 @@ PlexMetadata _$PlexMetadataFromJson(Map<String, dynamic> json) => PlexMetadata(
extraType: (json['extraType'] as num?)?.toInt(),
primaryExtraKey: json['primaryExtraKey'] as String?,
clearLogo: json['clearLogo'] as String?,
backgroundSquare: json['backgroundSquare'] as String?,
);
Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dynamic>{
@@ -110,4 +111,5 @@ Map<String, dynamic> _$PlexMetadataToJson(PlexMetadata instance) => <String, dyn
'extraType': instance.extraType,
'primaryExtraKey': instance.primaryExtraKey,
'clearLogo': instance.clearLogo,
'backgroundSquare': instance.backgroundSquare,
};
+5 -4
View File
@@ -1264,7 +1264,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
},
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<DiscoverScreen>
);
}
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<DiscoverScreen>
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<DiscoverScreen>
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,
+6 -3
View File
@@ -1853,14 +1853,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> 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<DownloadProvider>().getArtworkLocalPath(
widget.metadata.serverId!,
metadata.art,
heroArtPath,
);
if (localPath != null && File(localPath).existsSync()) {
return Image.file(
@@ -1879,7 +1882,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> 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,
@@ -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