From c55acfb1d99111d6249aaffaef3571ee5380155f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:47:34 +0200 Subject: [PATCH] fix(epg): wire catch-up Watch button in unified live tab, surface resolve failures (#1133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(epg): wire catch-up Watch button in unified live tab, surface resolve failures The favorites/recent live views (UnifiedLiveTabComponent) passed archivePlaybackAvailable/archiveDays to the EPG timeline and list view, so the "Watch" button and the dialog's "Watch from start" action rendered — but neither (programActivated) nor (returnToLive) was bound. Clicking Watch emitted an event nobody listened to: no error, no feedback, no playback change. - Handle programActivated in the unified live tab: resolve the catch-up URL via resolveM3uCatchupUrl, swap the inline player's playback target (or hand it to the configured external player), show the "Archive playback" summary label, "From archive" block tag and "Return to live"; clear the override on channel switch/close/return. - Never fail silently: both the unified tab and the m3u-state effect now show an EPG.TIMELINE.CATCHUP_FAILED snackbar when the replay URL can't be resolved. New i18n key translated into all 17 locales via the i18n-fill workflow. - Extract the effect's resolve branch into a pure resolveActiveEpgProgramAction util and the unified tab's EPG summary helpers into unified-live-epg-summary.util.ts (component shrinks 598 → 572 lines despite the new feature). - Regression coverage: 6 component tests for the catch-up flow, 3 unit tests for the resolver action; docs updated in m3u-playlist-module.md. Co-Authored-By: Claude Fable 5 * fix(epg): address PR review — untrack progress tick in archive summary, open live external without prior timeshift - liveEpgPanelSummary no longer tracks the 30s progressTick while an archived programme is shown (Greptile P2): the archive summary is frozen, so re-running the computed each tick only produced needless re-renders. - returnToLivePlayback no longer early-returns when no timeshift is active (Codex P2): with an external player configured and openStreamOnDoubleClick enabled, the programme dialog's "Watch live" action now launches the external player instead of being dropped. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/m3u-playlist-module.md | 10 + libs/m3u-state/src/lib/effects.spec.ts | 78 ++++++- libs/m3u-state/src/lib/effects.ts | 32 +-- .../lib/resolve-active-epg-program.util.ts | 25 +++ .../unified-live-epg-summary.util.ts | 113 ++++++++++ .../unified-live-tab.component.html | 16 +- .../unified-live-tab.component.spec.ts | 152 ++++++++++++- .../unified-live-tab.component.ts | 212 ++++++++---------- 26 files changed, 519 insertions(+), 137 deletions(-) create mode 100644 libs/m3u-state/src/lib/resolve-active-epg-program.util.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-epg-summary.util.ts 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; }