mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
7fcef2341c
commit
d05ee9eef7
30 files changed
+537
-16
No files matched your search
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "هذا المسلسل مدرج لدى مزودك، لكن لا توجد مواسم أو حلقات متاحة للتشغيل.",
|
||||
"SEASON_EMPTY": "هذا الموسم فارغ",
|
||||
"SEASON_EMPTY_MESSAGE": "هذا الموسم مدرج لدى مزودك، لكن لا توجد حلقات قابلة للتشغيل متاحة.",
|
||||
"EPISODE_INFO": "تفاصيل الحلقة",
|
||||
"SELECT_CATEGORY": "اختر فئة",
|
||||
"PAGE": "الصفحة",
|
||||
"CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "هاد السلسلة مذكورة عند المزود ديالك، ولكن ما كاينش مواسم ولا حلقات متوفرة للتشغيل.",
|
||||
"SEASON_EMPTY": "هاد الموسم خاوي",
|
||||
"SEASON_EMPTY_MESSAGE": "هاد الموسم مذكور عند المزود ديالك، ولكن ما كاينش حلقات متوفرة للتشغيل.",
|
||||
"EPISODE_INFO": "تفاصيل الحلقة",
|
||||
"SELECT_CATEGORY": "اختار فئة",
|
||||
"PAGE": "الصفحة",
|
||||
"CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Гэты серыял пазначаны вашым правайдэрам, але сезоны або эпізоды для прайгравання недаступныя.",
|
||||
"SEASON_EMPTY": "Гэты сезон пусты",
|
||||
"SEASON_EMPTY_MESSAGE": "Гэты сезон пазначаны вашым правайдэрам, але даступных для прайгравання эпізодаў няма.",
|
||||
"EPISODE_INFO": "Падрабязнасці эпізода",
|
||||
"SELECT_CATEGORY": "Выберыце катэгорыю",
|
||||
"PAGE": "Старонка",
|
||||
"CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Αυτή η σειρά είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμες σεζόν ή επεισόδια για αναπαραγωγή.",
|
||||
"SEASON_EMPTY": "Αυτή η σεζόν είναι κενή",
|
||||
"SEASON_EMPTY_MESSAGE": "Αυτή η σεζόν είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμα επεισόδια για αναπαραγωγή.",
|
||||
"EPISODE_INFO": "Λεπτομέρειες επεισοδίου",
|
||||
"SELECT_CATEGORY": "Επιλέξτε μια κατηγορία",
|
||||
"PAGE": "Σελίδα",
|
||||
"CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "このシリーズはプロバイダーに登録されていますが、再生可能なシーズンやエピソードはありません。",
|
||||
"SEASON_EMPTY": "このシーズンにはエピソードがありません",
|
||||
"SEASON_EMPTY_MESSAGE": "このシーズンはプロバイダーに登録されていますが、再生可能なエピソードはありません。",
|
||||
"EPISODE_INFO": "エピソードの詳細",
|
||||
"SELECT_CATEGORY": "カテゴリーを選択",
|
||||
"PAGE": "ページ",
|
||||
"CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "이 시리즈는 공급자 목록에 있지만 재생 가능한 시즌이나 에피소드가 없습니다.",
|
||||
"SEASON_EMPTY": "이 시즌은 비어 있습니다",
|
||||
"SEASON_EMPTY_MESSAGE": "이 시즌은 공급자 목록에 있지만 재생 가능한 에피소드가 없습니다.",
|
||||
"EPISODE_INFO": "에피소드 정보",
|
||||
"SELECT_CATEGORY": "카테고리를 선택하세요",
|
||||
"PAGE": "페이지",
|
||||
"CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "Этот сериал есть в списке вашего провайдера, но доступных для воспроизведения сезонов или эпизодов нет.",
|
||||
"SEASON_EMPTY": "Этот сезон пуст",
|
||||
"SEASON_EMPTY_MESSAGE": "Этот сезон есть в списке вашего провайдера, но доступных для воспроизведения эпизодов нет.",
|
||||
"EPISODE_INFO": "Детали эпизода",
|
||||
"SELECT_CATEGORY": "Выберите категорию",
|
||||
"PAGE": "Страница",
|
||||
"CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "您的提供商列出了此剧集,但没有可播放的季或集。",
|
||||
"SEASON_EMPTY": "本季暂无内容",
|
||||
"SEASON_EMPTY_MESSAGE": "您的提供商列出了本季,但没有可播放的剧集。",
|
||||
"EPISODE_INFO": "剧集详情",
|
||||
"SELECT_CATEGORY": "选择一个分类",
|
||||
"PAGE": "页",
|
||||
"CONTENT_NOT_AVAILABLE": "此服务器上没有此内容",
|
||||
|
||||
@@ -931,6 +931,7 @@
|
||||
"NO_EPISODES_AVAILABLE_MESSAGE": "此供應商列出了這部影集,但目前沒有可播放的季數或集數。",
|
||||
"SEASON_EMPTY": "此季暫無內容",
|
||||
"SEASON_EMPTY_MESSAGE": "此供應商列出了這一季,但目前沒有可播放的集數。",
|
||||
"EPISODE_INFO": "集數詳情",
|
||||
"SELECT_CATEGORY": "選擇類別",
|
||||
"PAGE": "頁",
|
||||
"CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user