mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
7d4213026e
commit
c70b9b4ddd
6 files changed
+92
-6
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user