mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
1 parent
43c1ceac16
commit
7790e68147
36 files changed
+1119
-102
No files matched your search
+31
@@ -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"
|
||||
|
||||
+36
@@ -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;
|
||||
|
||||
+69
-1
@@ -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();
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user