diff --git a/.changes/ui-live-row-vertical-actions.md b/.changes/ui-live-row-vertical-actions.md new file mode 100644 index 000000000..763b73c07 --- /dev/null +++ b/.changes/ui-live-row-vertical-actions.md @@ -0,0 +1,11 @@ +--- +type: feature +area: ui +issues: [1128] +--- + +Live channel rows now stack their action buttons vertically — favorite on +top, programme info below — so the extra button costs no horizontal space. +The programme-info button is now also available in the Xtream and Stalker +channel sidebars, and its slot stays reserved while EPG data loads, so +buttons never jump. diff --git a/.changes/xtream-catchup-indicator.md b/.changes/xtream-catchup-indicator.md new file mode 100644 index 000000000..47461d763 --- /dev/null +++ b/.changes/xtream-catchup-indicator.md @@ -0,0 +1,10 @@ +--- +type: feature +area: xtream +issues: [1128] +--- + +Live channels with provider catch-up now show a small archive badge — in the +channel sidebar next to the channel name, on the "all channels" grid cards, +and in the favorites and recently-viewed lists (including global favorites). +Hovering it shows the catch-up window in days. diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index d337b6503..f7d11ab2b 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -64,6 +64,12 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { ).first(); await expect(channelRow).toBeVisible({ timeout: 20000 }); + // Provider-declared catch-up (tv_archive=1 in the fixture) is + // surfaced as a badge on the sidebar row (#1128). + await expect( + channelRow.getByTestId('catchup-badge') + ).toBeVisible(); + // Sidebar channel list shows the per-channel "now" programme line. await expect .poll(async () => diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 751a1c228..b86d52c6a 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "لم يتم العثور على قنوات", "NO_CHANNELS_FOUND": "لم يتم العثور على قنوات", "NO_GROUPS_FOUND": "لم يتم العثور على مجموعات", + "CATCHUP_AVAILABLE": "Catchup متاح", + "CATCHUP_AVAILABLE_DAYS": "Catchup متاح — {{days}} يوم", "TRY_DIFFERENT_SEARCH": "جرّب مصطلح بحث مختلف", "DETAILS_DIALOG": { "TITLE": "تفاصيل القناة", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index b49ab3d55..919812c5c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "ما تلقاتش حتى قناة", "NO_CHANNELS_FOUND": "ما تلقاتش حتى قناة", "NO_GROUPS_FOUND": "ما تلقاتش حتى مجموعة", + "CATCHUP_AVAILABLE": "Catchup متوفر", + "CATCHUP_AVAILABLE_DAYS": "Catchup متوفر — {{days}} يوم", "TRY_DIFFERENT_SEARCH": "جرب كلمة بحث أخرى", "DETAILS_DIALOG": { "TITLE": "تفاصيل القناة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 9bef73dc4..6f3176e29 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Каналаў не знойдзена", "NO_CHANNELS_FOUND": "Каналаў не знойдзена", "NO_GROUPS_FOUND": "Груп не знойдзена", + "CATCHUP_AVAILABLE": "Catchup даступны", + "CATCHUP_AVAILABLE_DAYS": "Catchup даступны — {{days}} дзён", "TRY_DIFFERENT_SEARCH": "Паспрабуйце іншы пошукавы запыт", "DETAILS_DIALOG": { "TITLE": "Інфармацыя аб канале", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f3999c9ca..54c20b104 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Keine Sender gefunden", "NO_CHANNELS_FOUND": "Keine Sender gefunden", "NO_GROUPS_FOUND": "Keine Gruppen gefunden", + "CATCHUP_AVAILABLE": "Catchup verfügbar", + "CATCHUP_AVAILABLE_DAYS": "Catchup verfügbar — {{days}} Tage", "TRY_DIFFERENT_SEARCH": "Versuchen Sie einen anderen Suchbegriff", "DETAILS_DIALOG": { "TITLE": "Senderdetails", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 7d236cfc9..9807e876d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Δεν βρέθηκαν κανάλια", "NO_CHANNELS_FOUND": "Δεν βρέθηκαν κανάλια", "NO_GROUPS_FOUND": "Δεν βρέθηκαν ομάδες", + "CATCHUP_AVAILABLE": "Διαθέσιμο Catchup", + "CATCHUP_AVAILABLE_DAYS": "Διαθέσιμο Catchup — {{days}} ημέρες", "TRY_DIFFERENT_SEARCH": "Δοκιμάστε διαφορετικό όρο αναζήτησης", "DETAILS_DIALOG": { "TITLE": "Λεπτομέρειες καναλιού", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 24902616f..7424a26b8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "No channels found", "NO_CHANNELS_FOUND": "No channels found", "NO_GROUPS_FOUND": "No groups found", + "CATCHUP_AVAILABLE": "Catchup available", + "CATCHUP_AVAILABLE_DAYS": "Catchup available — {{days}} days", "TRY_DIFFERENT_SEARCH": "Try a different search term", "DETAILS_DIALOG": { "TITLE": "Channel details", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 4a5ffd4f5..a709cbeec 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "No se encontraron canales", "NO_CHANNELS_FOUND": "No se encontraron canales", "NO_GROUPS_FOUND": "No se encontraron grupos", + "CATCHUP_AVAILABLE": "Catchup disponible", + "CATCHUP_AVAILABLE_DAYS": "Catchup disponible — {{days}} días", "TRY_DIFFERENT_SEARCH": "Prueba con otro término de búsqueda", "DETAILS_DIALOG": { "TITLE": "Detalles del canal", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index f1663f46a..d79ac9f7c 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Aucune chaîne trouvée", "NO_CHANNELS_FOUND": "Aucune chaîne trouvée", "NO_GROUPS_FOUND": "Aucun groupe trouvé", + "CATCHUP_AVAILABLE": "Catchup disponible", + "CATCHUP_AVAILABLE_DAYS": "Catchup disponible — {{days}} jours", "TRY_DIFFERENT_SEARCH": "Essayez un autre terme de recherche", "DETAILS_DIALOG": { "TITLE": "Détails de la chaîne", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 29b6aa342..6bbc22a7a 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Nem található csatorna", "NO_CHANNELS_FOUND": "Nem található csatorna", "NO_GROUPS_FOUND": "Nem található csoport", + "CATCHUP_AVAILABLE": "Visszanézés elérhető", + "CATCHUP_AVAILABLE_DAYS": "Visszanézés elérhető — {{days}} nap", "TRY_DIFFERENT_SEARCH": "Próbálkozzon másik keresési kifejezéssel", "DETAILS_DIALOG": { "TITLE": "Csatorna részletei", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8229ae330..b5d2ef763 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Nessun canale trovato", "NO_CHANNELS_FOUND": "Nessun canale trovato", "NO_GROUPS_FOUND": "Nessun gruppo trovato", + "CATCHUP_AVAILABLE": "Catchup disponibile", + "CATCHUP_AVAILABLE_DAYS": "Catchup disponibile — {{days}} giorni", "TRY_DIFFERENT_SEARCH": "Prova con un altro termine di ricerca", "DETAILS_DIALOG": { "TITLE": "Dettagli canale", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index a4be97d2c..721577a17 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "チャンネルが見つかりません", "NO_CHANNELS_FOUND": "チャンネルが見つかりません", "NO_GROUPS_FOUND": "グループが見つかりません", + "CATCHUP_AVAILABLE": "Catchup利用可能", + "CATCHUP_AVAILABLE_DAYS": "Catchup利用可能 — {{days}}日", "TRY_DIFFERENT_SEARCH": "別の検索キーワードをお試しください", "DETAILS_DIALOG": { "TITLE": "チャンネルの詳細", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 00adcc3a9..11656c3bb 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "채널을 찾을 수 없습니다", "NO_CHANNELS_FOUND": "채널을 찾을 수 없습니다", "NO_GROUPS_FOUND": "그룹을 찾을 수 없습니다", + "CATCHUP_AVAILABLE": "Catchup 사용 가능", + "CATCHUP_AVAILABLE_DAYS": "Catchup 사용 가능 — {{days}}일", "TRY_DIFFERENT_SEARCH": "다른 검색어를 시도해 보세요", "DETAILS_DIALOG": { "TITLE": "채널 세부정보", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 0234ca867..e8de42878 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Geen kanalen gevonden", "NO_CHANNELS_FOUND": "Geen kanalen gevonden", "NO_GROUPS_FOUND": "Geen groepen gevonden", + "CATCHUP_AVAILABLE": "Catchup beschikbaar", + "CATCHUP_AVAILABLE_DAYS": "Catchup beschikbaar — {{days}} dagen", "TRY_DIFFERENT_SEARCH": "Probeer een andere zoekterm", "DETAILS_DIALOG": { "TITLE": "Kanaaldetails", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ff30589d7..1113ddebb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Nie znaleziono kanałów", "NO_CHANNELS_FOUND": "Nie znaleziono kanałów", "NO_GROUPS_FOUND": "Nie znaleziono grup", + "CATCHUP_AVAILABLE": "Catchup dostępny", + "CATCHUP_AVAILABLE_DAYS": "Catchup dostępny — {{days}} dni", "TRY_DIFFERENT_SEARCH": "Spróbuj innego zapytania", "DETAILS_DIALOG": { "TITLE": "Szczegóły kanału", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 08ebcc047..d29fbe486 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Nenhum canal encontrado", "NO_CHANNELS_FOUND": "Nenhum canal encontrado", "NO_GROUPS_FOUND": "Nenhum grupo encontrado", + "CATCHUP_AVAILABLE": "Catchup disponível", + "CATCHUP_AVAILABLE_DAYS": "Catchup disponível — {{days}} dias", "TRY_DIFFERENT_SEARCH": "Tente um termo de pesquisa diferente", "DETAILS_DIALOG": { "TITLE": "Detalhes do canal", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 1652c870a..7f4762105 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Каналы не найдены", "NO_CHANNELS_FOUND": "Каналы не найдены", "NO_GROUPS_FOUND": "Группы не найдены", + "CATCHUP_AVAILABLE": "Catchup доступен", + "CATCHUP_AVAILABLE_DAYS": "Catchup доступен — {{days}} дн.", "TRY_DIFFERENT_SEARCH": "Попробуйте другой поисковый запрос", "DETAILS_DIALOG": { "TITLE": "Сведения о канале", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 00cbbbfbf..e6c4c0353 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "Kanal bulunamadı", "NO_CHANNELS_FOUND": "Kanal bulunamadı", "NO_GROUPS_FOUND": "Grup bulunamadı", + "CATCHUP_AVAILABLE": "Catchup mevcut", + "CATCHUP_AVAILABLE_DAYS": "Catchup mevcut — {{days}} gün", "TRY_DIFFERENT_SEARCH": "Farklı bir arama terimi deneyin", "DETAILS_DIALOG": { "TITLE": "Kanal ayrıntıları", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index c127aa976..a70d12f72 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "未找到频道", "NO_CHANNELS_FOUND": "未找到频道", "NO_GROUPS_FOUND": "未找到分组", + "CATCHUP_AVAILABLE": "Catchup 可用", + "CATCHUP_AVAILABLE_DAYS": "Catchup 可用 — {{days}} 天", "TRY_DIFFERENT_SEARCH": "尝试其他搜索词", "DETAILS_DIALOG": { "TITLE": "频道详情", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 243129808..c1f745555 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -635,6 +635,8 @@ "NO_SEARCH_RESULTS": "找不到頻道", "NO_CHANNELS_FOUND": "找不到頻道", "NO_GROUPS_FOUND": "找不到群組", + "CATCHUP_AVAILABLE": "Catchup 可用", + "CATCHUP_AVAILABLE_DAYS": "Catchup 可用 — {{days}} 天", "TRY_DIFFERENT_SEARCH": "請嘗試其他搜尋詞", "DETAILS_DIALOG": { "TITLE": "頻道詳細資訊", diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index a79a63c55..bd71d5519 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -40,6 +40,8 @@ [isRadio]="ch.radio === 'true'" [epgProgram]="ch.currentEpgProgram" [progressPercentage]="ch.progressPercentage" + [catchupAvailable]="catchupAvailable(ch)" + [catchupDays]="catchupDays(ch)" [showFavoriteButton]="true" [isFavorite]="isChannelFavorite(ch)" [showProgramInfoButton]="true" diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts index a01b7075a..c7f7c6baa 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.spec.ts @@ -75,6 +75,33 @@ describe('GlobalFavoritesListComponent', () => { expect(icons).toEqual(['star_outline', 'star']); }); + it('shows the catch-up badge on Xtream rows with a playable archive', () => { + fixture.componentRef.setInput('channels', [ + buildChannel('x1', 'Archive Channel', { + sourceType: 'xtream', + xtreamId: 42, + tvArchive: 1, + tvArchiveDuration: 7, + }), + buildChannel('x2', 'Plain Channel', { + sourceType: 'xtream', + xtreamId: 43, + tvArchive: 0, + tvArchiveDuration: 0, + }), + buildChannel('m1', 'M3U Channel'), + ]); + fixture.detectChanges(); + + const badges = fixture.nativeElement.querySelectorAll( + '[data-test-id="catchup-badge"]' + ); + expect(badges).toHaveLength(1); + + const badgeRow = badges[0].closest('[data-test-id="channel-item"]'); + expect(badgeRow?.textContent).toContain('Archive Channel'); + }); + it('renders radio rows as compact without a false EPG placeholder', () => { fixture.componentRef.setInput('channels', [ buildChannel('radio', 'Radio One', { radio: 'true' }), diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index 9fe42c7c0..e16821f47 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -35,6 +35,8 @@ import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, FavoritesChannelSortMode, + getXtreamCatchupDays, + isXtreamCatchupAvailable, sortFavoriteChannelItems, UnifiedFavoriteChannel, } from '@iptvnator/portal/shared/util'; @@ -144,6 +146,21 @@ export class GlobalFavoritesListComponent { }); }); + /** Catch-up fields arrive camelCase on unified rows — adapt for the + * shared snake_case helper. Only Xtream rows carry them. */ + protected catchupAvailable(channel: UnifiedFavoriteChannel): boolean { + return isXtreamCatchupAvailable({ + tv_archive: channel.tvArchive, + tv_archive_duration: channel.tvArchiveDuration, + }); + } + + protected catchupDays(channel: UnifiedFavoriteChannel): number { + return getXtreamCatchupDays({ + tv_archive_duration: channel.tvArchiveDuration, + }); + } + onChannelClick(channel: UnifiedFavoriteChannel): void { this.channelSelected.emit(channel); } diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss index aead84cdc..27fb49d51 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.scss @@ -107,6 +107,41 @@ mat-card:hover { } // ─── Rating badge ───────────────────────────────────────────────────────────── +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.catchup-badge { + position: absolute; + top: 8px; + left: 8px; + width: 24px; + height: 24px; + // Solid dark pill like .rating — no expensive backdrop-filter + background: rgba(0, 0, 0, 0.85); + color: rgba(255, 255, 255, 0.88); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + line-height: 15px; + } +} + .rating { position: absolute; top: 6px; diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts index 4bd36a133..441991135 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.spec.ts @@ -173,6 +173,70 @@ describe('GridListComponent', () => { ); }); + it('shows the catch-up badge on live cards with a playable archive', () => { + fixture.componentRef.setInput('items', [ + { + name: 'Archive Channel', + stream_icon: 'channel-logo.png', + tv_archive: 1, + tv_archive_duration: 7, + }, + ]); + fixture.componentRef.setInput('variant', 'logo'); + fixture.componentRef.setInput('type', 'live'); + + fixture.detectChanges(); + + const badge = fixture.debugElement.query( + By.css('[data-test-id="grid-catchup-badge"]') + ); + expect(badge).not.toBeNull(); + expect(badge.nativeElement.textContent).toContain('history'); + + // The icon is aria-hidden — the status must also exist as + // visually-hidden text for assistive technology. + const srText = badge.query(By.css('.visually-hidden')); + expect(srText.nativeElement.textContent).toContain( + 'CATCHUP_AVAILABLE' + ); + }); + + it('hides the catch-up badge for live cards without a playable archive', () => { + fixture.componentRef.setInput('items', [ + { name: 'Flag Only', tv_archive: 1, tv_archive_duration: 0 }, + { name: 'No Archive', tv_archive: 0, tv_archive_duration: 7 }, + { name: 'Legacy Row' }, + ]); + fixture.componentRef.setInput('type', 'live'); + + fixture.detectChanges(); + + expect( + fixture.debugElement.queryAll( + By.css('[data-test-id="grid-catchup-badge"]') + ) + ).toHaveLength(0); + }); + + it('never shows the catch-up badge on VOD grids', () => { + fixture.componentRef.setInput('items', [ + { + title: 'Some Movie', + tv_archive: 1, + tv_archive_duration: 7, + }, + ]); + fixture.componentRef.setInput('type', 'vod'); + + fixture.detectChanges(); + + expect( + fixture.debugElement.query( + By.css('[data-test-id="grid-catchup-badge"]') + ) + ).toBeNull(); + }); + it('renders raw titles while prefix stripping is disabled', () => { fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]); fixture.componentRef.setInput('type', 'live'); diff --git a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts index d4be4c1a7..2d77d89ec 100644 --- a/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts @@ -13,6 +13,10 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; +import { + getXtreamCatchupDays, + isXtreamCatchupAvailable, +} from '@iptvnator/portal/shared/util'; import { SettingsStore } from '@iptvnator/services'; import { ProgressCapsuleComponent, @@ -38,6 +42,8 @@ interface GridListItem { progress?: number; isWatched?: boolean; hasSeriesProgress?: boolean; + tv_archive?: number | string | null; + tv_archive_duration?: number | string | null; [key: string]: unknown; } @@ -136,6 +142,24 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined { @if (i.progress && i.progress > 0) { } + @if (showCatchupBadge(i)) { +
+ history + + {{ + catchupLabelKey(i) + | translate: { days: catchupDays(i) } + }} +
+ } @if (i.isWatched) { ['live', 'itv', 'radio'].includes(this.type()) ); + protected readonly catchupDays = getXtreamCatchupDays; + /** Catch-up badge is live-grid only; VOD/series rows never carry it. */ + protected showCatchupBadge(item: GridListItem): boolean { + return this.isLiveGrid() && isXtreamCatchupAvailable(item); + } + protected catchupLabelKey(item: GridListItem): string { + return getXtreamCatchupDays(item) > 0 + ? 'CHANNELS.CATCHUP_AVAILABLE_DAYS' + : 'CHANNELS.CATCHUP_AVAILABLE'; + } protected readonly channelTitle = (item: GridListItem): string => { const raw = item.title ?? item.o_name ?? item.name ?? ''; const stripped = applyChannelNameStrip( diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.channels-for-list.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.channels-for-list.spec.ts new file mode 100644 index 000000000..dc04d7ff0 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.channels-for-list.spec.ts @@ -0,0 +1,122 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateModule } from '@ngx-translate/core'; +import { + PORTAL_PLAYER, + UnifiedCollectionItem, +} from '@iptvnator/portal/shared/util'; +import { + StreamResolverService, + UnifiedRecentDataService, +} from '@iptvnator/portal/shared/data-access'; +import { + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; +import { UnifiedLiveTabComponent } from './unified-live-tab.component'; + +/** + * Focused spec for the `channelsForList` row mapping — kept separate from + * the main layout spec, which sits at the max-lines test budget. + */ +describe('UnifiedLiveTabComponent channelsForList', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent], + providers: [ + { + provide: StreamResolverService, + useValue: { + resolveLiveDetail: jest.fn(), + loadEpgForItems: jest + .fn() + .mockResolvedValue(new Map()), + }, + }, + { + provide: UnifiedRecentDataService, + useValue: { recordLivePlayback: jest.fn() }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsEpg: false }, + }, + { + provide: SettingsStore, + useValue: { + openStreamOnDoubleClick: signal(false), + player: signal(VideoPlayer.VideoJs), + stripCountryPrefix: signal(false), + resolvedEpgViewMode: signal('timeline'), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + ], + }) + .overrideComponent(UnifiedLiveTabComponent, { + set: { template: '' }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(UnifiedLiveTabComponent); + }); + + afterEach(() => fixture.destroy()); + + it('passes the archive fields through to the sidebar rows', () => { + const item: UnifiedCollectionItem = { + uid: 'xtream::pl-1::42', + name: 'Archive Channel', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'pl-1', + playlistName: 'Portal One', + logo: null, + posterUrl: null, + xtreamId: 42, + tvArchive: 1, + tvArchiveDuration: 5, + addedAt: '2026-04-30T12:00:00.000Z', + position: 0, + }; + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + + const row = fixture.componentInstance.channelsForList()[0]; + expect(row.tvArchive).toBe(1); + expect(row.tvArchiveDuration).toBe(5); + }); + + it('normalises absent archive fields to null', () => { + const item = { + uid: 'm3u::pl-2::chan', + name: 'Plain Channel', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'pl-2', + playlistName: 'Playlist Two', + logo: null, + posterUrl: null, + addedAt: '2026-04-30T12:00:00.000Z', + position: 0, + } as UnifiedCollectionItem; + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + + const row = fixture.componentInstance.channelsForList()[0]; + expect(row.tvArchive).toBeNull(); + expect(row.tvArchiveDuration).toBeNull(); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index e95223f81..820757a50 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -285,6 +285,8 @@ export class UnifiedLiveTabComponent { m3uChannel: item.m3uChannel, radio: item.radio, xtreamId: item.xtreamId, + tvArchive: item.tvArchive ?? null, + tvArchiveDuration: item.tvArchiveDuration ?? null, tvgId: item.tvgId, stalkerCmd: item.stalkerCmd, stalkerPortalUrl: item.stalkerPortalUrl, diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index ef9d78bc8..54f6692e3 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -26,6 +26,7 @@ export * from './lib/navigation/provider-detail-mode'; export * from './lib/navigation/portal-route.utils'; export * from './lib/navigation/workspace-portal-navigation'; export * from './lib/collection'; +export * from './lib/xtream-catchup.util'; export * from './lib/external-playback-button-state'; export * from './lib/inline-playback-position-writer'; export * from './lib/downloads/download-metadata-snapshot'; diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts index 12db6982e..690d44773 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts @@ -40,6 +40,12 @@ export interface UnifiedFavoriteChannel { /** Xtream numeric stream ID (Xtream channels only) */ xtreamId?: number; + /** Provider catch-up flag (Xtream live channels only; 1 = archive) */ + tvArchive?: number | null; + + /** Provider catch-up window in days (Xtream live channels only) */ + tvArchiveDuration?: number | null; + /** * TVG id for M3U EPG lookup. * For Xtream channels, also used for short-EPG lookup. diff --git a/libs/portal/shared/util/src/lib/xtream-catchup.util.spec.ts b/libs/portal/shared/util/src/lib/xtream-catchup.util.spec.ts new file mode 100644 index 000000000..f2f5a0553 --- /dev/null +++ b/libs/portal/shared/util/src/lib/xtream-catchup.util.spec.ts @@ -0,0 +1,82 @@ +import { + getXtreamCatchupDays, + isXtreamCatchupAvailable, +} from './xtream-catchup.util'; + +describe('xtream-catchup.util', () => { + describe('getXtreamCatchupDays', () => { + it('returns the numeric archive window', () => { + expect( + getXtreamCatchupDays({ tv_archive: 1, tv_archive_duration: 7 }) + ).toBe(7); + }); + + it('parses string durations sent by some panels', () => { + expect( + getXtreamCatchupDays({ + tv_archive: '1', + tv_archive_duration: '3', + }) + ).toBe(3); + }); + + it('returns 0 for absent, null, or unparsable values', () => { + expect(getXtreamCatchupDays(undefined)).toBe(0); + expect(getXtreamCatchupDays(null)).toBe(0); + expect(getXtreamCatchupDays({})).toBe(0); + expect( + getXtreamCatchupDays({ tv_archive_duration: null }) + ).toBe(0); + expect( + getXtreamCatchupDays({ tv_archive_duration: 'soon' }) + ).toBe(0); + }); + + it('clamps negative durations to 0', () => { + expect( + getXtreamCatchupDays({ tv_archive_duration: -2 }) + ).toBe(0); + }); + }); + + describe('isXtreamCatchupAvailable', () => { + it('requires both the flag and a positive window', () => { + expect( + isXtreamCatchupAvailable({ + tv_archive: 1, + tv_archive_duration: 7, + }) + ).toBe(true); + }); + + it('accepts string field values', () => { + expect( + isXtreamCatchupAvailable({ + tv_archive: '1', + tv_archive_duration: '5', + }) + ).toBe(true); + }); + + it('rejects a flag without a playable window', () => { + expect( + isXtreamCatchupAvailable({ + tv_archive: 1, + tv_archive_duration: 0, + }) + ).toBe(false); + }); + + it('rejects channels without the archive flag', () => { + expect( + isXtreamCatchupAvailable({ + tv_archive: 0, + tv_archive_duration: 7, + }) + ).toBe(false); + expect(isXtreamCatchupAvailable({})).toBe(false); + expect(isXtreamCatchupAvailable(null)).toBe(false); + expect(isXtreamCatchupAvailable(undefined)).toBe(false); + }); + }); +}); diff --git a/libs/portal/shared/util/src/lib/xtream-catchup.util.ts b/libs/portal/shared/util/src/lib/xtream-catchup.util.ts new file mode 100644 index 000000000..607730afd --- /dev/null +++ b/libs/portal/shared/util/src/lib/xtream-catchup.util.ts @@ -0,0 +1,33 @@ +/** + * Provider catch-up (archive/timeshift) availability for Xtream live streams. + * + * The gate mirrors the playback-side checks in `live-stream-layout` + * (`archivePlaybackAvailable`) and `unified-live-tab` + * (`timelineArchiveAvailable`): the provider must flag the channel with + * `tv_archive === 1` AND expose a positive archive window. A bare flag with a + * zero-day window is not playable through the catch-up URL builder, so it + * must not be advertised as available. + */ +export interface XtreamCatchupFields { + readonly tv_archive?: number | string | null; + readonly tv_archive_duration?: number | string | null; +} + +/** + * Catch-up window in days, `0` when absent or unparsable. Xtream panels + * report `tv_archive_duration` in days; some send it as a string. + */ +export function getXtreamCatchupDays( + item: XtreamCatchupFields | null | undefined +): number { + return Math.max(0, Number(item?.tv_archive_duration ?? 0) || 0); +} + +/** Whether the provider declares playable catch-up for this channel. */ +export function isXtreamCatchupAvailable( + item: XtreamCatchupFields | null | undefined +): boolean { + return ( + Number(item?.tv_archive ?? 0) === 1 && getXtreamCatchupDays(item) > 0 + ); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index b8bc99d2d..857b581af 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -113,7 +113,7 @@ [showEpg]="supportsEpg && !isRadioMode()" [isRadio]="isRadioMode()" [showFavoriteButton]="true" - [showProgramInfoButton]="false" + [showProgramInfoButton]="true" [showDetailsContextMenu]=" supportsEpgMapping && !isRadioMode() " diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 3f72bab30..6e5a57392 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -42,8 +42,10 @@ : 0 " [showEpg]="supportsEpg" + [catchupAvailable]="isCatchupAvailable(item)" + [catchupDays]="catchupDays(item)" [showFavoriteButton]="true" - [showProgramInfoButton]="false" + [showProgramInfoButton]="true" [showDetailsContextMenu]="supportsEpgMapping" [isFavorite]="favorites.get(favoriteKeyFor(item)) ?? false" (clicked)="playClicked.emit(item)" diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index a85ab35af..64aa22c35 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -38,6 +38,8 @@ import { EpgMappingDialogComponent, } from '@iptvnator/ui/components'; import { + getXtreamCatchupDays, + isXtreamCatchupAvailable, PortalChannelSortMode, sortPortalChannelItems, } from '@iptvnator/portal/shared/util'; @@ -57,6 +59,8 @@ export interface XtreamChannelListItem { readonly type?: 'live' | 'movie' | 'series' | 'vod'; readonly xtream_id: number; readonly epg_channel_id?: string | null; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | string | null; } interface XtreamCategoryLike { @@ -193,6 +197,9 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { return item.xtream_id; } + protected readonly isCatchupAvailable = isXtreamCatchupAvailable; + protected readonly catchupDays = getXtreamCatchupDays; + ngOnInit(): void { const { categoryId } = this.route.snapshot.params; if (categoryId && !this.channelsOverride()) diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 8caa8f672..fa0490aa0 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -28,7 +28,24 @@ }
-
{{ displayName() }}
+
+
{{ displayName() }}
+ @if (catchupAvailable()) { + history + + {{ + catchupLabelKey() | translate: { days: catchupDays() } + }} + } +
@if (showEpg()) { @if (epgProgram(); as program) {
@@ -65,23 +82,11 @@
@if ( - (showProgramInfoButton() && showEpg() && epgProgram()) || + (showProgramInfoButton() && showEpg()) || showFavoriteButton() || showAuxActionButton() ) {
- @if (showProgramInfoButton() && showEpg() && epgProgram(); as program) { - - } @if (showFavoriteButton()) { } + @if (showProgramInfoButton() && showEpg()) { + + + }
} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss index 75c1faed8..877cd3a65 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss @@ -123,6 +123,13 @@ gap: 3px; } +.channel-name-row { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + .channel-name { min-width: 0; overflow: hidden; @@ -134,6 +141,27 @@ line-height: 1.2; } +.catchup-badge { + flex-shrink: 0; + width: 15px; + height: 15px; + font-size: 15px; + line-height: 15px; + opacity: 0.62; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .epg-title { min-width: 0; overflow: hidden; @@ -208,13 +236,20 @@ .action-buttons { flex-shrink: 0; display: flex; + flex-direction: column; align-items: center; - gap: 2px; + justify-content: center; + gap: 0; margin-inline-start: auto; + .channel-list-item.compact & { + flex-direction: row; + gap: 2px; + } + button { - width: 32px; - height: 32px; + width: 28px; + height: 28px; /* border-radius: 10px; */ color: inherit; opacity: 0.76; @@ -231,9 +266,19 @@ } mat-icon { - width: 20px; - height: 20px; - font-size: 20px; + width: 18px; + height: 18px; + font-size: 18px; + line-height: 18px; + } + + /* Material keeps a 48px touch target on icon buttons; stacked 28px + buttons would overlap by 20px and the later sibling would steal + clicks from the lower third of the button above. Constrain the + target to the button bounds. */ + button ::ng-deep .mat-mdc-button-touch-target { + width: 100%; + height: 100%; } } @@ -326,3 +371,10 @@ .program-info-button { padding: 0; } + +/* Reserved slot: keeps the column geometry stable while the row has no + programme yet (or none at all), so the star never jumps. */ +.program-info-button.slot-empty { + visibility: hidden; + pointer-events: none; +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts index 9f1c3cfbc..628f1bace 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.spec.ts @@ -98,6 +98,76 @@ describe('ChannelListItemComponent', () => { ).toBe(true); }); + it('stacks the favorite button above the reserved programme-info slot', () => { + const program: EpgProgram = { + start: '2026-04-05 05:30:00', + stop: '2026-04-05 06:00:00', + channel: 'channel-1', + title: 'Current Show', + desc: 'Current description', + category: null, + }; + fixture.componentRef.setInput('name', 'Ordered Channel'); + fixture.componentRef.setInput('showFavoriteButton', true); + fixture.componentRef.setInput('epgProgram', program); + fixture.detectChanges(); + + const buttons = Array.from( + fixture.nativeElement.querySelectorAll('.action-buttons button'), + (element: Element) => element.className + ); + expect(buttons).toHaveLength(2); + expect(buttons[0]).toContain('favorite-button'); + expect(buttons[1]).toContain('program-info-button'); + expect(buttons[1]).not.toContain('slot-empty'); + }); + + it('keeps an inert info slot while the row has no programme', () => { + fixture.componentRef.setInput('name', 'No EPG Yet'); + fixture.componentRef.setInput('showFavoriteButton', true); + fixture.detectChanges(); + + // The slot must exist (reserving the geometry) but be inert, so the + // star above it never shifts when EPG data arrives later. + const slot = fixture.nativeElement.querySelector( + '.program-info-button' + ); + expect(slot).not.toBeNull(); + expect(slot.classList.contains('slot-empty')).toBe(true); + expect(slot.disabled).toBe(true); + + slot.click(); + expect(dialog.open).not.toHaveBeenCalled(); + }); + + it('renders the catch-up badge only when catch-up is available', () => { + fixture.componentRef.setInput('name', 'Archive Channel'); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="catchup-badge"]' + ) + ).toBeNull(); + + fixture.componentRef.setInput('catchupAvailable', true); + fixture.componentRef.setInput('catchupDays', 7); + fixture.detectChanges(); + + const badge = fixture.nativeElement.querySelector( + '[data-test-id="catchup-badge"]' + ); + expect(badge).not.toBeNull(); + expect(badge.textContent).toContain('history'); + + // The icon is aria-hidden — the status must also exist as + // visually-hidden text for assistive technology. + const srText = fixture.nativeElement.querySelector( + '.channel-name-row .visually-hidden' + ); + expect(srText?.textContent).toContain('CATCHUP_AVAILABLE'); + }); + it('shows the generic fallback icon when no logo is available', () => { fixture.componentRef.setInput('name', 'Channel Without Logo'); fixture.componentRef.setInput('logo', ''); diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index 97f4f9132..1aa9beea3 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -57,6 +57,15 @@ export class ChannelListItemComponent { readonly selected = input(false); readonly showEpg = input(true); readonly isRadio = input(false); + /** Shows the provider catch-up (archive) badge next to the channel name */ + readonly catchupAvailable = input(false); + /** Archive window in days for the badge tooltip; 0 hides the day count */ + readonly catchupDays = input(0); + readonly catchupLabelKey = computed(() => + this.catchupDays() > 0 + ? 'CHANNELS.CATCHUP_AVAILABLE_DAYS' + : 'CHANNELS.CATCHUP_AVAILABLE' + ); readonly epgProgram = input(); /** Progress percentage pre-computed by parent for performance */ readonly progressPercentage = input(0); @@ -88,6 +97,17 @@ export class ChannelListItemComponent { }); } + /** Guarded click for the reserved (possibly still empty) info slot. */ + onProgramInfoClick(event: MouseEvent): void { + const program = this.epgProgram(); + if (!program) { + event.stopPropagation(); + return; + } + + this.showProgramDescription(program, event); + } + onFavoriteClick(event: MouseEvent): void { event.stopPropagation(); this.favoriteToggled.emit(event); diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index 47a390da1..3da7f0131 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -38,6 +38,8 @@ interface XtreamGlobalFavoriteRow { readonly position?: number | null; readonly poster_url?: string | null; readonly title: string; + readonly tv_archive?: number | null; + readonly tv_archive_duration?: number | null; readonly xtream_id: number; } @@ -220,6 +222,8 @@ export class GlobalFavoritesService { playlistId: item.playlist_id, playlistName: item.playlist_name, xtreamId: item.xtream_id, + tvArchive: item.tv_archive ?? null, + tvArchiveDuration: item.tv_archive_duration ?? null, tvgId: String(item.xtream_id), addedAt: item.added_at ?? new Date(0).toISOString(), position: item.position ?? 0,