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;