mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(epg): copy catch-up programme URLs without changing playback
This commit is contained in:
1 parent
97b0264dee
commit
dcaeb26849
50 files changed
+850
-157
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
---
|
||||
|
||||
Copy a catch-up programme’s URL from its EPG details in Xtream and supported M3U sources, including Favorites and Recent, without interrupting playback. The link can be used in an external player or download tool.
|
||||
@@ -1019,3 +1019,11 @@ do not duplicate them here.
|
||||
- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
|
||||
|
||||
<!-- nx configuration end-->
|
||||
|
||||
## Catch-Up URL Copying
|
||||
|
||||
EPG timeline/list programme details expose Copy archive URL for supported
|
||||
Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns
|
||||
clipboard feedback; hosts resolve URLs without mutating playback. Stalker and
|
||||
the currently non-catch-up M3U guide expose no action. See
|
||||
`docs/architecture/m3u-playlist-module.md` (Copy archive URL).
|
||||
@@ -1830,3 +1830,11 @@ beside the resize handle. `PortalDetailShellComponent` owns a visible native
|
||||
scrollbar and guarded initial page focus. Contracts:
|
||||
`docs/architecture/iptvnator-ui-guidelines.md` and
|
||||
`docs/architecture/portal-detail-navigation.md`.
|
||||
|
||||
## Catch-Up URL Copying
|
||||
|
||||
EPG timeline/list programme details expose Copy archive URL for supported
|
||||
Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns
|
||||
clipboard feedback; hosts resolve URLs without mutating playback. Stalker and
|
||||
the currently non-catch-up M3U guide expose no action. See
|
||||
`docs/architecture/m3u-playlist-module.md` (Copy archive URL).
|
||||
@@ -239,3 +239,72 @@ test('@epg @xtream @electron derives the panel offset from its clock pair when t
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@epg @xtream @electron copies the archive URL without starting archive playback', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const credentials = { username: 'epg', password: 'epg' };
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, credentials);
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
env: { TZ: VIEWER_TIMEZONE },
|
||||
});
|
||||
try {
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Copy archive URL',
|
||||
...credentials,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openTimezoneNewsInLiveTv(app.mainWindow, fixture.categoryName);
|
||||
const captured = captureTimeshiftRequests(app.mainWindow);
|
||||
const block = app.mainWindow
|
||||
.locator('app-epg-timeline .epg-timeline__block')
|
||||
.filter({ hasText: PAST_PROGRAM })
|
||||
.first();
|
||||
await expect(block).toBeVisible({ timeout: 20000 });
|
||||
await block.locator('.epg-timeline__info').click();
|
||||
const dialog = app.mainWindow.getByRole('dialog');
|
||||
await expect(
|
||||
dialog.getByText(/URL may contain account credentials/)
|
||||
).toBeVisible();
|
||||
for (const dark of [false, true]) {
|
||||
await app.mainWindow.evaluate((enabled) => {
|
||||
document.body.classList.toggle('dark-theme', enabled);
|
||||
}, dark);
|
||||
await expect(
|
||||
dialog.getByRole('button', {
|
||||
name: 'Copy archive URL',
|
||||
exact: true,
|
||||
})
|
||||
).toBeInViewport();
|
||||
await dialog.screenshot({
|
||||
path: test
|
||||
.info()
|
||||
.outputPath(`archive-copy-${dark ? 'dark' : 'light'}.png`),
|
||||
});
|
||||
}
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Copy archive URL', exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow.getByText('Archive URL copied', { exact: true })
|
||||
).toBeVisible();
|
||||
const url = await app.electronApp.evaluate(({ clipboard }) =>
|
||||
clipboard.readText()
|
||||
);
|
||||
expect(url).toContain('/timeshift/epg/epg/');
|
||||
const start = TIMESHIFT_SEGMENT.exec(url)?.[1];
|
||||
expect(start).toBeTruthy();
|
||||
await expectServerClock(
|
||||
start!,
|
||||
[await pastProgramWindows(request, credentials)],
|
||||
0
|
||||
);
|
||||
// Main-process archive probes do not appear as renderer playback requests.
|
||||
expect(captured).toEqual([]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "اللغة",
|
||||
"SHOW_PROGRAM_DETAILS": "عرض تفاصيل هذا البرنامج",
|
||||
"EPISODE": "الحلقة",
|
||||
"DURATION": "المدة"
|
||||
"DURATION": "المدة",
|
||||
"COPY_ARCHIVE_URL": "نسخ رابط الأرشيف",
|
||||
"ARCHIVE_URL_COPIED": "تم نسخ رابط الأرشيف",
|
||||
"ARCHIVE_URL_FAILED": "تعذّر نسخ رابط الأرشيف",
|
||||
"ARCHIVE_URL_NOTICE": "قد يحتوي الرابط على بيانات الدخول. قد تحتاج المشغلات الخارجية أيضًا إلى ترويسات HTTP الخاصة بالمصدر."
|
||||
},
|
||||
"FETCH_SUCCESS": "تم جلب الدليل بنجاح.",
|
||||
"IMPORT_PROGRESS": "استيراد EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "اللغة",
|
||||
"SHOW_PROGRAM_DETAILS": "وري تفاصيل هاد البرنامج",
|
||||
"EPISODE": "الحلقة",
|
||||
"DURATION": "المدة"
|
||||
"DURATION": "المدة",
|
||||
"COPY_ARCHIVE_URL": "نسخ رابط الأرشيف",
|
||||
"ARCHIVE_URL_COPIED": "تنسخ رابط الأرشيف",
|
||||
"ARCHIVE_URL_FAILED": "ما قدرناش ننسخو رابط الأرشيف",
|
||||
"ARCHIVE_URL_NOTICE": "الرابط يقدر يكون فيه معلومات الدخول. المشغلات الخارجية تقدر تحتاج حتى ترويسات HTTP ديال المصدر."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG تجلب بنجاح",
|
||||
"IMPORT_PROGRESS": "استيراد EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Мова",
|
||||
"SHOW_PROGRAM_DETAILS": "Паказаць падрабязнасці гэтай перадачы",
|
||||
"EPISODE": "Эпізод",
|
||||
"DURATION": "Працягласць"
|
||||
"DURATION": "Працягласць",
|
||||
"COPY_ARCHIVE_URL": "Капіяваць спасылку на архіў",
|
||||
"ARCHIVE_URL_COPIED": "Спасылка на архіў скапіявана",
|
||||
"ARCHIVE_URL_FAILED": "Не ўдалося скапіяваць спасылку на архіў",
|
||||
"ARCHIVE_URL_NOTICE": "Спасылка можа змяшчаць даныя ўліковага запісу. Знешняму прайгравальніку таксама могуць спатрэбіцца HTTP-загалоўкі крыніцы."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG быў паспяхова загружаны",
|
||||
"IMPORT_PROGRESS": "Імпарт EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Sprache",
|
||||
"SHOW_PROGRAM_DETAILS": "Details zu dieser Sendung anzeigen",
|
||||
"EPISODE": "Episode",
|
||||
"DURATION": "Dauer"
|
||||
"DURATION": "Dauer",
|
||||
"COPY_ARCHIVE_URL": "Archiv-URL kopieren",
|
||||
"ARCHIVE_URL_COPIED": "Archiv-URL kopiert",
|
||||
"ARCHIVE_URL_FAILED": "Archiv-URL konnte nicht kopiert werden",
|
||||
"ARCHIVE_URL_NOTICE": "Die URL kann Zugangsdaten enthalten. Externe Player benötigen möglicherweise auch die HTTP-Header der Quelle."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG wurde erfolgreich abgerufen",
|
||||
"IMPORT_PROGRESS": "EPG-Import",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Γλώσσα",
|
||||
"SHOW_PROGRAM_DETAILS": "Εμφάνιση λεπτομερειών για αυτό το πρόγραμμα",
|
||||
"EPISODE": "Επεισόδιο",
|
||||
"DURATION": "Διάρκεια"
|
||||
"DURATION": "Διάρκεια",
|
||||
"COPY_ARCHIVE_URL": "Αντιγραφή URL αρχείου",
|
||||
"ARCHIVE_URL_COPIED": "Το URL αρχείου αντιγράφηκε",
|
||||
"ARCHIVE_URL_FAILED": "Αδυναμία αντιγραφής του URL αρχείου",
|
||||
"ARCHIVE_URL_NOTICE": "Το URL μπορεί να περιέχει στοιχεία σύνδεσης. Τα εξωτερικά προγράμματα αναπαραγωγής μπορεί να χρειάζονται και τις κεφαλίδες HTTP της πηγής."
|
||||
},
|
||||
"FETCH_SUCCESS": "Το EPG ανακτήθηκε με επιτυχία",
|
||||
"IMPORT_PROGRESS": "Εισαγωγή Ηλεκτρονικού Οδηγού Προγράμματος (EPG)",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Language",
|
||||
"SHOW_PROGRAM_DETAILS": "Show details about this program",
|
||||
"EPISODE": "Episode",
|
||||
"DURATION": "Duration"
|
||||
"DURATION": "Duration",
|
||||
"COPY_ARCHIVE_URL": "Copy archive URL",
|
||||
"ARCHIVE_URL_COPIED": "Archive URL copied",
|
||||
"ARCHIVE_URL_FAILED": "Could not copy the archive URL",
|
||||
"ARCHIVE_URL_NOTICE": "The URL may contain account credentials. External players may also need your source’s HTTP headers."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG was fetched successfully",
|
||||
"IMPORT_PROGRESS": "EPG Import",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Idioma",
|
||||
"SHOW_PROGRAM_DETAILS": "Mostrar detalles de este programa",
|
||||
"EPISODE": "Episodio",
|
||||
"DURATION": "Duración"
|
||||
"DURATION": "Duración",
|
||||
"COPY_ARCHIVE_URL": "Copiar URL del archivo",
|
||||
"ARCHIVE_URL_COPIED": "URL del archivo copiada",
|
||||
"ARCHIVE_URL_FAILED": "No se pudo copiar la URL del archivo",
|
||||
"ARCHIVE_URL_NOTICE": "La URL puede contener credenciales. Los reproductores externos también pueden necesitar las cabeceras HTTP de la fuente."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG obtenida correctamente",
|
||||
"IMPORT_PROGRESS": "Importación de EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Langue",
|
||||
"SHOW_PROGRAM_DETAILS": "Afficher les détails de ce programme",
|
||||
"EPISODE": "Épisode",
|
||||
"DURATION": "Durée"
|
||||
"DURATION": "Durée",
|
||||
"COPY_ARCHIVE_URL": "Copier le lien de rattrapage",
|
||||
"ARCHIVE_URL_COPIED": "Lien de rattrapage copié",
|
||||
"ARCHIVE_URL_FAILED": "Impossible de copier le lien de rattrapage",
|
||||
"ARCHIVE_URL_NOTICE": "Le lien peut contenir des identifiants. Les lecteurs externes peuvent aussi nécessiter les en-têtes HTTP de la source."
|
||||
},
|
||||
"FETCH_SUCCESS": "L'EPG a été récupéré avec succès",
|
||||
"IMPORT_PROGRESS": "Importation EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Nyelv",
|
||||
"SHOW_PROGRAM_DETAILS": "Műsor részleteinek megjelenítése",
|
||||
"EPISODE": "Epizód",
|
||||
"DURATION": "Időtartam"
|
||||
"DURATION": "Időtartam",
|
||||
"COPY_ARCHIVE_URL": "Archív URL másolása",
|
||||
"ARCHIVE_URL_COPIED": "Archív URL másolva",
|
||||
"ARCHIVE_URL_FAILED": "Az archív URL másolása sikertelen",
|
||||
"ARCHIVE_URL_NOTICE": "Az URL bejelentkezési adatokat tartalmazhat. A külső lejátszóknak a forrás HTTP-fejléceire is szükségük lehet."
|
||||
},
|
||||
"FETCH_SUCCESS": "Az EPG lekérése sikerült",
|
||||
"IMPORT_PROGRESS": "EPG importálása",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Lingua",
|
||||
"SHOW_PROGRAM_DETAILS": "Mostra dettagli su questo programma",
|
||||
"EPISODE": "Episodio",
|
||||
"DURATION": "Durata"
|
||||
"DURATION": "Durata",
|
||||
"COPY_ARCHIVE_URL": "Copia URL archivio",
|
||||
"ARCHIVE_URL_COPIED": "URL archivio copiato",
|
||||
"ARCHIVE_URL_FAILED": "Impossibile copiare URL archivio",
|
||||
"ARCHIVE_URL_NOTICE": "L’URL può contenere credenziali. I lettori esterni potrebbero richiedere anche le intestazioni HTTP della sorgente."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG scaricato correttamente",
|
||||
"IMPORT_PROGRESS": "Importazione EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "言語",
|
||||
"SHOW_PROGRAM_DETAILS": "この番組の詳細を表示",
|
||||
"EPISODE": "エピソード",
|
||||
"DURATION": "長さ"
|
||||
"DURATION": "長さ",
|
||||
"COPY_ARCHIVE_URL": "アーカイブURLをコピー",
|
||||
"ARCHIVE_URL_COPIED": "アーカイブURLをコピーしました",
|
||||
"ARCHIVE_URL_FAILED": "アーカイブURLをコピーできませんでした",
|
||||
"ARCHIVE_URL_NOTICE": "URLにはアカウントの認証情報が含まれる場合があります。外部プレーヤーではソースのHTTPヘッダーも必要になることがあります。"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPGの取得に成功しました",
|
||||
"IMPORT_PROGRESS": "EPGインポート",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "언어",
|
||||
"SHOW_PROGRAM_DETAILS": "이 프로그램의 상세 정보 표시",
|
||||
"EPISODE": "에피소드",
|
||||
"DURATION": "재생 시간"
|
||||
"DURATION": "재생 시간",
|
||||
"COPY_ARCHIVE_URL": "다시보기 URL 복사",
|
||||
"ARCHIVE_URL_COPIED": "다시보기 URL이 복사되었습니다",
|
||||
"ARCHIVE_URL_FAILED": "다시보기 URL을 복사하지 못했습니다",
|
||||
"ARCHIVE_URL_NOTICE": "URL에 계정 인증 정보가 포함될 수 있습니다. 외부 플레이어에는 소스의 HTTP 헤더도 필요할 수 있습니다."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG를 성공적으로 가져왔습니다",
|
||||
"IMPORT_PROGRESS": "EPG 가져오기",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Taal",
|
||||
"SHOW_PROGRAM_DETAILS": "Details over dit programma tonen",
|
||||
"EPISODE": "Aflevering",
|
||||
"DURATION": "Duur"
|
||||
"DURATION": "Duur",
|
||||
"COPY_ARCHIVE_URL": "Archief-URL kopiëren",
|
||||
"ARCHIVE_URL_COPIED": "Archief-URL gekopieerd",
|
||||
"ARCHIVE_URL_FAILED": "Kan archief-URL niet kopiëren",
|
||||
"ARCHIVE_URL_NOTICE": "De URL kan inloggegevens bevatten. Externe spelers hebben mogelijk ook de HTTP-headers van de bron nodig."
|
||||
},
|
||||
"FETCH_SUCCESS": "TV-Gids bijgewerkt",
|
||||
"IMPORT_PROGRESS": "EPG-import",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Język",
|
||||
"SHOW_PROGRAM_DETAILS": "Pokaż szczegóły tego programu",
|
||||
"EPISODE": "Odcinek",
|
||||
"DURATION": "Czas trwania"
|
||||
"DURATION": "Czas trwania",
|
||||
"COPY_ARCHIVE_URL": "Kopiuj adres archiwum",
|
||||
"ARCHIVE_URL_COPIED": "Skopiowano adres archiwum",
|
||||
"ARCHIVE_URL_FAILED": "Nie udało się skopiować adresu archiwum",
|
||||
"ARCHIVE_URL_NOTICE": "Adres może zawierać dane logowania. Odtwarzacze zewnętrzne mogą też wymagać nagłówków HTTP źródła."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG zostało pomyślnie pobrane",
|
||||
"IMPORT_PROGRESS": "Import EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Idioma",
|
||||
"SHOW_PROGRAM_DETAILS": "Mostrar detalhes sobre este programa",
|
||||
"EPISODE": "Episódio",
|
||||
"DURATION": "Duração"
|
||||
"DURATION": "Duração",
|
||||
"COPY_ARCHIVE_URL": "Copiar URL do arquivo",
|
||||
"ARCHIVE_URL_COPIED": "URL do arquivo copiada",
|
||||
"ARCHIVE_URL_FAILED": "Não foi possível copiar a URL do arquivo",
|
||||
"ARCHIVE_URL_NOTICE": "A URL pode conter credenciais. Os leitores externos também podem precisar dos cabeçalhos HTTP da fonte."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG foi buscado com sucesso",
|
||||
"IMPORT_PROGRESS": "Importação de EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Язык",
|
||||
"SHOW_PROGRAM_DETAILS": "Показать сведения об этой программе",
|
||||
"EPISODE": "Эпизод",
|
||||
"DURATION": "Длительность"
|
||||
"DURATION": "Длительность",
|
||||
"COPY_ARCHIVE_URL": "Копировать ссылку на архив",
|
||||
"ARCHIVE_URL_COPIED": "Ссылка на архив скопирована",
|
||||
"ARCHIVE_URL_FAILED": "Не удалось скопировать ссылку на архив",
|
||||
"ARCHIVE_URL_NOTICE": "Ссылка может содержать данные аккаунта. Внешнему плееру также могут понадобиться HTTP-заголовки источника."
|
||||
},
|
||||
"FETCH_SUCCESS": "Программа каналов (EPG) была успешно получена",
|
||||
"IMPORT_PROGRESS": "Импорт EPG",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "Dil",
|
||||
"SHOW_PROGRAM_DETAILS": "Bu programın ayrıntılarını göster",
|
||||
"EPISODE": "Bölüm",
|
||||
"DURATION": "Süre"
|
||||
"DURATION": "Süre",
|
||||
"COPY_ARCHIVE_URL": "Arşiv URL’sini kopyala",
|
||||
"ARCHIVE_URL_COPIED": "Arşiv URL’si kopyalandı",
|
||||
"ARCHIVE_URL_FAILED": "Arşiv URL’si kopyalanamadı",
|
||||
"ARCHIVE_URL_NOTICE": "URL hesap bilgileri içerebilir. Harici oynatıcılar kaynağın HTTP başlıklarına da ihtiyaç duyabilir."
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG verileri başarıyla alındı",
|
||||
"IMPORT_PROGRESS": "EPG İçe Aktarımı",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "语言",
|
||||
"SHOW_PROGRAM_DETAILS": "显示此节目的详细信息",
|
||||
"EPISODE": "剧集",
|
||||
"DURATION": "时长"
|
||||
"DURATION": "时长",
|
||||
"COPY_ARCHIVE_URL": "复制回看链接",
|
||||
"ARCHIVE_URL_COPIED": "已复制回看链接",
|
||||
"ARCHIVE_URL_FAILED": "无法复制回看链接",
|
||||
"ARCHIVE_URL_NOTICE": "链接可能包含账户凭据。外部播放器可能还需要此来源的 HTTP 请求头。"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG 获取成功",
|
||||
"IMPORT_PROGRESS": "EPG 导入",
|
||||
|
||||
@@ -792,7 +792,11 @@
|
||||
"LANGUAGE": "語言",
|
||||
"SHOW_PROGRAM_DETAILS": "顯示關於該節目的詳細資訊",
|
||||
"EPISODE": "集數",
|
||||
"DURATION": "時長"
|
||||
"DURATION": "時長",
|
||||
"COPY_ARCHIVE_URL": "複製回看連結",
|
||||
"ARCHIVE_URL_COPIED": "已複製回看連結",
|
||||
"ARCHIVE_URL_FAILED": "無法複製回看連結",
|
||||
"ARCHIVE_URL_NOTICE": "連結可能包含帳戶憑證。外部播放器可能還需要此來源的 HTTP 標頭。"
|
||||
},
|
||||
"FETCH_SUCCESS": "EPG 已成功擷取",
|
||||
"IMPORT_PROGRESS": "EPG 匯入",
|
||||
|
||||
@@ -1123,6 +1123,23 @@ class EpgService {
|
||||
for a programme the user clicked, both hosts surface a
|
||||
`EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing.
|
||||
|
||||
### Copy archive URL
|
||||
|
||||
Programme details opened from the EPG timeline or list offer **Copy archive
|
||||
URL** when catch-up is available. Xtream Live TV, M3U playback and their
|
||||
Favorites/Recent live tabs resolve the same URL as archive playback without
|
||||
selecting a programme or changing the player. Stalker and unsupported/expired
|
||||
archive entries expose no copy action. The full M3U guide currently has no
|
||||
catch-up capability and therefore does not expose this action.
|
||||
|
||||
The URL can contain account credentials; the dialog explains that external
|
||||
players may also need the source's HTTP headers. Copy is explicit, errors use
|
||||
localized feedback without raw URLs, and repeated clicks share one pending
|
||||
copy operation. Timeline/list dialogs capture the host's source/channel
|
||||
`archiveContextKey`, refuse actions after it changes, and unsubscribe when
|
||||
the view is destroyed. A copied M3U start-over URL need not stop at the programme's
|
||||
end. Copying a URL does not download or preserve an expiring archive.
|
||||
|
||||
### Programme guide
|
||||
|
||||
`app-epg-guide` (`libs/ui/epg/src/lib/epg-guide/`) is a host-agnostic
|
||||
|
||||
@@ -257,6 +257,11 @@ tab can gate the timeline's archive window. `tv_archive_duration` is
|
||||
interpreted as **days** everywhere, matching
|
||||
`live-stream-layout.controlledArchiveDays` (issue #1138).
|
||||
|
||||
Programme details in the Live TV and Favorites/Recent EPG timeline/list also
|
||||
offer **Copy archive URL**. This uses the same resolver and persisted server
|
||||
timezone without changing playback. See the M3U module's "Copy archive URL"
|
||||
contract for clipboard feedback and credential/header handling.
|
||||
|
||||
### Start time is the panel's clock, not the viewer's
|
||||
|
||||
The `{start}` segment (`Y-m-d:H-M`) is read by the panel with `strtotime()`
|
||||
|
||||
+26
@@ -1,3 +1,4 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
import { Settings, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import {
|
||||
activeChannel,
|
||||
buildAiringProgram,
|
||||
dataServiceMock,
|
||||
epgServiceMock,
|
||||
epgUrlSetting,
|
||||
@@ -229,4 +232,27 @@ describe('VideoPlayerComponent — guide gating', () => {
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
overlayContainer.remove();
|
||||
});
|
||||
it('copies the archive URL without dispatching playback or opening the guide', async () => {
|
||||
const copy = jest
|
||||
.spyOn(TestBed.inject(Clipboard), 'copy')
|
||||
.mockReturnValue(true);
|
||||
activeChannel.set({
|
||||
...sampleChannel,
|
||||
catchup: { type: 'shift', days: '7' },
|
||||
});
|
||||
const program = buildAiringProgram('Archive');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
storeMock.dispatch.mockClear();
|
||||
component.onTimelineProgramActivated({
|
||||
type: 'copy-catchup-url',
|
||||
program,
|
||||
});
|
||||
await fixture.whenStable();
|
||||
expect(copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`utc=${program.startTimestamp}`)
|
||||
);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalled();
|
||||
copy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,7 @@
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archiveContextKey]="archiveContextKey()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
"
|
||||
@@ -211,6 +212,7 @@
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archiveContextKey]="archiveContextKey()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
"
|
||||
|
||||
@@ -32,9 +32,11 @@ import {
|
||||
isDashStreamUrl,
|
||||
isLikelyM3uMovie,
|
||||
isM3uCatchupPlaybackSupported,
|
||||
resolveM3uCatchupUrl,
|
||||
} from '@iptvnator/shared/m3u-utils';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
EpgArchiveCopyService,
|
||||
EPG_GUIDE_SOURCE,
|
||||
EpgDateNavigationDirection,
|
||||
EpgGuideComponent,
|
||||
@@ -307,6 +309,14 @@ export class VideoPlayerComponent
|
||||
readonly fullscreenPanelChannels = computed(() =>
|
||||
this.channels().filter((channel) => this.keepsInlinePlayer(channel))
|
||||
);
|
||||
readonly archiveContextKey = computed(() =>
|
||||
JSON.stringify([
|
||||
this.activePlaylistId(),
|
||||
this.activeChannel()?.id,
|
||||
this.activeChannel()?.url,
|
||||
])
|
||||
);
|
||||
private readonly archiveCopy = inject(EpgArchiveCopyService);
|
||||
private readonly guideSource = inject(M3uEpgGuideSourceService);
|
||||
/** Guide mode: sidebar and timeline give way to the multi-channel grid. */
|
||||
readonly guideOpen = signal(false);
|
||||
@@ -1108,6 +1118,13 @@ export class VideoPlayerComponent
|
||||
}
|
||||
|
||||
onTimelineProgramActivated(event: EpgProgramActivationEvent): void {
|
||||
if (event.type === 'copy-catchup-url') {
|
||||
const channel = this.activeChannel();
|
||||
void this.archiveCopy.copy(() =>
|
||||
resolveM3uCatchupUrl(channel, event.program)
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.type === 'live') {
|
||||
this.returnToLivePlayback();
|
||||
return;
|
||||
|
||||
@@ -26,6 +26,7 @@ export class StubEpgTimelineComponent {
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
readonly archiveContextKey = input<string | null>(null);
|
||||
readonly archiveDays = input(0);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly isLivePlayback = input(true);
|
||||
|
||||
+2
@@ -90,6 +90,7 @@
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archiveContextKey]="archiveContextKey()"
|
||||
[archivePlaybackAvailable]="
|
||||
timelineArchiveAvailable()
|
||||
"
|
||||
@@ -120,6 +121,7 @@
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archiveContextKey]="archiveContextKey()"
|
||||
[archivePlaybackAvailable]="
|
||||
timelineArchiveAvailable()
|
||||
"
|
||||
|
||||
+51
-122
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
buildLiveItem,
|
||||
buildProgram,
|
||||
buildCurrentProgram,
|
||||
buildEpgItem,
|
||||
buildCurrentEpgItem,
|
||||
createDeferred,
|
||||
} from './unified-live-tab.spec-data';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
@@ -18,11 +27,7 @@ import {
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EpgItem, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
PORTAL_PLAYER,
|
||||
UnifiedCollectionItem,
|
||||
@@ -983,6 +988,25 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
type: 'timeshift',
|
||||
});
|
||||
|
||||
it('copies M3U archive URLs without changing the active playback', async () => {
|
||||
await selectCatchupChannel();
|
||||
const before = component.inlinePlayback();
|
||||
const copy = jest
|
||||
.spyOn(TestBed.inject(Clipboard), 'copy')
|
||||
.mockReturnValue(true);
|
||||
component.onTimelineProgramActivated({
|
||||
...timeshiftEvent(),
|
||||
type: 'copy-catchup-url',
|
||||
});
|
||||
await fixture.whenStable();
|
||||
expect(copy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://example.com/m3u.m3u8?utc=')
|
||||
);
|
||||
expect(component.inlinePlayback()).toBe(before);
|
||||
expect(component.activeTimeshift()).toBeNull();
|
||||
copy.mockRestore();
|
||||
});
|
||||
|
||||
it('switches the inline player to the catch-up stream on Watch', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
await selectCatchupChannel();
|
||||
@@ -1120,6 +1144,28 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
fixture.debugElement.query(By.directive(StubEpgTimelineComponent))
|
||||
.componentInstance as StubEpgTimelineComponent;
|
||||
|
||||
it('copies Xtream archive URLs without changing the active playback', async () => {
|
||||
streamResolver.resolveXtreamCatchupUrl.mockResolvedValue(
|
||||
'https://example.com/timeshift.ts'
|
||||
);
|
||||
await selectXtreamArchiveChannel();
|
||||
const before = component.inlinePlayback();
|
||||
const copy = jest
|
||||
.spyOn(TestBed.inject(Clipboard), 'copy')
|
||||
.mockReturnValue(true);
|
||||
component.onTimelineProgramActivated({
|
||||
...timeshiftEvent(),
|
||||
type: 'copy-catchup-url',
|
||||
});
|
||||
await fixture.whenStable();
|
||||
expect(copy).toHaveBeenCalledWith(
|
||||
'https://example.com/timeshift.ts'
|
||||
);
|
||||
expect(component.inlinePlayback()).toBe(before);
|
||||
expect(component.activeTimeshift()).toBeNull();
|
||||
copy.mockRestore();
|
||||
});
|
||||
|
||||
it('exposes the provider archive window to the timeline in days', async () => {
|
||||
// Regression: tv_archive_duration is days (matching
|
||||
// live-stream-layout.controlledArchiveDays), not hours — a
|
||||
@@ -1215,120 +1261,3 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildLiveItem(
|
||||
sourceType: 'm3u' | 'xtream' | 'stalker'
|
||||
): UnifiedCollectionItem {
|
||||
if (sourceType === 'm3u') {
|
||||
return {
|
||||
uid: 'm3u::pl-1::m3u-channel',
|
||||
name: 'M3U Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
channelId: 'm3u-channel',
|
||||
tvgId: 'm3u-channel',
|
||||
logo: 'm3u.png',
|
||||
radio: 'false',
|
||||
};
|
||||
}
|
||||
|
||||
if (sourceType === 'xtream') {
|
||||
return {
|
||||
uid: 'xtream::pl-2::20',
|
||||
name: 'Xtream Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'pl-2',
|
||||
playlistName: 'Playlist Two',
|
||||
xtreamId: 20,
|
||||
tvgId: '20',
|
||||
logo: 'xtream.png',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
uid: 'stalker::pl-3::30',
|
||||
name: 'Stalker Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'pl-3',
|
||||
playlistName: 'Playlist Three',
|
||||
stalkerId: '30',
|
||||
stalkerCmd: 'ffmpeg http://stalker/30',
|
||||
tvgId: '30',
|
||||
logo: 'stalker.png',
|
||||
};
|
||||
}
|
||||
|
||||
function buildProgram(title: string): EpgProgram {
|
||||
return {
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel: 'test-channel',
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCurrentProgram(title: string): EpgProgram {
|
||||
const now = Date.now();
|
||||
return {
|
||||
start: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
stop: new Date(now + 10 * 60 * 1000).toISOString(),
|
||||
channel: 'test-channel',
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEpgItem(title: string): EpgItem {
|
||||
return {
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: '',
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
end: '2026-03-26T12:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel_id: '1',
|
||||
start_timestamp: '1774522800',
|
||||
stop_timestamp: '1774526400',
|
||||
};
|
||||
}
|
||||
|
||||
function buildCurrentEpgItem(title: string): EpgItem {
|
||||
const now = Date.now();
|
||||
const start = now - 10 * 60 * 1000;
|
||||
const stop = now + 10 * 60 * 1000;
|
||||
|
||||
return {
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: '',
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(stop).toISOString(),
|
||||
stop: new Date(stop).toISOString(),
|
||||
channel_id: '1',
|
||||
start_timestamp: String(Math.floor(start / 1000)),
|
||||
stop_timestamp: String(Math.floor(stop / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
+36
@@ -44,6 +44,7 @@ import {
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
EpgArchiveCopyService,
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
@@ -135,6 +136,14 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
/** The rail is owned by the page header toggle; ask it to expand. */
|
||||
readonly restoreSidebarRequested = output<void>();
|
||||
|
||||
readonly archiveContextKey = computed(() =>
|
||||
JSON.stringify([
|
||||
this.activeItem()?.uid,
|
||||
this.activeItem()?.playlistId,
|
||||
this.currentM3uChannel()?.url,
|
||||
])
|
||||
);
|
||||
private readonly archiveCopy = inject(EpgArchiveCopyService);
|
||||
private readonly streamResolver = inject(StreamResolverService);
|
||||
private readonly recentData = inject(UnifiedRecentDataService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
@@ -644,6 +653,33 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
}
|
||||
|
||||
onTimelineProgramActivated(event: EpgProgramActivationEvent): void {
|
||||
if (event.type === 'copy-catchup-url') {
|
||||
const channel = this.currentM3uChannel();
|
||||
const item = this.activeItem();
|
||||
const isM3u = this.isM3uSelection();
|
||||
void this.archiveCopy.copy(() => {
|
||||
if (isM3u) return resolveM3uCatchupUrl(channel, event.program);
|
||||
const start = toEpochSeconds(
|
||||
event.program.startTimestamp,
|
||||
event.program.start
|
||||
);
|
||||
const stop = toEpochSeconds(
|
||||
event.program.stopTimestamp,
|
||||
event.program.stop
|
||||
);
|
||||
return item?.xtreamId &&
|
||||
start != null &&
|
||||
stop != null &&
|
||||
stop > start
|
||||
? this.streamResolver.resolveXtreamCatchupUrl(
|
||||
item,
|
||||
start,
|
||||
stop
|
||||
)
|
||||
: null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === 'live') {
|
||||
this.returnToLivePlayback();
|
||||
return;
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
|
||||
export function buildLiveItem(
|
||||
sourceType: 'm3u' | 'xtream' | 'stalker'
|
||||
): UnifiedCollectionItem {
|
||||
if (sourceType === 'm3u') {
|
||||
return {
|
||||
uid: 'm3u::pl-1::m3u-channel',
|
||||
name: 'M3U Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'pl-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/m3u.m3u8',
|
||||
channelId: 'm3u-channel',
|
||||
tvgId: 'm3u-channel',
|
||||
logo: 'm3u.png',
|
||||
radio: 'false',
|
||||
};
|
||||
}
|
||||
|
||||
if (sourceType === 'xtream') {
|
||||
return {
|
||||
uid: 'xtream::pl-2::20',
|
||||
name: 'Xtream Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'pl-2',
|
||||
playlistName: 'Playlist Two',
|
||||
xtreamId: 20,
|
||||
tvgId: '20',
|
||||
logo: 'xtream.png',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
uid: 'stalker::pl-3::30',
|
||||
name: 'Stalker Live',
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: 'pl-3',
|
||||
playlistName: 'Playlist Three',
|
||||
stalkerId: '30',
|
||||
stalkerCmd: 'ffmpeg http://stalker/30',
|
||||
tvgId: '30',
|
||||
logo: 'stalker.png',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProgram(title: string): EpgProgram {
|
||||
return {
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel: 'test-channel',
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCurrentProgram(title: string): EpgProgram {
|
||||
const now = Date.now();
|
||||
return {
|
||||
start: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
stop: new Date(now + 10 * 60 * 1000).toISOString(),
|
||||
channel: 'test-channel',
|
||||
title,
|
||||
desc: `${title} description`,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEpgItem(title: string): EpgItem {
|
||||
return {
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: '',
|
||||
start: '2026-03-26T11:00:00.000Z',
|
||||
end: '2026-03-26T12:00:00.000Z',
|
||||
stop: '2026-03-26T12:00:00.000Z',
|
||||
channel_id: '1',
|
||||
start_timestamp: '1774522800',
|
||||
stop_timestamp: '1774526400',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCurrentEpgItem(title: string): EpgItem {
|
||||
const now = Date.now();
|
||||
const start = now - 10 * 60 * 1000;
|
||||
const stop = now + 10 * 60 * 1000;
|
||||
|
||||
return {
|
||||
id: '1',
|
||||
epg_id: '',
|
||||
title,
|
||||
description: `${title} description`,
|
||||
lang: '',
|
||||
start: new Date(start).toISOString(),
|
||||
end: new Date(stop).toISOString(),
|
||||
stop: new Date(stop).toISOString(),
|
||||
channel_id: '1',
|
||||
start_timestamp: String(Math.floor(start / 1000)),
|
||||
stop_timestamp: String(Math.floor(stop / 1000)),
|
||||
};
|
||||
}
|
||||
|
||||
export function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
+1
@@ -73,6 +73,7 @@ export class StubEpgTimelineComponent {
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
readonly archiveContextKey = input<string | null>(null);
|
||||
readonly archiveDays = input(0);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly isLivePlayback = input(true);
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ export class StubEpgTimelineComponent {
|
||||
readonly channelLogo = input('');
|
||||
readonly sourceLabel = input('');
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
readonly archiveContextKey = input<string | null>(null);
|
||||
readonly archiveDays = input(0);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly isLivePlayback = input(true);
|
||||
|
||||
+2
@@ -229,6 +229,7 @@
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archiveContextKey]="archiveContextKey()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
@@ -250,6 +251,7 @@
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archiveContextKey]="archiveContextKey()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
|
||||
+49
-2
@@ -24,6 +24,7 @@ import {
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
EpgArchiveCopyService,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
} from '@iptvnator/ui/epg';
|
||||
@@ -185,6 +186,16 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: EpgArchiveCopyService,
|
||||
useValue: {
|
||||
copy: jest.fn(
|
||||
async (resolve: () => Promise<string | null>) => {
|
||||
await resolve();
|
||||
}
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
@@ -237,6 +248,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
.overrideComponent(LiveStreamLayoutComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
EpgArchiveCopyService,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GridListComponent,
|
||||
@@ -265,11 +277,17 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState(
|
||||
'portal',
|
||||
'expanded'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState(
|
||||
'portal',
|
||||
'expanded'
|
||||
);
|
||||
fixture.destroy();
|
||||
jest.useRealTimers();
|
||||
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
|
||||
@@ -767,6 +785,35 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('copies the programme URL without changing playback or channel selection', async () => {
|
||||
const before = component.activePlayback();
|
||||
xtreamStore.openPlayer.mockClear();
|
||||
xtreamStore.setSelectedItem.mockClear();
|
||||
await component.onProgramActivated({
|
||||
type: 'copy-catchup-url',
|
||||
program: {
|
||||
start: '2026-04-04T10:00:00Z',
|
||||
stop: '2026-04-04T11:00:00Z',
|
||||
channel: 'channel-101',
|
||||
title: 'Archive',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
});
|
||||
expect(TestBed.inject(EpgArchiveCopyService).copy).toHaveBeenCalled();
|
||||
expect(xtreamUrlService.resolveCatchupUrl).toHaveBeenCalledWith(
|
||||
playlist.id,
|
||||
expect.objectContaining({ serverUrl: playlist.serverUrl }),
|
||||
101,
|
||||
1775296800,
|
||||
1775300400,
|
||||
undefined
|
||||
);
|
||||
expect(component.activePlayback()).toBe(before);
|
||||
expect(xtreamStore.openPlayer).not.toHaveBeenCalled();
|
||||
expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not attach a pending catchup result to a newer live owner', async () => {
|
||||
let resolveCatchup!: (url: string) => void;
|
||||
xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce(
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
XtreamStore,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
EpgArchiveCopyService,
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
@@ -145,6 +146,14 @@ export class LiveStreamLayoutComponent
|
||||
private readonly router = inject(Router);
|
||||
private readonly favoritesService = inject(FavoritesService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
readonly archiveContextKey = computed(() =>
|
||||
JSON.stringify([
|
||||
this.xtreamStore.currentPlaylist()?.id,
|
||||
this.xtreamStore.currentPlaylist()?.serverUrl,
|
||||
this.selectedLiveItem()?.xtream_id,
|
||||
])
|
||||
);
|
||||
private readonly archiveCopy = inject(EpgArchiveCopyService);
|
||||
private readonly xtreamUrlService = inject(XtreamUrlService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
@@ -610,6 +619,30 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'copy-catchup-url') {
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
await this.archiveCopy.copy(() => {
|
||||
const start = this.getProgramTimestampSeconds(
|
||||
event.program.start,
|
||||
event.program.startTimestamp
|
||||
);
|
||||
const stop = this.getProgramTimestampSeconds(
|
||||
event.program.stop,
|
||||
event.program.stopTimestamp
|
||||
);
|
||||
return playlist && start && stop && stop > start
|
||||
? this.xtreamUrlService.resolveCatchupUrl(
|
||||
playlist.id,
|
||||
playlist,
|
||||
selectedItem.xtream_id,
|
||||
start,
|
||||
stop,
|
||||
playlist.serverTimezone
|
||||
)
|
||||
: null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === 'live') {
|
||||
this.playLive(selectedItem, true);
|
||||
return;
|
||||
|
||||
@@ -28,3 +28,5 @@ export * from './lib/epg-guide/epg-guide-layout.util';
|
||||
export * from './lib/epg-guide/epg-guide-preferences';
|
||||
export * from './lib/epg-guide/epg-guide.component';
|
||||
export * from './lib/epg-guide/epg-guide-now-playing.component';
|
||||
|
||||
export * from './lib/epg-archive-copy.service';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { EpgArchiveCopyService } from './epg-archive-copy.service';
|
||||
|
||||
describe('EpgArchiveCopyService', () => {
|
||||
const copy = jest.fn();
|
||||
const open = jest.fn();
|
||||
let service: EpgArchiveCopyService;
|
||||
beforeEach(() => {
|
||||
copy.mockReset().mockReturnValue(true);
|
||||
open.mockReset();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: Clipboard, useValue: { copy } },
|
||||
{ provide: MatSnackBar, useValue: { open } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EpgArchiveCopyService);
|
||||
});
|
||||
it('copies the resolved URL and reports success', async () => {
|
||||
await service.copy(() =>
|
||||
Promise.resolve('https://provider.test/archive.ts')
|
||||
);
|
||||
expect(copy).toHaveBeenCalledWith('https://provider.test/archive.ts');
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_COPIED',
|
||||
undefined,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
});
|
||||
it.each([null, '', 'javascript:alert(1)'])(
|
||||
'rejects an unavailable or unsafe URL: %s',
|
||||
async (url) => {
|
||||
await service.copy(() => url);
|
||||
expect(copy).not.toHaveBeenCalled();
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED',
|
||||
undefined,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
}
|
||||
);
|
||||
it('handles resolver and clipboard failures without exposing their messages', async () => {
|
||||
await service.copy(() => {
|
||||
throw new Error('https://user:secret@provider.test');
|
||||
});
|
||||
copy.mockReturnValue(false);
|
||||
await service.copy(() => 'https://provider.test/archive.ts');
|
||||
expect(open.mock.calls.map(([key]) => key)).toEqual([
|
||||
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED',
|
||||
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED',
|
||||
]);
|
||||
});
|
||||
it('coalesces repeated clicks while the URL is resolving', async () => {
|
||||
let resolve!: (url: string) => void;
|
||||
const pending = service.copy(
|
||||
() =>
|
||||
new Promise<string>((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const duplicate = jest.fn();
|
||||
await service.copy(duplicate);
|
||||
expect(duplicate).not.toHaveBeenCalled();
|
||||
resolve('https://provider.test/archive.ts');
|
||||
await pending;
|
||||
expect(copy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
/** Clipboard feedback only; the host owns source identity and URL resolution. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EpgArchiveCopyService {
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private pending = false;
|
||||
|
||||
async copy(
|
||||
resolve: () => string | null | Promise<string | null>
|
||||
): Promise<void> {
|
||||
if (this.pending) return;
|
||||
this.pending = true;
|
||||
let copied = false;
|
||||
try {
|
||||
const url = await resolve();
|
||||
if (url && ['http:', 'https:'].includes(new URL(url).protocol)) {
|
||||
copied = this.clipboard.copy(url);
|
||||
}
|
||||
} catch {
|
||||
// Provider URLs and error messages may contain credentials.
|
||||
} finally {
|
||||
this.pending = false;
|
||||
}
|
||||
this.snackBar.open(
|
||||
this.translate.instant(
|
||||
copied
|
||||
? 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_COPIED'
|
||||
: 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED'
|
||||
),
|
||||
undefined,
|
||||
{ duration: 4000 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,9 @@
|
||||
|
||||
<div class="epg-dialog__body">
|
||||
<div class="epg-dialog__meta">
|
||||
<span>{{ startMs | date: 'EEE d MMM' : '' : currentLocale() }}</span>
|
||||
<span>{{
|
||||
startMs | date: 'EEE d MMM' : '' : currentLocale()
|
||||
}}</span>
|
||||
<span class="epg-dialog__dot"></span>
|
||||
<span
|
||||
>{{ startMs | date: 'HH:mm' }} –
|
||||
@@ -73,6 +75,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (dialogData.archiveUrlAvailable) {
|
||||
<p class="epg-dialog__note">
|
||||
{{ 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' | translate }}
|
||||
</p>
|
||||
}
|
||||
<div class="epg-dialog__actions">
|
||||
@if (primaryAction === 'live') {
|
||||
<button
|
||||
@@ -93,6 +100,17 @@
|
||||
{{ 'EPG.TIMELINE.WATCH_FROM_START' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (dialogData.archiveUrlAvailable) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-dialog__btn epg-dialog__btn--ghost"
|
||||
data-testid="copy-catchup-url"
|
||||
[mat-dialog-close]="'copy-catchup-url'"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
{{ 'EPG.PROGRAM_DIALOG.COPY_ARCHIVE_URL' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (archiveUnavailableNote) {
|
||||
<span class="epg-dialog__note">{{
|
||||
'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
|
||||
/* ── actions ── */
|
||||
.epg-dialog__actions {
|
||||
flex-wrap: wrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
@@ -71,6 +71,28 @@ describe('EpgItemDescriptionComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('offers archive URL copying only with explicit capability and explains credentials', () => {
|
||||
Object.assign(component.dialogData, { archiveUrlAvailable: true });
|
||||
fixture.changeDetectorRef.markForCheck();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="copy-catchup-url"]'
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE'
|
||||
);
|
||||
Object.assign(component.dialogData, { archiveUrlAvailable: false });
|
||||
fixture.changeDetectorRef.markForCheck();
|
||||
fixture.detectChanges();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="copy-catchup-url"]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('applies the stored EPG display offset to the dialog timestamps', () => {
|
||||
const start = Date.parse('2026-04-05T11:30:00.000Z');
|
||||
const stop = Date.parse('2026-04-05T12:30:00.000Z');
|
||||
|
||||
@@ -11,9 +11,11 @@ import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { getProgramTimeMs } from '../epg-program.utils';
|
||||
|
||||
export type EpgItemDialogAction = 'live' | 'timeshift';
|
||||
export type EpgItemDialogAction = 'live' | 'timeshift' | 'copy-catchup-url';
|
||||
|
||||
export type EpgItemDialogData = EpgProgram & {
|
||||
/** Host can resolve an archive URL without starting playback. */
|
||||
archiveUrlAvailable?: boolean;
|
||||
channelName?: string | null;
|
||||
channel_name?: string | null;
|
||||
display_name?: string | null;
|
||||
@@ -64,10 +66,11 @@ export class EpgItemDescriptionComponent {
|
||||
constructor() {
|
||||
this.epgProgram = this.dialogData;
|
||||
// Check multiple possible field names for channel name
|
||||
this.channelName = this.dialogData.channelName
|
||||
|| this.dialogData.channel_name
|
||||
|| this.dialogData.display_name
|
||||
|| null;
|
||||
this.channelName =
|
||||
this.dialogData.channelName ||
|
||||
this.dialogData.channel_name ||
|
||||
this.dialogData.display_name ||
|
||||
null;
|
||||
// Prefer the channel logo; fall back to the programme/EPG icon
|
||||
// (M3U playlists without tvg-logo still get an icon from the EPG feed).
|
||||
this.channelLogo =
|
||||
@@ -105,7 +108,9 @@ export class EpgItemDescriptionComponent {
|
||||
}
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remainingMins = mins % 60;
|
||||
return remainingMins > 0 ? `${hours}h ${remainingMins}m` : `${hours}h`;
|
||||
return remainingMins > 0
|
||||
? `${hours}h ${remainingMins}m`
|
||||
: `${hours}h`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -32,12 +32,14 @@ function localDateKey(iso: string): string {
|
||||
describe('EpgListViewComponent', () => {
|
||||
let fixture: ComponentFixture<EpgListViewComponent>;
|
||||
let component: EpgListViewComponent;
|
||||
let dialogResult: BehaviorSubject<'live' | 'timeshift' | undefined>;
|
||||
let dialogResult: BehaviorSubject<
|
||||
'live' | 'timeshift' | 'copy-catchup-url' | undefined
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogResult = new BehaviorSubject<'live' | 'timeshift' | undefined>(
|
||||
undefined
|
||||
);
|
||||
dialogResult = new BehaviorSubject<
|
||||
'live' | 'timeshift' | 'copy-catchup-url' | undefined
|
||||
>(undefined);
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EpgListViewComponent],
|
||||
providers: [
|
||||
@@ -218,6 +220,48 @@ describe('EpgListViewComponent', () => {
|
||||
expect(events).toEqual(['timeshift']);
|
||||
});
|
||||
|
||||
it('copies an archive URL without selecting or activating playback', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
dialogResult.next('copy-catchup-url');
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((event) =>
|
||||
events.push(event.type)
|
||||
);
|
||||
const selection = component.selectedKey();
|
||||
component.openDetails(rowWhen('past'));
|
||||
expect(events).toEqual(['copy-catchup-url']);
|
||||
expect(component.selectedKey()).toBe(selection);
|
||||
});
|
||||
|
||||
it('does not copy a programme from a dialog belonging to the previous channel', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
archiveContextKey: 'source:channel-a',
|
||||
});
|
||||
const result = new BehaviorSubject<string | undefined>(undefined);
|
||||
jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({
|
||||
afterClosed: () => result,
|
||||
} as never);
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((event) =>
|
||||
events.push(event.type)
|
||||
);
|
||||
component.openDetails(rowWhen('past'));
|
||||
setInputs({ archiveContextKey: 'source:channel-b' });
|
||||
result.next('copy-catchup-url');
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits the day when stepping forward', () => {
|
||||
setInputs({ programs: [programAt(-30, 60)] });
|
||||
const emitted: string[] = [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
@@ -81,6 +82,8 @@ export class EpgListViewComponent {
|
||||
* timeline's literal "Timeline" while in list mode. */
|
||||
readonly sourceLabel = input('Programme guide');
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
/** Source/channel identity captured by programme details, independent of playback. */
|
||||
readonly archiveContextKey = input<string | null>(null);
|
||||
readonly archiveDays = input(0);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly isLivePlayback = input(true);
|
||||
@@ -102,6 +105,7 @@ export class EpgListViewComponent {
|
||||
readonly collapsedChange = output<boolean>();
|
||||
readonly openGuide = output<void>();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly programmeDialog = inject(EpgProgrammeDialogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
@@ -280,18 +284,27 @@ export class EpgListViewComponent {
|
||||
}
|
||||
|
||||
openDetails(row: EpgListRow): void {
|
||||
const archiveContextKey = this.archiveContextKey();
|
||||
this.programmeDialog
|
||||
.open({
|
||||
...row.program,
|
||||
channelName: this.channelName(),
|
||||
channelLogo: this.channelLogo(),
|
||||
archiveUrlAvailable: row.canCatchUp,
|
||||
primaryAction: epgDialogActionFor(row.when, row.canCatchUp),
|
||||
archiveUnavailableNote:
|
||||
row.when === 'past' && !this.archivePlaybackAvailable(),
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result: EpgItemDialogAction | undefined) => {
|
||||
if (archiveContextKey !== this.archiveContextKey()) return;
|
||||
if (result === 'live') {
|
||||
this.returnToLive.emit();
|
||||
} else if (result === 'copy-catchup-url' && row.canCatchUp) {
|
||||
this.programActivated.emit({
|
||||
program: row.program,
|
||||
type: result,
|
||||
});
|
||||
} else if (result === 'timeshift') {
|
||||
this.selectedKey.set(row.key);
|
||||
this.programActivated.emit({
|
||||
|
||||
@@ -2,5 +2,5 @@ import type { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export interface EpgProgramActivationEvent {
|
||||
program: EpgProgram;
|
||||
type: 'live' | 'timeshift';
|
||||
type: 'live' | 'timeshift' | 'copy-catchup-url';
|
||||
}
|
||||
@@ -188,6 +188,58 @@ describe('EpgTimelineComponent', () => {
|
||||
expect(returned).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards archive copy separately from playback and keeps selection', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({
|
||||
afterClosed: () => of('copy-catchup-url'),
|
||||
} as never);
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((event) =>
|
||||
events.push(event.type)
|
||||
);
|
||||
const selection = component.selectedKey();
|
||||
component.openDetails(
|
||||
component
|
||||
.blocks()
|
||||
.find((block) => block.when === 'past') as TimelineBlock
|
||||
);
|
||||
expect(events).toEqual(['copy-catchup-url']);
|
||||
expect(component.selectedKey()).toBe(selection);
|
||||
});
|
||||
|
||||
it('does not copy a programme from a dialog belonging to the previous channel', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
archiveContextKey: 'source:channel-a',
|
||||
});
|
||||
const result = new BehaviorSubject<string | undefined>(undefined);
|
||||
jest.spyOn(TestBed.inject(MatDialog), 'open').mockReturnValue({
|
||||
afterClosed: () => result,
|
||||
} as never);
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((event) =>
|
||||
events.push(event.type)
|
||||
);
|
||||
component.openDetails(
|
||||
component
|
||||
.blocks()
|
||||
.find((block) => block.when === 'past') as TimelineBlock
|
||||
);
|
||||
setInputs({ archiveContextKey: 'source:channel-b' });
|
||||
result.next('copy-catchup-url');
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits the centred day when stepping forward', () => {
|
||||
setInputs({ programs: [programAt(-30, 60)] });
|
||||
const emitted: string[] = [];
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
@@ -90,6 +91,8 @@ export class EpgTimelineComponent {
|
||||
readonly channelLogo = input('');
|
||||
readonly sourceLabel = input('Timeline');
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
/** Source/channel identity captured by programme details, independent of playback. */
|
||||
readonly archiveContextKey = input<string | null>(null);
|
||||
readonly archiveDays = input(0);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly isLivePlayback = input(true);
|
||||
@@ -111,6 +114,7 @@ export class EpgTimelineComponent {
|
||||
readonly collapsedChange = output<boolean>();
|
||||
readonly openGuide = output<void>();
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly programmeDialog = inject(EpgProgrammeDialogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
@@ -378,18 +382,30 @@ export class EpgTimelineComponent {
|
||||
}
|
||||
|
||||
openDetails(block: TimelineBlock): void {
|
||||
const archiveContextKey = this.archiveContextKey();
|
||||
this.programmeDialog
|
||||
.open({
|
||||
...block.program,
|
||||
channelName: this.channelName(),
|
||||
channelLogo: this.channelLogo(),
|
||||
archiveUrlAvailable: this.canCatchUp(block),
|
||||
primaryAction: this.dialogActionFor(block),
|
||||
archiveUnavailableNote:
|
||||
block.when === 'past' && !this.archivePlaybackAvailable(),
|
||||
})
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result: EpgItemDialogAction | undefined) => {
|
||||
if (archiveContextKey !== this.archiveContextKey()) return;
|
||||
if (result === 'live') {
|
||||
this.returnToLive.emit();
|
||||
} else if (
|
||||
result === 'copy-catchup-url' &&
|
||||
this.canCatchUp(block)
|
||||
) {
|
||||
this.programActivated.emit({
|
||||
program: block.program,
|
||||
type: result,
|
||||
});
|
||||
} else if (result === 'timeshift') {
|
||||
this.selectedKey.set(block.key);
|
||||
this.programActivated.emit({
|
||||
|
||||
Reference in new issue
Block a user