mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): add programme guide implementation plan Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add window-scoped guide programme queries Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): harden guide query scoping, caps and row mapping - Scoped guide programme/coverage queries now include legacy (unsourced) rows via source_url IN (...) OR IS NULL OR '', mirroring EpgQueryService's legacy fallback. - getProgramsForChannels/getProgramCoverage build their result from the normalized, capped window.channelIds instead of the raw request, so a key cut by the cap is absent rather than [] — an invalid window now returns {}. Truncation logs counts only. - Split the 100-channel guide cap from a new 2000-key coverage cap, and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a parameter and moved (with guideWindowOverlapSqlText) into epg-guide-window.util.ts. - Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram) into epg-program-row.util.ts, used by both EpgQueryService and EpgGuideQueryService so invalid start/stop rows are dropped identically in both. - Added a real-SQLite-backed test for the overlap predicate's exact text, plus per-key array copies in the response. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): render the guide predicate in tests and document its scope Correct the guide query's JSDoc: it runs one query accepting the union of requested-source and unsourced legacy rows, unlike EpgQueryService's two-query scoped-then-legacy fallback. Replace the hand-maintained plain-SQL twin of the Drizzle overlap predicate with a rendered copy of the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a source-scoping case, and drop the now-redundant operator-sequence test. warnIfTruncated reuses uniqueTrimmedStrings and names which read (programme/coverage) was truncated. Rename epg-query.service.ts's local EpgProgramRow to EpgProgramSelectRow so it isn't confused with the shared EpgProgramRow type, and document getProgramCoverage like its sibling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): expose guide programme and coverage reads over the bridge Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): separate coverage chunk size in the guide plan Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add guide source contract, day layout maths and preferences Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): key guide IPC answers by trimmed, present keys only Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): guide search hits carry a row id Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): make guide geometry DST-safe and tighten the contract Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): cache guide programmes per day with batched loading Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add guide keyboard navigation controller Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): make guide programme cache robust to first-run effects and coverage failures Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add the programme guide grid components Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(epg): add a Guide button to the timeline toolbar Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(m3u): adapt the playlist channel list to the guide contract Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): guard the guide's initial group scope and track language changes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(m3u): open the programme guide in place with a docked player Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(epg): remove the multi-EPG overlay and the channel-range IPC Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): document the programme guide and its release note Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * i18n(epg): translate the programme guide Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): let the guide own the keyboard and gate its entry points While the programme guide is open the docked player carries `data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours alongside `[inert]` — the arrows moved the player's volume instead of the guide's row focus. The external-player strip loses its Collapse toggle (nothing to reveal, no preference to write), the header action and its palette command report `disabled` when the guide cannot open, the docked strip derives its programme from the active channel's own schedule instead of the retained NgRx value, switching playlists closes the guide, and the collapsed strip can reach 48 px on phones. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): keep the sidebar mounted while the guide is open Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the guide destroyed `app-channel-list-container`, whose `ngOnDestroy` dispatches `resetActiveChannel()`. That cleared the active channel, which unmounted the block hosting `app-epg-guide` and tripped the `!canOpenGuide()` effect into closing the guide again: the guide never appeared and the page dropped to "Please select a channel". The sidebar now stays mounted and is hidden with `.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read by assistive technology while the guide owns the layout. Hiding also preserves the channel list's scroll position across guide toggles. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(e2e): cover the programme guide flow Imports a two-channel playlist with XMLTV, opens the guide from the timeline toolbar and asserts the row list, the "Only with EPG" filter, a channel switch that keeps the guide open, the hidden-but-mounted sidebar, and that the player element survives both the mode and channel switches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(epg): tidy guide docs, palette gating and the unbound output Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): match guide favorites by channel URL and skip re-activating the playing row Favorites are persisted by channel URL (FavoritesActions.updateFavorites), so the Favorites scope compared the wrong key; the id stays as a legacy fallback. A double-click arrives as click, click, dblclick and each activate restarts playback, so the guide now leaves the already-playing row alone and the commit path only closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(epg): let the guide window predicate use the programme time index Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(epg): split the guide shell, add a roving focus model and offset-aware search times The shell component now owns rows, focus and the viewport only: the day, zoom, density, filters, clock and day geometry move to EpgGuideViewState, and every programme-dialog entry point to EpgGuideDialogController. Keyboard navigation is reachable by assistive technology: exactly one grid cell carries tabindex="0" (the focused cell, else the playing row's channel cell, else the first row's), the guide moves DOM focus with it after each handled key, a click hands the roving index to the clicked cell, and the viewport, rows and cells expose grid/row/gridcell roles. Search results were formatting raw provider instants, so they ignored the EPG display offset; they go through getProgramTimeMs like every other time the guide renders. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(m3u): make guide row ids collision-proof and gate the G shortcut Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(epg): describe guide row ids as scope-local Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown Search hits carry scope-local row ids, so a scope change drops them. Two playlist entries can share an id but not a stream, so the active row is matched by id + url before falling back to the id. A failed coverage query now rejects instead of answering an empty set, which the guide already treats as "coverage unknown" (every row stays visible). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers The store spreads the selected channel, so the active row is matched by id, url, group and name before widening; G no longer closes the guide from a dialog or menu; guide answers use null-prototype records so a key named __proto__ stays an own property. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row EpgQueryService.getChannelMetadata swallowed database errors into {}, so the guide's coverage read could publish an empty set after a transient failure; the guide now uses the strict resolveChannelMetadata (getChannelMetadata is the fail-soft wrapper around it). The active guide row is matched on the whole channel entry (all fields except the reducer-rewritten epgParams) before widening to url and id, so copies that differ only in playback headers or logo are told apart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): let the guide return catch-up to live and normalise programme-search rows The guide source contract gains an optional livePlayback signal: while the host plays a catch-up URL, the active row may be activated again, which is how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw snake_case rows to the EpgProgram shape the bridge promises (plus the joined channel name), so search hits resolve their channel and keep descriptions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): name search hits, keep guide coverage strict on mapping failures - Search results and the unresolved programme dialog show the channel's display name (playlist row name, else the XMLTV display name the search joined in) instead of the raw XMLTV id. - The guide coverage read resolves manual mappings through a strict variant that rejects on database failure, so a mapped channel can never be reported as uncovered and hidden by "Only with EPG". - Architecture doc describes the tiered active-row resolution. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): offer the Guide action in the list view too The EPG list view mirrors the timeline's input/output contract, but the Guide action was bound only in the timeline branch, so Settings → EPG → Guide view = List lost the in-panel entry point. The list toolbar now carries the same icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U host binds it in both branches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
0a2373f192
commit
7d1503fd31
126 files changed
+15848
-2639
No files matched your search
@@ -0,0 +1,13 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
issues: [171]
|
||||
highlight: Programme guide, rebuilt
|
||||
---
|
||||
|
||||
The programme guide now shows your playlist's own channels in their order,
|
||||
with the current group or favorites one click away. Click a channel to
|
||||
switch playback while the player stays on screen above the grid;
|
||||
double-click switches and closes. Open it from the new Guide button, the
|
||||
header, the command palette, or the G key. Hide channels without EPG and
|
||||
pick comfortable or compact rows.
|
||||
@@ -362,7 +362,7 @@ This is an Nx monorepo with the following structure:
|
||||
- **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/<slug>.svg`)
|
||||
- **shared/testing** - Shared test helpers
|
||||
- **ui/components** - Reusable UI components (incl. channel list)
|
||||
- **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)
|
||||
- **ui/epg** - EPG UI (timeline ribbon, programme guide grid via `EPG_GUIDE_SOURCE`, progress panel, program dialogs)
|
||||
- **ui/playback** - Player UI (video/audio players)
|
||||
- **ui/pipes** - Angular pipes
|
||||
- **ui/remote-control** - Remote-control UI pieces
|
||||
@@ -1485,7 +1485,8 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
|
||||
- XMLTV format support
|
||||
- Background parsing in worker thread
|
||||
- Stored in database for quick lookup
|
||||
- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and multi-EPG overlay read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset")
|
||||
- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset")
|
||||
- Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the EPG panel's Guide button (timeline or list view) or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide").
|
||||
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
|
||||
|
||||
**TMDB Metadata Enrichment** (opt-in):
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
buildM3uContent,
|
||||
channelItemByTitle,
|
||||
closeElectronApp,
|
||||
createMutableTextServer,
|
||||
expect,
|
||||
importM3uPlaylistFromUrl,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
function formatXmltvDate(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return [
|
||||
date.getUTCFullYear(),
|
||||
pad(date.getUTCMonth() + 1),
|
||||
pad(date.getUTCDate()),
|
||||
pad(date.getUTCHours()),
|
||||
pad(date.getUTCMinutes()),
|
||||
pad(date.getUTCSeconds()),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function xmltvWithCurrentProgramme(
|
||||
channelId: string,
|
||||
channelName: string,
|
||||
title: string
|
||||
): string {
|
||||
const start = new Date(Date.now() - 15 * 60 * 1000);
|
||||
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tv>
|
||||
<channel id="${channelId}"><display-name>${channelName}</display-name></channel>
|
||||
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
|
||||
<title>${title}</title>
|
||||
<desc>Guide E2E programme.</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
`;
|
||||
}
|
||||
|
||||
test('@epg @electron opens the programme guide with the playlist channels, switches channels and keeps the player mounted', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const epgServer = await createMutableTextServer(
|
||||
xmltvWithCurrentProgramme(
|
||||
'guide-news',
|
||||
'Guide News',
|
||||
'Guide Bulletin Now'
|
||||
),
|
||||
{
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
resourcePath: '/guide.xml',
|
||||
}
|
||||
);
|
||||
const playlistServer = await createMutableTextServer(
|
||||
buildM3uContent([
|
||||
{
|
||||
name: 'Guide News',
|
||||
tvgId: 'guide-news',
|
||||
url: 'https://example.com/live/guide-news.m3u8',
|
||||
},
|
||||
{
|
||||
name: 'Guide Silent',
|
||||
tvgId: 'guide-silent',
|
||||
url: 'https://example.com/live/guide-silent.m3u8',
|
||||
},
|
||||
]).replace('#EXTM3U', `#EXTM3U x-tvg-url="${epgServer.resourceUrl}"`),
|
||||
{
|
||||
contentType: 'application/x-mpegurl; charset=utf-8',
|
||||
resourcePath: '/guide-playlist.m3u',
|
||||
}
|
||||
);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0].setSize(1600, 1000);
|
||||
});
|
||||
// The demo stream URLs are unreachable; the player still mounts.
|
||||
await app.mainWindow.route('https://example.com/**', (route) =>
|
||||
route.abort()
|
||||
);
|
||||
|
||||
await importM3uPlaylistFromUrl(
|
||||
app.mainWindow,
|
||||
playlistServer.resourceUrl
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'.epg-progress-panel .import-item.status-complete'
|
||||
)
|
||||
).toHaveCount(1, { timeout: 30000 });
|
||||
|
||||
await openWorkspaceSection(app.mainWindow, 'All channels');
|
||||
const newsRow = channelItemByTitle(app.mainWindow, 'Guide News');
|
||||
await expect(newsRow).toBeVisible();
|
||||
await newsRow.click();
|
||||
|
||||
const timeline = app.mainWindow.locator('app-epg-timeline');
|
||||
await expect(timeline).toBeVisible({ timeout: 20000 });
|
||||
await app.mainWindow.evaluate(() =>
|
||||
document
|
||||
.querySelector('app-web-player-view')
|
||||
?.setAttribute('data-e2e-guide-marker', 'kept')
|
||||
);
|
||||
|
||||
// Entry point: the Guide button in the timeline toolbar.
|
||||
await timeline.locator('.epg-timeline__guide').click();
|
||||
const guide = app.mainWindow.locator('app-epg-guide');
|
||||
await expect(guide).toBeVisible();
|
||||
// The sidebar stays mounted (its channel list resets the active
|
||||
// channel on destroy) and is hidden while the guide owns the layout.
|
||||
await expect(app.mainWindow.locator('.sidebar')).toBeHidden();
|
||||
await expect(timeline).toHaveCount(0);
|
||||
|
||||
// Rows are the playlist's channels, in order, with the playing row marked.
|
||||
const rows = guide.locator('app-epg-guide-row');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.nth(0).locator('.epg-guide-row__name b')).toHaveText(
|
||||
'Guide News'
|
||||
);
|
||||
await expect(rows.nth(1).locator('.epg-guide-row__name b')).toHaveText(
|
||||
'Guide Silent'
|
||||
);
|
||||
await expect(rows.nth(0)).toHaveClass(/is-active/);
|
||||
await expect(
|
||||
rows.nth(0).locator('.epg-guide-row__block.is-now')
|
||||
).toContainText('Guide Bulletin Now', { timeout: 20000 });
|
||||
await expect(rows.nth(1).locator('.epg-guide-row__empty')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// "Only with EPG" hides the silent channel once coverage is known.
|
||||
const toggle = guide.locator('.guide-toolbar__toggle input');
|
||||
await expect(toggle).toBeEnabled({ timeout: 20000 });
|
||||
await guide.locator('.guide-toolbar__toggle').click();
|
||||
await expect(rows).toHaveCount(1);
|
||||
await guide.locator('.guide-toolbar__toggle').click();
|
||||
await expect(rows).toHaveCount(2);
|
||||
|
||||
// Clicking a channel switches playback without closing the guide.
|
||||
await rows.nth(1).locator('.epg-guide-row__channel').click();
|
||||
await expect(rows.nth(1)).toHaveClass(/is-active/);
|
||||
await expect(rows.nth(0)).not.toHaveClass(/is-active/);
|
||||
await expect(guide).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator('app-epg-guide-now-playing')
|
||||
).toContainText('Guide Silent');
|
||||
|
||||
// The player element survived both the mode switch and the channel switch.
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'app-web-player-view[data-e2e-guide-marker="kept"]'
|
||||
)
|
||||
).toHaveCount(1);
|
||||
|
||||
// Escape closes the guide and restores the sidebar and timeline.
|
||||
await app.mainWindow.keyboard.press('Escape');
|
||||
await expect(guide).toHaveCount(0);
|
||||
await expect(app.mainWindow.locator('.sidebar')).toBeVisible();
|
||||
await expect(app.mainWindow.locator('app-epg-timeline')).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'app-web-player-view[data-e2e-guide-marker="kept"]'
|
||||
)
|
||||
).toHaveCount(1);
|
||||
|
||||
// G reopens it; the header shortcut is highlighted while open.
|
||||
await app.mainWindow.keyboard.press('g');
|
||||
await expect(guide).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator('.header-shortcut.is-active')
|
||||
).toHaveCount(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
await playlistServer.close();
|
||||
await epgServer.close();
|
||||
}
|
||||
});
|
||||
@@ -718,7 +718,7 @@ test.describe('Electron EPG', () => {
|
||||
|
||||
async function getEpgChannelCount(page: Page): Promise<number> {
|
||||
return page.evaluate(async () => {
|
||||
const channels = await window.electron?.getEpgChannelsByRange?.(0, 20);
|
||||
return Array.isArray(channels) ? channels.length : 0;
|
||||
const result = await window.electron?.getEpgChannels?.();
|
||||
return Array.isArray(result?.channels) ? result.channels.length : 0;
|
||||
});
|
||||
}
|
||||
@@ -514,10 +514,16 @@ export const epgPreloadCases: PreloadInvokeCase[] = [
|
||||
forwardedArgs: [],
|
||||
},
|
||||
{
|
||||
method: 'getEpgChannelsByRange',
|
||||
args: [10, 20],
|
||||
channel: 'EPG_GET_CHANNELS_BY_RANGE',
|
||||
forwardedArgs: [{ skip: 10, limit: 20 }],
|
||||
method: 'getEpgProgramsForChannels',
|
||||
args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
|
||||
channel: 'EPG_GET_PROGRAMS_FOR_CHANNELS',
|
||||
forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
|
||||
},
|
||||
{
|
||||
method: 'getEpgProgramCoverage',
|
||||
args: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
|
||||
channel: 'EPG_GET_PROGRAM_COVERAGE',
|
||||
forwardedArgs: [{ channelIds, fromMs: 1_000, toMs: 2_000 }],
|
||||
},
|
||||
{
|
||||
method: 'forceFetchEpg',
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
ElectronBridgeDbOperationEvent,
|
||||
ElectronBridgeDownloadStartPayload,
|
||||
ElectronBridgeCurrentProgramsOptions,
|
||||
ElectronBridgeEpgGuideWindow,
|
||||
ElectronBridgeEpgLookupOptions,
|
||||
ElectronBridgeEpgProgress,
|
||||
ElectronBridgePlaybackPositionInput,
|
||||
@@ -672,8 +673,10 @@ const electronApi: ElectronBridgeApi = {
|
||||
) =>
|
||||
ipcRenderer.invoke('EPG_GET_CHANNEL_METADATA', { channelIds, options }),
|
||||
getEpgChannels: () => ipcRenderer.invoke('EPG_GET_CHANNELS'),
|
||||
getEpgChannelsByRange: (skip: number, limit: number) =>
|
||||
ipcRenderer.invoke('EPG_GET_CHANNELS_BY_RANGE', { skip, limit }),
|
||||
getEpgProgramsForChannels: (window: ElectronBridgeEpgGuideWindow) =>
|
||||
ipcRenderer.invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', window),
|
||||
getEpgProgramCoverage: (window: ElectronBridgeEpgGuideWindow) =>
|
||||
ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window),
|
||||
forceFetchEpg: (url: string, options?: ElectronBridgeTrustOptions) =>
|
||||
ipcRenderer.invoke('EPG_FORCE_FETCH', { url, options }),
|
||||
clearEpgData: () => ipcRenderer.invoke('EPG_CLEAR_ALL'),
|
||||
|
||||
@@ -76,13 +76,40 @@ describe('epg-db.events', () => {
|
||||
});
|
||||
|
||||
it('searches programmes with a LIKE pattern built from the trimmed term', async () => {
|
||||
const rows = [{ title: 'News', channel_name: 'NHK' }];
|
||||
const rows = [
|
||||
{
|
||||
channel_id: 'nhk.jp',
|
||||
start: '2026-09-06T16:00:00.000Z',
|
||||
stop: '2026-09-06T16:30:00.000Z',
|
||||
title: 'News',
|
||||
description: 'Evening bulletin',
|
||||
category: 'news',
|
||||
icon_url: 'https://x/icon.png',
|
||||
rating: null,
|
||||
episode_num: 'S1E2',
|
||||
channel_name: 'NHK',
|
||||
},
|
||||
];
|
||||
const all = jest.fn().mockResolvedValue(rows);
|
||||
getDatabase.mockResolvedValue({ all });
|
||||
|
||||
// Raw snake_case columns become the public `EpgProgram` shape.
|
||||
await expect(
|
||||
getIpcMainHandler('EPG_DB_SEARCH_PROGRAMS')({}, ' news ', 25)
|
||||
).resolves.toEqual(rows);
|
||||
).resolves.toEqual([
|
||||
{
|
||||
start: '2026-09-06T16:00:00.000Z',
|
||||
stop: '2026-09-06T16:30:00.000Z',
|
||||
channel: 'nhk.jp',
|
||||
title: 'News',
|
||||
desc: 'Evening bulletin',
|
||||
category: 'news',
|
||||
iconUrl: 'https://x/icon.png',
|
||||
rating: null,
|
||||
episodeNum: 'S1E2',
|
||||
channelName: 'NHK',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(all).toHaveBeenCalledTimes(1);
|
||||
const query = all.mock.calls[0][0] as {
|
||||
|
||||
@@ -9,8 +9,45 @@
|
||||
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { ipcMain } from 'electron';
|
||||
import type { ElectronBridgeEpgSearchProgram } from '@iptvnator/shared/interfaces';
|
||||
import { getDatabase } from '../../database/connection';
|
||||
|
||||
/** Raw `epg_programs` columns as `db.all` returns them, plus the joined channel name. */
|
||||
interface EpgSearchRow {
|
||||
channel_id: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
icon_url: string | null;
|
||||
rating: string | null;
|
||||
episode_num: string | null;
|
||||
channel_name: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `db.all` hands back snake_case columns; the bridge promises the public
|
||||
* `EpgProgram` shape (`channel`, `desc`, `iconUrl`, …), so map here or every
|
||||
* consumer sees `program.channel === undefined`.
|
||||
*/
|
||||
export function toSearchProgram(
|
||||
row: EpgSearchRow
|
||||
): ElectronBridgeEpgSearchProgram {
|
||||
return {
|
||||
start: row.start,
|
||||
stop: row.stop,
|
||||
channel: row.channel_id,
|
||||
title: row.title,
|
||||
desc: row.description,
|
||||
category: row.category,
|
||||
iconUrl: row.icon_url,
|
||||
rating: row.rating,
|
||||
episodeNum: row.episode_num,
|
||||
channelName: row.channel_name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const loggerLabel = '[EPG DB]';
|
||||
|
||||
/**
|
||||
@@ -35,7 +72,7 @@ ipcMain.handle(
|
||||
|
||||
// JOIN with epg_channels to get channel display name
|
||||
// Include all programs (past and future) for catchup/archive feature
|
||||
const results = await db.all(sql`
|
||||
const results = (await db.all(sql`
|
||||
SELECT
|
||||
p.*,
|
||||
c.display_name as channel_name
|
||||
@@ -48,9 +85,9 @@ ipcMain.handle(
|
||||
)
|
||||
ORDER BY p.start
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
`)) as EpgSearchRow[];
|
||||
|
||||
return results;
|
||||
return results.map(toSearchProgram);
|
||||
} catch (error) {
|
||||
console.error(loggerLabel, 'Error searching EPG programs:', error);
|
||||
throw error;
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
|
||||
import {
|
||||
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST,
|
||||
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST,
|
||||
EpgGuideQueryService,
|
||||
guideWindowCondition,
|
||||
normalizeGuideWindow,
|
||||
} from './epg-guide-query.service';
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
|
||||
const getDatabase = jest.fn();
|
||||
|
||||
jest.mock('../database/connection', () => ({
|
||||
getDatabase: (...args: unknown[]) => getDatabase(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../util/epg-logger', () => ({
|
||||
epgLogger: { error: jest.fn(), log: jest.fn() },
|
||||
}));
|
||||
|
||||
function flattenSql(value: unknown, seen = new Set<unknown>()): string {
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean'
|
||||
) {
|
||||
return String(value ?? '');
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return '';
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => flattenSql(item, seen)).join(' ');
|
||||
}
|
||||
const sqlLike = value as {
|
||||
name?: unknown;
|
||||
queryChunks?: unknown[];
|
||||
value?: unknown;
|
||||
};
|
||||
if (Array.isArray(sqlLike.queryChunks)) {
|
||||
return sqlLike.queryChunks
|
||||
.map((chunk) => flattenSql(chunk, seen))
|
||||
.join(' ');
|
||||
}
|
||||
if (Array.isArray(sqlLike.value)) {
|
||||
return sqlLike.value.join(' ');
|
||||
}
|
||||
if (typeof sqlLike.value === 'string') {
|
||||
return sqlLike.value;
|
||||
}
|
||||
if (typeof sqlLike.name === 'string') {
|
||||
return sqlLike.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function programRow(
|
||||
channelId: string,
|
||||
start: string,
|
||||
stop: string,
|
||||
title: string
|
||||
) {
|
||||
return {
|
||||
channelId,
|
||||
start,
|
||||
stop,
|
||||
title,
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** `db.select(cols).from().where().orderBy()` resolving to `rows`. */
|
||||
function programSelect(rows: unknown[], whereCalls: unknown[]) {
|
||||
return jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn((condition: unknown) => {
|
||||
whereCalls.push(condition);
|
||||
return { orderBy: jest.fn().mockResolvedValue(rows) };
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/** `db.selectDistinct(cols).from().where()` resolving to `rows`. */
|
||||
function coverageSelect(rows: unknown[], whereCalls: unknown[]) {
|
||||
return jest.fn(() => ({
|
||||
from: jest.fn(() => ({
|
||||
where: jest.fn((condition: unknown) => {
|
||||
whereCalls.push(condition);
|
||||
return Promise.resolve(rows);
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
const FROM = Date.UTC(2026, 8, 6, 0, 0, 0);
|
||||
const TO = Date.UTC(2026, 8, 7, 0, 0, 0);
|
||||
|
||||
const FROM_ISO = '2026-09-06T00:00:00.000Z';
|
||||
const TO_ISO = '2026-09-07T00:00:00.000Z';
|
||||
/** `FROM_ISO`/`TO_ISO` widened by the 48 h prefilter slack. */
|
||||
const FROM_SLACK_ISO = '2026-09-04T00:00:00.000Z';
|
||||
const TO_SLACK_ISO = '2026-09-09T00:00:00.000Z';
|
||||
|
||||
/**
|
||||
* Renders the real Drizzle predicate (never a hand-written restatement of its
|
||||
* logic) so both its SQL shape and its behaviour can be asserted.
|
||||
*/
|
||||
function renderQuery(
|
||||
epgIds: string[],
|
||||
sourceUrls: string[] = []
|
||||
): { sql: string; params: unknown[] } {
|
||||
const window = normalizeGuideWindow({
|
||||
channelIds: epgIds,
|
||||
fromMs: Date.parse(FROM_ISO),
|
||||
toMs: Date.parse(TO_ISO),
|
||||
sourceUrls,
|
||||
});
|
||||
if (!window) {
|
||||
throw new Error('expected a valid window');
|
||||
}
|
||||
return new SQLiteSyncDialect().sqlToQuery(
|
||||
guideWindowCondition(epgIds, window)
|
||||
);
|
||||
}
|
||||
|
||||
describe('normalizeGuideWindow', () => {
|
||||
it('rejects an empty or inverted window', () => {
|
||||
expect(
|
||||
normalizeGuideWindow({ channelIds: ['a'], fromMs: TO, toMs: FROM })
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeGuideWindow({ channelIds: [' '], fromMs: FROM, toMs: TO })
|
||||
).toBeNull();
|
||||
expect(
|
||||
normalizeGuideWindow({
|
||||
channelIds: ['a'],
|
||||
fromMs: Number.NaN,
|
||||
toMs: TO,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('trims, de-duplicates and caps the channel keys to the default limit', () => {
|
||||
const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`);
|
||||
const window = normalizeGuideWindow({
|
||||
channelIds: [' a ', 'a', ...ids],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
expect(window?.channelIds[0]).toBe('a');
|
||||
expect(window?.channelIds).toHaveLength(
|
||||
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST
|
||||
);
|
||||
expect(window?.fromIso).toBe('2026-09-06T00:00:00.000Z');
|
||||
expect(window?.toIso).toBe('2026-09-07T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('accepts an explicit larger cap for coverage-sized requests', () => {
|
||||
const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`);
|
||||
const window = normalizeGuideWindow(
|
||||
{ channelIds: ids, fromMs: FROM, toMs: TO },
|
||||
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST
|
||||
);
|
||||
expect(window?.channelIds).toHaveLength(150);
|
||||
});
|
||||
|
||||
it('caps sourceUrls at 50 regardless of the channel cap', () => {
|
||||
const urls = Array.from(
|
||||
{ length: 60 },
|
||||
(_, index) => `https://epg.example.com/${index}.xml`
|
||||
);
|
||||
const window = normalizeGuideWindow({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
sourceUrls: urls,
|
||||
});
|
||||
expect(window?.sourceUrls).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('widens the index prefilter bounds to 48 h outside the exact window', () => {
|
||||
const window = normalizeGuideWindow({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
expect(window?.fromSlackIso).toBe(FROM_SLACK_ISO);
|
||||
expect(window?.toSlackIso).toBe(TO_SLACK_ISO);
|
||||
expect(Date.parse(window?.fromSlackIso ?? '')).toBe(
|
||||
FROM - 48 * 60 * 60 * 1000
|
||||
);
|
||||
expect(Date.parse(window?.toSlackIso ?? '')).toBe(
|
||||
TO + 48 * 60 * 60 * 1000
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps a slack bound that would overflow the serializable range', () => {
|
||||
const window = normalizeGuideWindow({
|
||||
channelIds: ['a'],
|
||||
fromMs: -8.64e15,
|
||||
toMs: 8.64e15,
|
||||
});
|
||||
// `toISOString()` throws past this range, so the widened bounds are
|
||||
// clamped instead of widened. Such a window excludes every row through
|
||||
// the exact predicate anyway.
|
||||
expect(window?.fromSlackIso).toBe(new Date(-8.64e15).toISOString());
|
||||
expect(window?.toSlackIso).toBe(new Date(8.64e15).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('guideWindowCondition rendered SQL', () => {
|
||||
it('pairs index-usable plain comparisons with the exact datetime test', () => {
|
||||
const { sql, params } = renderQuery(['a', 'b']);
|
||||
// Plain comparisons on the bare indexed columns: what lets the planner
|
||||
// bound its `(channel_id, start, stop)` range scan.
|
||||
expect(sql).toContain('"epg_programs"."start" < ?');
|
||||
expect(sql).toContain('"epg_programs"."stop" > ?');
|
||||
// The exact overlap test stays — the prefilter only narrows the scan.
|
||||
expect(sql).toContain('datetime("epg_programs"."start") < datetime(?)');
|
||||
expect(sql).toContain('datetime("epg_programs"."stop") > datetime(?)');
|
||||
expect(params).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
TO_SLACK_ISO,
|
||||
FROM_SLACK_ISO,
|
||||
TO_ISO,
|
||||
FROM_ISO,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** `true` when the CLI answers `sqlite3 -version`. Present on macOS and CI. */
|
||||
function hasSqlite(): boolean {
|
||||
try {
|
||||
execFileSync('sqlite3', ['-version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function quote(literal: string): string {
|
||||
return `'${literal.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** Substitutes a Drizzle-rendered query's `?` placeholders with literal SQL values. */
|
||||
function inlineParams(sqlText: string, params: unknown[]): string {
|
||||
let index = 0;
|
||||
return sqlText.replace(/\?/g, () => {
|
||||
const value = params[index];
|
||||
index += 1;
|
||||
return typeof value === 'string' ? quote(value) : String(value);
|
||||
});
|
||||
}
|
||||
|
||||
const describeWithSqlite = hasSqlite() ? describe : describe.skip;
|
||||
|
||||
describeWithSqlite('guideWindowCondition rendered against SQLite', () => {
|
||||
/** The real predicate as literal SQL, ready to prove against the engine. */
|
||||
function renderPredicate(
|
||||
epgIds: string[],
|
||||
sourceUrls: string[] = []
|
||||
): string {
|
||||
const { sql, params } = renderQuery(epgIds, sourceUrls);
|
||||
return inlineParams(sql, params);
|
||||
}
|
||||
|
||||
const CREATE_TABLE =
|
||||
'CREATE TABLE epg_programs (channel_id TEXT, start TEXT, stop TEXT, source_url TEXT);';
|
||||
|
||||
function matchingChannelIds(
|
||||
predicate: string,
|
||||
rows: Array<
|
||||
[
|
||||
channelId: string,
|
||||
start: string,
|
||||
stop: string,
|
||||
sourceUrl: string | null,
|
||||
]
|
||||
>
|
||||
): string[] {
|
||||
const values = rows
|
||||
.map(
|
||||
([channelId, start, stop, sourceUrl]) =>
|
||||
`(${quote(channelId)}, ${quote(start)}, ${quote(stop)}, ${
|
||||
sourceUrl === null ? 'NULL' : quote(sourceUrl)
|
||||
})`
|
||||
)
|
||||
.join(', ');
|
||||
const script = [
|
||||
CREATE_TABLE,
|
||||
`INSERT INTO epg_programs (channel_id, start, stop, source_url) VALUES ${values};`,
|
||||
`SELECT channel_id FROM epg_programs WHERE ${predicate} ORDER BY channel_id;`,
|
||||
].join('\n');
|
||||
const out = execFileSync('sqlite3', [':memory:', script], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return out
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
it('keeps rows overlapping the window and drops boundary-touching or non-overlapping rows', () => {
|
||||
const predicate = renderPredicate(['a', 'b', 'c', 'd', 'e']);
|
||||
const result = matchingChannelIds(predicate, [
|
||||
// Crosses the window start: overlaps.
|
||||
['a', '2026-09-05T23:30:00.000Z', '2026-09-06T00:30:00.000Z', null],
|
||||
// Spans the whole window: overlaps.
|
||||
['b', '2026-09-01T00:00:00.000Z', '2026-09-10T00:00:00.000Z', null],
|
||||
// stop === from: excluded (strict `>`).
|
||||
['c', '2026-09-05T22:00:00.000Z', '2026-09-06T00:00:00.000Z', null],
|
||||
// start === to: excluded (strict `<`).
|
||||
['d', '2026-09-07T00:00:00.000Z', '2026-09-07T01:00:00.000Z', null],
|
||||
// Stored with a +03:00 offset. In UTC this is
|
||||
// [2026-09-06T23:00:00Z, 2026-09-07T00:30:00Z), which overlaps —
|
||||
// but a raw string compare of "07T02:00...+03:00" against
|
||||
// "07T00:00...Z" would say `start < to` is FALSE (lexically "02"
|
||||
// sorts after "00"), wrongly excluding it. Only `datetime()`
|
||||
// normalization recovers the correct overlap.
|
||||
[
|
||||
'e',
|
||||
'2026-09-07T02:00:00+03:00',
|
||||
'2026-09-07T03:30:00+03:00',
|
||||
null,
|
||||
],
|
||||
]);
|
||||
expect(result).toEqual(['a', 'b', 'e']);
|
||||
});
|
||||
|
||||
it('scopes to the requested source URLs plus unsourced legacy rows', () => {
|
||||
const predicate = renderPredicate(
|
||||
['u1-row', 'other-row', 'null-row', 'empty-row'],
|
||||
['u1']
|
||||
);
|
||||
expect(predicate).toContain('"source_url" is null');
|
||||
const result = matchingChannelIds(predicate, [
|
||||
[
|
||||
'u1-row',
|
||||
'2026-09-06T12:00:00.000Z',
|
||||
'2026-09-06T13:00:00.000Z',
|
||||
'u1',
|
||||
],
|
||||
[
|
||||
'other-row',
|
||||
'2026-09-06T12:00:00.000Z',
|
||||
'2026-09-06T13:00:00.000Z',
|
||||
'other',
|
||||
],
|
||||
[
|
||||
'null-row',
|
||||
'2026-09-06T12:00:00.000Z',
|
||||
'2026-09-06T13:00:00.000Z',
|
||||
null,
|
||||
],
|
||||
[
|
||||
'empty-row',
|
||||
'2026-09-06T12:00:00.000Z',
|
||||
'2026-09-06T13:00:00.000Z',
|
||||
'',
|
||||
],
|
||||
]);
|
||||
expect(result).toEqual(['empty-row', 'null-row', 'u1-row']);
|
||||
});
|
||||
|
||||
it('keeps and drops the right rows around the 48 h prefilter slack', () => {
|
||||
const predicate = renderPredicate(['far-east', 'far-future', 'junk']);
|
||||
const result = matchingChannelIds(predicate, [
|
||||
// Stored with the largest real offset (+14:00). In UTC this is
|
||||
// [2026-09-06T23:00:00Z, 2026-09-07T00:30:00Z), which overlaps —
|
||||
// but its wall-clock prefix ("07T13:00") lies 13 h past `to`, so
|
||||
// an un-slackened plain `start < to` compare would drop it. The
|
||||
// 48 h of slack is what keeps the prefilter a superset.
|
||||
[
|
||||
'far-east',
|
||||
'2026-09-07T13:00:00+14:00',
|
||||
'2026-09-07T14:30:00+14:00',
|
||||
null,
|
||||
],
|
||||
// Starts three days past `to`: outside the slack AND outside the
|
||||
// exact window, so both halves of the predicate reject it.
|
||||
[
|
||||
'far-future',
|
||||
'2026-09-10T00:00:00.000Z',
|
||||
'2026-09-10T01:00:00.000Z',
|
||||
null,
|
||||
],
|
||||
// XMLTV wire format, never normalized on import: `datetime()`
|
||||
// yields NULL, so the exact predicate excludes it regardless of
|
||||
// what the prefilter's string compare says.
|
||||
['junk', '20260906180000 +0300', '20260906190000 +0300', null],
|
||||
]);
|
||||
expect(result).toEqual(['far-east']);
|
||||
});
|
||||
|
||||
it('lets the planner use the programme time-range index', () => {
|
||||
const script = [
|
||||
CREATE_TABLE,
|
||||
'CREATE INDEX idx_epg_programs_time_range ON epg_programs(channel_id, start, stop);',
|
||||
`EXPLAIN QUERY PLAN SELECT channel_id FROM epg_programs WHERE ${renderPredicate(
|
||||
['a', 'b']
|
||||
)};`,
|
||||
].join('\n');
|
||||
const plan = execFileSync('sqlite3', [':memory:', script], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
// Without the plain-string prefilter the planner can only constrain
|
||||
// `channel_id=?` and then runs `datetime()` over the channel's whole
|
||||
// retained history; `start<?` is the part that bounds that scan.
|
||||
expect(plan).toContain('idx_epg_programs_time_range');
|
||||
expect(plan).toContain('channel_id=? AND start<?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpgGuideQueryService', () => {
|
||||
const resolveChannelMetadata = jest.fn();
|
||||
let service: EpgGuideQueryService;
|
||||
|
||||
beforeEach(() => {
|
||||
getDatabase.mockReset();
|
||||
resolveChannelMetadata.mockReset();
|
||||
(epgLogger.log as jest.Mock).mockReset();
|
||||
(epgLogger.error as jest.Mock).mockReset();
|
||||
service = new EpgGuideQueryService(
|
||||
{ resolveChannelMetadata },
|
||||
'[Test]'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty object (no per-key placeholders) for an invalid window', async () => {
|
||||
const result = await service.getProgramsForChannels({
|
||||
channelIds: ['a', 'b'],
|
||||
fromMs: TO,
|
||||
toMs: FROM,
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
expect(resolveChannelMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects coverage when the channel lookup itself fails', async () => {
|
||||
resolveChannelMetadata.mockRejectedValue(new Error('lookup down'));
|
||||
await expect(
|
||||
service.getProgramCoverage({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
})
|
||||
).rejects.toThrow('lookup down');
|
||||
// Programmes keep the documented fail-soft answer.
|
||||
await expect(
|
||||
service.getProgramsForChannels({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
})
|
||||
).resolves.toEqual({ a: [] });
|
||||
});
|
||||
|
||||
it('rethrows a coverage failure after logging it', async () => {
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
a: { id: 'a', displayName: 'A', iconUrl: null },
|
||||
});
|
||||
getDatabase.mockRejectedValue(new Error('locked'));
|
||||
await expect(
|
||||
service.getProgramCoverage({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
})
|
||||
).rejects.toThrow('locked');
|
||||
});
|
||||
|
||||
it('resolves keys through channel metadata and maps rows back onto every requested key', async () => {
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
|
||||
'zdf.de': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
|
||||
unknown: null,
|
||||
});
|
||||
const whereCalls: unknown[] = [];
|
||||
getDatabase.mockResolvedValue({
|
||||
select: programSelect(
|
||||
[
|
||||
programRow(
|
||||
'zdf.de',
|
||||
'2026-09-06T16:00:00.000Z',
|
||||
'2026-09-06T16:45:00.000Z',
|
||||
'heute-journal'
|
||||
),
|
||||
programRow(
|
||||
'zdf.de',
|
||||
'2026-09-06T16:00:00.000Z',
|
||||
'2026-09-06T16:45:00.000Z',
|
||||
'heute-journal'
|
||||
),
|
||||
],
|
||||
whereCalls
|
||||
),
|
||||
});
|
||||
|
||||
const result = await service.getProgramsForChannels({
|
||||
channelIds: ['ZDF HD', 'zdf.de', 'unknown'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(resolveChannelMetadata).toHaveBeenCalledWith(
|
||||
['ZDF HD', 'zdf.de', 'unknown'],
|
||||
{}
|
||||
);
|
||||
expect(result['ZDF HD']).toHaveLength(1);
|
||||
expect(result['zdf.de']).toHaveLength(1);
|
||||
expect(result['ZDF HD'][0]).toMatchObject({
|
||||
channel: 'zdf.de',
|
||||
title: 'heute-journal',
|
||||
});
|
||||
// Both keys resolved to the same channel — each must get its own
|
||||
// array so mutating one can never affect the other.
|
||||
expect(result['ZDF HD']).not.toBe(result['zdf.de']);
|
||||
expect(result['unknown']).toEqual([]);
|
||||
const condition = flattenSql(whereCalls[0]);
|
||||
expect(condition).toContain('channel_id');
|
||||
expect(condition).toContain('2026-09-07T00:00:00.000Z');
|
||||
expect(condition).toContain('2026-09-06T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('scopes the programme rows to the requested source URLs plus legacy (unsourced) rows', async () => {
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
a: { id: 'a', displayName: 'A', iconUrl: null },
|
||||
});
|
||||
const whereCalls: unknown[] = [];
|
||||
getDatabase.mockResolvedValue({
|
||||
select: programSelect([], whereCalls),
|
||||
});
|
||||
|
||||
await service.getProgramsForChannels({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
sourceUrls: ['https://guide.example.com/epg.xml'],
|
||||
});
|
||||
|
||||
expect(resolveChannelMetadata).toHaveBeenCalledWith(['a'], {
|
||||
sourceUrls: ['https://guide.example.com/epg.xml'],
|
||||
});
|
||||
const condition = flattenSql(whereCalls[0]).toLowerCase();
|
||||
expect(condition).toContain('source_url');
|
||||
expect(condition).toContain('is null');
|
||||
});
|
||||
|
||||
it('fails soft when the database throws', async () => {
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
a: { id: 'a', displayName: 'A', iconUrl: null },
|
||||
});
|
||||
getDatabase.mockRejectedValue(new Error('locked'));
|
||||
|
||||
await expect(
|
||||
service.getProgramsForChannels({
|
||||
channelIds: ['a'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
})
|
||||
).resolves.toEqual({ a: [] });
|
||||
});
|
||||
|
||||
it('keeps a provider key named __proto__ as an own response property', async () => {
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
__proto__: { id: 'proto.tv', displayName: 'Proto', iconUrl: null },
|
||||
});
|
||||
getDatabase.mockResolvedValue({
|
||||
select: programSelect(
|
||||
[
|
||||
programRow(
|
||||
'proto.tv',
|
||||
'2026-09-06T16:00:00.000Z',
|
||||
'2026-09-06T16:45:00.000Z',
|
||||
'Proto show'
|
||||
),
|
||||
],
|
||||
[]
|
||||
),
|
||||
});
|
||||
|
||||
const result = await service.getProgramsForChannels({
|
||||
channelIds: ['__proto__'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(
|
||||
true
|
||||
);
|
||||
expect(result['__proto__'][0].title).toBe('Proto show');
|
||||
});
|
||||
|
||||
it('drops channel keys cut by the per-request cap from the result entirely', async () => {
|
||||
const ids = Array.from({ length: 101 }, (_, index) => `ch-${index}`);
|
||||
resolveChannelMetadata.mockResolvedValue({});
|
||||
|
||||
const result = await service.getProgramsForChannels({
|
||||
channelIds: ids,
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(Object.keys(result)).toHaveLength(
|
||||
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST
|
||||
);
|
||||
// The 101st key was cut by the cap: absent, never present as `[]`.
|
||||
expect('ch-100' in result).toBe(false);
|
||||
expect(epgLogger.log).toHaveBeenCalledWith(
|
||||
'[Test]',
|
||||
'Guide programme request truncated',
|
||||
{ requested: 101, kept: EPG_GUIDE_MAX_CHANNELS_PER_REQUEST }
|
||||
);
|
||||
});
|
||||
|
||||
it('reports coverage for the requested keys whose channel has a programme in the window', async () => {
|
||||
resolveChannelMetadata.mockResolvedValue({
|
||||
'ZDF HD': { id: 'zdf.de', displayName: 'ZDF HD', iconUrl: null },
|
||||
ARTE: { id: 'arte.de', displayName: 'ARTE', iconUrl: null },
|
||||
none: null,
|
||||
});
|
||||
const whereCalls: unknown[] = [];
|
||||
getDatabase.mockResolvedValue({
|
||||
selectDistinct: coverageSelect(
|
||||
[{ channelId: 'zdf.de' }],
|
||||
whereCalls
|
||||
),
|
||||
});
|
||||
|
||||
const covered = await service.getProgramCoverage({
|
||||
channelIds: ['ZDF HD', 'ARTE', 'none'],
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(covered).toEqual(['ZDF HD']);
|
||||
expect(flattenSql(whereCalls[0])).toContain('channel_id');
|
||||
});
|
||||
|
||||
it('does not drop any of 150 requested keys under the larger coverage cap', async () => {
|
||||
const ids = Array.from({ length: 150 }, (_, index) => `ch-${index}`);
|
||||
resolveChannelMetadata.mockResolvedValue({});
|
||||
|
||||
await service.getProgramCoverage({
|
||||
channelIds: ids,
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(resolveChannelMetadata).toHaveBeenCalledWith(ids, {});
|
||||
});
|
||||
|
||||
it('logs the coverage-specific truncation message when the coverage cap drops keys', async () => {
|
||||
const ids = Array.from(
|
||||
{ length: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + 1 },
|
||||
(_, index) => `ch-${index}`
|
||||
);
|
||||
resolveChannelMetadata.mockResolvedValue({});
|
||||
|
||||
await service.getProgramCoverage({
|
||||
channelIds: ids,
|
||||
fromMs: FROM,
|
||||
toMs: TO,
|
||||
});
|
||||
|
||||
expect(epgLogger.log).toHaveBeenCalledWith(
|
||||
'[Test]',
|
||||
'Guide coverage request truncated',
|
||||
{
|
||||
requested: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST + 1,
|
||||
kept: EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
gt,
|
||||
inArray,
|
||||
isNull,
|
||||
lt,
|
||||
or,
|
||||
sql,
|
||||
type SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { getDatabase } from '../database/connection';
|
||||
import * as schema from '../database/schema';
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import {
|
||||
EpgProgramRow,
|
||||
isValidEpgProgram,
|
||||
toEpgProgramFromRow,
|
||||
} from './epg-program-row.util';
|
||||
import { epgQueryService, EpgQueryService } from './epg-query.service';
|
||||
import {
|
||||
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST,
|
||||
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST,
|
||||
EpgGuideWindowRequest,
|
||||
NormalizedGuideWindow,
|
||||
normalizeGuideWindow,
|
||||
uniqueTrimmedStrings,
|
||||
} from './epg-guide-window.util';
|
||||
|
||||
export {
|
||||
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST,
|
||||
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST,
|
||||
normalizeGuideWindow,
|
||||
} from './epg-guide-window.util';
|
||||
export type {
|
||||
EpgGuideWindowRequest,
|
||||
NormalizedGuideWindow,
|
||||
} from './epg-guide-window.util';
|
||||
|
||||
/** The strict resolver: a lookup failure rejects instead of answering `{}`. */
|
||||
type ChannelResolver = Pick<EpgQueryService, 'resolveChannelMetadata'>;
|
||||
|
||||
/**
|
||||
* Overlap test in SQLite `datetime()` so provider-local offsets in the
|
||||
* stored ISO strings compare correctly against the UTC window bounds.
|
||||
*
|
||||
* `datetime()` around the indexed `start`/`stop` columns is opaque to the
|
||||
* planner, which would use `idx_epg_programs_time_range` for `channel_id`
|
||||
* only and then run the function over the channel's ENTIRE retained history —
|
||||
* on a multi-week XMLTV database a 2000-key coverage probe stalls on that. So
|
||||
* the exact test is paired with plain string comparisons against the
|
||||
* slack-widened bounds (`toSlackIso`/`fromSlackIso`), which the planner CAN
|
||||
* turn into a `channel_id=? AND start<?` range scan. The slack makes that
|
||||
* prefilter a strict superset of the exact result, so it narrows the scan
|
||||
* without ever changing the answer — see `GUIDE_WINDOW_SLACK_MS` in
|
||||
* `epg-guide-window.util.ts` for why 48 h is enough.
|
||||
*
|
||||
* Unlike `EpgQueryService`, which runs a scoped query and then, on an empty
|
||||
* result, a second unscoped legacy query, this predicate is folded into a
|
||||
* SINGLE query: when `sourceUrls` is non-empty, the `WHERE` clause accepts
|
||||
* the union of rows belonging to the requested sources AND unsourced legacy
|
||||
* rows (`source_url` null or `''`) in one pass. Rows that satisfy both a
|
||||
* requested source and the legacy leg are not double-counted — identical
|
||||
* slots (same channel + start + title) are collapsed later in
|
||||
* `groupPrograms`. Legacy rows exist only for programme data imported
|
||||
* before per-source EPG tracking was added; the `source_url` backfill in
|
||||
* `libs/shared/database/src/lib/connection.ts` fills it in where it can
|
||||
* still be inferred from the owning channel, but a row it cannot attribute
|
||||
* stays unsourced permanently. In v1 the M3U host never scopes this query to
|
||||
* specific sources (it queries unscoped), so this additive behaviour only
|
||||
* matters for portal (Xtream/Stalker) hosts, which do pass `sourceUrls`.
|
||||
*/
|
||||
export function guideWindowCondition(
|
||||
epgIds: string[],
|
||||
window: NormalizedGuideWindow
|
||||
): SQL {
|
||||
const overlap = and(
|
||||
inArray(schema.epgPrograms.channelId, epgIds),
|
||||
lt(schema.epgPrograms.start, window.toSlackIso),
|
||||
gt(schema.epgPrograms.stop, window.fromSlackIso),
|
||||
sql`datetime(${schema.epgPrograms.start}) < datetime(${window.toIso})`,
|
||||
sql`datetime(${schema.epgPrograms.stop}) > datetime(${window.fromIso})`
|
||||
) as SQL;
|
||||
if (window.sourceUrls.length === 0) {
|
||||
return overlap;
|
||||
}
|
||||
return and(
|
||||
overlap,
|
||||
or(
|
||||
inArray(schema.epgPrograms.sourceUrl, window.sourceUrls),
|
||||
isNull(schema.epgPrograms.sourceUrl),
|
||||
eq(schema.epgPrograms.sourceUrl, '')
|
||||
) as SQL
|
||||
) as SQL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme-guide reads for a batch of playlist channel keys: every programme
|
||||
* overlapping a time window, and which keys have any programme in it at all.
|
||||
* Keys resolve to XMLTV channel ids through the same metadata lookup the
|
||||
* sidebar uses (exact id, case-insensitive id, display name); manual mappings
|
||||
* are applied by the IPC layer before the keys reach this service.
|
||||
*/
|
||||
export class EpgGuideQueryService {
|
||||
constructor(
|
||||
private readonly resolver: ChannelResolver = epgQueryService,
|
||||
private readonly loggerLabel = '[EPG Guide]'
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Response keys are exactly the trimmed, de-duplicated, cap-respecting
|
||||
* requested keys (`window.channelIds`) — never the raw request. A key cut
|
||||
* by the per-request cap is absent from the result, never present with an
|
||||
* empty list, so a caller can tell "queried, nothing found" apart from
|
||||
* "not queried at all". An invalid window returns `{}`.
|
||||
*/
|
||||
async getProgramsForChannels(
|
||||
request: EpgGuideWindowRequest
|
||||
): Promise<Record<string, EpgProgram[]>> {
|
||||
const window = normalizeGuideWindow(
|
||||
request,
|
||||
EPG_GUIDE_MAX_CHANNELS_PER_REQUEST
|
||||
);
|
||||
if (!window) {
|
||||
return {};
|
||||
}
|
||||
this.warnIfTruncated(
|
||||
request.channelIds,
|
||||
window.channelIds,
|
||||
'programme'
|
||||
);
|
||||
const result = this.emptyResult(window.channelIds);
|
||||
try {
|
||||
const resolved = await this.resolveChannelIds(window);
|
||||
const epgIds = Array.from(new Set(resolved.values()));
|
||||
if (epgIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const db = await getDatabase();
|
||||
const rows: EpgProgramRow[] = await db
|
||||
.select({
|
||||
channelId: schema.epgPrograms.channelId,
|
||||
start: schema.epgPrograms.start,
|
||||
stop: schema.epgPrograms.stop,
|
||||
title: schema.epgPrograms.title,
|
||||
description: schema.epgPrograms.description,
|
||||
category: schema.epgPrograms.category,
|
||||
iconUrl: schema.epgPrograms.iconUrl,
|
||||
rating: schema.epgPrograms.rating,
|
||||
episodeNum: schema.epgPrograms.episodeNum,
|
||||
})
|
||||
.from(schema.epgPrograms)
|
||||
.where(guideWindowCondition(epgIds, window))
|
||||
.orderBy(schema.epgPrograms.start);
|
||||
const byEpgId = this.groupPrograms(rows);
|
||||
for (const [requestedId, epgId] of resolved) {
|
||||
// Copy so two requested keys resolving to the same channel
|
||||
// never share one array reference.
|
||||
result[requestedId] = [...(byEpgId.get(epgId) ?? [])];
|
||||
}
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error loading guide programmes:',
|
||||
error
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response keys are exactly the trimmed, de-duplicated, cap-respecting
|
||||
* requested keys (`window.channelIds`) — never the raw request. A key cut
|
||||
* by the per-request cap is absent from the result. An invalid window,
|
||||
* or any failure while querying, returns `[]`.
|
||||
*/
|
||||
async getProgramCoverage(
|
||||
request: EpgGuideWindowRequest
|
||||
): Promise<string[]> {
|
||||
const window = normalizeGuideWindow(
|
||||
request,
|
||||
EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST
|
||||
);
|
||||
if (!window) {
|
||||
return [];
|
||||
}
|
||||
this.warnIfTruncated(request.channelIds, window.channelIds, 'coverage');
|
||||
try {
|
||||
const resolved = await this.resolveChannelIds(window);
|
||||
const epgIds = Array.from(new Set(resolved.values()));
|
||||
if (epgIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await getDatabase();
|
||||
const rows: Array<{ channelId: string }> = await db
|
||||
.selectDistinct({ channelId: schema.epgPrograms.channelId })
|
||||
.from(schema.epgPrograms)
|
||||
.where(guideWindowCondition(epgIds, window));
|
||||
const covered = new Set(rows.map((row) => row.channelId));
|
||||
return window.channelIds.filter((requestedId) => {
|
||||
const epgId = resolved.get(requestedId);
|
||||
return epgId !== undefined && covered.has(epgId);
|
||||
});
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error loading guide coverage:',
|
||||
error
|
||||
);
|
||||
// Unlike programmes, coverage must not fail soft: an empty answer
|
||||
// reads as "no channel has EPG" and the "Only with EPG" toggle
|
||||
// would hide every row. A rejection keeps coverage unknown.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private emptyResult(channelIds: string[]): Record<string, EpgProgram[]> {
|
||||
// Null prototype: a provider key such as `__proto__` must become an
|
||||
// own property, not hit the legacy setter.
|
||||
const result: Record<string, EpgProgram[]> = Object.create(null);
|
||||
for (const id of channelIds) {
|
||||
result[id] = [];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs (counts only — no channel keys or source URLs) when the per-request
|
||||
* cap dropped part of the request, so an oversized batch is visible in
|
||||
* diagnostics instead of silently losing channels. `kind` names which
|
||||
* read was truncated (programme lookup vs. coverage probe) since both
|
||||
* share this helper but log to the same channel.
|
||||
*/
|
||||
private warnIfTruncated(
|
||||
requestedIds: unknown,
|
||||
keptIds: string[],
|
||||
kind: 'programme' | 'coverage'
|
||||
): void {
|
||||
const requestedCount = uniqueTrimmedStrings(
|
||||
requestedIds,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
).length;
|
||||
if (requestedCount > keptIds.length) {
|
||||
epgLogger.log(this.loggerLabel, `Guide ${kind} request truncated`, {
|
||||
requested: requestedCount,
|
||||
kept: keptIds.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** requested key → XMLTV channel id (keys without a match are absent). */
|
||||
private async resolveChannelIds(
|
||||
window: NormalizedGuideWindow
|
||||
): Promise<Map<string, string>> {
|
||||
const metadata = await this.resolver.resolveChannelMetadata(
|
||||
window.channelIds,
|
||||
window.sourceUrls.length > 0
|
||||
? { sourceUrls: window.sourceUrls }
|
||||
: {}
|
||||
);
|
||||
const resolved = new Map<string, string>();
|
||||
for (const requestedId of window.channelIds) {
|
||||
const epgId = metadata[requestedId]?.id;
|
||||
if (epgId) {
|
||||
resolved.set(requestedId, epgId);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group by channel, drop rows with an invalid/unparsable start or stop
|
||||
* (mirrors `EpgQueryService.isValidEpgProgram`), and collapse duplicate
|
||||
* slots (same start + title).
|
||||
*/
|
||||
private groupPrograms(rows: EpgProgramRow[]): Map<string, EpgProgram[]> {
|
||||
const grouped = new Map<string, EpgProgram[]>();
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.channelId}|${row.start}|${row.title}`;
|
||||
if (seen.has(key) || !row.title) {
|
||||
continue;
|
||||
}
|
||||
const program = toEpgProgramFromRow(row);
|
||||
if (!isValidEpgProgram(program)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
const list = grouped.get(row.channelId) ?? [];
|
||||
list.push(program);
|
||||
grouped.set(row.channelId, list);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
}
|
||||
|
||||
export const epgGuideQueryService = new EpgGuideQueryService();
|
||||
@@ -0,0 +1,124 @@
|
||||
/** The renderer splits larger channel batches; anything beyond this is dropped. */
|
||||
export const EPG_GUIDE_MAX_CHANNELS_PER_REQUEST = 100;
|
||||
|
||||
/**
|
||||
* Coverage checks are cheap (`selectDistinct` over an already-scoped id set)
|
||||
* and portal hosts may probe many more keys than a single guide screen shows
|
||||
* at once, so this cap is intentionally much larger than the programme cap.
|
||||
*/
|
||||
export const EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST = 2000;
|
||||
|
||||
/** Bound on how many distinct EPG source URLs one request may scope to. */
|
||||
const MAX_SOURCE_URLS_PER_REQUEST = 50;
|
||||
|
||||
/** Largest |ms| `Date` can serialize; a corrupt payload must not throw in SQL. */
|
||||
const MAX_SERIALIZABLE_MS = 8.64e15;
|
||||
|
||||
/**
|
||||
* Slack applied to the index-usable plain-string bounds
|
||||
* (`fromSlackIso`/`toSlackIso`) that `guideWindowCondition` puts beside its
|
||||
* exact `datetime()` overlap test.
|
||||
*
|
||||
* Stored `start`/`stop` are ISO-8601 strings, so a lexicographic compare
|
||||
* orders them by their local wall-clock prefix rather than by the instant
|
||||
* they denote. A provider offset shifts the true instant by at most 14 h, and
|
||||
* a same-day `'YYYY-MM-DD HH:MM'` vs `'…THH:MM'` format difference by less
|
||||
* than 24 h (a space sorts before 'T'), so 48 h of slack keeps the
|
||||
* prefilter a strict superset of the exact result — while cutting the
|
||||
* per-channel scan from the channel's whole retained history down to about
|
||||
* four days. Rows whose strings `datetime()` cannot parse fail the exact
|
||||
* predicate anyway, so the prefilter can never change the answer.
|
||||
*/
|
||||
const GUIDE_WINDOW_SLACK_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
export interface EpgGuideWindowRequest {
|
||||
channelIds: string[];
|
||||
/** Provider-clock instants (the renderer removes the display offset). */
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
sourceUrls?: string[];
|
||||
}
|
||||
|
||||
export interface NormalizedGuideWindow {
|
||||
channelIds: string[];
|
||||
fromIso: string;
|
||||
toIso: string;
|
||||
/**
|
||||
* The same window widened by `GUIDE_WINDOW_SLACK_MS` on both sides, for
|
||||
* the plain-string prefilter that lets SQLite bound its
|
||||
* `(channel_id, start, stop)` index scan. Never a substitute for the
|
||||
* exact `datetime()` comparison against `fromIso`/`toIso`.
|
||||
*/
|
||||
fromSlackIso: string;
|
||||
toSlackIso: string;
|
||||
sourceUrls: string[];
|
||||
}
|
||||
|
||||
function isUsableInstant(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
Math.abs(value) <= MAX_SERIALIZABLE_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a widened bound. `toISOString()` throws past
|
||||
* `MAX_SERIALIZABLE_MS`, so the slack is clamped there. A bound that lands in
|
||||
* the extended `+YYYYYY-…` year form is one `datetime()` cannot parse, so the
|
||||
* exact predicate already excludes every row for such a window and the
|
||||
* narrower prefilter stays harmless.
|
||||
*/
|
||||
function slackIso(ms: number): string {
|
||||
const clamped = Math.min(
|
||||
Math.max(ms, -MAX_SERIALIZABLE_MS),
|
||||
MAX_SERIALIZABLE_MS
|
||||
);
|
||||
return new Date(clamped).toISOString();
|
||||
}
|
||||
|
||||
/** Trims, drops blanks, de-duplicates (first-occurrence order) and caps. */
|
||||
export function uniqueTrimmedStrings(values: unknown, max: number): string[] {
|
||||
if (!Array.isArray(values)) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
if (trimmed.length > 0) {
|
||||
seen.add(trimmed);
|
||||
}
|
||||
}
|
||||
return Array.from(seen).slice(0, max);
|
||||
}
|
||||
|
||||
/** Validates and trims a request; `null` means "nothing to query". */
|
||||
export function normalizeGuideWindow(
|
||||
request: EpgGuideWindowRequest,
|
||||
maxChannels = EPG_GUIDE_MAX_CHANNELS_PER_REQUEST
|
||||
): NormalizedGuideWindow | null {
|
||||
if (
|
||||
!isUsableInstant(request.fromMs) ||
|
||||
!isUsableInstant(request.toMs) ||
|
||||
request.fromMs >= request.toMs ||
|
||||
!Array.isArray(request.channelIds)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const channelIds = uniqueTrimmedStrings(request.channelIds, maxChannels);
|
||||
if (channelIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const sourceUrls = uniqueTrimmedStrings(
|
||||
request.sourceUrls ?? [],
|
||||
MAX_SOURCE_URLS_PER_REQUEST
|
||||
);
|
||||
return {
|
||||
channelIds,
|
||||
fromIso: new Date(request.fromMs).toISOString(),
|
||||
toIso: new Date(request.toMs).toISOString(),
|
||||
fromSlackIso: slackIso(request.fromMs - GUIDE_WINDOW_SLACK_MS),
|
||||
toSlackIso: slackIso(request.toMs + GUIDE_WINDOW_SLACK_MS),
|
||||
sourceUrls,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type EpgEventsType from './epg.events';
|
||||
|
||||
const getProgramsForChannels = jest.fn();
|
||||
const getProgramCoverage = jest.fn();
|
||||
const resolveChannelIds = jest.fn();
|
||||
const resolveChannelIdsStrict = jest.fn();
|
||||
const ipcHandlers = new Map<
|
||||
string,
|
||||
(event: unknown, args: unknown) => Promise<unknown>
|
||||
>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => [],
|
||||
},
|
||||
ipcMain: {
|
||||
handle: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./epg-guide-query.service', () => ({
|
||||
epgGuideQueryService: {
|
||||
getProgramsForChannels: (...args: unknown[]) =>
|
||||
getProgramsForChannels(...args),
|
||||
getProgramCoverage: (...args: unknown[]) => getProgramCoverage(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('./epg-mapping.service', () => ({
|
||||
resolveChannelIds: (...args: unknown[]) => resolveChannelIds(...args),
|
||||
resolveChannelIdsStrict: (...args: unknown[]) =>
|
||||
resolveChannelIdsStrict(...args),
|
||||
queryByResolvedChannelIds: jest.fn(),
|
||||
handleGetEpgMapping: jest.fn(),
|
||||
handleGetEpgMappingsBatch: jest.fn(),
|
||||
handleSetEpgMapping: jest.fn(),
|
||||
handleDeleteEpgMapping: jest.fn(),
|
||||
handleSearchEpgChannels: jest.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` apply manual
|
||||
* mappings (`resolveChannelIds`) before delegating to `epgGuideQueryService`,
|
||||
* then key the answer back by the verbatim requested id. The service keys
|
||||
* its own answer by the TRIMMED, deduplicated form of the ids it was given
|
||||
* (`normalizeGuideWindow`), so the handler must resolve the same trimmed key
|
||||
* to find that answer — these tests pin the trim mismatch, the "absent
|
||||
* means not queried" contract, and the coverage de-duplication.
|
||||
*/
|
||||
describe('EPG guide IPC handlers', () => {
|
||||
let EpgEvents: typeof EpgEventsType;
|
||||
|
||||
const program = {
|
||||
channelId: 'xmltv.id',
|
||||
title: 'News',
|
||||
start: '2026-01-01T00:00:00.000Z',
|
||||
stop: '2026-01-01T01:00:00.000Z',
|
||||
description: null,
|
||||
category: null,
|
||||
iconUrl: null,
|
||||
rating: null,
|
||||
episodeNum: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
ipcHandlers.clear();
|
||||
[getProgramsForChannels, getProgramCoverage, resolveChannelIds].forEach(
|
||||
(mock) => mock.mockReset()
|
||||
);
|
||||
resolveChannelIds.mockResolvedValue(new Map());
|
||||
resolveChannelIdsStrict.mockResolvedValue(new Map());
|
||||
|
||||
({ default: EpgEvents } = await import('./epg.events'));
|
||||
|
||||
const { ipcMain } = jest.requireMock('electron');
|
||||
(ipcMain.handle as jest.Mock).mockReset();
|
||||
(ipcMain.handle as jest.Mock).mockImplementation(
|
||||
(
|
||||
channel: string,
|
||||
handler: (event: unknown, args: unknown) => Promise<unknown>
|
||||
) => {
|
||||
ipcHandlers.set(channel, handler);
|
||||
}
|
||||
);
|
||||
|
||||
EpgEvents.bootstrapEpgEvents();
|
||||
});
|
||||
|
||||
function invoke(channel: string, args: unknown): Promise<unknown> {
|
||||
const handler = ipcHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`No IPC handler registered for "${channel}"`);
|
||||
}
|
||||
return handler({}, args);
|
||||
}
|
||||
|
||||
it('applies a manual mapping before querying and keys the answer back by the requested key', async () => {
|
||||
resolveChannelIds.mockResolvedValue(new Map([['m3u-key', 'xmltv.id']]));
|
||||
getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] });
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: ['m3u-key'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(getProgramsForChannels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelIds: ['xmltv.id'] })
|
||||
);
|
||||
expect(result).toEqual({ 'm3u-key': [program] });
|
||||
});
|
||||
|
||||
it('gives two requested keys resolving to one target their own array reference', async () => {
|
||||
resolveChannelIds.mockResolvedValue(
|
||||
new Map([
|
||||
['a', 'xmltv.id'],
|
||||
['b', 'xmltv.id'],
|
||||
])
|
||||
);
|
||||
getProgramsForChannels.mockResolvedValue({ 'xmltv.id': [program] });
|
||||
|
||||
const result = (await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: ['a', 'b'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
})) as Record<string, unknown[]>;
|
||||
|
||||
expect(result['a']).toEqual([program]);
|
||||
expect(result['b']).toEqual([program]);
|
||||
expect(result['a']).not.toBe(result['b']);
|
||||
});
|
||||
|
||||
it('trims a padded requested id to find the service answer keyed by the trimmed form', async () => {
|
||||
getProgramsForChannels.mockResolvedValue({ CNN: [program] });
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: [' CNN '],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(getProgramsForChannels).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelIds: [' CNN '] })
|
||||
);
|
||||
expect(result).toEqual({ ' CNN ': [program] });
|
||||
});
|
||||
|
||||
it('omits a key the service did not answer for instead of filling it with []', async () => {
|
||||
getProgramsForChannels.mockResolvedValue({});
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: ['dropped'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects coverage when the manual-mapping lookup fails, but still answers programmes', async () => {
|
||||
resolveChannelIdsStrict.mockRejectedValue(new Error('mapping db down'));
|
||||
getProgramsForChannels.mockResolvedValue({ a: [] });
|
||||
|
||||
await expect(
|
||||
invoke('EPG_GET_PROGRAM_COVERAGE', {
|
||||
channelIds: ['a'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
})
|
||||
).rejects.toThrow('mapping db down');
|
||||
await expect(
|
||||
invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', {
|
||||
channelIds: ['a'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
})
|
||||
).resolves.toEqual({ a: [] });
|
||||
});
|
||||
|
||||
it('de-duplicates requested coverage keys and keeps only the covered ones', async () => {
|
||||
getProgramCoverage.mockResolvedValue(['a']);
|
||||
|
||||
const result = await invoke('EPG_GET_PROGRAM_COVERAGE', {
|
||||
channelIds: ['a', 'a', 'b'],
|
||||
fromMs: 0,
|
||||
toMs: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -34,14 +34,24 @@ export async function resolveChannelIds(
|
||||
channelIds: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const mappings = await getEpgMappingsBatch(db, channelIds);
|
||||
return mappings;
|
||||
return await resolveChannelIdsStrict(channelIds);
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same lookup, but a database failure rejects. The guide's coverage read
|
||||
* uses it: answering "unmapped" for a mapped channel would let the
|
||||
* "Only with EPG" filter hide it, whereas a rejection keeps coverage unknown.
|
||||
*/
|
||||
export async function resolveChannelIdsStrict(
|
||||
channelIds: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const db = await getDatabase();
|
||||
return getEpgMappingsBatch(db, channelIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a batch query against mapping-resolved channel IDs and remap the results
|
||||
* back onto the original request keys the renderer asked for.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Shared shape for a raw `epg_programs` row as read by both
|
||||
* `EpgQueryService` and `EpgGuideQueryService`. Callers with additional
|
||||
* columns (e.g. `id`) satisfy this structurally without extending it.
|
||||
*/
|
||||
export interface EpgProgramRow {
|
||||
channelId: string;
|
||||
start: string;
|
||||
stop: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
iconUrl: string | null;
|
||||
rating: string | null;
|
||||
episodeNum: string | null;
|
||||
}
|
||||
|
||||
/** Maps a raw DB row onto the public `EpgProgram` shape. No validation. */
|
||||
export function toEpgProgramFromRow(row: EpgProgramRow): EpgProgram {
|
||||
return {
|
||||
start: row.start,
|
||||
stop: row.stop,
|
||||
channel: row.channelId,
|
||||
title: row.title,
|
||||
desc: row.description,
|
||||
category: row.category,
|
||||
iconUrl: row.iconUrl,
|
||||
rating: row.rating,
|
||||
episodeNum: row.episodeNum,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A programme is only usable once its start/stop are non-empty AND parse to
|
||||
* a real instant — a malformed XMLTV timestamp must not reach the renderer
|
||||
* as a bogus block.
|
||||
*/
|
||||
export function isValidEpgProgram(program: EpgProgram): boolean {
|
||||
return Boolean(
|
||||
program.start &&
|
||||
program.stop &&
|
||||
!Number.isNaN(new Date(program.start).getTime()) &&
|
||||
!Number.isNaN(new Date(program.stop).getTime())
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { epgLogger } from '../util/epg-logger';
|
||||
import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import {
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { getDatabase } from '../database/connection';
|
||||
import * as schema from '../database/schema';
|
||||
import { isValidEpgProgram, toEpgProgramFromRow } from './epg-program-row.util';
|
||||
|
||||
interface EpgProgramRow {
|
||||
interface EpgProgramSelectRow {
|
||||
id: number;
|
||||
channelId: string;
|
||||
start: string;
|
||||
@@ -215,11 +213,12 @@ export class EpgQueryService {
|
||||
|
||||
unmatchedIds = validIds.filter((id) => !(id in result));
|
||||
if (unmatchedIds.length > 0) {
|
||||
const scopedCandidates = await this.selectChannelLookupCandidates(
|
||||
db,
|
||||
unmatchedIds,
|
||||
sourceUrls
|
||||
);
|
||||
const scopedCandidates =
|
||||
await this.selectChannelLookupCandidates(
|
||||
db,
|
||||
unmatchedIds,
|
||||
sourceUrls
|
||||
);
|
||||
const unresolvedIds = unmatchedIds.filter(
|
||||
(channelId) =>
|
||||
!this.resolveChannelMetadataCandidate(
|
||||
@@ -345,52 +344,62 @@ export class EpgQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve channel lookup keys to XMLTV channel metadata. Throws on a
|
||||
* database failure so callers that must tell "no match" apart from "could
|
||||
* not look up" (the guide's coverage read) can fail open.
|
||||
*/
|
||||
async resolveChannelMetadata(
|
||||
channelIds: string[],
|
||||
options: { sourceUrls?: string[] } = {}
|
||||
): Promise<Record<string, EpgChannelMetadata | null>> {
|
||||
const normalizedChannelIds =
|
||||
this.normalizeChannelLookupKeys(channelIds);
|
||||
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
|
||||
let candidates = await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
normalizedChannelIds,
|
||||
sourceUrls
|
||||
);
|
||||
if (sourceUrls.length > 0) {
|
||||
const missingChannelIds = normalizedChannelIds.filter(
|
||||
(channelId) =>
|
||||
!this.resolveChannelMetadataCandidate(channelId, candidates)
|
||||
);
|
||||
if (missingChannelIds.length > 0) {
|
||||
candidates = [
|
||||
...candidates,
|
||||
...(await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
missingChannelIds,
|
||||
sourceUrls,
|
||||
{ legacyOnly: true }
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
channelId,
|
||||
this.resolveChannelMetadataCandidate(channelId, candidates),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** Fail-soft variant of `resolveChannelMetadata`: a failure logs and answers `{}`. */
|
||||
async getChannelMetadata(
|
||||
channelIds: string[],
|
||||
options: { sourceUrls?: string[] } = {}
|
||||
): Promise<Record<string, EpgChannelMetadata | null>> {
|
||||
try {
|
||||
const normalizedChannelIds =
|
||||
this.normalizeChannelLookupKeys(channelIds);
|
||||
|
||||
if (normalizedChannelIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const sourceUrls = this.normalizeSourceUrls(options.sourceUrls);
|
||||
let candidates = await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
normalizedChannelIds,
|
||||
sourceUrls
|
||||
);
|
||||
if (sourceUrls.length > 0) {
|
||||
const missingChannelIds = normalizedChannelIds.filter(
|
||||
(channelId) =>
|
||||
!this.resolveChannelMetadataCandidate(
|
||||
channelId,
|
||||
candidates
|
||||
)
|
||||
);
|
||||
if (missingChannelIds.length > 0) {
|
||||
candidates = [
|
||||
...candidates,
|
||||
...(await this.selectChannelMetadataCandidates(
|
||||
db,
|
||||
missingChannelIds,
|
||||
sourceUrls,
|
||||
{ legacyOnly: true }
|
||||
)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
normalizedChannelIds.map((channelId) => [
|
||||
channelId,
|
||||
this.resolveChannelMetadataCandidate(channelId, candidates),
|
||||
])
|
||||
);
|
||||
return await this.resolveChannelMetadata(channelIds, options);
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
@@ -401,54 +410,6 @@ export class EpgQueryService {
|
||||
}
|
||||
}
|
||||
|
||||
async getChannelsByRange(
|
||||
skip: number,
|
||||
limit: number
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
iconUrl: string | null;
|
||||
programs: EpgProgram[];
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const channels = await db
|
||||
.select({
|
||||
id: schema.epgChannels.id,
|
||||
displayName: schema.epgChannels.displayName,
|
||||
iconUrl: schema.epgChannels.iconUrl,
|
||||
})
|
||||
.from(schema.epgChannels)
|
||||
.orderBy(schema.epgChannels.displayName)
|
||||
.offset(skip)
|
||||
.limit(limit);
|
||||
|
||||
return Promise.all(
|
||||
channels.map(async (channel) => {
|
||||
const programs = await db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(eq(schema.epgPrograms.channelId, channel.id))
|
||||
.orderBy(schema.epgPrograms.start);
|
||||
|
||||
return {
|
||||
...channel,
|
||||
programs: programs.map(this.transformDbRowToEpgProgram),
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
epgLogger.error(
|
||||
this.loggerLabel,
|
||||
'Error getting channels by range:',
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeChannelLookupKeys(channelIds: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
@@ -471,7 +432,7 @@ export class EpgQueryService {
|
||||
|
||||
private assignCurrentProgramRows(
|
||||
result: Record<string, EpgProgram | null>,
|
||||
rows: EpgProgramRow[]
|
||||
rows: EpgProgramSelectRow[]
|
||||
): Set<string> {
|
||||
const matchedChannelIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
@@ -479,8 +440,8 @@ export class EpgQueryService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const program = this.transformDbRowToEpgProgram(row);
|
||||
if (this.isValidEpgProgram(program)) {
|
||||
const program = toEpgProgramFromRow(row);
|
||||
if (isValidEpgProgram(program)) {
|
||||
result[row.channelId] = program;
|
||||
matchedChannelIds.add(row.channelId);
|
||||
}
|
||||
@@ -509,12 +470,12 @@ export class EpgQueryService {
|
||||
private assignCandidateCurrentProgramRows(
|
||||
result: Record<string, EpgProgram | null>,
|
||||
candidateIdsByRequestedId: Map<string, string>,
|
||||
rows: EpgProgramRow[]
|
||||
rows: EpgProgramSelectRow[]
|
||||
): Set<string> {
|
||||
const matchedCandidateIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const program = this.transformDbRowToEpgProgram(row);
|
||||
if (!this.isValidEpgProgram(program)) {
|
||||
const program = toEpgProgramFromRow(row);
|
||||
if (!isValidEpgProgram(program)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -537,32 +498,34 @@ export class EpgQueryService {
|
||||
db: EpgDatabase,
|
||||
channelId: string,
|
||||
sourceUrls: string[]
|
||||
): Promise<EpgProgramRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
this.withProgramSourceScope(
|
||||
eq(schema.epgPrograms.channelId, channelId),
|
||||
sourceUrls
|
||||
): Promise<EpgProgramSelectRow[]> {
|
||||
return (
|
||||
db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
this.withProgramSourceScope(
|
||||
eq(schema.epgPrograms.channelId, channelId),
|
||||
sourceUrls
|
||||
)
|
||||
)
|
||||
)
|
||||
// Collapse identical slots from multiple sources in SQL so the
|
||||
// 500-row cap counts distinct programmes, not duplicate rows.
|
||||
.groupBy(
|
||||
schema.epgPrograms.channelId,
|
||||
schema.epgPrograms.start,
|
||||
schema.epgPrograms.title
|
||||
)
|
||||
.orderBy(schema.epgPrograms.start)
|
||||
.limit(500);
|
||||
// Collapse identical slots from multiple sources in SQL so the
|
||||
// 500-row cap counts distinct programmes, not duplicate rows.
|
||||
.groupBy(
|
||||
schema.epgPrograms.channelId,
|
||||
schema.epgPrograms.start,
|
||||
schema.epgPrograms.title
|
||||
)
|
||||
.orderBy(schema.epgPrograms.start)
|
||||
.limit(500)
|
||||
);
|
||||
}
|
||||
|
||||
private async selectLegacyChannelPrograms(
|
||||
db: EpgDatabase,
|
||||
channelId: string,
|
||||
sourceUrls: string[]
|
||||
): Promise<EpgProgramRow[]> {
|
||||
): Promise<EpgProgramSelectRow[]> {
|
||||
if (sourceUrls.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -592,7 +555,7 @@ export class EpgQueryService {
|
||||
now: string,
|
||||
sourceUrls: string[],
|
||||
options: { legacyOnly?: boolean } = {}
|
||||
): Promise<EpgProgramRow[]> {
|
||||
): Promise<EpgProgramSelectRow[]> {
|
||||
if (channelIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -600,23 +563,25 @@ export class EpgQueryService {
|
||||
return [];
|
||||
}
|
||||
|
||||
return db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
this.withProgramSourceScope(
|
||||
and(
|
||||
inArray(schema.epgPrograms.channelId, channelIds),
|
||||
this.isAiringAt(now)
|
||||
) as SQL,
|
||||
sourceUrls,
|
||||
options
|
||||
return (
|
||||
db
|
||||
.select()
|
||||
.from(schema.epgPrograms)
|
||||
.where(
|
||||
this.withProgramSourceScope(
|
||||
and(
|
||||
inArray(schema.epgPrograms.channelId, channelIds),
|
||||
this.isAiringAt(now)
|
||||
) as SQL,
|
||||
sourceUrls,
|
||||
options
|
||||
)
|
||||
)
|
||||
)
|
||||
// One current row per channel so duplicate cross-source slots
|
||||
// don't consume the per-channel cap and starve other channels.
|
||||
.groupBy(schema.epgPrograms.channelId)
|
||||
.limit(channelIds.length);
|
||||
// One current row per channel so duplicate cross-source slots
|
||||
// don't consume the per-channel cap and starve other channels.
|
||||
.groupBy(schema.epgPrograms.channelId)
|
||||
.limit(channelIds.length)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -701,9 +666,7 @@ export class EpgQueryService {
|
||||
return db
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
this.withChannelSourceScope(condition, sourceUrls)
|
||||
)
|
||||
.where(this.withChannelSourceScope(condition, sourceUrls))
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
@@ -725,11 +688,9 @@ export class EpgQueryService {
|
||||
.select()
|
||||
.from(schema.epgChannels)
|
||||
.where(
|
||||
this.withChannelSourceScope(
|
||||
condition,
|
||||
sourceUrls,
|
||||
{ legacyOnly: true }
|
||||
)
|
||||
this.withChannelSourceScope(condition, sourceUrls, {
|
||||
legacyOnly: true,
|
||||
})
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
@@ -874,7 +835,9 @@ export class EpgQueryService {
|
||||
}
|
||||
|
||||
private channelHasProgramsForSourceScope(sourceUrls: string[]): SQL {
|
||||
const sourceUrlValues = sourceUrls.map((sourceUrl) => sql`${sourceUrl}`);
|
||||
const sourceUrlValues = sourceUrls.map(
|
||||
(sourceUrl) => sql`${sourceUrl}`
|
||||
);
|
||||
|
||||
return sql`EXISTS (
|
||||
SELECT 1
|
||||
@@ -927,12 +890,12 @@ export class EpgQueryService {
|
||||
* `sourceUrls`) can return the same channel/start/title from two EPG
|
||||
* sources; keep the first so a programme is never shown twice.
|
||||
*/
|
||||
private toEpgPrograms(rows: EpgProgramRow[]): EpgProgram[] {
|
||||
private toEpgPrograms(rows: EpgProgramSelectRow[]): EpgProgram[] {
|
||||
const seen = new Set<string>();
|
||||
const programs: EpgProgram[] = [];
|
||||
for (const row of rows) {
|
||||
const program = this.transformDbRowToEpgProgram(row);
|
||||
if (!this.isValidEpgProgram(program)) {
|
||||
const program = toEpgProgramFromRow(row);
|
||||
if (!isValidEpgProgram(program)) {
|
||||
continue;
|
||||
}
|
||||
const key = `${program.channel}|${program.start}|${program.title}`;
|
||||
@@ -945,29 +908,6 @@ export class EpgQueryService {
|
||||
return programs;
|
||||
}
|
||||
|
||||
private transformDbRowToEpgProgram(row: EpgProgramRow): EpgProgram {
|
||||
return {
|
||||
start: row.start,
|
||||
stop: row.stop,
|
||||
channel: row.channelId,
|
||||
title: row.title,
|
||||
desc: row.description,
|
||||
category: row.category,
|
||||
iconUrl: row.iconUrl,
|
||||
rating: row.rating,
|
||||
episodeNum: row.episodeNum,
|
||||
};
|
||||
}
|
||||
|
||||
private isValidEpgProgram(program: EpgProgram): boolean {
|
||||
return Boolean(
|
||||
program.start &&
|
||||
program.stop &&
|
||||
!Number.isNaN(new Date(program.start).getTime()) &&
|
||||
!Number.isNaN(new Date(program.stop).getTime())
|
||||
);
|
||||
}
|
||||
|
||||
private async getMapping(
|
||||
db: EpgDatabase,
|
||||
channelKey: string
|
||||
@@ -979,9 +919,7 @@ export class EpgQueryService {
|
||||
epgChannelId: schema.epgChannelMappings.epgChannelId,
|
||||
})
|
||||
.from(schema.epgChannelMappings)
|
||||
.where(
|
||||
eq(schema.epgChannelMappings.channelKey, channelKey)
|
||||
)
|
||||
.where(eq(schema.epgChannelMappings.channelKey, channelKey))
|
||||
.limit(1);
|
||||
if (rows.length > 0) {
|
||||
return rows[0].epgChannelId;
|
||||
@@ -1022,10 +960,7 @@ export class EpgQueryService {
|
||||
)
|
||||
)
|
||||
.where(eq(schema.content.epgChannelId, channelKey))
|
||||
.orderBy(
|
||||
schema.categories.playlistId,
|
||||
schema.content.xtreamId
|
||||
)
|
||||
.orderBy(schema.categories.playlistId, schema.content.xtreamId)
|
||||
.limit(1);
|
||||
if (mapped.length > 0) {
|
||||
return mapped[0].epgChannelId;
|
||||
|
||||
@@ -2,11 +2,13 @@ import { reconcileEpgSources } from './epg-source-settings.service';
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
ElectronBridgeCurrentProgramsOptions,
|
||||
ElectronBridgeEpgGuideWindow,
|
||||
ElectronBridgeTrustOptions,
|
||||
EpgChannelMetadata,
|
||||
EpgProgram,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { epgQueryService } from './epg-query.service';
|
||||
import { epgGuideQueryService } from './epg-guide-query.service';
|
||||
import { epgWorkerService } from './epg-worker.service';
|
||||
import { checkEpgFreshness, handleFetchEpg } from './epg-fetch.service';
|
||||
import type { EpgFetchResult, EpgFreshnessResult } from './epg-fetch.service';
|
||||
@@ -17,6 +19,8 @@ import {
|
||||
handleSearchEpgChannels,
|
||||
handleSetEpgMapping,
|
||||
queryByResolvedChannelIds,
|
||||
resolveChannelIds,
|
||||
resolveChannelIdsStrict,
|
||||
} from './epg-mapping.service';
|
||||
|
||||
/**
|
||||
@@ -98,9 +102,16 @@ export default class EpgEvents {
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_GET_CHANNELS_BY_RANGE',
|
||||
async (_event, args: { skip: number; limit: number }) => {
|
||||
return this.handleGetChannelsByRange(args.skip, args.limit);
|
||||
'EPG_GET_PROGRAMS_FOR_CHANNELS',
|
||||
async (_event, args: ElectronBridgeEpgGuideWindow) => {
|
||||
return this.handleGetGuidePrograms(args);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
'EPG_GET_PROGRAM_COVERAGE',
|
||||
async (_event, args: ElectronBridgeEpgGuideWindow) => {
|
||||
return this.handleGetGuideCoverage(args);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -249,18 +260,81 @@ export default class EpgEvents {
|
||||
);
|
||||
}
|
||||
|
||||
private static async handleGetChannelsByRange(
|
||||
skip: number,
|
||||
limit: number
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
iconUrl: string | null;
|
||||
programs: EpgProgram[];
|
||||
}>
|
||||
> {
|
||||
return epgQueryService.getChannelsByRange(skip, limit);
|
||||
/**
|
||||
* The guide query service keys its answer by the TRIMMED, deduplicated,
|
||||
* cap-respecting form of the channel ids it was given
|
||||
* (`normalizeGuideWindow`). Handlers must resolve the same trimmed key
|
||||
* to look up that answer, or a padded request id (`" CNN "`) would never
|
||||
* find its entry even though the service queried it successfully.
|
||||
*/
|
||||
private static resolvedGuideKey(
|
||||
mapping: Map<string, string>,
|
||||
id: unknown
|
||||
): string {
|
||||
return typeof id === 'string' ? (mapping.get(id) ?? id).trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Guide reads take playlist channel keys. Manual mappings are applied
|
||||
* here, before the query, and the answer is keyed back by the requested
|
||||
* key so the renderer never sees a mapped id. A key the service did not
|
||||
* answer for (cut by its per-request cap) stays absent from the result
|
||||
* rather than being filled with `[]`, so callers can tell "queried,
|
||||
* nothing found" apart from "not queried at all".
|
||||
*/
|
||||
private static async handleGetGuidePrograms(
|
||||
args: ElectronBridgeEpgGuideWindow
|
||||
): Promise<Record<string, EpgProgram[]>> {
|
||||
const requested = Array.isArray(args?.channelIds)
|
||||
? args.channelIds
|
||||
: [];
|
||||
const mapping = await resolveChannelIds(requested);
|
||||
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
|
||||
const programs = await epgGuideQueryService.getProgramsForChannels({
|
||||
...args,
|
||||
channelIds: resolvedIds,
|
||||
});
|
||||
const answer: Record<string, EpgProgram[]> = Object.create(null);
|
||||
for (const id of requested) {
|
||||
const key = this.resolvedGuideKey(mapping, id);
|
||||
if (Object.prototype.hasOwnProperty.call(programs, key)) {
|
||||
// Copy so two requested ids resolving to one target never
|
||||
// share an array reference.
|
||||
answer[String(id)] = [...programs[key]];
|
||||
}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** A thrown error rejects the renderer's `invoke`, which is the intended fail-open path for coverage. */
|
||||
private static async handleGetGuideCoverage(
|
||||
args: ElectronBridgeEpgGuideWindow
|
||||
): Promise<string[]> {
|
||||
const requested = Array.isArray(args?.channelIds)
|
||||
? args.channelIds
|
||||
: [];
|
||||
// Strict on purpose: a mapping lookup failure must reject (coverage
|
||||
// unknown), not report mapped channels as unmapped and uncovered.
|
||||
const mapping = await resolveChannelIdsStrict(requested);
|
||||
const resolvedIds = requested.map((id) => mapping.get(id) ?? id);
|
||||
const covered = new Set(
|
||||
await epgGuideQueryService.getProgramCoverage({
|
||||
...args,
|
||||
channelIds: resolvedIds,
|
||||
})
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
const answer: string[] = [];
|
||||
for (const id of requested) {
|
||||
if (seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
if (covered.has(this.resolvedGuideKey(mapping, id))) {
|
||||
answer.push(id);
|
||||
}
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
static async clearEpgData(): Promise<void> {
|
||||
|
||||
@@ -240,7 +240,8 @@ export function createElectronStub(): typeof window.electron {
|
||||
.fn()
|
||||
.mockResolvedValue(DEFAULT_APP_UPDATE_STATUS),
|
||||
getChannelPrograms: jest.fn().mockResolvedValue([]),
|
||||
getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
|
||||
getEpgProgramsForChannels: jest.fn().mockResolvedValue({}),
|
||||
getEpgProgramCoverage: jest.fn().mockResolvedValue([]),
|
||||
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
|
||||
checkForAppUpdate: jest
|
||||
.fn()
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "فتح عرض Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "دليل البرامج"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "اليوم التالي",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
|
||||
"COLLAPSE_PANEL": "طي لوحة EPG",
|
||||
"EXPAND_PANEL": "توسيع لوحة EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "دليل البرامج",
|
||||
"CLOSE": "إغلاق الدليل",
|
||||
"CHANNEL": "قناة",
|
||||
"TODAY": "اليوم",
|
||||
"NOW": "الآن",
|
||||
"JUMP_NOW": "الانتقال إلى الآن",
|
||||
"SCOPE": "القنوات المعروضة",
|
||||
"ONLY_WITH_EPG": "فقط مع EPG",
|
||||
"ONLY_WITH_EPG_HINT": "إخفاء القنوات التي لا تحتوي على بيانات برامج",
|
||||
"DENSITY_COMFORTABLE": "صفوف مريحة",
|
||||
"DENSITY_COMPACT": "صفوف مضغوطة",
|
||||
"ZOOM": "تكبير",
|
||||
"FILTER_CHANNELS": "تصفية القنوات…",
|
||||
"SEARCH_PROGRAMMES": "بحث في البرامج…",
|
||||
"NO_RESULTS": "لم يتم العثور على برامج",
|
||||
"NO_PROGRAMME_INFO": "لا توجد معلومات عن البرنامج",
|
||||
"NO_PROGRAMME_INFO_LONG": "لا توجد معلومات عن البرنامج لهذه القناة",
|
||||
"PLAYING": "قيد التشغيل",
|
||||
"NO_CHANNELS": "لا توجد قنوات تطابق “{{query}}”",
|
||||
"CLEAR_FILTER": "مسح التصفية",
|
||||
"COLLAPSE_PLAYER": "طي المشغل",
|
||||
"EXPAND_PLAYER": "توسيع المشغل",
|
||||
"KEYS": {
|
||||
"CHANNEL": "قناة",
|
||||
"PROGRAMME": "برنامج",
|
||||
"PLAY": "تشغيل",
|
||||
"DETAILS": "التفاصيل",
|
||||
"NOW": "الآن",
|
||||
"DAY": "اليوم",
|
||||
"CLOSE": "إغلاق"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "فتح دليل البرامج",
|
||||
"TODAY": "اليوم",
|
||||
"NOW": "الآن",
|
||||
"JUMP_NOW": "الانتقال إلى الآن",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "عرض تفاصيل حساب خادم Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "الانتقال إلى {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "مسح جميع عناصر {{type}} المرئية في هذا العرض",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "فتح عرض EPG متعدد القنوات",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "فتح دليل البرامج لقائمة التشغيل هذه",
|
||||
"GO_TO_VOD_LABEL": "الأفلام",
|
||||
"GO_TO_VOD_DESCRIPTION": "الانتقال إلى قسم الأفلام",
|
||||
"GO_TO_LIVE_LABEL": "البث المباشر",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "حل عرض Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "دليل البرامج"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "اليوم التالي",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "تشغيل الأرشيف",
|
||||
"COLLAPSE_PANEL": "طوي لوحة EPG",
|
||||
"EXPAND_PANEL": "وسع لوحة EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "دليل البرامج",
|
||||
"CLOSE": "سد الدليل",
|
||||
"CHANNEL": "قناة",
|
||||
"TODAY": "اليوم",
|
||||
"NOW": "دابا",
|
||||
"JUMP_NOW": "نقز لدابا",
|
||||
"SCOPE": "القنوات اللي بانين",
|
||||
"ONLY_WITH_EPG": "فقط مع EPG",
|
||||
"ONLY_WITH_EPG_HINT": "خبّي القنوات اللي ما عندهاش بيانات البرامج",
|
||||
"DENSITY_COMFORTABLE": "صفوف مريحة",
|
||||
"DENSITY_COMPACT": "صفوف مضغوطة",
|
||||
"ZOOM": "تكبير",
|
||||
"FILTER_CHANNELS": "فلتر القنوات…",
|
||||
"SEARCH_PROGRAMMES": "بحث في البرامج…",
|
||||
"NO_RESULTS": "ما لقيناش برامج",
|
||||
"NO_PROGRAMME_INFO": "ما كايناش معلومات على البرنامج",
|
||||
"NO_PROGRAMME_INFO_LONG": "ما كايناش معلومات على البرنامج ديال هاد القناة",
|
||||
"PLAYING": "كيتشغل",
|
||||
"NO_CHANNELS": "ما كاين حتى قناة كتطابق “{{query}}”",
|
||||
"CLEAR_FILTER": "مسح الفلتر",
|
||||
"COLLAPSE_PLAYER": "صغّر المشغل",
|
||||
"EXPAND_PLAYER": "كبّر المشغل",
|
||||
"KEYS": {
|
||||
"CHANNEL": "قناة",
|
||||
"PROGRAMME": "برنامج",
|
||||
"PLAY": "شغّل",
|
||||
"DETAILS": "التفاصيل",
|
||||
"NOW": "دابا",
|
||||
"DAY": "النهار",
|
||||
"CLOSE": "سد"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "حل دليل البرامج",
|
||||
"TODAY": "اليوم",
|
||||
"NOW": "دابا",
|
||||
"JUMP_NOW": "نقز لدابا",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "وري تفاصيل حساب سيرفر Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "وجه ل{{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "مسح جميع عناصر {{type}} الظاهرة في هاد العرض",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "حل عرض EPG متعدد القنوات",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "حل دليل البرامج ديال قائمة التشغيل هاد",
|
||||
"GO_TO_VOD_LABEL": "الأفلام",
|
||||
"GO_TO_VOD_DESCRIPTION": "اقفز لقسم الأفلام",
|
||||
"GO_TO_LIVE_LABEL": "البث المباشر",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Адкрыць прагляд Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Праграма перадач"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Наступны дзень",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Прайграванне архіва",
|
||||
"COLLAPSE_PANEL": "Згарнуць панэль EPG",
|
||||
"EXPAND_PANEL": "Разгарнуць панэль EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Праграма перадач",
|
||||
"CLOSE": "Закрыць праграму перадач",
|
||||
"CHANNEL": "Канал",
|
||||
"TODAY": "Сёння",
|
||||
"NOW": "Цяпер",
|
||||
"JUMP_NOW": "Перайсці да цяперашняга",
|
||||
"SCOPE": "Паказаныя каналы",
|
||||
"ONLY_WITH_EPG": "Толькі з EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Схаваць каналы без даных праграмы",
|
||||
"DENSITY_COMFORTABLE": "Зручныя радкі",
|
||||
"DENSITY_COMPACT": "Кампактныя радкі",
|
||||
"ZOOM": "Маштаб",
|
||||
"FILTER_CHANNELS": "Фільтраваць каналы…",
|
||||
"SEARCH_PROGRAMMES": "Пошук перадач…",
|
||||
"NO_RESULTS": "Праграмы не знойдзены",
|
||||
"NO_PROGRAMME_INFO": "Няма інфармацыі аб праграме",
|
||||
"NO_PROGRAMME_INFO_LONG": "Няма інфармацыі аб праграме для гэтага канала",
|
||||
"PLAYING": "Прайграецца",
|
||||
"NO_CHANNELS": "Няма каналаў, якія адпавядаюць «{{query}}»",
|
||||
"CLEAR_FILTER": "Ачысціць фільтр",
|
||||
"COLLAPSE_PLAYER": "Згарнуць плэер",
|
||||
"EXPAND_PLAYER": "Разгарнуць плэер",
|
||||
"KEYS": {
|
||||
"CHANNEL": "канал",
|
||||
"PROGRAMME": "праграма",
|
||||
"PLAY": "прайграць",
|
||||
"DETAILS": "падрабязнасці",
|
||||
"NOW": "цяпер",
|
||||
"DAY": "дзень",
|
||||
"CLOSE": "закрыць"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Адкрыць праграму перадач",
|
||||
"TODAY": "Сёння",
|
||||
"NOW": "Цяпер",
|
||||
"JUMP_NOW": "Перайсці да цяперашняга",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Паказаць дэталі ўліковага запісу сервера Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Перайсці да {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Ачысціць усе бачныя элементы тыпу {{type}} у гэтым выглядзе",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Адкрыць выгляд мульты-EPG",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Адкрыць праграму перадач для гэтага плэйліста",
|
||||
"GO_TO_VOD_LABEL": "Фільмы",
|
||||
"GO_TO_VOD_DESCRIPTION": "Перайсці да раздзела фільмаў",
|
||||
"GO_TO_LIVE_LABEL": "Прамы эфір",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Multi-EPG-Ansicht öffnen"
|
||||
"OPEN_EPG_GUIDE": "Programmführer"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Zum nächsten Tag",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Archivwiedergabe",
|
||||
"COLLAPSE_PANEL": "EPG-Panel einklappen",
|
||||
"EXPAND_PANEL": "EPG-Panel ausklappen",
|
||||
"GUIDE": {
|
||||
"TITLE": "Programmführer",
|
||||
"CLOSE": "Programmführer schließen",
|
||||
"CHANNEL": "Sender",
|
||||
"TODAY": "Heute",
|
||||
"NOW": "Jetzt",
|
||||
"JUMP_NOW": "Zu jetzt springen",
|
||||
"SCOPE": "Angezeigte Sender",
|
||||
"ONLY_WITH_EPG": "Nur mit EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Sender ohne Programmdaten ausblenden",
|
||||
"DENSITY_COMFORTABLE": "Komfortable Zeilen",
|
||||
"DENSITY_COMPACT": "Kompakte Zeilen",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Sender filtern…",
|
||||
"SEARCH_PROGRAMMES": "Sendungen durchsuchen…",
|
||||
"NO_RESULTS": "Keine Sendungen gefunden",
|
||||
"NO_PROGRAMME_INFO": "Keine Programminformationen",
|
||||
"NO_PROGRAMME_INFO_LONG": "Keine Programminformationen für diesen Sender",
|
||||
"PLAYING": "Läuft",
|
||||
"NO_CHANNELS": "Keine Sender stimmen mit „{{query}}“ überein",
|
||||
"CLEAR_FILTER": "Filter zurücksetzen",
|
||||
"COLLAPSE_PLAYER": "Player einklappen",
|
||||
"EXPAND_PLAYER": "Player ausklappen",
|
||||
"KEYS": {
|
||||
"CHANNEL": "Kanal",
|
||||
"PROGRAMME": "Programm",
|
||||
"PLAY": "Wiedergabe",
|
||||
"DETAILS": "Details",
|
||||
"NOW": "jetzt",
|
||||
"DAY": "Tag",
|
||||
"CLOSE": "schließen"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Programmführer öffnen",
|
||||
"TODAY": "Heute",
|
||||
"NOW": "Jetzt",
|
||||
"JUMP_NOW": "Zu jetzt springen",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Xtream-Server-Kontodaten anzeigen",
|
||||
"OPEN_VIEW_DESCRIPTION": "Zu {{view}} wechseln",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Alle sichtbaren {{type}}-Einträge in dieser Ansicht löschen",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Die Multi-EPG-Ansicht öffnen",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Programmführer für diese Playlist öffnen",
|
||||
"GO_TO_VOD_LABEL": "Filme",
|
||||
"GO_TO_VOD_DESCRIPTION": "Zum Filmbereich wechseln",
|
||||
"GO_TO_LIVE_LABEL": "Live-TV",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Άνοιγμα προβολής Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Οδηγός προγράμματος"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Επόμενη μέρα",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου",
|
||||
"COLLAPSE_PANEL": "Σύμπτυξη πίνακα EPG",
|
||||
"EXPAND_PANEL": "Ανάπτυξη πίνακα EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Οδηγός προγράμματος",
|
||||
"CLOSE": "Κλείσιμο οδηγού",
|
||||
"CHANNEL": "Κανάλι",
|
||||
"TODAY": "Σήμερα",
|
||||
"NOW": "Τώρα",
|
||||
"JUMP_NOW": "Μετάβαση στο τώρα",
|
||||
"SCOPE": "Εμφανιζόμενα κανάλια",
|
||||
"ONLY_WITH_EPG": "Μόνο με EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Απόκρυψη καναλιών χωρίς δεδομένα προγράμματος",
|
||||
"DENSITY_COMFORTABLE": "Άνετες γραμμές",
|
||||
"DENSITY_COMPACT": "Συμπαγείς γραμμές",
|
||||
"ZOOM": "Ζουμ",
|
||||
"FILTER_CHANNELS": "Φιλτράρισμα καναλιών…",
|
||||
"SEARCH_PROGRAMMES": "Αναζήτηση προγραμμάτων…",
|
||||
"NO_RESULTS": "Δεν βρέθηκαν προγράμματα",
|
||||
"NO_PROGRAMME_INFO": "Δεν υπάρχουν πληροφορίες προγράμματος",
|
||||
"NO_PROGRAMME_INFO_LONG": "Δεν υπάρχουν πληροφορίες προγράμματος για αυτό το κανάλι",
|
||||
"PLAYING": "Παίζει",
|
||||
"NO_CHANNELS": "Κανένα κανάλι δεν ταιριάζει με «{{query}}»",
|
||||
"CLEAR_FILTER": "Απαλοιφή φίλτρου",
|
||||
"COLLAPSE_PLAYER": "Σύμπτυξη player",
|
||||
"EXPAND_PLAYER": "Ανάπτυξη player",
|
||||
"KEYS": {
|
||||
"CHANNEL": "κανάλι",
|
||||
"PROGRAMME": "πρόγραμμα",
|
||||
"PLAY": "αναπαραγωγή",
|
||||
"DETAILS": "λεπτομέρειες",
|
||||
"NOW": "τώρα",
|
||||
"DAY": "ημέρα",
|
||||
"CLOSE": "κλείσιμο"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Άνοιγμα του οδηγού προγράμματος",
|
||||
"TODAY": "Σήμερα",
|
||||
"NOW": "Τώρα",
|
||||
"JUMP_NOW": "Μετάβαση στο τώρα",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Εμφάνιση στοιχείων λογαριασμού διακομιστή Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Πλοήγηση στο {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Εκκαθάριση όλων των ορατών στοιχείων {{type}} σε αυτή την προβολή",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Άνοιγμα της προβολής EPG πολλαπλών καναλιών",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Άνοιγμα του οδηγού προγράμματος για αυτή τη λίστα αναπαραγωγής",
|
||||
"GO_TO_VOD_LABEL": "Ταινίες",
|
||||
"GO_TO_VOD_DESCRIPTION": "Μετάβαση στην ενότητα ταινιών",
|
||||
"GO_TO_LIVE_LABEL": "Ζωντανή TV",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Open Multi-EPG view"
|
||||
"OPEN_EPG_GUIDE": "Programme guide"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Next day",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Archive playback",
|
||||
"COLLAPSE_PANEL": "Collapse EPG panel",
|
||||
"EXPAND_PANEL": "Expand EPG panel",
|
||||
"GUIDE": {
|
||||
"TITLE": "Programme guide",
|
||||
"CLOSE": "Close guide",
|
||||
"CHANNEL": "Channel",
|
||||
"TODAY": "Today",
|
||||
"NOW": "Now",
|
||||
"JUMP_NOW": "Jump to now",
|
||||
"SCOPE": "Channels shown",
|
||||
"ONLY_WITH_EPG": "Only with EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Hide channels without programme data",
|
||||
"DENSITY_COMFORTABLE": "Comfortable rows",
|
||||
"DENSITY_COMPACT": "Compact rows",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Filter channels…",
|
||||
"SEARCH_PROGRAMMES": "Search programmes…",
|
||||
"NO_RESULTS": "No programmes found",
|
||||
"NO_PROGRAMME_INFO": "No programme information",
|
||||
"NO_PROGRAMME_INFO_LONG": "No programme information for this channel",
|
||||
"PLAYING": "Playing",
|
||||
"NO_CHANNELS": "No channels match \"{{query}}\"",
|
||||
"CLEAR_FILTER": "Clear filter",
|
||||
"COLLAPSE_PLAYER": "Collapse player",
|
||||
"EXPAND_PLAYER": "Expand player",
|
||||
"KEYS": {
|
||||
"CHANNEL": "channel",
|
||||
"PROGRAMME": "programme",
|
||||
"PLAY": "play",
|
||||
"DETAILS": "details",
|
||||
"NOW": "now",
|
||||
"DAY": "day",
|
||||
"CLOSE": "close"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Open the programme guide",
|
||||
"TODAY": "Today",
|
||||
"NOW": "Now",
|
||||
"JUMP_NOW": "Jump to now",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Show Xtream server account details",
|
||||
"OPEN_VIEW_DESCRIPTION": "Navigate to {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Clear all visible {{type}} items in this view",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Open the multi-channel EPG view",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Open the programme guide for this playlist",
|
||||
"GO_TO_VOD_LABEL": "Movies",
|
||||
"GO_TO_VOD_DESCRIPTION": "Jump to movies section",
|
||||
"GO_TO_LIVE_LABEL": "Live TV",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Abrir vista Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Guía de programas"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Día siguiente",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Reproducción de archivo",
|
||||
"COLLAPSE_PANEL": "Contraer panel EPG",
|
||||
"EXPAND_PANEL": "Expandir panel EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Guía de programas",
|
||||
"CLOSE": "Cerrar guía",
|
||||
"CHANNEL": "Canal",
|
||||
"TODAY": "Hoy",
|
||||
"NOW": "Ahora",
|
||||
"JUMP_NOW": "Ir a ahora",
|
||||
"SCOPE": "Canales mostrados",
|
||||
"ONLY_WITH_EPG": "Solo con EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Oculta los canales sin datos de programación",
|
||||
"DENSITY_COMFORTABLE": "Filas cómodas",
|
||||
"DENSITY_COMPACT": "Filas compactas",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Filtrar canales…",
|
||||
"SEARCH_PROGRAMMES": "Buscar programas…",
|
||||
"NO_RESULTS": "No se encontraron programas",
|
||||
"NO_PROGRAMME_INFO": "Sin información del programa",
|
||||
"NO_PROGRAMME_INFO_LONG": "Sin información del programa para este canal",
|
||||
"PLAYING": "Reproduciendo",
|
||||
"NO_CHANNELS": "Ningún canal coincide con “{{query}}”",
|
||||
"CLEAR_FILTER": "Borrar filtro",
|
||||
"COLLAPSE_PLAYER": "Contraer reproductor",
|
||||
"EXPAND_PLAYER": "Expandir reproductor",
|
||||
"KEYS": {
|
||||
"CHANNEL": "canal",
|
||||
"PROGRAMME": "programa",
|
||||
"PLAY": "reproducir",
|
||||
"DETAILS": "detalles",
|
||||
"NOW": "ahora",
|
||||
"DAY": "día",
|
||||
"CLOSE": "cerrar"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Abrir la guía de programas",
|
||||
"TODAY": "Hoy",
|
||||
"NOW": "Ahora",
|
||||
"JUMP_NOW": "Ir a ahora",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Mostrar los detalles de la cuenta del servidor Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Ir a {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Borrar todos los elementos {{type}} visibles en esta vista",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Abrir la vista EPG de varios canales",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Abrir la guía de programas de esta lista de reproducción",
|
||||
"GO_TO_VOD_LABEL": "Películas",
|
||||
"GO_TO_VOD_DESCRIPTION": "Ir a la sección de películas",
|
||||
"GO_TO_LIVE_LABEL": "TV en vivo",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Ouvrir la vue Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Guide des programmes"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Jour suivant",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Lecture archive",
|
||||
"COLLAPSE_PANEL": "Réduire le panneau EPG",
|
||||
"EXPAND_PANEL": "Développer le panneau EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Guide des programmes",
|
||||
"CLOSE": "Fermer le guide",
|
||||
"CHANNEL": "Chaîne",
|
||||
"TODAY": "Aujourd'hui",
|
||||
"NOW": "Maintenant",
|
||||
"JUMP_NOW": "Aller à maintenant",
|
||||
"SCOPE": "Chaînes affichées",
|
||||
"ONLY_WITH_EPG": "Uniquement avec EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Masquer les chaînes sans données de programme",
|
||||
"DENSITY_COMFORTABLE": "Lignes confortables",
|
||||
"DENSITY_COMPACT": "Lignes compactes",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Filtrer les chaînes…",
|
||||
"SEARCH_PROGRAMMES": "Rechercher des programmes…",
|
||||
"NO_RESULTS": "Aucun programme trouvé",
|
||||
"NO_PROGRAMME_INFO": "Aucune information sur le programme",
|
||||
"NO_PROGRAMME_INFO_LONG": "Aucune information sur le programme pour cette chaîne",
|
||||
"PLAYING": "En lecture",
|
||||
"NO_CHANNELS": "Aucune chaîne ne correspond à « {{query}} »",
|
||||
"CLEAR_FILTER": "Effacer le filtre",
|
||||
"COLLAPSE_PLAYER": "Réduire le lecteur",
|
||||
"EXPAND_PLAYER": "Agrandir le lecteur",
|
||||
"KEYS": {
|
||||
"CHANNEL": "chaîne",
|
||||
"PROGRAMME": "programme",
|
||||
"PLAY": "lecture",
|
||||
"DETAILS": "détails",
|
||||
"NOW": "maintenant",
|
||||
"DAY": "jour",
|
||||
"CLOSE": "fermer"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Ouvrir le guide des programmes",
|
||||
"TODAY": "Aujourd'hui",
|
||||
"NOW": "Maintenant",
|
||||
"JUMP_NOW": "Aller à maintenant",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Afficher les détails du compte du serveur Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Naviguer vers {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Effacer tous les éléments {{type}} visibles dans cette vue",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Ouvrir la vue EPG multi-chaînes",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Ouvrir le guide des programmes pour cette liste de lecture",
|
||||
"GO_TO_VOD_LABEL": "Films",
|
||||
"GO_TO_VOD_DESCRIPTION": "Aller à la section films",
|
||||
"GO_TO_LIVE_LABEL": "TV en direct",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Többcsatornás EPG-nézet megnyitása"
|
||||
"OPEN_EPG_GUIDE": "Műsorújság"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Következő nap",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Műsor visszanézése",
|
||||
"COLLAPSE_PANEL": "EPG-panel összecsukása",
|
||||
"EXPAND_PANEL": "EPG-panel kibontása",
|
||||
"GUIDE": {
|
||||
"TITLE": "Műsorújság",
|
||||
"CLOSE": "Műsorújság bezárása",
|
||||
"CHANNEL": "Csatorna",
|
||||
"TODAY": "Ma",
|
||||
"NOW": "Most",
|
||||
"JUMP_NOW": "Ugrás a jelenhez",
|
||||
"SCOPE": "Megjelenített csatornák",
|
||||
"ONLY_WITH_EPG": "Csak EPG-vel",
|
||||
"ONLY_WITH_EPG_HINT": "A műsoradatok nélküli csatornák elrejtése",
|
||||
"DENSITY_COMFORTABLE": "Kényelmes sorok",
|
||||
"DENSITY_COMPACT": "Kompakt sorok",
|
||||
"ZOOM": "Nagyítás",
|
||||
"FILTER_CHANNELS": "Csatornák szűrése…",
|
||||
"SEARCH_PROGRAMMES": "Műsorok keresése…",
|
||||
"NO_RESULTS": "Nem található műsor",
|
||||
"NO_PROGRAMME_INFO": "Nincs műsorinformáció",
|
||||
"NO_PROGRAMME_INFO_LONG": "Nincs műsorinformáció ehhez a csatornához",
|
||||
"PLAYING": "Lejátszás alatt",
|
||||
"NO_CHANNELS": "Nincs „{{query}}”-nak megfelelő csatorna",
|
||||
"CLEAR_FILTER": "Szűrő törlése",
|
||||
"COLLAPSE_PLAYER": "Lejátszó összecsukása",
|
||||
"EXPAND_PLAYER": "Lejátszó kibontása",
|
||||
"KEYS": {
|
||||
"CHANNEL": "csatorna",
|
||||
"PROGRAMME": "műsor",
|
||||
"PLAY": "lejátszás",
|
||||
"DETAILS": "részletek",
|
||||
"NOW": "most",
|
||||
"DAY": "nap",
|
||||
"CLOSE": "bezárás"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Műsorújság megnyitása",
|
||||
"TODAY": "Ma",
|
||||
"NOW": "Most",
|
||||
"JUMP_NOW": "Ugrás a jelenhez",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Az Xtream-kiszolgálóhoz tartozó fiókadatok megjelenítése",
|
||||
"OPEN_VIEW_DESCRIPTION": "Nézet megnyitása: {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "A nézetben látható elemek törlése – {{type}}",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Többcsatornás EPG-nézet megnyitása",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Műsorújság megnyitása ehhez a lejátszási listához",
|
||||
"GO_TO_VOD_LABEL": "Filmek",
|
||||
"GO_TO_VOD_DESCRIPTION": "Ugrás a filmekhez",
|
||||
"GO_TO_LIVE_LABEL": "Élő tévéadás",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Apri vista Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Guida programmi"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Giorno successivo",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Riproduzione archivio",
|
||||
"COLLAPSE_PANEL": "Comprimi pannello EPG",
|
||||
"EXPAND_PANEL": "Espandi pannello EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Guida programmi",
|
||||
"CLOSE": "Chiudi guida",
|
||||
"CHANNEL": "Canale",
|
||||
"TODAY": "Oggi",
|
||||
"NOW": "Ora",
|
||||
"JUMP_NOW": "Vai a ora",
|
||||
"SCOPE": "Canali mostrati",
|
||||
"ONLY_WITH_EPG": "Solo con EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Nascondi i canali senza dati di programmazione",
|
||||
"DENSITY_COMFORTABLE": "Righe comode",
|
||||
"DENSITY_COMPACT": "Righe compatte",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Filtra canali…",
|
||||
"SEARCH_PROGRAMMES": "Cerca programmi…",
|
||||
"NO_RESULTS": "Nessun programma trovato",
|
||||
"NO_PROGRAMME_INFO": "Nessuna informazione sul programma",
|
||||
"NO_PROGRAMME_INFO_LONG": "Nessuna informazione sul programma per questo canale",
|
||||
"PLAYING": "In riproduzione",
|
||||
"NO_CHANNELS": "Nessun canale corrisponde a “{{query}}”",
|
||||
"CLEAR_FILTER": "Cancella filtro",
|
||||
"COLLAPSE_PLAYER": "Comprimi player",
|
||||
"EXPAND_PLAYER": "Espandi player",
|
||||
"KEYS": {
|
||||
"CHANNEL": "canale",
|
||||
"PROGRAMME": "programma",
|
||||
"PLAY": "riproduzione",
|
||||
"DETAILS": "dettagli",
|
||||
"NOW": "ora",
|
||||
"DAY": "giorno",
|
||||
"CLOSE": "chiudi"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Apri la guida programmi",
|
||||
"TODAY": "Oggi",
|
||||
"NOW": "Ora",
|
||||
"JUMP_NOW": "Vai a ora",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Mostra i dettagli account del server Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Vai a {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Cancella tutti gli elementi {{type}} visibili in questa vista",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Apri la vista EPG multi-canale",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Apri la guida programmi per questa playlist",
|
||||
"GO_TO_VOD_LABEL": "Film",
|
||||
"GO_TO_VOD_DESCRIPTION": "Vai alla sezione film",
|
||||
"GO_TO_LIVE_LABEL": "TV in diretta",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "マルチ EPG ビューを開く"
|
||||
"OPEN_EPG_GUIDE": "番組表"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "翌日",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "アーカイブ再生",
|
||||
"COLLAPSE_PANEL": "EPG パネルを折りたたむ",
|
||||
"EXPAND_PANEL": "EPG パネルを展開",
|
||||
"GUIDE": {
|
||||
"TITLE": "番組表",
|
||||
"CLOSE": "番組表を閉じる",
|
||||
"CHANNEL": "チャンネル",
|
||||
"TODAY": "今日",
|
||||
"NOW": "現在",
|
||||
"JUMP_NOW": "現在へ移動",
|
||||
"SCOPE": "表示するチャンネル",
|
||||
"ONLY_WITH_EPG": "EPGのみ",
|
||||
"ONLY_WITH_EPG_HINT": "番組データがないチャンネルを非表示にする",
|
||||
"DENSITY_COMFORTABLE": "ゆったり表示",
|
||||
"DENSITY_COMPACT": "コンパクト表示",
|
||||
"ZOOM": "ズーム",
|
||||
"FILTER_CHANNELS": "チャンネルをフィルター…",
|
||||
"SEARCH_PROGRAMMES": "番組を検索…",
|
||||
"NO_RESULTS": "番組が見つかりません",
|
||||
"NO_PROGRAMME_INFO": "番組情報がありません",
|
||||
"NO_PROGRAMME_INFO_LONG": "このチャンネルの番組情報がありません",
|
||||
"PLAYING": "再生中",
|
||||
"NO_CHANNELS": "「{{query}}」に一致するチャンネルはありません",
|
||||
"CLEAR_FILTER": "フィルターをクリア",
|
||||
"COLLAPSE_PLAYER": "プレーヤーを折りたたむ",
|
||||
"EXPAND_PLAYER": "プレーヤーを展開",
|
||||
"KEYS": {
|
||||
"CHANNEL": "チャンネル",
|
||||
"PROGRAMME": "番組",
|
||||
"PLAY": "再生",
|
||||
"DETAILS": "詳細",
|
||||
"NOW": "現在",
|
||||
"DAY": "日",
|
||||
"CLOSE": "閉じる"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "番組表を開く",
|
||||
"TODAY": "今日",
|
||||
"NOW": "現在",
|
||||
"JUMP_NOW": "現在へ移動",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Xtreamサーバーのアカウント詳細を表示",
|
||||
"OPEN_VIEW_DESCRIPTION": "{{view}}に移動",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "このビューに表示中の{{type}}項目をすべてクリア",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "マルチチャンネルEPGビューを開く",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "このプレイリストの番組表を開く",
|
||||
"GO_TO_VOD_LABEL": "映画",
|
||||
"GO_TO_VOD_DESCRIPTION": "映画セクションに移動",
|
||||
"GO_TO_LIVE_LABEL": "ライブTV",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "멀티 EPG 보기 열기"
|
||||
"OPEN_EPG_GUIDE": "프로그램 가이드"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "다음날",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "아카이브 재생",
|
||||
"COLLAPSE_PANEL": "EPG 패널 접기",
|
||||
"EXPAND_PANEL": "EPG 패널 펼치기",
|
||||
"GUIDE": {
|
||||
"TITLE": "프로그램 가이드",
|
||||
"CLOSE": "가이드 닫기",
|
||||
"CHANNEL": "채널",
|
||||
"TODAY": "오늘",
|
||||
"NOW": "지금",
|
||||
"JUMP_NOW": "현재 시각으로 이동",
|
||||
"SCOPE": "표시할 채널",
|
||||
"ONLY_WITH_EPG": "EPG 채널만",
|
||||
"ONLY_WITH_EPG_HINT": "프로그램 정보가 없는 채널 숨기기",
|
||||
"DENSITY_COMFORTABLE": "여유로운 행",
|
||||
"DENSITY_COMPACT": "조밀한 행",
|
||||
"ZOOM": "확대/축소",
|
||||
"FILTER_CHANNELS": "채널 필터…",
|
||||
"SEARCH_PROGRAMMES": "프로그램 검색…",
|
||||
"NO_RESULTS": "프로그램을 찾을 수 없습니다",
|
||||
"NO_PROGRAMME_INFO": "프로그램 정보 없음",
|
||||
"NO_PROGRAMME_INFO_LONG": "이 채널에 대한 프로그램 정보가 없습니다",
|
||||
"PLAYING": "재생 중",
|
||||
"NO_CHANNELS": "“{{query}}”와 일치하는 채널이 없습니다",
|
||||
"CLEAR_FILTER": "필터 지우기",
|
||||
"COLLAPSE_PLAYER": "플레이어 접기",
|
||||
"EXPAND_PLAYER": "플레이어 펼치기",
|
||||
"KEYS": {
|
||||
"CHANNEL": "채널",
|
||||
"PROGRAMME": "프로그램",
|
||||
"PLAY": "재생",
|
||||
"DETAILS": "상세정보",
|
||||
"NOW": "지금",
|
||||
"DAY": "날짜",
|
||||
"CLOSE": "닫기"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "프로그램 가이드 열기",
|
||||
"TODAY": "오늘",
|
||||
"NOW": "지금",
|
||||
"JUMP_NOW": "현재 시각으로 이동",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Xtream 서버 계정 세부정보 표시",
|
||||
"OPEN_VIEW_DESCRIPTION": "{{view}}(으)로 이동",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "이 화면에서 보이는 모든 {{type}} 항목 지우기",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "다중 채널 EPG 화면 열기",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "이 재생목록의 프로그램 가이드 열기",
|
||||
"GO_TO_VOD_LABEL": "영화",
|
||||
"GO_TO_VOD_DESCRIPTION": "영화 섹션으로 이동",
|
||||
"GO_TO_LIVE_LABEL": "라이브 TV",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Multi-EPG-weergave openen"
|
||||
"OPEN_EPG_GUIDE": "Programmagids"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Volgende dag",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Archief afspelen",
|
||||
"COLLAPSE_PANEL": "EPG-paneel inklappen",
|
||||
"EXPAND_PANEL": "EPG-paneel uitklappen",
|
||||
"GUIDE": {
|
||||
"TITLE": "Programmagids",
|
||||
"CLOSE": "Programmagids sluiten",
|
||||
"CHANNEL": "Kanaal",
|
||||
"TODAY": "Vandaag",
|
||||
"NOW": "Nu",
|
||||
"JUMP_NOW": "Ga naar nu",
|
||||
"SCOPE": "Getoonde kanalen",
|
||||
"ONLY_WITH_EPG": "Alleen met EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Verberg kanalen zonder programmagegevens",
|
||||
"DENSITY_COMFORTABLE": "Comfortabele rijen",
|
||||
"DENSITY_COMPACT": "Compacte rijen",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Kanalen filteren…",
|
||||
"SEARCH_PROGRAMMES": "Programma's zoeken…",
|
||||
"NO_RESULTS": "Geen programma's gevonden",
|
||||
"NO_PROGRAMME_INFO": "Geen programma-informatie",
|
||||
"NO_PROGRAMME_INFO_LONG": "Geen programma-informatie voor dit kanaal",
|
||||
"PLAYING": "Speelt nu",
|
||||
"NO_CHANNELS": "Geen kanalen komen overeen met “{{query}}”",
|
||||
"CLEAR_FILTER": "Filter wissen",
|
||||
"COLLAPSE_PLAYER": "Speler inklappen",
|
||||
"EXPAND_PLAYER": "Speler uitklappen",
|
||||
"KEYS": {
|
||||
"CHANNEL": "kanaal",
|
||||
"PROGRAMME": "programma",
|
||||
"PLAY": "afspelen",
|
||||
"DETAILS": "details",
|
||||
"NOW": "nu",
|
||||
"DAY": "dag",
|
||||
"CLOSE": "sluiten"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Programmagids openen",
|
||||
"TODAY": "Vandaag",
|
||||
"NOW": "Nu",
|
||||
"JUMP_NOW": "Ga naar nu",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Toon Xtream-serveraccountdetails",
|
||||
"OPEN_VIEW_DESCRIPTION": "Navigeer naar {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Wis alle zichtbare {{type}}-items in deze weergave",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Open de multi-kanaal EPG-weergave",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Programmagids voor deze afspeellijst openen",
|
||||
"GO_TO_VOD_LABEL": "Films",
|
||||
"GO_TO_VOD_DESCRIPTION": "Spring naar de filmsectie",
|
||||
"GO_TO_LIVE_LABEL": "Live tv",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Otwórz widok Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Przewodnik po programach"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Następny dzień",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Odtwarzanie archiwum",
|
||||
"COLLAPSE_PANEL": "Zwiń panel EPG",
|
||||
"EXPAND_PANEL": "Rozwiń panel EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Przewodnik po programach",
|
||||
"CLOSE": "Zamknij przewodnik",
|
||||
"CHANNEL": "Kanał",
|
||||
"TODAY": "Dzisiaj",
|
||||
"NOW": "Teraz",
|
||||
"JUMP_NOW": "Przejdź do teraz",
|
||||
"SCOPE": "Wyświetlane kanały",
|
||||
"ONLY_WITH_EPG": "Tylko z EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Ukryj kanały bez danych programu",
|
||||
"DENSITY_COMFORTABLE": "Wygodne wiersze",
|
||||
"DENSITY_COMPACT": "Kompaktowe wiersze",
|
||||
"ZOOM": "Powiększenie",
|
||||
"FILTER_CHANNELS": "Filtruj kanały…",
|
||||
"SEARCH_PROGRAMMES": "Szukaj programów…",
|
||||
"NO_RESULTS": "Nie znaleziono programów",
|
||||
"NO_PROGRAMME_INFO": "Brak informacji o programie",
|
||||
"NO_PROGRAMME_INFO_LONG": "Brak informacji o programie dla tego kanału",
|
||||
"PLAYING": "Odtwarzane",
|
||||
"NO_CHANNELS": "Brak kanałów pasujących do „{{query}}”",
|
||||
"CLEAR_FILTER": "Wyczyść filtr",
|
||||
"COLLAPSE_PLAYER": "Zwiń odtwarzacz",
|
||||
"EXPAND_PLAYER": "Rozwiń odtwarzacz",
|
||||
"KEYS": {
|
||||
"CHANNEL": "kanał",
|
||||
"PROGRAMME": "program",
|
||||
"PLAY": "odtwórz",
|
||||
"DETAILS": "szczegóły",
|
||||
"NOW": "teraz",
|
||||
"DAY": "dzień",
|
||||
"CLOSE": "zamknij"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Otwórz przewodnik po programach",
|
||||
"TODAY": "Dzisiaj",
|
||||
"NOW": "Teraz",
|
||||
"JUMP_NOW": "Przejdź do teraz",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Pokaż szczegóły konta serwera Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Przejdź do {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Wyczyść wszystkie widoczne elementy {{type}} w tym widoku",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Otwórz widok Multi-EPG dla wielu kanałów",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Otwórz przewodnik po programach dla tej listy odtwarzania",
|
||||
"GO_TO_VOD_LABEL": "Filmy",
|
||||
"GO_TO_VOD_DESCRIPTION": "Przejdź do sekcji filmów",
|
||||
"GO_TO_LIVE_LABEL": "Telewizja na żywo",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Abrir visualização Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Guia de programação"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Próximo dia",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Reprodução do arquivo",
|
||||
"COLLAPSE_PANEL": "Recolher painel EPG",
|
||||
"EXPAND_PANEL": "Expandir painel EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Guia de programação",
|
||||
"CLOSE": "Fechar guia",
|
||||
"CHANNEL": "Canal",
|
||||
"TODAY": "Hoje",
|
||||
"NOW": "Agora",
|
||||
"JUMP_NOW": "Ir para agora",
|
||||
"SCOPE": "Canais exibidos",
|
||||
"ONLY_WITH_EPG": "Somente com EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Ocultar canais sem dados de programação",
|
||||
"DENSITY_COMFORTABLE": "Linhas confortáveis",
|
||||
"DENSITY_COMPACT": "Linhas compactas",
|
||||
"ZOOM": "Zoom",
|
||||
"FILTER_CHANNELS": "Filtrar canais…",
|
||||
"SEARCH_PROGRAMMES": "Pesquisar programas…",
|
||||
"NO_RESULTS": "Nenhum programa encontrado",
|
||||
"NO_PROGRAMME_INFO": "Sem informações do programa",
|
||||
"NO_PROGRAMME_INFO_LONG": "Sem informações do programa para este canal",
|
||||
"PLAYING": "Reproduzindo",
|
||||
"NO_CHANNELS": "Nenhum canal corresponde a “{{query}}”",
|
||||
"CLEAR_FILTER": "Limpar filtro",
|
||||
"COLLAPSE_PLAYER": "Recolher player",
|
||||
"EXPAND_PLAYER": "Expandir player",
|
||||
"KEYS": {
|
||||
"CHANNEL": "canal",
|
||||
"PROGRAMME": "programa",
|
||||
"PLAY": "reproduzir",
|
||||
"DETAILS": "detalhes",
|
||||
"NOW": "agora",
|
||||
"DAY": "dia",
|
||||
"CLOSE": "fechar"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Abrir o guia de programação",
|
||||
"TODAY": "Hoje",
|
||||
"NOW": "Agora",
|
||||
"JUMP_NOW": "Ir para agora",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Mostrar detalhes da conta do servidor Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Navegar até {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Limpar todos os itens de {{type}} visíveis nesta visualização",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Abrir a visualização Multi-EPG",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Abrir o guia de programação desta playlist",
|
||||
"GO_TO_VOD_LABEL": "Filmes",
|
||||
"GO_TO_VOD_DESCRIPTION": "Ir para a seção de filmes",
|
||||
"GO_TO_LIVE_LABEL": "TV ao vivo",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Открыть вид Multi-EPG"
|
||||
"OPEN_EPG_GUIDE": "Программа передач"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Следующий день",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Архивная запись",
|
||||
"COLLAPSE_PANEL": "Свернуть панель EPG",
|
||||
"EXPAND_PANEL": "Развернуть панель EPG",
|
||||
"GUIDE": {
|
||||
"TITLE": "Программа передач",
|
||||
"CLOSE": "Закрыть программу передач",
|
||||
"CHANNEL": "Канал",
|
||||
"TODAY": "Сегодня",
|
||||
"NOW": "Сейчас",
|
||||
"JUMP_NOW": "Перейти к текущему",
|
||||
"SCOPE": "Отображаемые каналы",
|
||||
"ONLY_WITH_EPG": "Только с EPG",
|
||||
"ONLY_WITH_EPG_HINT": "Скрыть каналы без данных о программе",
|
||||
"DENSITY_COMFORTABLE": "Комфортные строки",
|
||||
"DENSITY_COMPACT": "Компактные строки",
|
||||
"ZOOM": "Масштаб",
|
||||
"FILTER_CHANNELS": "Фильтровать каналы…",
|
||||
"SEARCH_PROGRAMMES": "Поиск передач…",
|
||||
"NO_RESULTS": "Программы не найдены",
|
||||
"NO_PROGRAMME_INFO": "Нет информации о программе",
|
||||
"NO_PROGRAMME_INFO_LONG": "Нет информации о программе для этого канала",
|
||||
"PLAYING": "Играет",
|
||||
"NO_CHANNELS": "Нет каналов, соответствующих «{{query}}»",
|
||||
"CLEAR_FILTER": "Очистить фильтр",
|
||||
"COLLAPSE_PLAYER": "Свернуть плеер",
|
||||
"EXPAND_PLAYER": "Развернуть плеер",
|
||||
"KEYS": {
|
||||
"CHANNEL": "канал",
|
||||
"PROGRAMME": "программа",
|
||||
"PLAY": "воспроизвести",
|
||||
"DETAILS": "подробности",
|
||||
"NOW": "сейчас",
|
||||
"DAY": "день",
|
||||
"CLOSE": "закрыть"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Открыть программу передач",
|
||||
"TODAY": "Сегодня",
|
||||
"NOW": "Сейчас",
|
||||
"JUMP_NOW": "Перейти к текущему",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Показать данные аккаунта Xtream",
|
||||
"OPEN_VIEW_DESCRIPTION": "Перейти к разделу {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Очистить все видимые элементы «{{type}}» в этом представлении",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Открыть представление Multi-EPG",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Открыть программу передач для этого плейлиста",
|
||||
"GO_TO_VOD_LABEL": "Фильмы",
|
||||
"GO_TO_VOD_DESCRIPTION": "Перейти к разделу фильмов",
|
||||
"GO_TO_LIVE_LABEL": "Прямой эфир",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "Multi-EPG görünümünü aç"
|
||||
"OPEN_EPG_GUIDE": "Program rehberi"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "Sonraki gün",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "Arşiv oynatma",
|
||||
"COLLAPSE_PANEL": "EPG panelini daralt",
|
||||
"EXPAND_PANEL": "EPG panelini genişlet",
|
||||
"GUIDE": {
|
||||
"TITLE": "Program rehberi",
|
||||
"CLOSE": "Rehberi kapat",
|
||||
"CHANNEL": "Kanal",
|
||||
"TODAY": "Bugün",
|
||||
"NOW": "Şimdi",
|
||||
"JUMP_NOW": "Şimdiye atla",
|
||||
"SCOPE": "Gösterilen kanallar",
|
||||
"ONLY_WITH_EPG": "Yalnızca EPG'li",
|
||||
"ONLY_WITH_EPG_HINT": "Program verisi olmayan kanalları gizle",
|
||||
"DENSITY_COMFORTABLE": "Rahat satırlar",
|
||||
"DENSITY_COMPACT": "Sıkışık satırlar",
|
||||
"ZOOM": "Yakınlaştırma",
|
||||
"FILTER_CHANNELS": "Kanalları filtrele…",
|
||||
"SEARCH_PROGRAMMES": "Programları ara…",
|
||||
"NO_RESULTS": "Program bulunamadı",
|
||||
"NO_PROGRAMME_INFO": "Program bilgisi yok",
|
||||
"NO_PROGRAMME_INFO_LONG": "Bu kanal için program bilgisi yok",
|
||||
"PLAYING": "Oynatılıyor",
|
||||
"NO_CHANNELS": "“{{query}}” ile eşleşen kanal yok",
|
||||
"CLEAR_FILTER": "Filtreyi temizle",
|
||||
"COLLAPSE_PLAYER": "Oynatıcıyı daralt",
|
||||
"EXPAND_PLAYER": "Oynatıcıyı genişlet",
|
||||
"KEYS": {
|
||||
"CHANNEL": "kanal",
|
||||
"PROGRAMME": "program",
|
||||
"PLAY": "oynat",
|
||||
"DETAILS": "ayrıntılar",
|
||||
"NOW": "şimdi",
|
||||
"DAY": "gün",
|
||||
"CLOSE": "kapat"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "Program rehberini aç",
|
||||
"TODAY": "Bugün",
|
||||
"NOW": "Şimdi",
|
||||
"JUMP_NOW": "Şimdiye atla",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "Xtream sunucu hesabı ayrıntılarını göster",
|
||||
"OPEN_VIEW_DESCRIPTION": "{{view}} görünümüne git",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "Bu görünümdeki görünür tüm {{type}} öğelerini temizle",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "Çoklu kanal EPG görünümünü aç",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "Bu oynatma listesi için program rehberini aç",
|
||||
"GO_TO_VOD_LABEL": "Filmler",
|
||||
"GO_TO_VOD_DESCRIPTION": "Filmler bölümüne atla",
|
||||
"GO_TO_LIVE_LABEL": "Canlı TV",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "打开多频道 EPG 视图"
|
||||
"OPEN_EPG_GUIDE": "节目指南"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "明天",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "回看播放",
|
||||
"COLLAPSE_PANEL": "收起 EPG 面板",
|
||||
"EXPAND_PANEL": "展开 EPG 面板",
|
||||
"GUIDE": {
|
||||
"TITLE": "节目指南",
|
||||
"CLOSE": "关闭指南",
|
||||
"CHANNEL": "频道",
|
||||
"TODAY": "今天",
|
||||
"NOW": "现在",
|
||||
"JUMP_NOW": "跳转到现在",
|
||||
"SCOPE": "显示的频道",
|
||||
"ONLY_WITH_EPG": "仅含 EPG",
|
||||
"ONLY_WITH_EPG_HINT": "隐藏没有节目数据的频道",
|
||||
"DENSITY_COMFORTABLE": "舒适行距",
|
||||
"DENSITY_COMPACT": "紧凑行距",
|
||||
"ZOOM": "缩放",
|
||||
"FILTER_CHANNELS": "筛选频道…",
|
||||
"SEARCH_PROGRAMMES": "搜索节目…",
|
||||
"NO_RESULTS": "未找到节目",
|
||||
"NO_PROGRAMME_INFO": "没有节目信息",
|
||||
"NO_PROGRAMME_INFO_LONG": "此频道没有节目信息",
|
||||
"PLAYING": "正在播放",
|
||||
"NO_CHANNELS": "没有频道匹配“{{query}}”",
|
||||
"CLEAR_FILTER": "清除筛选",
|
||||
"COLLAPSE_PLAYER": "收起播放器",
|
||||
"EXPAND_PLAYER": "展开播放器",
|
||||
"KEYS": {
|
||||
"CHANNEL": "频道",
|
||||
"PROGRAMME": "节目",
|
||||
"PLAY": "播放",
|
||||
"DETAILS": "详情",
|
||||
"NOW": "现在",
|
||||
"DAY": "日期",
|
||||
"CLOSE": "关闭"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "打开节目指南",
|
||||
"TODAY": "今天",
|
||||
"NOW": "现在",
|
||||
"JUMP_NOW": "跳转到现在",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "显示 Xtream 服务器的账户详情",
|
||||
"OPEN_VIEW_DESCRIPTION": "跳转到 {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "清除此视图中所有可见的{{type}}项目",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "打开多频道 EPG 视图",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "打开此播放列表的节目指南",
|
||||
"GO_TO_VOD_LABEL": "电影",
|
||||
"GO_TO_VOD_DESCRIPTION": "跳转到电影区",
|
||||
"GO_TO_LIVE_LABEL": "直播电视",
|
||||
|
||||
@@ -764,7 +764,7 @@
|
||||
}
|
||||
},
|
||||
"TOP_MENU": {
|
||||
"OPEN_MULTI_EPG": "開啟多 EPG 視圖"
|
||||
"OPEN_EPG_GUIDE": "節目表"
|
||||
},
|
||||
"EPG": {
|
||||
"NEXT_DAY": "下一天",
|
||||
@@ -814,7 +814,41 @@
|
||||
"ARCHIVE_PLAYBACK": "回看播放",
|
||||
"COLLAPSE_PANEL": "收合 EPG 面板",
|
||||
"EXPAND_PANEL": "展開 EPG 面板",
|
||||
"GUIDE": {
|
||||
"TITLE": "節目表",
|
||||
"CLOSE": "關閉節目表",
|
||||
"CHANNEL": "頻道",
|
||||
"TODAY": "今天",
|
||||
"NOW": "現在",
|
||||
"JUMP_NOW": "跳至現在",
|
||||
"SCOPE": "顯示的頻道",
|
||||
"ONLY_WITH_EPG": "僅含 EPG",
|
||||
"ONLY_WITH_EPG_HINT": "隱藏沒有節目資料的頻道",
|
||||
"DENSITY_COMFORTABLE": "舒適列距",
|
||||
"DENSITY_COMPACT": "緊湊列距",
|
||||
"ZOOM": "縮放",
|
||||
"FILTER_CHANNELS": "篩選頻道…",
|
||||
"SEARCH_PROGRAMMES": "搜尋節目…",
|
||||
"NO_RESULTS": "找不到節目",
|
||||
"NO_PROGRAMME_INFO": "沒有節目資訊",
|
||||
"NO_PROGRAMME_INFO_LONG": "此頻道沒有節目資訊",
|
||||
"PLAYING": "播放中",
|
||||
"NO_CHANNELS": "沒有頻道符合「{{query}}」",
|
||||
"CLEAR_FILTER": "清除篩選",
|
||||
"COLLAPSE_PLAYER": "收合播放器",
|
||||
"EXPAND_PLAYER": "展開播放器",
|
||||
"KEYS": {
|
||||
"CHANNEL": "頻道",
|
||||
"PROGRAMME": "節目",
|
||||
"PLAY": "播放",
|
||||
"DETAILS": "詳情",
|
||||
"NOW": "現在",
|
||||
"DAY": "日期",
|
||||
"CLOSE": "關閉"
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"OPEN_GUIDE": "開啟節目表",
|
||||
"TODAY": "今天",
|
||||
"NOW": "現在",
|
||||
"JUMP_NOW": "跳至現在",
|
||||
@@ -1537,7 +1571,7 @@
|
||||
"ACCOUNT_INFO_DESCRIPTION": "顯示 Xtream 伺服器帳戶詳細資訊",
|
||||
"OPEN_VIEW_DESCRIPTION": "前往 {{view}}",
|
||||
"CLEAR_CURRENT_VIEW_DESCRIPTION": "清除此畫面中所有可見的{{type}}項目",
|
||||
"OPEN_MULTI_EPG_DESCRIPTION": "開啟多頻道 EPG 視圖",
|
||||
"OPEN_EPG_GUIDE_DESCRIPTION": "開啟此播放清單的節目表",
|
||||
"GO_TO_VOD_LABEL": "電影",
|
||||
"GO_TO_VOD_DESCRIPTION": "跳至電影區段",
|
||||
"GO_TO_LIVE_LABEL": "直播電視",
|
||||
|
||||
@@ -98,15 +98,11 @@ app-root.macos-platform .current-playlist {
|
||||
|
||||
// Windows/Linux frameless title bar: reserve room in top-aligned drag
|
||||
// regions for the renderer-drawn window controls (3 × 46px buttons).
|
||||
// Body-level class so it also reaches cdk-overlay content (multi-EPG).
|
||||
// Body-level class so it also reaches cdk-overlay content (dialogs, menus).
|
||||
body.frameless-platform .workspace-header {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
body.frameless-platform #epg-navigation {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
// ─── Base ─────────────────────────────────────────────────────────────────────
|
||||
// Native UI (scrollbars, form controls, popups) must follow the app theme,
|
||||
// not the OS preference. Chromium colors unstyled/native scrollbars from the
|
||||
|
||||
@@ -703,7 +703,7 @@ comparison; applying both doubles the shift.
|
||||
header's range and progress) receives the offset as the `offsetMinutes`
|
||||
input from each host; `app-channel-list-item`, the dashboard time range and
|
||||
the recording detail shift their labels the same way. The programme dialog
|
||||
and the multi-EPG overlay are opened imperatively, so they read
|
||||
and the programme guide are opened imperatively, so they read
|
||||
`SettingsStore.resolvedEpgOffsetMinutes` themselves.
|
||||
- **Clock form** — every "currently airing" decision, so the row picked as
|
||||
"now" is the one the UI renders as "now": the batched
|
||||
@@ -764,7 +764,10 @@ matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure
|
||||
`app-epg-list-view-row`; and delegates its own vertical auto-focus + sticky
|
||||
"now" strip to `EpgListScrollController` (`epg-list-scroll.controller.ts`). Render
|
||||
states, the collapsed inline summary, the date stepper, catch-up/timeshift
|
||||
activation, and the details dialog behave identically to the timeline.
|
||||
activation, and the details dialog behave identically to the timeline. Both
|
||||
views also carry the optional `guideAvailable` input and `openGuide` output:
|
||||
the M3U host binds them in both branches, so the programme guide's Guide
|
||||
action is reachable whichever view the setting selects.
|
||||
|
||||
- **One channel, preloaded window.** The panel always shows a single channel.
|
||||
Each provider returns a multi-day window in roughly one call (M3U
|
||||
@@ -1053,12 +1056,12 @@ class EpgService {
|
||||
|
||||
### EPG Components
|
||||
|
||||
| Component | Purpose |
|
||||
| ----------------------------- | ------------------------------------ |
|
||||
| `EpgTimelineComponent` | Horizontal timeline for one channel |
|
||||
| `EpgListViewComponent` | Vertical single-day list alternative |
|
||||
| `EpgItemDescriptionComponent` | Program details dialog |
|
||||
| `MultiEpgContainerComponent` | Grid view of all channels' schedules |
|
||||
| Component | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `EpgTimelineComponent` | Horizontal timeline for one channel |
|
||||
| `EpgListViewComponent` | Vertical single-day list alternative |
|
||||
| `EpgItemDescriptionComponent` | Program details dialog |
|
||||
| `EpgGuideComponent` | Multi-channel programme guide grid fed by `EPG_GUIDE_SOURCE` (rows: `EpgGuideRowComponent`, toolbar: `EpgGuideToolbarComponent`, docked strip: `EpgGuideNowPlayingComponent`) |
|
||||
|
||||
## Video Player
|
||||
|
||||
@@ -1078,7 +1081,7 @@ class EpgService {
|
||||
- EPG sidebar
|
||||
- Collapsible inline EPG panel for internal players, persisted through the
|
||||
shared `live-epg-panel-state` preference
|
||||
- Multi-EPG modal view
|
||||
- Programme guide (multi-channel grid) in guide mode — see "Programme guide" below
|
||||
- Channel info overlay
|
||||
- External player support (MPV, VLC) in Electron
|
||||
- M3U archive/catch-up playback for supported replay schemes
|
||||
@@ -1120,6 +1123,104 @@ class EpgService {
|
||||
for a programme the user clicked, both hosts surface a
|
||||
`EPG.TIMELINE.CATCHUP_FAILED` snackbar instead of doing nothing.
|
||||
|
||||
### Programme guide
|
||||
|
||||
`app-epg-guide` (`libs/ui/epg/src/lib/epg-guide/`) is a host-agnostic
|
||||
multi-channel grid. It reads everything through the `EPG_GUIDE_SOURCE`
|
||||
injection token (`epg-guide-source.ts`): the scope-resolved channel list
|
||||
(`EpgGuideChannel { id, number, name, logoUrl, epgKey }`), the available scopes
|
||||
(all / group / favorites), `loadPrograms(window)` and `loadCoverage(window)`
|
||||
for a provider-clock time window, the active channel and `activate(id)`. An
|
||||
optional `searchPrograms(query)` returns `EpgGuideSearchHit { channelId,
|
||||
program }`, where `channelId` is `null` when the host cannot say which row a
|
||||
hit belongs to. The M3U adapter is `M3uEpgGuideSourceService`
|
||||
(`libs/playlist/m3u/feature-player/src/lib/epg-guide/`): channels come from
|
||||
NgRx, `epgKey` uses the `tvg.id → tvg.name → name` chain, and the two bridge
|
||||
reads `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` resolve keys
|
||||
in the main process (manual mappings first, then the metadata lookup shared
|
||||
with the sidebar) and return programmes keyed by the requested key. Queries
|
||||
are unscoped, like the timeline.
|
||||
|
||||
Row ids are not channel ids: `createChannel` falls back to the stream URL for
|
||||
an entry without an explicit id, so one stream listed in two groups yields two
|
||||
channels sharing an id. The adapter therefore builds SCOPE-LOCAL row ids by
|
||||
prefixing every row with its position in the scope (`<index>:<channel id>`) —
|
||||
the guide keys its programme, coverage and selection maps by row id, so
|
||||
without that both copies lit up as playing and activating either one played
|
||||
the first, and suffixing only the repeats was not enough because a real
|
||||
channel id can itself look like a generated suffix (ids `x`, `x`, `x#1`
|
||||
produced `x#1` twice). Ids move with the scope and the channel list, so only
|
||||
ids the guide was just handed may be passed back. The active channel is
|
||||
resolved to a row in three steps, because the store spreads the selected
|
||||
channel (identity is gone) and copies can share an id: the row whose channel
|
||||
matches the playing one field for field (every field except the
|
||||
reducer-rewritten `epgParams`, key order ignored — `isSameChannelEntry`),
|
||||
else the first same-id row with the same stream url, else the first same-id
|
||||
row; `null` when the channel is outside the scope. `activate(rowId)`
|
||||
resolves the row back to its own channel object. Opening the
|
||||
guide mirrors the sidebar view (`applyInitialScope`): favorites stays
|
||||
favorites, and the groups view opens on the group the sidebar's rail is
|
||||
SHOWING — forwarded from `GroupsViewComponent.selectedGroupChange` through the
|
||||
channel list container and `app-sidebar` into
|
||||
`VideoPlayerComponent.selectedSidebarGroup` — because the user may have
|
||||
browsed away from the playing channel's group before opening the guide. The
|
||||
playing channel's group is the fallback, then `all`.
|
||||
|
||||
Guide mode is host layout, not an overlay: `VideoPlayerComponent.guideOpen`
|
||||
hides the sidebar and the timeline, renders the guide, and CSS reflows the
|
||||
untouched `app-web-player-view` into a 128 px docked strip
|
||||
(`.content-container.is-guide`) beside `app-epg-guide-now-playing`; the strip
|
||||
collapses to one 48 px line (`epg-guide:dock-collapsed`), which is also the
|
||||
fixed height of the strip shown for an external MPV/VLC session — that one has
|
||||
no video to reveal, so it hides the Collapse toggle (`collapsible=false`) and
|
||||
writes no preference. Nothing remounts, so playback and native-view Embedded
|
||||
MPV bounds survive. While the guide is open the docked `.video-player` carries
|
||||
`data-player-shortcuts-suspended`, which makes `ControlsShortcuts` (shared and
|
||||
legacy player shortcuts alike) yield ↑/↓, Space, F and M to the guide's own
|
||||
keyboard controller. Entry points: the workspace header action
|
||||
(`m3u-epg-guide`), the command palette, the Guide button in the EPG panel's
|
||||
toolbar (`openGuide` on both `EpgTimelineComponent` and `EpgListViewComponent`,
|
||||
so the action survives the list-view setting) and the `G` key on the player
|
||||
page.
|
||||
The header action reports `disabled` whenever the guide cannot open, which
|
||||
greys out the header button and disables its palette command instead of
|
||||
offering a no-op. Player fullscreen, radio, recognised movies, switching to
|
||||
another playlist and the PWA close or withhold it. `openGuide()` itself also
|
||||
refuses while ANY element is fullscreen or a CDK dialog is open — the guide
|
||||
mounts in the page flow, so it would be painted over and still swallow the
|
||||
keyboard — and the `G` key additionally ignores a press raised inside a
|
||||
`.cdk-overlay-pane`, `[role="menu"]` or `[role="dialog"]`, the same surfaces
|
||||
the PageUp/PageDown zapping yields to. Inside the guide: single click on a row or an
|
||||
"on now" card switches the channel and keeps the guide open, double-click or
|
||||
Enter switches and closes, other cards open the programme dialog; ↑/↓ ←/→
|
||||
navigate, I details, N now, PgUp/PgDn day, Esc close — the keyboard controller
|
||||
ignores keys on any element matching its interactive-target selector
|
||||
(inputs, buttons, menu/option items, links, editable content) unless that
|
||||
element carries `data-epg-guide-grid`, which the guide's own channel cells and
|
||||
programme cards set so their `role="button"` does not shadow the grid's own
|
||||
keys; a real control nested inside one (the catch-up button) still wins.
|
||||
Density (`epg-guide:density`, comfortable 60 px / compact 44 px), zoom
|
||||
(`epg-guide:zoom`, 120–480 px per hour, default 240) and the "Only with EPG"
|
||||
toggle (`epg-guide:only-with-epg`) persist in localStorage; coverage is loaded
|
||||
for the whole scope so the toggle never hides rows as they scroll in. The
|
||||
guide reads the EPG display offset itself: the day axis is display time, the
|
||||
request window is converted with `epgProviderClockMs`.
|
||||
|
||||
**Backend contract**: `EpgGuideQueryService`
|
||||
(`apps/electron-backend/src/app/events/epg-guide-query.service.ts`) answers
|
||||
both IPCs from one `guideWindowCondition()` predicate — when the request
|
||||
carries `sourceUrls` (portal hosts only; the M3U host never does), a row
|
||||
qualifies if it belongs to one of those sources OR carries no source at all
|
||||
(legacy pre-per-source-tracking data), never if it belongs to a _different_
|
||||
source. Both reads cap the requested channel-key batch
|
||||
(`EPG_GUIDE_MAX_CHANNELS_PER_REQUEST` = 100 for programmes,
|
||||
`EPG_GUIDE_MAX_COVERAGE_KEYS_PER_REQUEST` = 2000 for the cheaper coverage
|
||||
probe) and respond with exactly the trimmed, de-duplicated, cap-respecting
|
||||
requested keys: a key cut by the cap is absent from the answer, never present
|
||||
with an empty list, so a caller can tell "queried, nothing found" apart from
|
||||
"not queried at all". An invalid window (bad instants, `fromMs >= toMs`, no
|
||||
usable keys) returns `{}`/`[]` rather than throwing.
|
||||
|
||||
### External Player Request Headers
|
||||
|
||||
The built-in web players inherit the playlist-level custom
|
||||
|
||||
@@ -113,8 +113,8 @@ The self-hosted build is the browser PWA, not the Electron desktop app. Keep
|
||||
these limitations explicit in UI, troubleshooting, and release notes:
|
||||
|
||||
- EPG/XMLTV is not supported in the PWA yet. Do not render live EPG panels,
|
||||
multi-EPG shortcuts, or EPG-fetching flows in browser/PWA mode, and do not use
|
||||
EPG as the readiness signal for Docker.
|
||||
programme-guide entry points, or EPG-fetching flows in browser/PWA mode, and
|
||||
do not use EPG as the readiness signal for Docker.
|
||||
- The PWA does not use the Electron SQLite database or DB worker. Playlist
|
||||
metadata uses `PlaylistsService` with IndexedDB; Xtream favorites, recently
|
||||
viewed items, playback positions, and cached collection snapshots use
|
||||
|
||||
@@ -277,8 +277,7 @@ The Electron window hides the native title bar on all desktop platforms
|
||||
|
||||
The controls are mounted once in `app-root` (not inside the workspace
|
||||
header) as a `position: fixed` top-right overlay so they stay clickable
|
||||
above full-window content such as the multi-EPG cdk overlay and Material
|
||||
dialog backdrops — the same behavior as the macOS traffic lights. Because
|
||||
above full-window content such as Material dialog backdrops — the same behavior as the macOS traffic lights. Because
|
||||
CDK overlays render as popovers in the browser top layer (above any
|
||||
z-index), the component host is itself a `popover="manual"` element: it
|
||||
enters the top layer on init and re-enters it (hide + show) whenever
|
||||
@@ -413,8 +412,8 @@ Layout integration:
|
||||
`AppComponent`, same mechanism as `dark-theme`) — body-level so rules
|
||||
also reach cdk-overlay content rendered outside `app-root`.
|
||||
2. `apps/web/src/styles.scss` reserves `padding-right: 150px` in
|
||||
top-aligned drag regions (`.workspace-header`, multi-EPG
|
||||
`#epg-navigation`) for the 3 × 46px button strip.
|
||||
the top-aligned drag region (`.workspace-header`) for the 3 × 46px
|
||||
button strip.
|
||||
3. Button colors follow the theme via CSS variables (`--app-on-surface`,
|
||||
`--app-hover-overlay`); the close button uses the Windows-style red
|
||||
hover (`#e81123`). No theme IPC is involved.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,337 @@
|
||||
# Programme Guide (Multi-EPG) Redesign — M3U Host
|
||||
|
||||
Sub-project 1 of the guide rework tracked by issue #171. It rebuilds the
|
||||
multi-channel EPG grid as a host-agnostic component, fills it with the
|
||||
playlist's own channels, makes channel rows switch playback, and restyles it in
|
||||
the visual language of the EPG timeline under the player. Sub-project 2 (Xtream
|
||||
and Stalker hosts) is out of scope here but shapes the data contract; see
|
||||
"Deferred to sub-project 2".
|
||||
|
||||
Decisions were taken against a visual companion:
|
||||
https://claude.ai/code/artifact/0cac36f6-9027-46a7-bd0c-b264416e6acf
|
||||
(Q1 layout → B "player docked on top", Q2 grid styling → as drawn, Q3 density
|
||||
→ comfortable by default with a toggle).
|
||||
|
||||
## Goal
|
||||
|
||||
- The guide shows the channels of the current playlist scope (all channels, a
|
||||
group, or favorites) in playlist order, never the whole XMLTV channel table.
|
||||
- Clicking a channel row switches live playback without closing the guide;
|
||||
double-click or Enter switches and closes. The player stays visible while
|
||||
the guide is open.
|
||||
- The grid reuses the timeline's tokens, typography, now-line, card tiers and
|
||||
behaviour so both guides read as one product.
|
||||
- Rows for channels without EPG data stay in place (numbers match the sidebar)
|
||||
and can be hidden with an "Only with EPG" toggle.
|
||||
- The old SVG overlay, its IPC and its capability are removed, not kept beside
|
||||
the new implementation.
|
||||
|
||||
## Current Problem
|
||||
|
||||
`MultiEpgContainerComponent` (`libs/ui/epg/src/lib/multi-epg/`) is a
|
||||
full-window CDK overlay opened from `VideoPlayerComponent.openMultiEpgView()`.
|
||||
It receives `playlistChannels` but only uses the emission as a reload trigger:
|
||||
its data comes from `EPG_GET_CHANNELS_BY_RANGE`, which pages through the
|
||||
entire `epg_channels` table alphabetically and issues one programme query per
|
||||
channel. A large XMLTV therefore fills the grid with hundreds of channels the
|
||||
playlist does not carry. Channel rows are not clickable, only programmes are,
|
||||
and the SVG + `foreignObject` layout cannot virtualise rows. The overlay
|
||||
covers the player, so a native-view Embedded MPV surface, which paints above
|
||||
the DOM, would hide the grid, and channel switching from an overlay is blind
|
||||
anyway.
|
||||
|
||||
## Considered Approaches
|
||||
|
||||
### 1. Host-owned layout mode + injected data source (selected)
|
||||
|
||||
The guide becomes `app-epg-guide` in `libs/ui/epg`, ignorant of playlists and
|
||||
portals. A host provides `EPG_GUIDE_SOURCE`. The M3U host keeps a `guideOpen`
|
||||
signal; while it is on, the template hides the sidebar (it stays mounted,
|
||||
`inert`, because destroying the channel list resets the active channel) and
|
||||
drops the timeline,
|
||||
renders the guide, and CSS reflows the player container into a docked strip
|
||||
on top. The player component keeps its DOM position, so nothing remounts and
|
||||
native-view Embedded MPV syncs bounds like on any resize. This is the same
|
||||
pattern as the fullscreen channel panel: the host owns layout, the shared
|
||||
component owns content.
|
||||
|
||||
### 2. Keep the CDK overlay and cut a "hole" for the player
|
||||
|
||||
Two independent layers would have to agree pixel-for-pixel on resize and
|
||||
zoom, the overlay lives in another stacking context (window controls,
|
||||
dialogs), and the scope switcher in the guide toolbar would have to reach
|
||||
sidebar state through the overlay. Rejected as fragile.
|
||||
|
||||
### 3. Child route `/workspace/playlists/:id/guide`
|
||||
|
||||
The M3U child routes only drive the sidebar view; the guide needs the whole
|
||||
width, so the parent template would still switch layout on route state. That
|
||||
is approach 1 plus routing, and the only gain is a URL nobody asked for.
|
||||
|
||||
## Data Contract: `EPG_GUIDE_SOURCE`
|
||||
|
||||
Defined in `libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts` (types and the
|
||||
injection token only, no Angular services).
|
||||
|
||||
```ts
|
||||
interface EpgGuideChannel {
|
||||
id: string; // host-stable id (M3U: channel.id)
|
||||
number: number; // 1-based position within the current scope
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
epgKey: string | null; // programme lookup key; null = host knows there is no binding
|
||||
}
|
||||
|
||||
interface EpgGuideScope {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'all' | 'group' | 'favorites';
|
||||
}
|
||||
|
||||
interface EpgGuideWindow {
|
||||
channels: readonly EpgGuideChannel[];
|
||||
fromMs: number; // provider-clock instants (display offset already removed)
|
||||
toMs: number;
|
||||
}
|
||||
|
||||
interface EpgGuideSource {
|
||||
readonly channels: Signal<EpgGuideChannel[]>; // scope-resolved, playlist order, radio/movies excluded by host
|
||||
readonly scopes: Signal<EpgGuideScope[]>;
|
||||
readonly scopeId: Signal<string>;
|
||||
setScope(id: string): void;
|
||||
loadPrograms(window: EpgGuideWindow): Promise<Map<string, EpgProgram[]>>; // keyed by channel.id
|
||||
loadCoverage(window: EpgGuideWindow): Promise<Set<string>>; // channel.ids with ≥1 programme
|
||||
readonly activeChannelId: Signal<string | null>;
|
||||
activate(channelId: string): void; // switch playback; the guide stays open
|
||||
searchPrograms?(query: string): Promise<EpgGuideSearchHit[]>; // toolbar search hidden when absent
|
||||
// EpgGuideSearchHit = { channelId: string | null; program: EpgProgram } — the host
|
||||
// supplies the row id when it can resolve one, so a hit can be highlighted later
|
||||
catchUp?: {
|
||||
canWatch(channel: EpgGuideChannel, program: EpgProgram): boolean;
|
||||
watch(channel: EpgGuideChannel, program: EpgProgram): void;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Programmes are the shared `EpgProgram` (XMLTV shape). Portal adapters convert
|
||||
`EpgItem` with the existing `toEpgProgram` from
|
||||
`unified-live-epg-summary.util`.
|
||||
- A channel with `epgKey === null` renders "No programme information" without
|
||||
a request.
|
||||
- `loadCoverage` exists so the "Only with EPG" toggle has a complete answer
|
||||
before rows scroll into view; hiding rows as they load would look like data
|
||||
loss. For M3U it is one SQL `EXISTS`; Stalker's bulk cache answers for free.
|
||||
- Time windows are provider-clock instants: the guide converts the selected
|
||||
day with `epgProviderClockMs` before requesting and renders with
|
||||
`epgDisplayTimeMs`, exactly one offset form per comparison (contract in
|
||||
`epg-display-offset.util.ts`). The guide reads
|
||||
`SettingsStore.resolvedEpgOffsetMinutes` itself, as the programme dialog
|
||||
does, because it has several openers.
|
||||
- The contract does not carry the player, "now playing", or layout. Those are
|
||||
host concerns.
|
||||
|
||||
## Guide Component
|
||||
|
||||
Location: `libs/ui/epg/src/lib/epg-guide/`. Every production file stays under
|
||||
300 lines.
|
||||
|
||||
- `epg-guide.component` — shell. Injects the source, owns the selected day,
|
||||
zoom, density, channel filter, "Only with EPG" state. Outputs `close` and
|
||||
`channelActivated`. The time axis is 00:00–24:00 of the selected day; the
|
||||
now-line and its time badge tick once a minute; "Now" returns to today and
|
||||
scrolls the current time into view. Single click on a row or on an
|
||||
"ON NOW" card calls `activate`; double-click or Enter calls `activate` then
|
||||
emits `close`; click on a past or future card opens the existing programme
|
||||
dialog via `EpgProgrammeDialogService`.
|
||||
- `epg-guide-toolbar.component` — ‹ date ›, Now, scope select, "Only with
|
||||
EPG" toggle, density toggle (comfortable 60 px / compact 44 px), zoom range
|
||||
(120–480 px per hour), channel filter, programme search (only when the
|
||||
source implements `searchPrograms`). Density, zoom and the toggle persist in
|
||||
`localStorage` under `epg-guide:density`, `epg-guide:zoom`,
|
||||
`epg-guide:only-with-epg`; the scope is not persisted.
|
||||
- `epg-guide-row.component` (OnPush) — channel cell (number, logo, name;
|
||||
second line: current programme title, "No programme information", or
|
||||
"▶ Playing" plus an equaliser icon for the active row) and the lane with
|
||||
cards. Card tiers reuse the timeline's rules: wide = mono time + title,
|
||||
narrow (< ~70 px) = title only, micro (< ~20 px) = bar with tooltip. Past
|
||||
cards have no fill and a thin border; "ON NOW" cards carry a progress wash
|
||||
and tag; the active channel's row uses the selection surface and a left
|
||||
accent bar.
|
||||
- `epg-guide-layout.util` — day window, ticks, now-x, card left/width/tier
|
||||
computed through `epg-timeline-render.util` so grid and timeline agree.
|
||||
- `epg-guide-programs.service` (component-provided) — cache keyed by
|
||||
(channel id, day), batched loads for visible rows plus a 10-row buffer,
|
||||
per-channel status `idle | loading | loaded`, coverage per day, reset on
|
||||
scope or source change. Batches are capped at 100 channels per IPC.
|
||||
- `epg-guide-keyboard.controller` — ↑/↓ channel, ←/→ programme, Enter play,
|
||||
I details, N now, PgUp/PgDn day, Esc close. Ignores events whose target
|
||||
matches its interactive-element selector (inputs, buttons, menu/option
|
||||
items, links, editable content) and while a dialog is open. The guide's own
|
||||
channel cells and programme cards carry `role="button"` for assistive tech,
|
||||
which would otherwise match that selector too — they opt back in with
|
||||
`data-epg-guide-grid` so the controller still owns their keys; a real
|
||||
control nested inside one (the catch-up button) is the closest match and
|
||||
still wins over the grid attribute.
|
||||
- `epg-guide-now-playing.component` — the right part of the docked player
|
||||
strip: channel, programme title, description, progress, Close (Esc) and
|
||||
Collapse. Rendered by the host beside the player, because the strip itself
|
||||
is host layout.
|
||||
|
||||
Rendering: CDK virtual scroll over fixed-height rows inside one viewport that
|
||||
scrolls both ways; the channel column is `position: sticky; left: 0`, the
|
||||
ruler `sticky; top: 0`. Styles come from `_epg-theme.scss`, the timeline's
|
||||
token set; card radius is 8 px (timeline uses 11 px on 96 px cards).
|
||||
|
||||
## M3U Host
|
||||
|
||||
- `VideoPlayerComponent` gains `guideOpen` and `guideDockCollapsed` signals
|
||||
(the latter persisted under `epg-guide:dock-collapsed`). While
|
||||
`guideOpen()` is true the sidebar is hidden (`sidebar--guide-hidden` +
|
||||
`inert`, still mounted) and the `.epg` block is not rendered;
|
||||
`<app-epg-guide>` takes their place; `.content-container` gets `is-guide`
|
||||
and CSS turns `.video-player` into a 128 px strip (16:9 video on the left,
|
||||
`app-epg-guide-now-playing` beside it; 48 px single line when collapsed).
|
||||
`app-web-player-view` keeps its DOM position, so HTML5/hls.js, Video.js,
|
||||
ArtPlayer and both Embedded MPV engines keep playing; native-view syncs
|
||||
bounds as on any resize.
|
||||
- The guide is unavailable for radio and recognised movies (no
|
||||
`app-web-player-view` host there) and in the PWA (`supportsEpg` is false).
|
||||
Entering player fullscreen closes the guide; the guide and the fullscreen
|
||||
channel panel are mutually exclusive.
|
||||
- `M3uEpgGuideSourceService` in
|
||||
`libs/playlist/m3u/feature-player/src/lib/epg-guide/`, provided by the host
|
||||
component as `EPG_GUIDE_SOURCE`. Channels come from NgRx (`selectChannels`,
|
||||
groups, favorites); scopes are "All channels", each group, "Favorites"; the
|
||||
initial scope follows `activeView()` and the sidebar's selected group.
|
||||
`epgKey` uses the `stream-resolver` chain `tvg.id → tvg.name → name`; radio
|
||||
and movie entries are excluded. `loadPrograms`/`loadCoverage` call the new
|
||||
bridge methods unscoped (every imported EPG source), exactly like the
|
||||
timeline's `getChannelPrograms`; `sourceUrls` stays in the IPC contract for
|
||||
portal hosts. `activate` dispatches `ChannelActions.setActiveChannel`;
|
||||
`activeChannelId` mirrors `activeChannel()`. `searchPrograms` uses
|
||||
`EPG_SEARCH_PROGRAMS` unfiltered (result click opens the programme dialog,
|
||||
as the previous overlay did). `catchUp` is not wired in this sub-project.
|
||||
- Entry points, all toggling the same signal:
|
||||
1. Workspace header action (`m3u-epg-guide`, icon `grid_view`, label
|
||||
"Programme guide"), highlighted while open.
|
||||
2. Command palette entry (same action).
|
||||
3. A "Guide" button in the timeline toolbar next to Now and zoom: new
|
||||
`openGuide` output on `app-epg-timeline`, rendered only when the host is
|
||||
subscribed.
|
||||
4. Key `G` on the M3U player page, handled by the host's existing document
|
||||
listener (the PageUp/PageDown zapping one) with the same exclusions:
|
||||
typing, dialogs, player fullscreen. Not added to workspace-level
|
||||
shortcuts, so portals without a guide never swallow G.
|
||||
- Closing: Esc, the strip's Close button, any entry point again,
|
||||
double-click on a channel, leaving the route.
|
||||
|
||||
## Backend
|
||||
|
||||
Two new IPC handlers registered in `epg.events.ts`, implemented in a new
|
||||
`epg-guide-query.service.ts` (`epg-query.service.ts` is already past 900
|
||||
lines).
|
||||
|
||||
- `EPG_GET_PROGRAMS_FOR_CHANNELS { channelIds, fromMs, toMs, sourceUrls? }` →
|
||||
`Record<requestedId, EpgProgram[]>`. Id resolution matches
|
||||
`GET_CURRENT_PROGRAMS_BATCH`: manual mappings via `queryByResolvedChannelIds`,
|
||||
then exact id in scoped sources, legacy rows, then case-insensitive
|
||||
`id`/`display_name` candidates. One SQL per batch:
|
||||
`channel_id IN (...) AND start < to AND stop > from` on
|
||||
`idx_epg_programs_time_range`; programmes crossing the day boundary are
|
||||
included. Batches are limited to 100 ids; the renderer splits.
|
||||
- `EPG_GET_PROGRAM_COVERAGE { channelIds, fromMs, toMs, sourceUrls? }` →
|
||||
`string[]` of requested ids with at least one programme in the window. Same
|
||||
resolution, then `SELECT DISTINCT channel_id … WHERE start < to AND stop >
|
||||
from`. 2000 keys cost one resolution and one query, not 2000.
|
||||
- Both responses key exactly the trimmed, de-duplicated, cap-respecting
|
||||
requested ids — never the raw request. An id cut by the per-request cap
|
||||
(100 for programmes, 2000 for coverage) is absent from the answer, never
|
||||
present with an empty list, so a caller can tell "queried, nothing found"
|
||||
apart from "not queried at all"; an invalid window (bad instants,
|
||||
`fromMs >= toMs`, no usable ids) returns `{}`/`[]`.
|
||||
- When a request carries `sourceUrls`, `guideWindowCondition()` folds the
|
||||
source scoping into the same query instead of a second scoped/unscoped
|
||||
pass: a row qualifies if it belongs to one of the requested sources OR
|
||||
carries no source at all (legacy rows predating per-source EPG tracking),
|
||||
never if it belongs to a *different* source. The M3U host never sets
|
||||
`sourceUrls`, so this only matters for portal hosts.
|
||||
- Bridge: `getEpgProgramsForChannels` and `getEpgProgramCoverage` in
|
||||
`ElectronBridgeApi`, `main.preload.ts`, `EpgRuntimeBridgeService`;
|
||||
capability `supportsEpgGuide` in `RuntimeCapabilitiesService` replaces
|
||||
`supportsEpgChannelBrowser` inside the composite `supportsEpg`. Both methods
|
||||
get rows in `main.preload.spec-data.ts`.
|
||||
- The display offset never reaches SQL (see the contract rules above).
|
||||
|
||||
## Removed
|
||||
|
||||
- `libs/ui/epg/src/lib/multi-epg/` (component, spec, `overlay-ref.token.ts`),
|
||||
its export, its `max-lines-baseline.mjs` entry, `openMultiEpgView` and the
|
||||
`COMPONENT_OVERLAY_REF` wiring in `VideoPlayerComponent`.
|
||||
- `EPG_GET_CHANNELS_BY_RANGE`, `EpgQueryService.getChannelsByRange`,
|
||||
`getEpgChannelsByRange` in preload and contract, `supportsEpgChannelBrowser`.
|
||||
- Comments describing the "multi-EPG overlay" in
|
||||
`window-controls.component.ts`, `epg-item-description.component.ts`,
|
||||
`docs/architecture/workspace-shell.md`: the guide is ordinary host layout,
|
||||
so the window controls no longer have an overlay to stay above.
|
||||
- i18n keys `TOP_MENU.OPEN_MULTI_EPG` and
|
||||
`WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION`, replaced by
|
||||
`EPG.GUIDE.*` (toolbar, density, empty states, key hints, player strip)
|
||||
added to all 19 locales with the `i18n-fill` skill.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit, `ui-epg`: layout util (ticks, now-x, boundary-crossing cards, tiers);
|
||||
programs service (visible-row batching, per-day cache, coverage, reset on
|
||||
scope change); keyboard controller (navigation, input/dialog exclusions);
|
||||
guide component (click/double-click/Enter → `activate`/`close`, toggle hides
|
||||
only uncovered rows, density/zoom/toggle persistence).
|
||||
- Unit, `playlist-m3u-feature-player`: source adapter (scopes from groups and
|
||||
favorites, `epgKey` chain, radio/movie exclusion, `sourceUrls` forwarded,
|
||||
`activate` dispatches); `video-player.component.spec` (guide mode hides
|
||||
sidebar and timeline, the player instance is unchanged, fullscreen closes
|
||||
the guide, G and Esc).
|
||||
- Unit, `electron-backend`: `epg-guide-query.service.spec` on in-memory SQLite
|
||||
(day window with boundary overlap, manual mapping and case-insensitive
|
||||
`display_name` resolution, scoped sources, batch limit, coverage);
|
||||
`main.preload.spec-data.ts` rows for both methods.
|
||||
- E2E, `electron-backend-e2e`: new `epg-guide.e2e.ts` on the fixture used by
|
||||
`epg.e2e.ts` (imports XMLTV, renders the timeline): open the guide from the
|
||||
timeline button, rows equal the playlist's channels in order, the toggle
|
||||
hides the row without EPG, clicking a channel changes the active channel,
|
||||
Esc closes, and the `<video>` element is the same node (marker attribute set
|
||||
before opening).
|
||||
- `pnpm run typecheck:ci` and `pnpm nx lint` on touched projects; manual CDP
|
||||
check on native-view Embedded MPV for strip bounds on open, collapse and
|
||||
window zoom.
|
||||
|
||||
## Documentation and Release Note
|
||||
|
||||
- `docs/architecture/m3u-playlist-module.md`: a "Programme guide" section
|
||||
(contract, host mode, IPC, persisted keys, keys) replaces the "Multi-EPG
|
||||
modal view" line.
|
||||
- `CLAUDE.md`: `ui/epg` description, EPG section, and the display-offset
|
||||
sentence naming the "multi-EPG overlay". `workspace-shell.md` and
|
||||
`pwa-self-hosted.md` where they mention the overlay.
|
||||
- README and website reference `multi-epg-view.webp`; it is recaptured only
|
||||
by the release screenshot script against the mock servers and is a
|
||||
release-time follow-up, not part of the PR.
|
||||
- `.changes/epg-programme-guide.md`, `type: feature`, highlight
|
||||
"Programme guide, rebuilt": the guide shows your playlist's channels in
|
||||
their order, a click switches channel without interrupting playback, the
|
||||
player stays on screen, a Guide button lives in the programme panel, G
|
||||
opens it, plus an "only with EPG" toggle and two density modes.
|
||||
|
||||
## Deferred to Sub-project 2
|
||||
|
||||
- Xtream and Stalker adapters for `EPG_GUIDE_SOURCE`. Decided direction: per
|
||||
channel exactly one source, no toolbar switch. Xtream follows the existing
|
||||
"prefer uploaded EPG over Xtream" setting (portal `get_simple_data_table`
|
||||
first otherwise, XMLTV as fallback) and loads visible rows lazily through
|
||||
`EpgQueueService`; Stalker uses the bulk `get_epg_info` cache with manual
|
||||
mapping overlays as the live layout already does. A small source hint may
|
||||
appear on hover.
|
||||
- `catchUp` wiring for M3U catch-up attributes.
|
||||
- Time-windowed lazy loading inside a day (v1 loads the whole day for the
|
||||
visible rows).
|
||||
@@ -16,7 +16,7 @@ describe('EpgRuntimeBridgeService', () => {
|
||||
supportsEpgChannelMetadata: false,
|
||||
supportsEpgSourceFreshness: false,
|
||||
supportsEpgDataManagement: false,
|
||||
supportsEpgChannelBrowser: false,
|
||||
supportsEpgGuide: false,
|
||||
supportsEpgProgramSearch: false,
|
||||
};
|
||||
|
||||
@@ -122,7 +122,6 @@ describe('EpgRuntimeBridgeService', () => {
|
||||
freshUrls: ['https://example.com/epg.xml'],
|
||||
staleUrls: [],
|
||||
});
|
||||
const getEpgChannelsByRange = jest.fn().mockResolvedValue([]);
|
||||
const searchEpgPrograms = jest.fn().mockResolvedValue([]);
|
||||
window.electron = {
|
||||
...window.electron,
|
||||
@@ -130,14 +129,12 @@ describe('EpgRuntimeBridgeService', () => {
|
||||
getCurrentProgramsBatch,
|
||||
getEpgChannelMetadata,
|
||||
checkEpgFreshness,
|
||||
getEpgChannelsByRange,
|
||||
searchEpgPrograms,
|
||||
} as unknown as typeof window.electron;
|
||||
runtimeCapabilities.supportsEpgProgramLookup = true;
|
||||
runtimeCapabilities.supportsEpgCurrentProgramBatch = true;
|
||||
runtimeCapabilities.supportsEpgChannelMetadata = true;
|
||||
runtimeCapabilities.supportsEpgSourceFreshness = true;
|
||||
runtimeCapabilities.supportsEpgChannelBrowser = true;
|
||||
runtimeCapabilities.supportsEpgProgramSearch = true;
|
||||
|
||||
await service.getChannelPrograms('channel-1');
|
||||
@@ -148,7 +145,6 @@ describe('EpgRuntimeBridgeService', () => {
|
||||
sourceUrls: ['https://playlist.example.com/guide.xml'],
|
||||
});
|
||||
await service.checkFreshness(['https://example.com/epg.xml'], 12);
|
||||
await service.getChannelsByRange(0, 20);
|
||||
await service.searchPrograms('news', 20);
|
||||
|
||||
expect(getChannelPrograms).toHaveBeenCalledWith('channel-1');
|
||||
@@ -162,10 +158,47 @@ describe('EpgRuntimeBridgeService', () => {
|
||||
['https://example.com/epg.xml'],
|
||||
12
|
||||
);
|
||||
expect(getEpgChannelsByRange).toHaveBeenCalledWith(0, 20);
|
||||
expect(searchEpgPrograms).toHaveBeenCalledWith('news', 20);
|
||||
});
|
||||
|
||||
it('forwards guide reads when the runtime supports them', async () => {
|
||||
const getEpgProgramsForChannels = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ a: [] });
|
||||
const getEpgProgramCoverage = jest.fn().mockResolvedValue(['a']);
|
||||
window.electron = {
|
||||
...window.electron,
|
||||
getEpgProgramsForChannels,
|
||||
getEpgProgramCoverage,
|
||||
} as unknown as typeof window.electron;
|
||||
runtimeCapabilities.supportsEpgGuide = true;
|
||||
|
||||
const guideWindow = { channelIds: ['a'], fromMs: 1, toMs: 2 };
|
||||
await expect(
|
||||
service.getProgramsForChannels(guideWindow)
|
||||
).resolves.toEqual({ a: [] });
|
||||
await expect(service.getProgramCoverage(guideWindow)).resolves.toEqual([
|
||||
'a',
|
||||
]);
|
||||
expect(getEpgProgramsForChannels).toHaveBeenCalledWith(guideWindow);
|
||||
expect(getEpgProgramCoverage).toHaveBeenCalledWith(guideWindow);
|
||||
});
|
||||
|
||||
it('answers null for guide reads without runtime support or channels', async () => {
|
||||
runtimeCapabilities.supportsEpgGuide = false;
|
||||
await expect(
|
||||
service.getProgramsForChannels({
|
||||
channelIds: ['a'],
|
||||
fromMs: 1,
|
||||
toMs: 2,
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
runtimeCapabilities.supportsEpgGuide = true;
|
||||
await expect(
|
||||
service.getProgramCoverage({ channelIds: [], fromMs: 1, toMs: 2 })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('subscribes to EPG progress only when progress events are supported', () => {
|
||||
const onEpgProgress = jest.fn();
|
||||
window.electron = {
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
ElectronBridgeEpgProgress,
|
||||
ElectronBridgeEpgProgressStats,
|
||||
ElectronBridgeEpgProgressStatus,
|
||||
ElectronBridgeEpgSearchProgram,
|
||||
ElectronBridgeEpgSearchResult,
|
||||
ElectronBridgeEpgChannelWithPrograms,
|
||||
ElectronBridgeEpgGuideWindow,
|
||||
ElectronBridgeEpgFreshnessResult,
|
||||
ElectronBridgeEpgLookupOptions,
|
||||
ElectronBridgeResult,
|
||||
@@ -39,7 +40,8 @@ type EpgElectronBridge = Pick<
|
||||
| 'getChannelPrograms'
|
||||
| 'getCurrentProgramsBatch'
|
||||
| 'getEpgChannelMetadata'
|
||||
| 'getEpgChannelsByRange'
|
||||
| 'getEpgProgramsForChannels'
|
||||
| 'getEpgProgramCoverage'
|
||||
| 'onEpgProgress'
|
||||
| 'searchEpgPrograms'
|
||||
| 'getEpgMapping'
|
||||
@@ -81,8 +83,8 @@ export class EpgRuntimeBridgeService {
|
||||
return this.runtime.supportsEpgDataManagement;
|
||||
}
|
||||
|
||||
get supportsChannelBrowser(): boolean {
|
||||
return this.runtime.supportsEpgChannelBrowser;
|
||||
get supportsGuide(): boolean {
|
||||
return this.runtime.supportsEpgGuide;
|
||||
}
|
||||
|
||||
get supportsProgramSearch(): boolean {
|
||||
@@ -206,16 +208,26 @@ export class EpgRuntimeBridgeService {
|
||||
);
|
||||
}
|
||||
|
||||
getChannelsByRange(
|
||||
skip: number,
|
||||
limit: number
|
||||
): Promise<ElectronBridgeEpgChannelWithPrograms[] | null> {
|
||||
if (!this.supportsChannelBrowser) {
|
||||
getProgramsForChannels(
|
||||
guideWindow: ElectronBridgeEpgGuideWindow
|
||||
): Promise<Record<string, EpgProgram[]> | null> {
|
||||
if (!this.supportsGuide || guideWindow.channelIds.length === 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return (
|
||||
this.bridge?.getEpgChannelsByRange?.(skip, limit) ??
|
||||
this.bridge?.getEpgProgramsForChannels?.(guideWindow) ??
|
||||
Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
getProgramCoverage(
|
||||
guideWindow: ElectronBridgeEpgGuideWindow
|
||||
): Promise<string[] | null> {
|
||||
if (!this.supportsGuide || guideWindow.channelIds.length === 0) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return (
|
||||
this.bridge?.getEpgProgramCoverage?.(guideWindow) ??
|
||||
Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
@@ -223,7 +235,7 @@ export class EpgRuntimeBridgeService {
|
||||
searchPrograms(
|
||||
searchTerm: string,
|
||||
limit?: number
|
||||
): Promise<EpgProgram[] | null> {
|
||||
): Promise<ElectronBridgeEpgSearchProgram[] | null> {
|
||||
if (!this.supportsProgramSearch) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
@@ -241,7 +253,9 @@ export class EpgRuntimeBridgeService {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null);
|
||||
return (
|
||||
this.bridge?.getEpgMapping?.(channelKey) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
getEpgMappingsBatch(
|
||||
@@ -267,8 +281,11 @@ export class EpgRuntimeBridgeService {
|
||||
}
|
||||
|
||||
return (
|
||||
this.bridge?.setEpgMapping?.(channelKey, epgChannelId, playlistId) ??
|
||||
Promise.resolve(null)
|
||||
this.bridge?.setEpgMapping?.(
|
||||
channelKey,
|
||||
epgChannelId,
|
||||
playlistId
|
||||
) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,7 +294,9 @@ export class EpgRuntimeBridgeService {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null);
|
||||
return (
|
||||
this.bridge?.deleteEpgMapping?.(channelKey) ?? Promise.resolve(null)
|
||||
);
|
||||
}
|
||||
|
||||
searchEpgChannels(
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { Channel } from '@iptvnator/shared/interfaces';
|
||||
import { isSameChannelEntry } from './channel-entry-identity.util';
|
||||
|
||||
function entry(overrides: Partial<Channel> = {}): Channel {
|
||||
return {
|
||||
id: 'dup',
|
||||
url: 'https://example.com/dup.m3u8',
|
||||
name: 'Dup',
|
||||
group: { title: 'News' },
|
||||
tvg: { id: '', name: '', url: '', logo: '', rec: '' },
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: 'false',
|
||||
epgParams: '',
|
||||
...overrides,
|
||||
} as Channel;
|
||||
}
|
||||
|
||||
describe('isSameChannelEntry', () => {
|
||||
it('treats spread copies with a rewritten epgParams as the same entry', () => {
|
||||
const a = entry();
|
||||
expect(isSameChannelEntry(a, { ...a, epgParams: 'x' })).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores key order', () => {
|
||||
const a = entry();
|
||||
const reordered = JSON.parse(JSON.stringify(a)) as Channel;
|
||||
expect(isSameChannelEntry(a, reordered)).toBe(true);
|
||||
});
|
||||
|
||||
it('tells entries apart by playback headers, logo or group', () => {
|
||||
const a = entry();
|
||||
expect(
|
||||
isSameChannelEntry(
|
||||
a,
|
||||
entry({
|
||||
http: { referrer: '', 'user-agent': 'VLC', origin: '' },
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSameChannelEntry(
|
||||
a,
|
||||
entry({ tvg: { ...a.tvg, logo: 'https://x/logo.png' } })
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSameChannelEntry(a, entry({ group: { title: 'Sports' } }))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Channel } from '@iptvnator/shared/interfaces';
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonical).join(',')}]`;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.keys(value as Record<string, unknown>)
|
||||
.sort()
|
||||
.map(
|
||||
(key) =>
|
||||
`${JSON.stringify(key)}:${canonical(
|
||||
(value as Record<string, unknown>)[key]
|
||||
)}`
|
||||
);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two playlist entries are the same entry, field for field. The
|
||||
* store spreads the selected channel and resets `epgParams`, so identity is
|
||||
* lost and only the data can tell copies apart: two rows may share id, url,
|
||||
* group and name yet differ in playback headers or logo, and each is its own
|
||||
* guide row. `epgParams` is excluded because the reducer rewrites it.
|
||||
*/
|
||||
export function isSameChannelEntry(a: Channel, b: Channel): boolean {
|
||||
const { epgParams: _a, ...restA } = a;
|
||||
const { epgParams: _b, ...restB } = b;
|
||||
return canonical(restA) === canonical(restB);
|
||||
}
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { ChannelActions } from '@iptvnator/m3u-state';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { M3uEpgGuideSourceService } from './m3u-epg-guide-source.service';
|
||||
|
||||
function makeChannel(
|
||||
id: string,
|
||||
overrides: Partial<Channel> & { tvgId?: string; group?: string } = {}
|
||||
): Channel {
|
||||
return {
|
||||
id,
|
||||
url: `https://example.com/${id}.m3u8`,
|
||||
name: overrides.name ?? `Channel ${id}`,
|
||||
group: { title: overrides.group ?? 'News' },
|
||||
tvg: {
|
||||
id: overrides.tvgId ?? '',
|
||||
name: '',
|
||||
url: '',
|
||||
logo: overrides.tvg?.logo ?? '',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: overrides.radio ?? 'false',
|
||||
} as Channel;
|
||||
}
|
||||
|
||||
function program(channel: string): EpgProgram {
|
||||
return {
|
||||
start: '2026-09-06T16:00:00.000Z',
|
||||
stop: '2026-09-06T17:00:00.000Z',
|
||||
channel,
|
||||
title: `${channel} show`,
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('M3uEpgGuideSourceService', () => {
|
||||
const channels = signal<Channel[]>([]);
|
||||
const favoriteKeys = signal<string[]>([]);
|
||||
const activeChannel = signal<Channel | null>(null);
|
||||
const activePlaybackUrl = signal<string | null>(null);
|
||||
const selectedGroup = signal<string | null>(null);
|
||||
const dispatch = jest.fn();
|
||||
const getProgramsForChannels = jest.fn();
|
||||
const getProgramCoverage = jest.fn();
|
||||
const searchPrograms = jest.fn();
|
||||
let service: M3uEpgGuideSourceService;
|
||||
let translateStub: { instant: (key: string) => string };
|
||||
let onLangChange: Subject<{ lang: string }>;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
getProgramsForChannels.mockReset();
|
||||
getProgramCoverage.mockReset();
|
||||
searchPrograms.mockReset();
|
||||
onLangChange = new Subject<{ lang: string }>();
|
||||
translateStub = { instant: (key: string) => key };
|
||||
channels.set([
|
||||
makeChannel('a', { tvgId: 'a.tv', group: 'News' }),
|
||||
makeChannel('b', { name: 'Beta', group: 'Sports' }),
|
||||
makeChannel('c', { name: ' ', group: 'Sports' }),
|
||||
]);
|
||||
favoriteKeys.set(['https://example.com/b.m3u8']);
|
||||
activeChannel.set(channels()[0]);
|
||||
selectedGroup.set(null);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
M3uEpgGuideSourceService,
|
||||
{ provide: Store, useValue: { dispatch } },
|
||||
{
|
||||
provide: EpgRuntimeBridgeService,
|
||||
useValue: {
|
||||
getProgramsForChannels,
|
||||
getProgramCoverage,
|
||||
searchPrograms,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { stripCountryPrefix: signal(false) },
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
get instant() {
|
||||
return translateStub.instant;
|
||||
},
|
||||
onLangChange,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(M3uEpgGuideSourceService);
|
||||
service.bind({
|
||||
channels,
|
||||
favoriteKeys,
|
||||
activeChannel,
|
||||
activePlaybackUrl,
|
||||
selectedGroup,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers all / groups / favorites scopes and lists channels in playlist order', () => {
|
||||
expect(service.scopes().map((scope) => scope.id)).toEqual([
|
||||
'all',
|
||||
'favorites',
|
||||
'group:News',
|
||||
'group:Sports',
|
||||
]);
|
||||
expect(service.channels().map((channel) => channel.number)).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
service.setScope('group:Sports');
|
||||
// Row ids are scope-local: the position inside the scope, then the
|
||||
// channel id.
|
||||
expect(service.channels().map((channel) => channel.id)).toEqual([
|
||||
'0:b',
|
||||
'1:c',
|
||||
]);
|
||||
expect(service.channels()[0].number).toBe(1);
|
||||
service.setScope('favorites');
|
||||
expect(service.channels().map((channel) => channel.id)).toEqual([
|
||||
'0:b',
|
||||
]);
|
||||
// Legacy rows saved before URL keys are still matched by channel id.
|
||||
favoriteKeys.set(['c']);
|
||||
expect(service.channels().map((channel) => channel.id)).toEqual([
|
||||
'0:c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives the EPG key from tvg-id, then name, and null for blank names', () => {
|
||||
const keys = service.channels().map((channel) => channel.epgKey);
|
||||
expect(keys).toEqual(['a.tv', 'Beta', null]);
|
||||
});
|
||||
|
||||
it('loads programmes and coverage through the bridge keyed back by channel id', async () => {
|
||||
getProgramsForChannels.mockResolvedValue({
|
||||
'a.tv': [program('a.tv')],
|
||||
Beta: [],
|
||||
});
|
||||
getProgramCoverage.mockResolvedValue(['a.tv']);
|
||||
const window = { channels: service.channels(), fromMs: 1, toMs: 2 };
|
||||
|
||||
const programs = await service.loadPrograms(window);
|
||||
expect(getProgramsForChannels).toHaveBeenCalledWith({
|
||||
channelIds: ['a.tv', 'Beta'],
|
||||
fromMs: 1,
|
||||
toMs: 2,
|
||||
});
|
||||
expect(programs.get('0:a')?.[0].title).toBe('a.tv show');
|
||||
expect(programs.get('1:b')).toEqual([]);
|
||||
expect(programs.has('2:c')).toBe(false);
|
||||
|
||||
const covered = await service.loadCoverage(window);
|
||||
expect(covered).toEqual(new Set(['0:a']));
|
||||
});
|
||||
|
||||
it('answers empty results when the bridge is unavailable', async () => {
|
||||
getProgramsForChannels.mockResolvedValue(null);
|
||||
getProgramCoverage.mockResolvedValue(null);
|
||||
const window = { channels: service.channels(), fromMs: 1, toMs: 2 };
|
||||
expect((await service.loadPrograms(window)).size).toBe(0);
|
||||
expect((await service.loadCoverage(window)).size).toBe(0);
|
||||
});
|
||||
|
||||
it('marks the duplicate whose stream url matches the playing channel', () => {
|
||||
const first = makeChannel('dup');
|
||||
const second = {
|
||||
...makeChannel('dup'),
|
||||
url: 'https://example.com/second.m3u8',
|
||||
};
|
||||
channels.set([first, second]);
|
||||
activeChannel.set(second);
|
||||
expect(service.activeChannelId()).toBe('1:dup');
|
||||
activeChannel.set({
|
||||
...makeChannel('dup'),
|
||||
url: 'https://example.com/unknown.m3u8',
|
||||
});
|
||||
expect(service.activeChannelId()).toBe('0:dup');
|
||||
});
|
||||
|
||||
it('marks the copy in the selected group when a stream is listed twice', () => {
|
||||
const news = makeChannel('dup', { group: 'News' });
|
||||
const sports = makeChannel('dup', { group: 'Sports' });
|
||||
channels.set([news, sports]);
|
||||
activeChannel.set({ ...sports });
|
||||
expect(service.activeChannelId()).toBe('1:dup');
|
||||
activeChannel.set({ ...news });
|
||||
expect(service.activeChannelId()).toBe('0:dup');
|
||||
});
|
||||
|
||||
it('marks the copy whose playback headers match when everything else is equal', () => {
|
||||
const plain = makeChannel('dup');
|
||||
const withUa = {
|
||||
...makeChannel('dup'),
|
||||
http: { referrer: '', 'user-agent': 'VLC/3', origin: '' },
|
||||
};
|
||||
channels.set([plain, withUa]);
|
||||
activeChannel.set({ ...withUa, epgParams: '' });
|
||||
expect(service.activeChannelId()).toBe('1:dup');
|
||||
activeChannel.set({ ...plain, epgParams: '' });
|
||||
expect(service.activeChannelId()).toBe('0:dup');
|
||||
});
|
||||
|
||||
it('reports live playback unless the host plays a catch-up url', () => {
|
||||
expect(service.livePlayback()).toBe(true);
|
||||
activePlaybackUrl.set('https://example.com/archive.m3u8');
|
||||
expect(service.livePlayback()).toBe(false);
|
||||
});
|
||||
|
||||
it('propagates a coverage failure so the guide keeps coverage unknown', async () => {
|
||||
getProgramCoverage.mockRejectedValue(new Error('bridge down'));
|
||||
await expect(
|
||||
service.loadCoverage({
|
||||
channels: service.channels(),
|
||||
fromMs: 1,
|
||||
toMs: 2,
|
||||
})
|
||||
).rejects.toThrow('bridge down');
|
||||
});
|
||||
|
||||
it('mirrors the active channel and dispatches playback on activate', () => {
|
||||
expect(service.activeChannelId()).toBe('0:a');
|
||||
service.activate('1:b');
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: channels()[1],
|
||||
startPlayback: true,
|
||||
})
|
||||
);
|
||||
service.activate('missing');
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps duplicate channel ids apart and activates the row that was clicked', () => {
|
||||
// `createChannel` falls back to the URL as the id, so one stream
|
||||
// listed twice yields two channels sharing an id. A real id can also
|
||||
// look like a generated suffix (`x#1`), so suffixing the repeats made
|
||||
// the third row collide with the second; the position prefix cannot.
|
||||
const duplicated = signal<Channel[]>([
|
||||
makeChannel('x', { name: 'First copy', group: 'News' }),
|
||||
makeChannel('x', { name: 'Second copy', group: 'News' }),
|
||||
makeChannel('x#1', { name: 'Hashed id', group: 'News' }),
|
||||
]);
|
||||
service.bind({
|
||||
channels: duplicated,
|
||||
favoriteKeys,
|
||||
activeChannel: signal<Channel | null>(duplicated()[0]),
|
||||
activePlaybackUrl,
|
||||
selectedGroup,
|
||||
});
|
||||
|
||||
const rowIds = service.channels().map((channel) => channel.id);
|
||||
expect(rowIds).toEqual(['0:x', '1:x', '2:x#1']);
|
||||
expect(new Set(rowIds).size).toBe(3);
|
||||
// The playing channel marks the first of the two rows.
|
||||
expect(service.activeChannelId()).toBe('0:x');
|
||||
|
||||
service.activate('1:x');
|
||||
expect(dispatch).toHaveBeenLastCalledWith(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: duplicated()[1],
|
||||
startPlayback: true,
|
||||
})
|
||||
);
|
||||
|
||||
service.activate('2:x#1');
|
||||
expect(dispatch).toHaveBeenLastCalledWith(
|
||||
ChannelActions.setActiveChannel({
|
||||
channel: duplicated()[2],
|
||||
startPlayback: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('seeds the initial scope from the sidebar view', () => {
|
||||
service.applyInitialScope('favorites');
|
||||
expect(service.scopeId()).toBe('favorites');
|
||||
service.applyInitialScope('groups');
|
||||
expect(service.scopeId()).toBe('group:News');
|
||||
service.applyInitialScope('all');
|
||||
expect(service.scopeId()).toBe('all');
|
||||
});
|
||||
|
||||
it("prefers the sidebar's selected group over the playing channel's group", () => {
|
||||
selectedGroup.set('Sports');
|
||||
|
||||
service.applyInitialScope('groups');
|
||||
|
||||
expect(service.scopeId()).toBe('group:Sports');
|
||||
});
|
||||
|
||||
it('falls back to the playing group when the selected one offers no rows', () => {
|
||||
selectedGroup.set('Nothing Here');
|
||||
|
||||
service.applyInitialScope('groups');
|
||||
|
||||
expect(service.scopeId()).toBe('group:News');
|
||||
});
|
||||
|
||||
it("falls back to the all scope when the active channel's group has no eligible channels", () => {
|
||||
const sportsOnlyChannels = signal<Channel[]>([
|
||||
makeChannel('b', { name: 'Beta', group: 'Sports' }),
|
||||
]);
|
||||
const newsActiveChannel = signal<Channel | null>(
|
||||
makeChannel('a', { tvgId: 'a.tv', group: 'News' })
|
||||
);
|
||||
service.bind({
|
||||
channels: sportsOnlyChannels,
|
||||
favoriteKeys,
|
||||
activeChannel: newsActiveChannel,
|
||||
activePlaybackUrl,
|
||||
selectedGroup,
|
||||
});
|
||||
|
||||
service.applyInitialScope('groups');
|
||||
|
||||
expect(service.scopeId()).toBe('all');
|
||||
});
|
||||
|
||||
it('re-labels scopes when the active language changes', () => {
|
||||
expect(service.scopes()[0].label).toBe('CHANNELS.ALL_CHANNELS');
|
||||
|
||||
translateStub.instant = (key: string) => `${key}!`;
|
||||
onLangChange.next({ lang: 'de' });
|
||||
|
||||
expect(service.scopes()[0].label).toBe('CHANNELS.ALL_CHANNELS!');
|
||||
});
|
||||
|
||||
it('forwards programme search to the bridge', async () => {
|
||||
searchPrograms.mockResolvedValue([program('a.tv'), program('x')]);
|
||||
const hits = await service.searchPrograms('news');
|
||||
expect(hits.map((hit) => hit.channelId)).toEqual(['0:a', null]);
|
||||
expect(hits.map((hit) => hit.channelName)).toEqual(['Channel a', null]);
|
||||
expect(hits[1].program.title).toBe('x show');
|
||||
searchPrograms.mockResolvedValue([
|
||||
{ ...program('x'), channelName: 'XMLTV Name' },
|
||||
]);
|
||||
expect((await service.searchPrograms('x'))[0].channelName).toBe(
|
||||
'XMLTV Name'
|
||||
);
|
||||
expect(searchPrograms).toHaveBeenCalledWith('news', 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { computed, inject, Injectable, Signal, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
|
||||
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
|
||||
import { Channel } from '@iptvnator/shared/interfaces';
|
||||
import type { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgGuideChannel,
|
||||
EpgGuideScope,
|
||||
EpgGuideSearchHit,
|
||||
EpgGuideSource,
|
||||
EpgGuideWindow,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { createM3uChannelPlaybackRequest } from '../video-player/m3u-channel-playback-actions';
|
||||
import { isSameChannelEntry } from './channel-entry-identity.util';
|
||||
|
||||
export interface M3uEpgGuideInputs {
|
||||
/** Guide-eligible channels (host excludes radio and recognised movies). */
|
||||
channels: Signal<Channel[]>;
|
||||
/**
|
||||
* Keys of the playlist's favorites as the store persists them: the
|
||||
* channel URL (`FavoritesActions.updateFavorites`), with the channel id
|
||||
* accepted as a legacy fallback for rows saved before URL keys.
|
||||
*/
|
||||
favoriteKeys: Signal<string[]>;
|
||||
activeChannel: Signal<Channel | null>;
|
||||
/** The catch-up/archive URL the host plays instead of the live stream, if any. */
|
||||
activePlaybackUrl: Signal<string | null>;
|
||||
/**
|
||||
* Group the sidebar's groups view currently shows, by title. The guide
|
||||
* opens on what the user was looking at, which is not necessarily the
|
||||
* playing channel's group — they may have browsed away from it.
|
||||
*/
|
||||
selectedGroup: Signal<string | null>;
|
||||
}
|
||||
|
||||
const SCOPE_ALL = 'all';
|
||||
const SCOPE_FAVORITES = 'favorites';
|
||||
const GROUP_PREFIX = 'group:';
|
||||
const SEARCH_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* `EPG_GUIDE_SOURCE` for the M3U player. Channels, favorites and the active
|
||||
* channel are bound by the host; programmes come from the XMLTV bridge
|
||||
* keyed by the same lookup chain the sidebar uses (tvg-id → tvg-name → name).
|
||||
* Queries are unscoped (all imported EPG sources), like the M3U timeline.
|
||||
*/
|
||||
@Injectable()
|
||||
export class M3uEpgGuideSourceService implements EpgGuideSource {
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
private readonly inputs = signal<M3uEpgGuideInputs | null>(null);
|
||||
private readonly scope = signal(SCOPE_ALL);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
readonly scopeId = this.scope.asReadonly();
|
||||
|
||||
private readonly allChannels = computed(
|
||||
() => this.inputs()?.channels() ?? []
|
||||
);
|
||||
|
||||
readonly scopes = computed<EpgGuideScope[]>(() => {
|
||||
this.languageTick();
|
||||
const groups: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const channel of this.allChannels()) {
|
||||
const title = channel.group?.title?.trim();
|
||||
if (title && !seen.has(title)) {
|
||||
seen.add(title);
|
||||
groups.push(title);
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: SCOPE_ALL,
|
||||
label: this.translate.instant('CHANNELS.ALL_CHANNELS'),
|
||||
kind: 'all',
|
||||
},
|
||||
{
|
||||
id: SCOPE_FAVORITES,
|
||||
label: this.translate.instant('CHANNELS.FAVORITES'),
|
||||
kind: 'favorites',
|
||||
},
|
||||
...groups.map((title) => ({
|
||||
id: `${GROUP_PREFIX}${title}`,
|
||||
label: title,
|
||||
kind: 'group' as const,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
private readonly scopedChannels = computed(() => {
|
||||
const scope = this.scope();
|
||||
const channels = this.allChannels();
|
||||
if (scope === SCOPE_FAVORITES) {
|
||||
const favorites = new Set(this.inputs()?.favoriteKeys() ?? []);
|
||||
return channels.filter(
|
||||
(channel) =>
|
||||
favorites.has(channel.url) || favorites.has(channel.id)
|
||||
);
|
||||
}
|
||||
if (scope.startsWith(GROUP_PREFIX)) {
|
||||
const title = scope.slice(GROUP_PREFIX.length);
|
||||
return channels.filter(
|
||||
(channel) => channel.group?.title?.trim() === title
|
||||
);
|
||||
}
|
||||
return channels;
|
||||
});
|
||||
|
||||
/**
|
||||
* Rows the guide renders, each with an id that is unique within the scope.
|
||||
* `createChannel` falls back to the stream URL for an M3U entry without an
|
||||
* explicit id, so one stream listed in two groups — or simply repeated —
|
||||
* yields two channels sharing an id. The guide keys its programme, status
|
||||
* and selection maps by row id, so duplicates used to collide: both rows
|
||||
* lit up as playing and activating either one played the first.
|
||||
* Every row is therefore prefixed with its position in the scope: two rows
|
||||
* always differ in that leading integer, so no channel id can collide with
|
||||
* another row (suffixing only the repeats did not hold — a playlist whose
|
||||
* ids are `x`, `x` and `x#1` produced `x#1` twice). Ids are SCOPE-LOCAL:
|
||||
* they change with the scope and the channel list, and only ids the guide
|
||||
* was just handed in `channels()` may be passed back.
|
||||
*/
|
||||
private readonly scopedRows = computed<
|
||||
Array<{ rowId: string; channel: Channel }>
|
||||
>(() =>
|
||||
this.scopedChannels().map((channel, index) => ({
|
||||
rowId: `${index}:${channel.id}`,
|
||||
channel,
|
||||
}))
|
||||
);
|
||||
|
||||
private readonly channelsByRowId = computed(
|
||||
() => new Map(this.scopedRows().map((row) => [row.rowId, row.channel]))
|
||||
);
|
||||
|
||||
/** `EpgGuideChannel.id` is the scope-local row id, not `Channel.id`. */
|
||||
readonly channels = computed<EpgGuideChannel[]>(() => {
|
||||
const strip = this.settingsStore.stripCountryPrefix?.();
|
||||
return this.scopedRows().map(({ rowId, channel }, index) => ({
|
||||
id: rowId,
|
||||
number: index + 1,
|
||||
name: applyChannelNameStrip(channel.name, strip) || channel.name,
|
||||
logoUrl: channel.tvg?.logo?.trim() || null,
|
||||
epgKey: resolveChannelEpgLookupKey(channel) || null,
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* The row id of the first row carrying the active channel's id —
|
||||
* duplicates are indistinguishable from here, so the guide marks the first
|
||||
* of them. `null` when the playing channel is outside the current scope:
|
||||
* row ids are scope-local, so there is nothing to point at.
|
||||
*/
|
||||
readonly activeChannelId = computed(() => {
|
||||
const active = this.inputs()?.activeChannel();
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
// The store spreads the selected channel, so identity is gone; the
|
||||
// same stream can sit in two groups (same id and url), so narrow by
|
||||
// the metadata that still tells copies apart before widening.
|
||||
const sameId = this.scopedRows().filter(
|
||||
(row) => row.channel.id === active.id
|
||||
);
|
||||
const match =
|
||||
sameId.find((row) => isSameChannelEntry(row.channel, active)) ??
|
||||
sameId.find((row) => row.channel.url === active.url) ??
|
||||
sameId[0];
|
||||
return match?.rowId ?? null;
|
||||
});
|
||||
|
||||
/** Live unless the host plays a catch-up URL for the active channel. */
|
||||
readonly livePlayback = computed(() => !this.inputs()?.activePlaybackUrl());
|
||||
|
||||
bind(inputs: M3uEpgGuideInputs): void {
|
||||
this.inputs.set(inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the host when the guide opens: mirror the sidebar view. In the
|
||||
* groups view the sidebar's SELECTED group wins over the playing channel's
|
||||
* group — the user may have browsed to another group before opening the
|
||||
* guide, and that is what they expect to see. The playing channel's group
|
||||
* remains the fallback (nothing selected yet, or a group with no
|
||||
* guide-eligible channels left).
|
||||
*/
|
||||
applyInitialScope(view: string): void {
|
||||
if (view === SCOPE_FAVORITES) {
|
||||
this.scope.set(SCOPE_FAVORITES);
|
||||
return;
|
||||
}
|
||||
if (view === 'groups') {
|
||||
const groupScopeId =
|
||||
this.groupScopeIdFor(this.inputs()?.selectedGroup()) ??
|
||||
this.groupScopeIdFor(
|
||||
this.inputs()?.activeChannel()?.group?.title
|
||||
);
|
||||
if (groupScopeId) {
|
||||
this.scope.set(groupScopeId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.scope.set(SCOPE_ALL);
|
||||
}
|
||||
|
||||
/** The scope id for a group title, or null when it offers no rows. */
|
||||
private groupScopeIdFor(title: string | null | undefined): string | null {
|
||||
const trimmed = title?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const scopeId = `${GROUP_PREFIX}${trimmed}`;
|
||||
return this.scopes().some((scope) => scope.id === scopeId)
|
||||
? scopeId
|
||||
: null;
|
||||
}
|
||||
|
||||
setScope(id: string): void {
|
||||
if (this.scopes().some((scope) => scope.id === id)) {
|
||||
this.scope.set(id);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPrograms(
|
||||
window: EpgGuideWindow
|
||||
): Promise<Map<string, EpgProgram[]>> {
|
||||
const keyed = this.keyedChannels(window.channels);
|
||||
const result = new Map<string, EpgProgram[]>();
|
||||
if (keyed.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const response = await this.epgBridge.getProgramsForChannels({
|
||||
channelIds: Array.from(new Set(keyed.map(([, key]) => key))),
|
||||
fromMs: window.fromMs,
|
||||
toMs: window.toMs,
|
||||
});
|
||||
if (!response) {
|
||||
return result;
|
||||
}
|
||||
for (const [channel, key] of keyed) {
|
||||
result.set(channel.id, response[key] ?? []);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async loadCoverage(window: EpgGuideWindow): Promise<Set<string>> {
|
||||
const keyed = this.keyedChannels(window.channels);
|
||||
const covered = new Set<string>();
|
||||
if (keyed.length === 0) {
|
||||
return covered;
|
||||
}
|
||||
const response = await this.epgBridge.getProgramCoverage({
|
||||
channelIds: Array.from(new Set(keyed.map(([, key]) => key))),
|
||||
fromMs: window.fromMs,
|
||||
toMs: window.toMs,
|
||||
});
|
||||
const keys = new Set(response ?? []);
|
||||
for (const [channel, key] of keyed) {
|
||||
if (keys.has(key)) {
|
||||
covered.add(channel.id);
|
||||
}
|
||||
}
|
||||
return covered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the row id back to its own channel — the guide only ever passes
|
||||
* back ids it rendered from `channels()`, and a duplicated channel id can
|
||||
* only be told apart by that row id.
|
||||
*/
|
||||
activate(rowId: string): void {
|
||||
const channel = this.channelsByRowId().get(rowId);
|
||||
if (channel) {
|
||||
this.store.dispatch(createM3uChannelPlaybackRequest(channel));
|
||||
}
|
||||
}
|
||||
|
||||
/** Hits carry the row id only when an exact `epgKey` match exists in scope. */
|
||||
async searchPrograms(query: string): Promise<EpgGuideSearchHit[]> {
|
||||
const programs =
|
||||
(await this.epgBridge.searchPrograms(query, SEARCH_LIMIT)) ?? [];
|
||||
const byKey = new Map<string, EpgGuideChannel>();
|
||||
for (const channel of this.channels()) {
|
||||
// Duplicate rows share an EPG key; jump to the first, like
|
||||
// `activeChannelId`.
|
||||
if (channel.epgKey !== null && !byKey.has(channel.epgKey)) {
|
||||
byKey.set(channel.epgKey, channel);
|
||||
}
|
||||
}
|
||||
return programs.map((program) => {
|
||||
const row = byKey.get(program.channel);
|
||||
return {
|
||||
channelId: row?.id ?? null,
|
||||
// The playlist's own name when the row is known, else the
|
||||
// XMLTV display name the search joined in; never the raw id.
|
||||
channelName: row?.name ?? program.channelName ?? null,
|
||||
program,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private keyedChannels(
|
||||
channels: readonly EpgGuideChannel[]
|
||||
): Array<[EpgGuideChannel, string]> {
|
||||
return channels
|
||||
.filter((channel) => channel.epgKey !== null)
|
||||
.map((channel): [EpgGuideChannel, string] => [
|
||||
channel,
|
||||
channel.epgKey as string,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { of } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import { Settings, VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import {
|
||||
dataServiceMock,
|
||||
epgServiceMock,
|
||||
epgUrlSetting,
|
||||
epgViewMode,
|
||||
externalSession,
|
||||
player,
|
||||
playlistId,
|
||||
playlistsServiceMock,
|
||||
routerMock,
|
||||
sampleChannel,
|
||||
showCaptions,
|
||||
storeMock,
|
||||
stripCountryPrefix,
|
||||
syncStoreState,
|
||||
translateServiceProvider,
|
||||
} from './video-player.spec-harness';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
/**
|
||||
* What may NOT open the programme guide. The guide mounts in the page flow,
|
||||
* so a fullscreen element or a dialog paints over it while it still owns the
|
||||
* keyboard. Kept apart from `video-player.component.spec.ts`, which sits at
|
||||
* the spec line budget; the template is reduced to the panel's `ng-template`
|
||||
* so no child stubs are needed.
|
||||
*/
|
||||
describe('VideoPlayerComponent — guide gating', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
|
||||
function setFullscreenElement(element: Element | null): void {
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => element,
|
||||
});
|
||||
}
|
||||
|
||||
/** A stand-in for the inline live player, inside the component's host. */
|
||||
function mountPlayerView(): HTMLElement {
|
||||
const playerView = document.createElement('app-web-player-view');
|
||||
fixture.nativeElement.appendChild(playerView);
|
||||
return playerView;
|
||||
}
|
||||
|
||||
function pressGuideKey(target: EventTarget = document): void {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'g', bubbles: true })
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
setFullscreenElement(null);
|
||||
syncStoreState(sampleChannel);
|
||||
playlistId.set('playlist-1');
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
showCaptions.set(false);
|
||||
stripCountryPrefix.set(false);
|
||||
externalSession.set(null);
|
||||
storeMock.dispatch.mockClear();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: playlistId(), view: 'all' }),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
queryParams: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provide: Router, useValue: routerMock },
|
||||
{ provide: Store, useValue: storeMock },
|
||||
translateServiceProvider,
|
||||
{ provide: DataService, useValue: dataServiceMock },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: true,
|
||||
isElectron: false,
|
||||
supportsRemoteControl: false,
|
||||
},
|
||||
},
|
||||
{ provide: PlaylistsService, useValue: playlistsServiceMock },
|
||||
{ provide: EpgService, useValue: epgServiceMock },
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: { resolvedPlaylistId: playlistId },
|
||||
},
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled: () => false },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
player,
|
||||
showCaptions,
|
||||
stripCountryPrefix,
|
||||
m3uVodDetails: signal(true),
|
||||
resolvedEpgViewMode: epgViewMode,
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
epgUrl: epgUrlSetting,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: jest.fn(() =>
|
||||
of({ player: player() } as Partial<Settings>)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: externalSession },
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideComponent(VideoPlayerComponent, {
|
||||
set: {
|
||||
imports: [],
|
||||
template:
|
||||
'<ng-template #fullscreenChannelPanel></ng-template>',
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
setFullscreenElement(null);
|
||||
document
|
||||
.querySelectorAll('.cdk-overlay-container, [role="dialog"]')
|
||||
.forEach((element) => element.remove());
|
||||
});
|
||||
|
||||
it('leaves the guide closed while the live player owns fullscreen', () => {
|
||||
expect(component.canOpenGuide()).toBe(true);
|
||||
setFullscreenElement(mountPlayerView());
|
||||
|
||||
pressGuideKey();
|
||||
|
||||
// The fullscreen surface paints over the page: the guide would be
|
||||
// invisible there and still swallow the keys.
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
component.openGuide();
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
|
||||
setFullscreenElement(null);
|
||||
pressGuideKey();
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the guide closed while any element is fullscreen', () => {
|
||||
const other = document.createElement('div');
|
||||
document.body.appendChild(other);
|
||||
setFullscreenElement(other);
|
||||
|
||||
component.openGuide();
|
||||
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
other.remove();
|
||||
});
|
||||
|
||||
it('leaves the guide closed while a dialog is open', () => {
|
||||
const overlayContainer = document.createElement('div');
|
||||
overlayContainer.className = 'cdk-overlay-container';
|
||||
const dialog = document.createElement('div');
|
||||
dialog.setAttribute('role', 'dialog');
|
||||
const dialogButton = document.createElement('button');
|
||||
dialog.appendChild(dialogButton);
|
||||
overlayContainer.appendChild(dialog);
|
||||
document.body.appendChild(overlayContainer);
|
||||
|
||||
// The header action and the command palette go through `openGuide()`
|
||||
// without an event to inspect.
|
||||
component.openGuide();
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
|
||||
// A G from a focused control inside the dialog belongs to the dialog.
|
||||
pressGuideKey(dialogButton);
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
|
||||
overlayContainer.remove();
|
||||
pressGuideKey();
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
|
||||
// Once open, a G from inside a dialog must not close it either.
|
||||
document.body.appendChild(overlayContainer);
|
||||
pressGuideKey(dialogButton);
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
overlayContainer.remove();
|
||||
});
|
||||
});
|
||||
+2
-5
@@ -2,7 +2,6 @@ import { AsyncPipe } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import { translateServiceProvider } from './video-player.spec-harness';
|
||||
|
||||
// The component's chain reaches video.js (ui/playback → VjsPlayer); the CJS
|
||||
// bundle breaks under the ESM jest environment, so it is mocked before the
|
||||
@@ -167,10 +167,7 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => {
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: { position: jest.fn(), create: jest.fn() },
|
||||
},
|
||||
translateServiceProvider,
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, NO_ERRORS_SCHEMA, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
ComponentFixture,
|
||||
DeferBlockState,
|
||||
TestBed,
|
||||
} from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
@@ -38,6 +42,8 @@ import {
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import { translateServiceProvider } from './video-player.spec-harness';
|
||||
import { StubSidebarComponent } from './video-player.spec-stubs';
|
||||
|
||||
// The component's chain reaches video.js (ui/playback → VjsPlayer); the CJS
|
||||
// bundle breaks under the ESM jest environment, so it is mocked before the
|
||||
@@ -62,10 +68,12 @@ class StubChannelListHiddenStateComponent {
|
||||
* The collapsible channels rail of the M3U player. Separate from
|
||||
* `video-player.component.spec.ts` only because that file sits at the
|
||||
* max-lines test budget; the harness here is the minimal subset needed to
|
||||
* render the rail and the content-area empty states.
|
||||
* render the rail and the content-area empty states, plus the outputs the
|
||||
* rail forwards to the host.
|
||||
*/
|
||||
describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let M3uEpgGuideSourceService: typeof import('../epg-guide/m3u-epg-guide-source.service').M3uEpgGuideSourceService;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
let sidebarState: LiveLayoutSidebarStateService;
|
||||
@@ -77,6 +85,10 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
const channelsLoading = signal(false);
|
||||
const currentEpgProgram = signal<EpgProgram | null>(null);
|
||||
const activeEpgProgram = signal<EpgProgram | null>(null);
|
||||
const routeParams$ = new BehaviorSubject<Record<string, string>>({
|
||||
id: 'playlist-1',
|
||||
view: 'all',
|
||||
});
|
||||
const channels$ = new BehaviorSubject<Channel[]>([]);
|
||||
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
|
||||
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
|
||||
@@ -138,6 +150,8 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
({ M3uEpgGuideSourceService } =
|
||||
await import('../epg-guide/m3u-epg-guide-source.service'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -146,6 +160,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
activeChannel$.next(null);
|
||||
channels.set([]);
|
||||
channels$.next([]);
|
||||
routeParams$.next({ id: playlistId(), view: 'all' });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
@@ -153,7 +168,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: playlistId(), view: 'all' }),
|
||||
params: routeParams$.asObservable(),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
@@ -170,6 +185,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
translateServiceProvider,
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: { position: jest.fn(), create: jest.fn() },
|
||||
@@ -248,6 +264,7 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
StubChannelListHiddenStateComponent,
|
||||
StubSidebarComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
@@ -349,4 +366,43 @@ describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
expect(hiddenState()).toBeNull();
|
||||
expect(query('.video-player')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('opens the guide on the group the sidebar rail is showing', async () => {
|
||||
// The user can browse to another group before pressing the guide
|
||||
// shortcut; the playing channel's group is only the fallback.
|
||||
const makeChannel = (id: string, group: string) =>
|
||||
({
|
||||
id,
|
||||
url: `http://localhost/${id}.m3u8`,
|
||||
name: id,
|
||||
epgParams: '',
|
||||
radio: 'false',
|
||||
group: { title: group },
|
||||
tvg: { id: '', logo: '', name: id },
|
||||
}) as Channel;
|
||||
const newsChannel = makeChannel('news-1', 'News');
|
||||
const sportsChannel = makeChannel('sports-1', 'Sports');
|
||||
activeChannel.set(newsChannel);
|
||||
activeChannel$.next(newsChannel);
|
||||
channels.set([newsChannel, sportsChannel]);
|
||||
channels$.next([newsChannel, sportsChannel]);
|
||||
routeParams$.next({ id: playlistId(), view: 'groups' });
|
||||
fixture.detectChanges();
|
||||
// The rail sits behind an `@defer`; TestBed leaves such a block on its
|
||||
// placeholder until the test renders it.
|
||||
const [railBlock] = await fixture.getDeferBlocks();
|
||||
await railBlock.render(DeferBlockState.Complete);
|
||||
|
||||
fixture.debugElement
|
||||
.query(By.directive(StubSidebarComponent))
|
||||
.componentInstance.selectedGroupChange.emit('Sports');
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
|
||||
const guideSource = fixture.debugElement.injector.get(
|
||||
M3uEpgGuideSourceService
|
||||
);
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
expect(guideSource.scopeId()).toBe('group:Sports');
|
||||
});
|
||||
});
|
||||
+2
-5
@@ -29,8 +29,8 @@ import {
|
||||
PlaylistMeta,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import { translateServiceProvider } from './video-player.spec-harness';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
@@ -158,10 +158,7 @@ describe('VideoPlayerComponent fullscreen channel panel + zapping', () => {
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: { position: jest.fn(), create: jest.fn() },
|
||||
},
|
||||
translateServiceProvider,
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<!--
|
||||
Guide mode HIDES this sidebar instead of removing it: destroying it would
|
||||
destroy `app-channel-list-container`, whose `ngOnDestroy` dispatches
|
||||
`resetActiveChannel()` — that clears the active channel the guide itself
|
||||
needs, so the guide would close again the moment it opened. `inert` keeps
|
||||
the hidden rail out of the tab order and away from assistive technology.
|
||||
-->
|
||||
<div
|
||||
class="sidebar"
|
||||
[class.sidebar-collapsed]="isSidebarCollapsed()"
|
||||
[class.sidebar--guide-hidden]="guideOpen()"
|
||||
[attr.inert]="guideOpen() ? '' : null"
|
||||
appResizable
|
||||
[minWidth]="sidebarMinWidth"
|
||||
[maxWidth]="sidebarMaxWidth"
|
||||
@@ -35,6 +44,7 @@
|
||||
onGroupedSidebarWidthRequestEnded($event)
|
||||
"
|
||||
(sidebarToggleRequested)="toggleSidebar()"
|
||||
(selectedGroupChange)="onSidebarGroupSelected($event)"
|
||||
/>
|
||||
} @loading {
|
||||
<app-channel-list-loading-state
|
||||
@@ -49,8 +59,12 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="content-container">
|
||||
@if (isSidebarCollapsed()) {
|
||||
<div
|
||||
class="content-container"
|
||||
[class.is-guide]="guideOpen()"
|
||||
[class.is-guide-collapsed]="guideOpen() && guideDockCollapsed()"
|
||||
>
|
||||
@if (isSidebarCollapsed() && !guideOpen()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
@@ -82,7 +96,12 @@
|
||||
activeChannel.radio === 'true' ||
|
||||
shouldShowInlinePlayer(activeChannel)
|
||||
) {
|
||||
<div class="video-player">
|
||||
<div
|
||||
class="video-player"
|
||||
[attr.data-player-shortcuts-suspended]="
|
||||
guideOpen() ? '' : null
|
||||
"
|
||||
>
|
||||
@if (activeChannel.radio === 'true') {
|
||||
<app-audio-player
|
||||
[url]="activeChannel.url"
|
||||
@@ -110,10 +129,35 @@
|
||||
(recordingStopped)="onRecordingStopped($event)"
|
||||
/>
|
||||
}
|
||||
@if (guideOpen()) {
|
||||
<app-epg-guide-now-playing
|
||||
[channelName]="timelineChannelName()"
|
||||
[program]="guideNowPlayingProgram()"
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
[collapsed]="guideDockCollapsed()"
|
||||
(closeRequested)="closeGuide()"
|
||||
(collapsedChange)="setGuideDockCollapsed($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsEpg && activeChannel.radio !== 'true') {
|
||||
@if (guideOpen() && !shouldShowInlinePlayer(activeChannel)) {
|
||||
<div class="video-player guide-dock--external">
|
||||
<app-epg-guide-now-playing
|
||||
[channelName]="timelineChannelName()"
|
||||
[program]="guideNowPlayingProgram()"
|
||||
[offsetMinutes]="epgOffsetMinutes()"
|
||||
[collapsed]="true"
|
||||
[collapsible]="false"
|
||||
(closeRequested)="closeGuide()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (guideOpen()) {
|
||||
<app-epg-guide class="epg-guide-host" (close)="closeGuide()" />
|
||||
} @else if (supportsEpg && activeChannel.radio !== 'true') {
|
||||
@let canCollapseEpgPanel =
|
||||
shouldShowInlinePlayer(activeChannel);
|
||||
<div
|
||||
@@ -147,6 +191,7 @@
|
||||
liveEpgPanelSummaryLabelKey()
|
||||
"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
[guideAvailable]="canOpenGuide()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
@@ -158,6 +203,7 @@
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
(openGuide)="openGuide()"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
@@ -181,6 +227,7 @@
|
||||
liveEpgPanelSummaryLabelKey()
|
||||
"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
[guideAvailable]="canOpenGuide()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
@@ -192,6 +239,7 @@
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
(openGuide)="openGuide()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -145,3 +145,70 @@
|
||||
height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Guide mode ─────────────────────────────────────────────────────────────
|
||||
// The sidebar stays MOUNTED while the guide is open and is only hidden: its
|
||||
// channel-list container dispatches `resetActiveChannel()` from `ngOnDestroy`,
|
||||
// so removing it would clear the active channel the guide needs and close the
|
||||
// guide again. `!important` beats the resizable directive's inline width, and
|
||||
// it is declared after the phone breakpoint so the bottom-drawer rules
|
||||
// (`height: 50vh`, borders) cannot leave a phantom band on small screens.
|
||||
// Hiding rather than removing also preserves the list's scroll position.
|
||||
.sidebar.sidebar--guide-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// The player keeps its DOM position and is only re-flowed into a docked strip;
|
||||
// no remount, so playback (and native-view Embedded MPV bounds) survive.
|
||||
.content-container.is-guide {
|
||||
.video-player {
|
||||
flex: 0 0 auto;
|
||||
height: 128px;
|
||||
// The shared player mixin floors `.video-player` at 96px on phones;
|
||||
// the docked strip is 128px expanded and 48px collapsed, so the floor
|
||||
// has to go or the collapsed strip cannot shrink.
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 18px;
|
||||
padding: 14px 18px;
|
||||
box-sizing: border-box;
|
||||
background: var(--app-widget-bg);
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
|
||||
> app-web-player-view {
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
> app-epg-guide-now-playing {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-dock--external {
|
||||
height: 48px;
|
||||
padding: 6px 18px;
|
||||
}
|
||||
|
||||
.epg-guide-host {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.is-guide-collapsed .video-player {
|
||||
height: 48px;
|
||||
padding: 6px 18px;
|
||||
|
||||
> app-web-player-view {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+287
-246
@@ -1,12 +1,5 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
NO_ERRORS_SCHEMA,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -14,17 +7,8 @@ import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import {
|
||||
ChannelActions,
|
||||
EpgActions,
|
||||
selectActive,
|
||||
selectActiveEpgProgram,
|
||||
selectActivePlaybackUrl,
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { of } from 'rxjs';
|
||||
import { ChannelActions, EpgActions } from '@iptvnator/m3u-state';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
@@ -42,17 +26,52 @@ import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
ExternalPlayerSession,
|
||||
RecordingStartMetadata,
|
||||
RecordingStoppedEvent,
|
||||
ResolvedPortalPlayback,
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
|
||||
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
import { StubEpgTimelineComponent } from './video-player.spec-stubs';
|
||||
import {
|
||||
activeChannel,
|
||||
activeEpgProgram,
|
||||
buildAiringProgram,
|
||||
activePlaybackUrl,
|
||||
channels,
|
||||
channels$,
|
||||
activeChannel$,
|
||||
channelsLoading,
|
||||
currentEpgProgram,
|
||||
currentEpgProgram$,
|
||||
dataServiceMock,
|
||||
epgPrograms$,
|
||||
epgServiceMock,
|
||||
epgUrlSetting,
|
||||
epgViewMode,
|
||||
externalSession,
|
||||
player,
|
||||
playlistId,
|
||||
playlistsServiceMock,
|
||||
routerMock,
|
||||
sampleChannel,
|
||||
showCaptions,
|
||||
storeMock,
|
||||
stripCountryPrefix,
|
||||
syncStoreState,
|
||||
translateServiceProvider,
|
||||
} from './video-player.spec-harness';
|
||||
import {
|
||||
StubAudioPlayerComponent,
|
||||
StubChannelListLoadingStateComponent,
|
||||
StubEpgGuideComponent,
|
||||
StubEpgGuideNowPlayingComponent,
|
||||
StubEpgTimelineComponent,
|
||||
StubPortalEmptyStateComponent,
|
||||
StubResizableDirective,
|
||||
StubSidebarComponent,
|
||||
StubWebPlayerViewComponent,
|
||||
} from './video-player.spec-stubs';
|
||||
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
@@ -62,231 +81,27 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-loading-state',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubChannelListLoadingStateComponent {
|
||||
readonly view = input<string | null>(null);
|
||||
readonly showEpg = input(true);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubSidebarComponent {
|
||||
readonly channels = input<Channel[]>([]);
|
||||
readonly channelsLoading = input(false);
|
||||
readonly showPlaylistHeader = input(false);
|
||||
readonly activeView = input('');
|
||||
readonly sidebarWidth = input(0);
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-empty-state',
|
||||
standalone: true,
|
||||
template: '<div class="stub-empty-state">{{ message() }}</div>',
|
||||
})
|
||||
class StubPortalEmptyStateComponent {
|
||||
readonly icon = input('');
|
||||
readonly message = input('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-audio-player',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubAudioPlayerComponent {
|
||||
readonly url = input('');
|
||||
readonly icon = input('');
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
readonly volume = input<number | null>(null);
|
||||
readonly volumeChange = output<number>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
readonly recordingStopped = output<RecordingStoppedEvent>();
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
class StubResizableDirective {
|
||||
readonly minWidth = input(0);
|
||||
readonly maxWidth = input(0);
|
||||
readonly defaultWidth = input(0);
|
||||
readonly storageKey = input('');
|
||||
readonly widthChange = output<number>();
|
||||
readonly resizeEnd = output<number>();
|
||||
}
|
||||
|
||||
describe('VideoPlayerComponent', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
let headerContext: WorkspaceHeaderContextService;
|
||||
|
||||
const playlistId = signal('playlist-1');
|
||||
const activeChannel = signal<Channel | null>(null);
|
||||
const activePlaybackUrl = signal<string | null>(null);
|
||||
const channels = signal<Channel[]>([]);
|
||||
const channelsLoading = signal(false);
|
||||
const currentEpgProgram = signal<EpgProgram | null>(null);
|
||||
const activeEpgProgram = signal<EpgProgram | null>(null);
|
||||
|
||||
const channels$ = new BehaviorSubject<Channel[]>([]);
|
||||
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
|
||||
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
|
||||
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
|
||||
const epgServiceMock = {
|
||||
currentEpgPrograms$: epgPrograms$.asObservable(),
|
||||
getChannelMetadataForChannels: () => of(new Map()),
|
||||
};
|
||||
|
||||
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
|
||||
const showCaptions = signal(false);
|
||||
const stripCountryPrefix = signal(false);
|
||||
const epgViewMode = signal<'timeline' | 'list'>('timeline');
|
||||
const epgUrlSetting = signal<string[]>([]);
|
||||
const externalSession = signal<ExternalPlayerSession | null>(null);
|
||||
const originalElectron = window.electron;
|
||||
|
||||
const overlayRef = {
|
||||
attach: jest.fn().mockReturnValue({ instance: {} }),
|
||||
backdropClick: jest.fn().mockReturnValue(of(undefined)),
|
||||
dispose: jest.fn(),
|
||||
};
|
||||
const positionStrategy = {
|
||||
centerHorizontally: jest.fn().mockReturnThis(),
|
||||
centerVertically: jest.fn().mockReturnThis(),
|
||||
};
|
||||
const overlayMock = {
|
||||
position: jest.fn().mockReturnValue({
|
||||
global: jest.fn().mockReturnValue(positionStrategy),
|
||||
}),
|
||||
create: jest.fn().mockReturnValue(overlayRef),
|
||||
};
|
||||
|
||||
const storeMock = {
|
||||
dispatch: jest.fn(),
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectActive:
|
||||
return activeChannel;
|
||||
case selectActivePlaybackUrl:
|
||||
return activePlaybackUrl;
|
||||
case selectChannels:
|
||||
return channels;
|
||||
case selectChannelsLoading:
|
||||
return channelsLoading;
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram;
|
||||
case selectActiveEpgProgram:
|
||||
return activeEpgProgram;
|
||||
default:
|
||||
return signal(null);
|
||||
}
|
||||
}),
|
||||
select: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectChannels:
|
||||
return channels$.asObservable();
|
||||
case selectActive:
|
||||
return activeChannel$.asObservable();
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram$.asObservable();
|
||||
default:
|
||||
return of(null);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const routerMock = {
|
||||
url: '/workspace/playlists/playlist-1/all',
|
||||
navigate: jest.fn(),
|
||||
currentNavigation: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
const playlistsServiceMock = {
|
||||
getPlaylist: jest.fn(() =>
|
||||
of({
|
||||
playlist: {
|
||||
items: channels(),
|
||||
},
|
||||
favorites: [],
|
||||
})
|
||||
),
|
||||
getPlaylistWithGlobalFavorites: jest.fn(() =>
|
||||
of({
|
||||
playlist: {
|
||||
items: [],
|
||||
},
|
||||
favorites: [],
|
||||
})
|
||||
),
|
||||
addM3uRecentlyViewed: jest.fn(() =>
|
||||
of({
|
||||
recentlyViewed: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
const dataServiceMock = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
|
||||
const sampleChannel: Channel = {
|
||||
id: 'channel-1',
|
||||
url: 'http://localhost/live.m3u8',
|
||||
name: 'Sample TV',
|
||||
epgParams: '',
|
||||
radio: 'false',
|
||||
tvg: {
|
||||
id: 'sample-tvg-id',
|
||||
logo: 'http://localhost/logo.png',
|
||||
name: 'Sample TV',
|
||||
},
|
||||
} as Channel;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
});
|
||||
|
||||
function syncStoreState(channel: Channel | null): void {
|
||||
activeChannel.set(channel);
|
||||
activeChannel$.next(channel);
|
||||
channels.set(channel ? [channel] : []);
|
||||
channels$.next(channel ? [channel] : []);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
onChannelChange: jest.fn(() => jest.fn()),
|
||||
onRemoteControlCommand: jest.fn(() => jest.fn()),
|
||||
} as typeof window.electron;
|
||||
getEpgProgramsForChannels: jest.fn().mockResolvedValue({}),
|
||||
getEpgProgramCoverage: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as typeof window.electron;
|
||||
|
||||
syncStoreState(null);
|
||||
playlistId.set('playlist-1');
|
||||
@@ -302,9 +117,6 @@ describe('VideoPlayerComponent', () => {
|
||||
externalSession.set(null);
|
||||
currentEpgProgram$.next(null);
|
||||
epgPrograms$.next([]);
|
||||
overlayMock.create.mockClear();
|
||||
overlayRef.attach.mockClear();
|
||||
overlayRef.dispose.mockClear();
|
||||
routerMock.currentNavigation.mockReturnValue(null);
|
||||
routerMock.navigate.mockClear();
|
||||
storeMock.dispatch.mockClear();
|
||||
@@ -333,10 +145,7 @@ describe('VideoPlayerComponent', () => {
|
||||
provide: Store,
|
||||
useValue: storeMock,
|
||||
},
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: overlayMock,
|
||||
},
|
||||
translateServiceProvider,
|
||||
{
|
||||
provide: DataService,
|
||||
useValue: dataServiceMock,
|
||||
@@ -354,8 +163,7 @@ describe('VideoPlayerComponent', () => {
|
||||
// remote-control bridge method must be present.
|
||||
get supportsRemoteControl() {
|
||||
const bridge = window.electron as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
Record<string, unknown> | undefined;
|
||||
return [
|
||||
'updateRemoteControlStatus',
|
||||
'onChannelChange',
|
||||
@@ -417,6 +225,8 @@ describe('VideoPlayerComponent', () => {
|
||||
AsyncPipe,
|
||||
StubAudioPlayerComponent,
|
||||
StubChannelListLoadingStateComponent,
|
||||
StubEpgGuideComponent,
|
||||
StubEpgGuideNowPlayingComponent,
|
||||
StubEpgTimelineComponent,
|
||||
StubPortalEmptyStateComponent,
|
||||
StubResizableDirective,
|
||||
@@ -442,13 +252,13 @@ describe('VideoPlayerComponent', () => {
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('registers and clears the workspace multi-EPG header shortcut', () => {
|
||||
it('registers and clears the workspace guide header shortcut', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(headerContext.action()).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'm3u-multi-epg',
|
||||
icon: 'view_list',
|
||||
id: 'm3u-epg-guide',
|
||||
icon: 'grid_view',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -456,6 +266,235 @@ describe('VideoPlayerComponent', () => {
|
||||
expect(headerContext.action()).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the guide, hides the sidebar without unmounting it, and keeps the player', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
const playerBefore = fixture.nativeElement.querySelector(
|
||||
'app-web-player-view'
|
||||
);
|
||||
expect(playerBefore).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).not.toBeNull();
|
||||
const sidebarBefore = fixture.nativeElement.querySelector('.sidebar');
|
||||
expect(sidebarBefore).not.toBeNull();
|
||||
storeMock.dispatch.mockClear();
|
||||
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-guide')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-guide-now-playing')
|
||||
).not.toBeNull();
|
||||
// Regression: the sidebar used to sit behind `@if (!guideOpen())`, so
|
||||
// opening the guide destroyed the channel-list container, whose
|
||||
// `ngOnDestroy` dispatches `resetActiveChannel()`. That cleared the
|
||||
// active channel and closed the guide again the moment it opened.
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
ChannelActions.resetActiveChannel()
|
||||
);
|
||||
const sidebarWhileOpen =
|
||||
fixture.nativeElement.querySelector('.sidebar');
|
||||
expect(sidebarWhileOpen).toBe(sidebarBefore);
|
||||
expect(sidebarWhileOpen.classList).toContain('sidebar--guide-hidden');
|
||||
expect(sidebarWhileOpen.hasAttribute('inert')).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.content-container').classList
|
||||
).toContain('is-guide');
|
||||
expect(fixture.nativeElement.querySelector('app-web-player-view')).toBe(
|
||||
playerBefore
|
||||
);
|
||||
expect(headerContext.action()?.active?.()).toBe(true);
|
||||
|
||||
component.closeGuide();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('app-epg-guide')).toBeNull();
|
||||
const sidebarAfter = fixture.nativeElement.querySelector('.sidebar');
|
||||
expect(sidebarAfter).toBe(sidebarBefore);
|
||||
expect(sidebarAfter.classList).not.toContain('sidebar--guide-hidden');
|
||||
expect(sidebarAfter.hasAttribute('inert')).toBe(false);
|
||||
expect(fixture.nativeElement.querySelector('app-web-player-view')).toBe(
|
||||
playerBefore
|
||||
);
|
||||
});
|
||||
|
||||
it('toggles the guide with G, ignores typing, and lets the guide own other keys', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'g', bubbles: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
|
||||
storeMock.dispatch.mockClear();
|
||||
const pageDown = new KeyboardEvent('keydown', {
|
||||
key: 'PageDown',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(pageDown);
|
||||
expect(pageDown.defaultPrevented).toBe(false);
|
||||
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: expect.stringContaining('setActiveChannel'),
|
||||
})
|
||||
);
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'G', bubbles: true })
|
||||
);
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'g', bubbles: true })
|
||||
);
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('closes the guide when the live player enters fullscreen or the channel turns to radio', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
|
||||
const playerView = fixture.nativeElement.querySelector(
|
||||
'app-web-player-view'
|
||||
) as HTMLElement;
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => playerView,
|
||||
});
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => null,
|
||||
});
|
||||
|
||||
component.openGuide();
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
syncStoreState({ ...sampleChannel, radio: 'true' } as Channel);
|
||||
fixture.detectChanges();
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('remembers the collapsed dock strip', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
component.setGuideDockCollapsed(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.content-container').classList
|
||||
).toContain('is-guide-collapsed');
|
||||
expect(localStorage.getItem('epg-guide:dock-collapsed')).toBe('1');
|
||||
localStorage.removeItem('epg-guide:dock-collapsed');
|
||||
});
|
||||
|
||||
it('suspends the docked player shortcuts while the guide is open', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
const dock = fixture.nativeElement.querySelector(
|
||||
'.video-player'
|
||||
) as HTMLElement;
|
||||
expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe(
|
||||
false
|
||||
);
|
||||
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe(true);
|
||||
|
||||
component.closeGuide();
|
||||
fixture.detectChanges();
|
||||
expect(dock.hasAttribute('data-player-shortcuts-suspended')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('derives the docked strip programme from the active channel schedule', () => {
|
||||
// The NgRx `currentEpgProgram` retains the previous channel's value
|
||||
// across a switch, so the strip must not read it.
|
||||
currentEpgProgram.set(buildAiringProgram('Retained Show'));
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
epgPrograms$.next([buildAiringProgram('Sample Now')]);
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
const stripProgram = () =>
|
||||
fixture.debugElement
|
||||
.query(By.directive(StubEpgGuideNowPlayingComponent))
|
||||
.componentInstance.program();
|
||||
|
||||
expect(stripProgram()).toEqual(
|
||||
expect.objectContaining({ title: 'Sample Now' })
|
||||
);
|
||||
|
||||
syncStoreState({
|
||||
...sampleChannel,
|
||||
id: 'channel-2',
|
||||
url: 'http://localhost/other.m3u8',
|
||||
name: 'Other TV',
|
||||
tvg: { id: 'other-tvg-id', name: 'Other TV', logo: '' },
|
||||
} as Channel);
|
||||
epgPrograms$.next([buildAiringProgram('Other Now', 'other-tvg-id')]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(stripProgram()).toEqual(
|
||||
expect.objectContaining({ title: 'Other Now' })
|
||||
);
|
||||
|
||||
epgPrograms$.next([]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(stripProgram()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables the guide header action while nothing can host the guide', () => {
|
||||
syncStoreState(null);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(headerContext.action()?.disabled?.()).toBe(true);
|
||||
|
||||
syncStoreState(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(headerContext.action()?.disabled?.()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes the guide when the active playlist changes', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
fixture.detectChanges();
|
||||
component.openGuide();
|
||||
fixture.detectChanges();
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
|
||||
playlistId.set('playlist-2');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('strips country prefixes from the timeline and player titles when enabled', () => {
|
||||
stripCountryPrefix.set(true);
|
||||
syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel);
|
||||
@@ -516,7 +555,7 @@ describe('VideoPlayerComponent', () => {
|
||||
epgViewMode.set('timeline'); // restore for sibling tests
|
||||
});
|
||||
|
||||
it('hides EPG controls and the multi-EPG header action in browser/PWA playback', () => {
|
||||
it('hides EPG controls and the guide header action in browser/PWA playback', () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
@@ -1220,13 +1259,15 @@ describe('VideoPlayerComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the registered header shortcut callback to open multi EPG', () => {
|
||||
it('reuses the registered header shortcut callback to toggle the guide', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
headerContext.action()?.run();
|
||||
expect(component.guideOpen()).toBe(true);
|
||||
|
||||
expect(overlayMock.create).toHaveBeenCalledTimes(1);
|
||||
expect(overlayRef.attach).toHaveBeenCalledTimes(1);
|
||||
headerContext.action()?.run();
|
||||
expect(component.guideOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('switches channels by number through a playback request', () => {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
|
||||
import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import { AsyncPipe, CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
Injector,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
TemplateRef,
|
||||
@@ -38,14 +35,17 @@ import {
|
||||
} from '@iptvnator/shared/m3u-utils';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
COMPONENT_OVERLAY_REF,
|
||||
EPG_GUIDE_SOURCE,
|
||||
EpgDateNavigationDirection,
|
||||
EpgGuideComponent,
|
||||
EpgGuideNowPlayingComponent,
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
EpgTimelineComponent,
|
||||
EpgTimelineEmptyReason,
|
||||
getTodayEpgDateKey,
|
||||
MultiEpgContainerComponent,
|
||||
persistEpgGuideDockCollapsed,
|
||||
restoreEpgGuideDockCollapsed,
|
||||
shiftEpgDateKey,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
selectFavorites,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import {
|
||||
firstValueFrom,
|
||||
@@ -136,11 +137,12 @@ import {
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { M3uEpgGuideSourceService } from '../epg-guide/m3u-epg-guide-source.service';
|
||||
import { M3uVodDetailComponent } from '../m3u-vod-detail/m3u-vod-detail.component';
|
||||
import { M3uFullscreenChannelListComponent } from './fullscreen-channel-list/m3u-fullscreen-channel-list.component';
|
||||
import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions';
|
||||
|
||||
const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
|
||||
const M3U_EPG_GUIDE_HEADER_ACTION_ID = 'm3u-epg-guide';
|
||||
const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width';
|
||||
const M3U_GROUPS_SIDEBAR_STORAGE_KEY = 'm3u-groups-sidebar-width';
|
||||
const M3U_SIDEBAR_MIN_WIDTH = 200;
|
||||
@@ -194,6 +196,8 @@ function isInsideScrollableRegion(
|
||||
AudioPlayerComponent,
|
||||
ChannelListLoadingStateComponent,
|
||||
CommonModule,
|
||||
EpgGuideComponent,
|
||||
EpgGuideNowPlayingComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
M3uFullscreenChannelListComponent,
|
||||
@@ -215,6 +219,8 @@ function isInsideScrollableRegion(
|
||||
provide: FULLSCREEN_CHANNEL_PANEL,
|
||||
useExisting: forwardRef(() => VideoPlayerComponent),
|
||||
},
|
||||
M3uEpgGuideSourceService,
|
||||
{ provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService },
|
||||
],
|
||||
templateUrl: './video-player.component.html',
|
||||
styleUrl: './video-player.component.scss',
|
||||
@@ -225,7 +231,6 @@ export class VideoPlayerComponent
|
||||
private readonly activatedRoute = inject(ActivatedRoute);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly dataService = inject(DataService);
|
||||
private readonly overlay = inject(Overlay);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly playlistContext = inject(PlaylistContextFacade);
|
||||
private readonly router = inject(Router);
|
||||
@@ -302,6 +307,49 @@ export class VideoPlayerComponent
|
||||
readonly fullscreenPanelChannels = computed(() =>
|
||||
this.channels().filter((channel) => this.keepsInlinePlayer(channel))
|
||||
);
|
||||
private readonly guideSource = inject(M3uEpgGuideSourceService);
|
||||
/** Guide mode: sidebar and timeline give way to the multi-channel grid. */
|
||||
readonly guideOpen = signal(false);
|
||||
readonly guideDockCollapsed = signal(restoreEpgGuideDockCollapsed());
|
||||
/** Rows the guide may show: everything that keeps the live host mounted. */
|
||||
readonly guideChannels = computed(() =>
|
||||
this.channels().filter(
|
||||
(channel) =>
|
||||
channel.radio !== 'true' && !this.opensMovieDetail(channel)
|
||||
)
|
||||
);
|
||||
/**
|
||||
* Group the sidebar's groups view is showing, mirrored from its
|
||||
* `selectedGroupChange` output. The guide opens on the group the user was
|
||||
* browsing, which is not necessarily the playing channel's.
|
||||
*/
|
||||
readonly selectedSidebarGroup = signal<string | null>(null);
|
||||
readonly canOpenGuide = computed(() => {
|
||||
const channel = this.activeChannel();
|
||||
return (
|
||||
this.supportsEpg &&
|
||||
!!channel &&
|
||||
channel.radio !== 'true' &&
|
||||
!this.showMovieDetail()
|
||||
);
|
||||
});
|
||||
/**
|
||||
* Programme shown in the docked strip (catch-up selection wins). The
|
||||
* live fallback is derived from the ACTIVE channel's own schedule and the
|
||||
* 30 s clock, exactly like {@link recordingMetadata} — the NgRx
|
||||
* `currentEpgProgram` keeps its last value across a channel switch and
|
||||
* through EPG gaps, so reading it here left the strip advertising the
|
||||
* previous channel's programme.
|
||||
*/
|
||||
readonly guideNowPlayingProgram = computed<EpgProgram | null>(
|
||||
() =>
|
||||
this.activeEpgProgramOrNull() ??
|
||||
findCurrentEpgProgram(
|
||||
this.epgPrograms(),
|
||||
epgProviderClockMs(this.epgNowMs(), this.epgOffsetMinutes())
|
||||
) ??
|
||||
null
|
||||
);
|
||||
readonly archivePlaybackAvailable = computed(() =>
|
||||
isM3uCatchupPlaybackSupported(this.activeChannel())
|
||||
);
|
||||
@@ -633,8 +681,6 @@ export class VideoPlayerComponent
|
||||
readonly supportsEpg = this.runtime.supportsEpg;
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.activatedRoute);
|
||||
|
||||
/** EPG overlay reference */
|
||||
private overlayRef!: OverlayRef;
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private statusSubscription?: Subscription;
|
||||
@@ -677,6 +723,36 @@ export class VideoPlayerComponent
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.guideSource.bind({
|
||||
channels: this.guideChannels,
|
||||
favoriteKeys: this.store.selectSignal(selectFavorites),
|
||||
// `selectActive` is `Channel | undefined`; the contract is
|
||||
// nullable, so normalize rather than widen the shared type.
|
||||
activeChannel: computed(() => this.activeChannel() ?? null),
|
||||
selectedGroup: this.selectedSidebarGroup.asReadonly(),
|
||||
activePlaybackUrl: this.activePlaybackUrl,
|
||||
});
|
||||
// Radio, a recognised movie, or a lost channel takes the guide's host
|
||||
// (and its player) away — close instead of leaving it stranded.
|
||||
effect(() => {
|
||||
if (!this.canOpenGuide()) {
|
||||
untracked(() => this.closeGuide());
|
||||
}
|
||||
});
|
||||
|
||||
// Switching to another playlist keeps this component mounted, so an
|
||||
// open guide would survive and show the new playlist's channels under
|
||||
// the previous one's initial scope. Close it and let the user reopen.
|
||||
let lastGuidePlaylistId = this.activePlaylistId();
|
||||
effect(() => {
|
||||
const playlistId = this.activePlaylistId();
|
||||
if (playlistId === lastGuidePlaylistId) {
|
||||
return;
|
||||
}
|
||||
lastGuidePlaylistId = playlistId;
|
||||
untracked(() => this.closeGuide());
|
||||
});
|
||||
|
||||
// React to settings changes
|
||||
effect(() => {
|
||||
this.playerSettings = {
|
||||
@@ -975,7 +1051,7 @@ export class VideoPlayerComponent
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.workspaceHeaderContext.clearAction(M3U_MULTI_EPG_HEADER_ACTION_ID);
|
||||
this.workspaceHeaderContext.clearAction(M3U_EPG_GUIDE_HEADER_ACTION_ID);
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
this.statusSubscription?.unsubscribe();
|
||||
@@ -1159,57 +1235,73 @@ export class VideoPlayerComponent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the overlay with multi EPG view
|
||||
*/
|
||||
openMultiEpgView(): void {
|
||||
if (!this.supportsEpg) {
|
||||
onSidebarGroupSelected(group: string | null): void {
|
||||
this.selectedSidebarGroup.set(group);
|
||||
}
|
||||
|
||||
openGuide(): void {
|
||||
if (!this.canOpenGuide() || this.guideOpen() || this.isGuideBlocked()) {
|
||||
return;
|
||||
}
|
||||
this.guideSource.applyInitialScope(this.activeView());
|
||||
this.guideOpen.set(true);
|
||||
}
|
||||
|
||||
const positionStrategy = this.overlay
|
||||
.position()
|
||||
.global()
|
||||
.centerHorizontally()
|
||||
.centerVertically();
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
hasBackdrop: true,
|
||||
positionStrategy,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: COMPONENT_OVERLAY_REF,
|
||||
useValue: this.overlayRef,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const portal = new ComponentPortal(
|
||||
MultiEpgContainerComponent,
|
||||
null,
|
||||
injector
|
||||
/**
|
||||
* The guide mounts in the page flow, so anything painting over the page
|
||||
* would hide it while it still swallowed the keyboard: a fullscreen
|
||||
* element (the live player's own, which has the fullscreen channel panel
|
||||
* instead — see {@link onFullscreenChange} — or any other) and an open
|
||||
* CDK dialog. Gated here rather than in the key handler alone so the
|
||||
* header action and the command palette are covered too.
|
||||
*/
|
||||
private isGuideBlocked(): boolean {
|
||||
return (
|
||||
!!document.fullscreenElement ||
|
||||
!!document.querySelector('.cdk-overlay-container [role="dialog"]')
|
||||
);
|
||||
}
|
||||
|
||||
const componentRef = this.overlayRef.attach(portal);
|
||||
componentRef.instance.playlistChannels = this.store.select(
|
||||
selectChannels
|
||||
) as Observable<Channel[]>;
|
||||
/** Whether the event was raised inside a menu, dialog or CDK overlay. */
|
||||
private isInsideOverlaySurface(event: KeyboardEvent): boolean {
|
||||
return event
|
||||
.composedPath()
|
||||
.some(
|
||||
(target) =>
|
||||
target instanceof Element &&
|
||||
target.matches(
|
||||
'.cdk-overlay-pane, [role="menu"], [role="dialog"]'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Pass the active channel's tvg.id for highlighting
|
||||
const currentChannel = this.activeChannel();
|
||||
if (currentChannel) {
|
||||
componentRef.instance.activeChannelId =
|
||||
currentChannel.tvg?.id || null;
|
||||
closeGuide(): void {
|
||||
this.guideOpen.set(false);
|
||||
}
|
||||
|
||||
toggleGuide(): void {
|
||||
if (this.guideOpen()) {
|
||||
this.closeGuide();
|
||||
} else {
|
||||
this.openGuide();
|
||||
}
|
||||
}
|
||||
|
||||
this.overlayRef.backdropClick().subscribe(() => {
|
||||
this.overlayRef.dispose();
|
||||
});
|
||||
setGuideDockCollapsed(collapsed: boolean): void {
|
||||
this.guideDockCollapsed.set(collapsed);
|
||||
persistEpgGuideDockCollapsed(collapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* The guide and the fullscreen channel panel are mutually exclusive: the
|
||||
* fullscreen surface paints over the whole page, so a guide left open
|
||||
* behind it would be invisible and still swallow keys on exit.
|
||||
*/
|
||||
@HostListener('document:fullscreenchange')
|
||||
onFullscreenChange(): void {
|
||||
if (this.guideOpen() && this.isLivePlayerFullscreen()) {
|
||||
this.closeGuide();
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
@@ -1224,6 +1316,31 @@ export class VideoPlayerComponent
|
||||
if (this.hostElement.nativeElement.closest('[inert]')) {
|
||||
return;
|
||||
}
|
||||
const isGuideKey =
|
||||
event.key.toLowerCase() === 'g' &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey;
|
||||
if (this.guideOpen()) {
|
||||
// The guide owns the keyboard while open; only G toggles it off,
|
||||
// and never from a dialog or menu that owns the keystroke.
|
||||
if (isGuideKey && !this.isInsideOverlaySurface(event)) {
|
||||
event.preventDefault();
|
||||
this.closeGuide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isGuideKey && this.canOpenGuide()) {
|
||||
// A G from a focused control inside a dialog or menu belongs to
|
||||
// that surface, not to the page behind it.
|
||||
if (!this.isInsideOverlaySurface(event)) {
|
||||
this.openGuide();
|
||||
}
|
||||
if (this.guideOpen()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === 'b'
|
||||
@@ -1239,15 +1356,7 @@ export class VideoPlayerComponent
|
||||
// the one way to change channels from the keyboard in fullscreen.
|
||||
if (event.key === 'PageUp' || event.key === 'PageDown') {
|
||||
if (
|
||||
event
|
||||
.composedPath()
|
||||
.some(
|
||||
(target) =>
|
||||
target instanceof Element &&
|
||||
target.matches(
|
||||
'.cdk-overlay-pane, [role="menu"], [role="dialog"]'
|
||||
)
|
||||
) ||
|
||||
this.isInsideOverlaySurface(event) ||
|
||||
isInsideScrollableRegion(
|
||||
event.target,
|
||||
this.hostElement.nativeElement
|
||||
@@ -1556,18 +1665,22 @@ export class VideoPlayerComponent
|
||||
}
|
||||
|
||||
this.workspaceHeaderContext.setAction({
|
||||
id: M3U_MULTI_EPG_HEADER_ACTION_ID,
|
||||
icon: 'view_list',
|
||||
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
|
||||
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
|
||||
id: M3U_EPG_GUIDE_HEADER_ACTION_ID,
|
||||
icon: 'grid_view',
|
||||
tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
active: () => this.guideOpen(),
|
||||
// Nothing to dock (no channel, radio, a recognised movie): the
|
||||
// header button and the palette command would both open nothing.
|
||||
disabled: () => !this.canOpenGuide(),
|
||||
palette: {
|
||||
labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
|
||||
labelKey: 'TOP_MENU.OPEN_EPG_GUIDE',
|
||||
descriptionKey:
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
|
||||
'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION',
|
||||
keywords: ['epg', 'guide', 'schedule'],
|
||||
priority: 10,
|
||||
},
|
||||
run: () => this.openMultiEpgView(),
|
||||
run: () => this.toggleGuide(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
selectActive,
|
||||
selectActiveEpgProgram,
|
||||
selectActivePlaybackUrl,
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
selectFavorites,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
ExternalPlayerSession,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { BehaviorSubject, EMPTY, of } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Shared state and collaborator mocks for the `VideoPlayerComponent` spec.
|
||||
* They live beside the spec rather than inside it because the suite is at the
|
||||
* 1200-line test cap; the signals are module singletons, which is safe because
|
||||
* Jest gives every spec file its own module registry.
|
||||
*/
|
||||
export const playlistId = signal('playlist-1');
|
||||
export const activeChannel = signal<Channel | null>(null);
|
||||
export const activePlaybackUrl = signal<string | null>(null);
|
||||
export const channels = signal<Channel[]>([]);
|
||||
export const channelsLoading = signal(false);
|
||||
export const currentEpgProgram = signal<EpgProgram | null>(null);
|
||||
export const activeEpgProgram = signal<EpgProgram | null>(null);
|
||||
export const favoriteIds = signal<string[]>([]);
|
||||
|
||||
export const channels$ = new BehaviorSubject<Channel[]>([]);
|
||||
export const activeChannel$ = new BehaviorSubject<Channel | null>(null);
|
||||
export const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
|
||||
export const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
|
||||
export const epgServiceMock = {
|
||||
currentEpgPrograms$: epgPrograms$.asObservable(),
|
||||
getChannelMetadataForChannels: () => of(new Map()),
|
||||
};
|
||||
|
||||
export const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
|
||||
export const showCaptions = signal(false);
|
||||
export const stripCountryPrefix = signal(false);
|
||||
export const epgViewMode = signal<'timeline' | 'list'>('timeline');
|
||||
export const epgUrlSetting = signal<string[]>([]);
|
||||
export const externalSession = signal<ExternalPlayerSession | null>(null);
|
||||
export const storeMock = {
|
||||
dispatch: jest.fn(),
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectActive:
|
||||
return activeChannel;
|
||||
case selectActivePlaybackUrl:
|
||||
return activePlaybackUrl;
|
||||
case selectChannels:
|
||||
return channels;
|
||||
case selectChannelsLoading:
|
||||
return channelsLoading;
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram;
|
||||
case selectActiveEpgProgram:
|
||||
return activeEpgProgram;
|
||||
case selectFavorites:
|
||||
return favoriteIds;
|
||||
default:
|
||||
return signal(null);
|
||||
}
|
||||
}),
|
||||
select: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectChannels:
|
||||
return channels$.asObservable();
|
||||
case selectActive:
|
||||
return activeChannel$.asObservable();
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram$.asObservable();
|
||||
default:
|
||||
return of(null);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export const routerMock = {
|
||||
url: '/workspace/playlists/playlist-1/all',
|
||||
navigate: jest.fn(),
|
||||
currentNavigation: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
export const playlistsServiceMock = {
|
||||
getPlaylist: jest.fn(() =>
|
||||
of({
|
||||
playlist: {
|
||||
items: channels(),
|
||||
},
|
||||
favorites: [],
|
||||
})
|
||||
),
|
||||
getPlaylistWithGlobalFavorites: jest.fn(() =>
|
||||
of({
|
||||
playlist: {
|
||||
items: [],
|
||||
},
|
||||
favorites: [],
|
||||
})
|
||||
),
|
||||
addM3uRecentlyViewed: jest.fn(() =>
|
||||
of({
|
||||
recentlyViewed: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
export const dataServiceMock = {
|
||||
sendIpcEvent: jest.fn(),
|
||||
};
|
||||
|
||||
/**
|
||||
* `VideoPlayerComponent` provides `M3uEpgGuideSourceService`, which reads its
|
||||
* scope labels through `TranslateService.instant` and re-computes them on
|
||||
* `onLangChange`. Every fixture of the host therefore needs the real service
|
||||
* token — a mocked `TranslatePipe` alone leaves it unprovided (NG0201) — so
|
||||
* the provider lives here and is shared by all four host specs.
|
||||
*/
|
||||
export const translateServiceProvider = {
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
instant: (key: string) => key,
|
||||
onLangChange: EMPTY,
|
||||
},
|
||||
};
|
||||
|
||||
export const sampleChannel: Channel = {
|
||||
id: 'channel-1',
|
||||
url: 'http://localhost/live.m3u8',
|
||||
name: 'Sample TV',
|
||||
epgParams: '',
|
||||
radio: 'false',
|
||||
tvg: {
|
||||
id: 'sample-tvg-id',
|
||||
logo: 'http://localhost/logo.png',
|
||||
name: 'Sample TV',
|
||||
},
|
||||
} as Channel;
|
||||
|
||||
export function syncStoreState(channel: Channel | null): void {
|
||||
activeChannel.set(channel);
|
||||
activeChannel$.next(channel);
|
||||
channels.set(channel ? [channel] : []);
|
||||
channels$.next(channel ? [channel] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programme covering "now" on `channel`. The docked guide strip and the
|
||||
* recording snapshot both derive the live programme from the active channel's
|
||||
* own schedule, so their assertions need a window the clock falls inside.
|
||||
*/
|
||||
export function buildAiringProgram(
|
||||
title: string,
|
||||
channel = 'sample-tvg-id'
|
||||
): EpgProgram {
|
||||
const startMs = Date.now() - 10 * 60_000;
|
||||
const stopMs = startMs + 60 * 60_000;
|
||||
return {
|
||||
start: new Date(startMs).toISOString(),
|
||||
stop: new Date(stopMs).toISOString(),
|
||||
channel,
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
startTimestamp: Math.floor(startMs / 1000),
|
||||
stopTimestamp: Math.floor(stopMs / 1000),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { Component, Directive, input, output } from '@angular/core';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
RecordingStartMetadata,
|
||||
RecordingStoppedEvent,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
|
||||
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||
|
||||
/**
|
||||
@@ -28,6 +35,7 @@ export class StubEpgTimelineComponent {
|
||||
readonly collapsed = input(false);
|
||||
readonly summary = input<LiveEpgPanelSummary | null>(null);
|
||||
readonly summaryLabelKey = input('');
|
||||
readonly guideAvailable = input(false);
|
||||
readonly programActivated = output<{
|
||||
program?: EpgProgram;
|
||||
type: 'timeshift' | 'live';
|
||||
@@ -37,4 +45,111 @@ export class StubEpgTimelineComponent {
|
||||
readonly openEpgSettings = output<void>();
|
||||
readonly retry = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
readonly openGuide = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-loading-state',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
export class StubChannelListLoadingStateComponent {
|
||||
readonly view = input<string | null>(null);
|
||||
readonly showEpg = input(true);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
export class StubSidebarComponent {
|
||||
readonly channels = input<Channel[]>([]);
|
||||
readonly channelsLoading = input(false);
|
||||
readonly showPlaylistHeader = input(false);
|
||||
readonly activeView = input('');
|
||||
readonly sidebarWidth = input(0);
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
readonly selectedGroupChange = output<string | null>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-empty-state',
|
||||
standalone: true,
|
||||
template: '<div class="stub-empty-state">{{ message() }}</div>',
|
||||
})
|
||||
export class StubPortalEmptyStateComponent {
|
||||
readonly icon = input('');
|
||||
readonly message = input('');
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-audio-player',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
export class StubAudioPlayerComponent {
|
||||
readonly url = input('');
|
||||
readonly icon = input('');
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
readonly volume = input<number | null>(null);
|
||||
readonly volumeChange = output<number>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-web-player-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
export class StubWebPlayerViewComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly streamUrl = input('');
|
||||
readonly title = input('');
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly recordingMetadata = input<RecordingStartMetadata | null>(null);
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
readonly recordingStopped = output<RecordingStoppedEvent>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-guide',
|
||||
standalone: true,
|
||||
template: '<div class="stub-guide"></div>',
|
||||
})
|
||||
export class StubEpgGuideComponent {
|
||||
readonly close = output<void>();
|
||||
readonly channelActivated = output<string>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-epg-guide-now-playing',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
export class StubEpgGuideNowPlayingComponent {
|
||||
readonly channelName = input('');
|
||||
readonly program = input<EpgProgram | null>(null);
|
||||
readonly offsetMinutes = input(0);
|
||||
readonly collapsed = input(false);
|
||||
readonly collapsible = input(true);
|
||||
readonly closeRequested = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[appResizable]',
|
||||
standalone: true,
|
||||
})
|
||||
export class StubResizableDirective {
|
||||
readonly minWidth = input(0);
|
||||
readonly maxWidth = input(0);
|
||||
readonly defaultWidth = input(0);
|
||||
readonly storageKey = input('');
|
||||
readonly widthChange = output<number>();
|
||||
readonly resizeEnd = output<number>();
|
||||
}
|
||||
@@ -6,6 +6,14 @@ export interface WorkspaceHeaderAction {
|
||||
icon: string;
|
||||
tooltipKey: string;
|
||||
ariaLabelKey: string;
|
||||
/** Pressed/highlighted state for toggling actions (e.g. the guide). */
|
||||
active?: () => boolean;
|
||||
/**
|
||||
* Whether the action currently cannot run (e.g. the guide with no live
|
||||
* channel to dock). Disables the header button and its palette command,
|
||||
* so neither entry point promises something the host would refuse.
|
||||
*/
|
||||
disabled?: () => boolean;
|
||||
run: () => void;
|
||||
palette?: WorkspaceHeaderCommandMetadata;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ import {
|
||||
} from './workspace-header-context.service';
|
||||
import { WorkspaceCommandContribution } from './workspace-view-command.types';
|
||||
|
||||
/** A palette flag may be a value or a getter; `undefined` means "not set". */
|
||||
function resolveCommandFlag(
|
||||
flag: boolean | (() => boolean | undefined) | undefined
|
||||
): boolean | undefined {
|
||||
return typeof flag === 'function' ? flag() : flag;
|
||||
}
|
||||
|
||||
interface RegisteredWorkspaceCommand {
|
||||
token: symbol;
|
||||
command: WorkspaceCommandContribution;
|
||||
@@ -17,21 +24,26 @@ export class WorkspaceViewCommandService {
|
||||
[]
|
||||
);
|
||||
|
||||
readonly commands = computed<readonly WorkspaceCommandContribution[]>(() => {
|
||||
const commands = this.registeredCommands().map(
|
||||
(entry) => entry.command
|
||||
);
|
||||
const headerAction = this.headerContext.action();
|
||||
const headerCommand = headerAction
|
||||
? this.toHeaderCommand(headerAction)
|
||||
: null;
|
||||
readonly commands = computed<readonly WorkspaceCommandContribution[]>(
|
||||
() => {
|
||||
const commands = this.registeredCommands().map(
|
||||
(entry) => entry.command
|
||||
);
|
||||
const headerAction = this.headerContext.action();
|
||||
const headerCommand = headerAction
|
||||
? this.toHeaderCommand(headerAction)
|
||||
: null;
|
||||
|
||||
return headerCommand ? [headerCommand, ...commands] : commands;
|
||||
});
|
||||
return headerCommand ? [headerCommand, ...commands] : commands;
|
||||
}
|
||||
);
|
||||
|
||||
registerCommand(command: WorkspaceCommandContribution): () => void {
|
||||
const token = Symbol(command.id);
|
||||
this.registeredCommands.update((entries) => [...entries, { token, command }]);
|
||||
this.registeredCommands.update((entries) => [
|
||||
...entries,
|
||||
{ token, command },
|
||||
]);
|
||||
|
||||
return () => {
|
||||
this.registeredCommands.update((entries) =>
|
||||
@@ -56,7 +68,13 @@ export class WorkspaceViewCommandService {
|
||||
priority: palette?.priority ?? 0,
|
||||
keywords: palette?.keywords,
|
||||
visible: palette?.visible,
|
||||
enabled: palette?.enabled,
|
||||
// The header action's own gate wins: a disabled button must not
|
||||
// leave a runnable command behind in the palette.
|
||||
enabled: action.disabled
|
||||
? () =>
|
||||
!action.disabled?.() &&
|
||||
(resolveCommandFlag(palette?.enabled) ?? true)
|
||||
: palette?.enabled,
|
||||
run: () => action.run(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsEpgChannelMetadata).toBe(false);
|
||||
expect(service.supportsEpgSourceFreshness).toBe(false);
|
||||
expect(service.supportsEpgDataManagement).toBe(false);
|
||||
expect(service.supportsEpgChannelBrowser).toBe(false);
|
||||
expect(service.supportsEpgGuide).toBe(false);
|
||||
expect(service.supportsEpgProgramSearch).toBe(false);
|
||||
});
|
||||
|
||||
@@ -145,7 +145,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
forceFetchEpg: jest.fn(),
|
||||
clearEpgData: jest.fn(),
|
||||
clearEpgDataForSource: jest.fn(),
|
||||
getEpgChannelsByRange: jest.fn(),
|
||||
getEpgProgramsForChannels: jest.fn(),
|
||||
getEpgProgramCoverage: jest.fn(),
|
||||
searchEpgPrograms: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -179,7 +180,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsEpgChannelMetadata).toBe(true);
|
||||
expect(service.supportsEpgSourceFreshness).toBe(true);
|
||||
expect(service.supportsEpgDataManagement).toBe(true);
|
||||
expect(service.supportsEpgChannelBrowser).toBe(true);
|
||||
expect(service.supportsEpgGuide).toBe(true);
|
||||
expect(service.supportsEpgProgramSearch).toBe(true);
|
||||
});
|
||||
|
||||
@@ -216,7 +217,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsEpgChannelMetadata).toBe(false);
|
||||
expect(service.supportsEpgSourceFreshness).toBe(false);
|
||||
expect(service.supportsEpgDataManagement).toBe(false);
|
||||
expect(service.supportsEpgChannelBrowser).toBe(false);
|
||||
expect(service.supportsEpgGuide).toBe(false);
|
||||
expect(service.supportsEpgProgramSearch).toBe(false);
|
||||
});
|
||||
|
||||
@@ -343,7 +344,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
forceFetchEpg: jest.fn(),
|
||||
clearEpgData: jest.fn(),
|
||||
clearEpgDataForSource: jest.fn(),
|
||||
getEpgChannelsByRange: jest.fn(),
|
||||
getEpgProgramsForChannels: jest.fn(),
|
||||
getEpgProgramCoverage: jest.fn(),
|
||||
searchEpgPrograms: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -359,7 +361,8 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
forceFetchEpg: jest.fn(),
|
||||
clearEpgData: jest.fn(),
|
||||
clearEpgDataForSource: jest.fn(),
|
||||
getEpgChannelsByRange: jest.fn(),
|
||||
getEpgProgramsForChannels: jest.fn(),
|
||||
getEpgProgramCoverage: jest.fn(),
|
||||
searchEpgPrograms: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -373,7 +376,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsEpgChannelMetadata).toBe(false);
|
||||
expect(service.supportsEpgSourceFreshness).toBe(true);
|
||||
expect(service.supportsEpgDataManagement).toBe(true);
|
||||
expect(service.supportsEpgChannelBrowser).toBe(true);
|
||||
expect(service.supportsEpgGuide).toBe(true);
|
||||
expect(service.supportsEpgProgramSearch).toBe(true);
|
||||
|
||||
testWindow.electron = {
|
||||
@@ -385,10 +388,20 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsEpgImport).toBe(false);
|
||||
expect(service.supportsEpgProgramLookup).toBe(false);
|
||||
expect(service.supportsEpgDataManagement).toBe(false);
|
||||
expect(service.supportsEpgChannelBrowser).toBe(false);
|
||||
expect(service.supportsEpgGuide).toBe(false);
|
||||
expect(service.supportsEpgProgramSearch).toBe(false);
|
||||
});
|
||||
|
||||
it('does not report guide support with only one of the two guide reads', () => {
|
||||
testWindow.electron = {
|
||||
getEpgProgramsForChannels: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new RuntimeCapabilitiesService();
|
||||
|
||||
expect(service.supportsEpgGuide).toBe(false);
|
||||
});
|
||||
|
||||
it('requires the complete downloads preload surface', () => {
|
||||
testWindow.electron = {
|
||||
downloadsGetList: jest.fn(),
|
||||
|
||||
@@ -97,7 +97,7 @@ export class RuntimeCapabilitiesService {
|
||||
this.supportsEpgProgramLookup &&
|
||||
this.supportsEpgSourceFreshness &&
|
||||
this.supportsEpgDataManagement &&
|
||||
this.supportsEpgChannelBrowser &&
|
||||
this.supportsEpgGuide &&
|
||||
this.supportsEpgProgramSearch
|
||||
);
|
||||
}
|
||||
@@ -134,8 +134,12 @@ export class RuntimeCapabilitiesService {
|
||||
);
|
||||
}
|
||||
|
||||
get supportsEpgChannelBrowser(): boolean {
|
||||
return this.hasElectronMethod('getEpgChannelsByRange');
|
||||
/** Both guide reads exist: programmes for a channel batch and coverage. */
|
||||
get supportsEpgGuide(): boolean {
|
||||
return (
|
||||
this.hasElectronMethod('getEpgProgramsForChannels') &&
|
||||
this.hasElectronMethod('getEpgProgramCoverage')
|
||||
);
|
||||
}
|
||||
|
||||
get supportsEpgProgramSearch(): boolean {
|
||||
|
||||
@@ -357,6 +357,11 @@ export interface ElectronBridgeEpgMapping {
|
||||
playlistId: string | null;
|
||||
}
|
||||
|
||||
/** A programme-search hit: the programme plus the XMLTV channel's display name. */
|
||||
export interface ElectronBridgeEpgSearchProgram extends EpgProgram {
|
||||
channelName: string | null;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgSearchResult {
|
||||
id: string;
|
||||
displayName: string;
|
||||
@@ -379,6 +384,18 @@ export interface ElectronBridgeCurrentProgramsOptions extends ElectronBridgeEpgL
|
||||
nowMs?: number;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgGuideWindow extends ElectronBridgeEpgLookupOptions {
|
||||
/**
|
||||
* Playlist channel lookup keys (tvg-id, else name), ≤100 keys for
|
||||
* programmes, ≤2000 for coverage; larger batches are truncated and the
|
||||
* cut keys are absent from the answer.
|
||||
*/
|
||||
channelIds: string[];
|
||||
/** Provider-clock window bounds in epoch ms. */
|
||||
fromMs: number;
|
||||
toMs: number;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgProgressStats {
|
||||
totalChannels: number;
|
||||
totalPrograms: number;
|
||||
@@ -406,11 +423,6 @@ export interface ElectronBridgeEpgChannelListResult {
|
||||
programs: [];
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgChannelWithPrograms extends ElectronBridgeEpgChannelSummary {
|
||||
iconUrl: string | null;
|
||||
programs: EpgProgram[];
|
||||
}
|
||||
|
||||
export interface ElectronBridgeDbOperationEvent {
|
||||
operationId?: string;
|
||||
operation: string;
|
||||
@@ -803,10 +815,12 @@ export interface ElectronBridgeApi {
|
||||
options?: ElectronBridgeEpgLookupOptions
|
||||
) => Promise<Record<string, EpgChannelMetadata | null>>;
|
||||
getEpgChannels: () => Promise<ElectronBridgeEpgChannelListResult>;
|
||||
getEpgChannelsByRange: (
|
||||
skip: number,
|
||||
limit: number
|
||||
) => Promise<ElectronBridgeEpgChannelWithPrograms[]>;
|
||||
getEpgProgramsForChannels: (
|
||||
window: ElectronBridgeEpgGuideWindow
|
||||
) => Promise<Record<string, EpgProgram[]>>;
|
||||
getEpgProgramCoverage: (
|
||||
window: ElectronBridgeEpgGuideWindow
|
||||
) => Promise<string[]>;
|
||||
forceFetchEpg: (
|
||||
url: string,
|
||||
options?: ElectronBridgeTrustOptions
|
||||
@@ -821,7 +835,7 @@ export interface ElectronBridgeApi {
|
||||
searchEpgPrograms: (
|
||||
searchTerm: string,
|
||||
limit?: number
|
||||
) => Promise<EpgProgram[]>;
|
||||
) => Promise<ElectronBridgeEpgSearchProgram[]>;
|
||||
|
||||
// EPG channel mapping (manual user overrides)
|
||||
getEpgMapping: (
|
||||
|
||||
@@ -4,9 +4,6 @@ export const EPG_FETCH_DONE = 'EPG:FETCH_DONE';
|
||||
export const EPG_ERROR = 'EPG:ERROR';
|
||||
export const EPG_GET_CHANNELS = 'EPG:GET_CHANNELS';
|
||||
export const EPG_GET_CHANNELS_DONE = 'EPG:GET_CHANNELS_DONE';
|
||||
export const EPG_GET_CHANNELS_BY_RANGE = 'EPG:EPG_GET_CHANNELS_BY_RANGE';
|
||||
export const EPG_GET_CHANNELS_BY_RANGE_RESPONSE =
|
||||
'EPG:EPG_GET_CHANNELS_BY_RANGE_RESPONSE';
|
||||
export const EPG_FORCE_FETCH = 'EPG:EPG_FORCE_FETCH';
|
||||
|
||||
// Playlist related commands
|
||||
|
||||
+1
@@ -70,6 +70,7 @@
|
||||
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
|
||||
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
|
||||
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
|
||||
(selectedGroupChange)="selectedGroupChange.emit($event)"
|
||||
/>
|
||||
}
|
||||
@case ('favorites') {
|
||||
|
||||
@@ -211,6 +211,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
|
||||
readonly sidebarWidthRequested = output<number>();
|
||||
readonly sidebarWidthRequestEnded = output<number>();
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
/** Groups view only: the group whose channels are currently listed. */
|
||||
readonly selectedGroupChange = output<string | null>();
|
||||
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
|
||||
private readonly routeSearchTerm = queryParamSignal(
|
||||
this.route,
|
||||
|
||||
+17
@@ -362,6 +362,23 @@ describe('GroupsViewComponent', () => {
|
||||
expect(component.selectedGroupKey()).toBe('Movies');
|
||||
});
|
||||
|
||||
it('reports the shown group for auto-selection and manual clicks', () => {
|
||||
const selected: Array<string | null> = [];
|
||||
component.selectedGroupChange.subscribe((key) => selected.push(key));
|
||||
|
||||
setInputs({ activeChannelUrl: worldUpdate.url });
|
||||
expect(selected).toEqual(['News']);
|
||||
|
||||
component.selectGroup('Movies');
|
||||
fixture.detectChanges();
|
||||
expect(selected).toEqual(['News', 'Movies']);
|
||||
|
||||
// Re-selecting the shown group is not a change.
|
||||
component.selectGroup('Movies');
|
||||
fixture.detectChanges();
|
||||
expect(selected).toEqual(['News', 'Movies']);
|
||||
});
|
||||
|
||||
it('retains a visible manual selection and falls back to the first visible group', () => {
|
||||
component.selectGroup('Movies');
|
||||
fixture.detectChanges();
|
||||
|
||||
+15
@@ -167,6 +167,14 @@ export class GroupsViewComponent {
|
||||
/** Emits when the user clicks the inline collapse toggle in the groups header */
|
||||
readonly sidebarToggleRequested = output<void>();
|
||||
|
||||
/**
|
||||
* The group currently shown in the channel pane, by title. Emitted for
|
||||
* both manual clicks and the auto-selection below, so a host can follow
|
||||
* what the user is looking at — the M3U player seeds the programme
|
||||
* guide's initial scope from it.
|
||||
*/
|
||||
readonly selectedGroupChange = output<string | null>();
|
||||
|
||||
readonly isGroupSearchOpen = signal(false);
|
||||
readonly localGroupSearchTerm = signal('');
|
||||
readonly selectedGroupKey = signal<string | null>(null);
|
||||
@@ -229,6 +237,13 @@ export class GroupsViewComponent {
|
||||
}
|
||||
});
|
||||
|
||||
// A signal effect only re-runs when the value actually changes, so
|
||||
// this emits once per selection — `selectGroup()` included, since it
|
||||
// writes the same signal.
|
||||
effect(() => {
|
||||
this.selectedGroupChange.emit(this.selectedGroupKey());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const selectedGroupKey = this.selectedGroupKey();
|
||||
if (selectedGroupKey == null) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
|
||||
* native traffic lights instead.
|
||||
*
|
||||
* Rendered once in the app root so the buttons stay reachable above
|
||||
* full-window content such as the multi-EPG overlay and dialog backdrops —
|
||||
* full-window content such as dialog backdrops —
|
||||
* mirroring how the macOS traffic lights float above everything. CDK
|
||||
* overlays render as popovers in the browser top layer (above any
|
||||
* z-index), so the host is itself a manual popover and re-enters the top
|
||||
@@ -91,7 +91,7 @@ export class WindowControlsComponent implements AfterViewInit {
|
||||
/**
|
||||
* Re-enter the top layer after any other popover opens: top-layer
|
||||
* elements paint in insertion order, so hiding and re-showing puts the
|
||||
* controls back above freshly opened CDK overlays (dialogs, multi-EPG).
|
||||
* controls back above freshly opened CDK overlays (dialogs, menus).
|
||||
*/
|
||||
private readonly onDocumentToggle = (event: Event): void => {
|
||||
const newState = (event as { newState?: string }).newState;
|
||||
|
||||
@@ -23,5 +23,8 @@ export {
|
||||
} from './lib/epg-timeline/epg-summary.util';
|
||||
export * from './lib/epg-progress-panel/epg-progress-panel.component';
|
||||
export * from './lib/epg-source-status/epg-source-status.component';
|
||||
export * from './lib/multi-epg/multi-epg-container.component';
|
||||
export * from './lib/multi-epg/overlay-ref.token';
|
||||
export * from './lib/epg-guide/epg-guide-source';
|
||||
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';
|
||||
@@ -21,4 +21,9 @@ $live-text: color-mix(
|
||||
var(--app-on-surface)
|
||||
);
|
||||
$accent-live: var(--app-live-color);
|
||||
// Text/icons ON a filled `$accent-live` surface. The content background is the
|
||||
// live colour's counterpart in both themes — near-black behind the light
|
||||
// theme's red and near-white behind the dark theme's pink — so it clears 4.5:1
|
||||
// either way, where a fixed dark literal only worked in the light theme.
|
||||
$on-live: var(--app-content-bg);
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
@@ -0,0 +1,205 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { of } from 'rxjs';
|
||||
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import {
|
||||
EpgGuideDialogController,
|
||||
EpgGuideDialogHost,
|
||||
} from './epg-guide-dialog.controller';
|
||||
import { EpgGuideCatchUp, EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
const START_MS = Date.UTC(2026, 0, 15, 20, 0);
|
||||
|
||||
const CHANNELS: EpgGuideChannel[] = [
|
||||
{ id: 'a', number: 1, name: 'Channel a', logoUrl: null, epgKey: 'a' },
|
||||
{ id: 'b', number: 2, name: 'Channel b', logoUrl: 'logo.png', epgKey: 'b' },
|
||||
];
|
||||
|
||||
function program(overrides: Partial<EpgProgram> = {}): EpgProgram {
|
||||
return {
|
||||
start: new Date(START_MS).toISOString(),
|
||||
stop: new Date(START_MS + 30 * MINUTE_MS).toISOString(),
|
||||
channel: 'a',
|
||||
title: 'Programme',
|
||||
desc: null,
|
||||
category: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function block(
|
||||
when: 'past' | 'now' | 'future',
|
||||
canCatchUp = false
|
||||
): TimelineRenderBlock {
|
||||
return {
|
||||
kind: 'block',
|
||||
key: 'k',
|
||||
block: {
|
||||
program: program(),
|
||||
key: 'k',
|
||||
startMs: START_MS,
|
||||
stopMs: START_MS + 30 * MINUTE_MS,
|
||||
when,
|
||||
offsetMin: 0,
|
||||
durationMin: 30,
|
||||
},
|
||||
leftPx: 0,
|
||||
widthPx: 100,
|
||||
tier: 'wide',
|
||||
nowFillPercent: 0,
|
||||
canCatchUp,
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
controller: EpgGuideDialogController;
|
||||
open: jest.Mock;
|
||||
focusRow: jest.Mock;
|
||||
activate: jest.Mock;
|
||||
watch: jest.Mock;
|
||||
offsetMinutes: number;
|
||||
}
|
||||
|
||||
function harness(offsetMinutes = 0): Harness {
|
||||
const open = jest.fn(() => of(undefined));
|
||||
const focusRow = jest.fn();
|
||||
const activate = jest.fn();
|
||||
const watch = jest.fn();
|
||||
const catchUp: EpgGuideCatchUp = { canWatch: () => true, watch };
|
||||
const state = { offsetMinutes };
|
||||
const host: EpgGuideDialogHost = {
|
||||
rows: () => CHANNELS,
|
||||
offsetMinutes: () => state.offsetMinutes,
|
||||
focusRow,
|
||||
activate,
|
||||
catchUp: () => catchUp,
|
||||
};
|
||||
return {
|
||||
controller: new EpgGuideDialogController(
|
||||
{ open } as unknown as EpgProgrammeDialogService,
|
||||
host
|
||||
),
|
||||
open,
|
||||
focusRow,
|
||||
activate,
|
||||
watch,
|
||||
offsetMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EpgGuideDialogController', () => {
|
||||
it('labels a card dialog with its channel and offers the live action', () => {
|
||||
const test = harness();
|
||||
test.open.mockReturnValueOnce(of('live'));
|
||||
|
||||
test.controller.openDetails(CHANNELS[1], block('now'));
|
||||
|
||||
expect(test.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelName: 'Channel b',
|
||||
channelLogo: 'logo.png',
|
||||
primaryAction: 'live',
|
||||
archiveUnavailableNote: false,
|
||||
})
|
||||
);
|
||||
expect(test.activate).toHaveBeenCalledWith(CHANNELS[1]);
|
||||
});
|
||||
|
||||
it('offers timeshift for a recorded past card and plays it back', () => {
|
||||
const test = harness();
|
||||
test.open.mockReturnValueOnce(of('timeshift'));
|
||||
const item = block('past', true);
|
||||
|
||||
test.controller.openDetails(CHANNELS[0], item);
|
||||
|
||||
expect(test.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
primaryAction: 'timeshift',
|
||||
archiveUnavailableNote: false,
|
||||
})
|
||||
);
|
||||
expect(test.watch).toHaveBeenCalledWith(
|
||||
CHANNELS[0],
|
||||
item.block.program
|
||||
);
|
||||
});
|
||||
|
||||
it('notes a past card that cannot be replayed, and ignores a missing one', () => {
|
||||
const test = harness();
|
||||
|
||||
test.controller.openDetails(CHANNELS[0], block('past'));
|
||||
expect(test.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
primaryAction: null,
|
||||
archiveUnavailableNote: true,
|
||||
})
|
||||
);
|
||||
|
||||
test.open.mockClear();
|
||||
test.controller.openDetails(CHANNELS[0], undefined);
|
||||
test.controller.openDetails(undefined, block('now'));
|
||||
expect(test.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends a catch-up request straight to the host', () => {
|
||||
const test = harness();
|
||||
const item = block('past', true);
|
||||
|
||||
test.controller.watch(CHANNELS[0], item);
|
||||
|
||||
expect(test.watch).toHaveBeenCalledWith(
|
||||
CHANNELS[0],
|
||||
item.block.program
|
||||
);
|
||||
expect(test.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('focuses the row a search hit belongs to, and copes without one', () => {
|
||||
const test = harness();
|
||||
|
||||
test.controller.openSearchResult({
|
||||
channelId: 'b',
|
||||
program: program({ channel: 'b' }),
|
||||
});
|
||||
|
||||
expect(test.focusRow).toHaveBeenCalledWith(1);
|
||||
expect(test.open).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channelName: 'Channel b' })
|
||||
);
|
||||
|
||||
test.focusRow.mockClear();
|
||||
test.open.mockClear();
|
||||
test.controller.openSearchResult({
|
||||
channelId: null,
|
||||
program: program({ channel: 'x' }),
|
||||
});
|
||||
|
||||
expect(test.focusRow).not.toHaveBeenCalled();
|
||||
expect(test.open).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ channelName: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it('shifts a search hit start by the EPG display offset', () => {
|
||||
const test = harness(60);
|
||||
|
||||
expect(
|
||||
test.controller.searchHitStartMs({
|
||||
channelId: 'a',
|
||||
program: program(),
|
||||
})
|
||||
).toBe(START_MS + 60 * MINUTE_MS);
|
||||
|
||||
// A unix timestamp wins over the ISO string, offset included.
|
||||
expect(
|
||||
test.controller.searchHitStartMs({
|
||||
channelId: 'a',
|
||||
program: program({
|
||||
start: 'not a date',
|
||||
startTimestamp: START_MS / 1000,
|
||||
}),
|
||||
})
|
||||
).toBe(START_MS + 60 * MINUTE_MS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { getProgramTimeMs } from '../epg-program.utils';
|
||||
import { EpgItemDialogAction } from '../epg-item-description/epg-item-description.component';
|
||||
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import {
|
||||
EpgGuideCatchUp,
|
||||
EpgGuideChannel,
|
||||
EpgGuideSearchHit,
|
||||
} from './epg-guide-source';
|
||||
|
||||
export interface EpgGuideDialogHost {
|
||||
/** The rows currently rendered, in display order. */
|
||||
rows(): readonly EpgGuideChannel[];
|
||||
/** EPG display offset in minutes; search hits carry provider instants. */
|
||||
offsetMinutes(): number;
|
||||
/** Move the keyboard focus to a row and scroll it into view. */
|
||||
focusRow(rowIndex: number): void;
|
||||
/** Switch playback to the channel (a no-op when it already plays). */
|
||||
activate(channel: EpgGuideChannel): void;
|
||||
catchUp(): EpgGuideCatchUp | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every programme-dialog entry point of the guide: a card's details, a
|
||||
* catch-up request and a search hit. Split out of the shell component, which
|
||||
* owns rows, focus and playback and supplies them through the host.
|
||||
*/
|
||||
export class EpgGuideDialogController {
|
||||
constructor(
|
||||
private readonly programmeDialog: EpgProgrammeDialogService,
|
||||
private readonly host: EpgGuideDialogHost
|
||||
) {}
|
||||
|
||||
openDetails(
|
||||
channel: EpgGuideChannel | undefined,
|
||||
item: TimelineRenderBlock | undefined
|
||||
): void {
|
||||
if (!channel || !item) {
|
||||
return;
|
||||
}
|
||||
const when = item.block.when;
|
||||
this.programmeDialog
|
||||
.open({
|
||||
...item.block.program,
|
||||
channelName: channel.name,
|
||||
channelLogo: channel.logoUrl,
|
||||
primaryAction:
|
||||
when === 'now'
|
||||
? 'live'
|
||||
: item.canCatchUp
|
||||
? 'timeshift'
|
||||
: null,
|
||||
archiveUnavailableNote: when === 'past' && !item.canCatchUp,
|
||||
})
|
||||
.subscribe((result: EpgItemDialogAction | undefined) => {
|
||||
if (result === 'live') {
|
||||
this.host.activate(channel);
|
||||
} else if (result === 'timeshift') {
|
||||
this.host.catchUp()?.watch(channel, item.block.program);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(channel: EpgGuideChannel, item: TimelineRenderBlock): void {
|
||||
this.host.catchUp()?.watch(channel, item.block.program);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a search hit. When the host resolved the hit's row, focus and reveal
|
||||
* it and label the dialog with that channel; an unresolved hit still opens,
|
||||
* just without a channel.
|
||||
*/
|
||||
openSearchResult(hit: EpgGuideSearchHit): void {
|
||||
const rows = this.host.rows();
|
||||
const rowIndex =
|
||||
hit.channelId === null
|
||||
? -1
|
||||
: rows.findIndex((row) => row.id === hit.channelId);
|
||||
const channel = rowIndex < 0 ? null : rows[rowIndex];
|
||||
if (channel) {
|
||||
this.host.focusRow(rowIndex);
|
||||
}
|
||||
this.programmeDialog
|
||||
.open(
|
||||
channel
|
||||
? {
|
||||
...hit.program,
|
||||
channelName: channel.name,
|
||||
channelLogo: channel.logoUrl,
|
||||
}
|
||||
: {
|
||||
...hit.program,
|
||||
channelName: hit.channelName ?? null,
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* A hit's start as the guide displays it. Results come straight from the
|
||||
* host's EPG store, so they carry provider instants and must go through
|
||||
* the same display-offset shift as every other time in the guide.
|
||||
*/
|
||||
searchHitStartMs(hit: EpgGuideSearchHit): number {
|
||||
return getProgramTimeMs(
|
||||
hit.program.start,
|
||||
hit.program.startTimestamp,
|
||||
this.host.offsetMinutes()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
EpgGuideKeyboardController,
|
||||
EpgGuideKeyboardHost,
|
||||
} from './epg-guide-keyboard.controller';
|
||||
|
||||
function key(
|
||||
keyName: string,
|
||||
init: Partial<KeyboardEventInit> & { target?: EventTarget } = {}
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { key: keyName, ...init });
|
||||
if (init.target) {
|
||||
Object.defineProperty(event, 'target', { value: init.target });
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('EpgGuideKeyboardController', () => {
|
||||
let host: jest.Mocked<EpgGuideKeyboardHost>;
|
||||
let controller: EpgGuideKeyboardController;
|
||||
|
||||
beforeEach(() => {
|
||||
host = {
|
||||
rowCount: jest.fn(() => 5),
|
||||
blockCount: jest.fn(() => 3),
|
||||
activeRow: jest.fn(() => 2),
|
||||
isBlocked: jest.fn(() => false),
|
||||
isOwnedTarget: jest.fn(() => true),
|
||||
play: jest.fn(),
|
||||
details: jest.fn(),
|
||||
jumpNow: jest.fn(),
|
||||
stepDay: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
controller = new EpgGuideKeyboardController(host);
|
||||
});
|
||||
|
||||
it('starts row focus from the active channel and clamps at the ends', () => {
|
||||
expect(controller.handle(key('ArrowDown'))).toBe(true);
|
||||
expect(controller.focus()).toEqual({ row: 3, block: null });
|
||||
controller.handle(key('ArrowDown'));
|
||||
controller.handle(key('ArrowDown'));
|
||||
controller.handle(key('ArrowDown'));
|
||||
expect(controller.focus()).toEqual({ row: 4, block: null });
|
||||
host.activeRow.mockReturnValue(-1);
|
||||
controller.focus.set(null);
|
||||
controller.handle(key('ArrowUp'));
|
||||
expect(controller.focus()).toEqual({ row: 4, block: null });
|
||||
});
|
||||
|
||||
it('moves block focus inside the focused row', () => {
|
||||
controller.handle(key('ArrowRight'));
|
||||
expect(controller.focus()).toEqual({ row: 2, block: 0 });
|
||||
controller.handle(key('ArrowRight'));
|
||||
controller.handle(key('ArrowRight'));
|
||||
controller.handle(key('ArrowRight'));
|
||||
expect(controller.focus()).toEqual({ row: 2, block: 2 });
|
||||
controller.handle(key('ArrowLeft'));
|
||||
expect(controller.focus()).toEqual({ row: 2, block: 1 });
|
||||
controller.handle(key('ArrowDown'));
|
||||
expect(controller.focus()).toEqual({ row: 3, block: null });
|
||||
});
|
||||
|
||||
it('plays the focused (or active) row on Enter and opens details with I', () => {
|
||||
controller.handle(key('Enter'));
|
||||
expect(host.play).toHaveBeenCalledWith(2);
|
||||
controller.handle(key('ArrowDown'));
|
||||
controller.handle(key('ArrowRight'));
|
||||
controller.handle(key('i'));
|
||||
expect(host.details).toHaveBeenCalledWith(3, 0);
|
||||
controller.handle(key('Enter'));
|
||||
expect(host.play).toHaveBeenLastCalledWith(3);
|
||||
});
|
||||
|
||||
it('maps N, PageUp/PageDown and Escape', () => {
|
||||
controller.handle(key('n'));
|
||||
expect(host.jumpNow).toHaveBeenCalled();
|
||||
controller.handle(key('PageUp'));
|
||||
expect(host.stepDay).toHaveBeenCalledWith('prev');
|
||||
controller.handle(key('PageDown'));
|
||||
expect(host.stepDay).toHaveBeenCalledWith('next');
|
||||
expect(controller.handle(key('Escape'))).toBe(true);
|
||||
expect(host.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores typing, modifier chords, blocked state and unknown keys', () => {
|
||||
const input = document.createElement('input');
|
||||
expect(controller.handle(key('ArrowDown', { target: input }))).toBe(
|
||||
false
|
||||
);
|
||||
expect(controller.handle(key('ArrowDown', { ctrlKey: true }))).toBe(
|
||||
false
|
||||
);
|
||||
host.isBlocked.mockReturnValue(true);
|
||||
expect(controller.handle(key('Escape'))).toBe(false);
|
||||
host.isBlocked.mockReturnValue(false);
|
||||
expect(controller.handle(key('x'))).toBe(false);
|
||||
expect(host.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores keys aimed at a control the guide does not own', () => {
|
||||
const button = document.createElement('button');
|
||||
expect(controller.handle(key('Enter', { target: button }))).toBe(false);
|
||||
expect(host.play).not.toHaveBeenCalled();
|
||||
|
||||
const icon = document.createElement('mat-icon');
|
||||
button.appendChild(icon);
|
||||
expect(controller.handle(key('Enter', { target: icon }))).toBe(false);
|
||||
expect(host.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still handles keys on its own grid surfaces', () => {
|
||||
const card = document.createElement('div');
|
||||
card.setAttribute('role', 'button');
|
||||
card.setAttribute('data-epg-guide-grid', '');
|
||||
expect(controller.handle(key('Enter', { target: card }))).toBe(true);
|
||||
expect(host.play).toHaveBeenCalledWith(2);
|
||||
|
||||
const label = document.createElement('span');
|
||||
card.appendChild(label);
|
||||
expect(controller.handle(key('ArrowDown', { target: label }))).toBe(
|
||||
true
|
||||
);
|
||||
expect(controller.focus()).toEqual({ row: 3, block: null });
|
||||
});
|
||||
|
||||
it('only closes when the target is outside the guide', () => {
|
||||
host.isOwnedTarget.mockReturnValue(false);
|
||||
|
||||
expect(controller.handle(key('ArrowDown'))).toBe(false);
|
||||
expect(controller.focus()).toBeNull();
|
||||
expect(controller.handle(key('Enter'))).toBe(false);
|
||||
expect(host.play).not.toHaveBeenCalled();
|
||||
expect(controller.handle(key('n'))).toBe(false);
|
||||
expect(host.jumpNow).not.toHaveBeenCalled();
|
||||
|
||||
// Escape is the documented close key from anywhere on the page.
|
||||
expect(controller.handle(key('Escape'))).toBe(true);
|
||||
expect(host.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the catch-up button inside a card to the button', () => {
|
||||
const card = document.createElement('div');
|
||||
card.setAttribute('role', 'button');
|
||||
card.setAttribute('data-epg-guide-grid', '');
|
||||
const watch = document.createElement('button');
|
||||
card.appendChild(watch);
|
||||
expect(controller.handle(key('Enter', { target: watch }))).toBe(false);
|
||||
expect(host.play).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { EpgDateNavigationDirection } from '../epg-date';
|
||||
|
||||
export interface EpgGuideFocus {
|
||||
readonly row: number;
|
||||
/** Block index inside the row, or null when the whole row is focused. */
|
||||
readonly block: number | null;
|
||||
}
|
||||
|
||||
export interface EpgGuideKeyboardHost {
|
||||
rowCount(): number;
|
||||
blockCount(row: number): number;
|
||||
/** Index of the playing channel's row, or -1. */
|
||||
activeRow(): number;
|
||||
/** True while a dialog owns the keyboard. */
|
||||
isBlocked(): boolean;
|
||||
/**
|
||||
* True when the key belongs to the guide: the listener is on the document,
|
||||
* so a target that is neither inside the guide nor an unfocused page keeps
|
||||
* its own keys (the docked player, the header).
|
||||
*/
|
||||
isOwnedTarget(target: EventTarget | null): boolean;
|
||||
play(row: number): void;
|
||||
details(row: number, block: number): void;
|
||||
jumpNow(): void;
|
||||
stepDay(direction: EpgDateNavigationDirection): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls the guide must not steal keys from: form fields, buttons, menu and
|
||||
* listbox items, links and editable content — anywhere the browser or a
|
||||
* Material overlay already gives the key a meaning.
|
||||
*/
|
||||
const INTERACTIVE_SELECTOR =
|
||||
'input, textarea, select, button, [role="button"], [role="menuitem"],' +
|
||||
' [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"],' +
|
||||
' a[href], [contenteditable=""], [contenteditable="true"]';
|
||||
|
||||
/**
|
||||
* The guide's own grid surfaces (channel cells and programme cards) carry
|
||||
* `role="button"` for assistive technology, so they match the selector above
|
||||
* even though the guide owns their keys. They opt back in with
|
||||
* `data-epg-guide-grid`; a real control nested inside one (the catch-up
|
||||
* button) is the closest match and still wins.
|
||||
*/
|
||||
const GUIDE_GRID_ATTRIBUTE = 'data-epg-guide-grid';
|
||||
|
||||
function isForeignInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
if (target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
const interactive = target.closest(INTERACTIVE_SELECTOR);
|
||||
return (
|
||||
interactive !== null && !interactive.hasAttribute(GUIDE_GRID_ATTRIBUTE)
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Roving focus for the guide grid. ↑/↓ move between rows, ←/→ between the
|
||||
* focused row's programmes, Enter plays the row, I opens details, N jumps to
|
||||
* now, PgUp/PgDn change the day, Esc closes. Every key but Esc requires the
|
||||
* host to own the event's target. Returns whether the event was consumed so
|
||||
* the caller can `preventDefault()`.
|
||||
*/
|
||||
export class EpgGuideKeyboardController {
|
||||
readonly focus = signal<EpgGuideFocus | null>(null);
|
||||
|
||||
constructor(private readonly host: EpgGuideKeyboardHost) {}
|
||||
|
||||
handle(event: KeyboardEvent): boolean {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
isForeignInteractiveTarget(event.target) ||
|
||||
this.host.isBlocked()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Escape is the documented close key and works from anywhere on the
|
||||
// page; every other guide key applies only while the guide owns the
|
||||
// target, or it would be stolen from whatever else has the focus.
|
||||
if (event.key === 'Escape') {
|
||||
this.host.close();
|
||||
return true;
|
||||
}
|
||||
if (!this.host.isOwnedTarget(event.target)) {
|
||||
return false;
|
||||
}
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
return this.moveRow(1);
|
||||
case 'ArrowUp':
|
||||
return this.moveRow(-1);
|
||||
case 'ArrowRight':
|
||||
return this.moveBlock(1);
|
||||
case 'ArrowLeft':
|
||||
return this.moveBlock(-1);
|
||||
case 'Enter':
|
||||
return this.play();
|
||||
case 'i':
|
||||
case 'I':
|
||||
return this.details();
|
||||
case 'n':
|
||||
case 'N':
|
||||
this.host.jumpNow();
|
||||
return true;
|
||||
case 'PageUp':
|
||||
this.host.stepDay('prev');
|
||||
return true;
|
||||
case 'PageDown':
|
||||
this.host.stepDay('next');
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private currentRow(): number {
|
||||
const focused = this.focus();
|
||||
if (focused) {
|
||||
return focused.row;
|
||||
}
|
||||
return this.host.activeRow();
|
||||
}
|
||||
|
||||
private moveRow(delta: number): boolean {
|
||||
const count = this.host.rowCount();
|
||||
if (count === 0) {
|
||||
return false;
|
||||
}
|
||||
const current = this.currentRow();
|
||||
const next =
|
||||
current < 0
|
||||
? delta > 0
|
||||
? 0
|
||||
: count - 1
|
||||
: clamp(current + delta, 0, count - 1);
|
||||
this.focus.set({ row: next, block: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
private moveBlock(delta: number): boolean {
|
||||
const count = this.host.rowCount();
|
||||
if (count === 0) {
|
||||
return false;
|
||||
}
|
||||
const row = clamp(Math.max(0, this.currentRow()), 0, count - 1);
|
||||
const blocks = this.host.blockCount(row);
|
||||
if (blocks === 0) {
|
||||
this.focus.set({ row, block: null });
|
||||
return true;
|
||||
}
|
||||
const current =
|
||||
this.focus()?.row === row ? (this.focus()?.block ?? null) : null;
|
||||
const start = current ?? (delta > 0 ? -1 : blocks);
|
||||
this.focus.set({ row, block: clamp(start + delta, 0, blocks - 1) });
|
||||
return true;
|
||||
}
|
||||
|
||||
private play(): boolean {
|
||||
const row = this.currentRow();
|
||||
if (row < 0 || row >= this.host.rowCount()) {
|
||||
return false;
|
||||
}
|
||||
this.host.play(row);
|
||||
return true;
|
||||
}
|
||||
|
||||
private details(): boolean {
|
||||
const focused = this.focus();
|
||||
if (!focused || focused.block === null) {
|
||||
return false;
|
||||
}
|
||||
this.host.details(focused.row, focused.block);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildGuideDayAxis,
|
||||
buildGuideRowBlocks,
|
||||
buildGuideTicks,
|
||||
EPG_GUIDE_ZOOM_DEFAULT,
|
||||
guideNowLeftPx,
|
||||
guideTrackWidthPx,
|
||||
guideXForMs,
|
||||
} from './epg-guide-layout.util';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
function program(startMs: number, stopMs: number, title = 'P'): EpgProgram {
|
||||
return {
|
||||
start: new Date(startMs).toISOString(),
|
||||
stop: new Date(stopMs).toISOString(),
|
||||
channel: 'ch',
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('epg-guide-layout.util', () => {
|
||||
const axis = buildGuideDayAxis('2026-09-06');
|
||||
|
||||
it('builds a local-midnight day axis of exactly 24 hours', () => {
|
||||
expect(axis.dayKey).toBe('2026-09-06');
|
||||
expect(axis.endMs - axis.startMs).toBe(24 * HOUR);
|
||||
expect(new Date(axis.startMs).getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it('places a tick every 30 minutes, hours emphasised', () => {
|
||||
const ticks = buildGuideTicks(axis, 240);
|
||||
expect(ticks).toHaveLength(48);
|
||||
expect(ticks[0]).toEqual({ ms: axis.startMs, leftPx: 0, kind: 'hour' });
|
||||
expect(ticks[1]).toEqual({
|
||||
ms: axis.startMs + HOUR / 2,
|
||||
leftPx: 120,
|
||||
kind: 'half',
|
||||
});
|
||||
expect(guideTrackWidthPx(axis, 240)).toBe(5760);
|
||||
});
|
||||
|
||||
it('maps instants to x by the hour width and hides "now" outside the day', () => {
|
||||
expect(guideXForMs(axis, axis.startMs + 2 * HOUR, 240)).toBe(480);
|
||||
expect(guideNowLeftPx(axis, axis.startMs + HOUR, 240)).toBe(240);
|
||||
expect(guideNowLeftPx(axis, axis.startMs, 240)).toBe(0);
|
||||
expect(guideNowLeftPx(axis, axis.startMs - 1, 240)).toBeNull();
|
||||
expect(guideNowLeftPx(axis, axis.endMs, 240)).toBeNull();
|
||||
});
|
||||
|
||||
// A DST transition day is 23 or 25 hours, so `endMs - startMs` is not a
|
||||
// fixed 24h — track width and tick count must scale with the axis span
|
||||
// rather than assuming a fixed day length. `TZ` cannot be pinned per test
|
||||
// in a running Node process, so these assert relative invariants that
|
||||
// hold in every timezone, and actually exercise the 23h/25h cases when
|
||||
// the test machine is in a European zone (2026-10-25 falls back,
|
||||
// 2026-03-29 springs forward).
|
||||
it('derives track width and tick count from the axis span, not a fixed 24h day', () => {
|
||||
for (const dayKey of ['2026-10-25', '2026-03-29']) {
|
||||
const dstAxis = buildGuideDayAxis(dayKey);
|
||||
const spanHours = (dstAxis.endMs - dstAxis.startMs) / HOUR;
|
||||
expect(guideTrackWidthPx(dstAxis, 240)).toBe(spanHours * 240);
|
||||
expect(buildGuideTicks(dstAxis, 240)).toHaveLength(spanHours * 2);
|
||||
}
|
||||
});
|
||||
|
||||
it('lays out programmes overlapping the day, including boundary crossers, with tiers', () => {
|
||||
const nowMs = axis.startMs + 16 * HOUR + 4 * 60_000;
|
||||
const blocks = buildGuideRowBlocks(
|
||||
[
|
||||
program(axis.startMs - HOUR, axis.startMs + HOUR, 'Crosser'),
|
||||
program(
|
||||
axis.startMs + 16 * HOUR,
|
||||
axis.startMs + 16.75 * HOUR,
|
||||
'Now'
|
||||
),
|
||||
program(
|
||||
axis.startMs + 17 * HOUR,
|
||||
axis.startMs + 17 * HOUR + 5 * 60_000,
|
||||
'Micro'
|
||||
),
|
||||
program(axis.endMs + HOUR, axis.endMs + 2 * HOUR, 'Tomorrow'),
|
||||
],
|
||||
{
|
||||
axis,
|
||||
hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT,
|
||||
nowMs,
|
||||
offsetMinutes: 0,
|
||||
}
|
||||
);
|
||||
expect(blocks.map((block) => block.block.program.title)).toEqual([
|
||||
'Crosser',
|
||||
'Now',
|
||||
'Micro',
|
||||
]);
|
||||
expect(blocks[0].leftPx).toBe(-240);
|
||||
expect(blocks[1].block.when).toBe('now');
|
||||
expect(blocks[1].nowFillPercent).toBeCloseTo((4 / 45) * 100, 3);
|
||||
// 5 min at 240px/h = 20px raw, below the guide's 30px "micro" cutoff
|
||||
// now that the guide floor (14px) no longer bumps it up to 40px.
|
||||
expect(blocks[2].tier).toBe('micro');
|
||||
expect(blocks[2].widthPx).toBe(17);
|
||||
});
|
||||
|
||||
it('reports "narrow" just above the micro cutoff', () => {
|
||||
// 15 min at 240px/h = 60px raw, in the [30, 70) "narrow" band.
|
||||
const blocks = buildGuideRowBlocks(
|
||||
[program(axis.startMs, axis.startMs + 15 * 60_000, 'Narrow')],
|
||||
{
|
||||
axis,
|
||||
hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT,
|
||||
nowMs: axis.startMs,
|
||||
offsetMinutes: 0,
|
||||
}
|
||||
);
|
||||
expect(blocks[0].tier).toBe('narrow');
|
||||
expect(blocks[0].widthPx).toBe(57);
|
||||
});
|
||||
|
||||
it('excludes programmes touching the axis only at its boundaries', () => {
|
||||
const blocks = buildGuideRowBlocks(
|
||||
[
|
||||
program(axis.startMs - HOUR, axis.startMs, 'EndsAtStart'),
|
||||
program(axis.endMs, axis.endMs + HOUR, 'StartsAtEnd'),
|
||||
],
|
||||
{
|
||||
axis,
|
||||
hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT,
|
||||
nowMs: axis.startMs,
|
||||
offsetMinutes: 0,
|
||||
}
|
||||
);
|
||||
expect(blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty layout for no programmes', () => {
|
||||
expect(
|
||||
buildGuideRowBlocks([], {
|
||||
axis,
|
||||
hourWidthPx: EPG_GUIDE_ZOOM_DEFAULT,
|
||||
nowMs: axis.startMs,
|
||||
offsetMinutes: 0,
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('shifts programme times by the display offset before layout', () => {
|
||||
const blocks = buildGuideRowBlocks(
|
||||
[program(axis.startMs + 10 * HOUR, axis.startMs + 11 * HOUR)],
|
||||
{ axis, hourWidthPx: 240, nowMs: axis.startMs, offsetMinutes: 60 }
|
||||
);
|
||||
expect(blocks[0].leftPx).toBe(11 * 240);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { addDays, addMinutes } from 'date-fns';
|
||||
import { parseEpgDateKey } from '../epg-date';
|
||||
import {
|
||||
buildTimelineBlocks,
|
||||
TimelineAxis,
|
||||
} from '../epg-timeline/epg-timeline.utils';
|
||||
import {
|
||||
buildTimelineRenderItems,
|
||||
TimelineRenderBlock,
|
||||
} from '../epg-timeline/epg-timeline-render.util';
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Pixels per hour. */
|
||||
export const EPG_GUIDE_ZOOM_MIN = 120;
|
||||
export const EPG_GUIDE_ZOOM_MAX = 480;
|
||||
export const EPG_GUIDE_ZOOM_STEP = 20;
|
||||
export const EPG_GUIDE_ZOOM_DEFAULT = 240;
|
||||
|
||||
export type EpgGuideDensity = 'comfortable' | 'compact';
|
||||
export const EPG_GUIDE_ROW_HEIGHT_PX: Record<EpgGuideDensity, number> = {
|
||||
comfortable: 60,
|
||||
compact: 44,
|
||||
};
|
||||
export const EPG_GUIDE_CHANNEL_COLUMN_PX = 232;
|
||||
/** Rows loaded ahead of the rendered range, in each direction. */
|
||||
export const EPG_GUIDE_ROW_BUFFER = 10;
|
||||
|
||||
/**
|
||||
* Guide rows are shorter (44–60px) than the timeline ribbon, so the shared
|
||||
* `TIMELINE_MIN_BLOCK_WIDTH_PX`/`TIMELINE_BLOCK_GAP_PX` floor (40px/4px) is
|
||||
* too generous here — it would make `tierFor`'s micro branch unreachable at
|
||||
* common zoom levels. The guide uses its own, tighter floor and gap.
|
||||
*/
|
||||
export const EPG_GUIDE_MIN_BLOCK_WIDTH_PX = 14;
|
||||
export const EPG_GUIDE_BLOCK_GAP_PX = 3;
|
||||
|
||||
/** One selected day in DISPLAY time (local midnight to local midnight). */
|
||||
export interface EpgGuideDayAxis extends TimelineAxis {
|
||||
readonly dayKey: string;
|
||||
}
|
||||
|
||||
export interface EpgGuideTick {
|
||||
readonly ms: number;
|
||||
readonly leftPx: number;
|
||||
readonly kind: 'hour' | 'half';
|
||||
}
|
||||
|
||||
export interface EpgGuideRowLayoutOptions {
|
||||
readonly axis: EpgGuideDayAxis;
|
||||
readonly hourWidthPx: number;
|
||||
readonly nowMs: number;
|
||||
readonly offsetMinutes: number;
|
||||
readonly catchUpAvailable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A DST transition day is 23 or 25 hours, not 24 — `addDays` walks the
|
||||
* calendar (local midnight to local midnight), so `endMs - startMs` is not a
|
||||
* fixed constant. `guideTrackWidthPx` and `buildGuideTicks` below derive their
|
||||
* geometry from that span instead of assuming a 24-hour day.
|
||||
*/
|
||||
export function buildGuideDayAxis(dayKey: string): EpgGuideDayAxis {
|
||||
const start = parseEpgDateKey(dayKey);
|
||||
return {
|
||||
dayKey,
|
||||
startMs: start.getTime(),
|
||||
endMs: addDays(start, 1).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
export function guideTrackWidthPx(
|
||||
axis: TimelineAxis,
|
||||
hourWidthPx: number
|
||||
): number {
|
||||
return ((axis.endMs - axis.startMs) / HOUR_MS) * hourWidthPx;
|
||||
}
|
||||
|
||||
export function guideXForMs(
|
||||
axis: TimelineAxis,
|
||||
ms: number,
|
||||
hourWidthPx: number
|
||||
): number {
|
||||
return ((ms - axis.startMs) / HOUR_MS) * hourWidthPx;
|
||||
}
|
||||
|
||||
/** x of the now-line, or null when "now" is not on the selected day. */
|
||||
export function guideNowLeftPx(
|
||||
axis: TimelineAxis,
|
||||
nowMs: number,
|
||||
hourWidthPx: number
|
||||
): number | null {
|
||||
if (nowMs < axis.startMs || nowMs >= axis.endMs) {
|
||||
return null;
|
||||
}
|
||||
return guideXForMs(axis, nowMs, hourWidthPx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticks every 30 minutes from the axis start until the axis end, which — on a
|
||||
* DST transition day — is not a multiple of 1440 minutes of wall-clock time.
|
||||
* Iterating by adding 30-minute steps to the start `Date` (rather than
|
||||
* looping a fixed minute count) keeps every tick's local time correct across
|
||||
* the transition.
|
||||
*/
|
||||
export function buildGuideTicks(
|
||||
axis: TimelineAxis,
|
||||
hourWidthPx: number
|
||||
): EpgGuideTick[] {
|
||||
const ticks: EpgGuideTick[] = [];
|
||||
const start = new Date(axis.startMs);
|
||||
for (let index = 0; ; index += 1) {
|
||||
const ms = addMinutes(start, index * 30).getTime();
|
||||
if (ms >= axis.endMs) {
|
||||
break;
|
||||
}
|
||||
ticks.push({
|
||||
ms,
|
||||
leftPx: guideXForMs(axis, ms, hourWidthPx),
|
||||
kind: new Date(ms).getMinutes() === 0 ? 'hour' : 'half',
|
||||
});
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positioned blocks for one channel row, sharing the timeline's block maths
|
||||
* (`buildTimelineBlocks` → `buildTimelineRenderItems`) so both guides agree
|
||||
* on tiers and the on-now fill, though the guide applies its own tighter
|
||||
* minimum width and gap (`EPG_GUIDE_MIN_BLOCK_WIDTH_PX`/
|
||||
* `EPG_GUIDE_BLOCK_GAP_PX`) sized for its shorter rows. Programmes are shifted
|
||||
* into display time by `offsetMinutes` and compared with the wall-clock
|
||||
* `nowMs` (the display form of the EPG offset contract). Short-run grouping
|
||||
* is off: a grid row has no room for group chips.
|
||||
*
|
||||
* A programme that started the previous day and is still airing keeps a
|
||||
* negative `leftPx` (it is not re-clamped to the axis start) — the caller's
|
||||
* lane must clip with `overflow: hidden` rather than relying on layout to
|
||||
* hide the offscreen portion.
|
||||
*/
|
||||
export function buildGuideRowBlocks(
|
||||
programs: readonly EpgProgram[],
|
||||
options: EpgGuideRowLayoutOptions
|
||||
): TimelineRenderBlock[] {
|
||||
const { axis, hourWidthPx, nowMs, offsetMinutes } = options;
|
||||
const blocks = buildTimelineBlocks(
|
||||
programs,
|
||||
axis,
|
||||
nowMs,
|
||||
offsetMinutes
|
||||
).filter(
|
||||
(block) => block.stopMs > axis.startMs && block.startMs < axis.endMs
|
||||
);
|
||||
const items = buildTimelineRenderItems(blocks, hourWidthPx / 60, {
|
||||
minWidthPx: EPG_GUIDE_MIN_BLOCK_WIDTH_PX,
|
||||
gapPx: EPG_GUIDE_BLOCK_GAP_PX,
|
||||
allowGroup: false,
|
||||
nowMs,
|
||||
archivePlaybackAvailable: options.catchUpAvailable ?? false,
|
||||
});
|
||||
return items.filter(
|
||||
(item): item is TimelineRenderBlock => item.kind === 'block'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<div class="now-playing" [class.now-playing--collapsed]="collapsed()">
|
||||
<div class="now-playing__body">
|
||||
<div class="now-playing__eyebrow">
|
||||
<span>{{ channelName() }}</span>
|
||||
@if (isOnNow()) {
|
||||
<span class="now-playing__on">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
@if (program(); as program) {
|
||||
<div class="now-playing__title">{{ program.title }}</div>
|
||||
@if (!collapsed() && program.desc) {
|
||||
<div class="now-playing__desc">{{ program.desc }}</div>
|
||||
}
|
||||
@if (!collapsed() && progress() !== null) {
|
||||
<div class="now-playing__progress">
|
||||
<span>{{ startMs() | date: 'HH:mm' }}</span>
|
||||
<i aria-hidden="true"
|
||||
><b [style.width.%]="progress()"></b
|
||||
></i>
|
||||
<span>{{ stopMs() | date: 'HH:mm' }}</span>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="now-playing__title now-playing__title--muted">
|
||||
{{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="now-playing__actions">
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
(click)="closeRequested.emit()"
|
||||
>
|
||||
{{ 'EPG.GUIDE.CLOSE' | translate }} <kbd>Esc</kbd>
|
||||
</button>
|
||||
@if (collapsible()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-button
|
||||
(click)="collapsedChange.emit(!collapsed())"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
>
|
||||
{{
|
||||
(collapsed()
|
||||
? 'EPG.GUIDE.EXPAND_PLAYER'
|
||||
: 'EPG.GUIDE.COLLAPSE_PLAYER'
|
||||
) | translate
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,116 @@
|
||||
@use '../epg-theme' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.now-playing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
color: $text-primary;
|
||||
}
|
||||
.now-playing__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.now-playing__eyebrow {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font: 500 10px $font-mono;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: $text-secondary;
|
||||
|
||||
.now-playing__on {
|
||||
color: $live-text;
|
||||
}
|
||||
.now-playing__on::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
margin-right: 5px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
}
|
||||
.now-playing__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.now-playing__title--muted {
|
||||
color: $text-tertiary;
|
||||
font-weight: 400;
|
||||
}
|
||||
.now-playing__desc {
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 60ch;
|
||||
}
|
||||
.now-playing__progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font: 11px $font-mono;
|
||||
color: $text-secondary;
|
||||
|
||||
i {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: $surface-3;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-blue;
|
||||
}
|
||||
}
|
||||
.now-playing__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.now-playing--collapsed {
|
||||
gap: 12px;
|
||||
|
||||
.now-playing__body {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.now-playing__title {
|
||||
font-size: 13px;
|
||||
}
|
||||
.now-playing__actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
kbd {
|
||||
font: 10px $font-mono;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
margin-left: 6px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { getProgramTimeMs } from '../epg-program.utils';
|
||||
|
||||
/**
|
||||
* Info block of the docked player strip while the guide is open: channel,
|
||||
* current programme, progress, Close and Collapse. The host renders it next to
|
||||
* the player because the strip itself is host layout.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-guide-now-playing',
|
||||
imports: [DatePipe, MatButtonModule, TranslatePipe],
|
||||
templateUrl: './epg-guide-now-playing.component.html',
|
||||
styleUrl: './epg-guide-now-playing.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgGuideNowPlayingComponent implements OnInit, OnDestroy {
|
||||
readonly channelName = input('');
|
||||
readonly program = input<EpgProgram | null>(null);
|
||||
readonly offsetMinutes = input(0);
|
||||
readonly collapsed = input(false);
|
||||
/**
|
||||
* Whether the strip offers its Collapse/Expand toggle. False where the
|
||||
* strip is already at its minimum — the external-player dock has no video
|
||||
* to collapse away, so the button would only write a preference the user
|
||||
* cannot see the effect of.
|
||||
*/
|
||||
readonly collapsible = input(true);
|
||||
|
||||
readonly closeRequested = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
|
||||
private readonly nowMs = signal(Date.now());
|
||||
private timer?: number;
|
||||
|
||||
readonly startMs = computed(() => this.boundary('start'));
|
||||
readonly stopMs = computed(() => this.boundary('stop'));
|
||||
readonly isOnNow = computed(() => {
|
||||
const start = this.startMs();
|
||||
const stop = this.stopMs();
|
||||
const now = this.nowMs();
|
||||
return start !== null && stop !== null && start <= now && now < stop;
|
||||
});
|
||||
readonly progress = computed(() => {
|
||||
const start = this.startMs();
|
||||
const stop = this.stopMs();
|
||||
if (start === null || stop === null || stop <= start) {
|
||||
return null;
|
||||
}
|
||||
const pct = ((this.nowMs() - start) / (stop - start)) * 100;
|
||||
return Math.min(100, Math.max(0, pct));
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.timer = window.setInterval(
|
||||
() => this.nowMs.set(Date.now()),
|
||||
30_000
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
window.clearInterval(this.timer);
|
||||
}
|
||||
|
||||
private boundary(edge: 'start' | 'stop'): number | null {
|
||||
const program = this.program();
|
||||
if (!program) {
|
||||
return null;
|
||||
}
|
||||
const ms = getProgramTimeMs(
|
||||
program[edge],
|
||||
edge === 'start' ? program.startTimestamp : program.stopTimestamp,
|
||||
this.offsetMinutes()
|
||||
);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
EPG_GUIDE_DENSITY_KEY,
|
||||
EPG_GUIDE_DOCK_COLLAPSED_KEY,
|
||||
EPG_GUIDE_ONLY_WITH_EPG_KEY,
|
||||
EPG_GUIDE_ZOOM_KEY,
|
||||
persistEpgGuideDockCollapsed,
|
||||
persistEpgGuidePreferences,
|
||||
restoreEpgGuideDockCollapsed,
|
||||
restoreEpgGuidePreferences,
|
||||
} from './epg-guide-preferences';
|
||||
import { EPG_GUIDE_ZOOM_DEFAULT } from './epg-guide-layout.util';
|
||||
|
||||
describe('epg-guide-preferences', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it('falls back to comfortable density, default zoom and the toggle off', () => {
|
||||
expect(restoreEpgGuidePreferences()).toEqual({
|
||||
density: 'comfortable',
|
||||
zoom: EPG_GUIDE_ZOOM_DEFAULT,
|
||||
onlyWithEpg: false,
|
||||
});
|
||||
expect(restoreEpgGuideDockCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips every preference and clamps the zoom', () => {
|
||||
persistEpgGuidePreferences({
|
||||
density: 'compact',
|
||||
zoom: 9_999,
|
||||
onlyWithEpg: true,
|
||||
});
|
||||
persistEpgGuideDockCollapsed(true);
|
||||
expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact');
|
||||
expect(localStorage.getItem(EPG_GUIDE_ZOOM_KEY)).toBe('480');
|
||||
expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1');
|
||||
expect(localStorage.getItem(EPG_GUIDE_DOCK_COLLAPSED_KEY)).toBe('1');
|
||||
expect(restoreEpgGuidePreferences()).toEqual({
|
||||
density: 'compact',
|
||||
zoom: 480,
|
||||
onlyWithEpg: true,
|
||||
});
|
||||
expect(restoreEpgGuideDockCollapsed()).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores corrupt stored values', () => {
|
||||
localStorage.setItem(EPG_GUIDE_DENSITY_KEY, 'huge');
|
||||
localStorage.setItem(EPG_GUIDE_ZOOM_KEY, 'abc');
|
||||
expect(restoreEpgGuidePreferences().density).toBe('comfortable');
|
||||
expect(restoreEpgGuidePreferences().zoom).toBe(EPG_GUIDE_ZOOM_DEFAULT);
|
||||
});
|
||||
|
||||
it('survives a throwing storage', () => {
|
||||
const broken = {
|
||||
getItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
} as unknown as Storage;
|
||||
expect(() =>
|
||||
persistEpgGuidePreferences(
|
||||
{ density: 'compact', zoom: 200, onlyWithEpg: false },
|
||||
broken
|
||||
)
|
||||
).not.toThrow();
|
||||
expect(restoreEpgGuidePreferences(broken)).toEqual({
|
||||
density: 'comfortable',
|
||||
zoom: EPG_GUIDE_ZOOM_DEFAULT,
|
||||
onlyWithEpg: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
EPG_GUIDE_ZOOM_DEFAULT,
|
||||
EPG_GUIDE_ZOOM_MAX,
|
||||
EPG_GUIDE_ZOOM_MIN,
|
||||
EpgGuideDensity,
|
||||
} from './epg-guide-layout.util';
|
||||
|
||||
export const EPG_GUIDE_DENSITY_KEY = 'epg-guide:density';
|
||||
export const EPG_GUIDE_ZOOM_KEY = 'epg-guide:zoom';
|
||||
export const EPG_GUIDE_ONLY_WITH_EPG_KEY = 'epg-guide:only-with-epg';
|
||||
export const EPG_GUIDE_DOCK_COLLAPSED_KEY = 'epg-guide:dock-collapsed';
|
||||
|
||||
export interface EpgGuidePreferences {
|
||||
density: EpgGuideDensity;
|
||||
zoom: number;
|
||||
onlyWithEpg: boolean;
|
||||
}
|
||||
|
||||
function isDensity(value: unknown): value is EpgGuideDensity {
|
||||
return value === 'comfortable' || value === 'compact';
|
||||
}
|
||||
|
||||
export function clampGuideZoom(value: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return EPG_GUIDE_ZOOM_DEFAULT;
|
||||
}
|
||||
return Math.min(EPG_GUIDE_ZOOM_MAX, Math.max(EPG_GUIDE_ZOOM_MIN, value));
|
||||
}
|
||||
|
||||
function read(storage: Storage, key: string): string | null {
|
||||
try {
|
||||
return storage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(storage: Storage, key: string, value: string): void {
|
||||
try {
|
||||
storage.setItem(key, value);
|
||||
} catch {
|
||||
// Storage may be unavailable (private mode, quota); the guide simply
|
||||
// starts from defaults next time.
|
||||
}
|
||||
}
|
||||
|
||||
function defaultStorage(): Storage {
|
||||
return globalThis.localStorage;
|
||||
}
|
||||
|
||||
export function restoreEpgGuidePreferences(
|
||||
storage: Storage = defaultStorage()
|
||||
): EpgGuidePreferences {
|
||||
const density = read(storage, EPG_GUIDE_DENSITY_KEY);
|
||||
const rawZoom = read(storage, EPG_GUIDE_ZOOM_KEY);
|
||||
// `null` (never stored) and `''` (a cleared/corrupt value) both mean "no
|
||||
// preference" and fall back to the default; anything else — including
|
||||
// non-numeric garbage, which `Number()` turns into `NaN` — is clamped by
|
||||
// `clampGuideZoom`, which itself defaults on a non-finite value.
|
||||
const zoom =
|
||||
rawZoom === null || rawZoom === ''
|
||||
? EPG_GUIDE_ZOOM_DEFAULT
|
||||
: clampGuideZoom(Number(rawZoom));
|
||||
return {
|
||||
density: isDensity(density) ? density : 'comfortable',
|
||||
zoom,
|
||||
onlyWithEpg: read(storage, EPG_GUIDE_ONLY_WITH_EPG_KEY) === '1',
|
||||
};
|
||||
}
|
||||
|
||||
export function persistEpgGuidePreferences(
|
||||
preferences: EpgGuidePreferences,
|
||||
storage: Storage = defaultStorage()
|
||||
): void {
|
||||
write(storage, EPG_GUIDE_DENSITY_KEY, preferences.density);
|
||||
write(
|
||||
storage,
|
||||
EPG_GUIDE_ZOOM_KEY,
|
||||
String(clampGuideZoom(preferences.zoom))
|
||||
);
|
||||
write(
|
||||
storage,
|
||||
EPG_GUIDE_ONLY_WITH_EPG_KEY,
|
||||
preferences.onlyWithEpg ? '1' : '0'
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreEpgGuideDockCollapsed(
|
||||
storage: Storage = defaultStorage()
|
||||
): boolean {
|
||||
return read(storage, EPG_GUIDE_DOCK_COLLAPSED_KEY) === '1';
|
||||
}
|
||||
|
||||
export function persistEpgGuideDockCollapsed(
|
||||
collapsed: boolean,
|
||||
storage: Storage = defaultStorage()
|
||||
): void {
|
||||
write(storage, EPG_GUIDE_DOCK_COLLAPSED_KEY, collapsed ? '1' : '0');
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EPG_GUIDE_SOURCE,
|
||||
EpgGuideChannel,
|
||||
EpgGuideSource,
|
||||
} from './epg-guide-source';
|
||||
import {
|
||||
EPG_GUIDE_LOAD_CHUNK,
|
||||
EpgGuideProgramsService,
|
||||
} from './epg-guide-programs.service';
|
||||
|
||||
function channel(id: string, epgKey: string | null = id): EpgGuideChannel {
|
||||
return { id, number: 1, name: id, logoUrl: null, epgKey };
|
||||
}
|
||||
|
||||
function programFor(channelId: string): EpgProgram {
|
||||
return {
|
||||
start: '2026-09-06T16:00:00.000Z',
|
||||
stop: '2026-09-06T17:00:00.000Z',
|
||||
channel: channelId,
|
||||
title: `${channelId} show`,
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe('EpgGuideProgramsService', () => {
|
||||
const channels = signal<EpgGuideChannel[]>([]);
|
||||
const loadPrograms = jest.fn<
|
||||
Promise<Map<string, EpgProgram[]>>,
|
||||
[unknown]
|
||||
>();
|
||||
const loadCoverage = jest.fn<Promise<Set<string>>, [unknown]>();
|
||||
let service: EpgGuideProgramsService;
|
||||
|
||||
beforeEach(() => {
|
||||
loadPrograms.mockReset();
|
||||
loadCoverage.mockReset();
|
||||
loadCoverage.mockResolvedValue(new Set());
|
||||
channels.set([channel('a'), channel('b'), channel('c', null)]);
|
||||
const source: EpgGuideSource = {
|
||||
channels,
|
||||
scopes: signal([]),
|
||||
scopeId: signal('all'),
|
||||
setScope: jest.fn(),
|
||||
loadPrograms,
|
||||
loadCoverage,
|
||||
activeChannelId: signal(null),
|
||||
activate: jest.fn(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EpgGuideProgramsService,
|
||||
{ provide: EPG_GUIDE_SOURCE, useValue: source },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EpgGuideProgramsService);
|
||||
// Consume the constructor effect's first run (a no-op by design) so
|
||||
// each test starts from a clean, predictable call count.
|
||||
TestBed.flushEffects();
|
||||
});
|
||||
|
||||
it('reports "none" for channels without an EPG key and never requests them', async () => {
|
||||
loadPrograms.mockResolvedValue(new Map());
|
||||
service.setWindow(1_000, 2_000);
|
||||
service.ensureLoaded(channels());
|
||||
await flush();
|
||||
expect(service.statusFor('c')).toBe('none');
|
||||
const requested = loadPrograms.mock.calls[0][0] as {
|
||||
channels: EpgGuideChannel[];
|
||||
};
|
||||
expect(requested.channels.map((item) => item.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('loads visible rows once, in chunks, and exposes their programmes', async () => {
|
||||
const many = Array.from({ length: EPG_GUIDE_LOAD_CHUNK + 5 }, (_, i) =>
|
||||
channel(`ch-${i}`)
|
||||
);
|
||||
channels.set(many);
|
||||
loadPrograms.mockImplementation(async (window) => {
|
||||
const { channels: requested } = window as {
|
||||
channels: EpgGuideChannel[];
|
||||
};
|
||||
return new Map(
|
||||
requested.map((item) => [item.id, [programFor(item.id)]])
|
||||
);
|
||||
});
|
||||
service.setWindow(1_000, 2_000);
|
||||
service.ensureLoaded(many);
|
||||
expect(service.statusFor('ch-0')).toBe('loading');
|
||||
await flush();
|
||||
expect(loadPrograms).toHaveBeenCalledTimes(2);
|
||||
expect(service.statusFor('ch-0')).toBe('loaded');
|
||||
expect(service.programsFor('ch-0')[0].title).toBe('ch-0 show');
|
||||
service.ensureLoaded(many);
|
||||
expect(loadPrograms).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('drops responses that belong to a previous window', async () => {
|
||||
let resolveFirst!: (value: Map<string, EpgProgram[]>) => void;
|
||||
loadPrograms.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFirst = resolve))
|
||||
);
|
||||
loadPrograms.mockResolvedValueOnce(new Map());
|
||||
service.setWindow(1_000, 2_000);
|
||||
service.ensureLoaded(channels());
|
||||
service.setWindow(3_000, 4_000);
|
||||
service.ensureLoaded(channels());
|
||||
resolveFirst(new Map([['a', [programFor('a')]]]));
|
||||
await flush();
|
||||
expect(service.programsFor('a')).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks a failed batch as loaded-empty instead of retrying on every scroll', async () => {
|
||||
loadPrograms.mockRejectedValue(new Error('ipc down'));
|
||||
service.setWindow(1_000, 2_000);
|
||||
service.ensureLoaded(channels());
|
||||
await flush();
|
||||
expect(service.statusFor('a')).toBe('loaded');
|
||||
service.ensureLoaded(channels());
|
||||
expect(loadPrograms).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loads coverage for the whole scope when the window is set and answers isCovered', async () => {
|
||||
loadCoverage.mockResolvedValue(new Set(['a']));
|
||||
expect(service.coverageLoaded()).toBe(false);
|
||||
expect(service.isCovered('b')).toBe(true);
|
||||
service.setWindow(1_000, 2_000);
|
||||
await flush();
|
||||
expect(loadCoverage).toHaveBeenCalledTimes(1);
|
||||
expect(service.coverageLoaded()).toBe(true);
|
||||
expect(service.isCovered('a')).toBe(true);
|
||||
expect(service.isCovered('b')).toBe(false);
|
||||
expect(service.isCovered('c')).toBe(false);
|
||||
});
|
||||
|
||||
it('resets programmes and coverage when the scope channels change', async () => {
|
||||
loadPrograms.mockResolvedValue(new Map([['a', [programFor('a')]]]));
|
||||
service.setWindow(1_000, 2_000);
|
||||
service.ensureLoaded(channels());
|
||||
await flush();
|
||||
expect(service.programsFor('a')).toHaveLength(1);
|
||||
const callsBefore = loadCoverage.mock.calls.length;
|
||||
channels.set([channel('a'), channel('z')]);
|
||||
TestBed.flushEffects();
|
||||
await flush();
|
||||
expect(service.statusFor('a')).toBe('idle');
|
||||
expect(loadCoverage.mock.calls.length).toBe(callsBefore + 1);
|
||||
const requested = loadCoverage.mock.calls[callsBefore][0] as {
|
||||
channels: EpgGuideChannel[];
|
||||
};
|
||||
const requestedIds = requested.channels.map((item) => item.id);
|
||||
expect(requestedIds).toContain('z');
|
||||
expect(requestedIds).not.toContain('b');
|
||||
});
|
||||
|
||||
it('does not re-invalidate or drop an in-flight load when the effect flushes again with unchanged channels', async () => {
|
||||
loadPrograms.mockResolvedValue(new Map([['a', [programFor('a')]]]));
|
||||
service.setWindow(1_000, 2_000);
|
||||
service.ensureLoaded(channels());
|
||||
TestBed.flushEffects();
|
||||
await flush();
|
||||
expect(loadCoverage).toHaveBeenCalledTimes(1);
|
||||
expect(service.programsFor('a')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves coverage unknown when a coverage chunk rejects', async () => {
|
||||
loadCoverage.mockRejectedValue(new Error('coverage down'));
|
||||
service.setWindow(1_000, 2_000);
|
||||
await flush();
|
||||
expect(service.coverageLoaded()).toBe(false);
|
||||
expect(service.isCovered('b')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty-string epgKey the same as null', async () => {
|
||||
channels.set([channel('a'), channel('empty', '')]);
|
||||
loadPrograms.mockResolvedValue(new Map());
|
||||
service.setWindow(1_000, 2_000);
|
||||
service.ensureLoaded(channels());
|
||||
await flush();
|
||||
expect(service.statusFor('empty')).toBe('none');
|
||||
const requestedPrograms = loadPrograms.mock.calls[0][0] as {
|
||||
channels: EpgGuideChannel[];
|
||||
};
|
||||
expect(requestedPrograms.channels.map((item) => item.id)).not.toContain(
|
||||
'empty'
|
||||
);
|
||||
const requestedCoverage = loadCoverage.mock.calls[0][0] as {
|
||||
channels: EpgGuideChannel[];
|
||||
};
|
||||
expect(requestedCoverage.channels.map((item) => item.id)).not.toContain(
|
||||
'empty'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { computed, effect, inject, Injectable, signal } from '@angular/core';
|
||||
import { createDevLogger, EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EPG_GUIDE_SOURCE,
|
||||
EpgGuideChannel,
|
||||
EpgGuideWindow,
|
||||
} from './epg-guide-source';
|
||||
|
||||
/** Channels per `loadPrograms` call (the programme IPC caps at 100 keys). */
|
||||
export const EPG_GUIDE_LOAD_CHUNK = 100;
|
||||
/**
|
||||
* Channels per `loadCoverage` call. Deliberately half the IPC's 2000-key cap:
|
||||
* it leaves headroom for the host to append keys to the same request (e.g. a
|
||||
* scope change mid-flight) without immediately needing a second chunk and a
|
||||
* new round-trip contract.
|
||||
*/
|
||||
export const EPG_GUIDE_COVERAGE_CHUNK = 1000;
|
||||
|
||||
export type EpgGuideRowStatus = 'idle' | 'loading' | 'loaded' | 'none';
|
||||
|
||||
const EMPTY: readonly EpgProgram[] = [];
|
||||
|
||||
const debugEpgGuidePrograms = createDevLogger('EpgGuideProgramsService');
|
||||
|
||||
/** A channel is only ever requested when it carries a real, non-blank key. */
|
||||
function hasEpgKey(channel: EpgGuideChannel): boolean {
|
||||
return (
|
||||
typeof channel.epgKey === 'string' && channel.epgKey.trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-day programme cache for the guide. Programmes load lazily for the rows
|
||||
* the viewport asks about; coverage (which rows have anything at all) loads
|
||||
* eagerly for the whole scope so "Only with EPG" can answer before rows
|
||||
* scroll into view. Every response is tagged with the generation it was
|
||||
* requested under, so a window or scope change makes older answers no-ops.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EpgGuideProgramsService {
|
||||
private readonly source = inject(EPG_GUIDE_SOURCE);
|
||||
|
||||
private readonly programs = signal<
|
||||
ReadonlyMap<string, readonly EpgProgram[]>
|
||||
>(new Map());
|
||||
private readonly statuses = signal<ReadonlyMap<string, EpgGuideRowStatus>>(
|
||||
new Map()
|
||||
);
|
||||
private readonly coverage = signal<ReadonlySet<string> | null>(null);
|
||||
|
||||
private range: { fromMs: number; toMs: number } | null = null;
|
||||
private generation = 0;
|
||||
/** Last channel reference seen by the constructor effect (see below). */
|
||||
private seenChannels: readonly EpgGuideChannel[] | null = null;
|
||||
|
||||
readonly coverageLoaded = computed(() => this.coverage() !== null);
|
||||
|
||||
constructor() {
|
||||
// A new channel set (scope change) invalidates everything, including
|
||||
// coverage: the toggle must be answered for the new rows. The first
|
||||
// effect run must NOT invalidate: a host can call `setWindow` (and
|
||||
// thus `ensureLoaded`) before the initial change-detection flush, and
|
||||
// invalidating here would bump the generation and drop that
|
||||
// in-flight request's answer along with any coverage it kicked off.
|
||||
effect(() => {
|
||||
const next = this.source.channels();
|
||||
if (next === this.seenChannels) {
|
||||
return;
|
||||
}
|
||||
const first = this.seenChannels === null;
|
||||
this.seenChannels = next;
|
||||
if (first) {
|
||||
return;
|
||||
}
|
||||
this.invalidate();
|
||||
this.requestCoverage();
|
||||
});
|
||||
}
|
||||
|
||||
setWindow(fromMs: number, toMs: number): void {
|
||||
if (this.range?.fromMs === fromMs && this.range?.toMs === toMs) {
|
||||
return;
|
||||
}
|
||||
this.range = { fromMs, toMs };
|
||||
this.invalidate();
|
||||
this.requestCoverage();
|
||||
}
|
||||
|
||||
programsFor(channelId: string): readonly EpgProgram[] {
|
||||
return this.programs().get(channelId) ?? EMPTY;
|
||||
}
|
||||
|
||||
statusFor(channelId: string): EpgGuideRowStatus {
|
||||
return this.statuses().get(channelId) ?? 'idle';
|
||||
}
|
||||
|
||||
/** Unknown (coverage not loaded yet, or a chunk failed) counts as covered
|
||||
* so rows never blink. */
|
||||
isCovered(channelId: string): boolean {
|
||||
const coverage = this.coverage();
|
||||
return coverage === null || coverage.has(channelId);
|
||||
}
|
||||
|
||||
/** Request programmes for rows that are idle; chunked, generation-tagged. */
|
||||
ensureLoaded(channels: readonly EpgGuideChannel[]): void {
|
||||
const range = this.range;
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const pending = channels.filter(
|
||||
(channel) =>
|
||||
this.statusFor(channel.id) === 'idle' && hasEpgKey(channel)
|
||||
);
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.patchStatuses(pending.map((channel) => [channel.id, 'loading']));
|
||||
const generation = this.generation;
|
||||
for (
|
||||
let start = 0;
|
||||
start < pending.length;
|
||||
start += EPG_GUIDE_LOAD_CHUNK
|
||||
) {
|
||||
const chunk = pending.slice(start, start + EPG_GUIDE_LOAD_CHUNK);
|
||||
this.loadChunk({ channels: chunk, ...range }, generation);
|
||||
}
|
||||
}
|
||||
|
||||
private loadChunk(request: EpgGuideWindow, generation: number): void {
|
||||
this.source
|
||||
.loadPrograms(request)
|
||||
.then(
|
||||
(result) => result,
|
||||
() => {
|
||||
debugEpgGuidePrograms(
|
||||
'loadPrograms failed for a chunk; marking as loaded-empty',
|
||||
{ channelCount: request.channels.length }
|
||||
);
|
||||
return null;
|
||||
}
|
||||
)
|
||||
.then((result) => {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
const next = new Map(this.programs());
|
||||
for (const channel of request.channels) {
|
||||
next.set(channel.id, result?.get(channel.id) ?? EMPTY);
|
||||
}
|
||||
this.programs.set(next);
|
||||
this.patchStatuses(
|
||||
request.channels.map((channel) => [channel.id, 'loaded'])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private requestCoverage(): void {
|
||||
const range = this.range;
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
const generation = this.generation;
|
||||
const keyed = this.source.channels().filter(hasEpgKey);
|
||||
const chunks: EpgGuideChannel[][] = [];
|
||||
for (
|
||||
let start = 0;
|
||||
start < keyed.length;
|
||||
start += EPG_GUIDE_COVERAGE_CHUNK
|
||||
) {
|
||||
chunks.push(keyed.slice(start, start + EPG_GUIDE_COVERAGE_CHUNK));
|
||||
}
|
||||
Promise.all(
|
||||
chunks.map((channels) =>
|
||||
this.source.loadCoverage({ channels, ...range }).then(
|
||||
(covered) => covered,
|
||||
() => null
|
||||
)
|
||||
)
|
||||
).then((sets) => {
|
||||
if (generation !== this.generation) {
|
||||
return;
|
||||
}
|
||||
// Fail open: if any chunk failed, coverage stays unknown (`null`)
|
||||
// rather than publishing a partial/empty set that would hide rows
|
||||
// that do have programmes.
|
||||
if (sets.some((set) => set === null)) {
|
||||
debugEpgGuidePrograms(
|
||||
'loadCoverage failed for a chunk; leaving coverage unknown',
|
||||
{ chunkCount: sets.length }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const merged = new Set<string>();
|
||||
for (const set of sets) {
|
||||
if (set === null) {
|
||||
continue;
|
||||
}
|
||||
set.forEach((id) => merged.add(id));
|
||||
}
|
||||
this.coverage.set(merged);
|
||||
});
|
||||
}
|
||||
|
||||
private invalidate(): void {
|
||||
this.generation += 1;
|
||||
this.programs.set(new Map());
|
||||
this.statuses.set(
|
||||
new Map(
|
||||
this.source
|
||||
.channels()
|
||||
.filter((channel) => !hasEpgKey(channel))
|
||||
.map((channel) => [channel.id, 'none' as const])
|
||||
)
|
||||
);
|
||||
this.coverage.set(null);
|
||||
}
|
||||
|
||||
private patchStatuses(entries: Array<[string, EpgGuideRowStatus]>): void {
|
||||
const next = new Map(this.statuses());
|
||||
for (const [id, status] of entries) {
|
||||
next.set(id, status);
|
||||
}
|
||||
this.statuses.set(next);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<div class="epg-guide-row__channel-cell" role="gridcell">
|
||||
<div
|
||||
class="epg-guide-row__channel"
|
||||
role="button"
|
||||
data-epg-guide-grid
|
||||
[attr.tabindex]="channelTabIndex()"
|
||||
[attr.aria-label]="channel().name"
|
||||
[attr.aria-pressed]="active()"
|
||||
(click)="onChannelClick()"
|
||||
(dblclick)="channelCommitted.emit()"
|
||||
>
|
||||
<span class="epg-guide-row__num">{{ channel().number }}</span>
|
||||
<span class="epg-guide-row__logo">
|
||||
@if (channel().logoUrl; as logo) {
|
||||
<img [src]="logo" alt="" loading="lazy" />
|
||||
} @else {
|
||||
<mat-icon>live_tv</mat-icon>
|
||||
}
|
||||
</span>
|
||||
<span class="epg-guide-row__name">
|
||||
<b>{{ channel().name }}</b>
|
||||
@if (density() === 'comfortable') {
|
||||
<small>
|
||||
@if (active()) {
|
||||
▶ {{ 'EPG.GUIDE.PLAYING' | translate }}
|
||||
} @else if (nowBlock(); as now) {
|
||||
{{ now.block.program.title }}
|
||||
} @else if (showEmpty()) {
|
||||
{{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }}
|
||||
}
|
||||
</small>
|
||||
}
|
||||
</span>
|
||||
@if (active()) {
|
||||
<mat-icon class="epg-guide-row__playing">equalizer</mat-icon>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="epg-guide-row__lane"
|
||||
role="gridcell"
|
||||
[style.width.px]="trackWidthPx()"
|
||||
>
|
||||
@if (showEmpty()) {
|
||||
<span class="epg-guide-row__empty">{{
|
||||
'EPG.GUIDE.NO_PROGRAMME_INFO_LONG' | translate
|
||||
}}</span>
|
||||
} @else if (isLoading()) {
|
||||
<span
|
||||
class="epg-guide-row__skeleton"
|
||||
style="left: 24px; width: 180px"
|
||||
></span>
|
||||
<span
|
||||
class="epg-guide-row__skeleton"
|
||||
style="left: 220px; width: 260px"
|
||||
></span>
|
||||
<span
|
||||
class="epg-guide-row__skeleton"
|
||||
style="left: 500px; width: 200px"
|
||||
></span>
|
||||
}
|
||||
@for (item of blocks(); track item.key; let index = $index) {
|
||||
<div
|
||||
class="epg-guide-row__block"
|
||||
role="button"
|
||||
data-epg-guide-grid
|
||||
[attr.tabindex]="blockTabIndex(index)"
|
||||
[attr.data-tier]="item.tier"
|
||||
[class.is-past]="item.block.when === 'past'"
|
||||
[class.is-now]="item.block.when === 'now'"
|
||||
[class.is-future]="item.block.when === 'future'"
|
||||
[class.is-playing]="active() && item.block.when === 'now'"
|
||||
[class.is-focused]="focusedBlock() === index"
|
||||
[style.left.px]="item.leftPx"
|
||||
[style.width.px]="item.widthPx"
|
||||
[matTooltip]="
|
||||
item.tier === 'micro' || item.tier === 'narrow'
|
||||
? item.block.program.title
|
||||
: ''
|
||||
"
|
||||
[attr.aria-label]="item.block.program.title"
|
||||
(click)="onBlockClick(item, index)"
|
||||
(dblclick)="onBlockDoubleClick(item)"
|
||||
>
|
||||
@if (item.block.when === 'now') {
|
||||
<span
|
||||
class="epg-guide-row__fill"
|
||||
[style.width.%]="item.nowFillPercent"
|
||||
></span>
|
||||
}
|
||||
<span class="epg-guide-row__time">
|
||||
{{ item.block.startMs | date: 'HH:mm' }}
|
||||
@if (density() === 'comfortable') {
|
||||
– {{ item.block.stopMs | date: 'HH:mm' }}
|
||||
}
|
||||
</span>
|
||||
<span class="epg-guide-row__title">{{
|
||||
item.block.program.title
|
||||
}}</span>
|
||||
@if (item.block.when === 'now') {
|
||||
<span class="epg-guide-row__tag">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
}
|
||||
@if (item.canCatchUp && item.block.when === 'past') {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-guide-row__watch"
|
||||
(click)="onWatchClick($event, item)"
|
||||
[attr.aria-label]="'EPG.TIMELINE.WATCH' | translate"
|
||||
>
|
||||
<mat-icon>replay</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,311 @@
|
||||
@use '../epg-theme' as *;
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid $line;
|
||||
background: var(--app-content-bg);
|
||||
|
||||
&:hover {
|
||||
background: $surface-3;
|
||||
}
|
||||
&.is-active {
|
||||
background: var(--app-selection-surface);
|
||||
}
|
||||
&.is-focused {
|
||||
box-shadow: inset 0 0 0 2px $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
// The grid cell holding the channel column; the button inside it is the
|
||||
// keyboard/roving-focus target.
|
||||
.epg-guide-row__channel-cell {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
flex: 0 0 var(--epg-guide-channel-col, 232px);
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
background: inherit;
|
||||
border-right: 1px solid $line-strong;
|
||||
}
|
||||
|
||||
.epg-guide-row__channel {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px 0 14px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
color: $text-primary;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
:host(.is-active) &::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
background: $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__num {
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
font: 11px $font-mono;
|
||||
color: $text-tertiary;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.epg-guide-row__logo {
|
||||
flex: 0 0 auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: $surface-1;
|
||||
border: 1px solid $line;
|
||||
color: $text-tertiary;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
:host(.is-compact) & {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
small {
|
||||
font-size: 10.5px;
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
:host(.is-active) & small {
|
||||
color: $accent-text;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__playing {
|
||||
color: $accent-blue;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.epg-guide-row__lane {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.epg-guide-row__empty {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
|
||||
.epg-guide-row__skeleton {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
border-radius: 8px;
|
||||
background: $surface-2;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.epg-guide-row__block {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
bottom: 7px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid $line-strong;
|
||||
background: $surface-1;
|
||||
color: $text-primary;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
transition:
|
||||
border-color 120ms,
|
||||
transform 120ms;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent-blue;
|
||||
transform: translateY(-1px);
|
||||
z-index: 3;
|
||||
}
|
||||
&.is-focused,
|
||||
&:focus-visible {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 1px;
|
||||
z-index: 3;
|
||||
}
|
||||
&.is-past {
|
||||
background: transparent;
|
||||
border-color: $line;
|
||||
|
||||
.epg-guide-row__title {
|
||||
color: $text-secondary;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
&.is-now {
|
||||
background: var(--app-selection-surface);
|
||||
border-color: $accent-blue;
|
||||
|
||||
.epg-guide-row__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
&.is-playing {
|
||||
background: var(--app-selection-surface-strong);
|
||||
}
|
||||
&[data-tier='narrow'] {
|
||||
padding: 6px 8px;
|
||||
|
||||
.epg-guide-row__time,
|
||||
.epg-guide-row__tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
&[data-tier='micro'] {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
.epg-guide-row__time,
|
||||
.epg-guide-row__title,
|
||||
.epg-guide-row__tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
:host(.is-compact) & {
|
||||
top: 5px;
|
||||
bottom: 5px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 0 9px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__fill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: color-mix(in srgb, $accent-blue 14%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.epg-guide-row__time {
|
||||
position: relative;
|
||||
font: 10.5px $font-mono;
|
||||
color: $text-secondary;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.epg-guide-row__title {
|
||||
position: relative;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.epg-guide-row__tag {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 7px;
|
||||
font: 600 9px $font-mono;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-text;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
margin-right: 4px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
:host(.is-compact) & {
|
||||
position: static;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-guide-row__watch {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 5px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
&:hover {
|
||||
color: $accent-text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { buildGuideDayAxis, EpgGuideDayAxis } from './epg-guide-layout.util';
|
||||
import { EpgGuideRowStatus } from './epg-guide-programs.service';
|
||||
import { EpgGuideRowComponent } from './epg-guide-row.component';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
const CHANNEL: EpgGuideChannel = {
|
||||
id: 'a',
|
||||
number: 1,
|
||||
name: 'Channel a',
|
||||
logoUrl: null,
|
||||
epgKey: 'a',
|
||||
};
|
||||
|
||||
function todayDateKey(): string {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => `${value}`.padStart(2, '0');
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
|
||||
now.getDate()
|
||||
)}`;
|
||||
}
|
||||
|
||||
describe('EpgGuideRowComponent', () => {
|
||||
let fixture: ComponentFixture<EpgGuideRowComponent>;
|
||||
let component: EpgGuideRowComponent;
|
||||
let axis: EpgGuideDayAxis;
|
||||
/** Fixed "now" in the middle of the rendered day, so no case is clock-dependent. */
|
||||
let nowMs: number;
|
||||
|
||||
function program(
|
||||
title: string,
|
||||
fromNowMin: number,
|
||||
toNowMin: number
|
||||
): EpgProgram {
|
||||
return {
|
||||
start: new Date(nowMs + fromNowMin * MINUTE_MS).toISOString(),
|
||||
stop: new Date(nowMs + toNowMin * MINUTE_MS).toISOString(),
|
||||
channel: 'a',
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function render(
|
||||
programs: readonly EpgProgram[],
|
||||
overrides: {
|
||||
status?: EpgGuideRowStatus;
|
||||
catchUpAvailable?: boolean;
|
||||
rowFocused?: boolean;
|
||||
focusedBlock?: number | null;
|
||||
tabbable?: boolean;
|
||||
} = {}
|
||||
): void {
|
||||
fixture.componentRef.setInput('channel', CHANNEL);
|
||||
fixture.componentRef.setInput('axis', axis);
|
||||
fixture.componentRef.setInput('programs', programs);
|
||||
fixture.componentRef.setInput('status', overrides.status ?? 'loaded');
|
||||
fixture.componentRef.setInput('nowMs', nowMs);
|
||||
fixture.componentRef.setInput('hourWidthPx', 240);
|
||||
fixture.componentRef.setInput(
|
||||
'catchUpAvailable',
|
||||
overrides.catchUpAvailable ?? false
|
||||
);
|
||||
fixture.componentRef.setInput(
|
||||
'rowFocused',
|
||||
overrides.rowFocused ?? false
|
||||
);
|
||||
fixture.componentRef.setInput(
|
||||
'focusedBlock',
|
||||
overrides.focusedBlock ?? null
|
||||
);
|
||||
fixture.componentRef.setInput('tabbable', overrides.tabbable ?? false);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function tabIndexes(): (string | null)[] {
|
||||
return fixture.debugElement
|
||||
.queryAll(By.css('[data-epg-guide-grid]'))
|
||||
.map((element) =>
|
||||
(element.nativeElement as HTMLElement).getAttribute('tabindex')
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
axis = buildGuideDayAxis(todayDateKey());
|
||||
nowMs = axis.startMs + 12 * 60 * MINUTE_MS;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EpgGuideRowComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: new BehaviorSubject(null),
|
||||
onTranslationChange: new BehaviorSubject(null),
|
||||
onDefaultLangChange: new BehaviorSubject(null),
|
||||
instant: (key: string) => key,
|
||||
get: (key: string) => of(key),
|
||||
stream: (key: string) => of(key),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(EpgGuideRowComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('activates the channel when the on-now card is clicked', () => {
|
||||
render([program('on now', -20, 40)]);
|
||||
const activated = jest.fn();
|
||||
const details = jest.fn();
|
||||
component.channelActivated.subscribe(activated);
|
||||
component.detailsRequested.subscribe(details);
|
||||
|
||||
const card = fixture.debugElement.query(
|
||||
By.css('.epg-guide-row__block')
|
||||
);
|
||||
expect(card.nativeElement.classList).toContain('is-now');
|
||||
card.nativeElement.click();
|
||||
|
||||
expect(activated).toHaveBeenCalled();
|
||||
expect(details).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens details for a past card instead of switching channel', () => {
|
||||
render([program('earlier', -180, -120)]);
|
||||
const activated = jest.fn();
|
||||
const details = jest.fn();
|
||||
component.channelActivated.subscribe(activated);
|
||||
component.detailsRequested.subscribe(details);
|
||||
|
||||
fixture.debugElement
|
||||
.query(By.css('.epg-guide-row__block'))
|
||||
.nativeElement.click();
|
||||
|
||||
expect(details).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: 'block' })
|
||||
);
|
||||
expect(activated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports a catch-up click as watch only, not as details', () => {
|
||||
render([program('earlier', -180, -120)], { catchUpAvailable: true });
|
||||
const details = jest.fn();
|
||||
const watch = jest.fn();
|
||||
component.detailsRequested.subscribe(details);
|
||||
component.watchRequested.subscribe(watch);
|
||||
|
||||
const button = fixture.debugElement.query(
|
||||
By.css('.epg-guide-row__watch')
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
button.nativeElement.click();
|
||||
|
||||
expect(watch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ canCatchUp: true })
|
||||
);
|
||||
expect(details).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the empty note when the host says there is no EPG', () => {
|
||||
render([], { status: 'none' });
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.epg-guide-row__empty'))
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('.epg-guide-row__skeleton'))
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders skeletons while programmes are loading', () => {
|
||||
render([], { status: 'loading' });
|
||||
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('.epg-guide-row__skeleton'))
|
||||
.length
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.epg-guide-row__empty'))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('moves the roving tabindex between the channel cell and the focused card', () => {
|
||||
// [channel cell, card] — a row nobody points at is fully untabbable.
|
||||
render([program('on now', -20, 40)]);
|
||||
expect(tabIndexes()).toEqual(['-1', '-1']);
|
||||
|
||||
// The shell's fallback row: Tab lands on the channel cell.
|
||||
render([program('on now', -20, 40)], { tabbable: true });
|
||||
expect(tabIndexes()).toEqual(['0', '-1']);
|
||||
|
||||
// Keyboard focus on the row itself keeps it on the channel cell...
|
||||
render([program('on now', -20, 40)], { rowFocused: true });
|
||||
expect(tabIndexes()).toEqual(['0', '-1']);
|
||||
|
||||
// ...and moves to the card once a programme is focused.
|
||||
render([program('on now', -20, 40)], {
|
||||
rowFocused: true,
|
||||
focusedBlock: 0,
|
||||
});
|
||||
expect(tabIndexes()).toEqual(['-1', '0']);
|
||||
});
|
||||
|
||||
it('reports the clicked cell so the roving focus follows the mouse', () => {
|
||||
render([program('on now', -20, 40)]);
|
||||
const focused: (number | null)[] = [];
|
||||
component.focusRequested.subscribe((block) => focused.push(block));
|
||||
|
||||
fixture.debugElement
|
||||
.query(By.css('.epg-guide-row__channel'))
|
||||
.nativeElement.click();
|
||||
fixture.debugElement
|
||||
.query(By.css('.epg-guide-row__block'))
|
||||
.nativeElement.click();
|
||||
|
||||
expect(focused).toEqual([null, 0]);
|
||||
});
|
||||
|
||||
it('exposes the grid roles the guide viewport announces', () => {
|
||||
render([program('on now', -20, 40)]);
|
||||
|
||||
expect(fixture.nativeElement.getAttribute('role')).toBe('row');
|
||||
expect(
|
||||
fixture.debugElement
|
||||
.queryAll(By.css('[role="gridcell"]'))
|
||||
.map((cell) => (cell.nativeElement as HTMLElement).className)
|
||||
).toEqual(['epg-guide-row__channel-cell', 'epg-guide-row__lane']);
|
||||
});
|
||||
|
||||
it('marks its channel cell and cards as guide-owned keyboard surfaces', () => {
|
||||
render([program('on now', -20, 40)]);
|
||||
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('[data-epg-guide-grid]'))
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import {
|
||||
buildGuideRowBlocks,
|
||||
EPG_GUIDE_ZOOM_DEFAULT,
|
||||
EpgGuideDayAxis,
|
||||
EpgGuideDensity,
|
||||
guideTrackWidthPx,
|
||||
} from './epg-guide-layout.util';
|
||||
import { EpgGuideRowStatus } from './epg-guide-programs.service';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
/**
|
||||
* One channel row: the sticky channel cell and the lane with positioned
|
||||
* programme cards. Layout is recomputed only when its own inputs change, so a
|
||||
* 2000-row guide re-lays-out one row per programme batch, not the grid.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-guide-row',
|
||||
imports: [DatePipe, MatIconModule, MatTooltipModule, TranslatePipe],
|
||||
templateUrl: './epg-guide-row.component.html',
|
||||
styleUrl: './epg-guide-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'epg-guide-row',
|
||||
role: 'row',
|
||||
'[class.is-active]': 'active()',
|
||||
'[class.is-focused]': 'rowFocused()',
|
||||
'[class.is-compact]': 'density() === "compact"',
|
||||
},
|
||||
})
|
||||
export class EpgGuideRowComponent {
|
||||
readonly channel = input.required<EpgGuideChannel>();
|
||||
readonly programs = input<readonly EpgProgram[]>([]);
|
||||
readonly status = input<EpgGuideRowStatus>('idle');
|
||||
readonly axis = input.required<EpgGuideDayAxis>();
|
||||
readonly hourWidthPx = input(EPG_GUIDE_ZOOM_DEFAULT);
|
||||
readonly nowMs = input(0);
|
||||
readonly offsetMinutes = input(0);
|
||||
readonly catchUpAvailable = input(false);
|
||||
readonly active = input(false);
|
||||
readonly density = input<EpgGuideDensity>('comfortable');
|
||||
readonly rowFocused = input(false);
|
||||
readonly focusedBlock = input<number | null>(null);
|
||||
/**
|
||||
* True when this row owns the grid's single `tabindex="0"` — the shell
|
||||
* hands it to the focused row, else the playing one, else the first, so
|
||||
* Tab always reaches the grid.
|
||||
*/
|
||||
readonly tabbable = input(false);
|
||||
|
||||
/** Single click on the channel cell or the on-now card. */
|
||||
readonly channelActivated = output<void>();
|
||||
/** Double click: activate and close the guide. */
|
||||
readonly channelCommitted = output<void>();
|
||||
readonly detailsRequested = output<TimelineRenderBlock>();
|
||||
readonly watchRequested = output<TimelineRenderBlock>();
|
||||
/**
|
||||
* A pointer landed on a grid cell: the block's index, or `null` for the
|
||||
* channel cell. The shell moves its keyboard focus there so the roving
|
||||
* tabindex follows the mouse.
|
||||
*/
|
||||
readonly focusRequested = output<number | null>();
|
||||
|
||||
readonly blocks = computed(() =>
|
||||
buildGuideRowBlocks(this.programs(), {
|
||||
axis: this.axis(),
|
||||
hourWidthPx: this.hourWidthPx(),
|
||||
nowMs: this.nowMs(),
|
||||
offsetMinutes: this.offsetMinutes(),
|
||||
catchUpAvailable: this.catchUpAvailable(),
|
||||
})
|
||||
);
|
||||
readonly nowBlock = computed(
|
||||
() => this.blocks().find((item) => item.block.when === 'now') ?? null
|
||||
);
|
||||
readonly trackWidthPx = computed(() =>
|
||||
guideTrackWidthPx(this.axis(), this.hourWidthPx())
|
||||
);
|
||||
readonly showEmpty = computed(
|
||||
() =>
|
||||
this.status() === 'none' ||
|
||||
(this.status() === 'loaded' && this.blocks().length === 0)
|
||||
);
|
||||
readonly isLoading = computed(() => this.status() === 'loading');
|
||||
|
||||
/** This row holds the roving `tabindex="0"` while it is focused or picked. */
|
||||
private readonly roving = computed(
|
||||
() => this.tabbable() || this.rowFocused()
|
||||
);
|
||||
readonly channelTabIndex = computed(() =>
|
||||
this.roving() && this.focusedBlock() === null ? 0 : -1
|
||||
);
|
||||
|
||||
blockTabIndex(index: number): number {
|
||||
return this.roving() && this.focusedBlock() === index ? 0 : -1;
|
||||
}
|
||||
|
||||
onChannelClick(): void {
|
||||
this.focusRequested.emit(null);
|
||||
this.channelActivated.emit();
|
||||
}
|
||||
|
||||
onBlockClick(item: TimelineRenderBlock, index: number): void {
|
||||
this.focusRequested.emit(index);
|
||||
if (item.block.when === 'now') {
|
||||
this.channelActivated.emit();
|
||||
} else {
|
||||
this.detailsRequested.emit(item);
|
||||
}
|
||||
}
|
||||
|
||||
onBlockDoubleClick(item: TimelineRenderBlock): void {
|
||||
if (item.block.when === 'now') {
|
||||
this.channelCommitted.emit();
|
||||
}
|
||||
}
|
||||
|
||||
onWatchClick(event: Event, item: TimelineRenderBlock): void {
|
||||
event.stopPropagation();
|
||||
this.watchRequested.emit(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
guideBlockRevealScrollLeft,
|
||||
guideNowScrollLeft,
|
||||
guideRowNeedsReveal,
|
||||
scrollElementLeft,
|
||||
} from './epg-guide-scroll.util';
|
||||
|
||||
function block(leftPx: number, widthPx: number) {
|
||||
return { leftPx, widthPx };
|
||||
}
|
||||
|
||||
describe('guideNowScrollLeft', () => {
|
||||
it('puts the now-line a third into the visible lane', () => {
|
||||
// 1100 - 200 = 900 visible, a third of it is 300.
|
||||
expect(guideNowScrollLeft(1100, 900, 200)).toBe(600);
|
||||
});
|
||||
|
||||
it('never scrolls before the start of the track', () => {
|
||||
expect(guideNowScrollLeft(1000, 100, 200)).toBe(0);
|
||||
});
|
||||
|
||||
it('survives a viewport narrower than the channel column', () => {
|
||||
expect(guideNowScrollLeft(100, 500, 200)).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guideRowNeedsReveal', () => {
|
||||
it('is false for a row fully inside the viewport', () => {
|
||||
expect(guideRowNeedsReveal(3, 60, 100, 400)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for a row above the viewport', () => {
|
||||
expect(guideRowNeedsReveal(1, 60, 100, 400)).toBe(true);
|
||||
});
|
||||
|
||||
it('is true for a row only partly below the bottom edge', () => {
|
||||
// Row 8 spans 480–540, the viewport ends at 100 + 400 = 500.
|
||||
expect(guideRowNeedsReveal(8, 60, 100, 400)).toBe(true);
|
||||
// Row 7 spans 420–480 and still fits.
|
||||
expect(guideRowNeedsReveal(7, 60, 100, 400)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a row flush with either edge as visible', () => {
|
||||
expect(guideRowNeedsReveal(2, 50, 100, 400)).toBe(false);
|
||||
expect(guideRowNeedsReveal(9, 50, 100, 400)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guideBlockRevealScrollLeft', () => {
|
||||
it('returns null while the block is fully visible', () => {
|
||||
expect(guideBlockRevealScrollLeft(block(300, 100), 200, 800, 200)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('scrolls back with padding for a block left of the lane', () => {
|
||||
expect(guideBlockRevealScrollLeft(block(300, 100), 500, 800, 200)).toBe(
|
||||
260
|
||||
);
|
||||
});
|
||||
|
||||
it('scrolls forward for a block that overflows the right edge', () => {
|
||||
// Visible lane is 600 wide, so 100..900 ends past 0 + 600.
|
||||
expect(guideBlockRevealScrollLeft(block(100, 800), 0, 800, 200)).toBe(
|
||||
60
|
||||
);
|
||||
});
|
||||
|
||||
it('clamps the target to the start of the track', () => {
|
||||
expect(guideBlockRevealScrollLeft(block(10, 50), 200, 800, 200)).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollElementLeft', () => {
|
||||
it('uses scrollTo when the element implements it', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const element = { scrollTo, scrollLeft: 0 } as unknown as HTMLElement;
|
||||
scrollElementLeft(element, 120, true);
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
left: 120,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
scrollElementLeft(element, 10, false);
|
||||
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||
left: 10,
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to assigning scrollLeft (jsdom has no scrollTo)', () => {
|
||||
const element = { scrollLeft: 0 } as unknown as HTMLElement;
|
||||
scrollElementLeft(element, 42, true);
|
||||
expect(element.scrollLeft).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Pure scroll geometry for the guide viewport, extracted from the shell so the
|
||||
* component stays small and the maths can be unit-tested without a DOM.
|
||||
*/
|
||||
|
||||
/** Horizontal padding kept to the left of a revealed programme block. */
|
||||
const REVEAL_PADDING_PX = 40;
|
||||
|
||||
/** Puts the now-line a third into the visible lane, never past the start. */
|
||||
export function guideNowScrollLeft(
|
||||
clientWidth: number,
|
||||
nowLeftPx: number,
|
||||
channelColumnPx: number
|
||||
): number {
|
||||
const visibleTrack = Math.max(0, clientWidth - channelColumnPx);
|
||||
return Math.max(0, nowLeftPx - visibleTrack / 3);
|
||||
}
|
||||
|
||||
/** True when the row is (partly) outside the vertical viewport. */
|
||||
export function guideRowNeedsReveal(
|
||||
rowIndex: number,
|
||||
rowHeightPx: number,
|
||||
scrollTop: number,
|
||||
clientHeight: number
|
||||
): boolean {
|
||||
const rowTop = rowIndex * rowHeightPx;
|
||||
return (
|
||||
rowTop < scrollTop || rowTop + rowHeightPx > scrollTop + clientHeight
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Target scrollLeft that brings a block into the lane, or `null` when it is
|
||||
* already fully visible.
|
||||
*/
|
||||
export function guideBlockRevealScrollLeft(
|
||||
block: { readonly leftPx: number; readonly widthPx: number },
|
||||
scrollLeft: number,
|
||||
clientWidth: number,
|
||||
channelColumnPx: number
|
||||
): number | null {
|
||||
const visibleWidth = Math.max(0, clientWidth - channelColumnPx);
|
||||
const fitsLeft = block.leftPx >= scrollLeft;
|
||||
const fitsRight = block.leftPx + block.widthPx <= scrollLeft + visibleWidth;
|
||||
if (fitsLeft && fitsRight) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, block.leftPx - REVEAL_PADDING_PX);
|
||||
}
|
||||
|
||||
/**
|
||||
* `Element.scrollTo` is not implemented everywhere the guide renders (jsdom in
|
||||
* unit tests), so fall back to assigning `scrollLeft` directly.
|
||||
*/
|
||||
export function scrollElementLeft(
|
||||
element: HTMLElement,
|
||||
left: number,
|
||||
animate: boolean
|
||||
): void {
|
||||
if (typeof element.scrollTo === 'function') {
|
||||
element.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' });
|
||||
return;
|
||||
}
|
||||
element.scrollLeft = left;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { computed, signal } from '@angular/core';
|
||||
import { EpgGuideSearchHit } from './epg-guide-source';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const SEARCH_MAX_RESULTS = 20;
|
||||
/** Shortest query that runs a search — and shows the results panel. */
|
||||
export const EPG_GUIDE_SEARCH_MIN_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Debounced programme search for the guide toolbar. The host's
|
||||
* `searchPrograms` is optional, so `enabled` decides whether the field is
|
||||
* rendered at all; a response is dropped when the query moved on while it was
|
||||
* in flight, and a failing search yields no results rather than an error.
|
||||
*/
|
||||
export class EpgGuideSearchController {
|
||||
readonly query = signal('');
|
||||
readonly results = signal<EpgGuideSearchHit[]>([]);
|
||||
readonly enabled: boolean;
|
||||
/** True while the query is long enough for the results panel to show. */
|
||||
readonly panelVisible = computed(
|
||||
() =>
|
||||
this.enabled &&
|
||||
this.query().trim().length >= EPG_GUIDE_SEARCH_MIN_LENGTH
|
||||
);
|
||||
|
||||
private timer?: number;
|
||||
|
||||
constructor(
|
||||
private readonly search?: (
|
||||
query: string
|
||||
) => Promise<EpgGuideSearchHit[]>
|
||||
) {
|
||||
this.enabled = typeof search === 'function';
|
||||
}
|
||||
|
||||
setQuery(query: string): void {
|
||||
this.query.set(query);
|
||||
window.clearTimeout(this.timer);
|
||||
const term = query.trim();
|
||||
if (term.length < EPG_GUIDE_SEARCH_MIN_LENGTH || !this.search) {
|
||||
this.results.set([]);
|
||||
return;
|
||||
}
|
||||
this.timer = window.setTimeout(async () => {
|
||||
// The host's search may throw synchronously (before it ever
|
||||
// returns a promise), which `.catch()` alone would not contain.
|
||||
const hits = await this.runSearch(term);
|
||||
if (this.query() === query) {
|
||||
this.results.set(hits.slice(0, SEARCH_MAX_RESULTS));
|
||||
}
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
window.clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
private async runSearch(term: string): Promise<EpgGuideSearchHit[]> {
|
||||
try {
|
||||
return (await this.search?.(term)) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { InjectionToken, Signal } from '@angular/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* One row of the guide. `id` is the host's ROW id, unique within the current
|
||||
* scope (M3U: `<index>:<Channel.id>`, since playlist ids can repeat); it is
|
||||
* only ever handed back to the same host (`activate`, search hits).
|
||||
*/
|
||||
export interface EpgGuideChannel {
|
||||
id: string;
|
||||
/** 1-based position inside the current scope; shown in the channel cell. */
|
||||
number: number;
|
||||
name: string;
|
||||
logoUrl: string | null;
|
||||
/**
|
||||
* Programme lookup key (M3U: tvg-id, else tvg-name, else name). `null`
|
||||
* means the host already knows there is no EPG binding, so the guide
|
||||
* renders "no programme information" without asking.
|
||||
*/
|
||||
epgKey: string | null;
|
||||
}
|
||||
|
||||
export type EpgGuideScopeKind = 'all' | 'group' | 'favorites';
|
||||
|
||||
export interface EpgGuideScope {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: EpgGuideScopeKind;
|
||||
}
|
||||
|
||||
/** A request window. Instants are provider-clock ms (display offset removed). */
|
||||
export interface EpgGuideWindow {
|
||||
readonly channels: readonly EpgGuideChannel[];
|
||||
readonly fromMs: number;
|
||||
readonly toMs: number;
|
||||
}
|
||||
|
||||
export interface EpgGuideCatchUp {
|
||||
canWatch(channel: EpgGuideChannel, program: EpgProgram): boolean;
|
||||
watch(channel: EpgGuideChannel, program: EpgProgram): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One programme search result. `channelId` is the matching row's
|
||||
* `EpgGuideChannel.id` when the host can resolve it (e.g. an exact `epgKey`
|
||||
* match) — `null` when the host cannot say which row the hit belongs to, in
|
||||
* which case the guide can show the hit but not jump to or highlight a row.
|
||||
*/
|
||||
export interface EpgGuideSearchHit {
|
||||
channelId: string | null;
|
||||
/** Display name for the hit's channel: the playlist row's when resolved, else the guide source's own (e.g. the XMLTV display name). */
|
||||
channelName?: string | null;
|
||||
program: EpgProgram;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the guide needs from its host. The host owns scope state,
|
||||
* playback and the player; the guide owns rendering, caching and keyboard
|
||||
* navigation. Portal hosts implement the same contract with their own EPG
|
||||
* feeds (sub-project 2).
|
||||
*/
|
||||
export interface EpgGuideSource {
|
||||
/** Scope-resolved channels in playlist order (radio/movies excluded by host). */
|
||||
readonly channels: Signal<EpgGuideChannel[]>;
|
||||
readonly scopes: Signal<EpgGuideScope[]>;
|
||||
readonly scopeId: Signal<string>;
|
||||
setScope(id: string): void;
|
||||
/** Programmes overlapping the window, keyed by `EpgGuideChannel.id`. */
|
||||
loadPrograms(range: EpgGuideWindow): Promise<Map<string, EpgProgram[]>>;
|
||||
/** Ids of channels with at least one programme in the window. */
|
||||
loadCoverage(range: EpgGuideWindow): Promise<Set<string>>;
|
||||
readonly activeChannelId: Signal<string | null>;
|
||||
/**
|
||||
* False while the host plays something other than the active channel's
|
||||
* live stream (catch-up/archive). The guide then lets the active row be
|
||||
* activated again, which is how the host returns to live.
|
||||
*/
|
||||
readonly livePlayback?: Signal<boolean>;
|
||||
/** Switch playback; the guide stays open. */
|
||||
activate(channelId: string): void;
|
||||
/** Optional programme search; the toolbar hides its field when absent. */
|
||||
searchPrograms?(query: string): Promise<EpgGuideSearchHit[]>;
|
||||
readonly catchUp?: EpgGuideCatchUp;
|
||||
}
|
||||
|
||||
export const EPG_GUIDE_SOURCE = new InjectionToken<EpgGuideSource>(
|
||||
'EPG_GUIDE_SOURCE'
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
<div class="guide-toolbar">
|
||||
<div class="guide-toolbar__day">
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="stepDay.emit('prev')"
|
||||
[attr.aria-label]="'EPG.PREVIOUS_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<span class="guide-toolbar__date">
|
||||
{{ dayMs() | date: 'EEE, d MMM' : '' : currentLocale() }}
|
||||
@if (isToday()) {
|
||||
<small>{{ 'EPG.GUIDE.TODAY' | translate }}</small>
|
||||
}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="stepDay.emit('next')"
|
||||
[attr.aria-label]="'EPG.NEXT_DAY' | translate"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="guide-toolbar__now"
|
||||
(click)="jumpNow.emit()"
|
||||
[matTooltip]="'EPG.GUIDE.JUMP_NOW' | translate"
|
||||
>
|
||||
{{ 'EPG.GUIDE.NOW' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="guide-toolbar__scope"
|
||||
[matMenuTriggerFor]="scopeMenu"
|
||||
[attr.aria-label]="'EPG.GUIDE.SCOPE' | translate"
|
||||
>
|
||||
{{ scopeLabel() }} <mat-icon>arrow_drop_down</mat-icon>
|
||||
</button>
|
||||
<mat-menu #scopeMenu="matMenu" class="guide-toolbar__scope-menu">
|
||||
@for (scope of scopes(); track scope.id) {
|
||||
<button
|
||||
mat-menu-item
|
||||
[class.is-selected]="scope.id === scopeId()"
|
||||
(click)="scopeChange.emit(scope.id)"
|
||||
>
|
||||
{{ scope.label }}
|
||||
</button>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
<label
|
||||
class="guide-toolbar__toggle"
|
||||
[class.is-on]="onlyWithEpg()"
|
||||
[matTooltip]="'EPG.GUIDE.ONLY_WITH_EPG_HINT' | translate"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="onlyWithEpg()"
|
||||
[disabled]="!coverageLoaded()"
|
||||
(change)="onlyWithEpgChange.emit(!onlyWithEpg())"
|
||||
/>
|
||||
<i aria-hidden="true"></i>
|
||||
<span>{{ 'EPG.GUIDE.ONLY_WITH_EPG' | translate }}</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="guide-toolbar__density"
|
||||
(click)="toggleDensity()"
|
||||
[matTooltip]="densityLabelKey() | translate"
|
||||
[attr.aria-label]="densityLabelKey() | translate"
|
||||
>
|
||||
<mat-icon>{{ densityIcon() }}</mat-icon>
|
||||
</button>
|
||||
|
||||
<label
|
||||
class="guide-toolbar__zoom"
|
||||
[matTooltip]="'EPG.GUIDE.ZOOM' | translate"
|
||||
>
|
||||
<mat-icon>zoom_in</mat-icon>
|
||||
<input
|
||||
type="range"
|
||||
[min]="zoomMin"
|
||||
[max]="zoomMax"
|
||||
[step]="zoomStep"
|
||||
[value]="zoom()"
|
||||
(input)="onZoomInput($event)"
|
||||
[attr.aria-label]="'EPG.GUIDE.ZOOM' | translate"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<span class="guide-toolbar__spacer"></span>
|
||||
|
||||
<label class="guide-toolbar__input">
|
||||
<mat-icon>search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
[value]="filter()"
|
||||
(input)="onFilterInput($event)"
|
||||
(keydown.escape)="onFilterEscape($event)"
|
||||
[placeholder]="'EPG.GUIDE.FILTER_CHANNELS' | translate"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (filter()) {
|
||||
<span class="guide-toolbar__count"
|
||||
>{{ shownCount() }} / {{ totalCount() }}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="filterChange.emit('')"
|
||||
[attr.aria-label]="'EPG.GUIDE.CLEAR_FILTER' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
|
||||
@if (searchEnabled()) {
|
||||
<label class="guide-toolbar__input guide-toolbar__input--search">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
<input
|
||||
type="text"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchInput($event)"
|
||||
(keydown.escape)="onSearchEscape($event)"
|
||||
[placeholder]="'EPG.GUIDE.SEARCH_PROGRAMMES' | translate"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
(click)="searchQueryChange.emit('')"
|
||||
[attr.aria-label]="'CLEAR' | translate"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,285 @@
|
||||
@use '../epg-theme' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// The guide lives in a lane that clips its overflow (the player dock, a split
|
||||
// pane), so a toolbar that cannot fit one row must wrap: pushed-out controls
|
||||
// would simply be unreachable. Everything but the two text fields keeps its
|
||||
// size; the fields shrink first, then the row breaks.
|
||||
.guide-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
row-gap: 8px;
|
||||
min-height: 52px;
|
||||
padding: 6px 18px;
|
||||
border-bottom: 1px solid $line;
|
||||
background: var(--app-content-bg);
|
||||
color: $text-primary;
|
||||
|
||||
button,
|
||||
label,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__day,
|
||||
.guide-toolbar__now,
|
||||
.guide-toolbar__scope,
|
||||
.guide-toolbar__toggle,
|
||||
.guide-toolbar__density,
|
||||
.guide-toolbar__zoom {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.guide-toolbar__day {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 8px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
border-left: 1px solid $line-strong;
|
||||
border-right: 1px solid $line-strong;
|
||||
height: 100%;
|
||||
|
||||
small {
|
||||
font: 10px $font-mono;
|
||||
letter-spacing: 0.1em;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__now,
|
||||
.guide-toolbar__scope {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid $line-strong;
|
||||
background: transparent;
|
||||
color: $text-primary;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: $surface-2;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__now {
|
||||
border-color: $accent-blue;
|
||||
color: $accent-text;
|
||||
background: var(--app-selection-surface);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, $accent-live 20%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__scope mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
margin-right: -6px;
|
||||
}
|
||||
|
||||
.guide-toolbar__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
i {
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
border-radius: 99px;
|
||||
background: $surface-3;
|
||||
position: relative;
|
||||
transition: background 120ms;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: $text-tertiary;
|
||||
transition:
|
||||
left 120ms,
|
||||
background 120ms;
|
||||
}
|
||||
}
|
||||
&.is-on i {
|
||||
background: $accent-blue;
|
||||
|
||||
&::after {
|
||||
left: 16px;
|
||||
background: var(--app-content-bg);
|
||||
}
|
||||
}
|
||||
&:has(input:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
&:has(input:focus-visible) i {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__zoom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: $text-secondary;
|
||||
|
||||
input[type='range'] {
|
||||
width: 110px;
|
||||
accent-color: $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__spacer {
|
||||
flex: 1;
|
||||
min-width: 8px;
|
||||
}
|
||||
|
||||
.guide-toolbar__input {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 4px 0 10px;
|
||||
// Grows to its old fixed width when there is room and shrinks before the
|
||||
// row wraps; `max-width` keeps the wide-window layout unchanged.
|
||||
flex: 1 1 160px;
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
max-width: 190px;
|
||||
border: 1px solid $line-strong;
|
||||
border-radius: 8px;
|
||||
background: $surface-1;
|
||||
color: $text-tertiary;
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $text-primary;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-tertiary;
|
||||
}
|
||||
}
|
||||
button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
&:focus-within {
|
||||
border-color: $accent-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__input--search {
|
||||
flex-basis: 200px;
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
// Too narrow for one row: the flexible spacer goes, the toggle keeps only
|
||||
// its switch (the label stays in the DOM for assistive technology and the
|
||||
// tooltip still names it) and the programme search takes the second row.
|
||||
.guide-toolbar__spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.guide-toolbar__toggle span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.guide-toolbar__input--search {
|
||||
flex: 1 1 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.guide-toolbar__count {
|
||||
font: 10.5px $font-mono;
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// CDK menus render outside the component, so the scope menu needs a piercing
|
||||
// rule rather than component-scoped styling.
|
||||
::ng-deep .guide-toolbar__scope-menu {
|
||||
max-height: 60vh;
|
||||
|
||||
.is-selected {
|
||||
color: var(--app-selection-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgDateNavigationDirection } from '../epg-date';
|
||||
import {
|
||||
EPG_GUIDE_ZOOM_MAX,
|
||||
EPG_GUIDE_ZOOM_MIN,
|
||||
EPG_GUIDE_ZOOM_STEP,
|
||||
EpgGuideDensity,
|
||||
} from './epg-guide-layout.util';
|
||||
import { EpgGuideScope } from './epg-guide-source';
|
||||
|
||||
/**
|
||||
* Guide toolbar: day stepper, "Now", scope menu, "Only with EPG", density,
|
||||
* zoom, the channel filter and (when the host supports it) programme search.
|
||||
* Purely presentational — every control reports through an output and the
|
||||
* guide shell owns the state.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-guide-toolbar',
|
||||
imports: [
|
||||
DatePipe,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatMenuModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './epg-guide-toolbar.component.html',
|
||||
styleUrl: './epg-guide-toolbar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EpgGuideToolbarComponent {
|
||||
readonly dayMs = input.required<number>();
|
||||
readonly isToday = input(false);
|
||||
readonly currentLocale = input('en');
|
||||
readonly scopes = input<EpgGuideScope[]>([]);
|
||||
readonly scopeId = input('');
|
||||
readonly onlyWithEpg = input(false);
|
||||
readonly coverageLoaded = input(false);
|
||||
readonly density = input<EpgGuideDensity>('comfortable');
|
||||
readonly zoom = input(EPG_GUIDE_ZOOM_MIN);
|
||||
readonly filter = input('');
|
||||
readonly searchEnabled = input(false);
|
||||
readonly searchQuery = input('');
|
||||
readonly shownCount = input(0);
|
||||
readonly totalCount = input(0);
|
||||
|
||||
readonly stepDay = output<EpgDateNavigationDirection>();
|
||||
readonly jumpNow = output<void>();
|
||||
readonly scopeChange = output<string>();
|
||||
readonly onlyWithEpgChange = output<boolean>();
|
||||
readonly densityChange = output<EpgGuideDensity>();
|
||||
readonly zoomChange = output<number>();
|
||||
readonly filterChange = output<string>();
|
||||
readonly searchQueryChange = output<string>();
|
||||
|
||||
readonly zoomMin = EPG_GUIDE_ZOOM_MIN;
|
||||
readonly zoomMax = EPG_GUIDE_ZOOM_MAX;
|
||||
readonly zoomStep = EPG_GUIDE_ZOOM_STEP;
|
||||
|
||||
readonly scopeLabel = computed(
|
||||
() =>
|
||||
this.scopes().find((scope) => scope.id === this.scopeId())?.label ??
|
||||
''
|
||||
);
|
||||
readonly densityIcon = computed(() =>
|
||||
this.density() === 'comfortable' ? 'density_medium' : 'density_small'
|
||||
);
|
||||
readonly densityLabelKey = computed(() =>
|
||||
this.density() === 'comfortable'
|
||||
? 'EPG.GUIDE.DENSITY_COMPACT'
|
||||
: 'EPG.GUIDE.DENSITY_COMFORTABLE'
|
||||
);
|
||||
|
||||
toggleDensity(): void {
|
||||
this.densityChange.emit(
|
||||
this.density() === 'comfortable' ? 'compact' : 'comfortable'
|
||||
);
|
||||
}
|
||||
|
||||
onZoomInput(event: Event): void {
|
||||
this.zoomChange.emit((event.target as HTMLInputElement).valueAsNumber);
|
||||
}
|
||||
|
||||
onFilterInput(event: Event): void {
|
||||
this.filterChange.emit((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
onSearchInput(event: Event): void {
|
||||
this.searchQueryChange.emit((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The guide's keyboard controller ignores keys typed into inputs, so Esc
|
||||
* is handled here: it first clears a non-empty field, and only blurs an
|
||||
* already-empty one so the next Esc reaches the guide and closes it.
|
||||
*/
|
||||
onFilterEscape(event: Event): void {
|
||||
this.onEscape(event, this.filter(), (value) =>
|
||||
this.filterChange.emit(value)
|
||||
);
|
||||
}
|
||||
|
||||
onSearchEscape(event: Event): void {
|
||||
this.onEscape(event, this.searchQuery(), (value) =>
|
||||
this.searchQueryChange.emit(value)
|
||||
);
|
||||
}
|
||||
|
||||
private onEscape(
|
||||
event: Event,
|
||||
value: string,
|
||||
clear: (value: string) => void
|
||||
): void {
|
||||
if (value.length > 0) {
|
||||
event.stopPropagation();
|
||||
clear('');
|
||||
return;
|
||||
}
|
||||
(event.target as HTMLElement | null)?.blur();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { getTodayEpgDateKey } from '../epg-date';
|
||||
import {
|
||||
EPG_GUIDE_DENSITY_KEY,
|
||||
EPG_GUIDE_ONLY_WITH_EPG_KEY,
|
||||
EPG_GUIDE_ZOOM_KEY,
|
||||
} from './epg-guide-preferences';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
import { EpgGuideViewState } from './epg-guide-view-state';
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
function channel(id: string, name = `Channel ${id}`): EpgGuideChannel {
|
||||
return { id, number: 1, name, logoUrl: null, epgKey: id };
|
||||
}
|
||||
|
||||
function create(): EpgGuideViewState {
|
||||
return TestBed.runInInjectionContext(() => new EpgGuideViewState());
|
||||
}
|
||||
|
||||
describe('EpgGuideViewState', () => {
|
||||
let state: EpgGuideViewState;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
state?.destroy();
|
||||
});
|
||||
|
||||
it('starts from the persisted preferences and writes changes back', () => {
|
||||
localStorage.setItem(EPG_GUIDE_DENSITY_KEY, 'compact');
|
||||
localStorage.setItem(EPG_GUIDE_ZOOM_KEY, '300');
|
||||
localStorage.setItem(EPG_GUIDE_ONLY_WITH_EPG_KEY, '1');
|
||||
|
||||
state = create();
|
||||
expect(state.density()).toBe('compact');
|
||||
expect(state.zoom()).toBe(300);
|
||||
expect(state.onlyWithEpg()).toBe(true);
|
||||
expect(state.rowHeightPx()).toBe(44);
|
||||
|
||||
// Out-of-range zoom is clamped before it is stored.
|
||||
state.setZoom(9_999);
|
||||
state.density.set('comfortable');
|
||||
TestBed.tick();
|
||||
|
||||
expect(state.zoom()).toBe(480);
|
||||
expect(localStorage.getItem(EPG_GUIDE_ZOOM_KEY)).toBe('480');
|
||||
expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('comfortable');
|
||||
});
|
||||
|
||||
it('filters rows by name and by EPG coverage', () => {
|
||||
state = create();
|
||||
const rows = [channel('a', 'News HD'), channel('b', 'Sports')];
|
||||
const covered = new Set(['b']);
|
||||
const isCovered = (id: string) => covered.has(id);
|
||||
|
||||
expect(state.filterRows(rows, isCovered)).toEqual(rows);
|
||||
|
||||
state.filter.set(' NEWS ');
|
||||
expect(state.filterRows(rows, isCovered).map((row) => row.id)).toEqual([
|
||||
'a',
|
||||
]);
|
||||
|
||||
state.filter.set('');
|
||||
state.onlyWithEpg.set(true);
|
||||
expect(state.filterRows(rows, isCovered).map((row) => row.id)).toEqual([
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('steps the day and returns to today', () => {
|
||||
state = create();
|
||||
const today = state.dayKey();
|
||||
|
||||
state.stepDay('next');
|
||||
expect(state.dayKey()).not.toBe(today);
|
||||
expect(state.axis().dayKey).toBe(state.dayKey());
|
||||
|
||||
state.goToToday();
|
||||
expect(state.dayKey()).toBe(getTodayEpgDateKey());
|
||||
});
|
||||
|
||||
it('hides the now-line once it scrolls behind the channel column', () => {
|
||||
state = create();
|
||||
const nowX = state.nowLeftPx();
|
||||
expect(nowX).not.toBeNull();
|
||||
|
||||
state.scrollLeft.set(Number(nowX) - 10);
|
||||
expect(state.nowLineLeftPx()).toBe(10);
|
||||
|
||||
state.scrollLeft.set(Number(nowX) + 10);
|
||||
expect(state.nowLineLeftPx()).toBeNull();
|
||||
});
|
||||
|
||||
it('lays out a row against the current day, zoom and clock', () => {
|
||||
state = create();
|
||||
const startMs = state.axis().startMs + 10 * 60 * MINUTE_MS;
|
||||
state.nowMs.set(startMs + 5 * MINUTE_MS);
|
||||
const program: EpgProgram = {
|
||||
start: new Date(startMs).toISOString(),
|
||||
stop: new Date(startMs + 30 * MINUTE_MS).toISOString(),
|
||||
channel: 'a',
|
||||
title: 'On now',
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
|
||||
const blocks = state.blocksFor([program], 0, false);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].block.when).toBe('now');
|
||||
// 10 hours into the day at the default 240px/hour zoom.
|
||||
expect(blocks[0].leftPx).toBeCloseTo(2400, 0);
|
||||
});
|
||||
|
||||
it('stops its clock when destroyed', () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
state = create();
|
||||
const first = state.nowMs();
|
||||
|
||||
jest.advanceTimersByTime(60_000);
|
||||
expect(state.nowMs()).toBeGreaterThan(first);
|
||||
|
||||
const last = state.nowMs();
|
||||
state.destroy();
|
||||
jest.advanceTimersByTime(120_000);
|
||||
expect(state.nowMs()).toBe(last);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { computed, effect, signal } from '@angular/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
} from '../epg-date';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import {
|
||||
buildGuideDayAxis,
|
||||
buildGuideRowBlocks,
|
||||
buildGuideTicks,
|
||||
EPG_GUIDE_ROW_HEIGHT_PX,
|
||||
EpgGuideDensity,
|
||||
guideNowLeftPx,
|
||||
guideTrackWidthPx,
|
||||
} from './epg-guide-layout.util';
|
||||
import {
|
||||
clampGuideZoom,
|
||||
persistEpgGuidePreferences,
|
||||
restoreEpgGuidePreferences,
|
||||
} from './epg-guide-preferences';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
/** How often the guide re-reads the clock; drives "on now" and the now-line. */
|
||||
const CLOCK_TICK_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Everything the guide renders itself from: the selected day, the persisted
|
||||
* view preferences (zoom, density, "only with EPG"), the channel filter, the
|
||||
* clock and the derived day geometry. It owns its own minute timer and the
|
||||
* preference-persisting effect, so it must be constructed inside an injection
|
||||
* context and released with `destroy()`.
|
||||
*
|
||||
* Split out of the shell component, which keeps ownership of the data source,
|
||||
* keyboard focus and the viewport.
|
||||
*/
|
||||
export class EpgGuideViewState {
|
||||
private readonly preferences = restoreEpgGuidePreferences();
|
||||
|
||||
readonly dayKey = signal(getTodayEpgDateKey());
|
||||
readonly zoom = signal(this.preferences.zoom);
|
||||
readonly density = signal<EpgGuideDensity>(this.preferences.density);
|
||||
readonly onlyWithEpg = signal(this.preferences.onlyWithEpg);
|
||||
readonly filter = signal('');
|
||||
readonly nowMs = signal(Date.now());
|
||||
/** The viewport's horizontal offset; the ruler and now-line follow it. */
|
||||
readonly scrollLeft = signal(0);
|
||||
|
||||
readonly axis = computed(() => buildGuideDayAxis(this.dayKey()));
|
||||
readonly isToday = computed(() => {
|
||||
this.nowMs();
|
||||
return this.dayKey() === getTodayEpgDateKey();
|
||||
});
|
||||
readonly ticks = computed(() => buildGuideTicks(this.axis(), this.zoom()));
|
||||
readonly trackWidthPx = computed(() =>
|
||||
guideTrackWidthPx(this.axis(), this.zoom())
|
||||
);
|
||||
readonly nowLeftPx = computed(() =>
|
||||
guideNowLeftPx(this.axis(), this.nowMs(), this.zoom())
|
||||
);
|
||||
/**
|
||||
* The now-line's x inside the scrolling lane, or `null` when it is not on
|
||||
* the selected day or has scrolled behind the sticky channel column — the
|
||||
* clip layer starts at the column's right edge, so a negative offset would
|
||||
* otherwise be painted under it.
|
||||
*/
|
||||
readonly nowLineLeftPx = computed(() => {
|
||||
const nowX = this.nowLeftPx();
|
||||
if (nowX === null) {
|
||||
return null;
|
||||
}
|
||||
const left = nowX - this.scrollLeft();
|
||||
return left >= 0 ? left : null;
|
||||
});
|
||||
readonly rowHeightPx = computed(
|
||||
() => EPG_GUIDE_ROW_HEIGHT_PX[this.density()]
|
||||
);
|
||||
|
||||
private readonly clock = window.setInterval(
|
||||
() => this.nowMs.set(Date.now()),
|
||||
CLOCK_TICK_MS
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() =>
|
||||
persistEpgGuidePreferences({
|
||||
density: this.density(),
|
||||
zoom: this.zoom(),
|
||||
onlyWithEpg: this.onlyWithEpg(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the channel filter and the "only with EPG" toggle. `isCovered` is
|
||||
* called synchronously, so a signal it reads is tracked by the caller's
|
||||
* own computed.
|
||||
*/
|
||||
filterRows(
|
||||
channels: readonly EpgGuideChannel[],
|
||||
isCovered: (channelId: string) => boolean
|
||||
): EpgGuideChannel[] {
|
||||
const needle = this.filter().trim().toLowerCase();
|
||||
const onlyWithEpg = this.onlyWithEpg();
|
||||
return channels.filter(
|
||||
(channel) =>
|
||||
(!needle || channel.name.toLowerCase().includes(needle)) &&
|
||||
(!onlyWithEpg || isCovered(channel.id))
|
||||
);
|
||||
}
|
||||
|
||||
setZoom(value: number): void {
|
||||
this.zoom.set(clampGuideZoom(value));
|
||||
}
|
||||
|
||||
stepDay(direction: EpgDateNavigationDirection): void {
|
||||
this.dayKey.set(shiftEpgDateKey(this.dayKey(), direction));
|
||||
}
|
||||
|
||||
/** Select today and re-read the clock; the caller does the scrolling. */
|
||||
goToToday(): void {
|
||||
this.dayKey.set(getTodayEpgDateKey());
|
||||
this.nowMs.set(Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Row-block geometry for the current day, zoom and clock — the same layout
|
||||
* the row component renders, needed by keyboard focus and scroll reveal.
|
||||
*/
|
||||
blocksFor(
|
||||
programs: readonly EpgProgram[],
|
||||
offsetMinutes: number,
|
||||
catchUpAvailable: boolean
|
||||
): TimelineRenderBlock[] {
|
||||
return buildGuideRowBlocks(programs, {
|
||||
axis: this.axis(),
|
||||
hourWidthPx: this.zoom(),
|
||||
nowMs: this.nowMs(),
|
||||
offsetMinutes,
|
||||
catchUpAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
window.clearInterval(this.clock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ListRange } from '@angular/cdk/collections';
|
||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||
import { DestroyRef } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
import {
|
||||
EpgGuideViewportController,
|
||||
EpgGuideViewportHost,
|
||||
} from './epg-guide-viewport.controller';
|
||||
|
||||
const CHANNEL_COLUMN_PX = 200;
|
||||
|
||||
function channels(count: number): EpgGuideChannel[] {
|
||||
return Array.from({ length: count }, (_unused, index) => ({
|
||||
id: `c${index}`,
|
||||
number: index + 1,
|
||||
name: `Channel ${index}`,
|
||||
logoUrl: null,
|
||||
epgKey: `c${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function block(leftPx: number, widthPx: number): TimelineRenderBlock {
|
||||
return {
|
||||
kind: 'block',
|
||||
key: `b${leftPx}`,
|
||||
block: {
|
||||
program: {
|
||||
start: '',
|
||||
stop: '',
|
||||
channel: 'c0',
|
||||
title: 't',
|
||||
desc: null,
|
||||
category: null,
|
||||
},
|
||||
key: `b${leftPx}`,
|
||||
startMs: 0,
|
||||
stopMs: 1,
|
||||
when: 'now',
|
||||
offsetMin: 0,
|
||||
durationMin: 1,
|
||||
},
|
||||
leftPx,
|
||||
widthPx,
|
||||
tier: 'wide',
|
||||
nowFillPercent: 0,
|
||||
canCatchUp: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
controller: EpgGuideViewportController;
|
||||
host: EpgGuideViewportHost;
|
||||
viewport: CdkVirtualScrollViewport;
|
||||
element: HTMLElement;
|
||||
renderedRange$: Subject<ListRange>;
|
||||
scrolled$: Subject<Event>;
|
||||
destroy: () => void;
|
||||
destroyRef: DestroyRef;
|
||||
ensureLoaded: jest.Mock;
|
||||
setScrollLeft: jest.Mock;
|
||||
scrollToIndex: jest.Mock;
|
||||
checkViewportSize: jest.Mock;
|
||||
scrollTo: jest.Mock;
|
||||
}
|
||||
|
||||
function harness(rowCount = 100): Harness {
|
||||
const renderedRange$ = new Subject<ListRange>();
|
||||
const scrolled$ = new Subject<Event>();
|
||||
const destroyHandlers: Array<() => void> = [];
|
||||
const destroyRef = {
|
||||
onDestroy: (handler: () => void) => {
|
||||
destroyHandlers.push(handler);
|
||||
return () => undefined;
|
||||
},
|
||||
} as unknown as DestroyRef;
|
||||
const scrollTo = jest.fn();
|
||||
// A real element (a ResizeObserver, where one exists, only accepts one),
|
||||
// with the layout properties jsdom always reports as 0 shadowed.
|
||||
const element = document.createElement('div');
|
||||
Object.defineProperty(element, 'clientWidth', { value: 1000 });
|
||||
Object.defineProperty(element, 'clientHeight', { value: 400 });
|
||||
Object.defineProperty(element, 'scrollLeft', { value: 0, writable: true });
|
||||
element.scrollTo = scrollTo as unknown as HTMLElement['scrollTo'];
|
||||
const scrollToIndex = jest.fn();
|
||||
const checkViewportSize = jest.fn();
|
||||
const viewport = {
|
||||
elementRef: { nativeElement: element },
|
||||
renderedRangeStream: renderedRange$.asObservable(),
|
||||
elementScrolled: () => scrolled$.asObservable(),
|
||||
measureScrollOffset: jest.fn(() => 0),
|
||||
scrollToIndex,
|
||||
checkViewportSize,
|
||||
} as unknown as CdkVirtualScrollViewport;
|
||||
const ensureLoaded = jest.fn();
|
||||
const setScrollLeft = jest.fn();
|
||||
const rows = channels(rowCount);
|
||||
const host: EpgGuideViewportHost = {
|
||||
viewport: () => viewport,
|
||||
rows: () => rows,
|
||||
rowHeightPx: () => 60,
|
||||
channelColumnPx: () => CHANNEL_COLUMN_PX,
|
||||
blocksFor: () => [block(0, 100), block(2000, 300)],
|
||||
activeRow: () => 40,
|
||||
ensureLoaded,
|
||||
setScrollLeft,
|
||||
};
|
||||
return {
|
||||
controller: new EpgGuideViewportController(host),
|
||||
host,
|
||||
viewport,
|
||||
element,
|
||||
renderedRange$,
|
||||
scrolled$,
|
||||
destroy: () => destroyHandlers.forEach((handler) => handler()),
|
||||
destroyRef,
|
||||
ensureLoaded,
|
||||
setScrollLeft,
|
||||
scrollToIndex,
|
||||
checkViewportSize,
|
||||
scrollTo,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EpgGuideViewportController', () => {
|
||||
it('requests the first screenful before the viewport reports a range', () => {
|
||||
const { controller, ensureLoaded } = harness();
|
||||
controller.loadRenderedRange();
|
||||
// 30 initial rows, extended by the buffer, clamped to the row count.
|
||||
expect(ensureLoaded.mock.calls[0][0]).toHaveLength(
|
||||
30 + EPG_GUIDE_ROW_BUFFER
|
||||
);
|
||||
expect(ensureLoaded.mock.calls[0][0][0].id).toBe('c0');
|
||||
});
|
||||
|
||||
it('buffers the rendered range on both sides and clamps at the ends', () => {
|
||||
const test = harness(100);
|
||||
test.controller.watch(test.viewport, test.destroyRef);
|
||||
|
||||
test.renderedRange$.next({ start: 50, end: 60 });
|
||||
const middle = test.ensureLoaded.mock.calls.at(-1)?.[0];
|
||||
expect(middle[0].id).toBe(`c${50 - EPG_GUIDE_ROW_BUFFER}`);
|
||||
expect(middle).toHaveLength(10 + 2 * EPG_GUIDE_ROW_BUFFER);
|
||||
|
||||
test.renderedRange$.next({ start: 0, end: 95 });
|
||||
const edges = test.ensureLoaded.mock.calls.at(-1)?.[0];
|
||||
expect(edges[0].id).toBe('c0');
|
||||
expect(edges).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('mirrors the viewport scroll offset to the host', () => {
|
||||
const test = harness();
|
||||
test.controller.watch(test.viewport, test.destroyRef);
|
||||
|
||||
test.element.scrollLeft = 640;
|
||||
test.scrolled$.next(new Event('scroll'));
|
||||
|
||||
expect(test.setScrollLeft).toHaveBeenCalledWith(640);
|
||||
});
|
||||
|
||||
it('stops mirroring and loading once the host is destroyed', () => {
|
||||
const test = harness();
|
||||
test.controller.watch(test.viewport, test.destroyRef);
|
||||
test.destroy();
|
||||
test.ensureLoaded.mockClear();
|
||||
|
||||
test.scrolled$.next(new Event('scroll'));
|
||||
test.renderedRange$.next({ start: 10, end: 20 });
|
||||
|
||||
expect(test.setScrollLeft).not.toHaveBeenCalled();
|
||||
expect(test.ensureLoaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls the lane and the playing row to now, and does nothing off-day', () => {
|
||||
const test = harness();
|
||||
test.controller.scrollToNow(900, false);
|
||||
// 1000 - 200 visible, a third of it kept to the left of the line.
|
||||
expect(test.scrollTo).toHaveBeenCalledWith({
|
||||
left: 900 - 800 / 3,
|
||||
behavior: 'auto',
|
||||
});
|
||||
expect(test.scrollToIndex).toHaveBeenCalledWith(37, 'auto');
|
||||
|
||||
test.scrollTo.mockClear();
|
||||
test.scrollToIndex.mockClear();
|
||||
test.controller.scrollToNow(null, false);
|
||||
expect(test.scrollTo).not.toHaveBeenCalled();
|
||||
expect(test.scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives the DOM focus to the cell holding the roving tabindex', () => {
|
||||
const test = harness();
|
||||
const passive = document.createElement('div');
|
||||
passive.setAttribute('data-epg-guide-grid', '');
|
||||
passive.tabIndex = -1;
|
||||
const roving = document.createElement('div');
|
||||
roving.setAttribute('data-epg-guide-grid', '');
|
||||
roving.tabIndex = 0;
|
||||
test.element.append(passive, roving);
|
||||
document.body.append(test.element);
|
||||
|
||||
test.controller.focusRovingTarget();
|
||||
expect(document.activeElement).toBe(roving);
|
||||
|
||||
// Nothing tabbable (a row scrolled out of the rendered range): the
|
||||
// current focus is left alone rather than reset.
|
||||
roving.remove();
|
||||
const outside = document.createElement('button');
|
||||
document.body.append(outside);
|
||||
outside.focus();
|
||||
test.controller.focusRovingTarget();
|
||||
expect(document.activeElement).toBe(outside);
|
||||
|
||||
outside.remove();
|
||||
test.element.remove();
|
||||
});
|
||||
|
||||
it('reveals the focused row and block, and ignores a null focus', () => {
|
||||
const test = harness();
|
||||
test.controller.revealFocus({ row: 40, block: 1 });
|
||||
expect(test.scrollToIndex).toHaveBeenCalledWith(38);
|
||||
expect(test.scrollTo).toHaveBeenCalledWith({
|
||||
left: 2000 - 40,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
test.scrollTo.mockClear();
|
||||
test.controller.revealFocus({ row: 40, block: 0 });
|
||||
expect(test.scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
test.scrollToIndex.mockClear();
|
||||
test.controller.revealFocus(null);
|
||||
expect(test.scrollToIndex).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { ListRange } from '@angular/cdk/collections';
|
||||
import { DestroyRef } from '@angular/core';
|
||||
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { TimelineRenderBlock } from '../epg-timeline/epg-timeline-render.util';
|
||||
import { EpgGuideFocus } from './epg-guide-keyboard.controller';
|
||||
import { EPG_GUIDE_ROW_BUFFER } from './epg-guide-layout.util';
|
||||
import {
|
||||
guideBlockRevealScrollLeft,
|
||||
guideNowScrollLeft,
|
||||
guideRowNeedsReveal,
|
||||
scrollElementLeft,
|
||||
} from './epg-guide-scroll.util';
|
||||
import { EpgGuideChannel } from './epg-guide-source';
|
||||
|
||||
/** Rows kept above the keyboard focus when it is scrolled into view. */
|
||||
const FOCUS_ROW_MARGIN = 2;
|
||||
/** Rows kept above the playing channel when the guide jumps to now. */
|
||||
const ACTIVE_ROW_MARGIN = 3;
|
||||
/** Rows requested before the viewport has reported a rendered range. */
|
||||
const INITIAL_LOAD_ROWS = 30;
|
||||
|
||||
export interface EpgGuideViewportHost {
|
||||
viewport(): CdkVirtualScrollViewport | undefined;
|
||||
rows(): readonly EpgGuideChannel[];
|
||||
rowHeightPx(): number;
|
||||
channelColumnPx(): number;
|
||||
blocksFor(row: number): TimelineRenderBlock[];
|
||||
activeRow(): number;
|
||||
ensureLoaded(channels: readonly EpgGuideChannel[]): void;
|
||||
/** Reports the viewport's horizontal offset; drives the ruler and now-line. */
|
||||
setScrollLeft(left: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolling and lazy loading for the guide's virtual viewport: which rows the
|
||||
* programme cache is asked about, where "now" and the keyboard focus are
|
||||
* scrolled to. Split out of the shell component, which owns state and
|
||||
* rendering only.
|
||||
*/
|
||||
export class EpgGuideViewportController {
|
||||
private renderedRange: ListRange | null = null;
|
||||
|
||||
constructor(private readonly host: EpgGuideViewportHost) {}
|
||||
|
||||
/**
|
||||
* Reload as the viewport renders new rows, mirror its horizontal offset and
|
||||
* re-measure it when the host resizes — all until the host is destroyed.
|
||||
* `elementScrolled()` runs outside the zone, and the signal write it feeds
|
||||
* schedules change detection itself, so scrolling costs no zone churn.
|
||||
*/
|
||||
watch(viewport: CdkVirtualScrollViewport, destroyRef: DestroyRef): void {
|
||||
viewport.renderedRangeStream
|
||||
.pipe(takeUntilDestroyed(destroyRef))
|
||||
.subscribe((range) => {
|
||||
this.renderedRange = range;
|
||||
this.loadRenderedRange();
|
||||
});
|
||||
const element = viewport.elementRef.nativeElement;
|
||||
viewport
|
||||
.elementScrolled()
|
||||
.pipe(takeUntilDestroyed(destroyRef))
|
||||
.subscribe(() => this.host.setScrollLeft(element.scrollLeft));
|
||||
this.observeSize(viewport, element, destroyRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CDK only re-measures on window resize, so a viewport that changes
|
||||
* size with its container (a drawer opening, a split pane) keeps a stale
|
||||
* height and renders the wrong range. `ResizeObserver` is absent in jsdom.
|
||||
*/
|
||||
private observeSize(
|
||||
viewport: CdkVirtualScrollViewport,
|
||||
element: HTMLElement,
|
||||
destroyRef: DestroyRef
|
||||
): void {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new ResizeObserver(() => viewport.checkViewportSize());
|
||||
observer.observe(element);
|
||||
destroyRef.onDestroy(() => observer.disconnect());
|
||||
}
|
||||
|
||||
/**
|
||||
* Request programmes for the rendered rows plus a buffer. Before the
|
||||
* viewport has measured itself (it reports nothing in a zero-sized host,
|
||||
* e.g. jsdom) the first screenful is requested instead, so the guide is
|
||||
* never blank waiting for a range that will not arrive.
|
||||
*/
|
||||
loadRenderedRange(): void {
|
||||
const rows = this.host.rows();
|
||||
const range = this.renderedRange ?? {
|
||||
start: 0,
|
||||
end: Math.min(rows.length, INITIAL_LOAD_ROWS),
|
||||
};
|
||||
const start = Math.max(0, range.start - EPG_GUIDE_ROW_BUFFER);
|
||||
const end = Math.min(rows.length, range.end + EPG_GUIDE_ROW_BUFFER);
|
||||
this.host.ensureLoaded(rows.slice(start, end));
|
||||
}
|
||||
|
||||
/** Put the now-line into view, and the playing channel's row with it. */
|
||||
scrollToNow(nowLeftPx: number | null, animate: boolean): void {
|
||||
const viewport = this.host.viewport();
|
||||
if (!viewport || nowLeftPx === null) {
|
||||
return;
|
||||
}
|
||||
const element = viewport.elementRef.nativeElement;
|
||||
scrollElementLeft(
|
||||
element,
|
||||
guideNowScrollLeft(
|
||||
element.clientWidth,
|
||||
nowLeftPx,
|
||||
this.host.channelColumnPx()
|
||||
),
|
||||
animate
|
||||
);
|
||||
const activeRow = this.host.activeRow();
|
||||
if (activeRow >= 0) {
|
||||
viewport.scrollToIndex(
|
||||
Math.max(0, activeRow - ACTIVE_ROW_MARGIN),
|
||||
animate ? 'smooth' : 'auto'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the DOM focus to whichever grid cell currently carries the roving
|
||||
* `tabindex="0"`, so assistive technology follows the guide's keyboard
|
||||
* navigation. Scrolling is `revealFocus`'s job, hence `preventScroll`.
|
||||
* Call it after the render that moved the tabindex.
|
||||
*/
|
||||
focusRovingTarget(): void {
|
||||
const element = this.host.viewport()?.elementRef.nativeElement;
|
||||
const target = element?.querySelector<HTMLElement>(
|
||||
'[data-epg-guide-grid][tabindex="0"]'
|
||||
);
|
||||
if (typeof target?.focus === 'function') {
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the keyboard focus target inside the viewport, both axes. */
|
||||
revealFocus(focused: EpgGuideFocus | null): void {
|
||||
const viewport = this.host.viewport();
|
||||
if (!focused || !viewport) {
|
||||
return;
|
||||
}
|
||||
const element = viewport.elementRef.nativeElement;
|
||||
if (
|
||||
guideRowNeedsReveal(
|
||||
focused.row,
|
||||
this.host.rowHeightPx(),
|
||||
viewport.measureScrollOffset('top'),
|
||||
element.clientHeight
|
||||
)
|
||||
) {
|
||||
viewport.scrollToIndex(Math.max(0, focused.row - FOCUS_ROW_MARGIN));
|
||||
}
|
||||
if (focused.block === null) {
|
||||
return;
|
||||
}
|
||||
const block = this.host.blocksFor(focused.row)[focused.block];
|
||||
const left =
|
||||
block &&
|
||||
guideBlockRevealScrollLeft(
|
||||
block,
|
||||
element.scrollLeft,
|
||||
element.clientWidth,
|
||||
this.host.channelColumnPx()
|
||||
);
|
||||
if (typeof left === 'number') {
|
||||
scrollElementLeft(element, left, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<!-- The toolbar wraps in narrow windows, so the search results hang off
|
||||
its real height rather than a hard-coded one. -->
|
||||
<div class="epg-guide__head">
|
||||
<app-epg-guide-toolbar
|
||||
[dayMs]="view.axis().startMs"
|
||||
[isToday]="view.isToday()"
|
||||
[currentLocale]="currentLocale()"
|
||||
[scopes]="scopes()"
|
||||
[scopeId]="scopeId()"
|
||||
[onlyWithEpg]="onlyWithEpg()"
|
||||
[coverageLoaded]="coverageLoaded()"
|
||||
[density]="density()"
|
||||
[zoom]="zoom()"
|
||||
[filter]="filter()"
|
||||
[searchEnabled]="searchEnabled"
|
||||
[searchQuery]="searchQuery()"
|
||||
[shownCount]="rows().length"
|
||||
[totalCount]="totalCount()"
|
||||
(stepDay)="stepDay($event)"
|
||||
(jumpNow)="jumpNow()"
|
||||
(scopeChange)="setScope($event)"
|
||||
(onlyWithEpgChange)="setOnlyWithEpg($event)"
|
||||
(densityChange)="setDensity($event)"
|
||||
(zoomChange)="setZoom($event)"
|
||||
(filterChange)="setFilter($event)"
|
||||
(searchQueryChange)="onSearchQueryChange($event)"
|
||||
/>
|
||||
|
||||
@if (searchPanelVisible()) {
|
||||
<div class="epg-guide__search-results" role="listbox">
|
||||
@for (
|
||||
hit of searchResults();
|
||||
track hit.program.channel + hit.program.start + $index
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-guide__search-result"
|
||||
role="option"
|
||||
[attr.aria-selected]="false"
|
||||
(click)="openSearchResult(hit)"
|
||||
>
|
||||
<span class="epg-guide__search-title">{{
|
||||
hit.program.title
|
||||
}}</span>
|
||||
<span class="epg-guide__search-meta">
|
||||
{{
|
||||
searchHitStartMs(hit)
|
||||
| date: 'EEE d MMM HH:mm' : '' : currentLocale()
|
||||
}}
|
||||
· {{ hit.channelName ?? hit.program.channel }}
|
||||
</span>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="epg-guide__search-empty">
|
||||
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="epg-guide__grid"
|
||||
[style.--epg-guide-channel-col.px]="channelColumnPx"
|
||||
>
|
||||
<div class="epg-guide__ruler">
|
||||
<div class="epg-guide__corner">
|
||||
{{ 'EPG.GUIDE.CHANNEL' | translate }}
|
||||
</div>
|
||||
<div class="epg-guide__ruler-clip">
|
||||
<div
|
||||
class="epg-guide__ruler-track"
|
||||
[style.width.px]="view.trackWidthPx()"
|
||||
[style.transform]="'translateX(' + -view.scrollLeft() + 'px)'"
|
||||
>
|
||||
@for (tick of view.ticks(); track tick.ms) {
|
||||
<div
|
||||
class="epg-guide__tick"
|
||||
[class.is-half]="tick.kind === 'half'"
|
||||
[style.left.px]="tick.leftPx"
|
||||
>
|
||||
{{ tick.ms | date: 'HH:mm' }}
|
||||
</div>
|
||||
}
|
||||
@if (view.nowLeftPx() !== null) {
|
||||
<div
|
||||
class="epg-guide__now-badge"
|
||||
[style.left.px]="view.nowLeftPx()"
|
||||
>
|
||||
{{ view.nowMs() | date: 'HH:mm' }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<cdk-virtual-scroll-viewport
|
||||
#viewport
|
||||
class="epg-guide__viewport"
|
||||
role="grid"
|
||||
[attr.aria-label]="'EPG.GUIDE.TITLE' | translate"
|
||||
[attr.aria-rowcount]="rows().length"
|
||||
[itemSize]="rowHeightPx()"
|
||||
[minBufferPx]="rowHeightPx() * 6"
|
||||
[maxBufferPx]="rowHeightPx() * 12"
|
||||
>
|
||||
<app-epg-guide-row
|
||||
*cdkVirtualFor="
|
||||
let channel of rows();
|
||||
trackBy: trackRow;
|
||||
let index = index
|
||||
"
|
||||
[attr.aria-rowindex]="index + 1"
|
||||
[channel]="channel"
|
||||
[programs]="programsFor(channel.id)"
|
||||
[status]="statusFor(channel.id)"
|
||||
[axis]="view.axis()"
|
||||
[hourWidthPx]="zoom()"
|
||||
[nowMs]="view.nowMs()"
|
||||
[offsetMinutes]="offsetMinutes()"
|
||||
[catchUpAvailable]="catchUpAvailable"
|
||||
[active]="channel.id === activeChannelId()"
|
||||
[density]="density()"
|
||||
[rowFocused]="focus()?.row === index"
|
||||
[focusedBlock]="focusedBlockFor(index)"
|
||||
[tabbable]="tabbableRow() === index"
|
||||
[style.height.px]="rowHeightPx()"
|
||||
(focusRequested)="focusCell(index, $event)"
|
||||
(channelActivated)="activateRow(channel)"
|
||||
(channelCommitted)="commitRow(channel)"
|
||||
(detailsRequested)="openDetails(channel, $event)"
|
||||
(watchRequested)="watch(channel, $event)"
|
||||
/>
|
||||
@if (rows().length === 0) {
|
||||
<div class="epg-guide__no-rows">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>
|
||||
{{
|
||||
'EPG.GUIDE.NO_CHANNELS' | translate: { query: filter() }
|
||||
}}
|
||||
</p>
|
||||
@if (filter()) {
|
||||
<button type="button" mat-button (click)="setFilter('')">
|
||||
{{ 'EPG.GUIDE.CLEAR_FILTER' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</cdk-virtual-scroll-viewport>
|
||||
|
||||
<!-- Clipped to the lane so the line never paints over the sticky column. -->
|
||||
<div class="epg-guide__now-clip">
|
||||
@if (view.nowLineLeftPx() !== null) {
|
||||
<div
|
||||
class="epg-guide__now-line"
|
||||
[style.left.px]="view.nowLineLeftPx()"
|
||||
></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="epg-guide__keys">
|
||||
<span
|
||||
><kbd>↑</kbd><kbd>↓</kbd
|
||||
>{{ 'EPG.GUIDE.KEYS.CHANNEL' | translate }}</span
|
||||
>
|
||||
<span
|
||||
><kbd>←</kbd><kbd>→</kbd
|
||||
>{{ 'EPG.GUIDE.KEYS.PROGRAMME' | translate }}</span
|
||||
>
|
||||
<span><kbd>Enter</kbd>{{ 'EPG.GUIDE.KEYS.PLAY' | translate }}</span>
|
||||
<span><kbd>I</kbd>{{ 'EPG.GUIDE.KEYS.DETAILS' | translate }}</span>
|
||||
<span><kbd>N</kbd>{{ 'EPG.GUIDE.KEYS.NOW' | translate }}</span>
|
||||
<span
|
||||
><kbd>PgUp</kbd><kbd>PgDn</kbd
|
||||
>{{ 'EPG.GUIDE.KEYS.DAY' | translate }}</span
|
||||
>
|
||||
<span><kbd>Esc</kbd>{{ 'EPG.GUIDE.KEYS.CLOSE' | translate }}</span>
|
||||
</div>
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user