diff --git a/.changes/portal-season-posters.md b/.changes/portal-season-posters.md new file mode 100644 index 000000000..a605db813 --- /dev/null +++ b/.changes/portal-season-posters.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 9a585212f..cf34e96b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:`); 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 `` 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 `` 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. diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 4a227e55f..ce7514ab7 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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--` (cover) and + // `season-big--` (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 diff --git a/apps/web/src/_cover-size.scss b/apps/web/src/_cover-size.scss index 6a45242b0..74e3de6f4 100644 --- a/apps/web/src/_cover-size.scss +++ b/apps/web/src/_cover-size.scss @@ -1,32 +1,39 @@ // Cover size tokens — consumed by all cover/poster grids and rails. // Toggled via [data-cover-size] attribute on , 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; } } diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 70b05f160..d1d94826f 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index bfb9ce3b8..45f4e052c 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 `` 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 diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 9390b9db7..6fb9a544a 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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 diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts index a6378f52c..b24438b19 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.spec.ts @@ -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), { diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts index 0d5539bf8..f24f726e3 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts @@ -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>( new Map() ); + /** mapKey → full URL of the season's own TMDB poster */ + private readonly postersByKey = signal>( + new Map() + ); /** mapKey → season number currently being fetched (in-flight dedup) */ private readonly pending = new Map(); @@ -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 { - 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 { + return this.forShow(this.postersByKey(), tmdbId); + } + + private forShow( + entries: ReadonlyMap, + tmdbId: number | null | undefined + ): Record { + if (!tmdbId || entries.size === 0) { return {}; } const prefix = `${tmdbId}|`; - const descriptions: Record = {}; - for (const [mapKey, overview] of overviews) { + const bySeason: Record = {}; + 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); } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 457052a04..4bd24bbe8 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -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]=" diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 977a1d636..1a4a7773f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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>(() => + 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 — diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts index 269d847bd..6b51e1c2b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.test-helpers.ts @@ -24,6 +24,7 @@ export class StubSeasonContainerComponent { readonly activeEpisodeId = input(null); readonly playingEpisodeId = input(null); readonly seasonDescriptions = input(null); + readonly seasonPosters = input(null); readonly isLoading = input(false); readonly downloadsEnabled = input(true); readonly downloadAdapter = input(null); @@ -49,6 +50,7 @@ export class StubPortalInlinePlayerComponent { readonly seriesNavigation = input(null); readonly upNextEpisodes = input(null); readonly seriesEpisodes = input(null); + readonly seasonPosters = input(null); readonly episodePlaybackPositions = input(null); readonly seasonLoadStates = input(null); readonly timeUpdate = output(); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts index 1fdd2e336..fb7429cd8 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts @@ -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); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + const updated = store.setSelectedItem.mock.calls[0][0] as { + tmdb_season_posters: Record; + }; + 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); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + expect(store.setSelectedItem).toHaveBeenCalledTimes(1); + const updated = store.setSelectedItem.mock.calls[0][0] as { + episodes: Record; + tmdb_season_overviews?: Record; + tmdb_season_posters: Record; + }; + 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); + + await enrichSerialSeasonWithTmdb(store, enrichment, '1'); + + const updated = store.setSelectedItem.mock.calls[0][0] as { + tmdb_season_posters?: Record; + }; + 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); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts index 3f0e1f58c..cb05021af 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts @@ -4,6 +4,7 @@ import { mergeEpisodesWithTmdb, mergeSerieInfoWithTmdb, mergeVodInfoWithTmdb, + tmdbSeasonPosterUrl, } from '@iptvnator/services'; import { XtreamSerieDetails, @@ -187,7 +188,11 @@ export async function enrichSerialSeasonWithTmdb( 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( 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( }, } : {}), + ...(posterChanged + ? { + tmdb_season_posters: { + ...current.tmdb_season_posters, + [seasonKey]: seasonPoster, + }, + } + : {}), } as unknown as TItem); } catch (error) { console.warn('[TMDB] season merge failed:', error); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/season-posters.util.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/season-posters.util.spec.ts new file mode 100644 index 000000000..f9161d279 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/season-posters.util.spec.ts @@ -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> = {} +): 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' }); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/season-posters.util.ts b/libs/portal/xtream/feature/src/lib/serial-details/season-posters.util.ts new file mode 100644 index 000000000..0b71a90bf --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/season-posters.util.ts @@ -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 { + const posters: Record = {}; + 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; +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 76f7ee58d..1d9ab441f 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -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)" diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 7cc3642b7..518bdafba 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -52,6 +52,7 @@ class StubSeasonContainerComponent { readonly activeEpisodeId = input(null); readonly playingEpisodeId = input(null); readonly seasonDescriptions = input(null); + readonly seasonPosters = input(null); readonly seasonWatchBatchRunning = input(false); readonly episodeClicked = output(); readonly playbackToggleRequested = output(); @@ -72,6 +73,7 @@ class StubPortalInlinePlayerComponent { readonly seriesNavigation = input(null); readonly upNextEpisodes = input(null); readonly seriesEpisodes = input(null); + readonly seasonPosters = input(null); readonly episodePlaybackPositions = input(null); readonly seasonLoadStates = input(null); readonly timeUpdate = output(); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index f647f3785..365f26f8c 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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>(() => + buildSeasonPosters(this.selectedItem()) + ); + /** The "Similar" rail: catalog matches plus cross-portal matches */ private readonly similar = inject(SerialDetailsSimilarService); readonly similarItems = this.similar.similarItems; diff --git a/libs/services/src/lib/tmdb/tmdb-config.ts b/libs/services/src/lib/tmdb/tmdb-config.ts index 9b208a637..c8a760452 100644 --- a/libs/services/src/lib/tmdb/tmdb-config.ts +++ b/libs/services/src/lib/tmdb/tmdb-config.ts @@ -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; +} diff --git a/libs/services/src/lib/tmdb/tmdb.types.ts b/libs/services/src/lib/tmdb/tmdb.types.ts index 7fecc3389..8cd80c05a 100644 --- a/libs/services/src/lib/tmdb/tmdb.types.ts +++ b/libs/services/src/lib/tmdb/tmdb.types.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts index 8052c481d..02636652e 100644 --- a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts @@ -17,6 +17,13 @@ export interface XtreamSerieDetails { * the provider's `seasons[].overview` is empty or URL-only junk. */ tmdb_season_overviews?: Record; + /** + * 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; } export interface XtreamSerieInfo { diff --git a/libs/ui/components/src/lib/season-container/season-auto-select.util.ts b/libs/ui/components/src/lib/season-container/season-auto-select.util.ts new file mode 100644 index 000000000..65902480c --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-auto-select.util.ts @@ -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; + positionOf: ( + episode: XtreamSerieEpisode + ) => PlaybackPositionData | undefined; + /** Stalker lazy-VOD: some seasons' episode lists are not loaded yet. */ + hasUnloadedSeasons: boolean; + episodeCounts: Record; + watchedCounts: Record; +} + +/** + * 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; +} diff --git a/libs/ui/components/src/lib/season-container/season-container.component.html b/libs/ui/components/src/lib/season-container/season-container.component.html index b75fbbb72..9bf06fa4b 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.html +++ b/libs/ui/components/src/lib/season-container/season-container.component.html @@ -22,16 +22,37 @@ /> @if (hasSeasons()) { - + +
+ @if (selectedSeasonPosterUrl(); as posterUrl) { + + } + +
}
diff --git a/libs/ui/components/src/lib/season-container/season-container.component.scss b/libs/ui/components/src/lib/season-container/season-container.component.scss index 568daffe9..ac8c8df6a 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.scss +++ b/libs/ui/components/src/lib/season-container/season-container.component.scss @@ -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 diff --git a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts index 46cf6225f..452ff24a3 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts @@ -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) diff --git a/libs/ui/components/src/lib/season-container/season-container.component.ts b/libs/ui/components/src/lib/season-container/season-container.component.ts index 7cc9d8638..fd1a0a8ed 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.ts @@ -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(null); /** Per-season descriptions (TMDB/provider), keyed by season key. */ readonly seasonDescriptions = input | 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 | 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>( + 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; } } diff --git a/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts b/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts new file mode 100644 index 000000000..76137b686 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-container.season-cover.spec.ts @@ -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 { + 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; + let component: SeasonContainerComponent; + + const setRequiredInputs = ( + seasons: Record + ) => { + 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; + 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(); + }); +}); diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html index 5f01af5ab..4fc6c2819 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.html @@ -1,5 +1,36 @@
+ @if (seasonStrip(); as strip) { +
+ + + + {{ + 'PORTALS.SEASON_TAB' + | translate: { number: strip.seasonKey } + }} + + + {{ + (strip.episodeCount === 1 + ? 'DOWNLOADS.EPISODE_COUNT_ONE' + : 'DOWNLOADS.EPISODE_COUNT_OTHER' + ) | translate: { count: strip.episodeCount } + }} + + +
+ } { 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( + '.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(); diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts index ad73320f8..25cab2ed7 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.component.ts @@ -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>(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); } diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.spec.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.spec.ts index c530e837b..0744e260e 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.spec.ts +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.spec.ts @@ -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: { diff --git a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts index 0922329b3..76e4737e3 100644 --- a/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts +++ b/libs/ui/playback/src/lib/fullscreen-episode-panel/fullscreen-episode-panel.util.ts @@ -60,6 +60,8 @@ export interface FullscreenEpisodePanelSeason { key: string; episodes: FullscreenEpisodePanelItem[]; 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> > | null; + /** Per season key, the season's poster URL; absent means none known. */ + seasonPosters?: Readonly> | null; } /** @@ -87,6 +91,7 @@ export function buildFullscreenEpisodePanelSeasons< currentEpisodeId, playbackPositions, seasonLoadStates, + seasonPosters, }: BuildFullscreenEpisodePanelSeasonsOptions): FullscreenEpisodePanelSeason[] { 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, diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.host.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.host.ts index d6bd06184..4706c7ba1 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.host.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.host.ts @@ -30,6 +30,8 @@ export interface EpisodePanelHostInputs { > | null>; playbackPositions: Signal | null>; seasonLoadStates: Signal; + /** Per season key, the season's poster for the panel's season strip. */ + seasonPosters?: Signal> | null>; seriesTitle: Signal; /** Playback title, the header fallback when the host names no series. */ fallbackTitle: Signal; @@ -72,6 +74,7 @@ export function createEpisodePanelHost( currentEpisodeId: playingEpisodeId(), playbackPositions: inputs.playbackPositions(), seasonLoadStates: inputs.seasonLoadStates(), + seasonPosters: inputs.seasonPosters?.() ?? null, }) ); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 201635a3f..3b810ba97 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -115,6 +115,13 @@ export class PortalInlinePlayerComponent { * series), keyed by season; absent keys are loaded. */ readonly seasonLoadStates = input(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> | 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, });