feat(portal): show each season's own poster beside the season tabs (#1628)

Series detail pages now render the selected season's poster as a season
cover next to the season tabs and description, and the fullscreen episode
panel shows the same poster as a season strip above its tabs.

Resolution is TMDB-first, like the show artwork merge: the lazy season
enrichment stores `/tv/{id}/season/{n}` `poster_path` as a w342 URL in
`tmdb_season_posters` (Xtream) or `StalkerSeriesTmdbSeasonsService.posters()`
(Stalker), under the same write-only-if-changed convergence guard as the
season overview. Xtream falls back to the provider's `seasons[].cover_big`/
`cover` when it is an http(s) URL other than the show poster, because panels
repeat the show poster on every season. Stalker is TMDB-only.

The cover column is not rendered for one-season items, seasons without a
poster, or a failed image, so every fallback is today's markup. It is sized
by a new `--season-cover-width` token (96/120/144px per Settings.coverSize).
The hero poster never follows the season.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-19 11:45:54 +02:00
1 parent 43c1ceac16
commit 7790e68147
36 files changed
+1119 -102

No files matched your search

@@ -0,0 +1,95 @@
import { isPortalPlaybackInProgress } from '@iptvnator/portal/shared/util';
import {
PlaybackPositionData,
XtreamSerieEpisode,
} from '@iptvnator/shared/interfaces';
export interface AutoSeasonContext {
/** Season keys in display order. */
keys: readonly string[];
/** Season of the inline-playing episode, if it is in the loaded set. */
playingSeasonKey: string | null;
seasons: Record<string, XtreamSerieEpisode[]>;
positionOf: (
episode: XtreamSerieEpisode
) => PlaybackPositionData | undefined;
/** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */
hasUnloadedSeasons: boolean;
episodeCounts: Record<string, number>;
watchedCounts: Record<string, number>;
}
/**
* The season the container opens on when the season set or the initial
* playback positions change: the inline-playing episode's season, else the
* most recently updated in-progress episode's season, else the default
* fallback below. Pure, so the container's auto-select effect stays a thin
* wrapper (see `SeasonContainerComponent.selectedSeason`).
*/
export function resolveAutoSelectedSeason(
context: AutoSeasonContext
): string | undefined {
const { keys } = context;
if (keys.length === 0) {
return undefined;
}
if (context.playingSeasonKey) {
return context.playingSeasonKey;
}
return (
findMostRecentInProgressSeason(context) ?? resolveDefaultSeason(context)
);
}
/**
* Fallback when nothing is playing or in progress: the earliest season with
* unwatched episodes, or — once everything loaded is watched — the latest
* non-empty season, where new episodes land (issue #1441). Loaded-but-empty
* seasons (a valid Stalker answer) are never picked over one that has
* episodes. Stalker lazy-VOD series with unhydrated seasons keep the first
* season: their watched state is unknown, so skipping past them would be a
* guess.
*/
function resolveDefaultSeason({
keys,
hasUnloadedSeasons,
episodeCounts,
watchedCounts,
}: AutoSeasonContext): string {
if (hasUnloadedSeasons) {
return keys[0];
}
const firstUnwatched = keys.find((key) => {
const total = episodeCounts[key] ?? 0;
return total > 0 && (watchedCounts[key] ?? 0) < total;
});
if (firstUnwatched) {
return firstUnwatched;
}
const latestWithEpisodes = [...keys]
.reverse()
.find((key) => (episodeCounts[key] ?? 0) > 0);
return latestWithEpisodes ?? keys[0];
}
function findMostRecentInProgressSeason({
seasons,
positionOf,
}: AutoSeasonContext): string | null {
let bestSeason: string | null = null;
let bestUpdatedAt = '';
for (const [key, episodes] of Object.entries(seasons)) {
for (const episode of episodes ?? []) {
const position = positionOf(episode);
if (!isPortalPlaybackInProgress(position)) {
continue;
}
const updatedAt = position?.updatedAt ?? '';
if (updatedAt >= bestUpdatedAt) {
bestUpdatedAt = updatedAt;
bestSeason = key;
}
}
}
return bestSeason;
}
@@ -22,16 +22,37 @@
/>
@if (hasSeasons()) {
<app-season-tabs
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonDescription]="selectedSeasonDescription()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
<!-- Season card: the selected season's own cover beside the tabs and
description. Without a cover the column is not rendered at all,
so the tabs sit exactly where they did before season posters. -->
<div
class="season-card"
[class.season-card--with-cover]="selectedSeasonPosterUrl()"
>
@if (selectedSeasonPosterUrl(); as posterUrl) {
<img
class="season-card__cover"
data-testid="season-cover"
[src]="posterUrl"
[alt]="
'PORTALS.SEASON_TAB'
| translate: { number: selectedSeason() }
"
loading="lazy"
(error)="onSeasonPosterError(posterUrl)"
/>
}
<app-season-tabs
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonDescription]="selectedSeasonDescription()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
</div>
}
<div class="content-container">
@@ -77,9 +77,42 @@
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
// Season card: an optional cover column (--season-cover-width follows
// Settings.coverSize: 96 / 120 / 144px, tokens in apps/web/src/_cover-size.scss)
// next to the tabs + description. Without a cover the grid is one column and
// the tabs render exactly as they did before season posters existed.
.season-card {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
align-items: start;
margin-bottom: 16px;
&--with-cover {
grid-template-columns: var(--season-cover-width, 120px) minmax(0, 1fr);
}
&__cover {
display: block;
width: 100%;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 8px;
background: var(--app-widget-bg);
box-shadow: var(--poster-shadow, 0 8px 40px rgba(0, 0, 0, 0.5));
}
}
app-season-tabs {
display: block;
margin-bottom: 16px;
min-width: 0;
}
@media (max-width: 640px) {
.season-card--with-cover {
grid-template-columns: 88px minmax(0, 1fr);
gap: 14px;
}
}
// Episode Card
@@ -1185,9 +1185,7 @@ describe('SeasonContainerComponent', () => {
fixture.detectChanges();
const button = toggleButton();
expect(button?.textContent).toContain(
'Mark season as watched (2)'
);
expect(button?.textContent).toContain('Mark season as watched (2)');
button?.click();
expect(emitted).toEqual([
@@ -1277,9 +1275,7 @@ describe('SeasonContainerComponent', () => {
const button = toggleButton();
// 103 is the only markable episode: 101 watched, 102 playing.
expect(button?.textContent).toContain(
'Mark season as watched (1)'
);
expect(button?.textContent).toContain('Mark season as watched (1)');
button?.click();
expect(
emitted[0].requests.map((item) => item.contentXtreamId)
@@ -36,6 +36,7 @@ import {
buildEpisodeInfoDialogData,
} from './episode-info-dialog.component';
import { formatEpisodePositionText } from './episode-progress.util';
import { resolveAutoSelectedSeason } from './season-auto-select.util';
import { SeasonDownloadPresenter } from './season-download-presenter';
import {
type EpisodeViewMode,
@@ -95,6 +96,11 @@ export class SeasonContainerComponent implements OnInit {
readonly playingEpisodeId = input<number | null>(null);
/** Per-season descriptions (TMDB/provider), keyed by season key. */
readonly seasonDescriptions = input<Record<string, string> | null>(null);
/**
* Per-season poster URLs (TMDB season poster, provider season cover),
* keyed by season key. Rendered as the season cover beside the tabs.
*/
readonly seasonPosters = input<Record<string, string> | null>(null);
/** True while a host is persisting a season-level watched toggle. */
readonly seasonWatchBatchRunning = input(false);
/**
@@ -201,6 +207,26 @@ export class SeasonContainerComponent implements OnInit {
return this.seasonDescriptions()?.[selected] ?? null;
});
/** Poster URLs whose image request failed; the cover column then folds. */
private readonly failedSeasonPosters = signal<ReadonlySet<string>>(
new Set()
);
/**
* The selected season's cover. Withheld for one-season items — that
* poster is the show poster again, a few hundred pixels below the hero —
* and for a URL whose image failed, so a dead provider link never leaves
* a broken-image frame beside the tabs.
*/
readonly selectedSeasonPosterUrl = computed(() => {
const selected = this.selectedSeason();
if (!selected || this.sortedSeasonKeys().length < 2) {
return null;
}
const url = this.seasonPosters()?.[selected] ?? null;
return url && !this.failedSeasonPosters().has(url) ? url : null;
});
constructor() {
this.downloadPresenter.connect({
adapter: this.downloadAdapter,
@@ -298,6 +324,10 @@ export class SeasonContainerComponent implements OnInit {
this.selectedSeason.set(seasonKey);
}
onSeasonPosterError(url: string): void {
this.failedSeasonPosters.update((failed) => new Set(failed).add(url));
}
scrollToPlayingEpisode(): void {
const playingSeason = this.playingSeasonKey();
if (!playingSeason) {
@@ -427,66 +457,16 @@ export class SeasonContainerComponent implements OnInit {
return null;
}
/** Auto-select rules live in season-auto-select.util.ts. */
private resolveAutoSeason(): string | undefined {
const keys = this.sortedSeasonKeys();
if (keys.length === 0) {
return undefined;
}
const playingSeason = this.playingSeasonKey();
if (playingSeason) {
return playingSeason;
}
const resumeSeason = this.findMostRecentInProgressSeason();
return resumeSeason ?? this.resolveDefaultSeason(keys);
}
/**
* Fallback when nothing is playing or in progress: the earliest season
* with unwatched episodes, or — once everything loaded is watched — the
* latest non-empty season, where new episodes land (issue #1441).
* Loaded-but-empty seasons (a valid Stalker answer) are never picked over
* one that has episodes. Stalker lazy-VOD series with unhydrated seasons
* keep the first season: their watched state is unknown, so skipping
* past them would be a guess.
*/
private resolveDefaultSeason(keys: readonly string[]): string {
if (this.hasUnloadedSeasons()) {
return keys[0];
}
const episodeCounts = this.episodeCounts();
const watchedCounts = this.watchedCounts();
const firstUnwatched = keys.find((key) => {
const total = episodeCounts[key] ?? 0;
return total > 0 && (watchedCounts[key] ?? 0) < total;
return resolveAutoSelectedSeason({
keys: this.sortedSeasonKeys(),
playingSeasonKey: this.playingSeasonKey(),
seasons: this.seasons(),
positionOf: (episode) => this.getEpisodePosition(episode),
hasUnloadedSeasons: this.hasUnloadedSeasons(),
episodeCounts: this.episodeCounts(),
watchedCounts: this.watchedCounts(),
});
if (firstUnwatched) {
return firstUnwatched;
}
const latestWithEpisodes = [...keys]
.reverse()
.find((key) => (episodeCounts[key] ?? 0) > 0);
return latestWithEpisodes ?? keys[0];
}
private findMostRecentInProgressSeason(): string | null {
let bestSeason: string | null = null;
let bestUpdatedAt = '';
for (const [key, episodes] of Object.entries(this.seasons())) {
for (const episode of episodes ?? []) {
const position = this.getEpisodePosition(episode);
if (!isPortalPlaybackInProgress(position)) {
continue;
}
const updatedAt = position?.updatedAt ?? '';
if (updatedAt >= bestUpdatedAt) {
bestUpdatedAt = updatedAt;
bestSeason = key;
}
}
}
return bestSeason;
}
}
@@ -0,0 +1,153 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { SeasonDownloadCoordinator } from '@iptvnator/portal/shared/data-access';
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`
// input). Lives beside season-container.component.spec.ts, which sits at the
// max-lines cap.
function createEpisode(
overrides: Partial<XtreamSerieEpisode> = {}
): XtreamSerieEpisode {
return {
id: '101',
episode_num: 1,
title: 'Pilot',
container_extension: 'mp4',
info: { duration: '45 min' },
custom_sid: '',
added: '',
season: 1,
direct_source: '',
...overrides,
} as XtreamSerieEpisode;
}
describe('SeasonContainerComponent season cover', () => {
let fixture: ComponentFixture<SeasonContainerComponent>;
let component: SeasonContainerComponent;
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>
) => {
fixture.componentRef.setInput('seasons', seasons);
fixture.componentRef.setInput('seriesId', 20);
fixture.componentRef.setInput('playlistId', 'playlist-1');
};
const cover = () =>
fixture.nativeElement.querySelector(
'[data-testid="season-cover"]'
) as HTMLImageElement | null;
const coverColumnRendered = () =>
fixture.nativeElement.querySelector('.season-card--with-cover') !==
null;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
SeasonContainerComponent,
TranslateModule.forRoot(),
],
providers: [
{
provide: DownloadsService,
useValue: {
isAvailable: signal(false),
hasAuthoritativeDownloadList: signal(false),
hasLoadedDownloads: signal(false),
downloads: signal([]),
},
},
{ provide: MatDialog, useValue: { open: jest.fn() } },
SeasonDownloadCoordinator,
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
],
}).compileComponents();
fixture = TestBed.createComponent(SeasonContainerComponent);
component = fixture.componentInstance;
});
it('renders the selected season cover and swaps it with the tab', () => {
fixture.componentRef.setInput('seasonPosters', {
'1': 'https://img.test/season-1.jpg',
'2': 'https://img.test/season-2.jpg',
});
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
expect(cover()?.src).toBe('https://img.test/season-1.jpg');
expect(coverColumnRendered()).toBe(true);
const tabs = fixture.nativeElement.querySelectorAll(
'.season-tabs__pill'
) as NodeListOf<HTMLButtonElement>;
tabs[1].click();
fixture.detectChanges();
expect(cover()?.src).toBe('https://img.test/season-2.jpg');
});
it('folds the cover column when the image request fails', () => {
fixture.componentRef.setInput('seasonPosters', {
'1': 'https://img.test/season-1.jpg',
'2': 'https://img.test/season-2.jpg',
});
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
// A dead image link folds the cover column instead of leaving a
// broken-image frame beside the tabs; the tabs stay in place.
cover()?.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(cover()).toBeNull();
expect(coverColumnRendered()).toBe(false);
expect(
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
).toHaveLength(2);
});
it('withholds the season cover for a one-season item', () => {
fixture.componentRef.setInput('seasonPosters', {
'1': 'https://img.test/season-1.jpg',
});
setRequiredInputs({ '1': [createEpisode()] });
fixture.detectChanges();
expect(cover()).toBeNull();
expect(coverColumnRendered()).toBe(false);
});
it('withholds the cover when the selected season has no poster', () => {
fixture.componentRef.setInput('seasonPosters', {
'2': 'https://img.test/season-2.jpg',
});
setRequiredInputs({
'1': [createEpisode()],
'2': [createEpisode({ id: '201', season: 2 })],
});
fixture.detectChanges();
expect(component.selectedSeason()).toBe('1');
expect(cover()).toBeNull();
expect(coverColumnRendered()).toBe(false);
expect(
fixture.nativeElement.querySelector('app-season-tabs')
).not.toBeNull();
});
});
@@ -1,5 +1,36 @@
<div class="episode-panel">
<div class="episode-panel__tabs">
@if (seasonStrip(); as strip) {
<div
class="episode-panel__season"
data-test-id="fullscreen-episode-panel-season"
>
<img
class="episode-panel__season-poster"
[src]="strip.posterUrl"
alt=""
aria-hidden="true"
loading="lazy"
(error)="onSeasonPosterError(strip.posterUrl)"
/>
<span class="episode-panel__season-meta">
<span class="episode-panel__season-name">
{{
'PORTALS.SEASON_TAB'
| translate: { number: strip.seasonKey }
}}
</span>
<span class="episode-panel__season-count">
{{
(strip.episodeCount === 1
? 'DOWNLOADS.EPISODE_COUNT_ONE'
: 'DOWNLOADS.EPISODE_COUNT_OTHER'
) | translate: { count: strip.episodeCount }
}}
</span>
</span>
</div>
}
<app-season-tabs
[seasonKeys]="seasonKeys()"
[selectedSeason]="selectedSeasonKey() ?? undefined"
@@ -29,6 +29,42 @@
--accent-success: #4ade80;
}
// ─── Season strip: the shown season's poster, name and count above the tabs ──
.episode-panel__season {
display: flex;
align-items: center;
gap: 12px;
margin: 0 4px 10px;
}
.episode-panel__season-poster {
flex: none;
width: 44px;
aspect-ratio: 2 / 3;
object-fit: cover;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
}
.episode-panel__season-meta {
display: grid;
gap: 2px;
min-width: 0;
}
.episode-panel__season-name {
font-size: 0.9rem;
font-weight: 600;
color: #ffffff;
}
.episode-panel__season-count {
font-size: 0.76rem;
color: rgba(255, 255, 255, 0.6);
}
.episode-panel__list {
// Positioned so a row's offsetTop is measured against this scroll box.
position: relative;
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { FullscreenEpisodePanelComponent } from './fullscreen-episode-panel.component';
import type {
FullscreenEpisodePanelItem,
@@ -188,6 +188,74 @@ describe('FullscreenEpisodePanelComponent', () => {
expect(selected).toEqual(['2']);
});
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: {
EPISODE_COUNT_ONE: '1 episode',
EPISODE_COUNT_OTHER: '{{count}} episodes',
},
});
translate.use('en');
const withPosters = seasons(12).map((season) =>
season.key === '1'
? { ...season, posterUrl: 'https://img.test/season-1.jpg' }
: season
);
fixture.componentRef.setInput('seasons', withPosters);
fixture.detectChanges();
const strip = () =>
fixture.nativeElement.querySelector(
'[data-test-id="fullscreen-episode-panel-season"]'
) as HTMLElement | null;
expect(
strip()?.querySelector<HTMLImageElement>(
'.episode-panel__season-poster'
)?.src
).toBe('https://img.test/season-1.jpg');
expect(
strip()
?.querySelector('.episode-panel__season-name')
?.textContent?.trim()
).toBe('Season 1');
expect(
strip()
?.querySelector('.episode-panel__season-count')
?.textContent?.trim()
).toBe('2 episodes');
// Season two has no poster: the strip is withheld, tabs stay.
pills()[1].click();
fixture.detectChanges();
expect(strip()).toBeNull();
expect(pills()).toHaveLength(3);
pills()[0].click();
fixture.detectChanges();
strip()
?.querySelector('.episode-panel__season-poster')
?.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(strip()).toBeNull();
});
it('withholds the season strip for a one-season series', () => {
const [first] = seasons(12);
fixture.componentRef.setInput('seasons', [
{ ...first, posterUrl: 'https://img.test/season-1.jpg' },
]);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="fullscreen-episode-panel-season"]'
)
).toBeNull();
expect(rows()).toHaveLength(2);
});
it('shows a pending season as loading and a loaded empty season as empty', () => {
fixture.componentRef.setInput('seasons', seasons(12));
fixture.detectChanges();
@@ -106,6 +106,34 @@ export class FullscreenEpisodePanelComponent {
readonly episodeCounts = computed(() =>
countBySeason(this.seasons(), (season) => season.episodes.length)
);
/** Season poster URLs whose image request failed; the strip then folds. */
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
/**
* The season strip above the tabs: the shown season's own poster with
* its name and episode count. Withheld for one-season series (that
* poster is the show poster) and for a poster whose image failed.
*/
readonly seasonStrip = computed<{
posterUrl: string;
seasonKey: string;
episodeCount: number;
} | null>(() => {
const season = this.selectedSeason();
const posterUrl = season?.posterUrl;
if (
!season ||
!posterUrl ||
this.seasons().length < 2 ||
this.failedPosters().has(posterUrl)
) {
return null;
}
return {
posterUrl,
seasonKey: season.key,
episodeCount: season.episodes.length,
};
});
readonly watchedCounts = computed(() =>
countBySeason(
this.seasons(),
@@ -155,6 +183,10 @@ export class FullscreenEpisodePanelComponent {
this.failedStills.update((failed) => new Set(failed).add(item.id));
}
onSeasonPosterError(url: string): void {
this.failedPosters.update((failed) => new Set(failed).add(url));
}
hasStill(item: FullscreenEpisodePanelItem): boolean {
return item.thumbnailUrl !== null && !this.failedStills().has(item.id);
}
@@ -163,6 +163,17 @@ describe('buildFullscreenEpisodePanelSeasons', () => {
});
});
it('carries the host’s season poster only for seasons that have one', () => {
const seasons = buildFullscreenEpisodePanelSeasons({
episodesBySeason: { '1': [episode(11, 1, 1)], '2': [] },
currentEpisodeId: 11,
seasonPosters: { '1': 'https://img.test/season-1.jpg' },
});
expect(seasons[0].posterUrl).toBe('https://img.test/season-1.jpg');
expect('posterUrl' in seasons[1]).toBe(false);
});
it('falls back to the season key and the list index when an episode carries no numbers', () => {
const [season] = buildFullscreenEpisodePanelSeasons({
episodesBySeason: {
@@ -60,6 +60,8 @@ export interface FullscreenEpisodePanelSeason<TEpisode = unknown> {
key: string;
episodes: FullscreenEpisodePanelItem<TEpisode>[];
loadState: FullscreenPanelSeasonLoadState;
/** The season's own poster (TMDB or provider), when the host has one. */
posterUrl?: string;
}
export interface BuildFullscreenEpisodePanelSeasonsOptions<
@@ -73,6 +75,8 @@ export interface BuildFullscreenEpisodePanelSeasonsOptions<
seasonLoadStates?: Readonly<
Record<string, Exclude<FullscreenPanelSeasonLoadState, 'loaded'>>
> | null;
/** Per season key, the season's poster URL; absent means none known. */
seasonPosters?: Readonly<Record<string, string>> | null;
}
/**
@@ -87,6 +91,7 @@ export function buildFullscreenEpisodePanelSeasons<
currentEpisodeId,
playbackPositions,
seasonLoadStates,
seasonPosters,
}: BuildFullscreenEpisodePanelSeasonsOptions<TEpisode>): FullscreenEpisodePanelSeason<TEpisode>[] {
if (!episodesBySeason) {
return [];
@@ -99,9 +104,11 @@ export function buildFullscreenEpisodePanelSeasons<
return sortSeasonKeys(Object.keys(episodesBySeason)).map((seasonKey) => {
const episodes = episodesBySeason[seasonKey] ?? [];
const posterUrl = seasonPosters?.[seasonKey];
return {
key: seasonKey,
loadState: seasonLoadStates?.[seasonKey] ?? 'loaded',
...(posterUrl ? { posterUrl } : {}),
episodes: episodes.map((episode, index) =>
toPanelItem(
episode,
@@ -30,6 +30,8 @@ export interface EpisodePanelHostInputs {
> | null>;
playbackPositions: Signal<ReadonlyMap<number, PlaybackPositionData> | null>;
seasonLoadStates: Signal<SeasonLoadStates | null>;
/** Per season key, the season's poster for the panel's season strip. */
seasonPosters?: Signal<Readonly<Record<string, string>> | null>;
seriesTitle: Signal<string | null>;
/** Playback title, the header fallback when the host names no series. */
fallbackTitle: Signal<string>;
@@ -72,6 +74,7 @@ export function createEpisodePanelHost(
currentEpisodeId: playingEpisodeId(),
playbackPositions: inputs.playbackPositions(),
seasonLoadStates: inputs.seasonLoadStates(),
seasonPosters: inputs.seasonPosters?.() ?? null,
})
);
@@ -115,6 +115,13 @@ export class PortalInlinePlayerComponent {
* series), keyed by season; absent keys are loaded.
*/
readonly seasonLoadStates = input<SeasonLoadStates | null>(null);
/**
* Per season key, the season's own poster (TMDB season poster or the
* provider's season cover) for the fullscreen panel's season strip.
*/
readonly seasonPosters = input<Readonly<Record<string, string>> | null>(
null
);
/**
* Initial player volume. Only hosts that own a persisted volume pass it
* (the M3U player shares one across its channels); the portals keep the
@@ -230,6 +237,7 @@ export class PortalInlinePlayerComponent {
seriesEpisodes: this.seriesEpisodes,
playbackPositions: this.episodePlaybackPositions,
seasonLoadStates: this.seasonLoadStates,
seasonPosters: this.seasonPosters,
seriesTitle: this.seriesTitle,
fallbackTitle: this.title,
});