mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups Follow-ups to the season posters shipped in #1628: - The >6-seasons dropdown (`SeasonTabsComponent`) now carries a 28×42 season thumbnail at the start of each menu row that has a poster and in the closed trigger for the selected season, fed by a new `seasonPosters` input from the season container and the fullscreen episode panel. Rows without a poster get no placeholder, a failed image is dropped, and the pill row stays text-only as the design review decided. - The fullscreen season strip's episode count uses its own `PORTALS.EPISODE_COUNT_ONE/OTHER` keys instead of borrowing the download manager's; all 18 locales filled through the i18n merger from their existing `DOWNLOADS.EPISODE_COUNT_*` translations. - The Stalker mock's serve targets no longer pin `PORT` (an nx:run-commands `env` entry overrides the shell), and `main.ts` resolves `PORT`, then the Playwright-side `MOCK_PORT` alias, then 3210 — so `MOCK_PORT=3310` now relocates the whole E2E run. The Xtream mock honours `XTREAM_MOCK_PORT` the same way. - `resolveAutoSelectedSeason` gets a direct spec covering every branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(xtream-mock): mint marketing asset URLs on the port the server bound Greptile P1 on #1633: the listener honoured `XTREAM_MOCK_PORT`, but `marketingAssetOrigin()` still read `PORT` alone, so a run relocated only through the alias sent every poster/backdrop/logo/episode URL to 3211. One resolver (`resolveXtreamMockPortString` in `mock-port.ts`: `PORT`, then `XTREAM_MOCK_PORT`, then 3211) now feeds the environment parser, the marketing asset origin and the demo-guide origin fallback. A spec pins the precedence and that `marketingAssetUrl` follows the bound port. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
01c423ac43
commit
4cce4acaad
43 files changed
+699
-32
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type AutoSeasonContext,
|
||||
resolveAutoSelectedSeason,
|
||||
} from './season-auto-select.util';
|
||||
|
||||
// Direct coverage for the pure season auto-select rule. The container specs
|
||||
// exercise it through the component; this file pins each branch on its own.
|
||||
|
||||
function episode(id: string, season: number): XtreamSerieEpisode {
|
||||
return {
|
||||
id,
|
||||
episode_num: 1,
|
||||
title: `Episode ${id}`,
|
||||
container_extension: 'mp4',
|
||||
info: {},
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season,
|
||||
direct_source: '',
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
function position(
|
||||
contentXtreamId: number,
|
||||
overrides: Partial<PlaybackPositionData> = {}
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 2700,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const inProgress = (contentXtreamId: number, updatedAt?: string) =>
|
||||
position(contentXtreamId, { positionSeconds: 600, updatedAt });
|
||||
const watched = (contentXtreamId: number) =>
|
||||
position(contentXtreamId, { positionSeconds: 2700 });
|
||||
|
||||
/**
|
||||
* Builds the context the container would hand over: one episode per listed
|
||||
* id, season keys in display order, counts derived from the same data.
|
||||
*/
|
||||
function context(
|
||||
seasons: Record<string, string[]>,
|
||||
positions: Record<string, PlaybackPositionData> = {},
|
||||
overrides: Partial<AutoSeasonContext> = {}
|
||||
): AutoSeasonContext {
|
||||
const keys = Object.keys(seasons).sort((a, b) => Number(a) - Number(b));
|
||||
const episodes: Record<string, XtreamSerieEpisode[]> = {};
|
||||
const episodeCounts: Record<string, number> = {};
|
||||
const watchedCounts: Record<string, number> = {};
|
||||
for (const key of keys) {
|
||||
episodes[key] = seasons[key].map((id) => episode(id, Number(key)));
|
||||
episodeCounts[key] = seasons[key].length;
|
||||
watchedCounts[key] = seasons[key].filter((id) => {
|
||||
const stored = positions[id];
|
||||
return (
|
||||
stored !== undefined &&
|
||||
stored.positionSeconds / (stored.durationSeconds ?? 1) >= 0.9
|
||||
);
|
||||
}).length;
|
||||
}
|
||||
return {
|
||||
keys,
|
||||
playingSeasonKey: null,
|
||||
seasons: episodes,
|
||||
positionOf: (item) => positions[item.id],
|
||||
hasUnloadedSeasons: false,
|
||||
episodeCounts,
|
||||
watchedCounts,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveAutoSelectedSeason', () => {
|
||||
it('returns undefined without season keys', () => {
|
||||
expect(resolveAutoSelectedSeason(context({}))).toBeUndefined();
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({}, {}, { playingSeasonKey: '3' })
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers the playing season over every other rule', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{ '201': inProgress(201, '2026-07-02T00:00:00.000Z') },
|
||||
{ playingSeasonKey: '3' }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
|
||||
});
|
||||
|
||||
it('picks the most recently updated in-progress season', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-03T00:00:00.000Z'),
|
||||
'301': inProgress(301, '2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
// Season three is later in display order but its resume point is
|
||||
// older; recency, not position, decides.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
});
|
||||
|
||||
it('treats a missing updatedAt as the oldest resume point', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
|
||||
'201': inProgress(201),
|
||||
}
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
});
|
||||
|
||||
it('resolves an updatedAt tie to the season scanned last', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
|
||||
'201': inProgress(201, '2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
// `>=` keeps the later entry on a tie: the scan order of the seasons
|
||||
// map, which follows the container's sorted keys.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('ignores watched and barely-started positions when looking for progress', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{
|
||||
'101': watched(101),
|
||||
'301': position(301, {
|
||||
positionSeconds: 5,
|
||||
updatedAt: '2026-07-09T00:00:00.000Z',
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Season one is watched, season three's 5 s is below the in-progress
|
||||
// threshold, so the default rule runs: earliest unwatched season.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('falls back to the earliest season with unwatched episodes', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
|
||||
{ '101': watched(101), '102': watched(102) }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('opens the latest non-empty season once everything is watched', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{ '101': watched(101), '201': watched(201), '301': watched(301) }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
|
||||
});
|
||||
|
||||
it('skips loaded-but-empty seasons in both fallback branches', () => {
|
||||
// An empty season is never "unwatched": the first season with
|
||||
// episodes wins.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({ '1': [], '2': ['201'], '3': ['301'] })
|
||||
)
|
||||
).toBe('2');
|
||||
// Nor is it the "latest" season once everything is watched.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '101': watched(101), '201': watched(201) }
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
|
||||
it('returns the first key when every season is empty', () => {
|
||||
expect(resolveAutoSelectedSeason(context({ '1': [], '2': [] }))).toBe(
|
||||
'1'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the first key while seasons are still unloaded', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '101': watched(101) },
|
||||
{ hasUnloadedSeasons: true }
|
||||
);
|
||||
// Watched state of the unloaded seasons is unknown, so the fallback
|
||||
// does not skip ahead — but a resume point still outranks the pin.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '201': inProgress(201, '2026-07-01T00:00:00.000Z') },
|
||||
{ hasUnloadedSeasons: true }
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
|
||||
@@ -9,7 +9,8 @@ import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
|
||||
import { DownloadsService } from '@iptvnator/services';
|
||||
import { SeasonContainerComponent } from './season-container.component';
|
||||
|
||||
// Coverage for the season cover beside the season tabs (the `seasonPosters`
|
||||
// Coverage for the season cover beside the season tabs and the season
|
||||
// thumbnails in the >6-seasons dropdown (both fed by the `seasonPosters`
|
||||
// input). Lives beside season-container.component.spec.ts, which sits at the
|
||||
// max-lines cap.
|
||||
|
||||
@@ -133,6 +134,120 @@ describe('SeasonContainerComponent season cover', () => {
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
});
|
||||
|
||||
describe('dropdown thumbnails (more than six seasons)', () => {
|
||||
const sevenSeasons = (): Record<string, XtreamSerieEpisode[]> => {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let index = 1; index <= 7; index++) {
|
||||
seasons[String(index)] = [
|
||||
createEpisode({
|
||||
id: String(100 + index),
|
||||
season: index,
|
||||
}),
|
||||
];
|
||||
}
|
||||
return seasons;
|
||||
};
|
||||
const trigger = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
) as HTMLButtonElement | null;
|
||||
const triggerThumb = () =>
|
||||
trigger()?.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-dropdown-thumb"]'
|
||||
) ?? null;
|
||||
const openMenu = (): HTMLButtonElement[] => {
|
||||
trigger()?.click();
|
||||
fixture.detectChanges();
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.mat-mdc-menu-panel .mat-mdc-menu-item'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('renders a thumbnail per menu row that has a poster and none where a season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'3': 'https://img.test/season-3.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
// The pill row is untouched: the dropdown replaces it.
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
|
||||
).toHaveLength(0);
|
||||
const items = openMenu();
|
||||
expect(items).toHaveLength(7);
|
||||
const thumbOf = (item: HTMLButtonElement) =>
|
||||
item.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-menu-thumb"]'
|
||||
);
|
||||
expect(thumbOf(items[0])?.src).toBe(
|
||||
'https://img.test/season-1.jpg'
|
||||
);
|
||||
// Projected into the leading slot, ahead of Material's text span.
|
||||
expect(thumbOf(items[0])?.parentElement).toBe(items[0]);
|
||||
expect(thumbOf(items[1])).toBeNull();
|
||||
expect(thumbOf(items[2])?.src).toBe(
|
||||
'https://img.test/season-3.jpg'
|
||||
);
|
||||
// Rows without a poster carry no placeholder either.
|
||||
expect(items[1].querySelector('.season-tabs__thumb')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the selected season poster in the closed trigger and swaps it with the selection', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-1.jpg');
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
openMenu()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-2.jpg');
|
||||
|
||||
// A season without a poster leaves the trigger text-only.
|
||||
openMenu()[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('3');
|
||||
expect(triggerThumb()).toBeNull();
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a thumbnail whose image request fails instead of leaving a broken frame', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
triggerThumb()?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(triggerThumb()).toBeNull();
|
||||
// The same URL is dropped from the menu row too.
|
||||
expect(
|
||||
openMenu()[0].querySelector('[data-testid="season-menu-thumb"]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('withholds the cover when the selected season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
|
||||
@@ -3,9 +3,21 @@
|
||||
<button
|
||||
class="season-tabs__dropdown"
|
||||
data-testid="season-dropdown"
|
||||
[class.season-tabs__dropdown--with-thumb]="selectedPosterUrl()"
|
||||
[matMenuTriggerFor]="seasonMenu"
|
||||
type="button"
|
||||
>
|
||||
@if (selectedPosterUrl(); as posterUrl) {
|
||||
<img
|
||||
class="season-tabs__thumb"
|
||||
data-testid="season-dropdown-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
@@ -23,6 +35,20 @@
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
<!-- Projected into the item's leading slot so the row
|
||||
stays a flex line: thumbnail, name, count, check. -->
|
||||
@if (posterUrlOf(seasonKey); as posterUrl) {
|
||||
<img
|
||||
matMenuItemIcon
|
||||
class="season-tabs__thumb season-tabs__thumb--menu"
|
||||
data-testid="season-menu-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
|
||||
@@ -50,6 +50,28 @@
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
// The trigger hugs its thumbnail: less inset on the picture side and
|
||||
// vertically, so the pill grows only as much as the 42px poster needs.
|
||||
&--with-thumb {
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Season thumbnail in the dropdown (trigger + menu rows). Poster ratio at
|
||||
// 28×42 — small enough to keep the 48px menu row height Material sets.
|
||||
&__thumb {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
|
||||
&--menu {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-count {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
@@ -18,6 +19,15 @@ const MAX_SEASON_PILLS = 6;
|
||||
* for up to 6 seasons, a dropdown beyond that. Shows an optional season
|
||||
* description under the tabs and a "back to playing episode" chip when the
|
||||
* currently playing episode belongs to a different season.
|
||||
*
|
||||
* The dropdown carries season thumbnails (`seasonPosters`): a small poster at
|
||||
* the start of each menu row and, for the selected season, in the closed
|
||||
* trigger — a long menu of "Season 7 … Season 14" rows reads far faster with
|
||||
* a picture per row. The pill row deliberately stays text-only: a thumbnail
|
||||
* per pill turns a compact tab strip into a second poster rail. A season
|
||||
* without a poster simply has no thumbnail (no placeholder tile), and a
|
||||
* poster whose image request fails is dropped rather than left as a
|
||||
* broken-image frame.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-season-tabs',
|
||||
@@ -36,6 +46,13 @@ export class SeasonTabsComponent {
|
||||
readonly seasonDescription = input<string | null>(null);
|
||||
/** Season key of the episode currently playing inline, if any. */
|
||||
readonly playingSeasonKey = input<string | null>(null);
|
||||
/**
|
||||
* Per-season poster URLs keyed by season key, rendered as thumbnails in
|
||||
* the dropdown only (menu rows + the selected season's trigger).
|
||||
*/
|
||||
readonly seasonPosters = input<Readonly<Record<string, string>> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly backToPlayingRequested = output<void>();
|
||||
@@ -49,6 +66,24 @@ export class SeasonTabsComponent {
|
||||
return playing !== null && playing !== this.selectedSeason();
|
||||
});
|
||||
|
||||
/** Poster URLs whose image request failed; their thumbnail is dropped. */
|
||||
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** Thumbnail shown in the closed dropdown trigger. */
|
||||
readonly selectedPosterUrl = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
return selected === undefined ? null : this.posterUrlOf(selected);
|
||||
});
|
||||
|
||||
posterUrlOf(seasonKey: string): string | null {
|
||||
const url = this.seasonPosters()?.[seasonKey];
|
||||
return url && !this.failedPosters().has(url) ? url : null;
|
||||
}
|
||||
|
||||
onPosterError(url: string): void {
|
||||
this.failedPosters.update((failed) => new Set(failed).add(url));
|
||||
}
|
||||
|
||||
isSeasonCompleted(seasonKey: string): boolean {
|
||||
const total = this.episodeCounts()[seasonKey] ?? 0;
|
||||
return total > 0 && (this.watchedCounts()[seasonKey] ?? 0) >= total;
|
||||
|
||||
+3
-2
@@ -23,8 +23,8 @@
|
||||
<span class="episode-panel__season-count">
|
||||
{{
|
||||
(strip.episodeCount === 1
|
||||
? 'DOWNLOADS.EPISODE_COUNT_ONE'
|
||||
: 'DOWNLOADS.EPISODE_COUNT_OTHER'
|
||||
? 'PORTALS.EPISODE_COUNT_ONE'
|
||||
: 'PORTALS.EPISODE_COUNT_OTHER'
|
||||
) | translate: { count: strip.episodeCount }
|
||||
}}
|
||||
</span>
|
||||
@@ -36,6 +36,7 @@
|
||||
[selectedSeason]="selectedSeasonKey() ?? undefined"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(backToPlayingRequested)="backToPlaying()"
|
||||
|
||||
+47
-2
@@ -191,8 +191,8 @@ describe('FullscreenEpisodePanelComponent', () => {
|
||||
it('shows the season strip with poster, name and count, follows the tab and folds on a dead image', () => {
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
PORTALS: { SEASON_TAB: 'Season {{number}}' },
|
||||
DOWNLOADS: {
|
||||
PORTALS: {
|
||||
SEASON_TAB: 'Season {{number}}',
|
||||
EPISODE_COUNT_ONE: '1 episode',
|
||||
EPISODE_COUNT_OTHER: '{{count}} episodes',
|
||||
},
|
||||
@@ -241,6 +241,51 @@ describe('FullscreenEpisodePanelComponent', () => {
|
||||
expect(strip()).toBeNull();
|
||||
});
|
||||
|
||||
it('hands the season posters to the tabs dropdown of a long series', () => {
|
||||
const many: FullscreenEpisodePanelSeason[] = Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => ({
|
||||
key: String(index + 1),
|
||||
loadState: 'loaded',
|
||||
episodes: [item(10 * (index + 1), String(index + 1), 1)],
|
||||
...(index % 2 === 0
|
||||
? { posterUrl: `https://img.test/season-${index + 1}.jpg` }
|
||||
: {}),
|
||||
})
|
||||
);
|
||||
fixture.componentRef.setInput('seasons', many);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.seasonPosters()).toEqual({
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'3': 'https://img.test/season-3.jpg',
|
||||
'5': 'https://img.test/season-5.jpg',
|
||||
'7': 'https://img.test/season-7.jpg',
|
||||
});
|
||||
expect(pills()).toHaveLength(0);
|
||||
const trigger = fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(
|
||||
trigger.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-dropdown-thumb"]'
|
||||
)?.src
|
||||
).toBe('https://img.test/season-1.jpg');
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
const thumbs = Array.from(
|
||||
document.querySelectorAll<HTMLImageElement>(
|
||||
'.mat-mdc-menu-panel [data-testid="season-menu-thumb"]'
|
||||
)
|
||||
).map((thumb) => thumb.src);
|
||||
expect(thumbs).toEqual([
|
||||
'https://img.test/season-1.jpg',
|
||||
'https://img.test/season-3.jpg',
|
||||
'https://img.test/season-5.jpg',
|
||||
'https://img.test/season-7.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('withholds the season strip for a one-season series', () => {
|
||||
const [first] = seasons(12);
|
||||
fixture.componentRef.setInput('seasons', [
|
||||
|
||||
+10
@@ -106,6 +106,16 @@ export class FullscreenEpisodePanelComponent {
|
||||
readonly episodeCounts = computed(() =>
|
||||
countBySeason(this.seasons(), (season) => season.episodes.length)
|
||||
);
|
||||
/** Season poster URLs keyed by season, for the tabs' dropdown thumbnails. */
|
||||
readonly seasonPosters = computed<Record<string, string>>(() => {
|
||||
const posters: Record<string, string> = {};
|
||||
for (const season of this.seasons()) {
|
||||
if (season.posterUrl) {
|
||||
posters[season.key] = season.posterUrl;
|
||||
}
|
||||
}
|
||||
return posters;
|
||||
});
|
||||
/** Season poster URLs whose image request failed; the strip then folds. */
|
||||
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user