mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups Follow-ups to the season posters shipped in #1628: - The >6-seasons dropdown (`SeasonTabsComponent`) now carries a 28×42 season thumbnail at the start of each menu row that has a poster and in the closed trigger for the selected season, fed by a new `seasonPosters` input from the season container and the fullscreen episode panel. Rows without a poster get no placeholder, a failed image is dropped, and the pill row stays text-only as the design review decided. - The fullscreen season strip's episode count uses its own `PORTALS.EPISODE_COUNT_ONE/OTHER` keys instead of borrowing the download manager's; all 18 locales filled through the i18n merger from their existing `DOWNLOADS.EPISODE_COUNT_*` translations. - The Stalker mock's serve targets no longer pin `PORT` (an nx:run-commands `env` entry overrides the shell), and `main.ts` resolves `PORT`, then the Playwright-side `MOCK_PORT` alias, then 3210 — so `MOCK_PORT=3310` now relocates the whole E2E run. The Xtream mock honours `XTREAM_MOCK_PORT` the same way. - `resolveAutoSelectedSeason` gets a direct spec covering every branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(xtream-mock): mint marketing asset URLs on the port the server bound Greptile P1 on #1633: the listener honoured `XTREAM_MOCK_PORT`, but `marketingAssetOrigin()` still read `PORT` alone, so a run relocated only through the alias sent every poster/backdrop/logo/episode URL to 3211. One resolver (`resolveXtreamMockPortString` in `mock-port.ts`: `PORT`, then `XTREAM_MOCK_PORT`, then 3211) now feeds the environment parser, the marketing asset origin and the demo-guide origin fallback. A spec pins the precedence and that `marketingAssetUrl` follows the bound port. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
01c423ac43
commit
4cce4acaad
43 files changed
+699
-32
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: portal
|
||||
---
|
||||
|
||||
Series with more than six seasons now show a small season poster in the season
|
||||
dropdown — on each row that has one and on the closed selector — so a long
|
||||
list of "Season N" entries is easier to scan. The fullscreen episode panel's
|
||||
season strip also gets its own "N episodes" translation instead of borrowing
|
||||
the download manager's.
|
||||
@@ -1571,7 +1571,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). 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
|
||||
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons; the dropdown's menu rows and closed trigger carry a 28×42 season thumbnail from the same `seasonPosters` map when that season has one — no placeholder when it does not, a failed image is dropped — while the pill row deliberately stays text-only) 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, `PORTALS.EPISODE_COUNT_ONE/OTHER`) above its tabs, fed through `PortalInlinePlayerComponent.seasonPosters` and `FullscreenEpisodePanelSeason.posterUrl`, under the same gates, and hands the same map to its tabs' dropdown
|
||||
- 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.
|
||||
|
||||
@@ -101,7 +101,8 @@ flags a real portal sends. Outside the `static-channel-cmd` scenario they are
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `PORT` | `3210` | HTTP port the server listens on |
|
||||
| `PORT` | `3210` | HTTP port the server listens on. The Nx serve targets do not pin it, so `PORT=3310 pnpm nx run stalker-mock-server:serve` relocates the server |
|
||||
| `MOCK_PORT` | — | Fallback for `PORT`. It is the client-side knob `apps/web-e2e/playwright.config.ts` and the specs already read, so `MOCK_PORT=3310 pnpm exec playwright test …` moves the mock AND the tests together when another worktree holds 3210 |
|
||||
| `HOST` | `127.0.0.1` | Bind address. Loopback by default — the fixture serves fabricated, unauthenticated content, so set `HOST=0.0.0.0` only to deliberately point a phone or STB at it |
|
||||
| `NODE_ENV` | `development` | Node environment |
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"cwd": "{workspaceRoot}",
|
||||
"color": true,
|
||||
"env": {
|
||||
"PORT": "3210",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +25,6 @@
|
||||
"cwd": "{workspaceRoot}",
|
||||
"color": true,
|
||||
"env": {
|
||||
"PORT": "3210",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,27 @@ import {
|
||||
resolveMarketingPosterUrls,
|
||||
} from './app/marketing-poster-url.js';
|
||||
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
|
||||
function resolvePort(raw: string | undefined): number {
|
||||
if (raw === undefined) {
|
||||
return 3210;
|
||||
}
|
||||
const port = /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
|
||||
if (!Number.isInteger(port) || port > 65_535) {
|
||||
console.error(
|
||||
`[stalker-mock] Invalid port "${raw}": expected an integer in 0..65535`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
// `PORT` is the server's own knob; `MOCK_PORT` is the client-side alias
|
||||
// Playwright and the specs read (`apps/web-e2e/playwright.config.ts`), honoured
|
||||
// here so one variable relocates the whole E2E run when another worktree
|
||||
// holds 3210. The Nx serve targets deliberately do not pin `PORT`: an `env`
|
||||
// entry in `nx:run-commands` overrides the shell, which is what made the
|
||||
// override impossible before.
|
||||
const PORT = resolvePort(process.env['PORT'] ?? process.env['MOCK_PORT']);
|
||||
// Loopback by default: the fixture serves fabricated but unauthenticated
|
||||
// content, so it should not be reachable from other hosts unless a dev
|
||||
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it).
|
||||
|
||||
@@ -52,11 +52,12 @@ export default defineConfig({
|
||||
/* Run local dev servers before starting the tests.
|
||||
* Both the Angular app and the Stalker mock server start in parallel.
|
||||
*
|
||||
* MOCK_PORT only moves where the CLIENT looks — this health check and the
|
||||
* specs' MOCK_SERVER constants. The mock reads PORT, which
|
||||
* stalker-mock-server's serve target pins to 3210, and nothing maps one to
|
||||
* the other, so MOCK_PORT alone makes the wait below time out. Use it to
|
||||
* point at a mock you started yourself on that port.
|
||||
* MOCK_PORT / XTREAM_MOCK_PORT relocate a mock for the whole run: the
|
||||
* health checks below and the specs' MOCK_SERVER constants read them, and
|
||||
* both mock servers honour them as a fallback for PORT (their serve
|
||||
* targets no longer pin PORT, so an explicit shell value reaches the
|
||||
* process). That is what lets two worktrees run E2E side by side when one
|
||||
* already holds 3210/3211.
|
||||
*/
|
||||
webServer: [
|
||||
{
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "نبذة",
|
||||
"SEASONS_AND_EPISODES": "المواسم والحلقات",
|
||||
"SEASON_TAB": "الموسم {{number}}",
|
||||
"EPISODE_COUNT_ONE": "حلقة واحدة",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} حلقات",
|
||||
"BACK_TO_PLAYING_EPISODE": "العودة إلى الحلقة قيد التشغيل",
|
||||
"GRID_VIEW": "عرض شبكي",
|
||||
"LIST_VIEW": "عرض قائمة",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "حول",
|
||||
"SEASONS_AND_EPISODES": "المواسم والحلقات",
|
||||
"SEASON_TAB": "الموسم {{number}}",
|
||||
"EPISODE_COUNT_ONE": "حلقة وحدة",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} حلقات",
|
||||
"BACK_TO_PLAYING_EPISODE": "رجوع للحلقة اللي كتشغل",
|
||||
"GRID_VIEW": "عرض الشبكة",
|
||||
"LIST_VIEW": "عرض اللائحة",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Пра фільм",
|
||||
"SEASONS_AND_EPISODES": "Сезоны і эпізоды",
|
||||
"SEASON_TAB": "Сезон {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 эпізод",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} эпізодаў",
|
||||
"BACK_TO_PLAYING_EPISODE": "Назад да бягучага эпізоду",
|
||||
"GRID_VIEW": "Выгляд сеткай",
|
||||
"LIST_VIEW": "Выгляд спісам",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Info",
|
||||
"SEASONS_AND_EPISODES": "Staffeln und Folgen",
|
||||
"SEASON_TAB": "Staffel {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 Episode",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} Episoden",
|
||||
"BACK_TO_PLAYING_EPISODE": "Zurück zur laufenden Folge",
|
||||
"GRID_VIEW": "Rasteransicht",
|
||||
"LIST_VIEW": "Listenansicht",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Σχετικά",
|
||||
"SEASONS_AND_EPISODES": "Σεζόν και επεισόδια",
|
||||
"SEASON_TAB": "Σεζόν {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 επεισόδιο",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} επεισόδια",
|
||||
"BACK_TO_PLAYING_EPISODE": "Πίσω στο επεισόδιο που παίζει",
|
||||
"GRID_VIEW": "Προβολή πλέγματος",
|
||||
"LIST_VIEW": "Προβολή λίστας",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "About",
|
||||
"SEASONS_AND_EPISODES": "Seasons and Episodes",
|
||||
"SEASON_TAB": "Season {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 episode",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} episodes",
|
||||
"BACK_TO_PLAYING_EPISODE": "Back to playing episode",
|
||||
"GRID_VIEW": "Grid view",
|
||||
"LIST_VIEW": "List view",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Acerca de",
|
||||
"SEASONS_AND_EPISODES": "Temporadas y episodios",
|
||||
"SEASON_TAB": "Temporada {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 episodio",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} episodios",
|
||||
"BACK_TO_PLAYING_EPISODE": "Volver al episodio en reproducción",
|
||||
"GRID_VIEW": "Vista de cuadrícula",
|
||||
"LIST_VIEW": "Vista de lista",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "À propos",
|
||||
"SEASONS_AND_EPISODES": "Saisons et épisodes",
|
||||
"SEASON_TAB": "Saison {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 épisode",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} épisodes",
|
||||
"BACK_TO_PLAYING_EPISODE": "Retour à l'épisode en cours",
|
||||
"GRID_VIEW": "Vue en grille",
|
||||
"LIST_VIEW": "Vue en liste",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Ismertető",
|
||||
"SEASONS_AND_EPISODES": "Évadok és epizódok",
|
||||
"SEASON_TAB": "{{number}}. évad",
|
||||
"EPISODE_COUNT_ONE": "1 epizód",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} epizód",
|
||||
"BACK_TO_PLAYING_EPISODE": "Vissza a lejátszás alatt álló epizódhoz",
|
||||
"GRID_VIEW": "Rácsnézet",
|
||||
"LIST_VIEW": "Listanézet",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Info",
|
||||
"SEASONS_AND_EPISODES": "Stagioni ed episodi",
|
||||
"SEASON_TAB": "Stagione {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 episodio",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} episodi",
|
||||
"BACK_TO_PLAYING_EPISODE": "Torna all'episodio in riproduzione",
|
||||
"GRID_VIEW": "Vista griglia",
|
||||
"LIST_VIEW": "Vista elenco",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "概要",
|
||||
"SEASONS_AND_EPISODES": "シーズンとエピソード",
|
||||
"SEASON_TAB": "シーズン{{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 エピソード",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} エピソード",
|
||||
"BACK_TO_PLAYING_EPISODE": "再生中のエピソードに戻る",
|
||||
"GRID_VIEW": "グリッド表示",
|
||||
"LIST_VIEW": "リスト表示",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "정보",
|
||||
"SEASONS_AND_EPISODES": "시즌 및 에피소드",
|
||||
"SEASON_TAB": "시즌 {{number}}",
|
||||
"EPISODE_COUNT_ONE": "에피소드 1개",
|
||||
"EPISODE_COUNT_OTHER": "에피소드 {{count}}개",
|
||||
"BACK_TO_PLAYING_EPISODE": "재생 중인 에피소드로 돌아가기",
|
||||
"GRID_VIEW": "그리드 보기",
|
||||
"LIST_VIEW": "목록 보기",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Over",
|
||||
"SEASONS_AND_EPISODES": "Seizoenen en afleveringen",
|
||||
"SEASON_TAB": "Seizoen {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 aflevering",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} afleveringen",
|
||||
"BACK_TO_PLAYING_EPISODE": "Terug naar lopende aflevering",
|
||||
"GRID_VIEW": "Rasterweergave",
|
||||
"LIST_VIEW": "Lijstweergave",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Informacje",
|
||||
"SEASONS_AND_EPISODES": "Sezony i odcinki",
|
||||
"SEASON_TAB": "Sezon {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 odcinek",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} odcinków",
|
||||
"BACK_TO_PLAYING_EPISODE": "Wróć do odtwarzanego odcinka",
|
||||
"GRID_VIEW": "Widok siatki",
|
||||
"LIST_VIEW": "Widok listy",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Sobre",
|
||||
"SEASONS_AND_EPISODES": "Temporadas e episódios",
|
||||
"SEASON_TAB": "Temporada {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 episódio",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} episódios",
|
||||
"BACK_TO_PLAYING_EPISODE": "Voltar ao episódio em reprodução",
|
||||
"GRID_VIEW": "Visualização em grade",
|
||||
"LIST_VIEW": "Visualização em lista",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Об этом",
|
||||
"SEASONS_AND_EPISODES": "Сезоны и эпизоды",
|
||||
"SEASON_TAB": "Сезон {{number}}",
|
||||
"EPISODE_COUNT_ONE": "1 эпизод",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} эпизодов",
|
||||
"BACK_TO_PLAYING_EPISODE": "Вернуться к воспроизводимому эпизоду",
|
||||
"GRID_VIEW": "Вид сеткой",
|
||||
"LIST_VIEW": "Вид списком",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "Hakkında",
|
||||
"SEASONS_AND_EPISODES": "Sezonlar ve Bölümler",
|
||||
"SEASON_TAB": "{{number}}. Sezon",
|
||||
"EPISODE_COUNT_ONE": "1 bölüm",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} bölüm",
|
||||
"BACK_TO_PLAYING_EPISODE": "Oynatılan bölüme dön",
|
||||
"GRID_VIEW": "Izgara görünümü",
|
||||
"LIST_VIEW": "Liste görünümü",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "简介",
|
||||
"SEASONS_AND_EPISODES": "季与集",
|
||||
"SEASON_TAB": "第 {{number}} 季",
|
||||
"EPISODE_COUNT_ONE": "1 集",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} 集",
|
||||
"BACK_TO_PLAYING_EPISODE": "返回正在播放的剧集",
|
||||
"GRID_VIEW": "网格视图",
|
||||
"LIST_VIEW": "列表视图",
|
||||
|
||||
@@ -1249,6 +1249,8 @@
|
||||
"ABOUT": "簡介",
|
||||
"SEASONS_AND_EPISODES": "季數與集數",
|
||||
"SEASON_TAB": "第 {{number}} 季",
|
||||
"EPISODE_COUNT_ONE": "1 集",
|
||||
"EPISODE_COUNT_OTHER": "{{count}} 集",
|
||||
"BACK_TO_PLAYING_EPISODE": "返回正在播放的集數",
|
||||
"GRID_VIEW": "格狀檢視",
|
||||
"LIST_VIEW": "清單檢視",
|
||||
|
||||
@@ -42,7 +42,9 @@ When enabled, every `/__control/*` request requires the exact
|
||||
requests. The server refuses non-loopback binds and an empty token. The control
|
||||
routes do not exist when the flag is absent or is any value other than `1`.
|
||||
The Nx serve targets preserve an explicit shell `PORT`; when it is omitted, the
|
||||
server parser still defaults to `3211`.
|
||||
server parser falls back to `XTREAM_MOCK_PORT` — the client-side knob
|
||||
`apps/web-e2e/playwright.config.ts` and the specs read, so one variable moves
|
||||
the mock and the tests together — and then to `3211`.
|
||||
|
||||
Prepare the fixed synthetic fixture before starting a capture:
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
marketingTitleFromSlug,
|
||||
renderMarketingLogoSvg,
|
||||
} from '@iptvnator/shared/marketing-fixtures';
|
||||
import { resolveXtreamMockPortString } from '../mock-port.js';
|
||||
import { RawCategory } from './categories.generator.js';
|
||||
import {
|
||||
buildEpgListing,
|
||||
@@ -931,8 +932,9 @@ function buildMarketingEpgListings(
|
||||
}
|
||||
|
||||
function marketingAssetOrigin(): string {
|
||||
const port = process.env['PORT'] ?? '3211';
|
||||
return `http://localhost:${port}`;
|
||||
// Same resolution as the listener, so a run relocated through
|
||||
// `XTREAM_MOCK_PORT` alone still mints asset URLs on the bound port.
|
||||
return `http://localhost:${resolveXtreamMockPortString(process.env)}`;
|
||||
}
|
||||
|
||||
function parseSize(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { marketingAssetUrl } from './generators/marketing.generator.js';
|
||||
|
||||
// The generator module pulls in faker (ESM-only under this CJS Jest setup)
|
||||
// through its sibling generators; `marketingAssetUrl` never calls it, so an
|
||||
// empty stub keeps the import resolvable — same pattern as server.spec.ts.
|
||||
jest.mock('@faker-js/faker', () => ({ faker: {} }));
|
||||
import {
|
||||
DEFAULT_XTREAM_MOCK_PORT,
|
||||
resolveXtreamMockPortString,
|
||||
} from './mock-port.js';
|
||||
|
||||
describe('resolveXtreamMockPortString', () => {
|
||||
it('prefers PORT, then the Playwright-side XTREAM_MOCK_PORT alias, then the default', () => {
|
||||
expect(resolveXtreamMockPortString({})).toBe(
|
||||
String(DEFAULT_XTREAM_MOCK_PORT)
|
||||
);
|
||||
expect(resolveXtreamMockPortString({ XTREAM_MOCK_PORT: '3311' })).toBe(
|
||||
'3311'
|
||||
);
|
||||
expect(
|
||||
resolveXtreamMockPortString({
|
||||
PORT: '3221',
|
||||
XTREAM_MOCK_PORT: '3311',
|
||||
})
|
||||
).toBe('3221');
|
||||
});
|
||||
});
|
||||
|
||||
describe('marketing asset origin', () => {
|
||||
const saved = {
|
||||
PORT: process.env['PORT'],
|
||||
XTREAM_MOCK_PORT: process.env['XTREAM_MOCK_PORT'],
|
||||
};
|
||||
|
||||
const setEnvironment = (values: Record<string, string | undefined>) => {
|
||||
for (const key of ['PORT', 'XTREAM_MOCK_PORT'] as const) {
|
||||
if (values[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = values[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
afterEach(() => setEnvironment(saved));
|
||||
|
||||
it('mints asset URLs on the same port the listener resolves', () => {
|
||||
// A run relocated through the Playwright alias alone: the fixture's
|
||||
// poster/backdrop/logo/episode URLs must follow the bound port, or
|
||||
// the images load from another worktree's server (or nothing).
|
||||
setEnvironment({ PORT: undefined, XTREAM_MOCK_PORT: '3311' });
|
||||
expect(marketingAssetUrl('poster', 'Aurora News', '300x450')).toMatch(
|
||||
/^http:\/\/localhost:3311\/assets\/marketing\/poster\//
|
||||
);
|
||||
|
||||
setEnvironment({ PORT: '3221', XTREAM_MOCK_PORT: '3311' });
|
||||
expect(
|
||||
marketingAssetUrl('backdrop', 'Aurora News', '1280x720')
|
||||
).toMatch(/^http:\/\/localhost:3221\/assets\/marketing\/backdrop\//);
|
||||
|
||||
setEnvironment({ PORT: undefined, XTREAM_MOCK_PORT: undefined });
|
||||
expect(marketingAssetUrl('logo', 'Aurora News', '256x256')).toMatch(
|
||||
new RegExp(
|
||||
`^http://localhost:${DEFAULT_XTREAM_MOCK_PORT}/assets/marketing/logo/`
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Port the Xtream mock listens on when nothing in the environment says otherwise. */
|
||||
export const DEFAULT_XTREAM_MOCK_PORT = 3211;
|
||||
|
||||
/**
|
||||
* The one place the mock's port is read from the environment: `PORT` (the
|
||||
* server's own knob) first, then `XTREAM_MOCK_PORT` — the client-side alias
|
||||
* Playwright and the specs read, honoured so one variable relocates the whole
|
||||
* E2E run — then the default. Returned as the raw string so the caller
|
||||
* decides how strictly to validate it.
|
||||
*
|
||||
* Both the listener (`parseXtreamMockServerEnvironment`) and the marketing
|
||||
* fixture's asset origin (`marketingAssetUrl`) go through this, because the
|
||||
* poster/backdrop/logo/episode URLs the fixture mints must point at the port
|
||||
* the server actually bound — a fixture that read `PORT` alone sent every
|
||||
* `XTREAM_MOCK_PORT`-relocated run's images to 3211.
|
||||
*/
|
||||
export function resolveXtreamMockPortString(
|
||||
environment: NodeJS.ProcessEnv
|
||||
): string {
|
||||
return (
|
||||
environment['PORT'] ??
|
||||
environment['XTREAM_MOCK_PORT'] ??
|
||||
String(DEFAULT_XTREAM_MOCK_PORT)
|
||||
);
|
||||
}
|
||||
@@ -463,6 +463,21 @@ describe('Xtream mock environment parsing', () => {
|
||||
).toEqual({ host: '127.0.0.1', port: 3211 });
|
||||
});
|
||||
|
||||
it('falls back to the Playwright-side XTREAM_MOCK_PORT alias when PORT is unset', () => {
|
||||
expect(
|
||||
parseXtreamMockServerEnvironment({ XTREAM_MOCK_PORT: '3311' })
|
||||
).toEqual({ host: '127.0.0.1', port: 3311 });
|
||||
expect(
|
||||
parseXtreamMockServerEnvironment({
|
||||
PORT: '3221',
|
||||
XTREAM_MOCK_PORT: '3311',
|
||||
})
|
||||
).toEqual({ host: '127.0.0.1', port: 3221 });
|
||||
expect(() =>
|
||||
parseXtreamMockServerEnvironment({ XTREAM_MOCK_PORT: '12x' })
|
||||
).toThrow(/port/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ PORT: '-1' }, /port/i],
|
||||
[{ PORT: '12x' }, /port/i],
|
||||
|
||||
@@ -7,6 +7,7 @@ import express, {
|
||||
type Response,
|
||||
} from 'express';
|
||||
import { installLiveFormatFixture } from './live-format-fixture.js';
|
||||
import { resolveXtreamMockPortString } from './mock-port.js';
|
||||
import { resetAll } from './data-store.js';
|
||||
import { renderMarketingAssetSvg } from './generators/marketing.generator.js';
|
||||
import { installPerformanceControlRoutes } from './performance-control-routes.js';
|
||||
@@ -50,7 +51,6 @@ https://example.channels/path-to-file/3.m3u8
|
||||
https://example.channels/path-to-file/4.m3u8
|
||||
`;
|
||||
const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
|
||||
const DEFAULT_PORT = 3211;
|
||||
// Loopback by default: the fixtures serve fabricated but unauthenticated
|
||||
// content, so they should not be reachable from other hosts unless a dev
|
||||
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them).
|
||||
@@ -129,7 +129,7 @@ export function createXtreamMockApp(
|
||||
export function parseXtreamMockServerEnvironment(
|
||||
environment: NodeJS.ProcessEnv
|
||||
): XtreamMockServerOptions {
|
||||
const rawPort = environment['PORT'] ?? String(DEFAULT_PORT);
|
||||
const rawPort = resolveXtreamMockPortString(environment);
|
||||
if (!/^\d+$/.test(rawPort)) {
|
||||
throw new Error('Xtream mock port must be an integer');
|
||||
}
|
||||
@@ -219,7 +219,7 @@ function dispatchProxyAction(request: Request, response: Response): void {
|
||||
/** XMLTV for the marketing live channels; see `demo-xmltv.ts`. */
|
||||
function installDemoGuideRoute(app: express.Express): void {
|
||||
app.get('/demo/guide.xml', (request, response) => {
|
||||
const origin = `${request.protocol}://${request.get('host') ?? `localhost:${DEFAULT_PORT}`}`;
|
||||
const origin = `${request.protocol}://${request.get('host') ?? `localhost:${resolveXtreamMockPortString(process.env)}`}`;
|
||||
response
|
||||
.type('application/xml')
|
||||
.set('Cache-Control', 'no-store')
|
||||
|
||||
@@ -291,10 +291,25 @@ 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
|
||||
strip (poster, season name, episode count — `PORTALS.EPISODE_COUNT_ONE` /
|
||||
`PORTALS.EPISODE_COUNT_OTHER`) above its season tabs, under the
|
||||
same gates, from `PortalInlinePlayerComponent.seasonPosters` through
|
||||
`buildFullscreenEpisodePanelSeasons`.
|
||||
|
||||
Beyond six seasons the tabs become a `mat-menu` dropdown, and that dropdown
|
||||
carries **season thumbnails** from the same `seasonPosters` map
|
||||
(`SeasonTabsComponent.seasonPosters`, passed by the season container and by
|
||||
the fullscreen episode panel): a 28×42 poster projected into the leading slot
|
||||
of each menu row that has one, and the selected season's poster inside the
|
||||
closed trigger (`season-tabs__dropdown--with-thumb` tightens the pill around
|
||||
it). A season without a poster gets no placeholder tile — its row simply
|
||||
starts with the text — and a poster whose image request fails is dropped from
|
||||
both places rather than left as a broken-image frame (the component keeps its
|
||||
own failed-URL set, independent of the container's cover column). The pill
|
||||
row (six seasons or fewer) deliberately stays text-only: the design review
|
||||
rejected per-pill thumbnails as a second poster rail, and the season cover
|
||||
beside the tabs already shows the selected season's picture.
|
||||
|
||||
### Manual watched toggle for movies
|
||||
|
||||
Movies carry the same manual "watched" affordance as episodes, in the
|
||||
|
||||
@@ -374,15 +374,17 @@ webServer: [
|
||||
|
||||
Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance.
|
||||
|
||||
**`MOCK_PORT` moves the CLIENT side only** — Playwright's health-check URL and
|
||||
the `MOCK_SERVER` constants in the specs. The server's own port comes from
|
||||
`PORT` (`main.ts`), which the `serve` and `serve-with-watch` targets pin to
|
||||
`3210` in `project.json`, and nothing maps one variable to the other. Setting
|
||||
`MOCK_PORT` alone therefore points Playwright at a port nothing is listening
|
||||
on and the run times out waiting for `/health`. It is only useful against a
|
||||
mock you started yourself on that port (`reuseExistingServer` is on outside
|
||||
CI); relocating the Nx-managed one would need `MOCK_PORT` passed through as
|
||||
`PORT`.
|
||||
**`MOCK_PORT` relocates the whole run.** Playwright's health-check URL and the
|
||||
`MOCK_SERVER` constants in the specs read it, and `main.ts` resolves the
|
||||
server's port as `PORT`, then `MOCK_PORT`, then `3210` — so
|
||||
`MOCK_PORT=3310 pnpm exec playwright test …` starts the Nx-managed mock on
|
||||
3310 and points every spec at it, which is how two worktrees run E2E side by
|
||||
side when one already holds 3210. This only works because the `serve` and
|
||||
`serve-with-watch` targets no longer pin `PORT` in `project.json`: an `env`
|
||||
entry in `nx:run-commands` overrides the shell (`{...process.env, ...env}`),
|
||||
so a pinned value silently discarded every override. `PORT=3310 pnpm nx run
|
||||
stalker-mock-server:serve` relocates the server alone. The Xtream mock has the
|
||||
same shape with `XTREAM_MOCK_PORT`.
|
||||
|
||||
### Test Isolation
|
||||
|
||||
|
||||
@@ -122,7 +122,9 @@ unauthenticated content, so it should not be reachable from other hosts by
|
||||
accident. Set `HOST=0.0.0.0` explicitly to expose it, which is what you need
|
||||
when driving the mock from a phone, an STB, a container, or another machine.
|
||||
Control mode additionally *rejects* an explicitly configured non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell
|
||||
value reaches the parser; its no-value default remains `3211`.
|
||||
value reaches the parser; without one it falls back to `XTREAM_MOCK_PORT` (the
|
||||
client-side knob Playwright and the specs read, so one variable relocates the
|
||||
mock and the tests together) and then to `3211`.
|
||||
|
||||
Use a dedicated port rather than the normal `3211` E2E server:
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
PlaybackPositionData,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
type AutoSeasonContext,
|
||||
resolveAutoSelectedSeason,
|
||||
} from './season-auto-select.util';
|
||||
|
||||
// Direct coverage for the pure season auto-select rule. The container specs
|
||||
// exercise it through the component; this file pins each branch on its own.
|
||||
|
||||
function episode(id: string, season: number): XtreamSerieEpisode {
|
||||
return {
|
||||
id,
|
||||
episode_num: 1,
|
||||
title: `Episode ${id}`,
|
||||
container_extension: 'mp4',
|
||||
info: {},
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season,
|
||||
direct_source: '',
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
function position(
|
||||
contentXtreamId: number,
|
||||
overrides: Partial<PlaybackPositionData> = {}
|
||||
): PlaybackPositionData {
|
||||
return {
|
||||
contentXtreamId,
|
||||
contentType: 'episode',
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 2700,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const inProgress = (contentXtreamId: number, updatedAt?: string) =>
|
||||
position(contentXtreamId, { positionSeconds: 600, updatedAt });
|
||||
const watched = (contentXtreamId: number) =>
|
||||
position(contentXtreamId, { positionSeconds: 2700 });
|
||||
|
||||
/**
|
||||
* Builds the context the container would hand over: one episode per listed
|
||||
* id, season keys in display order, counts derived from the same data.
|
||||
*/
|
||||
function context(
|
||||
seasons: Record<string, string[]>,
|
||||
positions: Record<string, PlaybackPositionData> = {},
|
||||
overrides: Partial<AutoSeasonContext> = {}
|
||||
): AutoSeasonContext {
|
||||
const keys = Object.keys(seasons).sort((a, b) => Number(a) - Number(b));
|
||||
const episodes: Record<string, XtreamSerieEpisode[]> = {};
|
||||
const episodeCounts: Record<string, number> = {};
|
||||
const watchedCounts: Record<string, number> = {};
|
||||
for (const key of keys) {
|
||||
episodes[key] = seasons[key].map((id) => episode(id, Number(key)));
|
||||
episodeCounts[key] = seasons[key].length;
|
||||
watchedCounts[key] = seasons[key].filter((id) => {
|
||||
const stored = positions[id];
|
||||
return (
|
||||
stored !== undefined &&
|
||||
stored.positionSeconds / (stored.durationSeconds ?? 1) >= 0.9
|
||||
);
|
||||
}).length;
|
||||
}
|
||||
return {
|
||||
keys,
|
||||
playingSeasonKey: null,
|
||||
seasons: episodes,
|
||||
positionOf: (item) => positions[item.id],
|
||||
hasUnloadedSeasons: false,
|
||||
episodeCounts,
|
||||
watchedCounts,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveAutoSelectedSeason', () => {
|
||||
it('returns undefined without season keys', () => {
|
||||
expect(resolveAutoSelectedSeason(context({}))).toBeUndefined();
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({}, {}, { playingSeasonKey: '3' })
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers the playing season over every other rule', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{ '201': inProgress(201, '2026-07-02T00:00:00.000Z') },
|
||||
{ playingSeasonKey: '3' }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
|
||||
});
|
||||
|
||||
it('picks the most recently updated in-progress season', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-03T00:00:00.000Z'),
|
||||
'301': inProgress(301, '2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
// Season three is later in display order but its resume point is
|
||||
// older; recency, not position, decides.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
});
|
||||
|
||||
it('treats a missing updatedAt as the oldest resume point', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
|
||||
'201': inProgress(201),
|
||||
}
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
});
|
||||
|
||||
it('resolves an updatedAt tie to the season scanned last', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'] },
|
||||
{
|
||||
'101': inProgress(101, '2026-07-01T00:00:00.000Z'),
|
||||
'201': inProgress(201, '2026-07-01T00:00:00.000Z'),
|
||||
}
|
||||
);
|
||||
// `>=` keeps the later entry on a tie: the scan order of the seasons
|
||||
// map, which follows the container's sorted keys.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('ignores watched and barely-started positions when looking for progress', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{
|
||||
'101': watched(101),
|
||||
'301': position(301, {
|
||||
positionSeconds: 5,
|
||||
updatedAt: '2026-07-09T00:00:00.000Z',
|
||||
}),
|
||||
}
|
||||
);
|
||||
// Season one is watched, season three's 5 s is below the in-progress
|
||||
// threshold, so the default rule runs: earliest unwatched season.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('falls back to the earliest season with unwatched episodes', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101', '102'], '2': ['201'], '3': ['301'] },
|
||||
{ '101': watched(101), '102': watched(102) }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('2');
|
||||
});
|
||||
|
||||
it('opens the latest non-empty season once everything is watched', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': ['301'] },
|
||||
{ '101': watched(101), '201': watched(201), '301': watched(301) }
|
||||
);
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('3');
|
||||
});
|
||||
|
||||
it('skips loaded-but-empty seasons in both fallback branches', () => {
|
||||
// An empty season is never "unwatched": the first season with
|
||||
// episodes wins.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context({ '1': [], '2': ['201'], '3': ['301'] })
|
||||
)
|
||||
).toBe('2');
|
||||
// Nor is it the "latest" season once everything is watched.
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '101': watched(101), '201': watched(201) }
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
|
||||
it('returns the first key when every season is empty', () => {
|
||||
expect(resolveAutoSelectedSeason(context({ '1': [], '2': [] }))).toBe(
|
||||
'1'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the first key while seasons are still unloaded', () => {
|
||||
const ctx = context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '101': watched(101) },
|
||||
{ hasUnloadedSeasons: true }
|
||||
);
|
||||
// Watched state of the unloaded seasons is unknown, so the fallback
|
||||
// does not skip ahead — but a resume point still outranks the pin.
|
||||
expect(resolveAutoSelectedSeason(ctx)).toBe('1');
|
||||
expect(
|
||||
resolveAutoSelectedSeason(
|
||||
context(
|
||||
{ '1': ['101'], '2': ['201'], '3': [] },
|
||||
{ '201': inProgress(201, '2026-07-01T00:00:00.000Z') },
|
||||
{ hasUnloadedSeasons: true }
|
||||
)
|
||||
)
|
||||
).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonDescription]="selectedSeasonDescription()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="selectSeason($event)"
|
||||
(backToPlayingRequested)="scrollToPlayingEpisode()"
|
||||
|
||||
@@ -9,7 +9,8 @@ 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`
|
||||
// Coverage for the season cover beside the season tabs and the season
|
||||
// thumbnails in the >6-seasons dropdown (both fed by the `seasonPosters`
|
||||
// input). Lives beside season-container.component.spec.ts, which sits at the
|
||||
// max-lines cap.
|
||||
|
||||
@@ -133,6 +134,120 @@ describe('SeasonContainerComponent season cover', () => {
|
||||
expect(coverColumnRendered()).toBe(false);
|
||||
});
|
||||
|
||||
describe('dropdown thumbnails (more than six seasons)', () => {
|
||||
const sevenSeasons = (): Record<string, XtreamSerieEpisode[]> => {
|
||||
const seasons: Record<string, XtreamSerieEpisode[]> = {};
|
||||
for (let index = 1; index <= 7; index++) {
|
||||
seasons[String(index)] = [
|
||||
createEpisode({
|
||||
id: String(100 + index),
|
||||
season: index,
|
||||
}),
|
||||
];
|
||||
}
|
||||
return seasons;
|
||||
};
|
||||
const trigger = () =>
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
) as HTMLButtonElement | null;
|
||||
const triggerThumb = () =>
|
||||
trigger()?.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-dropdown-thumb"]'
|
||||
) ?? null;
|
||||
const openMenu = (): HTMLButtonElement[] => {
|
||||
trigger()?.click();
|
||||
fixture.detectChanges();
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.mat-mdc-menu-panel .mat-mdc-menu-item'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
afterEach(() => fixture.destroy());
|
||||
|
||||
it('renders a thumbnail per menu row that has a poster and none where a season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'3': 'https://img.test/season-3.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
// The pill row is untouched: the dropdown replaces it.
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('.season-tabs__pill')
|
||||
).toHaveLength(0);
|
||||
const items = openMenu();
|
||||
expect(items).toHaveLength(7);
|
||||
const thumbOf = (item: HTMLButtonElement) =>
|
||||
item.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-menu-thumb"]'
|
||||
);
|
||||
expect(thumbOf(items[0])?.src).toBe(
|
||||
'https://img.test/season-1.jpg'
|
||||
);
|
||||
// Projected into the leading slot, ahead of Material's text span.
|
||||
expect(thumbOf(items[0])?.parentElement).toBe(items[0]);
|
||||
expect(thumbOf(items[1])).toBeNull();
|
||||
expect(thumbOf(items[2])?.src).toBe(
|
||||
'https://img.test/season-3.jpg'
|
||||
);
|
||||
// Rows without a poster carry no placeholder either.
|
||||
expect(items[1].querySelector('.season-tabs__thumb')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the selected season poster in the closed trigger and swaps it with the selection', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.selectedSeason()).toBe('1');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-1.jpg');
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
openMenu()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('2');
|
||||
expect(triggerThumb()?.src).toBe('https://img.test/season-2.jpg');
|
||||
|
||||
// A season without a poster leaves the trigger text-only.
|
||||
openMenu()[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(component.selectedSeason()).toBe('3');
|
||||
expect(triggerThumb()).toBeNull();
|
||||
expect(
|
||||
trigger()?.classList.contains(
|
||||
'season-tabs__dropdown--with-thumb'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a thumbnail whose image request fails instead of leaving a broken frame', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
});
|
||||
setRequiredInputs(sevenSeasons());
|
||||
fixture.detectChanges();
|
||||
|
||||
triggerThumb()?.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
expect(triggerThumb()).toBeNull();
|
||||
// The same URL is dropped from the menu row too.
|
||||
expect(
|
||||
openMenu()[0].querySelector('[data-testid="season-menu-thumb"]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('withholds the cover when the selected season has no poster', () => {
|
||||
fixture.componentRef.setInput('seasonPosters', {
|
||||
'2': 'https://img.test/season-2.jpg',
|
||||
|
||||
@@ -3,9 +3,21 @@
|
||||
<button
|
||||
class="season-tabs__dropdown"
|
||||
data-testid="season-dropdown"
|
||||
[class.season-tabs__dropdown--with-thumb]="selectedPosterUrl()"
|
||||
[matMenuTriggerFor]="seasonMenu"
|
||||
type="button"
|
||||
>
|
||||
@if (selectedPosterUrl(); as posterUrl) {
|
||||
<img
|
||||
class="season-tabs__thumb"
|
||||
data-testid="season-dropdown-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
@@ -23,6 +35,20 @@
|
||||
"
|
||||
(click)="selectSeason(seasonKey)"
|
||||
>
|
||||
<!-- Projected into the item's leading slot so the row
|
||||
stays a flex line: thumbnail, name, count, check. -->
|
||||
@if (posterUrlOf(seasonKey); as posterUrl) {
|
||||
<img
|
||||
matMenuItemIcon
|
||||
class="season-tabs__thumb season-tabs__thumb--menu"
|
||||
data-testid="season-menu-thumb"
|
||||
[src]="posterUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
(error)="onPosterError(posterUrl)"
|
||||
/>
|
||||
}
|
||||
<span>
|
||||
{{
|
||||
'PORTALS.SEASON_TAB'
|
||||
|
||||
@@ -50,6 +50,28 @@
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
// The trigger hugs its thumbnail: less inset on the picture side and
|
||||
// vertically, so the pill grows only as much as the 42px poster needs.
|
||||
&--with-thumb {
|
||||
gap: 8px;
|
||||
padding: 4px 10px 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// Season thumbnail in the dropdown (trigger + menu rows). Poster ratio at
|
||||
// 28×42 — small enough to keep the 48px menu row height Material sets.
|
||||
&__thumb {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 42px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
background: var(--tag-bg, rgba(255, 255, 255, 0.08));
|
||||
|
||||
&--menu {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&__menu-count {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
@@ -18,6 +19,15 @@ const MAX_SEASON_PILLS = 6;
|
||||
* for up to 6 seasons, a dropdown beyond that. Shows an optional season
|
||||
* description under the tabs and a "back to playing episode" chip when the
|
||||
* currently playing episode belongs to a different season.
|
||||
*
|
||||
* The dropdown carries season thumbnails (`seasonPosters`): a small poster at
|
||||
* the start of each menu row and, for the selected season, in the closed
|
||||
* trigger — a long menu of "Season 7 … Season 14" rows reads far faster with
|
||||
* a picture per row. The pill row deliberately stays text-only: a thumbnail
|
||||
* per pill turns a compact tab strip into a second poster rail. A season
|
||||
* without a poster simply has no thumbnail (no placeholder tile), and a
|
||||
* poster whose image request fails is dropped rather than left as a
|
||||
* broken-image frame.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-season-tabs',
|
||||
@@ -36,6 +46,13 @@ export class SeasonTabsComponent {
|
||||
readonly seasonDescription = input<string | null>(null);
|
||||
/** Season key of the episode currently playing inline, if any. */
|
||||
readonly playingSeasonKey = input<string | null>(null);
|
||||
/**
|
||||
* Per-season poster URLs keyed by season key, rendered as thumbnails in
|
||||
* the dropdown only (menu rows + the selected season's trigger).
|
||||
*/
|
||||
readonly seasonPosters = input<Readonly<Record<string, string>> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
readonly seasonSelected = output<string>();
|
||||
readonly backToPlayingRequested = output<void>();
|
||||
@@ -49,6 +66,24 @@ export class SeasonTabsComponent {
|
||||
return playing !== null && playing !== this.selectedSeason();
|
||||
});
|
||||
|
||||
/** Poster URLs whose image request failed; their thumbnail is dropped. */
|
||||
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** Thumbnail shown in the closed dropdown trigger. */
|
||||
readonly selectedPosterUrl = computed(() => {
|
||||
const selected = this.selectedSeason();
|
||||
return selected === undefined ? null : this.posterUrlOf(selected);
|
||||
});
|
||||
|
||||
posterUrlOf(seasonKey: string): string | null {
|
||||
const url = this.seasonPosters()?.[seasonKey];
|
||||
return url && !this.failedPosters().has(url) ? url : null;
|
||||
}
|
||||
|
||||
onPosterError(url: string): void {
|
||||
this.failedPosters.update((failed) => new Set(failed).add(url));
|
||||
}
|
||||
|
||||
isSeasonCompleted(seasonKey: string): boolean {
|
||||
const total = this.episodeCounts()[seasonKey] ?? 0;
|
||||
return total > 0 && (this.watchedCounts()[seasonKey] ?? 0) >= total;
|
||||
|
||||
+3
-2
@@ -23,8 +23,8 @@
|
||||
<span class="episode-panel__season-count">
|
||||
{{
|
||||
(strip.episodeCount === 1
|
||||
? 'DOWNLOADS.EPISODE_COUNT_ONE'
|
||||
: 'DOWNLOADS.EPISODE_COUNT_OTHER'
|
||||
? 'PORTALS.EPISODE_COUNT_ONE'
|
||||
: 'PORTALS.EPISODE_COUNT_OTHER'
|
||||
) | translate: { count: strip.episodeCount }
|
||||
}}
|
||||
</span>
|
||||
@@ -36,6 +36,7 @@
|
||||
[selectedSeason]="selectedSeasonKey() ?? undefined"
|
||||
[episodeCounts]="episodeCounts()"
|
||||
[watchedCounts]="watchedCounts()"
|
||||
[seasonPosters]="seasonPosters()"
|
||||
[playingSeasonKey]="playingSeasonKey()"
|
||||
(seasonSelected)="onSeasonSelected($event)"
|
||||
(backToPlayingRequested)="backToPlaying()"
|
||||
|
||||
+47
-2
@@ -191,8 +191,8 @@ describe('FullscreenEpisodePanelComponent', () => {
|
||||
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: {
|
||||
PORTALS: {
|
||||
SEASON_TAB: 'Season {{number}}',
|
||||
EPISODE_COUNT_ONE: '1 episode',
|
||||
EPISODE_COUNT_OTHER: '{{count}} episodes',
|
||||
},
|
||||
@@ -241,6 +241,51 @@ describe('FullscreenEpisodePanelComponent', () => {
|
||||
expect(strip()).toBeNull();
|
||||
});
|
||||
|
||||
it('hands the season posters to the tabs dropdown of a long series', () => {
|
||||
const many: FullscreenEpisodePanelSeason[] = Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => ({
|
||||
key: String(index + 1),
|
||||
loadState: 'loaded',
|
||||
episodes: [item(10 * (index + 1), String(index + 1), 1)],
|
||||
...(index % 2 === 0
|
||||
? { posterUrl: `https://img.test/season-${index + 1}.jpg` }
|
||||
: {}),
|
||||
})
|
||||
);
|
||||
fixture.componentRef.setInput('seasons', many);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.seasonPosters()).toEqual({
|
||||
'1': 'https://img.test/season-1.jpg',
|
||||
'3': 'https://img.test/season-3.jpg',
|
||||
'5': 'https://img.test/season-5.jpg',
|
||||
'7': 'https://img.test/season-7.jpg',
|
||||
});
|
||||
expect(pills()).toHaveLength(0);
|
||||
const trigger = fixture.nativeElement.querySelector(
|
||||
'[data-testid="season-dropdown"]'
|
||||
) as HTMLButtonElement;
|
||||
expect(
|
||||
trigger.querySelector<HTMLImageElement>(
|
||||
'[data-testid="season-dropdown-thumb"]'
|
||||
)?.src
|
||||
).toBe('https://img.test/season-1.jpg');
|
||||
trigger.click();
|
||||
fixture.detectChanges();
|
||||
const thumbs = Array.from(
|
||||
document.querySelectorAll<HTMLImageElement>(
|
||||
'.mat-mdc-menu-panel [data-testid="season-menu-thumb"]'
|
||||
)
|
||||
).map((thumb) => thumb.src);
|
||||
expect(thumbs).toEqual([
|
||||
'https://img.test/season-1.jpg',
|
||||
'https://img.test/season-3.jpg',
|
||||
'https://img.test/season-5.jpg',
|
||||
'https://img.test/season-7.jpg',
|
||||
]);
|
||||
});
|
||||
|
||||
it('withholds the season strip for a one-season series', () => {
|
||||
const [first] = seasons(12);
|
||||
fixture.componentRef.setInput('seasons', [
|
||||
|
||||
+10
@@ -106,6 +106,16 @@ export class FullscreenEpisodePanelComponent {
|
||||
readonly episodeCounts = computed(() =>
|
||||
countBySeason(this.seasons(), (season) => season.episodes.length)
|
||||
);
|
||||
/** Season poster URLs keyed by season, for the tabs' dropdown thumbnails. */
|
||||
readonly seasonPosters = computed<Record<string, string>>(() => {
|
||||
const posters: Record<string, string> = {};
|
||||
for (const season of this.seasons()) {
|
||||
if (season.posterUrl) {
|
||||
posters[season.key] = season.posterUrl;
|
||||
}
|
||||
}
|
||||
return posters;
|
||||
});
|
||||
/** Season poster URLs whose image request failed; the strip then folds. */
|
||||
private readonly failedPosters = signal<ReadonlySet<string>>(new Set());
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user