style(season-container): enhance empty state handling and add corresponding styles

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-17 00:11:14 +02:00
1 parent d6fd28450d
commit 6e6bd04eca
4 files changed
+292 -2

No files matched your search

@@ -9,7 +9,7 @@
</button>
}
</div>
@if (selectedSeason) {
@if (selectedSeason && !showSeasonEmptyState()) {
<div class="view-toggle">
<mat-button-toggle-group
[value]="viewMode()"
@@ -32,6 +32,17 @@
<div class="loading-container">
<mat-spinner diameter="40"></mat-spinner>
</div>
} @else if (showSeriesEmptyState()) {
<div class="empty-state-panel" role="status">
<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>
<p class="empty-state-panel__message">
This series is listed by your provider, but no seasons or
episodes are available to play.
</p>
</div>
} @else if (!selectedSeason) {
<!-- Seasons Grid -->
<div class="seasons-grid">
@@ -62,6 +73,17 @@
</button>
}
</div>
} @else if (showSeasonEmptyState()) {
<div class="empty-state-panel empty-state-panel--season" role="status">
<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>
<p class="empty-state-panel__message">
This season is listed by your provider, but no playable
episodes are available.
</p>
</div>
} @else {
@if (viewMode() === 'grid') {
<!-- Episodes Grid View -->
@@ -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 {
@@ -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> = {}
): 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<SeasonContainerComponent>;
let component: SeasonContainerComponent;
const setRequiredInputs = (
seasons: Record<string, XtreamSerieEpisode[]>,
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');
});
});
@@ -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<Record<string, XtreamSerieEpisode[]>>();
readonly seriesId = input.required<number>();
@@ -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++) {