From a76f09a8ce3938e2a6128f422fa76d75b00c662e Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 25 Apr 2026 15:11:25 +0200 Subject: [PATCH] style(dashboard): enhance hero artwork handling with fallback logic and image failure management Entire-Checkpoint: c6e522b4276c --- .../workspace-dashboard-rails.component.html | 40 +++++-- .../workspace-dashboard-rails.component.scss | 34 ++++-- ...orkspace-dashboard-rails.component.spec.ts | 51 ++++++++- .../workspace-dashboard-rails.component.ts | 106 +++++++++++++++++- 4 files changed, 209 insertions(+), 22 deletions(-) diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index f1b854174..1c933a146 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -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) { - + @if (item.backdropUrl; as backdropUrl) { + + loading="eager" + decoding="async" + (error)="markHeroImageFailed(backdropUrl)" + /> } - @if (item.posterUrl) { + @if (item.posterUrl; as posterUrl) { } @else { - + {{ item.icon }} } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index 535db41a5..352935db4 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -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; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index a2bddd211..a679dce5c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -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'); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index b76a51b65..1146a9590 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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 +): 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>({}); readonly hero = computed(() => { 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()