diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 1b60023c5..4293e33aa 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "مشاهدة مباشرة", "WATCH_FROM_START": "المشاهدة من البداية", "ARCHIVE_UNAVAILABLE_NOTE": "التشغيل غير متاح — هذا المزود لا يوفر أرشيفًا.", + "CATCHUP_FAILED": "تعذّر بدء تشغيل الأرشيف لهذا البرنامج", "SCHEDULE_ONLY_NOTICE": "الجدول فقط. يوفر هذا المزود سجل البرامج لكن دون catch-up — لا يمكن تشغيل البرامج السابقة.", "EMPTY_DAY_TITLE": "لا يوجد برنامج لهذا اليوم", "EMPTY_DAY_DESCRIPTION": "لم يصل جدول اليوم المحدد بعد. أيام أخرى تحتوي على بيانات.", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3eebb6c41..4aad181c8 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "تفرج مباشر", "WATCH_FROM_START": "تفرج من البداية", "ARCHIVE_UNAVAILABLE_NOTE": "التشغيل ما متوفرش — هاد المزود ما كيوفرش أرشيف.", + "CATCHUP_FAILED": "ما أمكنش تشغيل الأرشيف لهاد البرنامج", "SCHEDULE_ONLY_NOTICE": "جدول البرامج فقط. هاد المزود كيوفر تاريخ البرامج ولكن ما كيوفرش catch-up — البرامج اللي فاتت ما يمكنش تشغيلها.", "EMPTY_DAY_TITLE": "ما كاين حتى برنامج لهاد النهار", "EMPTY_DAY_DESCRIPTION": "جدول البرامج ديال النهار اللي اختاريتي مازال ما وصلش. أيام أخرى عندهم بيانات.", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c8ad4d727..1e92b244f 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Глядзець эфір", "WATCH_FROM_START": "Глядзець спачатку", "ARCHIVE_UNAVAILABLE_NOTE": "Прайграванне недаступнае — гэты правайдэр не падае архіў.", + "CATCHUP_FAILED": "Не ўдалося запусціць прайграванне архіва для гэтай праграмы", "SCHEDULE_ONLY_NOTICE": "Толькі праграма перадач. Гэты правайдэр падае гісторыю перадач, але не catch-up — мінулыя перадачы прайграць немагчыма.", "EMPTY_DAY_TITLE": "На гэты дзень няма перадач", "EMPTY_DAY_DESCRIPTION": "Праграма на выбраны дзень яшчэ не паступіла. Для іншых дзён даныя ёсць.", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index de241c02e..4bb53182e 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Live ansehen", "WATCH_FROM_START": "Von Anfang an ansehen", "ARCHIVE_UNAVAILABLE_NOTE": "Wiedergabe nicht verfügbar – dieser Anbieter stellt kein Archiv bereit.", + "CATCHUP_FAILED": "Archivwiedergabe für diese Sendung konnte nicht gestartet werden", "SCHEDULE_ONLY_NOTICE": "Nur Programm. Dieser Anbieter stellt den Programmverlauf bereit, aber kein Catchup – vergangene Sendungen können nicht abgespielt werden.", "EMPTY_DAY_TITLE": "Kein Programm für diesen Tag", "EMPTY_DAY_DESCRIPTION": "Das Programm für den ausgewählten Tag ist noch nicht eingetroffen. Für andere Tage sind Daten vorhanden.", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index eb492d967..70b2795e3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Παρακολούθηση ζωντανά", "WATCH_FROM_START": "Παρακολούθηση από την αρχή", "ARCHIVE_UNAVAILABLE_NOTE": "Η αναπαραγωγή δεν είναι διαθέσιμη — αυτός ο πάροχος δεν παρέχει αρχείο.", + "CATCHUP_FAILED": "Δεν ήταν δυνατή η έναρξη αναπαραγωγής αρχείου για αυτό το πρόγραμμα", "SCHEDULE_ONLY_NOTICE": "Μόνο πρόγραμμα. Αυτός ο πάροχος παρέχει ιστορικό προγράμματος αλλά όχι catch-up — τα προηγούμενα προγράμματα δεν μπορούν να αναπαραχθούν.", "EMPTY_DAY_TITLE": "Δεν υπάρχει πρόγραμμα για αυτή την ημέρα", "EMPTY_DAY_DESCRIPTION": "Το πρόγραμμα για την επιλεγμένη ημέρα δεν έχει φτάσει ακόμα. Άλλες ημέρες έχουν δεδομένα.", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index f60e9abf5..b77a49418 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Watch live", "WATCH_FROM_START": "Watch from start", "ARCHIVE_UNAVAILABLE_NOTE": "Playback unavailable — this provider does not expose an archive.", + "CATCHUP_FAILED": "Couldn't start archive playback for this program", "SCHEDULE_ONLY_NOTICE": "Schedule only. This provider exposes programme history but not catch-up — past programmes can't be played.", "EMPTY_DAY_TITLE": "No programme for this day", "EMPTY_DAY_DESCRIPTION": "The schedule for the selected day hasn't arrived yet. Other days have data.", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index a9adb016d..0c4087ff0 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Ver en directo", "WATCH_FROM_START": "Ver desde el inicio", "ARCHIVE_UNAVAILABLE_NOTE": "Reproducción no disponible: este proveedor no expone un archivo.", + "CATCHUP_FAILED": "No se pudo iniciar la reproducción de archivo para este programa", "SCHEDULE_ONLY_NOTICE": "Solo programación. Este proveedor expone el historial de programas, pero no catch-up: los programas pasados no se pueden reproducir.", "EMPTY_DAY_TITLE": "No hay programación para este día", "EMPTY_DAY_DESCRIPTION": "La programación del día seleccionado aún no ha llegado. Otros días tienen datos.", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ae178064b..30be779d4 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Regarder en direct", "WATCH_FROM_START": "Regarder depuis le début", "ARCHIVE_UNAVAILABLE_NOTE": "Lecture indisponible — ce fournisseur n'expose pas d'archive.", + "CATCHUP_FAILED": "Impossible de démarrer la lecture archive pour ce programme", "SCHEDULE_ONLY_NOTICE": "Programme uniquement. Ce fournisseur expose l'historique des programmes mais pas le catch-up — les programmes passés ne peuvent pas être lus.", "EMPTY_DAY_TITLE": "Aucun programme pour ce jour", "EMPTY_DAY_DESCRIPTION": "Le programme du jour sélectionné n'est pas encore arrivé. D'autres jours ont des données.", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 482c397e1..e5f8559a4 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Guarda in diretta", "WATCH_FROM_START": "Guarda dall'inizio", "ARCHIVE_UNAVAILABLE_NOTE": "Riproduzione non disponibile — questo fornitore non espone un archivio.", + "CATCHUP_FAILED": "Impossibile avviare la riproduzione dall’archivio per questo programma", "SCHEDULE_ONLY_NOTICE": "Solo programmazione. Questo fornitore espone la cronologia dei programmi ma non il catch-up — i programmi passati non possono essere riprodotti.", "EMPTY_DAY_TITLE": "Nessun programma per questo giorno", "EMPTY_DAY_DESCRIPTION": "La programmazione per il giorno selezionato non è ancora arrivata. Altri giorni hanno dati.", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index dc380258e..9be5c7845 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "ライブを視聴", "WATCH_FROM_START": "最初から視聴", "ARCHIVE_UNAVAILABLE_NOTE": "再生できません — このプロバイダーはアーカイブを提供していません。", + "CATCHUP_FAILED": "この番組のアーカイブ再生を開始できませんでした", "SCHEDULE_ONLY_NOTICE": "番組表のみです。このプロバイダーは番組履歴を提供しますが catch-up には対応していないため、過去の番組は再生できません。", "EMPTY_DAY_TITLE": "この日の番組はありません", "EMPTY_DAY_DESCRIPTION": "選択した日の番組表はまだ届いていません。他の日にはデータがあります。", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 3298b6076..5b35c4905 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "라이브 시청", "WATCH_FROM_START": "처음부터 시청", "ARCHIVE_UNAVAILABLE_NOTE": "재생할 수 없습니다 — 이 공급자는 아카이브를 제공하지 않습니다.", + "CATCHUP_FAILED": "이 프로그램의 아카이브 재생을 시작할 수 없습니다", "SCHEDULE_ONLY_NOTICE": "편성표만 제공됩니다. 이 공급자는 프로그램 이력을 제공하지만 catch-up은 제공하지 않으므로 지난 프로그램은 재생할 수 없습니다.", "EMPTY_DAY_TITLE": "이 날짜에는 프로그램이 없습니다", "EMPTY_DAY_DESCRIPTION": "선택한 날짜의 편성표가 아직 도착하지 않았습니다. 다른 날짜에는 데이터가 있습니다.", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 0c64bcba3..70e472cce 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Live bekijken", "WATCH_FROM_START": "Vanaf begin bekijken", "ARCHIVE_UNAVAILABLE_NOTE": "Afspelen niet beschikbaar — deze provider biedt geen archief.", + "CATCHUP_FAILED": "Archief afspelen voor dit programma kon niet worden gestart", "SCHEDULE_ONLY_NOTICE": "Alleen schema. Deze provider toont programmageschiedenis maar geen catch-up — programma's uit het verleden kunnen niet worden afgespeeld.", "EMPTY_DAY_TITLE": "Geen programma voor deze dag", "EMPTY_DAY_DESCRIPTION": "Het schema voor de geselecteerde dag is nog niet binnen. Andere dagen hebben wel gegevens.", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 90e586907..38fdaea82 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Oglądaj na żywo", "WATCH_FROM_START": "Oglądaj od początku", "ARCHIVE_UNAVAILABLE_NOTE": "Odtwarzanie niedostępne — ten dostawca nie udostępnia archiwum.", + "CATCHUP_FAILED": "Nie udało się uruchomić odtwarzania z archiwum dla tego programu", "SCHEDULE_ONLY_NOTICE": "Tylko program. Ten dostawca udostępnia historię programów, ale nie catch-up — minionych programów nie można odtworzyć.", "EMPTY_DAY_TITLE": "Brak programu na ten dzień", "EMPTY_DAY_DESCRIPTION": "Program na wybrany dzień jeszcze nie dotarł. Inne dni mają dane.", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 6b20a4905..a1b75be66 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Assistir ao vivo", "WATCH_FROM_START": "Assistir desde o início", "ARCHIVE_UNAVAILABLE_NOTE": "Reprodução indisponível — este provedor não disponibiliza um arquivo.", + "CATCHUP_FAILED": "Não foi possível iniciar a reprodução do arquivo para este programa", "SCHEDULE_ONLY_NOTICE": "Apenas programação. Este provedor expõe o histórico de programas, mas não catch-up — programas passados não podem ser reproduzidos.", "EMPTY_DAY_TITLE": "Nenhum programa para este dia", "EMPTY_DAY_DESCRIPTION": "A programação do dia selecionado ainda não chegou. Outros dias têm dados.", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 33b3bc6f0..f043a6b13 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Смотреть в эфире", "WATCH_FROM_START": "Смотреть с начала", "ARCHIVE_UNAVAILABLE_NOTE": "Воспроизведение недоступно — этот провайдер не предоставляет архив.", + "CATCHUP_FAILED": "Не удалось запустить воспроизведение архива для этой программы", "SCHEDULE_ONLY_NOTICE": "Только расписание. Этот провайдер предоставляет историю программ, но не архив (catch-up) — прошедшие передачи воспроизвести нельзя.", "EMPTY_DAY_TITLE": "Нет программы на этот день", "EMPTY_DAY_DESCRIPTION": "Расписание на выбранный день ещё не получено. Для других дней данные есть.", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 677c3e03b..577f4ac60 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "Canlı izle", "WATCH_FROM_START": "Baştan izle", "ARCHIVE_UNAVAILABLE_NOTE": "Oynatma kullanılamıyor — bu sağlayıcı bir arşiv sunmuyor.", + "CATCHUP_FAILED": "Bu program için arşiv oynatma başlatılamadı", "SCHEDULE_ONLY_NOTICE": "Yalnızca program çizelgesi. Bu sağlayıcı program geçmişini sunar ancak catch-up sunmaz — geçmiş programlar oynatılamaz.", "EMPTY_DAY_TITLE": "Bu gün için program yok", "EMPTY_DAY_DESCRIPTION": "Seçilen güne ait program çizelgesi henüz gelmedi. Diğer günlerde veri var.", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 8f5540085..e473b0886 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "观看直播", "WATCH_FROM_START": "从头开始观看", "ARCHIVE_UNAVAILABLE_NOTE": "无法播放 —— 此提供商未提供归档。", + "CATCHUP_FAILED": "无法开始播放此节目的回看", "SCHEDULE_ONLY_NOTICE": "仅节目表。此提供商提供节目历史,但不支持 catch-up —— 无法播放过往节目。", "EMPTY_DAY_TITLE": "当天暂无节目", "EMPTY_DAY_DESCRIPTION": "所选日期的节目表尚未到达。其他日期已有数据。", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 09dafd1af..b138661e7 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -679,6 +679,7 @@ "WATCH_LIVE": "觀看直播", "WATCH_FROM_START": "從頭觀看", "ARCHIVE_UNAVAILABLE_NOTE": "無法播放——此供應商未提供存檔。", + "CATCHUP_FAILED": "無法開始播放此節目的回看", "SCHEDULE_ONLY_NOTICE": "僅提供節目表。此供應商提供節目歷史紀錄,但未提供 catch-up——無法播放過去的節目。", "EMPTY_DAY_TITLE": "這一天沒有節目", "EMPTY_DAY_DESCRIPTION": "所選日期的節目表尚未送達。其他日期有資料。", diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 2685d563c..54dbaeb90 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -652,6 +652,16 @@ class EpgService { `playlistState.activePlaybackUrl`. Inline and external players use `activePlaybackUrl ?? activeChannel.url`, and returning to live playback clears the override. +- The unified favorites/recent live tab + (`libs/portal/shared/ui/.../unified-collection/unified-live-tab.component.ts`) + hosts the same timeline but does not use the NgRx playlist state; it keeps + its own `activeTimeshift` signal, resolves the replay URL with + `resolveM3uCatchupUrl`, and swaps the inline player's playback target (or + hands the URL to the configured external player). Selecting another channel, + closing the player, or "Return to live" clears the override. +- Catch-up activation is never silent: if the replay URL cannot be resolved + for a programme the user clicked, both hosts surface a + `EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing. ## Interfaces diff --git a/libs/m3u-state/src/lib/effects.spec.ts b/libs/m3u-state/src/lib/effects.spec.ts index 08f5d560e..525953cf8 100644 --- a/libs/m3u-state/src/lib/effects.spec.ts +++ b/libs/m3u-state/src/lib/effects.spec.ts @@ -1,5 +1,7 @@ -import { Channel } from '@iptvnator/shared/interfaces'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { EpgActions } from './actions'; import { buildExternalPlayerPayload } from './external-player-payload.util'; +import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; describe('buildExternalPlayerPayload', () => { const activeChannel: Channel = { @@ -43,3 +45,77 @@ describe('buildExternalPlayerPayload', () => { }); }); }); + +describe('resolveActiveEpgProgramAction', () => { + const program: EpgProgram = { + start: '2026-07-05T09:00:00+03:00', + stop: '2026-07-05T10:50:00+03:00', + channel: 'sample-tv', + title: 'Archived show', + desc: null, + category: null, + }; + + const catchupChannel: Channel = { + id: 'channel-1', + name: 'Sample TV', + url: 'https://streams.example.com/live.m3u8', + group: { title: 'News' }, + tvg: { + id: 'sample-tv', + name: 'Sample TV', + url: '', + logo: '', + rec: '3', + }, + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: 'false', + epgParams: '', + } as Channel; + + it('resolves a catch-up url for a channel with archive support', () => { + const onUnavailable = jest.fn(); + + const action = resolveActiveEpgProgramAction( + program, + catchupChannel, + onUnavailable + ) as ReturnType; + + expect(action.type).toBe(EpgActions.setActivePlaybackUrl.type); + expect(action.playbackUrl).toContain( + 'https://streams.example.com/live.m3u8?utc=' + ); + expect(action.program).toEqual(program); + expect(onUnavailable).not.toHaveBeenCalled(); + }); + + it('notifies and resets when the channel exposes no catch-up window', () => { + const onUnavailable = jest.fn(); + + const action = resolveActiveEpgProgramAction( + program, + { + ...catchupChannel, + tvg: { ...catchupChannel.tvg, rec: '' }, + } as Channel, + onUnavailable + ); + + expect(action.type).toBe(EpgActions.resetActiveEpgProgram.type); + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); + + it('notifies and resets when there is no active channel', () => { + const onUnavailable = jest.fn(); + + const action = resolveActiveEpgProgramAction( + program, + undefined, + onUnavailable + ); + + expect(action.type).toBe(EpgActions.resetActiveEpgProgram.type); + expect(onUnavailable).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 20f9765d5..3931aafca 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -2,10 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Router } from '@angular/router'; import { EpgService } from '@iptvnator/epg/data-access'; -import { - normalizeEpgUrls, - resolveM3uCatchupUrl, -} from '@iptvnator/shared/m3u-utils'; +import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; @@ -50,6 +47,7 @@ import { import { resolveChannelEpgLookupKey } from './channel-epg-lookup.util'; import { buildExternalPlayerPayload } from './external-player-payload.util'; import { resolvePlaylistScopedEpgFetchPlan } from './playlist-scoped-epg-fetch.util'; +import { resolveActiveEpgProgramAction } from './resolve-active-epg-program.util'; @Injectable({ providedIn: 'any' }) export class PlaylistEffects { @@ -112,18 +110,20 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(EpgActions.setActiveEpgProgram), withLatestFrom(this.store.select(selectActive)), - map(([action, activeChannel]) => { - const playbackUrl = activeChannel - ? resolveM3uCatchupUrl(activeChannel, action.program) - : null; - - return playbackUrl - ? EpgActions.setActivePlaybackUrl({ - playbackUrl, - program: action.program, - }) - : EpgActions.resetActiveEpgProgram(); - }) + map(([action, activeChannel]) => + resolveActiveEpgProgramAction( + action.program, + activeChannel, + () => + this.snackBar.open( + this.translate.instant( + 'EPG.TIMELINE.CATCHUP_FAILED' + ), + undefined, + { duration: 4000 } + ) + ) + ) ); }); diff --git a/libs/m3u-state/src/lib/resolve-active-epg-program.util.ts b/libs/m3u-state/src/lib/resolve-active-epg-program.util.ts new file mode 100644 index 000000000..b409e4d66 --- /dev/null +++ b/libs/m3u-state/src/lib/resolve-active-epg-program.util.ts @@ -0,0 +1,25 @@ +import { resolveM3uCatchupUrl } from '@iptvnator/shared/m3u-utils'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { EpgActions } from './actions'; + +/** + * Map a catch-up request to its follow-up action. The user explicitly asked + * for archive playback, so an unresolvable URL must surface through + * `onUnavailable` (snackbar) instead of silently resetting. + */ +export function resolveActiveEpgProgramAction( + program: EpgProgram, + activeChannel: Channel | undefined | null, + onUnavailable: () => void +) { + const playbackUrl = activeChannel + ? resolveM3uCatchupUrl(activeChannel, program) + : null; + + if (!playbackUrl) { + onUnavailable(); + return EpgActions.resetActiveEpgProgram(); + } + + return EpgActions.setActivePlaybackUrl({ playbackUrl, program }); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts new file mode 100644 index 000000000..0d255a4a1 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts @@ -0,0 +1,113 @@ +import { ResolvedLiveCollectionDetail } from '@iptvnator/portal/shared/data-access'; +import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; +import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; + +/** + * Pure helpers for the unified live tab's collapsed EPG summary: pick the + * on-air programme from either EPG shape and flatten it for the panel header. + */ + +export function getLiveEpgPanelSummary( + detail: ResolvedLiveCollectionDetail | null +): LiveEpgPanelSummary | null { + if (!detail) { + return null; + } + + if (detail.epgMode === 'm3u') { + return toLiveEpgPanelSummary( + findCurrentM3uProgram(detail.epgPrograms ?? []) + ); + } + + return toLiveEpgPanelSummary( + findCurrentPortalProgram(detail.epgItems ?? []) + ); +} + +export function toLiveEpgPanelSummary( + program: EpgItem | EpgProgram | null | undefined +): LiveEpgPanelSummary | null { + if (!program) { + return null; + } + + return { + title: program.title, + start: program.start, + stop: program.stop ?? ('end' in program ? program.end : null), + }; +} + +/** Normalise a portal EPG item into the flat timeline programme shape. */ +export function toEpgProgram(item: EpgItem): EpgProgram { + return { + start: item.start, + stop: item.stop ?? item.end, + channel: item.channel_id ?? item.id, + title: item.title, + desc: item.description ?? null, + category: null, + startTimestamp: toTimestampSeconds(item.start_timestamp), + stopTimestamp: toTimestampSeconds(item.stop_timestamp), + }; +} + +function findCurrentM3uProgram( + programs: readonly EpgProgram[] +): EpgProgram | null { + const now = Date.now(); + return ( + programs.find((program) => { + const start = getProgramTimeMs( + program.start, + program.startTimestamp + ); + const stop = getProgramTimeMs(program.stop, program.stopTimestamp); + + return ( + start !== null && stop !== null && now >= start && now < stop + ); + }) ?? null + ); +} + +function findCurrentPortalProgram( + programs: readonly EpgItem[] +): EpgItem | null { + const now = Date.now(); + return ( + programs.find((program) => { + const start = getProgramTimeMs( + program.start, + program.start_timestamp + ); + const stop = getProgramTimeMs( + program.stop ?? program.end, + program.stop_timestamp + ); + + return ( + start !== null && stop !== null && now >= start && now < stop + ); + }) ?? null + ); +} + +function getProgramTimeMs( + rawDate: string | null | undefined, + rawTimestamp?: number | string | null +): number | null { + const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10); + if (Number.isFinite(timestamp) && timestamp > 0) { + return timestamp * 1000; + } + + const parsedDate = Date.parse(rawDate ?? ''); + return Number.isFinite(parsedDate) ? parsedDate : null; +} + +function toTimestampSeconds(value: string | null | undefined): number | null { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index edc6f56b2..47192240f 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -59,7 +59,7 @@ (null); + readonly isLivePlayback = input(true); readonly selectedDate = input(null); readonly collapsed = input(false); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -99,7 +103,8 @@ class StubEpgTimelineComponent { readonly summaryLabelKey = input(''); readonly selectedDateChange = output(); readonly collapsedChange = output(); - readonly programActivated = output(); + readonly programActivated = output(); + readonly returnToLive = output(); } @Component({ @@ -145,6 +150,7 @@ describe('UnifiedLiveTabComponent', () => { openResolvedPlayback: jest.Mock; openExternalPlayback: jest.Mock; }; + let snackBar: { open: jest.Mock }; const originalElectron = window.electron; beforeEach(async () => { @@ -170,6 +176,7 @@ describe('UnifiedLiveTabComponent', () => { openResolvedPlayback: jest.fn(), openExternalPlayback: jest.fn(), }; + snackBar = { open: jest.fn() }; await TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent], @@ -193,6 +200,7 @@ describe('UnifiedLiveTabComponent', () => { }, }, { provide: PORTAL_PLAYER, useValue: portalPlayer }, + { provide: MatSnackBar, useValue: snackBar }, ], }) .overrideComponent(UnifiedLiveTabComponent, { @@ -911,6 +919,148 @@ describe('UnifiedLiveTabComponent', () => { ); expect(autoOpenHandledSpy).toHaveBeenCalledTimes(1); }); + + describe('M3U catch-up (timeshift) playback', () => { + const selectCatchupChannel = async (rec = '3') => { + const item = buildLiveItem('m3u'); + streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ + epgMode: 'm3u', + playback: { + streamUrl: 'https://example.com/m3u.m3u8', + title: 'M3U Live', + }, + channel: { + id: 'm3u-channel', + name: 'M3U Live', + url: 'https://example.com/m3u.m3u8', + group: { title: 'News' }, + tvg: { + id: 'm3u-channel', + name: 'M3U Live', + url: '', + logo: 'm3u.png', + rec, + }, + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: 'false', + epgParams: '', + }, + epgPrograms: [buildProgram('M3U Show')], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + + await component.onChannelSelected(component.channelsForList()[0]); + fixture.detectChanges(); + await fixture.whenStable(); + }; + + const timeshiftEvent = (): EpgProgramActivationEvent => ({ + program: buildProgram('M3U Show'), + type: 'timeshift', + }); + + it('switches the inline player to the catch-up stream on Watch', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + await selectCatchupChannel(); + + component.onTimelineProgramActivated(timeshiftEvent()); + fixture.detectChanges(); + + const playback = component.inlinePlayback(); + expect(playback?.streamUrl).toContain( + 'https://example.com/m3u.m3u8?utc=' + ); + expect(playback?.streamUrl).toContain('lutc='); + expect(playback?.isLive).toBe(false); + expect(component.activeTimeshiftProgram()?.title).toBe('M3U Show'); + expect(component.liveEpgPanelSummaryLabelKey()).toBe( + 'EPG.ARCHIVE_PLAYBACK' + ); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('shows feedback instead of failing silently when the channel has no catch-up', async () => { + await selectCatchupChannel(''); + + component.onTimelineProgramActivated(timeshiftEvent()); + + expect(component.activeTimeshift()).toBeNull(); + expect(component.inlinePlayback()?.streamUrl).toBe( + 'https://example.com/m3u.m3u8' + ); + expect(snackBar.open).toHaveBeenCalledTimes(1); + }); + + it('returns to the live stream from catch-up playback', async () => { + await selectCatchupChannel(); + component.onTimelineProgramActivated(timeshiftEvent()); + + component.returnToLivePlayback(); + + expect(component.activeTimeshift()).toBeNull(); + expect(component.inlinePlayback()?.streamUrl).toBe( + 'https://example.com/m3u.m3u8' + ); + expect(component.liveEpgPanelSummaryLabelKey()).toBe( + 'EPG.CURRENT_PROGRAM' + ); + }); + + it('activating "live" from the timeline resets catch-up playback', async () => { + await selectCatchupChannel(); + component.onTimelineProgramActivated(timeshiftEvent()); + + component.onTimelineProgramActivated({ + program: buildProgram('M3U Show'), + type: 'live', + }); + + expect(component.activeTimeshift()).toBeNull(); + }); + + it('clears catch-up playback when another channel is selected', async () => { + await selectCatchupChannel(); + component.onTimelineProgramActivated(timeshiftEvent()); + + component.onClose(); + + expect(component.activeTimeshift()).toBeNull(); + }); + + it('opens the external player on "Watch live" even without an active timeshift', async () => { + await selectCatchupChannel(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + portalPlayer.openResolvedPlayback.mockClear(); + + component.returnToLivePlayback(); + + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + streamUrl: 'https://example.com/m3u.m3u8', + }) + ); + }); + + it('hands the catch-up stream to the external player when no inline player is used', async () => { + await selectCatchupChannel(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + + component.onTimelineProgramActivated(timeshiftEvent()); + + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith( + expect.objectContaining({ + isLive: false, + streamUrl: expect.stringContaining( + 'https://example.com/m3u.m3u8?utc=' + ), + }) + ); + }); + }); }); function buildLiveItem( 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 7c518374d..7ec920c23 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 @@ -12,10 +12,12 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { TranslatePipe } from '@ngx-translate/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { getM3uArchiveDays, isM3uCatchupPlaybackSupported, + resolveM3uCatchupUrl, } from '@iptvnator/shared/m3u-utils'; import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, @@ -37,10 +39,16 @@ import { import { EpgDateNavigationDirection, EpgListViewComponent, + EpgProgramActivationEvent, EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, } from '@iptvnator/ui/epg'; +import { + getLiveEpgPanelSummary, + toEpgProgram, + toLiveEpgPanelSummary, +} from './unified-live-epg-summary.util'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component'; import { @@ -50,8 +58,7 @@ import { } from '@iptvnator/ui/playback'; import { ResizableDirective } from '@iptvnator/ui/components'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; -import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; -import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; @Component({ selector: 'app-unified-live-tab', @@ -95,6 +102,8 @@ export class UnifiedLiveTabComponent { private readonly settingsStore = inject(SettingsStore); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly destroyRef = inject(DestroyRef); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); readonly player = this.settingsStore.player; readonly supportsEpg = this.runtime.supportsEpg; @@ -111,8 +120,30 @@ export class UnifiedLiveTabComponent { restoreLiveEpgPanelState() ); readonly selectedLiveEpgDate = signal(getTodayEpgDateKey()); + /** Catch-up override for the active M3U channel; null = live playback. */ + readonly activeTimeshift = signal<{ + url: string; + program: EpgProgram; + } | null>(null); + readonly activeTimeshiftProgram = computed( + () => this.activeTimeshift()?.program ?? null + ); + /** Playback target for the inline player, honouring a catch-up override. */ + readonly inlinePlayback = computed(() => { + const playback = this.activeDetail()?.playback ?? null; + const timeshift = this.activeTimeshift(); + if (!playback || !timeshift) { + return playback; + } + + return { + ...playback, + streamUrl: timeshift.url, + isLive: false, + }; + }); readonly currentStreamUrl = computed( - () => this.activeDetail()?.playback.streamUrl ?? '' + () => this.inlinePlayback()?.streamUrl ?? '' ); readonly isM3uSelection = computed( () => this.activeDetail()?.epgMode === 'm3u' @@ -196,9 +227,19 @@ export class UnifiedLiveTabComponent { /** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */ readonly epgViewMode = this.settingsStore.resolvedEpgViewMode; readonly liveEpgPanelSummary = computed(() => { + const timeshift = this.activeTimeshift(); + if (timeshift) { + // Archive summary is frozen — don't track the 30s progress tick. + return toLiveEpgPanelSummary(timeshift.program); + } this.progressTick(); - return this.getLiveEpgPanelSummary(this.activeDetail()); + return getLiveEpgPanelSummary(this.activeDetail()); }); + readonly liveEpgPanelSummaryLabelKey = computed(() => + this.activeTimeshift() + ? 'EPG.ARCHIVE_PLAYBACK' + : 'EPG.CURRENT_PROGRAM' + ); readonly channelsForList = computed((): UnifiedFavoriteChannel[] => this.items().map((item) => ({ @@ -340,6 +381,52 @@ export class UnifiedLiveTabComponent { this.selectedLiveEpgDate.set(selectedDate); } + onTimelineProgramActivated(event: EpgProgramActivationEvent): void { + if (event.type === 'live') { + this.returnToLivePlayback(); + return; + } + + const playbackUrl = resolveM3uCatchupUrl( + this.currentM3uChannel(), + event.program + ); + if (!playbackUrl) { + this.snackBar.open( + this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), + undefined, + { duration: 4000 } + ); + return; + } + + this.activeTimeshift.set({ url: playbackUrl, program: event.program }); + + // No inline player (external MPV/VLC configured) → hand the archive + // stream to the external player, mirroring the live-selection flow. + const playback = this.activeDetail()?.playback; + if (!this.shouldUseInlinePlayer() && playback) { + void this.portalPlayer.openResolvedPlayback({ + ...playback, + streamUrl: playbackUrl, + isLive: false, + }); + } + } + + returnToLivePlayback(): void { + this.activeTimeshift.set(null); + + // Inline player is already (back) on the live stream once the + // timeshift override is cleared. With an external player configured, + // "Watch live" must open it even when no archive was active — e.g. + // openStreamOnDoubleClick shows the guide without launching playback. + const playback = this.activeDetail()?.playback; + if (!this.shouldUseInlinePlayer() && playback) { + void this.portalPlayer.openResolvedPlayback(playback); + } + } + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { void this.portalPlayer.openExternalPlayback( request.playback, @@ -352,6 +439,7 @@ export class UnifiedLiveTabComponent { this.isSelecting.set(false); this.activeDetail.set(null); this.activeUid.set(null); + this.activeTimeshift.set(null); } private async loadEpgMap(items: UnifiedCollectionItem[]): Promise { @@ -383,6 +471,7 @@ export class UnifiedLiveTabComponent { const requestId = ++this.selectionRequestId; this.activeUid.set(item.uid); this.activeDetail.set(null); + this.activeTimeshift.set(null); this.isSelecting.set(true); try { @@ -482,117 +571,4 @@ export class UnifiedLiveTabComponent { }); } - private getLiveEpgPanelSummary( - detail: ResolvedLiveCollectionDetail | null - ): LiveEpgPanelSummary | null { - if (!detail) { - return null; - } - - if (detail.epgMode === 'm3u') { - return this.toLiveEpgPanelSummary( - this.findCurrentM3uProgram(detail.epgPrograms ?? []) - ); - } - - return this.toLiveEpgPanelSummary( - this.findCurrentPortalProgram(detail.epgItems ?? []) - ); - } - - private findCurrentM3uProgram( - programs: readonly EpgProgram[] - ): EpgProgram | null { - const now = Date.now(); - return ( - programs.find((program) => { - const start = this.getProgramTimeMs( - program.start, - program.startTimestamp - ); - const stop = this.getProgramTimeMs( - program.stop, - program.stopTimestamp - ); - - return ( - start !== null && - stop !== null && - now >= start && - now < stop - ); - }) ?? null - ); - } - - private findCurrentPortalProgram( - programs: readonly EpgItem[] - ): EpgItem | null { - const now = Date.now(); - return ( - programs.find((program) => { - const start = this.getProgramTimeMs( - program.start, - program.start_timestamp - ); - const stop = this.getProgramTimeMs( - program.stop ?? program.end, - program.stop_timestamp - ); - - return ( - start !== null && - stop !== null && - now >= start && - now < stop - ); - }) ?? null - ); - } - - private toLiveEpgPanelSummary( - program: EpgItem | EpgProgram | null | undefined - ): LiveEpgPanelSummary | null { - if (!program) { - return null; - } - - return { - title: program.title, - start: program.start, - stop: program.stop ?? ('end' in program ? program.end : null), - }; - } - - private getProgramTimeMs( - rawDate: string | null | undefined, - rawTimestamp?: number | string | null - ): number | null { - const timestamp = Number.parseInt(String(rawTimestamp ?? ''), 10); - if (Number.isFinite(timestamp) && timestamp > 0) { - return timestamp * 1000; - } - - const parsedDate = Date.parse(rawDate ?? ''); - return Number.isFinite(parsedDate) ? parsedDate : null; - } -} - -/** Normalise a portal EPG item into the flat timeline programme shape. */ -function toEpgProgram(item: EpgItem): EpgProgram { - return { - start: item.start, - stop: item.stop ?? item.end, - channel: item.channel_id ?? item.id, - title: item.title, - desc: item.description ?? null, - category: null, - startTimestamp: toTimestampSeconds(item.start_timestamp), - stopTimestamp: toTimestampSeconds(item.stop_timestamp), - }; -} - -function toTimestampSeconds(value: string | null | undefined): number | null { - const parsed = Number.parseInt(String(value ?? ''), 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; }