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 87d73bda6..e93d8f2a3 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
@@ -9,7 +9,7 @@
}
- @if (selectedSeason) {
+ @if (selectedSeason && !showSeasonEmptyState()) {
+ } @else if (showSeriesEmptyState()) {
+
+
+ video_library
+
+
No episodes available
+
+ This series is listed by your provider, but no seasons or
+ episodes are available to play.
+
+
} @else if (!selectedSeason) {
@@ -62,6 +73,17 @@
}
+ } @else if (showSeasonEmptyState()) {
+
+
+ video_library
+
+
This season is empty
+
+ This season is listed by your provider, but no playable
+ episodes are available.
+
+
} @else {
@if (viewMode() === 'grid') {
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 1190ee00e..ae3cf447e 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
@@ -112,6 +112,64 @@
width: 100%;
}
+.empty-state-panel {
+ display: grid;
+ justify-items: center;
+ gap: 14px;
+ width: min(100%, 520px);
+ margin: 0 auto;
+ padding: 32px 28px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 16px;
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03)),
+ radial-gradient(circle at top, rgba(255, 255, 255, 0.06), transparent 60%);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.04),
+ 0 18px 40px rgba(0, 0, 0, 0.22);
+ text-align: center;
+
+ &__icon {
+ display: grid;
+ place-items: center;
+ width: 64px;
+ height: 64px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: rgba(255, 255, 255, 0.52);
+
+ mat-icon {
+ font-size: 30px;
+ width: 30px;
+ height: 30px;
+ }
+ }
+
+ &__title {
+ margin: 0;
+ color: var(--text-primary);
+ font-size: 1.125rem;
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ }
+
+ &__message {
+ max-width: 40ch;
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 0.9375rem;
+ line-height: 1.6;
+ }
+
+ &--season {
+ border-color: rgba(255, 255, 255, 0.1);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04)),
+ radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 65%);
+ }
+}
+
// Grid Layouts
.seasons-grid,
.episodes-grid {
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
new file mode 100644
index 000000000..5f5e8d61e
--- /dev/null
+++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts
@@ -0,0 +1,162 @@
+import { signal } from '@angular/core';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import { XtreamSerieEpisode } from 'shared-interfaces';
+import { DownloadsService } from 'services';
+import { SeasonContainerComponent } from './season-container.component';
+
+const downloadsServiceStub = {
+ isAvailable: signal(false),
+ downloads: () => [],
+ startDownload: async () => undefined,
+ isDownloaded: () => false,
+ isDownloading: () => false,
+ getDownloadedFilePath: () => '',
+ playDownload: async () => undefined,
+};
+
+function createEpisode(
+ overrides: Partial = {}
+): XtreamSerieEpisode {
+ return {
+ id: '101',
+ episode_num: 1,
+ title: 'Pilot',
+ container_extension: 'mp4',
+ info: {
+ duration: '45 min',
+ plot: 'Pilot episode',
+ movie_image: 'https://example.com/poster.jpg',
+ },
+ custom_sid: '',
+ added: '',
+ season: 1,
+ direct_source: '',
+ ...overrides,
+ };
+}
+
+describe('SeasonContainerComponent', () => {
+ let fixture: ComponentFixture;
+ let component: SeasonContainerComponent;
+
+ const setRequiredInputs = (
+ seasons: Record,
+ isLoading = false
+ ) => {
+ fixture.componentRef.setInput('seasons', seasons);
+ fixture.componentRef.setInput('seriesId', 1);
+ fixture.componentRef.setInput('playlistId', 'playlist-1');
+ fixture.componentRef.setInput('isLoading', isLoading);
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ NoopAnimationsModule,
+ SeasonContainerComponent,
+ TranslateModule.forRoot(),
+ ],
+ providers: [
+ {
+ provide: DownloadsService,
+ useValue: downloadsServiceStub,
+ },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SeasonContainerComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('renders the series-level placeholder when no seasons are available', () => {
+ setRequiredInputs({});
+ fixture.detectChanges();
+
+ expect(
+ fixture.nativeElement.querySelector('.empty-state-panel')
+ ).not.toBeNull();
+ expect(fixture.nativeElement.textContent).toContain(
+ '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();
+ });
+
+ it('renders the loading state instead of the placeholder while seasons are loading', () => {
+ setRequiredInputs({}, true);
+ fixture.detectChanges();
+
+ expect(
+ 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();
+
+ setRequiredInputs({
+ '1': [episode],
+ '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(
+ fixture.nativeElement.querySelectorAll('.episode-card').length
+ ).toBe(1);
+ expect(
+ fixture.nativeElement.querySelector('.empty-state-panel')
+ ).toBeNull();
+ });
+
+ it('clears a stale selected season when the input seasons change', () => {
+ const episode = createEpisode();
+
+ setRequiredInputs({ '1': [episode] });
+ fixture.detectChanges();
+
+ fixture.nativeElement.querySelector('.season-card').click();
+ fixture.detectChanges();
+
+ fixture.componentRef.setInput('seasons', { '2': [episode] });
+ fixture.detectChanges();
+
+ expect(component.selectedSeason).toBeUndefined();
+ expect(
+ fixture.nativeElement.querySelector('.season-card__number')
+ ?.textContent
+ ).toContain('2');
+ });
+});
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 e31c91b38..57eef87fa 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,6 +1,7 @@
import { KeyValuePipe } from '@angular/common';
import {
Component,
+ DoCheck,
OnInit,
inject,
input,
@@ -78,9 +79,10 @@ function parseDuration(duration: string | number | undefined): number {
WatchedBadgeComponent,
],
})
-export class SeasonContainerComponent implements OnInit {
+export class SeasonContainerComponent implements OnInit, DoCheck {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('SeasonContainer');
+ private previousSeasonKeysSignature = '';
readonly seasons = input.required>();
readonly seriesId = input.required();
@@ -112,6 +114,11 @@ export class SeasonContainerComponent implements OnInit {
if (savedMode === 'grid' || savedMode === 'list') {
this.viewMode.set(savedMode);
}
+ this.previousSeasonKeysSignature = this.getSeasonKeys().join('|');
+ }
+
+ ngDoCheck(): void {
+ this.syncSelectedSeason();
}
setViewMode(mode: EpisodeViewMode) {
@@ -123,6 +130,18 @@ export class SeasonContainerComponent implements OnInit {
return Number(a.key) - Number(b.key);
}
+ hasSeasons(): boolean {
+ return this.getSeasonKeys().length > 0;
+ }
+
+ showSeriesEmptyState(): boolean {
+ return !this.selectedSeason && !this.hasSeasons();
+ }
+
+ showSeasonEmptyState(): boolean {
+ return Boolean(this.selectedSeason) && !this.selectedSeasonHasEpisodes();
+ }
+
selectSeason(seasonKey: string) {
this.selectedSeason = seasonKey;
this.seasonSelected.emit(seasonKey);
@@ -384,6 +403,35 @@ export class SeasonContainerComponent implements OnInit {
return this.playbackPositions().get(this.getEpisodeContentId(episode));
}
+ private selectedSeasonHasEpisodes(): boolean {
+ if (!this.selectedSeason) {
+ return false;
+ }
+
+ return (this.seasons()[this.selectedSeason]?.length ?? 0) > 0;
+ }
+
+ 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;
+ }
+
+ this.previousSeasonKeysSignature = seasonKeysSignature;
+
+ if (this.selectedSeason && !this.hasSeasonKey(this.selectedSeason)) {
+ this.selectedSeason = undefined;
+ }
+ }
+
private hashString(str: string): number {
let hash = 0;
for (let index = 0; index < str.length; index++) {