mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(dashboard): align the cinematic hero with the details pages
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2f5d67120
commit
aac8021553
9 files changed
+193
-58
No files matched your search
@@ -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.
|
||||
@@ -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`,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:workspace", "domain:workspace", "type:feature"],
|
||||
"implicitDependencies": ["ui-styles"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
|
||||
+15
@@ -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();
|
||||
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
<!-- Every slide's backdrop stays mounted so switching crossfades
|
||||
and the next image is already decoded. -->
|
||||
@@ -43,9 +45,13 @@
|
||||
"
|
||||
(error)="presenter.markImageFailed(backdropUrl)"
|
||||
/>
|
||||
<!-- No 16:9 backdrop: the sharp poster (or channel
|
||||
logo) becomes key art on the right. -->
|
||||
@if (slide.backdropSource === 'poster') {
|
||||
<!-- A channel logo is not a backdrop: it sits on the
|
||||
right as the slide's artwork. A title's poster
|
||||
only washes the stage, blurred. -->
|
||||
@if (
|
||||
slide.backdropSource === 'poster' &&
|
||||
slide.contentType === 'live'
|
||||
) {
|
||||
<img
|
||||
class="hero__backdrop-keyart"
|
||||
[src]="backdropUrl"
|
||||
@@ -90,31 +96,39 @@
|
||||
|
||||
<p class="hero__meta" data-test-id="dashboard-hero-badges">
|
||||
@if (slide.episodeBadge) {
|
||||
<span class="hero__pill">{{ slide.episodeBadge }}</span>
|
||||
<app-meta-chip class="hero__pill">{{
|
||||
slide.episodeBadge
|
||||
}}</app-meta-chip>
|
||||
}
|
||||
@if (slide.rating) {
|
||||
<span class="hero__pill hero__pill--rating"
|
||||
>★ {{ slide.rating }}</span
|
||||
<app-meta-chip class="hero__pill" variant="rating"
|
||||
>★ {{ slide.rating }}</app-meta-chip
|
||||
>
|
||||
}
|
||||
@for (genre of slide.genres; track genre) {
|
||||
<span class="hero__pill">{{ genre }}</span>
|
||||
<app-meta-chip class="hero__pill">{{
|
||||
genre
|
||||
}}</app-meta-chip>
|
||||
}
|
||||
@if (slide.category) {
|
||||
<span class="hero__pill">{{ slide.category }}</span>
|
||||
<app-meta-chip class="hero__pill">{{
|
||||
slide.category
|
||||
}}</app-meta-chip>
|
||||
}
|
||||
@if (slide.timeRange) {
|
||||
<span class="hero__pill hero__pill--time">{{
|
||||
<app-meta-chip class="hero__pill hero__pill--time">{{
|
||||
slide.timeRange
|
||||
}}</span>
|
||||
}}</app-meta-chip>
|
||||
}
|
||||
@if (slide.year) {
|
||||
<span class="hero__pill">{{ slide.year }}</span>
|
||||
<app-meta-chip class="hero__pill">{{
|
||||
slide.year
|
||||
}}</app-meta-chip>
|
||||
}
|
||||
@if (slide.source) {
|
||||
<span class="hero__pill hero__pill--source">{{
|
||||
<app-meta-chip class="hero__pill hero__pill--source">{{
|
||||
slide.source
|
||||
}}</span>
|
||||
}}</app-meta-chip>
|
||||
}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<HTMLElement>(
|
||||
'[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', () => {
|
||||
|
||||
@@ -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<HTMLElement>('.hero__button--primary')
|
||||
?.click();
|
||||
}
|
||||
}
|
||||
|
||||
/** Roving arrows between the dots, like a tab list. */
|
||||
onDotKeydown(event: KeyboardEvent, index: number): void {
|
||||
const count = this.slides().length;
|
||||
|
||||
Reference in new issue
Block a user