diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 7df6c53f4..c06c43c94 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -108,7 +108,12 @@ once): recently watched channels) whose EPG answer has a title; 3. one favourite movie/series and one Xtream recently-added title; 4. remaining places round-robin over the next items of those lists; -5. only when nothing qualifies, the newest history row of any kind. +5. only when nothing qualifies, the newest history row of any kind (a + detail action: it can be a finished title). + +While live candidates exist but none has answered yet, one place stays +reserved for the live slide, so its late arrival never evicts a slide the +user may be viewing. The live candidates are derived and pinned by `DashboardLiveEpgPresenter` itself (XMLTV lookup and portal queue), independent of the live rails, so the @@ -119,7 +124,8 @@ extras (backdrop, rating, genres, overview, year) come from `DashboardHeroTmdbService` per featured title and vanish when TMDB is off. Rotation is the active dot's CSS fill animation (8 s); its `animationend` -advances. Hover, focus inside the hero and the pause button pause it; under +advances. 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 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`), 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 10f710682..9321cac0a 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 @@ -107,6 +107,7 @@ export class DashboardHeroSlidesPresenter { ) ), live: this.liveSlide()?.candidate ?? null, + reserveLive: this.liveEpg.heroLiveCandidates().length > 0, favorites: this.data .globalFavoriteItems() .filter( diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts index ec88fc0ed..e5e9f4119 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts @@ -48,6 +48,7 @@ const input = ( ): DashboardHeroSourceInput => ({ continueItems: [], live: null, + reserveLive: false, favorites: [], recentlyAdded: [], mostRecent: null, @@ -137,6 +138,42 @@ describe('pickDashboardHeroSources', () => { ]); }); + it('keeps a place for a pending live slide so its arrival evicts nothing', () => { + const lists = { + continueItems: [recent(1), recent(2)], + favorites: [favorite(10)], + recentlyAdded: [added(20)], + }; + const pending = summary( + pickDashboardHeroSources(input({ ...lists, reserveLive: true })) + ); + const arrived = summary( + pickDashboardHeroSources( + input({ + ...lists, + reserveLive: true, + live: { origin: 'favorite', item: favorite(50, 'live') }, + }) + ) + ); + + expect(pending).toEqual([ + 'continue:recent-1', + 'favorite:favorite-10', + 'added:added-20', + ]); + // Every slide shown before the live answer is still there after it. + expect(arrived).toEqual([ + 'continue:recent-1', + 'live:favorite-50', + 'favorite:favorite-10', + 'added:added-20', + ]); + expect( + summary(pickDashboardHeroSources(input({ ...lists }))) + ).toHaveLength(HERO_SLIDE_LIMIT); + }); + it('falls back to the newest history row only when nothing else qualifies', () => { const channel = recent(70, 'live'); expect( diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts index b9b9f46a2..22dae216f 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts @@ -40,6 +40,12 @@ export interface DashboardHeroSourceInput { readonly continueItems: readonly PortalRecentItem[]; /** The channel picked for the live slide, when one has a programme on air. */ readonly live: DashboardHeroLiveCandidate | null; + /** + * Channels exist that could still fill the live slide (their EPG answer + * may be pending). Its place is then kept free, so the slide arriving + * late never pushes another one out from under the user. + */ + readonly reserveLive: boolean; /** Favourite movies/series. */ readonly favorites: readonly PortalFavoriteItem[]; readonly recentlyAdded: readonly PortalAddedItem[]; @@ -89,15 +95,21 @@ export function selectDashboardHeroLiveCandidates( * then one favourite and one recent import; remaining places go to the next * unfinished title, favourite and import in turn. A title never appears * twice, and the order is stable so a slide arriving late (the live slide - * waits for its EPG answer) slots in without reshuffling the rest. + * waits for its EPG answer) slots in without reshuffling the rest. While + * live candidates exist, one place stays reserved for the live slide, so + * its arrival never evicts a slide the user may be viewing. */ export function pickDashboardHeroSources( input: DashboardHeroSourceInput ): DashboardHeroSource[] { const seen = new Set(); const sources: DashboardHeroSource[] = []; + const limit = + input.reserveLive && !input.live + ? HERO_SLIDE_LIMIT - 1 + : HERO_SLIDE_LIMIT; const push = (source: DashboardHeroSource | null) => { - if (!source || sources.length >= HERO_SLIDE_LIMIT) { + if (!source || sources.length >= limit) { return; } const key = dashboardHeroItemKey(source.item); @@ -125,7 +137,7 @@ export function pickDashboardHeroSources( push(favoriteAt(0)); push(addedAt(0)); // Ends once every list has run out; duplicates only skip a place. - for (let index = 1; sources.length < HERO_SLIDE_LIMIT; index++) { + for (let index = 1; sources.length < limit; index++) { const next = [continueAt(index), favoriteAt(index), addedAt(index)]; if (next.every((source) => source === null)) { break; 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 26ac48682..c05b4250f 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 @@ -145,6 +145,27 @@ describe('DashboardHeroComponent', () => { expect(activeTitle()).toBe('Second'); }); + it('resumes on Play even while the button keeps the pointer and focus', () => { + render(); + const section = host().querySelector( + '[data-test-id=dashboard-hero]' + ) as HTMLElement; + const pause = host().querySelector( + '[data-test-id=dashboard-hero-pause]' + ) as HTMLButtonElement; + section.dispatchEvent(new Event('mouseenter')); + section.dispatchEvent(new FocusEvent('focusin')); + + pause.click(); + fixture.detectChanges(); + pause.click(); + fixture.detectChanges(); + + expect(section.classList).not.toContain('hero--paused'); + finishActiveDot(); + expect(activeTitle()).toBe('Second'); + }); + it('switches slides from the dots, with roving arrow keys', () => { render(); 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 caf610c52..a92e5fd0c 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 @@ -128,7 +128,16 @@ export class DashboardHeroComponent { } togglePaused(): void { - this.userPaused.update((paused) => !paused); + if (!this.userPaused()) { + this.userPaused.set(true); + return; + } + // An explicit Play wins over the implicit pauses: the pointer and + // the focus are on this very button, so they would otherwise keep + // the rotation stopped. They re-arm on the next enter / focus move. + this.userPaused.set(false); + this.hovered.set(false); + this.focusWithin.set(false); } setHovered(hovered: boolean): void {