From 191abe16eb20a3e27a61c831b1f8705e77ebb0dc Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Jul 2026 18:35:11 +0200 Subject: [PATCH] feat(ui): season tabs replace two-level seasons navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeasonTabsComponent renders a pill row (dropdown beyond 6 seasons), an optional season description and a back-to-playing chip. The season container auto-selects a season (inline-playing episode's season → most recent in-progress → first), emits seasonSelected for auto-selections so lazy-load/enrichment hooks keep firing, highlights the inline-playing episode, and loses the seasons grid + 'Back to seasons' button. Download/progress helpers move to pure utils; season strings are now translatable. Co-Authored-By: Claude Fable 5 --- apps/web/src/assets/i18n/en.json | 9 + .../stalker-series-view.component.html | 5 +- .../stalker-series-view.component.spec.ts | 4 +- .../serial-details.component.html | 3 + .../serial-details.component.spec.ts | 2 + .../season-container/episode-download.util.ts | 103 +++++ .../season-container/episode-progress.util.ts | 66 +++ .../season-container/episode-utils.spec.ts | 106 +++++ .../season-container.component.html | 103 ++--- .../season-container.component.scss | 169 +------- .../season-container.component.spec.ts | 236 ++++++++--- .../season-container.component.ts | 383 +++++++++--------- .../season-tabs.component.html | 78 ++++ .../season-tabs.component.scss | 109 +++++ .../season-container/season-tabs.component.ts | 61 +++ 15 files changed, 992 insertions(+), 445 deletions(-) create mode 100644 libs/ui/components/src/lib/season-container/episode-download.util.ts create mode 100644 libs/ui/components/src/lib/season-container/episode-progress.util.ts create mode 100644 libs/ui/components/src/lib/season-container/episode-utils.spec.ts create mode 100644 libs/ui/components/src/lib/season-container/season-tabs.component.html create mode 100644 libs/ui/components/src/lib/season-container/season-tabs.component.scss create mode 100644 libs/ui/components/src/lib/season-container/season-tabs.component.ts diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 0bab726d9..9aedbd5d3 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -922,6 +922,15 @@ "CLOSE_PLAYER": "Close player", "BACK_TO_BROWSE": "Back to details", "ABOUT": "About", + "SEASONS_AND_EPISODES": "Seasons and Episodes", + "SEASON_TAB": "Season {{number}}", + "BACK_TO_PLAYING_EPISODE": "Back to playing episode", + "GRID_VIEW": "Grid view", + "LIST_VIEW": "List view", + "NO_EPISODES_AVAILABLE": "No episodes available", + "NO_EPISODES_AVAILABLE_MESSAGE": "This series is listed by your provider, but no seasons or episodes are available to play.", + "SEASON_EMPTY": "This season is empty", + "SEASON_EMPTY_MESSAGE": "This season is listed by your provider, but no playable episodes are available.", "SELECT_CATEGORY": "Select a category", "PAGE": "Page", "CONTENT_NOT_AVAILABLE": "This content is not available on this server", 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 cd54bcfb6..fd016a499 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 @@ -146,9 +146,12 @@ [playbackPositions]="episodePlaybackPositions()" [openingEpisodeId]="openingEpisodeId()" [activeEpisodeId]="activeEpisodeId()" + [playingEpisodeId]=" + inlinePlayback()?.contentInfo?.contentXtreamId ?? null + " [isLoading]=" isVodSeriesSeasonsLoading() || - isCurrentSeasonLoading(seasonContainer?.selectedSeason) + isCurrentSeasonLoading(seasonContainer?.selectedSeason()) " (seasonSelected)="onSeasonSelected($event)" (episodeClicked)="onEpisodeClicked($event)" diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index 817297453..1513625dd 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -35,12 +35,14 @@ class StubSeasonContainerComponent { readonly playbackPositions = input(null); readonly openingEpisodeId = input(null); readonly activeEpisodeId = input(null); + readonly playingEpisodeId = input(null); + readonly seasonDescriptions = input(null); readonly isLoading = input(false); readonly seasonSelected = output(); readonly episodeClicked = output(); readonly episodeDownloadRequested = output(); readonly playbackToggleRequested = output(); - selectedSeason: string | undefined; + readonly selectedSeason = signal(undefined); } @Component({ 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 64b442115..b048e586e 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 @@ -154,6 +154,9 @@ [xtreamDownloadContext]="xtreamDownloadContext()" [openingEpisodeId]="openingEpisodeId()" [activeEpisodeId]="activeEpisodeId()" + [playingEpisodeId]=" + inlinePlayback()?.contentInfo?.contentXtreamId ?? null + " (episodeClicked)="playEpisode($event)" (playbackToggleRequested)="handlePlaybackToggleRequested($event)" (seasonSelected)="onSeasonSelected($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 d26751cfb..1ade24b8b 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 @@ -35,6 +35,8 @@ class StubSeasonContainerComponent { readonly xtreamDownloadContext = input(null); readonly openingEpisodeId = input(null); readonly activeEpisodeId = input(null); + readonly playingEpisodeId = input(null); + readonly seasonDescriptions = input(null); readonly episodeClicked = output(); readonly playbackToggleRequested = output(); } diff --git a/libs/ui/components/src/lib/season-container/episode-download.util.ts b/libs/ui/components/src/lib/season-container/episode-download.util.ts new file mode 100644 index 000000000..370db6310 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-download.util.ts @@ -0,0 +1,103 @@ +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; + +/** + * Pure helpers for mapping episodes (Xtream and Stalker-mapped) to the + * download-manager content ids and download requests used by the season + * container. + */ + +export interface XtreamEpisodeDownloadContext { + serverUrl?: string; + username?: string; + password?: string; +} + +export interface XtreamEpisodeDownloadRequest { + playlistId: string; + xtreamId: number; + contentType: 'episode'; + title: string; + url: string; + posterUrl?: string; + seriesXtreamId: number; + seasonNumber: number; + episodeNumber: number; +} + +export function hashString(str: string): number { + let hash = 0; + for (let index = 0; index < str.length; index++) { + const char = str.charCodeAt(index); + hash = (hash << 5) - hash + char; + hash &= hash; + } + return Math.abs(hash); +} + +export function isStalkerEpisode(episode: XtreamSerieEpisode): boolean { + const customSid = (episode as { custom_sid?: string }).custom_sid; + return customSid === 'vod-series' || customSid === 'regular-series'; +} + +export function getEpisodeDownloadId(episode: XtreamSerieEpisode): number { + const customSid = (episode as { custom_sid?: string }).custom_sid; + + if (customSid === 'regular-series') { + const cmd = (episode as { originalCmd?: string }).originalCmd; + if (cmd) { + const match = cmd.match(/file_(\d+)/); + if (match) { + return Number(match[1]); + } + return hashString(cmd); + } + return Number(episode.id); + } + + if (customSid === 'vod-series') { + const originalId = (episode as { originalId?: string | number }) + .originalId; + const numericId = Number(originalId); + return Number.isNaN(numericId) + ? hashString(String(originalId)) + : numericId; + } + + const numericId = Number(episode.id); + return Number.isNaN(numericId) ? hashString(String(episode.id)) : numericId; +} + +export function buildXtreamEpisodeDownloadRequest(options: { + episode: XtreamSerieEpisode; + context: XtreamEpisodeDownloadContext; + playlistId: string; + seriesId: number; + seriesTitle: string; + fallbackSeasonKey: string | undefined; + posterUrl?: string; +}): XtreamEpisodeDownloadRequest { + const { episode, context, playlistId, seriesId, seriesTitle } = options; + const serverUrl = context.serverUrl?.replace(/\/$/, '') || ''; + const username = context.username || ''; + const password = context.password || ''; + const extension = episode.container_extension || 'mp4'; + const seasonNumber = + episode.season || Number(options.fallbackSeasonKey) || 1; + const episodeNumber = episode.episode_num || 1; + const title = `${seriesTitle || 'Series'} - S${String(seasonNumber).padStart( + 2, + '0' + )}E${String(episodeNumber).padStart(2, '0')} - ${episode.title}`; + + return { + playlistId, + xtreamId: Number(episode.id), + contentType: 'episode', + title, + url: `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`, + posterUrl: options.posterUrl, + seriesXtreamId: seriesId, + seasonNumber, + episodeNumber, + }; +} diff --git a/libs/ui/components/src/lib/season-container/episode-progress.util.ts b/libs/ui/components/src/lib/season-container/episode-progress.util.ts new file mode 100644 index 000000000..5df56ac62 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-progress.util.ts @@ -0,0 +1,66 @@ +import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util'; +import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; + +/** Pure helpers for episode duration parsing and progress-text formatting. */ + +export function parseDuration(duration: string | number | undefined): number { + if (!duration) { + return 0; + } + + if (typeof duration === 'number') { + return duration; + } + + const parts = duration.split(':').map(Number); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } + if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } + + return Number(duration) || 0; +} + +/** + * Human-readable in-progress text ("41:12 left" / elapsed time when the + * duration is unknown). Returns null for watched or position-less episodes. + */ +export function formatEpisodePositionText( + position: PlaybackPositionData | undefined +): string | null { + if (isPortalPlaybackWatched(position)) { + return null; + } + + if (!position || !position.positionSeconds) { + return null; + } + + let seconds = position.positionSeconds; + let suffix = ''; + + if (position.durationSeconds && position.durationSeconds > 0) { + const remaining = Math.max( + 0, + position.durationSeconds - position.positionSeconds + ); + + if (remaining <= 0) { + return null; + } + + seconds = remaining; + suffix = ' left'; + } + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + const formatted = [hours, minutes, secs] + .map((value) => String(value).padStart(2, '0')) + .filter((value, index) => (index === 0 ? value !== '00' : true)) + .join(':'); + return `${formatted}${suffix}`; +} diff --git a/libs/ui/components/src/lib/season-container/episode-utils.spec.ts b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts new file mode 100644 index 000000000..839ac554e --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-utils.spec.ts @@ -0,0 +1,106 @@ +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import { + buildXtreamEpisodeDownloadRequest, + getEpisodeDownloadId, + hashString, + isStalkerEpisode, +} from './episode-download.util'; +import { + formatEpisodePositionText, + parseDuration, +} from './episode-progress.util'; + +const episode = (overrides: Record): XtreamSerieEpisode => + ({ id: '10', episode_num: 1, title: 'Ep', ...overrides }) as never; + +describe('episode-download.util', () => { + it('detects stalker-mapped episodes', () => { + expect(isStalkerEpisode(episode({ custom_sid: 'vod-series' }))).toBe( + true + ); + expect( + isStalkerEpisode(episode({ custom_sid: 'regular-series' })) + ).toBe(true); + expect(isStalkerEpisode(episode({}))).toBe(false); + }); + + it('resolves download ids per episode source', () => { + expect(getEpisodeDownloadId(episode({}))).toBe(10); + expect( + getEpisodeDownloadId( + episode({ + custom_sid: 'regular-series', + originalCmd: '/media/file_777.mpg', + }) + ) + ).toBe(777); + expect( + getEpisodeDownloadId( + episode({ custom_sid: 'vod-series', originalId: '42' }) + ) + ).toBe(42); + expect( + getEpisodeDownloadId( + episode({ custom_sid: 'vod-series', originalId: 'abc' }) + ) + ).toBe(hashString('abc')); + }); + + it('builds an Xtream episode download request', () => { + const request = buildXtreamEpisodeDownloadRequest({ + episode: episode({ + id: '55', + episode_num: 3, + season: 2, + container_extension: 'mkv', + title: 'The One', + }), + context: { + serverUrl: 'http://host/', + username: 'u', + password: 'p', + }, + playlistId: 'pl-1', + seriesId: 900, + seriesTitle: 'Show', + fallbackSeasonKey: '1', + }); + + expect(request.url).toBe('http://host/series/u/p/55.mkv'); + expect(request.title).toBe('Show - S02E03 - The One'); + expect(request.seasonNumber).toBe(2); + expect(request.seriesXtreamId).toBe(900); + }); +}); + +describe('episode-progress.util', () => { + it('parses duration strings', () => { + expect(parseDuration('01:00:30')).toBe(3630); + expect(parseDuration('45:12')).toBe(2712); + expect(parseDuration(120)).toBe(120); + expect(parseDuration(undefined)).toBe(0); + }); + + it('formats remaining time when duration is known', () => { + expect( + formatEpisodePositionText({ + contentXtreamId: 1, + contentType: 'episode', + positionSeconds: 60, + durationSeconds: 360, + }) + ).toBe('05:00 left'); + }); + + it('returns null for watched or missing positions', () => { + expect(formatEpisodePositionText(undefined)).toBeNull(); + expect( + formatEpisodePositionText({ + contentXtreamId: 1, + contentType: 'episode', + positionSeconds: 350, + durationSeconds: 360, + }) + ).toBeNull(); + }); +}); 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 7e7e97ce6..c97e96484 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 @@ -1,25 +1,27 @@
-

