diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 627eb17a5..490ee6cf4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "هذا المسلسل مدرج لدى مزودك، لكن لا توجد مواسم أو حلقات متاحة للتشغيل.", "SEASON_EMPTY": "هذا الموسم فارغ", "SEASON_EMPTY_MESSAGE": "هذا الموسم مدرج لدى مزودك، لكن لا توجد حلقات قابلة للتشغيل متاحة.", + "EPISODE_INFO": "تفاصيل الحلقة", "SELECT_CATEGORY": "اختر فئة", "PAGE": "الصفحة", "CONTENT_NOT_AVAILABLE": "هذا المحتوى غير متاح على هذا الخادم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 10245b246..a3bd848c2 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "هاد السلسلة مذكورة عند المزود ديالك، ولكن ما كاينش مواسم ولا حلقات متوفرة للتشغيل.", "SEASON_EMPTY": "هاد الموسم خاوي", "SEASON_EMPTY_MESSAGE": "هاد الموسم مذكور عند المزود ديالك، ولكن ما كاينش حلقات متوفرة للتشغيل.", + "EPISODE_INFO": "تفاصيل الحلقة", "SELECT_CATEGORY": "اختار فئة", "PAGE": "الصفحة", "CONTENT_NOT_AVAILABLE": "هاد المحتوى ما متوفرش على هاد السيرفر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 00dcb5cd9..11216a53c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "Гэты серыял пазначаны вашым правайдэрам, але сезоны або эпізоды для прайгравання недаступныя.", "SEASON_EMPTY": "Гэты сезон пусты", "SEASON_EMPTY_MESSAGE": "Гэты сезон пазначаны вашым правайдэрам, але даступных для прайгравання эпізодаў няма.", + "EPISODE_INFO": "Падрабязнасці эпізода", "SELECT_CATEGORY": "Выберыце катэгорыю", "PAGE": "Старонка", "CONTENT_NOT_AVAILABLE": "Гэты кантэнт недаступны на дадзеным серверы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index d5d5a9a0a..61cea28cc 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 734508d32..b722c13fc 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "Αυτή η σειρά είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμες σεζόν ή επεισόδια για αναπαραγωγή.", "SEASON_EMPTY": "Αυτή η σεζόν είναι κενή", "SEASON_EMPTY_MESSAGE": "Αυτή η σεζόν είναι καταχωρημένη από τον πάροχό σας, αλλά δεν υπάρχουν διαθέσιμα επεισόδια για αναπαραγωγή.", + "EPISODE_INFO": "Λεπτομέρειες επεισοδίου", "SELECT_CATEGORY": "Επιλέξτε μια κατηγορία", "PAGE": "Σελίδα", "CONTENT_NOT_AVAILABLE": "Αυτό το περιεχόμενο δεν είναι διαθέσιμο σε αυτόν τον διακομιστή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 9aedbd5d3..321fddc75 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a5abfd3aa..e568ee152 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 8e8f8f923..a1facf90c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 485b85cb3..30e78d8e5 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 959774d64..190b21834 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "このシリーズはプロバイダーに登録されていますが、再生可能なシーズンやエピソードはありません。", "SEASON_EMPTY": "このシーズンにはエピソードがありません", "SEASON_EMPTY_MESSAGE": "このシーズンはプロバイダーに登録されていますが、再生可能なエピソードはありません。", + "EPISODE_INFO": "エピソードの詳細", "SELECT_CATEGORY": "カテゴリーを選択", "PAGE": "ページ", "CONTENT_NOT_AVAILABLE": "このコンテンツはこのサーバーでは利用できません", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index f63bdf092..b791eae01 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "이 시리즈는 공급자 목록에 있지만 재생 가능한 시즌이나 에피소드가 없습니다.", "SEASON_EMPTY": "이 시즌은 비어 있습니다", "SEASON_EMPTY_MESSAGE": "이 시즌은 공급자 목록에 있지만 재생 가능한 에피소드가 없습니다.", + "EPISODE_INFO": "에피소드 정보", "SELECT_CATEGORY": "카테고리를 선택하세요", "PAGE": "페이지", "CONTENT_NOT_AVAILABLE": "이 서버에서는 이 콘텐츠를 사용할 수 없습니다", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index e5dc3b616..53e624690 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index f9b7ae8ed..4e99f98fa 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index c5f7fc21a..7b6c33047 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 52834c97b..5471b144f 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "Этот сериал есть в списке вашего провайдера, но доступных для воспроизведения сезонов или эпизодов нет.", "SEASON_EMPTY": "Этот сезон пуст", "SEASON_EMPTY_MESSAGE": "Этот сезон есть в списке вашего провайдера, но доступных для воспроизведения эпизодов нет.", + "EPISODE_INFO": "Детали эпизода", "SELECT_CATEGORY": "Выберите категорию", "PAGE": "Страница", "CONTENT_NOT_AVAILABLE": "Этот контент недоступен на этом сервере", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 421320648..871cbc06f 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 1cc03bc0f..bfc62c44c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "您的提供商列出了此剧集,但没有可播放的季或集。", "SEASON_EMPTY": "本季暂无内容", "SEASON_EMPTY_MESSAGE": "您的提供商列出了本季,但没有可播放的剧集。", + "EPISODE_INFO": "剧集详情", "SELECT_CATEGORY": "选择一个分类", "PAGE": "页", "CONTENT_NOT_AVAILABLE": "此服务器上没有此内容", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 5f400c2a1..6ff40a3ec 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -931,6 +931,7 @@ "NO_EPISODES_AVAILABLE_MESSAGE": "此供應商列出了這部影集,但目前沒有可播放的季數或集數。", "SEASON_EMPTY": "此季暫無內容", "SEASON_EMPTY_MESSAGE": "此供應商列出了這一季,但目前沒有可播放的集數。", + "EPISODE_INFO": "集數詳情", "SELECT_CATEGORY": "選擇類別", "PAGE": "頁", "CONTENT_NOT_AVAILABLE": "此伺服器上未提供此內容", diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index ef4e8ecb9..a725b7ea6 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -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'; diff --git a/libs/ui/components/src/lib/expandable-text/expandable-text.component.spec.ts b/libs/ui/components/src/lib/expandable-text/expandable-text.component.spec.ts new file mode 100644 index 000000000..ec6440d3f --- /dev/null +++ b/libs/ui/components/src/lib/expandable-text/expandable-text.component.spec.ts @@ -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; + 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'); + }); +}); diff --git a/libs/ui/components/src/lib/expandable-text/expandable-text.component.ts b/libs/ui/components/src/lib/expandable-text/expandable-text.component.ts new file mode 100644 index 000000000..e74141241 --- /dev/null +++ b/libs/ui/components/src/lib/expandable-text/expandable-text.component.ts @@ -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: ` +

+ {{ text() }} +

+ @if (hasOverflow() || isExpanded()) { + + } + `, + 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(); + readonly clampLines = input(3); + + readonly isExpanded = signal(false); + readonly hasOverflow = signal(false); + + private readonly textEl = viewChild>('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); + } +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts index 6f841052c..1113b1cfe 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts @@ -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' }); } } diff --git a/libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts b/libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts new file mode 100644 index 000000000..b8815ad86 --- /dev/null +++ b/libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts @@ -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: ` +
+ @if (data.thumbnailUrl) { + + } +
+
+ {{ data.episodeLabel }} + @if (data.duration) { + {{ + data.duration + }} + } + @if (data.airDate) { + {{ + data.airDate + }} + } + @if (data.rating) { + ⭐ {{ data.rating }} + } +
+

+ {{ data.title }} +

+ @if (data.plot) { +

{{ data.plot }}

+ } +
+
+
+ + +
+ `, + 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 + ); + thumbError = false; + + play(): void { + this.dialogRef.close(EPISODE_INFO_PLAY); + } +} 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 c97e96484..9be4479a3 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 @@ -188,6 +188,25 @@ } } + @if (episodeInfo?.plot) { + + } + }