feat(dashboard): contained logo + blurred backdrop for live channels

Render live TV channel logos with object-fit: contain on a soft blurred
wash sampled from the logo itself, instead of stretching small (100-256px)
logos to fill 2:3 portrait cards with object-fit: cover. Mirrors the
treatment used by Apple Podcasts and Plex Live TV. VOD/series posters
still get the full-bleed cover treatment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 6bb6f5796090
This commit is contained in:
4grayandClaude Opus 4.7 committed 2026-04-28 20:14:04 +02:00
1 parent 51347fc226
commit d163a1fbf2
6 files changed
+68

No files matched your search

@@ -49,9 +49,21 @@
>
<div
class="rail__art"
[class.rail__art--live]="
card.contentType === 'live'
"
[style.aspect-ratio]="aspectRatio()"
>
@if (card.imageUrl && !failedImages()[card.id]) {
@if (card.contentType === 'live') {
<span
class="rail__art-bg"
aria-hidden="true"
[style.background-image]="
'url(' + card.imageUrl + ')'
"
></span>
}
<img
[src]="card.imageUrl"
[alt]="card.title"
@@ -252,6 +252,34 @@
}
}
// Live TV channel logos are typically small (100–256px square) and look terrible
// when stretched to fill a 2:3 portrait card with `object-fit: cover`. The
// `--live` modifier centers the contained logo over a soft blurred wash sampled
// from the logo itself — same pattern as Apple Podcasts / Plex Live TV tiles.
.rail__art--live {
img {
box-sizing: border-box;
object-fit: contain;
padding: 26% 18%;
position: relative;
z-index: 1;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.35));
}
}
.rail__art-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(28px) saturate(1.4);
transform: scale(1.4);
opacity: 0.55;
z-index: 0;
pointer-events: none;
}
.rail__action-trigger {
position: absolute;
top: 8px;
@@ -31,6 +31,7 @@ export interface DashboardRailCard {
subtitle?: string;
imageUrl?: string;
icon: string;
contentType?: 'live' | 'movie' | 'series';
link: string[];
state?: Record<string, unknown>;
actions?: DashboardRailAction[];
@@ -54,6 +54,7 @@
[class.hero--has-default-backdrop]="
item.backdropSource === 'fallback'
"
[class.hero--live]="item.contentType === 'live'"
[routerLink]="item.link"
[state]="item.state ?? {}"
>
@@ -223,6 +223,27 @@
}
}
// Live channel logos (small square images) get destroyed by the default
// `object-fit: cover` poster treatment. The `.hero--live` modifier centers
// the logo with breathing room over a tinted backdrop. The blurred
// `.hero__backdrop` already provides the ambient color wash for the wider hero.
.hero--live .hero__poster {
background:
radial-gradient(
circle at 50% 35%,
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
transparent 65%
),
var(--app-widget-bg, #1e2330);
img {
box-sizing: border-box;
object-fit: contain;
padding: 22% 18%;
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.45));
}
}
.hero__body {
position: relative;
z-index: 2;
@@ -52,6 +52,7 @@ const SKELETON_RAILS = [1, 2, 3] as const;
interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly contentType?: 'live' | 'movie' | 'series';
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
@@ -260,6 +261,7 @@ export class WorkspaceDashboardRailsComponent {
return {
...artwork,
contentType: item.type,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
@@ -379,6 +381,7 @@ export class WorkspaceDashboardRailsComponent {
subtitle: `${this.data.getRecentItemProviderLabel(item)} · ${this.data.getRecentItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,
link: this.data.getRecentItemLink(item),
state: this.data.getRecentItemNavigationState(item),
};
@@ -391,6 +394,7 @@ export class WorkspaceDashboardRailsComponent {
subtitle: `${this.data.getFavoriteItemProviderLabel(item)} · ${this.data.getFavoriteItemTypeLabel(item)}`,
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,
link: this.data.getGlobalFavoriteLink(item),
state: this.data.getGlobalFavoriteNavigationState(item),
};
@@ -409,6 +413,7 @@ export class WorkspaceDashboardRailsComponent {
subtitle: subtitleParts.join(' · '),
imageUrl: item.poster_url,
icon: this.typeIcon(item.type),
contentType: item.type,
link: this.data.getRecentlyAddedLink(item),
state: this.data.getRecentlyAddedNavigationState(item),
};