From 6e6bd04eca4f626bfb4b85594ce7e434f60828d7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 17 Apr 2026 00:11:14 +0200 Subject: [PATCH] style(season-container): enhance empty state handling and add corresponding styles Entire-Checkpoint: c6e522b4276c --- .../season-container.component.html | 24 ++- .../season-container.component.scss | 58 +++++++ .../season-container.component.spec.ts | 162 ++++++++++++++++++ .../season-container.component.ts | 50 +++++- 4 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 libs/ui/components/src/lib/season-container/season-container.component.spec.ts 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()) { +
+ +

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()) { +
+ +

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++) {