fix(epg): wire catch-up Watch button in unified live tab, surface resolve failures (#1133)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-05 14:47:34 +02:00
1 parent 862b271eac
commit c55acfb1d9
26 files changed
+519 -137

No files matched your search

+1
View File
@@ -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": "لم يصل جدول اليوم المحدد بعد. أيام أخرى تحتوي على بيانات.",
+1
View File
@@ -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": "جدول البرامج ديال النهار اللي اختاريتي مازال ما وصلش. أيام أخرى عندهم بيانات.",
+1
View File
@@ -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": "Праграма на выбраны дзень яшчэ не паступіла. Для іншых дзён даныя ёсць.",
+1
View File
@@ -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.",
+1
View File
@@ -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": "Το πρόγραμμα για την επιλεγμένη ημέρα δεν έχει φτάσει ακόμα. Άλλες ημέρες έχουν δεδομένα.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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": "選択した日の番組表はまだ届いていません。他の日にはデータがあります。",
+1
View File
@@ -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": "선택한 날짜의 편성표가 아직 도착하지 않았습니다. 다른 날짜에는 데이터가 있습니다.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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": "Расписание на выбранный день ещё не получено. Для других дней данные есть.",
+1
View File
@@ -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.",
+1
View File
@@ -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": "所选日期的节目表尚未到达。其他日期已有数据。",
+1
View File
@@ -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": "所選日期的節目表尚未送達。其他日期有資料。",
+10
View File
@@ -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
+77 -1
View File
@@ -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<typeof EpgActions.setActivePlaybackUrl>;
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);
});
});
+16 -16
View File
@@ -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 }
)
)
)
);
});
@@ -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 });
}
@@ -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;
}
@@ -59,7 +59,7 @@
<app-web-player-view
[streamUrl]="currentStreamUrl()"
[title]="activeDetail()?.playback?.title ?? ''"
[playback]="activeDetail()?.playback ?? null"
[playback]="inlinePlayback()"
[playerOverride]="player()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
@@ -86,12 +86,19 @@
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
shouldUseInlinePlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
@@ -106,12 +113,19 @@
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
shouldUseInlinePlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
onTimelineProgramActivated($event)
"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
@@ -18,10 +18,12 @@ import {
} from '@iptvnator/ui/playback';
import {
EpgListViewComponent,
EpgProgramActivationEvent,
EpgTimelineComponent,
getTodayEpgDateKey,
shiftEpgDateKey,
} from '@iptvnator/ui/epg';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
RuntimeCapabilitiesService,
@@ -92,6 +94,8 @@ class StubEpgTimelineComponent {
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly selectedDate = input<string | null>(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<string>();
readonly collapsedChange = output<boolean>();
readonly programActivated = output<EpgProgram>();
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
}
@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(
@@ -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<void> {
@@ -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;
}