style(dashboard): enhance hero artwork handling with fallback logic and image failure management

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-25 15:11:25 +02:00
1 parent a2e2959b89
commit a76f09a8ce
4 files changed
+209 -22

No files matched your search

@@ -46,30 +46,50 @@
!!item.posterUrl || !!item.backdropUrl
"
[class.hero--has-backdrop]="item.hasBackdrop"
[class.hero--has-default-backdrop]="
item.backdropSource === 'fallback'
"
[routerLink]="item.link"
[state]="item.state ?? {}"
>
@if (item.backdropUrl) {
<span
class="hero__backdrop"
<span
class="hero__backdrop"
aria-hidden="true"
[style.backgroundImage]="
item.backdropUrl
? 'url(' + item.backdropUrl + ')'
: item.fallbackBackdropBackground
"
></span>
@if (item.backdropUrl; as backdropUrl) {
<img
class="hero__image-probe"
[src]="backdropUrl"
alt=""
aria-hidden="true"
[style.backgroundImage]="
'url(' + item.backdropUrl + ')'
"
></span>
loading="eager"
decoding="async"
(error)="markHeroImageFailed(backdropUrl)"
/>
}
<span class="hero__vignette" aria-hidden="true"></span>
<span class="hero__poster">
@if (item.posterUrl) {
@if (item.posterUrl; as posterUrl) {
<img
[src]="item.posterUrl"
[src]="posterUrl"
[alt]="item.title"
loading="eager"
decoding="async"
(error)="markHeroImageFailed(posterUrl)"
/>
} @else {
<span class="hero__poster-fallback">
<span
class="hero__poster-fallback"
[style.backgroundImage]="
item.fallbackPosterBackground
"
>
<mat-icon>{{ item.icon }}</mat-icon>
</span>
}
@@ -44,7 +44,8 @@
transform: translateY(-2px);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 20px 40px -20px var(--app-selection-glow, rgba(120, 173, 255, 0.55));
0 20px 40px -20px
var(--app-selection-glow, rgba(120, 173, 255, 0.55));
}
// No-image fallback keeps the accent gradient so the hero is never flat.
@@ -87,6 +88,21 @@
opacity: 1;
}
.hero--has-default-backdrop .hero__backdrop {
filter: none;
transform: none;
opacity: 0.95;
}
.hero__image-probe {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
opacity: 0;
pointer-events: none;
}
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
// right edge (text legibility). Middle stays mostly transparent so the
// backdrop colours show through. Darkens further when a sharp backdrop is
@@ -172,12 +188,13 @@
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(
circle at 50% 40%,
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
transparent 60%
);
background: radial-gradient(
circle at 50% 40%,
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
transparent 60%
);
background-size: cover;
background-position: center;
mat-icon {
font-size: 56px;
@@ -253,7 +270,8 @@
// Explicit min-height anchors the text baseline regardless of font-smoothing
min-height: 36px;
box-sizing: border-box;
box-shadow: 0 4px 12px -4px var(--app-selection-glow, rgba(120, 173, 255, 0.5));
box-shadow: 0 4px 12px -4px
var(--app-selection-glow, rgba(120, 173, 255, 0.5));
mat-icon {
font-size: 20px;
@@ -1,5 +1,8 @@
import type { PlaylistMeta } from 'shared-interfaces';
import { buildDashboardSourceActions } from './workspace-dashboard-rails.component';
import {
buildDashboardSourceActions,
resolveDashboardHeroArtwork,
} from './workspace-dashboard-rails.component';
describe('buildDashboardSourceActions', () => {
const basePlaylist = {
@@ -53,3 +56,49 @@ describe('buildDashboardSourceActions', () => {
expect(actionIds(playlist, false)).toEqual(['playlist-info', 'remove']);
});
});
describe('resolveDashboardHeroArtwork', () => {
const item = {
title: 'Broken Hero',
posterUrl: 'https://images.example.test/poster.jpg',
backdropUrl: 'https://images.example.test/backdrop.jpg',
};
it('uses the explicit backdrop when both hero images are available', () => {
expect(resolveDashboardHeroArtwork(item, {})).toMatchObject({
backdropUrl: item.backdropUrl,
backdropSource: 'backdrop',
hasBackdrop: true,
posterUrl: item.posterUrl,
});
});
it('falls back from a failed backdrop to the poster cover', () => {
expect(
resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
})
).toMatchObject({
backdropUrl: item.posterUrl,
backdropSource: 'poster',
hasBackdrop: false,
posterUrl: item.posterUrl,
});
});
it('uses generated fallback artwork when backdrop and poster both fail', () => {
const artwork = resolveDashboardHeroArtwork(item, {
[item.backdropUrl]: true,
[item.posterUrl]: true,
});
expect(artwork).toMatchObject({
backdropSource: 'fallback',
hasBackdrop: false,
posterUrl: undefined,
backdropUrl: undefined,
});
expect(artwork.fallbackBackdropBackground).toContain('linear-gradient');
expect(artwork.fallbackPosterBackground).toContain('linear-gradient');
});
});
@@ -4,6 +4,7 @@ import {
computed,
effect,
inject,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -47,6 +48,9 @@ const SKELETON_RAILS = [1, 2, 3] as const;
interface DashboardHeroModel {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly icon: string;
readonly link: string[];
@@ -56,6 +60,88 @@ interface DashboardHeroModel {
readonly title: string;
}
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
export interface DashboardHeroArtworkInput {
readonly backdropUrl?: string | null;
readonly posterUrl?: string | null;
readonly title: string;
}
export interface DashboardHeroArtwork {
readonly backdropUrl?: string;
readonly backdropSource: DashboardHeroBackdropSource;
readonly fallbackBackdropBackground: string;
readonly fallbackPosterBackground: string;
readonly hasBackdrop: boolean;
readonly posterUrl?: string;
}
export function resolveDashboardHeroArtwork(
item: DashboardHeroArtworkInput,
failedImages: Record<string, true>
): DashboardHeroArtwork {
const posterUrl =
item.posterUrl && !failedImages[item.posterUrl]
? item.posterUrl
: undefined;
const explicitBackdropUrl =
item.backdropUrl && !failedImages[item.backdropUrl]
? item.backdropUrl
: undefined;
const backdropUrl = explicitBackdropUrl ?? posterUrl;
const backdropSource: DashboardHeroBackdropSource = explicitBackdropUrl
? 'backdrop'
: posterUrl
? 'poster'
: 'fallback';
return {
backdropUrl,
backdropSource,
fallbackBackdropBackground: buildFallbackBackground(
item.title,
50,
15,
80,
5,
60
),
fallbackPosterBackground: buildFallbackBackground(
item.title,
40,
25,
50,
15,
40
),
hasBackdrop: backdropSource === 'backdrop',
posterUrl,
};
}
function buildFallbackBackground(
title: string,
saturationA: number,
lightnessA: number,
saturationB: number,
lightnessB: number,
hueOffset: number
): string {
const hue = calculateHue(title || 'placeholder');
const h2 = (hue + hueOffset) % 360;
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
}
function calculateHue(text: string): number {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
return Math.abs(hash) % 360;
}
export type DashboardSourceActionId =
| 'refresh'
| 'playlist-info'
@@ -148,6 +234,7 @@ export class WorkspaceDashboardRailsComponent {
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
readonly failedHeroImages = signal<Record<string, true>>({});
readonly hero = computed<DashboardHeroModel | null>(() => {
const item = this.data.globalRecentItems()[0] ?? null;
@@ -155,12 +242,19 @@ export class WorkspaceDashboardRailsComponent {
return null;
}
const artwork = resolveDashboardHeroArtwork(
{
backdropUrl: item.backdrop_url,
posterUrl: item.poster_url,
title: item.title,
},
this.failedHeroImages()
);
return {
backdropUrl: item.backdrop_url || item.poster_url,
hasBackdrop: Boolean(item.backdrop_url),
...artwork,
icon: this.typeIcon(item.type),
link: this.data.getRecentItemLink(item),
posterUrl: item.poster_url,
state: this.data.getRecentItemNavigationState(item),
subtitle: this.buildHeroSubtitle(item),
title: item.title,
@@ -231,6 +325,12 @@ export class WorkspaceDashboardRailsComponent {
this.shellActions.openAddPlaylistDialog();
}
markHeroImageFailed(url: string): void {
this.failedHeroImages.update((state) =>
state[url] ? state : { ...state, [url]: true }
);
}
onSourceActionSelected(selection: DashboardRailActionSelection): void {
const playlist = this.data
.playlists()