From aac80215536a92ee0e418654ff1240dc756eec7e Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 2 Oct 2026 08:55:51 +0200 Subject: [PATCH] feat(dashboard): align the cinematic hero with the details pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero's primary is the same light Continue/Play as the details pages and its chips are the shared meta chip. A title without a 16:9 backdrop gets the blurred poster alone as the stage (the sharp key-art copy is gone; a live channel's logo still sits on the right). Series titles drop their season marker. The hero is a focusable region: ←/→ switch slides, Enter follows the primary action, and rotation pauses while the document is hidden. Co-Authored-By: Claude Fable 5.1 --- .../dashboard-hero-cinematic-alignment.md | 6 ++ docs/architecture/workspace-dashboard.md | 14 ++++- libs/workspace/dashboard/feature/project.json | 1 + .../dashboard-hero-slides.presenter.spec.ts | 15 +++++ .../rails/dashboard-hero-slides.presenter.ts | 9 ++- .../lib/rails/dashboard-hero.component.html | 40 ++++++++---- .../lib/rails/dashboard-hero.component.scss | 56 ++++++----------- .../rails/dashboard-hero.component.spec.ts | 62 +++++++++++++++++++ .../src/lib/rails/dashboard-hero.component.ts | 48 +++++++++++++- 9 files changed, 193 insertions(+), 58 deletions(-) create mode 100644 .changes/dashboard-hero-cinematic-alignment.md diff --git a/.changes/dashboard-hero-cinematic-alignment.md b/.changes/dashboard-hero-cinematic-alignment.md new file mode 100644 index 000000000..08bbd7f6e --- /dev/null +++ b/.changes/dashboard-hero-cinematic-alignment.md @@ -0,0 +1,6 @@ +--- +type: feature +area: workspace +--- + +The dashboard hero now shares the details pages' look: the same light Continue button and chips, a series title without its "(Season 1)" marker, and the blurred poster alone as the backdrop when a title has none. The hero can be focused: ← and → switch slides, Enter follows the main action, and rotation pauses while the window is hidden. diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index db6c22753..abdaa2b81 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -132,13 +132,21 @@ discovery slides open the detail page; live slides open the channel. TMDB extras (backdrop, rating, genres, overview, year) come from `DashboardHeroTmdbService` per featured title and vanish when TMDB is off. +Artwork: a title's backdrop, else its poster blurred and scaled past the +edges (no second, sharp copy); a live channel's logo sits on the right as key +art over its own wash. Series titles drop their season marker +(`splitSeasonSuffix`) — the `S1·E1` chip names the season. Chips are +`app-meta-chip`; the primary is the details pages' light primary +(`light-primary-button` from `libs/ui/styles`). + Rotation is the active dot's CSS fill animation (8 s); its `animationend` advances. The fill animates `transform` only (a bar sliding in under the pill's rounded clip), so it runs on the compositor; animating `width` there cost a style, layout and paint pass on every frame of an idle dashboard. -Hover, focus inside the hero and the pause button pause it; an -explicit Play clears the hover/focus pause until they re-arm; under -`prefers-reduced-motion` nothing auto-advances. The active slide is tracked +Hover, focus inside the hero, a hidden document and the pause button pause +it; an explicit Play clears the hover/focus pause until they re-arm; under +`prefers-reduced-motion` nothing auto-advances. The hero is a focusable +region: ←/→ switch slides and Enter follows the primary action. The active slide is tracked by id, so a late live slide never moves the user off the current one. Test hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`), `dashboard-hero-dot`, `dashboard-hero-pause`, diff --git a/libs/workspace/dashboard/feature/project.json b/libs/workspace/dashboard/feature/project.json index e22fb3c1d..8449df25d 100644 --- a/libs/workspace/dashboard/feature/project.json +++ b/libs/workspace/dashboard/feature/project.json @@ -5,6 +5,7 @@ "prefix": "lib", "projectType": "library", "tags": ["scope:workspace", "domain:workspace", "type:feature"], + "implicitDependencies": ["ui-styles"], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts index 3d99bd59b..40e4da792 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -230,6 +230,21 @@ describe('DashboardHeroSlidesPresenter', () => { ); }); + it('drops a season marker from a series title and leaves live titles alone', () => { + recentItems.set([ + { ...series, title: 'Big Pharma (1 сезон)' }, + watchedMovie, + ]); + candidates.set([ + { origin: 'favorite', item: { ...channel, title: 'Sport S01' } }, + ]); + const presenter = create(); + const titles = presenter.slides().map((slide) => slide.title); + expect(titles).toContain('Big Pharma'); + expect(titles).not.toContain('Big Pharma (1 сезон)'); + expect(titles).toContain('Sport S01'); + }); + it('resumes an unfinished series and offers a detail-only way in', () => { const [resume] = create().slides(); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts index 6c974a67e..2eb353128 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -11,7 +11,10 @@ import { import { toSignal } from '@angular/core/rxjs-interop'; import { TranslateService } from '@ngx-translate/core'; import { startWith } from 'rxjs'; -import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util'; +import { + isPortalPlaybackWatched, + splitSeasonSuffix, +} from '@iptvnator/portal/shared/util'; import { playlistDisplayLabel, resolvePortalActivityWatchKind, @@ -228,7 +231,9 @@ export class DashboardHeroSlidesPresenter { id: `${source.kind}:${dashboardHeroItemKey(item)}`, kind: source.kind, contentType: item.type, - title: item.title, + // "Большая фарма (1 сезон)" → "Большая фарма": the episode badge + // already names the season. + title: isLive ? item.title : splitSeasonSuffix(item.title).title, // Watch kind: a Stalker embedded-VOD show routes as a movie typeLabelKey: TYPE_LABEL_KEYS[ diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html index 3336ca5f3..078bd04e7 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html @@ -3,6 +3,7 @@ class="hero" data-test-id="dashboard-hero" aria-roledescription="carousel" + tabindex="0" [attr.aria-label]="'WORKSPACE.DASHBOARD.HERO_LABEL' | translate" [class.hero--paused]="paused()" [class.hero--rotating]="canRotate()" @@ -10,6 +11,7 @@ (mouseleave)="setHovered(false)" (focusin)="onFocusIn()" (focusout)="onFocusOut($event)" + (keydown)="onHeroKeydown($event)" > @@ -43,9 +45,13 @@ " (error)="presenter.markImageFailed(backdropUrl)" /> - - @if (slide.backdropSource === 'poster') { + + @if ( + slide.backdropSource === 'poster' && + slide.contentType === 'live' + ) { @if (slide.episodeBadge) { - {{ slide.episodeBadge }} + {{ + slide.episodeBadge + }} } @if (slide.rating) { - ★ {{ slide.rating }}★ {{ slide.rating }} } @for (genre of slide.genres; track genre) { - {{ genre }} + {{ + genre + }} } @if (slide.category) { - {{ slide.category }} + {{ + slide.category + }} } @if (slide.timeRange) { - {{ + {{ slide.timeRange - }} + }} } @if (slide.year) { - {{ slide.year }} + {{ + slide.year + }} } @if (slide.source) { - {{ + {{ slide.source - }} + }} }

diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss index e42788f7a..61808cf0d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss @@ -8,6 +8,8 @@ // light theme gets a light scrim with dark text and the dark theme the // reverse, with the same geometry. +@use '../../../../../../ui/styles/detail-view-actions' as detail-actions; + // The host renders nothing when there is no slide and no skeleton; with // `display: contents` it then adds no flex gap to the rails page either. :host { @@ -34,13 +36,6 @@ var(--app-on-surface, #e6e1e5) 12%, transparent ); - // Gold in the dark theme, a deeper amber on the light one: mixed with - // the theme's heading colour rather than detected per theme. - --hero-rating: color-mix( - in srgb, - #f5b400 60%, - var(--app-heading-color, #d8dce8) - ); position: relative; // A floor, not a fixed height: the slide sits in normal flow at the @@ -59,6 +54,11 @@ overflow: hidden; isolation: isolate; color: var(--hero-heading); + + &:focus-visible { + outline: 2px solid var(--app-selection-color, #78adff); + outline-offset: -2px; + } } // ── Backdrops ────────────────────────────────────────────────────────────── @@ -101,19 +101,18 @@ } // No 16:9 backdrop (Xtream/Stalker often lack one): the poster, blurred and -// scaled, washes its colours across the whole stage… +// scaled past its edges, is the backdrop. No second, sharp copy of it. .hero__backdrop--poster .hero__backdrop-image { - filter: blur(48px) saturate(1.4); - transform: scale(1.35); - opacity: 0.8; + filter: blur(24px) saturate(1.2); + transform: scale(1.12); } .hero__backdrop--poster.hero__backdrop--active .hero__backdrop-image { - transform: scale(1.3); + transform: scale(1.1); } -// …and the sharp poster fills the right-hand side as key art, faded into -// that wash, so the slide still shows the actual title artwork. +// A live channel's logo fills the right-hand side as key art, faded into +// its own blurred wash, so the slide still shows the channel's artwork. .hero__backdrop-keyart { position: absolute; top: 0; @@ -278,25 +277,12 @@ } .hero__pill { - padding: 3px 8px; - border-radius: 999px; - background: var(--hero-pill-bg); - color: var(--hero-heading); - white-space: nowrap; - - &--rating { - color: var(--hero-rating); - font-weight: 600; - } - &--time { font-variant-numeric: tabular-nums; } &--source { max-width: 220px; - overflow: hidden; - text-overflow: ellipsis; color: var(--hero-body); } } @@ -396,19 +382,13 @@ transform: translateY(1px); } + // The same light primary as the details pages' Continue / Play. &--primary { - background: var(--app-selection-color, #78adff); - color: var(--app-selection-on-color, #071225); - box-shadow: 0 6px 18px -8px - var(--app-selection-glow, rgba(120, 173, 255, 0.6)); + @include detail-actions.light-primary-button; - &:hover { - background: color-mix( - in srgb, - var(--app-selection-color, #78adff) 88%, - #fff - ); - } + height: 38px; + padding: 0 18px 0 14px; + font-size: 13px; } &--secondary { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts index 58a3fa020..3e63eb74b 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts @@ -277,6 +277,68 @@ describe('DashboardHeroComponent', () => { host().querySelector('[data-test-id=dashboard-hero]') ).toBeNull(); }); + + it('switches slides with the arrow keys and follows Enter on the hero itself', () => { + render(); + const section = host().querySelector( + '[data-test-id=dashboard-hero]' + ) as HTMLElement; + expect(section.getAttribute('tabindex')).toBe('0'); + + section.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) + ); + fixture.detectChanges(); + expect(activeTitle()).toBe('Second'); + + section.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }) + ); + fixture.detectChanges(); + expect(activeTitle()).toBe('First'); + + const primary = host().querySelector( + '[data-test-id=dashboard-hero-primary-action]' + ) as HTMLElement; + const clicked = jest.fn(); + primary.addEventListener('click', (event) => { + event.preventDefault(); + clicked(); + }); + section.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + expect(clicked).toHaveBeenCalledTimes(1); + + // Keys pressed inside a control belong to that control. + const dot = dots()[0]; + dot.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + expect(clicked).toHaveBeenCalledTimes(1); + }); + + it('pauses the rotation while the document is hidden', () => { + const visibility = jest + .spyOn(document, 'visibilityState', 'get') + .mockReturnValue('visible'); + render(); + const section = host().querySelector( + '[data-test-id=dashboard-hero]' + ) as HTMLElement; + expect(section.classList).not.toContain('hero--paused'); + + visibility.mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + fixture.detectChanges(); + expect(section.classList).toContain('hero--paused'); + + visibility.mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + fixture.detectChanges(); + expect(section.classList).not.toContain('hero--paused'); + visibility.mockRestore(); + }); }); describe('DashboardHeroComponent rotation animation', () => { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts index a92e5fd0c..f12b66726 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts @@ -10,6 +10,7 @@ import { import { MatIcon } from '@angular/material/icon'; import { RouterLink } from '@angular/router'; import { TranslatePipe } from '@ngx-translate/core'; +import { MetaChipComponent } from '@iptvnator/ui/components'; import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter'; import { HERO_ROTATION_MS } from './dashboard-hero-slides.utils'; import type { DashboardHeroSlide } from './dashboard-hero.utils'; @@ -29,10 +30,13 @@ const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; * * The active slide is tracked by id, so a slide that arrives late (the live * slide waits for its EPG answer) never yanks the user off the current one. + * + * The hero is a focusable region: ←/→ switch slides, Enter follows the + * primary action. Rotation also pauses while the document is hidden. */ @Component({ selector: 'lib-dashboard-hero', - imports: [MatIcon, RouterLink, TranslatePipe], + imports: [MatIcon, MetaChipComponent, RouterLink, TranslatePipe], templateUrl: './dashboard-hero.component.html', styleUrl: './dashboard-hero.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -74,6 +78,7 @@ export class DashboardHeroComponent { }); private readonly hovered = signal(false); private readonly focusWithin = signal(false); + private readonly documentHidden = signal(false); readonly userPaused = signal(false); readonly reducedMotion = signal(false); @@ -93,10 +98,23 @@ export class DashboardHeroComponent { ); readonly paused = computed( - () => this.userPaused() || this.hovered() || this.focusWithin() + () => + this.userPaused() || + this.hovered() || + this.focusWithin() || + this.documentHidden() ); constructor() { + if (typeof document !== 'undefined') { + const onVisibility = () => + this.documentHidden.set(document.visibilityState === 'hidden'); + onVisibility(); + document.addEventListener('visibilitychange', onVisibility); + inject(DestroyRef).onDestroy(() => + document.removeEventListener('visibilitychange', onVisibility) + ); + } const media = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(REDUCED_MOTION_QUERY) @@ -156,6 +174,32 @@ export class DashboardHeroComponent { } } + /** + * On the hero region itself: ←/→ switch slides, Enter follows the + * primary action. Buttons and dots inside keep their own keys. + */ + onHeroKeydown(event: KeyboardEvent): void { + if (event.target !== event.currentTarget) { + return; + } + const count = this.slides().length; + if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + if (count < 2) { + return; + } + event.preventDefault(); + const step = event.key === 'ArrowRight' ? 1 : -1; + this.show((this.activeIndex() + step + count) % count); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + (event.currentTarget as HTMLElement) + .querySelector('.hero__button--primary') + ?.click(); + } + } + /** Roving arrows between the dots, like a tab list. */ onDotKeydown(event: KeyboardEvent, index: number): void { const count = this.slides().length;