feat(ui): expandable season description and episode info dialog

Season descriptions get the same show more/less toggle as the hero
(new shared ExpandableTextComponent); episode cards and list rows get
an info button that opens a dialog with the full plot, duration, air
date and a Play action — card heights stay fixed and click-to-play
semantics stay intact. Also: drop the per-frame backdrop blur on the
player card and make the watch-enter scroll instant so the hero morph
stops competing for frame budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 19:56:03 +02:00
1 parent 7fcef2341c
commit d05ee9eef7
30 files changed
+537 -16

No files matched your search

+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "هذا المسلسل مدرج لدى مزودك، لكن لا توجد مواسم أو حلقات متاحة للتشغيل.",
"SEASON_EMPTY": "هذا الموسم فارغ",
"SEASON_EMPTY_MESSAGE": "هذا الموسم مدرج لدى مزودك، لكن لا توجد حلقات قابلة للتشغيل متاحة.",
"EPISODE_INFO": "تفاصيل الحلقة",
"SELECT_CATEGORY": "اختر فئة",
"PAGE": "الصفحة",
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "هاد السلسلة مذكورة عند المزود ديالك، ولكن ما كاينش مواسم ولا حلقات متوفرة للتشغيل.",
"SEASON_EMPTY": "هاد الموسم خاوي",
"SEASON_EMPTY_MESSAGE": "هاد الموسم مذكور عند المزود ديالك، ولكن ما كاينش حلقات متوفرة للتشغيل.",
"EPISODE_INFO": "تفاصيل الحلقة",
"SELECT_CATEGORY": "اختار فئة",
"PAGE": "الصفحة",
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Гэты серыял пазначаны вашым правайдэрам, але сезоны або эпізоды для прайгравання недаступныя.",
"SEASON_EMPTY": "Гэты сезон пусты",
"SEASON_EMPTY_MESSAGE": "Гэты сезон пазначаны вашым правайдэрам, але даступных для прайгравання эпізодаў няма.",
"EPISODE_INFO": "Падрабязнасці эпізода",
"SELECT_CATEGORY": "Выберыце катэгорыю",
"PAGE": "Старонка",
"CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Diese Serie wird von Ihrem Anbieter gelistet, es sind jedoch keine Staffeln oder Folgen zum Abspielen verfügbar.",
"SEASON_EMPTY": "Diese Staffel ist leer",
"SEASON_EMPTY_MESSAGE": "Diese Staffel wird von Ihrem Anbieter gelistet, es sind jedoch keine abspielbaren Folgen verfügbar.",
"EPISODE_INFO": "Details zur Folge",
"SELECT_CATEGORY": "Wählen Sie eine Kategorie aus",
"PAGE": "Seite",
"CONTENT_NOT_AVAILABLE": "Dieser Inhalt ist auf diesem Server nicht verfügbar",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Αυτή η σειρά είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμες σεζόν ή επεισόδια για αναπαραγωγή.",
"SEASON_EMPTY": "Αυτή η σεζόν είναι κενή",
"SEASON_EMPTY_MESSAGE": "Αυτή η σεζόν είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμα επεισόδια για αναπαραγωγή.",
"EPISODE_INFO": "Λεπτομέρειες επεισοδίου",
"SELECT_CATEGORY": "Επιλέξτε μια κατηγορία",
"PAGE": "Σελίδα",
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
+1
View File
@@ -931,6 +931,7 @@
"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.",
"EPISODE_INFO": "Episode details",
"SELECT_CATEGORY": "Select a category",
"PAGE": "Page",
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Tu proveedor incluye esta serie en el listado, pero no hay temporadas ni episodios disponibles para reproducir.",
"SEASON_EMPTY": "Esta temporada está vacía",
"SEASON_EMPTY_MESSAGE": "Tu proveedor incluye esta temporada en el listado, pero no hay episodios reproducibles disponibles.",
"EPISODE_INFO": "Detalles del episodio",
"SELECT_CATEGORY": "Selecciona una categoría",
"PAGE": "Página",
"CONTENT_NOT_AVAILABLE": "Este contenido no está disponible en este servidor",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Cette série est répertoriée par votre fournisseur, mais aucune saison ni épisode n'est disponible à la lecture.",
"SEASON_EMPTY": "Cette saison est vide",
"SEASON_EMPTY_MESSAGE": "Cette saison est répertoriée par votre fournisseur, mais aucun épisode lisible n'est disponible.",
"EPISODE_INFO": "Détails de l'épisode",
"SELECT_CATEGORY": "Sélectionner une catégorie",
"PAGE": "Page",
"CONTENT_NOT_AVAILABLE": "Ce contenu n'est pas disponible sur ce serveur",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Questa serie è elencata dal tuo fornitore, ma non sono disponibili stagioni o episodi da riprodurre.",
"SEASON_EMPTY": "Questa stagione è vuota",
"SEASON_EMPTY_MESSAGE": "Questa stagione è elencata dal tuo fornitore, ma non sono disponibili episodi riproducibili.",
"EPISODE_INFO": "Dettagli episodio",
"SELECT_CATEGORY": "Seleziona una categoria",
"PAGE": "Pagina",
"CONTENT_NOT_AVAILABLE": "Questo contenuto non è disponibile su questo server",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "このシリーズはプロバイダーに登録されていますが、再生可能なシーズンやエピソードはありません。",
"SEASON_EMPTY": "このシーズンにはエピソードがありません",
"SEASON_EMPTY_MESSAGE": "このシーズンはプロバイダーに登録されていますが、再生可能なエピソードはありません。",
"EPISODE_INFO": "エピソードの詳細",
"SELECT_CATEGORY": "カテゴリーを選択",
"PAGE": "ページ",
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "이 시리즈는 공급자 목록에 있지만 재생 가능한 시즌이나 에피소드가 없습니다.",
"SEASON_EMPTY": "이 시즌은 비어 있습니다",
"SEASON_EMPTY_MESSAGE": "이 시즌은 공급자 목록에 있지만 재생 가능한 에피소드가 없습니다.",
"EPISODE_INFO": "에피소드 정보",
"SELECT_CATEGORY": "카테고리를 선택하세요",
"PAGE": "페이지",
"CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Deze serie staat in de lijst van je provider, maar er zijn geen seizoenen of afleveringen beschikbaar om af te spelen.",
"SEASON_EMPTY": "Dit seizoen is leeg",
"SEASON_EMPTY_MESSAGE": "Dit seizoen staat in de lijst van je provider, maar er zijn geen afspeelbare afleveringen beschikbaar.",
"EPISODE_INFO": "Afleveringsdetails",
"SELECT_CATEGORY": "Selecteer een categorie",
"PAGE": "Pagina",
"CONTENT_NOT_AVAILABLE": "Deze inhoud is niet beschikbaar op deze server",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Ten serial jest dostępny u Twojego dostawcy, ale nie ma sezonów ani odcinków do odtworzenia.",
"SEASON_EMPTY": "Ten sezon jest pusty",
"SEASON_EMPTY_MESSAGE": "Ten sezon jest dostępny u Twojego dostawcy, ale nie ma odcinków do odtworzenia.",
"EPISODE_INFO": "Szczegóły odcinka",
"SELECT_CATEGORY": "Wybierz kategorię",
"PAGE": "Strona",
"CONTENT_NOT_AVAILABLE": "Ta treść jest niedostępna na tym serwerze",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Esta série está listada pelo seu provedor, mas não há temporadas ou episódios disponíveis para reprodução.",
"SEASON_EMPTY": "Esta temporada está vazia",
"SEASON_EMPTY_MESSAGE": "Esta temporada está listada pelo seu provedor, mas não há episódios reproduzíveis disponíveis.",
"EPISODE_INFO": "Detalhes do episódio",
"SELECT_CATEGORY": "Selecione uma categoria",
"PAGE": "Página",
"CONTENT_NOT_AVAILABLE": "Este conteúdo não está disponível neste servidor",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Этот сериал есть в списке вашего провайдера, но доступных для воспроизведения сезонов или эпизодов нет.",
"SEASON_EMPTY": "Этот сезон пуст",
"SEASON_EMPTY_MESSAGE": "Этот сезон есть в списке вашего провайдера, но доступных для воспроизведения эпизодов нет.",
"EPISODE_INFO": "Детали эпизода",
"SELECT_CATEGORY": "Выберите категорию",
"PAGE": "Страница",
"CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "Bu dizi sağlayıcınız tarafından listeleniyor, ancak oynatılabilecek sezon veya bölüm bulunmuyor.",
"SEASON_EMPTY": "Bu sezon boş",
"SEASON_EMPTY_MESSAGE": "Bu sezon sağlayıcınız tarafından listeleniyor, ancak oynatılabilir bölüm bulunmuyor.",
"EPISODE_INFO": "Bölüm ayrıntıları",
"SELECT_CATEGORY": "Bir kategori seçin",
"PAGE": "Sayfa",
"CONTENT_NOT_AVAILABLE": "Bu içerik bu sunucuda mevcut değil",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "您的提供商列出了此剧集,但没有可播放的季或集。",
"SEASON_EMPTY": "本季暂无内容",
"SEASON_EMPTY_MESSAGE": "您的提供商列出了本季,但没有可播放的剧集。",
"EPISODE_INFO": "剧集详情",
"SELECT_CATEGORY": "选择一个分类",
"PAGE": "页",
"CONTENT_NOT_AVAILABLE": "此服务器上没有此内容",
+1
View File
@@ -931,6 +931,7 @@
"NO_EPISODES_AVAILABLE_MESSAGE": "此供應商列出了這部影集,但目前沒有可播放的季數或集數。",
"SEASON_EMPTY": "此季暫無內容",
"SEASON_EMPTY_MESSAGE": "此供應商列出了這一季,但目前沒有可播放的集數。",
"EPISODE_INFO": "集數詳情",
"SELECT_CATEGORY": "選擇類別",
"PAGE": "頁",
"CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容",
+1
View File
@@ -7,6 +7,7 @@ export * from './lib/channel-list-loading-state/channel-list-loading-state.compo
export * from './lib/confirm-dialog/confirm-dialog.component';
export * from './lib/confirm-dialog/dialog.service';
export * from './lib/content-hero/content-hero.component';
export * from './lib/expandable-text/expandable-text.component';
export * from './lib/external-playback-dock/external-playback-dock.component';
export * from './lib/portal-detail-shell/content-about.component';
export * from './lib/portal-detail-shell/detail-template.directives';
@@ -0,0 +1,74 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { ExpandableTextComponent } from './expandable-text.component';
describe('ExpandableTextComponent', () => {
let fixture: ComponentFixture<ExpandableTextComponent>;
const originalResizeObserver = globalThis.ResizeObserver;
afterEach(() => {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: originalResizeObserver,
});
});
beforeEach(async () => {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: undefined,
});
await TestBed.configureTestingModule({
imports: [ExpandableTextComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(ExpandableTextComponent);
fixture.componentRef.setInput('text', 'Some long description');
});
it('renders the text without a toggle when it does not overflow', () => {
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(host.textContent).toContain('Some long description');
// JSDOM reports zero heights, so no overflow is detected
expect(host.querySelector('.expandable-text__toggle')).toBeNull();
});
it('shows the toggle when the text overflows and expands on click', () => {
fixture.detectChanges();
// Simulate clamped overflow (JSDOM reports zero heights by default)
// and retrigger the measuring effect via a text change.
const paragraph = (
fixture.nativeElement as HTMLElement
).querySelector('.expandable-text') as HTMLElement;
Object.defineProperty(paragraph, 'scrollHeight', {
value: 120,
configurable: true,
});
Object.defineProperty(paragraph, 'clientHeight', {
value: 48,
configurable: true,
});
fixture.componentRef.setInput('text', 'Some even longer description');
fixture.detectChanges();
const toggle = (fixture.nativeElement as HTMLElement).querySelector(
'.expandable-text__toggle'
) as HTMLButtonElement;
expect(toggle).toBeTruthy();
expect(toggle.textContent).toContain('SHOW_MORE');
toggle.click();
fixture.detectChanges();
expect(fixture.componentInstance.isExpanded()).toBe(true);
expect(
(fixture.nativeElement as HTMLElement).textContent
).toContain('SHOW_LESS');
});
});
@@ -0,0 +1,143 @@
import {
Component,
DestroyRef,
ElementRef,
effect,
inject,
input,
signal,
viewChild,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
/**
* Line-clamped text with a "Show more / Show less" toggle that appears only
* when the text actually overflows the clamp (same pattern as the hero
* description in ContentHeroComponent).
*/
@Component({
selector: 'app-expandable-text',
standalone: true,
imports: [MatIconModule, TranslateModule],
template: `
<p
#textEl
class="expandable-text"
[class.expandable-text--expanded]="isExpanded()"
[style.-webkit-line-clamp]="isExpanded() ? 'unset' : clampLines()"
>
{{ text() }}
</p>
@if (hasOverflow() || isExpanded()) {
<button
type="button"
class="expandable-text__toggle"
(click)="toggle()"
[attr.aria-expanded]="isExpanded()"
>
<span>{{
(isExpanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate
}}</span>
<mat-icon aria-hidden="true">
{{ isExpanded() ? 'expand_less' : 'expand_more' }}
</mat-icon>
</button>
}
`,
styles: [
`
:host {
display: block;
min-width: 0;
}
.expandable-text {
margin: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
&--expanded {
display: block;
overflow: visible;
}
}
.expandable-text__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 2px;
padding: 4px 0;
background: transparent;
border: 0;
cursor: pointer;
color: inherit;
opacity: 0.75;
font: inherit;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
&:hover {
opacity: 1;
}
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
line-height: 16px;
}
}
`,
],
})
export class ExpandableTextComponent {
private readonly destroyRef = inject(DestroyRef);
readonly text = input.required<string>();
readonly clampLines = input(3);
readonly isExpanded = signal(false);
readonly hasOverflow = signal(false);
private readonly textEl = viewChild<ElementRef<HTMLElement>>('textEl');
private resizeObserver?: ResizeObserver;
constructor() {
effect(() => {
this.text();
const el = this.textEl()?.nativeElement;
if (!el) return;
this.measureOverflow(el);
this.observeOverflow(el);
});
this.destroyRef.onDestroy(() => this.resizeObserver?.disconnect());
}
toggle(): void {
this.isExpanded.update((value) => !value);
}
private measureOverflow(el: HTMLElement): void {
if (this.isExpanded()) return;
this.hasOverflow.set(el.scrollHeight > el.clientHeight + 1);
}
private observeOverflow(el: HTMLElement): void {
this.resizeObserver?.disconnect();
if (typeof ResizeObserver === 'undefined') {
this.measureOverflow(el);
return;
}
this.resizeObserver = new ResizeObserver(() =>
this.measureOverflow(el)
);
this.resizeObserver.observe(el);
}
}
@@ -94,16 +94,14 @@ export class PortalDetailShellComponent {
private scrollToTop(): void {
const element = this.host.nativeElement;
// Instant jump: a smooth scroll would run concurrently with the
// 300ms hero-collapse morph (a layout animation) and the two
// animations fight for frame budget. The morph visually covers the
// jump anyway.
if (typeof element.scrollTo !== 'function') {
element.scrollTop = 0;
return;
}
const reducedMotion =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
element.scrollTo({
top: 0,
behavior: reducedMotion ? 'auto' : 'smooth',
});
element.scrollTo({ top: 0, behavior: 'auto' });
}
}
@@ -0,0 +1,180 @@
import { Component, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
export interface EpisodeInfoDialogData {
/** e.g. "S01E05" */
episodeLabel: string;
title: string;
plot?: string;
duration?: string;
airDate?: string;
thumbnailUrl?: string;
rating?: string | number;
}
export function buildEpisodeInfoDialogData(
episode: XtreamSerieEpisode,
fallbackSeasonKey: string | undefined
): EpisodeInfoDialogData {
const info =
!episode.info || Array.isArray(episode.info)
? undefined
: episode.info;
const seasonNumber = Number(episode.season || fallbackSeasonKey || 1);
const episodeNumber = Number(episode.episode_num || 1);
return {
episodeLabel: `S${String(seasonNumber).padStart(2, '0')}E${String(
episodeNumber
).padStart(2, '0')}`,
title: episode.title,
plot: info?.plot || undefined,
duration: info?.duration || undefined,
airDate: info?.releasedate || undefined,
thumbnailUrl: info?.movie_image || undefined,
rating: info?.rating || undefined,
};
}
/** Result returned when the user chooses to play from the dialog. */
export const EPISODE_INFO_PLAY = 'play' as const;
/**
* Lightweight episode details dialog. Cards and list rows clamp the plot to
* keep fixed heights; the ℹ button opens this dialog with the full text.
*/
@Component({
selector: 'app-episode-info-dialog',
standalone: true,
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule],
template: `
<div class="episode-info">
@if (data.thumbnailUrl) {
<img
class="episode-info__thumb"
[src]="data.thumbnailUrl"
[alt]="data.title"
(error)="thumbError = true"
[style.display]="thumbError ? 'none' : ''"
/>
}
<div class="episode-info__body">
<div class="episode-info__eyebrow">
{{ data.episodeLabel }}
@if (data.duration) {
<span class="episode-info__chip">{{
data.duration
}}</span>
}
@if (data.airDate) {
<span class="episode-info__chip">{{
data.airDate
}}</span>
}
@if (data.rating) {
<span class="episode-info__chip"
>⭐ {{ data.rating }}</span
>
}
</div>
<h2 mat-dialog-title class="episode-info__title">
{{ data.title }}
</h2>
@if (data.plot) {
<p class="episode-info__plot">{{ data.plot }}</p>
}
</div>
</div>
<div mat-dialog-actions align="end">
<button mat-button mat-dialog-close type="button">
{{ 'CLOSE' | translate }}
</button>
<button
mat-flat-button
type="button"
data-testid="episode-info-play"
(click)="play()"
>
<mat-icon>play_arrow</mat-icon>
{{ 'XTREAM.PLAY' | translate }}
</button>
</div>
`,
styles: [
`
:host {
display: block;
max-width: 560px;
}
.episode-info__thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 12px 12px 0 0;
display: block;
}
.episode-info__body {
padding: 16px 24px 0;
}
.episode-info__eyebrow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.7;
}
.episode-info__chip {
font-weight: 500;
letter-spacing: normal;
text-transform: none;
}
.episode-info__title {
margin: 6px 0 0;
padding: 0;
font-size: 1.25rem;
line-height: 1.3;
overflow-wrap: anywhere;
&::before {
display: none;
}
}
.episode-info__plot {
margin: 10px 0 0;
font-size: 0.9375rem;
line-height: 1.6;
opacity: 0.85;
max-height: 50vh;
overflow-y: auto;
}
`,
],
})
export class EpisodeInfoDialogComponent {
readonly data: EpisodeInfoDialogData = inject(MAT_DIALOG_DATA);
private readonly dialogRef = inject(
MatDialogRef<EpisodeInfoDialogComponent>
);
thumbError = false;
play(): void {
this.dialogRef.close(EPISODE_INFO_PLAY);
}
}
@@ -188,6 +188,25 @@
</button>
}
}
@if (episodeInfo?.plot) {
<button
mat-icon-button
class="info-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo(
$event,
episode
)
"
[matTooltip]="
'PORTALS.EPISODE_INFO'
| translate
"
>
<mat-icon>info_outline</mat-icon>
</button>
}
<button
mat-icon-button
class="toggle-watched-btn"
@@ -391,6 +410,21 @@
</button>
}
}
@if (episodeInfo?.plot) {
<button
mat-icon-button
class="action-btn"
data-testid="episode-info-button"
(click)="
openEpisodeInfo($event, episode)
"
[matTooltip]="
'PORTALS.EPISODE_INFO' | translate
"
>
<mat-icon>info_outline</mat-icon>
</button>
}
<button
mat-icon-button
class="action-btn"
@@ -1,9 +1,12 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { DownloadsService } from '@iptvnator/services';
import { of } from 'rxjs';
import { EPISODE_INFO_PLAY } from './episode-info-dialog.component';
import { SeasonContainerComponent } from './season-container.component';
const downloadsServiceStub = {
@@ -37,6 +40,8 @@ function createEpisode(
};
}
const dialogOpen = jest.fn();
describe('SeasonContainerComponent', () => {
let fixture: ComponentFixture<SeasonContainerComponent>;
let component: SeasonContainerComponent;
@@ -64,12 +69,18 @@ describe('SeasonContainerComponent', () => {
provide: DownloadsService,
useValue: downloadsServiceStub,
},
{
provide: MatDialog,
useValue: { open: dialogOpen },
},
],
}).compileComponents();
fixture = TestBed.createComponent(SeasonContainerComponent);
component = fixture.componentInstance;
emittedSeasons = [];
dialogOpen.mockReset();
dialogOpen.mockReturnValue({ afterClosed: () => of(undefined) });
component.seasonSelected.subscribe((seasonKey) =>
emittedSeasons.push(seasonKey)
);
@@ -284,6 +295,41 @@ describe('SeasonContainerComponent', () => {
).not.toBeNull();
});
it('opens the episode info dialog and plays on the dialog play action', () => {
dialogOpen.mockReturnValue({
afterClosed: () => of(EPISODE_INFO_PLAY),
});
const played: XtreamSerieEpisode[] = [];
setRequiredInputs({ '1': [createEpisode()] });
component.episodeClicked.subscribe((episode) => played.push(episode));
fixture.detectChanges();
const infoButton = fixture.nativeElement.querySelector(
'[data-testid="episode-info-button"]'
) as HTMLButtonElement;
expect(infoButton).toBeTruthy();
infoButton.click();
expect(dialogOpen).toHaveBeenCalledTimes(1);
const data = dialogOpen.mock.calls[0][1].data;
expect(data.episodeLabel).toBe('S01E01');
expect(data.plot).toBe('Pilot episode');
expect(played.length).toBe(1);
});
it('hides the episode info button when the episode has no plot', () => {
setRequiredInputs({
'1': [createEpisode({ info: { duration: '45 min' } as never })],
});
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-testid="episode-info-button"]'
)
).toBeNull();
});
it('renders the season description for the selected season', () => {
fixture.componentRef.setInput('seasonDescriptions', {
'1': 'Season one overview',
@@ -13,6 +13,7 @@ import {
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -36,6 +37,11 @@ import {
getEpisodeDownloadId,
isStalkerEpisode,
} from './episode-download.util';
import {
EPISODE_INFO_PLAY,
EpisodeInfoDialogComponent,
buildEpisodeInfoDialogData,
} from './episode-info-dialog.component';
import {
formatEpisodePositionText,
parseDuration,
@@ -75,6 +81,7 @@ export interface SeasonContainerPlaybackToggleRequest {
})
export class SeasonContainerComponent implements OnInit {
private readonly downloadsService = inject(DownloadsService);
private readonly dialog = inject(MatDialog);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly logger = createLogger('SeasonContainer');
private lastEmittedSeason: string | undefined;
@@ -239,6 +246,24 @@ export class SeasonContainerComponent implements OnInit {
this.episodeClicked.emit(episode);
}
openEpisodeInfo(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
this.dialog
.open(EpisodeInfoDialogComponent, {
data: buildEpisodeInfoDialogData(
episode,
this.selectedSeason()
),
autoFocus: false,
})
.afterClosed()
.subscribe((result) => {
if (result === EPISODE_INFO_PLAY) {
this.selectEpisode(episode);
}
});
}
toggleWatched(event: Event, episode: XtreamSerieEpisode) {
event.stopPropagation();
if (!this.playlistId()) {
@@ -72,7 +72,10 @@
</div>
@if (seasonDescription(); as description) {
<p class="season-tabs__description" data-testid="season-description">
{{ description }}
</p>
<app-expandable-text
class="season-tabs__description"
data-testid="season-description"
[text]="description"
[clampLines]="3"
/>
}
@@ -102,8 +102,5 @@
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;
// Clamping + the show more/less toggle live in app-expandable-text.
}
@@ -8,6 +8,7 @@ import {
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { TranslateModule } from '@ngx-translate/core';
import { ExpandableTextComponent } from '../expandable-text/expandable-text.component';
/** Above this count the pill row becomes a dropdown selector. */
const MAX_SEASON_PILLS = 6;
@@ -23,7 +24,7 @@ const MAX_SEASON_PILLS = 6;
templateUrl: './season-tabs.component.html',
styleUrls: ['./season-tabs.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, MatMenuModule, TranslateModule],
imports: [ExpandableTextComponent, MatIcon, MatMenuModule, TranslateModule],
})
export class SeasonTabsComponent {
/** Season keys, already sorted in display order. */
@@ -16,7 +16,8 @@
box-shadow:
0 20px 56px rgba(25, 20, 20, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
backdrop-filter: blur(18px);
// No backdrop-filter here: blurring over a live video viewport forces an
// expensive re-blur every frame and makes the browse↔watch morph stutter.
}
.player-shell__header {