mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user