Seasons and Episodes

- @if (selectedSeason) { - - } +

+ {{ 'PORTALS.SEASONS_AND_EPISODES' | translate }} +

- @if (selectedSeason && !showSeasonEmptyState()) { + @if (hasSeasons() && !showSeasonEmptyState()) {
- + grid_view - + view_list @@ -27,6 +29,19 @@ }
+ @if (hasSeasons()) { + + } +
@if (isLoading()) {
@@ -37,42 +52,13 @@ -

No episodes available

+

+ {{ 'PORTALS.NO_EPISODES_AVAILABLE' | translate }} +

- This series is listed by your provider, but no seasons or - episodes are available to play. + {{ 'PORTALS.NO_EPISODES_AVAILABLE_MESSAGE' | translate }}

- } @else if (!selectedSeason) { - -
- @for ( - season of seasons() | keyvalue: compareSeasons; - track $index - ) { - - } -
} @else if (showSeasonEmptyState()) { -

This season is empty

+

+ {{ 'PORTALS.SEASON_EMPTY' | translate }} +

- This season is listed by your provider, but no playable - episodes are available. + {{ 'PORTALS.SEASON_EMPTY_MESSAGE' | translate }}

} @else { @if (viewMode() === 'grid') {
- @for (episode of seasons()[selectedSeason]; track $index) { + @for (episode of selectedSeasonEpisodes(); track $index) { @let episodeInfo = getEpisodeInfo(episode);
@if (episodeInfo?.movie_image) { @@ -221,7 +214,10 @@ > } @else { {{ - isEpisodeActiveExternal(episode) + isEpisodeActiveExternal( + episode + ) || + isEpisodePlayingInline(episode) ? 'equalizer' : 'play_arrow' }} @@ -254,10 +250,13 @@ } @else {
- @for (episode of seasons()[selectedSeason]; track $index) { + @for (episode of selectedSeasonEpisodes(); track $index) { @let episodeInfo = getEpisodeInfo(episode);
{{ episode.episode_num }} @@ -340,7 +342,8 @@ > } @else { {{ - isEpisodeActiveExternal(episode) + isEpisodeActiveExternal(episode) || + isEpisodePlayingInline(episode) ? 'equalizer' : 'play_arrow' }} 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 ae3cf447e..5a2ad58f6 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 @@ -26,33 +26,6 @@ letter-spacing: -0.01em; } - .back-btn { - display: flex; - align-items: center; - gap: 6px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--text-secondary); - font-size: 0.8125rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - padding: 6px 12px; - border-radius: 4px; - - &:hover { - color: var(--text-primary); - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.2); - } - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } - .view-toggle { mat-button-toggle-group { border: 1px solid rgba(255, 255, 255, 0.1); @@ -170,140 +143,17 @@ } } -// Grid Layouts -.seasons-grid, +// Grid Layout .episodes-grid { display: grid; gap: 12px; width: 100%; -} - -.seasons-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } -.episodes-grid { - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); -} - -// Season Card -.season-card { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - text-align: left; - position: relative; - overflow: hidden; - color: var(--text-primary); - font-size: 1rem; - font-family: inherit; - - &:hover { - background: rgba(255, 255, 255, 0.1); - transform: translateY(-2px); - border-color: rgba(255, 255, 255, 0.15); - - .season-card__arrow { - opacity: 1; - transform: translateX(0); - } - } - - &__progress-ring { - position: relative; - width: 42px; - height: 42px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - svg { - width: 100%; - height: 100%; - transform: rotate(-90deg); - } - - .progress-ring__bg { - fill: none; - stroke: rgba(255, 255, 255, 0.1); - stroke-width: 3; - } - - .progress-ring__fill { - fill: none; - stroke: var(--accent-color); - stroke-width: 3; - stroke-linecap: round; - transition: stroke-dasharray 0.5s ease-out; - } - - .season-card__number { - position: absolute; - font-size: 1rem !important; - color: var(--text-primary); - font-weight: 700; - opacity: 1; - margin: 0; - padding: 0; - background: transparent; - } - } - - &__info { - flex: 1; - display: flex; - flex-direction: column; - gap: 2px; - } - - &__label { - font-size: 0.9375rem !important; - font-weight: 600; - color: var(--text-primary); - } - - &__progress-text { - font-size: 0.75rem; - color: var(--text-muted); - } - - &__episodes { - font-size: 0.75rem; - color: var(--text-muted); - } - - &__arrow { - opacity: 0; - transform: translateX(-5px); - transition: all 0.2s ease; - color: var(--text-muted); - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } - - &--has-episodes { - /* background: rgba(var(--accent-color-rgb), 0.1); - border-color: rgba(var(--accent-color-rgb), 0.3); */ - - .season-card__number { - color: var(--text-primary); - } - - /* .season-card__label { - color: var(--accent-color); - } */ - } +app-season-tabs { + display: block; + margin-bottom: 16px; } // Episode Card @@ -352,6 +202,12 @@ background: color-mix(in srgb, var(--mat-sys-tertiary-container) 18%, transparent); } + // Playing in the inline player + &--playing { + border-color: color-mix(in srgb, var(--mat-sys-primary) 70%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 40%, transparent); + } + // Watched state &--watched { opacity: 0.7; @@ -757,6 +613,11 @@ background: color-mix(in srgb, var(--mat-sys-tertiary-container) 12%, transparent); } + &--playing { + border-color: color-mix(in srgb, var(--mat-sys-primary) 60%, transparent); + background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); + } + // Always show actions on touch devices @media (hover: none) { .episode-list-item__actions { 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 3788fbb4d..e8af8855c 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 @@ -1,6 +1,6 @@ import { signal } from '@angular/core'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule } from '@ngx-translate/core'; import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; import { DownloadsService } from '@iptvnator/services'; @@ -40,6 +40,7 @@ function createEpisode( describe('SeasonContainerComponent', () => { let fixture: ComponentFixture; let component: SeasonContainerComponent; + let emittedSeasons: string[]; const setRequiredInputs = ( seasons: Record, @@ -68,6 +69,10 @@ describe('SeasonContainerComponent', () => { fixture = TestBed.createComponent(SeasonContainerComponent); component = fixture.componentInstance; + emittedSeasons = []; + component.seasonSelected.subscribe((seasonKey) => + emittedSeasons.push(seasonKey) + ); }); it('renders the series-level placeholder when no seasons are available', () => { @@ -78,12 +83,11 @@ describe('SeasonContainerComponent', () => { fixture.nativeElement.querySelector('.empty-state-panel') ).not.toBeNull(); expect(fixture.nativeElement.textContent).toContain( - 'No episodes available' + 'PORTALS.NO_EPISODES_AVAILABLE' ); - expect(fixture.nativeElement.textContent).toContain( - 'This series is listed by your provider, but no seasons or episodes are available to play.' - ); - expect(fixture.nativeElement.querySelector('.season-card')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-season-tabs') + ).toBeNull(); }); it('renders the loading state instead of the placeholder while seasons are loading', () => { @@ -91,72 +95,206 @@ describe('SeasonContainerComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector('.loading-container mat-spinner') + fixture.nativeElement.querySelector( + '.loading-container mat-spinner' + ) ).not.toBeNull(); expect( fixture.nativeElement.querySelector('.empty-state-panel') ).toBeNull(); }); - it('renders the season-level placeholder when the selected season has no episodes', () => { - setRequiredInputs({ '1': [] }); - fixture.detectChanges(); - - fixture.nativeElement.querySelector('.season-card').click(); - fixture.detectChanges(); - - expect(fixture.nativeElement.textContent).toContain( - 'This season is empty' - ); - expect(fixture.nativeElement.textContent).toContain( - 'This season is listed by your provider, but no playable episodes are available.' - ); - expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull(); - }); - - it('renders season cards and episode cards when episodes exist', () => { - const episode = createEpisode(); - + it('auto-selects the first season, emits seasonSelected, and renders episodes without a click', () => { setRequiredInputs({ - '1': [episode], + '1': [createEpisode()], '2': [], }); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelectorAll('.season-card').length - ).toBe(2); - expect( - fixture.nativeElement.querySelector('.empty-state-panel') - ).toBeNull(); - - fixture.nativeElement.querySelector('.season-card').click(); - fixture.detectChanges(); - + expect(component.selectedSeason()).toBe('1'); + expect(emittedSeasons).toEqual(['1']); expect( fixture.nativeElement.querySelectorAll('.episode-card').length ).toBe(1); expect( - fixture.nativeElement.querySelector('.empty-state-panel') - ).toBeNull(); + fixture.nativeElement.querySelectorAll('.season-tabs__pill').length + ).toBe(2); }); - it('clears a stale selected season when the input seasons change', () => { - const episode = createEpisode(); - - setRequiredInputs({ '1': [episode] }); + it('renders the season-level placeholder when the selected season has no episodes', () => { + setRequiredInputs({ '1': [] }); fixture.detectChanges(); - fixture.nativeElement.querySelector('.season-card').click(); + expect(fixture.nativeElement.textContent).toContain( + 'PORTALS.SEASON_EMPTY' + ); + expect(fixture.nativeElement.querySelector('.view-toggle')).toBeNull(); + }); + + it('switches season via tab click and emits the lazy-load hook once per season', () => { + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); fixture.detectChanges(); - fixture.componentRef.setInput('seasons', { '2': [episode] }); + const pills = fixture.nativeElement.querySelectorAll( + '.season-tabs__pill' + ); + (pills[1] as HTMLButtonElement).click(); fixture.detectChanges(); - expect(component.selectedSeason).toBeUndefined(); + expect(component.selectedSeason()).toBe('2'); + expect(emittedSeasons).toEqual(['1', '2']); expect( - fixture.nativeElement.querySelector('.season-card__number') - ?.textContent - ).toContain('2'); + fixture.nativeElement.querySelector('[data-episode-id="201"]') + ).not.toBeNull(); + }); + + it('prefers the inline-playing episode season on auto-select', () => { + fixture.componentRef.setInput('playingEpisodeId', 201); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('2'); + expect(emittedSeasons).toEqual(['2']); + }); + + it('prefers the most recently updated in-progress episode season over the first season', () => { + fixture.componentRef.setInput( + 'playbackPositions', + new Map([ + [ + 201, + { + contentXtreamId: 201, + contentType: 'episode' as const, + positionSeconds: 500, + durationSeconds: 2700, + updatedAt: '2026-07-01T00:00:00.000Z', + }, + ], + ]) + ); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('2'); + }); + + it('re-resolves a stale selection when the season set changes', () => { + setRequiredInputs({ '1': [createEpisode()] }); + fixture.detectChanges(); + expect(component.selectedSeason()).toBe('1'); + + fixture.componentRef.setInput('seasons', { + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('2'); + expect(emittedSeasons).toEqual(['1', '2']); + }); + + it('keeps the user selection when episode data mutates without key changes', () => { + setRequiredInputs({ + '1': [createEpisode()], + '2': [], + }); + fixture.detectChanges(); + + const pills = fixture.nativeElement.querySelectorAll( + '.season-tabs__pill' + ); + (pills[1] as HTMLButtonElement).click(); + fixture.detectChanges(); + expect(component.selectedSeason()).toBe('2'); + + // Same keys, new object (e.g. TMDB overlay rebuild) — selection sticks + fixture.componentRef.setInput('seasons', { + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect(component.selectedSeason()).toBe('2'); + expect(emittedSeasons).toEqual(['1', '2']); + }); + + it('uses a dropdown selector when there are more than six seasons', () => { + const seasons: Record = {}; + for (let index = 1; index <= 7; index++) { + seasons[String(index)] = [ + createEpisode({ id: String(100 + index), season: index }), + ]; + } + setRequiredInputs(seasons); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-testid="season-dropdown"]' + ) + ).not.toBeNull(); + expect( + fixture.nativeElement.querySelectorAll('.season-tabs__pill').length + ).toBe(0); + }); + + it('shows the back-to-playing chip only when the playing episode is in another season', () => { + fixture.componentRef.setInput('playingEpisodeId', 101); + setRequiredInputs({ + '1': [createEpisode()], + '2': [createEpisode({ id: '201', season: 2 })], + }); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-testid="back-to-playing"]' + ) + ).toBeNull(); + + const pills = fixture.nativeElement.querySelectorAll( + '.season-tabs__pill' + ); + (pills[1] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-testid="back-to-playing"]' + ) + ).not.toBeNull(); + }); + + it('marks the inline-playing episode card', () => { + fixture.componentRef.setInput('playingEpisodeId', 101); + setRequiredInputs({ '1': [createEpisode()] }); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.episode-card--playing') + ).not.toBeNull(); + }); + + it('renders the season description for the selected season', () => { + fixture.componentRef.setInput('seasonDescriptions', { + '1': 'Season one overview', + }); + setRequiredInputs({ '1': [createEpisode()] }); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-testid="season-description"]' + )?.textContent + ).toContain('Season one overview'); }); }); 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 4c4a25fd0..0574de409 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 @@ -1,13 +1,15 @@ -import { KeyValuePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, - DoCheck, + ElementRef, OnInit, + computed, + effect, inject, input, output, signal, + untracked, } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; @@ -29,6 +31,16 @@ import { import { DownloadsService } from '@iptvnator/services'; import { ProgressCapsuleComponent } from '../progress-capsule/progress-capsule.component'; import { WatchedBadgeComponent } from '../watched-badge/watched-badge.component'; +import { + buildXtreamEpisodeDownloadRequest, + getEpisodeDownloadId, + isStalkerEpisode, +} from './episode-download.util'; +import { + formatEpisodePositionText, + parseDuration, +} from './episode-progress.util'; +import { SeasonTabsComponent } from './season-tabs.component'; type EpisodeViewMode = 'grid' | 'list'; const EPISODE_VIEW_MODE_KEY = 'iptvnator_episode_view_mode'; @@ -44,47 +56,28 @@ export interface SeasonContainerPlaybackToggleRequest { nextPosition: PlaybackPositionData | null; } -function parseDuration(duration: string | number | undefined): number { - if (!duration) { - return 0; - } - - if (typeof duration === 'number') { - return duration; - } - - const parts = duration.split(':').map(Number); - if (parts.length === 3) { - return parts[0] * 3600 + parts[1] * 60 + parts[2]; - } - if (parts.length === 2) { - return parts[0] * 60 + parts[1]; - } - - return Number(duration) || 0; -} - @Component({ selector: 'app-season-container', templateUrl: './season-container.component.html', styleUrls: ['./season-container.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - KeyValuePipe, MatButtonModule, MatButtonToggleModule, MatIcon, MatProgressSpinnerModule, MatTooltipModule, ProgressCapsuleComponent, + SeasonTabsComponent, TranslateModule, WatchedBadgeComponent, ], }) -export class SeasonContainerComponent implements OnInit, DoCheck { +export class SeasonContainerComponent implements OnInit { private readonly downloadsService = inject(DownloadsService); + private readonly host = inject>(ElementRef); private readonly logger = createLogger('SeasonContainer'); - private previousSeasonKeysSignature = ''; + private lastEmittedSeason: string | undefined; readonly seasons = input.required>(); readonly seriesId = input.required(); @@ -97,7 +90,12 @@ export class SeasonContainerComponent implements OnInit, DoCheck { readonly xtreamDownloadContext = input(null); readonly openingEpisodeId = input(null); + /** Episode currently playing in an EXTERNAL player session. */ readonly activeEpisodeId = input(null); + /** Episode currently playing in the inline player. */ + readonly playingEpisodeId = input(null); + /** Per-season descriptions (TMDB/provider), keyed by season key. */ + readonly seasonDescriptions = input | null>(null); readonly episodeClicked = output(); readonly episodeDownloadRequested = output(); @@ -107,7 +105,81 @@ export class SeasonContainerComponent implements OnInit, DoCheck { readonly isElectron = this.downloadsService.isAvailable; readonly viewMode = signal('grid'); - selectedSeason: string | undefined; + readonly sortedSeasonKeys = computed(() => + Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b)) + ); + + readonly episodeCounts = computed(() => { + const counts: Record = {}; + for (const [key, episodes] of Object.entries(this.seasons())) { + counts[key] = episodes?.length ?? 0; + } + return counts; + }); + + readonly watchedCounts = computed(() => { + const counts: Record = {}; + for (const [key, episodes] of Object.entries(this.seasons())) { + counts[key] = (episodes ?? []).filter((episode) => + this.isEpisodeWatched(episode) + ).length; + } + return counts; + }); + + /** Season key of the inline-playing episode, if it is in the loaded set. */ + readonly playingSeasonKey = computed(() => + this.findSeasonOfEpisode(this.playingEpisodeId()) + ); + + /** + * Selected season. Auto-resolves when the season key set changes or when + * playback positions first arrive (priority: inline-playing episode's + * season → most recently updated in-progress episode's season → first + * season); user tab clicks write to it and stick until the auto-select + * key changes. Ongoing position saves do not reset the selection — only + * the empty→loaded transition of the positions map does. + */ + readonly selectedSeason = signal(undefined); + + private readonly autoSelectKey = computed( + () => + `${this.sortedSeasonKeys().join('|')}::${ + this.playbackPositions().size > 0 ? '1' : '0' + }` + ); + private lastAutoSelectKey: string | null = null; + + readonly selectedSeasonDescription = computed(() => { + const selected = this.selectedSeason(); + if (!selected) { + return null; + } + return this.seasonDescriptions()?.[selected] ?? null; + }); + + constructor() { + effect(() => { + const key = this.autoSelectKey(); + if (key === this.lastAutoSelectKey) { + return; + } + this.lastAutoSelectKey = key; + this.selectedSeason.set( + untracked(() => this.resolveAutoSeason()) + ); + }); + + // Fire the lazy-load/enrichment hooks for auto-selected seasons too — + // with tabs there is no initial "pick a season" click anymore. + effect(() => { + const selected = this.selectedSeason(); + if (selected && selected !== this.lastEmittedSeason) { + this.lastEmittedSeason = selected; + this.seasonSelected.emit(selected); + } + }); + } ngOnInit() { const savedMode = localStorage.getItem( @@ -116,11 +188,6 @@ export class SeasonContainerComponent implements OnInit, DoCheck { if (savedMode === 'grid' || savedMode === 'list') { this.viewMode.set(savedMode); } - this.previousSeasonKeysSignature = this.getSeasonKeys().join('|'); - } - - ngDoCheck(): void { - this.syncSelectedSeason(); } setViewMode(mode: EpisodeViewMode) { @@ -128,25 +195,44 @@ export class SeasonContainerComponent implements OnInit, DoCheck { localStorage.setItem(EPISODE_VIEW_MODE_KEY, mode); } - compareSeasons(a: { key: string }, b: { key: string }): number { - return Number(a.key) - Number(b.key); - } - hasSeasons(): boolean { - return this.getSeasonKeys().length > 0; + return this.sortedSeasonKeys().length > 0; } showSeriesEmptyState(): boolean { - return !this.selectedSeason && !this.hasSeasons(); + return !this.hasSeasons(); } showSeasonEmptyState(): boolean { - return Boolean(this.selectedSeason) && !this.selectedSeasonHasEpisodes(); + const selected = this.selectedSeason(); + return ( + Boolean(selected) && + (this.seasons()[selected as string]?.length ?? 0) === 0 + ); + } + + selectedSeasonEpisodes(): XtreamSerieEpisode[] { + const selected = this.selectedSeason(); + return selected ? (this.seasons()[selected] ?? []) : []; } selectSeason(seasonKey: string) { - this.selectedSeason = seasonKey; - this.seasonSelected.emit(seasonKey); + this.selectedSeason.set(seasonKey); + } + + scrollToPlayingEpisode(): void { + const playingSeason = this.playingSeasonKey(); + if (!playingSeason) { + return; + } + this.selectedSeason.set(playingSeason); + // Wait a tick so the episode list re-renders for the new season. + setTimeout(() => { + const target = this.host.nativeElement.querySelector( + `[data-episode-id="${this.playingEpisodeId()}"]` + ); + target?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + }); } selectEpisode(episode: XtreamSerieEpisode) { @@ -181,7 +267,9 @@ export class SeasonContainerComponent implements OnInit, DoCheck { contentXtreamId, contentType: 'episode', seriesXtreamId: this.seriesId(), - seasonNumber: Number(episode.season || this.selectedSeason || 1), + seasonNumber: Number( + episode.season || this.selectedSeason() || 1 + ), episodeNumber: Number(episode.episode_num || 1), positionSeconds: duration, durationSeconds: duration, @@ -216,70 +304,28 @@ export class SeasonContainerComponent implements OnInit, DoCheck { return this.activeEpisodeId() === this.getEpisodeContentId(episode); } + isEpisodePlayingInline(episode: XtreamSerieEpisode): boolean { + return this.playingEpisodeId() === this.getEpisodeContentId(episode); + } + getEpisodeProgress(episode: XtreamSerieEpisode): number { - return getPortalPlaybackProgressPercent(this.getEpisodePosition(episode)); + return getPortalPlaybackProgressPercent( + this.getEpisodePosition(episode) + ); } getEpisodePositionText(episode: XtreamSerieEpisode): string | null { - if (this.isEpisodeWatched(episode)) { - return null; - } - - const position = this.getEpisodePosition(episode); - if (!position || !position.positionSeconds) { - return null; - } - - let seconds = position.positionSeconds; - let suffix = ''; - - if (position.durationSeconds && position.durationSeconds > 0) { - const remaining = Math.max( - 0, - position.durationSeconds - position.positionSeconds - ); - - if (remaining <= 0) { - return null; - } - - seconds = remaining; - suffix = ' left'; - } - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - const formatted = [hours, minutes, secs] - .map((value) => String(value).padStart(2, '0')) - .filter((value, index) => (index === 0 ? value !== '00' : true)) - .join(':'); - return `${formatted}${suffix}`; + return formatEpisodePositionText(this.getEpisodePosition(episode)); } - getSeasonWatchedCount(seasonKey: string): number { - const episodes = this.seasons()[seasonKey]; - if (!episodes) { - return 0; - } - return episodes.filter((episode) => this.isEpisodeWatched(episode)).length; - } - - getSeasonProgressDash(seasonKey: string): string { - const episodes = this.seasons()[seasonKey]; - if (!episodes || episodes.length === 0) { - return '0, 100'; - } - - const watched = this.getSeasonWatchedCount(seasonKey); - const percent = (watched / episodes.length) * 100; - return `${percent}, 100`; + getEpisodeContentId(episode: XtreamSerieEpisode): number { + return Number(episode.id); } async downloadEpisode(event: Event, episode: XtreamSerieEpisode) { event.stopPropagation(); - if (this.isStalkerEpisode(episode)) { + if (isStalkerEpisode(episode)) { this.episodeDownloadRequested.emit(episode); return; } @@ -289,30 +335,17 @@ export class SeasonContainerComponent implements OnInit, DoCheck { return; } - const serverUrl = xtreamDownload.serverUrl?.replace(/\/$/, '') || ''; - const username = xtreamDownload.username || ''; - const password = xtreamDownload.password || ''; - const extension = episode.container_extension || 'mp4'; - const url = `${serverUrl}/series/${username}/${password}/${episode.id}.${extension}`; - const episodeInfo = this.getEpisodeInfo(episode); - const posterUrl = episodeInfo?.movie_image; - const seasonNum = episode.season || Number(this.selectedSeason) || 1; - const episodeNum = episode.episode_num || 1; - const episodeTitle = `${this.seriesTitle() || 'Series'} - S${String( - seasonNum - ).padStart(2, '0')}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`; - - await this.downloadsService.startDownload({ - playlistId: this.playlistId(), - xtreamId: Number(episode.id), - contentType: 'episode', - title: episodeTitle, - url, - posterUrl, - seriesXtreamId: this.seriesId(), - seasonNumber: seasonNum, - episodeNumber: episodeNum, - }); + await this.downloadsService.startDownload( + buildXtreamEpisodeDownloadRequest({ + episode, + context: xtreamDownload, + playlistId: this.playlistId(), + seriesId: this.seriesId(), + seriesTitle: this.seriesTitle(), + fallbackSeasonKey: this.selectedSeason(), + posterUrl: this.getEpisodeInfo(episode)?.movie_image, + }) + ); } isEpisodeDownloaded(episode: XtreamSerieEpisode): boolean { @@ -322,7 +355,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck { this.downloadsService.downloads(); return this.downloadsService.isDownloaded( - this.getEpisodeDownloadId(episode), + getEpisodeDownloadId(episode), this.playlistId(), 'episode' ); @@ -335,7 +368,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck { this.downloadsService.downloads(); return this.downloadsService.isDownloading( - this.getEpisodeDownloadId(episode), + getEpisodeDownloadId(episode), this.playlistId(), 'episode' ); @@ -348,7 +381,7 @@ export class SeasonContainerComponent implements OnInit, DoCheck { } const filePath = this.downloadsService.getDownloadedFilePath( - this.getEpisodeDownloadId(episode), + getEpisodeDownloadId(episode), this.playlistId(), 'episode' ); @@ -358,89 +391,59 @@ export class SeasonContainerComponent implements OnInit, DoCheck { } } - private isStalkerEpisode(episode: XtreamSerieEpisode): boolean { - return ( - (episode as { custom_sid?: string }).custom_sid === 'vod-series' || - (episode as { custom_sid?: string }).custom_sid === 'regular-series' - ); - } - - private getEpisodeContentId(episode: XtreamSerieEpisode): number { - return Number(episode.id); - } - - private getEpisodeDownloadId(episode: XtreamSerieEpisode): number { - const customSid = (episode as { custom_sid?: string }).custom_sid; - - if (customSid === 'regular-series') { - const cmd = (episode as { originalCmd?: string }).originalCmd; - if (cmd) { - const match = cmd.match(/file_(\d+)/); - if (match) { - return Number(match[1]); - } - return this.hashString(cmd); - } - return Number(episode.id); - } - - if (customSid === 'vod-series') { - const originalId = (episode as { originalId?: string | number }) - .originalId; - const numericId = Number(originalId); - return Number.isNaN(numericId) - ? this.hashString(String(originalId)) - : numericId; - } - - const numericId = Number(episode.id); - return Number.isNaN(numericId) - ? this.hashString(String(episode.id)) - : numericId; - } - private getEpisodePosition( episode: XtreamSerieEpisode ): PlaybackPositionData | undefined { return this.playbackPositions().get(this.getEpisodeContentId(episode)); } - private selectedSeasonHasEpisodes(): boolean { - if (!this.selectedSeason) { - return false; + private findSeasonOfEpisode(episodeId: number | null): string | null { + if (episodeId === null) { + return null; } - - return (this.seasons()[this.selectedSeason]?.length ?? 0) > 0; + for (const [key, episodes] of Object.entries(this.seasons())) { + if ( + episodes?.some( + (episode) => this.getEpisodeContentId(episode) === episodeId + ) + ) { + return key; + } + } + return null; } - private getSeasonKeys(): string[] { - return Object.keys(this.seasons()); - } - - private hasSeasonKey(seasonKey: string): boolean { - return Object.prototype.hasOwnProperty.call(this.seasons(), seasonKey); - } - - private syncSelectedSeason(): void { - const seasonKeysSignature = this.getSeasonKeys().join('|'); - if (seasonKeysSignature === this.previousSeasonKeysSignature) { - return; + private resolveAutoSeason(): string | undefined { + const keys = this.sortedSeasonKeys(); + if (keys.length === 0) { + return undefined; } - this.previousSeasonKeysSignature = seasonKeysSignature; - - if (this.selectedSeason && !this.hasSeasonKey(this.selectedSeason)) { - this.selectedSeason = undefined; + const playingSeason = this.playingSeasonKey(); + if (playingSeason) { + return playingSeason; } + + const resumeSeason = this.findMostRecentInProgressSeason(); + return resumeSeason ?? keys[0]; } - private hashString(str: string): number { - let hash = 0; - for (let index = 0; index < str.length; index++) { - const char = str.charCodeAt(index); - hash = (hash << 5) - hash + char; - hash &= hash; + 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 Math.abs(hash); + return bestSeason; } } diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.html b/libs/ui/components/src/lib/season-container/season-tabs.component.html new file mode 100644 index 000000000..18ef7e62f --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.html @@ -0,0 +1,78 @@ +
+ @if (useDropdown()) { + + + @for (seasonKey of seasonKeys(); track seasonKey) { + + } + + } @else { + @for (seasonKey of seasonKeys(); track seasonKey) { + + } + } + + @if (showBackToPlaying()) { + + } +
+ +@if (seasonDescription(); as description) { +

+ {{ description }} +

+} diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.scss b/libs/ui/components/src/lib/season-container/season-tabs.component.scss new file mode 100644 index 000000000..3ac56ef89 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.scss @@ -0,0 +1,109 @@ +:host { + display: block; + min-width: 0; +} + +.season-tabs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + + &__pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: + background 150ms ease, + color 150ms ease; + + &:hover { + background: var(--tag-bg, rgba(255, 255, 255, 0.08)); + } + + &--active { + background: var(--tag-bg, rgba(255, 255, 255, 0.1)); + color: var(--text-primary, #fff); + font-weight: 600; + outline: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2)); + } + } + + &__dropdown { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 8px 10px 8px 16px; + border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2)); + border-radius: 999px; + background: transparent; + color: var(--text-primary, #fff); + font-size: 13px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + } + + &__menu-count { + margin-left: 12px; + color: var(--text-muted, rgba(255, 255, 255, 0.5)); + font-size: 12px; + } + + &__done { + font-size: 15px; + width: 15px; + height: 15px; + color: var(--accent-success, #4ade80); + } + + &__back-to-playing { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + padding: 7px 14px; + border: 1px solid var(--tag-border, rgba(255, 255, 255, 0.2)); + border-radius: 999px; + background: transparent; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + font-size: 12.5px; + font-family: inherit; + cursor: pointer; + + &:hover { + color: var(--text-primary, #fff); + background: var(--tag-bg, rgba(255, 255, 255, 0.08)); + } + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + } + + &__menu-item--selected { + font-weight: 700; + } +} + +.season-tabs__description { + margin: 10px 4px 0; + max-width: 760px; + font-size: 12.5px; + line-height: 1.55; + color: var(--text-secondary, rgba(255, 255, 255, 0.7)); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/libs/ui/components/src/lib/season-container/season-tabs.component.ts b/libs/ui/components/src/lib/season-container/season-tabs.component.ts new file mode 100644 index 000000000..1928be12f --- /dev/null +++ b/libs/ui/components/src/lib/season-container/season-tabs.component.ts @@ -0,0 +1,61 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { TranslateModule } from '@ngx-translate/core'; + +/** Above this count the pill row becomes a dropdown selector. */ +const MAX_SEASON_PILLS = 6; + +/** + * Season selector for the season container: a pill row ("Season 1 · 2 · 3") + * 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. + */ +@Component({ + selector: 'app-season-tabs', + templateUrl: './season-tabs.component.html', + styleUrls: ['./season-tabs.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIcon, MatMenuModule, TranslateModule], +}) +export class SeasonTabsComponent { + /** Season keys, already sorted in display order. */ + readonly seasonKeys = input.required(); + readonly selectedSeason = input(undefined); + readonly episodeCounts = input>({}); + readonly watchedCounts = input>({}); + /** Description of the selected season (TMDB/provider), if available. */ + readonly seasonDescription = input(null); + /** Season key of the episode currently playing inline, if any. */ + readonly playingSeasonKey = input(null); + + readonly seasonSelected = output(); + readonly backToPlayingRequested = output(); + + readonly useDropdown = computed( + () => this.seasonKeys().length > MAX_SEASON_PILLS + ); + + readonly showBackToPlaying = computed(() => { + const playing = this.playingSeasonKey(); + return playing !== null && playing !== this.selectedSeason(); + }); + + isSeasonCompleted(seasonKey: string): boolean { + const total = this.episodeCounts()[seasonKey] ?? 0; + return total > 0 && (this.watchedCounts()[seasonKey] ?? 0) >= total; + } + + selectSeason(seasonKey: string): void { + if (seasonKey !== this.selectedSeason()) { + this.seasonSelected.emit(seasonKey); + } + } +}