mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06: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
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: portal
|
||||
highlight: Season posters
|
||||
---
|
||||
|
||||
Series pages now show each season's own poster next to the season tabs, and
|
||||
the fullscreen episode list shows it above its tabs too. The picture comes
|
||||
from TMDB when metadata enrichment is on, otherwise from your provider's
|
||||
season art; one-season shows keep the page as it was.
|
||||
@@ -545,7 +545,7 @@ The live EPG panel is a horizontal **timeline ribbon** under the player (`app-ep
|
||||
|
||||
**Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:<surface>`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
|
||||
**Cover grids** (Xtream/Stalker VOD + series catalogs, favorites/recent, dashboard rails): sized by `Settings.coverSize` (small/medium/large → `--cover-grid-min-width`/`--cover-rail-width`/`--cover-gap`, written to `<html data-cover-size>` in `app.component.ts`, tokens in `apps/web/src/_cover-size.scss`). `Settings.showCoverTitles` (Settings → General, default on; only an explicit `false` opts out, coerced with the other default-on flags in `libs/services/src/lib/settings-opt-out.util.ts`) turns VOD/series grids into a posters-only wall: the title row is dropped and a `.cover-title-overlay` caption slides in on hover/`:focus-visible`, pinned open when the cover is missing or failed. `CoverTitlesService` (`libs/portal/shared/ui`) is the single resolver — opt-out AND a `(any-hover: hover)` pointer, so touch-only devices keep titles. Live channel grids, search results (search pages pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list` and `app-unified-grid-tab` keep titles while their `searchTerm` is non-blank), "recently added" rails and dashboard rails always keep their labels. Catalog and collection cards are keyboard buttons (`role="button"`, Enter/Space, focus ring). Contract: "Cover Grids" in `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
**Cover grids** (Xtream/Stalker VOD + series catalogs, favorites/recent, dashboard rails): sized by `Settings.coverSize` (small/medium/large → `--cover-grid-min-width`/`--cover-rail-width`/`--cover-gap`, plus `--season-cover-width` 96/120/144px for the season cover on series detail pages, written to `<html data-cover-size>` in `app.component.ts`, tokens in `apps/web/src/_cover-size.scss`). `Settings.showCoverTitles` (Settings → General, default on; only an explicit `false` opts out, coerced with the other default-on flags in `libs/services/src/lib/settings-opt-out.util.ts`) turns VOD/series grids into a posters-only wall: the title row is dropped and a `.cover-title-overlay` caption slides in on hover/`:focus-visible`, pinned open when the cover is missing or failed. `CoverTitlesService` (`libs/portal/shared/ui`) is the single resolver — opt-out AND a `(any-hover: hover)` pointer, so touch-only devices keep titles. Live channel grids, search results (search pages pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list` and `app-unified-grid-tab` keep titles while their `searchTerm` is non-blank), "recently added" rails and dashboard rails always keep their labels. Catalog and collection cards are keyboard buttons (`role="button"`, Enter/Space, focus ring). Contract: "Cover Grids" in `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
|
||||
**Radio Channel Layout** (when `channel.radio === 'true'`):
|
||||
|
||||
@@ -1570,7 +1570,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
|
||||
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
|
||||
- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Single-season title markers correct both displayed and playback season coordinates; lazy VOD retains the original provider season key/number for stable IDs and old progress. Lazy VOD episode tracking IDs scope the parent series, provider episode, original season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match the episode and either its resolved or retained original provider season. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. Season resources ignore metadata-only selection patches, and episode responses belong to the exact loading VM so navigation cannot mix episode lists.
|
||||
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker)
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker). The tabs sit in a season card with the selected season's own **season cover** on the left (`SeasonContainerComponent.seasonPosters`, keyed like the descriptions; TMDB-first: `tmdb_season_posters` written by the same lazy season enrichment as a `w342` `tmdbSeasonPosterUrl`, then Xtream's provider `seasons[].cover_big`/`cover` when it is a trimmed http(s) URL other than the show poster — `buildSeasonPosters` in `serial-details/season-posters.util.ts`; Stalker is TMDB-only via `StalkerSeriesTmdbSeasonsService.posters()`). Sized by `--season-cover-width`; the column is not rendered for one-season items, seasons without a poster, or a failed image, so those cases are today's markup. The hero poster never follows the season. The fullscreen episode panel shows the same poster as a season strip (poster + name + episode count) above its tabs, fed through `PortalInlinePlayerComponent.seasonPosters` and `FullscreenEpisodePanelSeason.posterUrl`, under the same gates
|
||||
- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
|
||||
- Movies get the same manual toggle in the detail action row (Xtream: icon square after Favorite, `VodDetailsWatchedService`; Stalker: labelled button in the shared `app-vod-details`, both the routed catalog detail and the collection inline detail wire it). Both portals go through one helper, `createVodWatchedToggle` in `@iptvnator/portal/shared/util`: marking writes a full-progress `vod` position row (stored duration → provider `duration_secs` → 1 s fallback, since Stalker states no runtime), unmarking deletes the row and so forgets the resume point, both through the rejecting `*OrThrow` persistence boundary so the row on screen changes only after a confirmed write. The toggle is disabled while the movie plays inline or in an external session (the ~15 s position tick would overwrite the row), acts only on the route copy's row (a pinned multi-source alternative keeps its own), and a completion landing after navigation refreshes the catalog badges but neither patches the new page nor shows its snackbar. A watched copy shows Play, never "Resume" from its final seconds. Catalog cards on both portals derive their corner badge from one shared `PortalWatchState` (`resolvePortalWatchState` / `watchStateFromProgressPercent`, 90% threshold; `resolvePortalSeriesWatchState` reports a series as at most `in-progress`, because the list payload never carries the episode total).
|
||||
- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only.
|
||||
|
||||
@@ -719,6 +719,76 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
|
||||
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Season cover beside the season tabs (provider tier)
|
||||
//
|
||||
// The default scenario's get_series_info carries a distinct seasons[].cover
|
||||
// per season, which differs from the show cover, so with TMDB enrichment off
|
||||
// the serial details page shows that cover next to the tabs and swaps it on
|
||||
// a tab click. Image requests are answered locally so a slow or blocked CDN
|
||||
// cannot fold the column (the app hides a cover whose image failed).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('@xtream season cover — shows the provider season cover and follows the selected tab', async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const TRANSPARENT_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
'base64'
|
||||
);
|
||||
await page.route('**/picsum.photos/**', (route) =>
|
||||
route.fulfill({ contentType: 'image/png', body: TRANSPARENT_PNG })
|
||||
);
|
||||
|
||||
const categories = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
|
||||
)
|
||||
).json()) as Array<{ category_id: string; category_name: string }>;
|
||||
const category = categories[0];
|
||||
const seriesItems = (await (
|
||||
await request.get(
|
||||
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
|
||||
)
|
||||
).json()) as Array<{ name: string; series_id: number }>;
|
||||
const targetSeries = seriesItems[0];
|
||||
|
||||
await addXtreamPortal(page);
|
||||
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
|
||||
|
||||
const categoryItem = page
|
||||
.locator('.context-panel .category-item')
|
||||
.filter({ hasText: category.category_name })
|
||||
.first();
|
||||
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
|
||||
await categoryItem.click();
|
||||
|
||||
const seriesCard = page
|
||||
.locator('app-grid-list mat-card')
|
||||
.filter({ hasText: targetSeries.name })
|
||||
.first();
|
||||
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
|
||||
await seriesCard.click();
|
||||
|
||||
// Season 1 is auto-selected; its provider cover sits beside the tabs.
|
||||
// The mock seeds season art as `season-<id>-<n>` (cover) and
|
||||
// `season-big-<id>-<n>` (cover_big); the app prefers cover_big.
|
||||
const cover = page.locator('[data-testid="season-cover"]');
|
||||
await expect(cover).toBeVisible({ timeout: 15_000 });
|
||||
await expect(cover).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(`season(-big)?-${targetSeries.series_id}-1/`)
|
||||
);
|
||||
|
||||
// The cover follows the selected tab.
|
||||
await page.locator('.season-tabs__pill').nth(1).click();
|
||||
await expect(cover).toHaveAttribute(
|
||||
'src',
|
||||
new RegExp(`season(-big)?-${targetSeries.series_id}-2/`)
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series-level watched toggle: the season header's ⋮ menu marks EVERY season
|
||||
// in one action (default scenario: 3 seasons × 8 episodes = 24), flips to
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
// Cover size tokens — consumed by all cover/poster grids and rails.
|
||||
// Toggled via [data-cover-size] attribute on <html>, set from SettingsStore.
|
||||
// --season-cover-width sizes the season cover beside the season tabs on the
|
||||
// series detail page; medium matches the About block's 120px poster.
|
||||
:root {
|
||||
--cover-grid-min-width: 148px;
|
||||
--cover-rail-width: 172px;
|
||||
--cover-gap: 16px;
|
||||
--season-cover-width: 120px;
|
||||
}
|
||||
|
||||
:root[data-cover-size='small'] {
|
||||
--cover-grid-min-width: 120px;
|
||||
--cover-rail-width: 140px;
|
||||
--cover-gap: 12px;
|
||||
--season-cover-width: 96px;
|
||||
}
|
||||
|
||||
:root[data-cover-size='medium'] {
|
||||
--cover-grid-min-width: 148px;
|
||||
--cover-rail-width: 172px;
|
||||
--cover-gap: 16px;
|
||||
--season-cover-width: 120px;
|
||||
}
|
||||
|
||||
:root[data-cover-size='large'] {
|
||||
--cover-grid-min-width: 200px;
|
||||
--cover-rail-width: 220px;
|
||||
--cover-gap: 20px;
|
||||
--season-cover-width: 144px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:root[data-cover-size='large'] {
|
||||
--cover-grid-min-width: 160px;
|
||||
--cover-rail-width: 170px;
|
||||
--season-cover-width: 120px;
|
||||
}
|
||||
}
|
||||
@@ -278,6 +278,23 @@ episode" chip appears when the playing episode is outside the opened
|
||||
season. Season descriptions come from `get_series_info` seasons (Xtream)
|
||||
or `TmdbEnrichmentService.getSeason` (Stalker).
|
||||
|
||||
The tabs and description sit in a **season card** with an optional cover
|
||||
column: the selected season's own poster (`seasonPosters` input, keyed like
|
||||
`seasonDescriptions`; TMDB season poster first, provider `seasons[].cover_big`
|
||||
next — see "Season/Episode Enrichment" in `tmdb-metadata-enrichment.md`).
|
||||
The column is sized by `--season-cover-width` (96 / 120 / 144px for
|
||||
`Settings.coverSize` small / medium / large, `apps/web/src/_cover-size.scss`;
|
||||
medium matches the About block's poster) and is not rendered at all — the
|
||||
card collapses to one column and the tabs render exactly as before — when
|
||||
the selected season has no poster, when the item has a single season (that
|
||||
poster is the show poster a few hundred pixels below the hero), or when the
|
||||
image request failed. The hero poster never follows the season: the show
|
||||
keeps its identity element, the season gets its own picture next to its own
|
||||
text. The fullscreen episode panel shows the same poster as a compact season
|
||||
strip (poster, season name, episode count) above its season tabs, under the
|
||||
same gates, from `PortalInlinePlayerComponent.seasonPosters` through
|
||||
`buildFullscreenEpisodePanelSeasons`.
|
||||
|
||||
### Manual watched toggle for movies
|
||||
|
||||
Movies carry the same manual "watched" affordance as episodes, in the
|
||||
|
||||
@@ -244,7 +244,10 @@ Movie and series covers render in three surfaces: the catalog grid
|
||||
recent card (`app-content-card`, same lib) and the dashboard rails. All of
|
||||
them size from the `--cover-grid-min-width` / `--cover-rail-width` /
|
||||
`--cover-gap` tokens that `Settings.coverSize` writes onto `<html>` as
|
||||
`data-cover-size` (`apps/web/src/_cover-size.scss`).
|
||||
`data-cover-size` (`apps/web/src/_cover-size.scss`). The same file carries
|
||||
`--season-cover-width` (96 / 120 / 144px) for the season cover beside the
|
||||
season tabs on series detail pages; medium equals the About block's 120px
|
||||
poster so browse and watch share one secondary-poster size.
|
||||
|
||||
### Posters-only wall
|
||||
|
||||
|
||||
@@ -248,6 +248,25 @@ routinely fill it with a bare cover-image URL —
|
||||
`sanitizeProviderOverview` (`@iptvnator/shared/interfaces`) treats a
|
||||
URL-only value as absent, and the stored TMDB overview fills the gap.
|
||||
|
||||
The same payload's `poster_path` — every TMDB season has its own poster,
|
||||
distinct from the show poster on most shows — is stored as a full `w342`
|
||||
URL (`tmdbSeasonPosterUrl`) in `tmdb_season_posters[seasonKey]` beside the
|
||||
overview, under the same write-only-if-changed convergence guard. Stalker
|
||||
keeps it in `StalkerSeriesTmdbSeasonsService.posters(tmdbId)` next to
|
||||
`descriptions(tmdbId)`, dropped together with the entry when a replacement
|
||||
fetch fails. Season posters resolve **TMDB-first**, like the show artwork
|
||||
merge (`prefer(tmdbPoster, provider)`): `buildSeasonPosters`
|
||||
(`libs/portal/xtream/feature/src/lib/serial-details/season-posters.util.ts`)
|
||||
takes the stored TMDB poster and falls back to the provider's
|
||||
`seasons[].cover_big`/`cover` from `get_series_info`, accepted only as a
|
||||
trimmed http(s) URL that differs from the show poster because panels repeat
|
||||
it on every season. Stalker has no provider season art, so it is TMDB-only.
|
||||
The detail views render the selected season's poster as the season cover
|
||||
beside the season tabs and in the fullscreen episode panel's season strip;
|
||||
both are withheld for one-season items (that poster is the show poster) and
|
||||
fold on a failed image request. Contract: "Two-State Detail Layout" in
|
||||
`embedded-inline-playback.md`.
|
||||
|
||||
The season number `{n}` is the provider's episode season number, with one
|
||||
correction (`resolveEnrichmentSeasonNumber` in
|
||||
`libs/shared/interfaces/src/lib/season-marker.util.ts`): providers often
|
||||
|
||||
+58
-8
@@ -50,15 +50,16 @@ describe('StalkerSeriesTmdbSeasonsService', () => {
|
||||
seasonCount: 1,
|
||||
});
|
||||
|
||||
expect(getSeason).toHaveBeenCalledWith(82856, 2);
|
||||
expect(getSeason).toHaveBeenCalledWith(82856, 2);
|
||||
|
||||
// Overlay still keys by the provider's season key
|
||||
const overlaid = service.overlay(
|
||||
{ '1': episodesOfSeason(1) },
|
||||
82856
|
||||
);
|
||||
expect(overlaid['1'][0].title).toBe('The Marshal');
|
||||
});
|
||||
// Overlay still keys by the provider's season key
|
||||
const overlaid = service.overlay(
|
||||
{ '1': episodesOfSeason(1) },
|
||||
82856
|
||||
);
|
||||
expect(overlaid['1'][0].title).toBe('The Marshal');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps provider numbering for multi-season items despite a marker', async () => {
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
|
||||
@@ -151,6 +152,55 @@ describe('StalkerSeriesTmdbSeasonsService', () => {
|
||||
expect(healed['1'][0].title).toBe('Season 3 Episode');
|
||||
});
|
||||
|
||||
it('exposes each fetched season poster per show and drops it with the entry', async () => {
|
||||
getSeason.mockResolvedValueOnce({
|
||||
overview: 'Season 2 overview',
|
||||
poster_path: '/season-two.jpg',
|
||||
episodes: [{ episode_number: 1, name: 'Season 2 Episode' }],
|
||||
});
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
|
||||
rawTitle: 'Мандалорец (2 сезон)',
|
||||
seasonCount: 1,
|
||||
});
|
||||
getSeason.mockResolvedValueOnce({
|
||||
overview: 'Other show',
|
||||
poster_path: '/other-show.jpg',
|
||||
episodes: [],
|
||||
});
|
||||
await service.fetchSeason(99, '3', episodesOfSeason(3));
|
||||
|
||||
expect(service.posters(82856)).toEqual({
|
||||
'1': 'https://image.tmdb.org/t/p/w342/season-two.jpg',
|
||||
});
|
||||
expect(service.posters(99)).toEqual({
|
||||
'3': 'https://image.tmdb.org/t/p/w342/other-show.jpg',
|
||||
});
|
||||
expect(service.posters(null)).toEqual({});
|
||||
|
||||
// A replacement resolution that fails drops the poster with the
|
||||
// stale entry, so another slice never wears season two's art.
|
||||
getSeason.mockResolvedValueOnce(null);
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
|
||||
rawTitle: 'Мандалорец (3 сезон)',
|
||||
seasonCount: 1,
|
||||
});
|
||||
expect(service.posters(82856)).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps no poster for a season TMDB has no art for', async () => {
|
||||
getSeason.mockResolvedValueOnce({
|
||||
overview: 'Season overview',
|
||||
poster_path: null,
|
||||
episodes: [{ episode_number: 1, name: 'The Marshal' }],
|
||||
});
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1));
|
||||
|
||||
expect(service.descriptions(82856)).toEqual({
|
||||
'1': 'Season overview',
|
||||
});
|
||||
expect(service.posters(82856)).toEqual({});
|
||||
});
|
||||
|
||||
it('does not cache a failed fetch, so a later trigger retries', async () => {
|
||||
getSeason.mockResolvedValueOnce(null);
|
||||
await service.fetchSeason(82856, '1', episodesOfSeason(1), {
|
||||
|
||||
+39
-6
@@ -2,6 +2,7 @@ import { Injectable, inject, signal } from '@angular/core';
|
||||
import {
|
||||
TmdbEnrichmentService,
|
||||
mergeEpisodesWithTmdb,
|
||||
tmdbSeasonPosterUrl,
|
||||
type TmdbEpisode,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
@@ -37,6 +38,10 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
private readonly overviewsByKey = signal<ReadonlyMap<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
/** mapKey → full URL of the season's own TMDB poster */
|
||||
private readonly postersByKey = signal<ReadonlyMap<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
/** mapKey → season number currently being fetched (in-flight dedup) */
|
||||
private readonly pending = new Map<string, number>();
|
||||
|
||||
@@ -69,19 +74,34 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
* given show. Reads a signal, so callers can use it in a `computed`.
|
||||
*/
|
||||
descriptions(tmdbId: number | null | undefined): Record<string, string> {
|
||||
const overviews = this.overviewsByKey();
|
||||
if (!tmdbId || overviews.size === 0) {
|
||||
return this.forShow(this.overviewsByKey(), tmdbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Season poster URLs for the season cover and the fullscreen episode
|
||||
* panel, keyed by season key of the given show. Stalker rows carry no
|
||||
* provider season art, so this is the only source. Reads a signal.
|
||||
*/
|
||||
posters(tmdbId: number | null | undefined): Record<string, string> {
|
||||
return this.forShow(this.postersByKey(), tmdbId);
|
||||
}
|
||||
|
||||
private forShow(
|
||||
entries: ReadonlyMap<string, string>,
|
||||
tmdbId: number | null | undefined
|
||||
): Record<string, string> {
|
||||
if (!tmdbId || entries.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const prefix = `${tmdbId}|`;
|
||||
const descriptions: Record<string, string> = {};
|
||||
for (const [mapKey, overview] of overviews) {
|
||||
const bySeason: Record<string, string> = {};
|
||||
for (const [mapKey, value] of entries) {
|
||||
if (mapKey.startsWith(prefix)) {
|
||||
descriptions[mapKey.slice(prefix.length)] = overview;
|
||||
bySeason[mapKey.slice(prefix.length)] = value;
|
||||
}
|
||||
}
|
||||
return descriptions;
|
||||
return bySeason;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,6 +179,15 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
}
|
||||
this.overviewsByKey.set(overviews);
|
||||
|
||||
const posters = new Map(this.postersByKey());
|
||||
const poster = tmdbSeasonPosterUrl(season.poster_path);
|
||||
if (poster) {
|
||||
posters.set(mapKey, poster);
|
||||
} else {
|
||||
posters.delete(mapKey);
|
||||
}
|
||||
this.postersByKey.set(posters);
|
||||
|
||||
const next = new Map(this.seasonsByKey());
|
||||
next.set(mapKey, {
|
||||
seasonNumber,
|
||||
@@ -180,5 +209,9 @@ export class StalkerSeriesTmdbSeasonsService {
|
||||
const overviews = new Map(this.overviewsByKey());
|
||||
overviews.delete(mapKey);
|
||||
this.overviewsByKey.set(overviews);
|
||||
|
||||
const posters = new Map(this.postersByKey());
|
||||
posters.delete(mapKey);
|
||||
this.postersByKey.set(posters);
|
||||
}
|
||||
}
|
||||
+2
@@ -212,6 +212,7 @@
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
[upNextEpisodes]="upNextEpisodes()"
|
||||
[seriesEpisodes]="mappedSeasons()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
[seasonLoadStates]="vodSeasonLoadStates()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
@@ -243,6 +244,7 @@
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
[seasonDescriptions]="seasonDescriptions()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[downloadsEnabled]="!providerOnly()"
|
||||
[downloadAdapter]="episodeDownloadAdapter()"
|
||||
[isLoading]="
|
||||
|
||||
+9
-5
@@ -305,6 +305,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
this.tmdbSeasons.descriptions(this.displayItem()?.info?.tmdb_id)
|
||||
);
|
||||
|
||||
/** Season posters for the season cover and the fullscreen episode panel. */
|
||||
readonly seasonPosters = computed<Record<string, string>>(() =>
|
||||
this.tmdbSeasons.posters(this.displayItem()?.info?.tmdb_id)
|
||||
);
|
||||
|
||||
/**
|
||||
* Track VOD series seasons with their loaded episodes
|
||||
*/
|
||||
@@ -1253,7 +1258,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
|
||||
/** Per-season load state for the fullscreen episode panel (lazy VOD). */
|
||||
readonly vodSeasonLoadStates = computed(() =>
|
||||
this.isVodSeries() ? getVodSeasonLoadStates(this.vodSeriesSeasons()) : {}
|
||||
this.isVodSeries()
|
||||
? getVodSeasonLoadStates(this.vodSeriesSeasons())
|
||||
: {}
|
||||
);
|
||||
|
||||
async handleSeriesPlaybackToggleRequested(
|
||||
@@ -1411,10 +1418,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
|
||||
request.requests.map((item) =>
|
||||
(item.nextPosition
|
||||
? this.persistSeriesPosition(playlistId, item.nextPosition)
|
||||
: this.clearSeriesPosition(
|
||||
playlistId,
|
||||
item.contentXtreamId
|
||||
)
|
||||
: this.clearSeriesPosition(playlistId, item.contentXtreamId)
|
||||
).then(
|
||||
() => true,
|
||||
// The scoped watched row was saved and published —
|
||||
|
||||
+2
@@ -24,6 +24,7 @@ export class StubSeasonContainerComponent {
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly isLoading = input(false);
|
||||
readonly downloadsEnabled = input(true);
|
||||
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
|
||||
@@ -49,6 +50,7 @@ export class StubPortalInlinePlayerComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly upNextEpisodes = input<unknown>(null);
|
||||
readonly seriesEpisodes = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly episodePlaybackPositions = input<unknown>(null);
|
||||
readonly seasonLoadStates = input<unknown>(null);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
|
||||
@@ -379,6 +379,84 @@ describe('enrichSerialSeasonWithTmdb', () => {
|
||||
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stores the TMDB season poster as a full w342 URL beside the overview', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
'1': [{ episode_num: 1, season: 1 }],
|
||||
})
|
||||
);
|
||||
const enrichment = createEnrichment({
|
||||
getSeason: jest.fn().mockResolvedValue({
|
||||
overview: 'Season overview.',
|
||||
poster_path: '/season-one.jpg',
|
||||
episodes: [{ episode_number: 1, name: 'The Marshal' }],
|
||||
}),
|
||||
} as Partial<TmdbEnrichmentService>);
|
||||
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
|
||||
const updated = store.setSelectedItem.mock.calls[0][0] as {
|
||||
tmdb_season_posters: Record<string, string>;
|
||||
};
|
||||
expect(updated.tmdb_season_posters).toEqual({
|
||||
'1': 'https://image.tmdb.org/t/p/w342/season-one.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('patches only the poster when TMDB returns neither episodes nor overview', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
'1': [{ episode_num: 1, season: 1 }],
|
||||
})
|
||||
);
|
||||
const enrichment = createEnrichment({
|
||||
getSeason: jest.fn().mockResolvedValue({
|
||||
poster_path: '/season-one.jpg',
|
||||
episodes: [],
|
||||
}),
|
||||
} as Partial<TmdbEnrichmentService>);
|
||||
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
|
||||
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
|
||||
const updated = store.setSelectedItem.mock.calls[0][0] as {
|
||||
episodes: Record<string, { title: string }[]>;
|
||||
tmdb_season_overviews?: Record<string, string>;
|
||||
tmdb_season_posters: Record<string, string>;
|
||||
};
|
||||
expect(updated.episodes['1'][0].title).toBe('Episode 1');
|
||||
expect(updated.tmdb_season_overviews).toBeUndefined();
|
||||
expect(updated.tmdb_season_posters).toEqual({
|
||||
'1': 'https://image.tmdb.org/t/p/w342/season-one.jpg',
|
||||
});
|
||||
|
||||
// Convergence: the same cache-served poster writes nothing again.
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
expect(store.setSelectedItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not store a season poster when TMDB has none', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
'1': [{ episode_num: 1, season: 1 }],
|
||||
})
|
||||
);
|
||||
const enrichment = createEnrichment({
|
||||
getSeason: jest.fn().mockResolvedValue({
|
||||
overview: 'Season overview.',
|
||||
poster_path: null,
|
||||
episodes: [],
|
||||
}),
|
||||
} as Partial<TmdbEnrichmentService>);
|
||||
|
||||
await enrichSerialSeasonWithTmdb(store, enrichment, '1');
|
||||
|
||||
const updated = store.setSelectedItem.mock.calls[0][0] as {
|
||||
tmdb_season_posters?: Record<string, string>;
|
||||
};
|
||||
expect(updated.tmdb_season_posters).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not store a blank TMDB season overview', async () => {
|
||||
const store = createStore(
|
||||
seasonSliceItem('The Mandalorian', {
|
||||
@@ -416,7 +494,9 @@ describe('enrichSerialSeasonWithTmdb', () => {
|
||||
})
|
||||
);
|
||||
return {
|
||||
episodes: [{ episode_number: 1, name: 'Playlist A episode' }],
|
||||
episodes: [
|
||||
{ episode_number: 1, name: 'Playlist A episode' },
|
||||
],
|
||||
};
|
||||
}),
|
||||
} as Partial<TmdbEnrichmentService>);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
mergeEpisodesWithTmdb,
|
||||
mergeSerieInfoWithTmdb,
|
||||
mergeVodInfoWithTmdb,
|
||||
tmdbSeasonPosterUrl,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
XtreamSerieDetails,
|
||||
@@ -187,7 +188,11 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
|
||||
const season = await enrichment.getSeason(info.tmdb_id, seasonNumber);
|
||||
const tmdbEpisodes = season?.episodes ?? [];
|
||||
const seasonOverview = season?.overview?.trim() || null;
|
||||
if ((!tmdbEpisodes.length && !seasonOverview) || !isCurrentSelection()) {
|
||||
const seasonPoster = tmdbSeasonPosterUrl(season?.poster_path);
|
||||
if (
|
||||
(!tmdbEpisodes.length && !seasonOverview && !seasonPoster) ||
|
||||
!isCurrentSelection()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +220,10 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
|
||||
const overviewChanged =
|
||||
seasonOverview !== null &&
|
||||
current.tmdb_season_overviews?.[seasonKey] !== seasonOverview;
|
||||
if (!episodesChanged && !overviewChanged) {
|
||||
const posterChanged =
|
||||
seasonPoster !== null &&
|
||||
current.tmdb_season_posters?.[seasonKey] !== seasonPoster;
|
||||
if (!episodesChanged && !overviewChanged && !posterChanged) {
|
||||
return;
|
||||
}
|
||||
store.setSelectedItem({
|
||||
@@ -236,6 +244,14 @@ export async function enrichSerialSeasonWithTmdb<TItem extends SelectionRecord>(
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(posterChanged
|
||||
? {
|
||||
tmdb_season_posters: {
|
||||
...current.tmdb_season_posters,
|
||||
[seasonKey]: seasonPoster,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
} as unknown as TItem);
|
||||
} catch (error) {
|
||||
console.warn('[TMDB] season merge failed:', error);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { XtreamSerieSeason } from '@iptvnator/shared/interfaces';
|
||||
import { buildSeasonPosters } from './season-posters.util';
|
||||
|
||||
const SHOW_POSTER = 'https://cdn.example.com/show.jpg';
|
||||
|
||||
function season(
|
||||
seasonNumber: number,
|
||||
cover: Partial<Pick<XtreamSerieSeason, 'cover' | 'cover_big'>> = {}
|
||||
): XtreamSerieSeason {
|
||||
return {
|
||||
season_number: seasonNumber,
|
||||
cover: '',
|
||||
cover_big: '',
|
||||
...cover,
|
||||
} as XtreamSerieSeason;
|
||||
}
|
||||
|
||||
describe('buildSeasonPosters', () => {
|
||||
it('returns an empty map for a missing item', () => {
|
||||
expect(buildSeasonPosters(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('prefers the TMDB season poster over the provider cover', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, {
|
||||
cover_big: 'https://cdn.example.com/s1-big.jpg',
|
||||
}),
|
||||
],
|
||||
tmdb_season_posters: {
|
||||
'1': 'https://image.tmdb.org/t/p/w342/s1.jpg',
|
||||
},
|
||||
})
|
||||
).toEqual({ '1': 'https://image.tmdb.org/t/p/w342/s1.jpg' });
|
||||
});
|
||||
|
||||
it('falls back to cover_big, then cover, where TMDB has no season poster', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, {
|
||||
cover: 'https://cdn.example.com/s1.jpg',
|
||||
cover_big: 'https://cdn.example.com/s1-big.jpg',
|
||||
}),
|
||||
season(2, { cover: 'https://cdn.example.com/s2.jpg' }),
|
||||
],
|
||||
tmdb_season_posters: {
|
||||
'3': 'https://image.tmdb.org/t/p/w342/s3.jpg',
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
'1': 'https://cdn.example.com/s1-big.jpg',
|
||||
'2': 'https://cdn.example.com/s2.jpg',
|
||||
'3': 'https://image.tmdb.org/t/p/w342/s3.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops provider covers that repeat the show poster', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, { cover_big: SHOW_POSTER, cover: SHOW_POSTER }),
|
||||
season(2, {
|
||||
cover_big: SHOW_POSTER,
|
||||
cover: 'https://cdn.example.com/s2.jpg',
|
||||
}),
|
||||
],
|
||||
})
|
||||
).toEqual({ '2': 'https://cdn.example.com/s2.jpg' });
|
||||
});
|
||||
|
||||
it('trims padded URLs and still recognizes a padded show-poster duplicate', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: ` ${SHOW_POSTER}\n` },
|
||||
seasons: [
|
||||
season(1, { cover_big: ` ${SHOW_POSTER} ` }),
|
||||
season(2, {
|
||||
cover_big: ' https://cdn.example.com/s2.jpg ',
|
||||
}),
|
||||
],
|
||||
tmdb_season_posters: {
|
||||
'3': '\thttps://image.tmdb.org/t/p/w342/s3.jpg ',
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
'2': 'https://cdn.example.com/s2.jpg',
|
||||
'3': 'https://image.tmdb.org/t/p/w342/s3.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts only http(s) URLs from either source', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: { cover: SHOW_POSTER },
|
||||
seasons: [
|
||||
season(1, { cover_big: 'season one', cover: '/s1.jpg' }),
|
||||
season(2, { cover_big: 'javascript:alert(1)' }),
|
||||
],
|
||||
tmdb_season_posters: { '3': 'data:image/png;base64,AAAA' },
|
||||
})
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores seasons without a number and an empty-array provider info', () => {
|
||||
expect(
|
||||
buildSeasonPosters({
|
||||
info: [],
|
||||
seasons: [
|
||||
{
|
||||
cover_big: 'https://cdn.example.com/x.jpg',
|
||||
} as XtreamSerieSeason,
|
||||
season(2, { cover_big: 'https://cdn.example.com/s2.jpg' }),
|
||||
],
|
||||
})
|
||||
).toEqual({ '2': 'https://cdn.example.com/s2.jpg' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { XtreamSerieDetails } from '@iptvnator/shared/interfaces';
|
||||
|
||||
const HTTP_URL_PATTERN = /^https?:\/\//i;
|
||||
|
||||
type SeasonPosterSource = Pick<
|
||||
XtreamSerieDetails,
|
||||
'seasons' | 'tmdb_season_posters'
|
||||
> & {
|
||||
/** Provider `info` is `[]` on some panels when no metadata exists */
|
||||
info?: { cover?: string } | [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Season posters keyed by season key, TMDB-first like the show artwork
|
||||
* merge (`prefer(tmdbPoster, provider)` in `tmdb-merge.ts`): the season's
|
||||
* own TMDB poster stored by the lazy season enrichment wins, and the
|
||||
* provider's `seasons[].cover_big`/`cover` from `get_series_info` fills
|
||||
* the gap. A provider season cover is accepted only as an http(s) URL
|
||||
* that differs from the show poster — panels routinely repeat the show
|
||||
* poster on every season, and a duplicate would render the hero poster a
|
||||
* second time under the season tabs.
|
||||
*/
|
||||
export function buildSeasonPosters(
|
||||
item: SeasonPosterSource | null
|
||||
): Record<string, string> {
|
||||
const posters: Record<string, string> = {};
|
||||
for (const [seasonKey, url] of Object.entries(
|
||||
item?.tmdb_season_posters ?? {}
|
||||
)) {
|
||||
const usable = usableUrl(url);
|
||||
if (usable) {
|
||||
posters[seasonKey] = usable;
|
||||
}
|
||||
}
|
||||
const showPoster =
|
||||
item?.info && !Array.isArray(item.info)
|
||||
? usableUrl(item.info.cover)
|
||||
: null;
|
||||
for (const season of item?.seasons ?? []) {
|
||||
if (season?.season_number === undefined) {
|
||||
continue;
|
||||
}
|
||||
const seasonKey = String(season.season_number);
|
||||
if (posters[seasonKey]) {
|
||||
continue;
|
||||
}
|
||||
// Panels pad URLs with whitespace now and then; compare and store
|
||||
// the trimmed form so a padded copy of the show poster is still
|
||||
// recognized as the duplicate it is.
|
||||
const cover = [season.cover_big, season.cover]
|
||||
.map(usableUrl)
|
||||
.find(
|
||||
(candidate) => candidate !== null && candidate !== showPoster
|
||||
);
|
||||
if (cover) {
|
||||
posters[seasonKey] = cover;
|
||||
}
|
||||
}
|
||||
return posters;
|
||||
}
|
||||
|
||||
/** The trimmed http(s) URL, or null for anything else. */
|
||||
function usableUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return HTTP_URL_PATTERN.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
@@ -219,6 +219,7 @@
|
||||
[seriesNavigation]="inlineSeriesNavigation()"
|
||||
[upNextEpisodes]="upNextEpisodes()"
|
||||
[seriesEpisodes]="item.episodes"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[episodePlaybackPositions]="episodePlaybackPositions()"
|
||||
(timeUpdate)="handleInlineTimeUpdate($event)"
|
||||
(closed)="closeInlinePlayer()"
|
||||
@@ -249,6 +250,7 @@
|
||||
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
|
||||
"
|
||||
[seasonDescriptions]="seasonDescriptions()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[seasonWatchBatchRunning]="seasonWatchBatchRunning()"
|
||||
(episodeClicked)="playEpisode($event)"
|
||||
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
|
||||
|
||||
@@ -52,6 +52,7 @@ class StubSeasonContainerComponent {
|
||||
readonly activeEpisodeId = input<number | null>(null);
|
||||
readonly playingEpisodeId = input<number | null>(null);
|
||||
readonly seasonDescriptions = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly seasonWatchBatchRunning = input(false);
|
||||
readonly episodeClicked = output<unknown>();
|
||||
readonly playbackToggleRequested = output<unknown>();
|
||||
@@ -72,6 +73,7 @@ class StubPortalInlinePlayerComponent {
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly upNextEpisodes = input<unknown>(null);
|
||||
readonly seriesEpisodes = input<unknown>(null);
|
||||
readonly seasonPosters = input<unknown>(null);
|
||||
readonly episodePlaybackPositions = input<unknown>(null);
|
||||
readonly seasonLoadStates = input<unknown>(null);
|
||||
readonly timeUpdate = output<unknown>();
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
XtreamSerieInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { buildSeasonDescriptions } from './season-descriptions.util';
|
||||
import { buildSeasonPosters } from './season-posters.util';
|
||||
import {
|
||||
createDiscoverFacetNavigation,
|
||||
isProviderOnlyDetailState,
|
||||
@@ -193,6 +194,11 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
|
||||
buildSeasonDescriptions(this.selectedItem())
|
||||
);
|
||||
|
||||
/** Season posters (TMDB season poster first, provider season cover next). */
|
||||
readonly seasonPosters = computed<Record<string, string>>(() =>
|
||||
buildSeasonPosters(this.selectedItem())
|
||||
);
|
||||
|
||||
/** The "Similar" rail: catalog matches plus cross-portal matches */
|
||||
private readonly similar = inject(SerialDetailsSimilarService);
|
||||
readonly similarItems = this.similar.similarItems;
|
||||
|
||||
@@ -6,6 +6,8 @@ export const TMDB_POSTER_SIZE = 'w500';
|
||||
export const TMDB_BACKDROP_SIZE = 'w1280';
|
||||
export const TMDB_PROFILE_SIZE = 'w185';
|
||||
export const TMDB_STILL_SIZE = 'w300';
|
||||
/** Season covers render at most 144 CSS px wide, so w342 covers 2× DPR */
|
||||
export const TMDB_SEASON_POSTER_SIZE = 'w342';
|
||||
|
||||
/**
|
||||
* Embedded application API key used when the user has not configured their
|
||||
@@ -107,3 +109,11 @@ export function tmdbProfileUrl(
|
||||
export function tmdbStillUrl(path: string | null | undefined): string | null {
|
||||
return path ? `${TMDB_IMAGE_BASE_URL}/${TMDB_STILL_SIZE}${path}` : null;
|
||||
}
|
||||
|
||||
export function tmdbSeasonPosterUrl(
|
||||
path: string | null | undefined
|
||||
): string | null {
|
||||
return path
|
||||
? `${TMDB_IMAGE_BASE_URL}/${TMDB_SEASON_POSTER_SIZE}${path}`
|
||||
: null;
|
||||
}
|
||||
@@ -133,6 +133,8 @@ export interface TmdbTvDetails extends TmdbDetailsBase {
|
||||
name: string;
|
||||
profile_path?: string | null;
|
||||
}[];
|
||||
/** Per-season summaries, including each season's own poster */
|
||||
seasons?: TmdbSeasonSummary[];
|
||||
}
|
||||
|
||||
export interface TmdbEpisode {
|
||||
@@ -150,9 +152,19 @@ export interface TmdbEpisode {
|
||||
export interface TmdbSeasonDetails {
|
||||
season_number?: number;
|
||||
overview?: string;
|
||||
/** The season's own poster; distinct from the show poster on most shows */
|
||||
poster_path?: string | null;
|
||||
episodes?: TmdbEpisode[];
|
||||
}
|
||||
|
||||
/** One entry of a TV details payload's `seasons[]` summary list */
|
||||
export interface TmdbSeasonSummary {
|
||||
season_number?: number;
|
||||
episode_count?: number;
|
||||
air_date?: string | null;
|
||||
poster_path?: string | null;
|
||||
}
|
||||
|
||||
/** One credit from /person/{id} combined_credits (cast or crew) */
|
||||
export interface TmdbPersonCredit {
|
||||
id: number;
|
||||
|
||||
@@ -17,6 +17,13 @@ export interface XtreamSerieDetails {
|
||||
* the provider's `seasons[].overview` is empty or URL-only junk.
|
||||
*/
|
||||
tmdb_season_overviews?: Record<string, string>;
|
||||
/**
|
||||
* Populated by lazy TMDB season enrichment; absent in raw provider
|
||||
* responses. Keyed like `tmdb_season_overviews`. Full image URLs of
|
||||
* each season's own TMDB poster, the first choice for the season
|
||||
* cover in the detail view and the fullscreen episode panel.
|
||||
*/
|
||||
tmdb_season_posters?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface XtreamSerieInfo {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+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