feat(ui): season tabs replace two-level seasons navigation

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 18:35:11 +02:00
1 parent 50189c27e2
commit 191abe16eb
15 files changed
+992 -445

No files matched your search

+9
View File
@@ -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",
@@ -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)"
@@ -35,12 +35,14 @@ class StubSeasonContainerComponent {
readonly playbackPositions = input<unknown>(null);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly playingEpisodeId = input<number | null>(null);
readonly seasonDescriptions = input<unknown>(null);
readonly isLoading = input(false);
readonly seasonSelected = output<string>();
readonly episodeClicked = output<unknown>();
readonly episodeDownloadRequested = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
selectedSeason: string | undefined;
readonly selectedSeason = signal<string | undefined>(undefined);
}
@Component({
@@ -154,6 +154,9 @@
[xtreamDownloadContext]="xtreamDownloadContext()"
[openingEpisodeId]="openingEpisodeId()"
[activeEpisodeId]="activeEpisodeId()"
[playingEpisodeId]="
inlinePlayback()?.contentInfo?.contentXtreamId ?? null
"
(episodeClicked)="playEpisode($event)"
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
(seasonSelected)="onSeasonSelected($event)"
@@ -35,6 +35,8 @@ class StubSeasonContainerComponent {
readonly xtreamDownloadContext = input<unknown>(null);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly playingEpisodeId = input<number | null>(null);
readonly seasonDescriptions = input<unknown>(null);
readonly episodeClicked = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
}
@@ -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,
};
}
@@ -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}`;
}
@@ -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<string, unknown>): 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();
});
});
@@ -1,25 +1,27 @@
<section class="seasons-section">
<div class="section-header">
<div class="section-header__left">
<h3 class="section-title">Seasons and Episodes</h3>
@if (selectedSeason) {
<button class="back-btn" (click)="selectedSeason = undefined">
<mat-icon>arrow_back</mat-icon>
<span>Back to seasons</span>
</button>
}
<h3 class="section-title">
{{ 'PORTALS.SEASONS_AND_EPISODES' | translate }}
</h3>
</div>
@if (selectedSeason && !showSeasonEmptyState()) {
@if (hasSeasons() && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
(change)="setViewMode($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle value="grid" [matTooltip]="'Grid view'">
<mat-button-toggle
value="grid"
[matTooltip]="'PORTALS.GRID_VIEW' | translate"
>
<mat-icon>grid_view</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="list" [matTooltip]="'List view'">
<mat-button-toggle
value="list"
[matTooltip]="'PORTALS.LIST_VIEW' | translate"
>
<mat-icon>view_list</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
@@ -27,6 +29,19 @@
}
</div>
@if (hasSeasons()) {
<app-season-tabs
[seasonKeys]="sortedSeasonKeys()"
[selectedSeason]="selectedSeason()"
[episodeCounts]="episodeCounts()"
[watchedCounts]="watchedCounts()"
[seasonDescription]="selectedSeasonDescription()"
[playingSeasonKey]="playingSeasonKey()"
(seasonSelected)="selectSeason($event)"
(backToPlayingRequested)="scrollToPlayingEpisode()"
/>
}
<div class="content-container">
@if (isLoading()) {
<div class="loading-container">
@@ -37,42 +52,13 @@
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">No episodes available</h4>
<h4 class="empty-state-panel__title">
{{ 'PORTALS.NO_EPISODES_AVAILABLE' | translate }}
</h4>
<p class="empty-state-panel__message">
This series is listed by your provider, but no seasons or
episodes are available to play.
{{ 'PORTALS.NO_EPISODES_AVAILABLE_MESSAGE' | translate }}
</p>
</div>
} @else if (!selectedSeason) {
<!-- Seasons Grid -->
<div class="seasons-grid">
@for (
season of seasons() | keyvalue: compareSeasons;
track $index
) {
<button
class="season-card"
(click)="selectSeason(season.key)"
[class.season-card--has-episodes]="season.value.length"
>
<div class="season-card__number">{{ season.key }}</div>
<div class="season-card__label">Season</div>
@if (season.value.length) {
<div class="season-card__episodes">
{{ season.value.length }}
{{
season.value.length === 1
? 'Episode'
: 'Episodes'
}}
</div>
}
<div class="season-card__arrow">
<mat-icon>chevron_right</mat-icon>
</div>
</button>
}
</div>
} @else if (showSeasonEmptyState()) {
<div
class="empty-state-panel empty-state-panel--season"
@@ -81,20 +67,24 @@
<div class="empty-state-panel__icon" aria-hidden="true">
<mat-icon>video_library</mat-icon>
</div>
<h4 class="empty-state-panel__title">This season is empty</h4>
<h4 class="empty-state-panel__title">
{{ 'PORTALS.SEASON_EMPTY' | translate }}
</h4>
<p class="empty-state-panel__message">
This season is listed by your provider, but no playable
episodes are available.
{{ 'PORTALS.SEASON_EMPTY_MESSAGE' | translate }}
</p>
</div>
} @else {
@if (viewMode() === 'grid') {
<!-- Episodes Grid View -->
<div class="episodes-grid">
@for (episode of seasons()[selectedSeason]; track $index) {
@for (episode of selectedSeasonEpisodes(); track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-card"
[attr.data-episode-id]="
getEpisodeContentId(episode)
"
(click)="selectEpisode(episode)"
[class.episode-card--watched]="
isEpisodeWatched(episode)
@@ -108,6 +98,9 @@
[class.episode-card--active-external]="
isEpisodeActiveExternal(episode)
"
[class.episode-card--playing]="
isEpisodePlayingInline(episode)
"
>
<div class="episode-card__thumbnail">
@if (episodeInfo?.movie_image) {
@@ -221,7 +214,10 @@
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
isEpisodeActiveExternal(
episode
) ||
isEpisodePlayingInline(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
@@ -254,10 +250,13 @@
} @else {
<!-- Episodes List View -->
<div class="episodes-list">
@for (episode of seasons()[selectedSeason]; track $index) {
@for (episode of selectedSeasonEpisodes(); track $index) {
@let episodeInfo = getEpisodeInfo(episode);
<article
class="episode-list-item"
[attr.data-episode-id]="
getEpisodeContentId(episode)
"
(click)="selectEpisode(episode)"
[class.episode-list-item--watched]="
isEpisodeWatched(episode)
@@ -271,6 +270,9 @@
[class.episode-list-item--active-external]="
isEpisodeActiveExternal(episode)
"
[class.episode-list-item--playing]="
isEpisodePlayingInline(episode)
"
>
<div class="episode-list-item__number">
{{ episode.episode_num }}
@@ -340,7 +342,8 @@
></mat-spinner>
} @else {
<mat-icon>{{
isEpisodeActiveExternal(episode)
isEpisodeActiveExternal(episode) ||
isEpisodePlayingInline(episode)
? 'equalizer'
: 'play_arrow'
}}</mat-icon>
@@ -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 {
@@ -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<SeasonContainerComponent>;
let component: SeasonContainerComponent;
let emittedSeasons: string[];
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>,
@@ -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<string, XtreamSerieEpisode[]> = {};
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');
});
});
@@ -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<HTMLElement>>(ElementRef);
private readonly logger = createLogger('SeasonContainer');
private previousSeasonKeysSignature = '';
private lastEmittedSeason: string | undefined;
readonly seasons = input.required<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>();
@@ -97,7 +90,12 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
readonly xtreamDownloadContext =
input<SeasonContainerXtreamDownloadContext | null>(null);
readonly openingEpisodeId = input<number | null>(null);
/** Episode currently playing in an EXTERNAL player session. */
readonly activeEpisodeId = input<number | null>(null);
/** Episode currently playing in the inline player. */
readonly playingEpisodeId = input<number | null>(null);
/** Per-season descriptions (TMDB/provider), keyed by season key. */
readonly seasonDescriptions = input<Record<string, string> | null>(null);
readonly episodeClicked = output<XtreamSerieEpisode>();
readonly episodeDownloadRequested = output<XtreamSerieEpisode>();
@@ -107,7 +105,81 @@ export class SeasonContainerComponent implements OnInit, DoCheck {
readonly isElectron = this.downloadsService.isAvailable;
readonly viewMode = signal<EpisodeViewMode>('grid');
selectedSeason: string | undefined;
readonly sortedSeasonKeys = computed(() =>
Object.keys(this.seasons()).sort((a, b) => Number(a) - Number(b))
);
readonly episodeCounts = computed(() => {
const counts: Record<string, number> = {};
for (const [key, episodes] of Object.entries(this.seasons())) {
counts[key] = episodes?.length ?? 0;
}
return counts;
});
readonly watchedCounts = computed(() => {
const counts: Record<string, number> = {};
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<string | undefined>(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;
}
}
@@ -0,0 +1,78 @@
<div class="season-tabs" role="tablist">
@if (useDropdown()) {
<button
class="season-tabs__dropdown"
data-testid="season-dropdown"
[matMenuTriggerFor]="seasonMenu"
type="button"
>
<span>
{{
'PORTALS.SEASON_TAB'
| translate: { number: selectedSeason() ?? '—' }
}}
</span>
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-menu #seasonMenu="matMenu">
@for (seasonKey of seasonKeys(); track seasonKey) {
<button
mat-menu-item
[class.season-tabs__menu-item--selected]="
seasonKey === selectedSeason()
"
(click)="selectSeason(seasonKey)"
>
<span>
{{
'PORTALS.SEASON_TAB'
| translate: { number: seasonKey }
}}
</span>
<span class="season-tabs__menu-count">
{{ episodeCounts()[seasonKey] ?? 0 }}
</span>
@if (isSeasonCompleted(seasonKey)) {
<mat-icon class="season-tabs__done">check</mat-icon>
}
</button>
}
</mat-menu>
} @else {
@for (seasonKey of seasonKeys(); track seasonKey) {
<button
class="season-tabs__pill"
type="button"
role="tab"
[attr.aria-selected]="seasonKey === selectedSeason()"
[class.season-tabs__pill--active]="
seasonKey === selectedSeason()
"
(click)="selectSeason(seasonKey)"
>
{{ 'PORTALS.SEASON_TAB' | translate: { number: seasonKey } }}
@if (isSeasonCompleted(seasonKey)) {
<mat-icon class="season-tabs__done">check</mat-icon>
}
</button>
}
}
@if (showBackToPlaying()) {
<button
class="season-tabs__back-to-playing"
data-testid="back-to-playing"
type="button"
(click)="backToPlayingRequested.emit()"
>
<mat-icon>play_circle</mat-icon>
{{ 'PORTALS.BACK_TO_PLAYING_EPISODE' | translate }}
</button>
}
</div>
@if (seasonDescription(); as description) {
<p class="season-tabs__description" data-testid="season-description">
{{ description }}
</p>
}
@@ -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;
}
@@ -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<string[]>();
readonly selectedSeason = input<string | undefined>(undefined);
readonly episodeCounts = input<Record<string, number>>({});
readonly watchedCounts = input<Record<string, number>>({});
/** Description of the selected season (TMDB/provider), if available. */
readonly seasonDescription = input<string | null>(null);
/** Season key of the episode currently playing inline, if any. */
readonly playingSeasonKey = input<string | null>(null);
readonly seasonSelected = output<string>();
readonly backToPlayingRequested = output<void>();
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);
}
}
}