mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
style(dashboard): enhance hero artwork handling with fallback logic and image failure management
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
a2e2959b89
commit
a76f09a8ce
4 files changed
+209
-22
No files matched your search
+30
-10
@@ -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>
|
||||
}
|
||||
|
||||
+26
-8
@@ -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;
|
||||
|
||||
+50
-1
@@ -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');
|
||||
});
|
||||
});
|
||||
+103
-3
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user