fix(workspace): keep hero slides stable on late live data; Play resumes

- Reserve the live slide's place while live candidates exist, so a late
  EPG answer never evicts the slide the user is viewing when the rotation
  was already full.
- An explicit Play clears the hover/focus pause (the button itself holds
  the pointer and focus), so keyboard users can resume the rotation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 18:04:59 +02:00
1 parent 7d4213026e
commit c70b9b4ddd
6 files changed
+92 -6

No files matched your search

+8 -2
View File
@@ -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`),
@@ -107,6 +107,7 @@ export class DashboardHeroSlidesPresenter {
)
),
live: this.liveSlide()?.candidate ?? null,
reserveLive: this.liveEpg.heroLiveCandidates().length > 0,
favorites: this.data
.globalFavoriteItems()
.filter(
@@ -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(
@@ -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<string>();
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;
@@ -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();
@@ -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 {