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()