diff --git a/.changes/epg-programme-guide.md b/.changes/epg-programme-guide.md new file mode 100644 index 000000000..03da9d351 --- /dev/null +++ b/.changes/epg-programme-guide.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index 71435ee62..28ab0a4a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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): diff --git a/apps/electron-backend-e2e/src/epg-guide.e2e.ts b/apps/electron-backend-e2e/src/epg-guide.e2e.ts new file mode 100644 index 000000000..6e8b84692 --- /dev/null +++ b/apps/electron-backend-e2e/src/epg-guide.e2e.ts @@ -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 ` + + ${channelName} + + ${title} + Guide E2E programme. + + +`; +} + +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(); + } +}); diff --git a/apps/electron-backend-e2e/src/epg.e2e.ts b/apps/electron-backend-e2e/src/epg.e2e.ts index 9b96075a1..83189e1c6 100644 --- a/apps/electron-backend-e2e/src/epg.e2e.ts +++ b/apps/electron-backend-e2e/src/epg.e2e.ts @@ -718,7 +718,7 @@ test.describe('Electron EPG', () => { async function getEpgChannelCount(page: Page): Promise { 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; }); } diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index ff3b75bc3..de0f5816b 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -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', diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index b3a60ee3a..09098763d 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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'), diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts index 3e5d07be5..a9f6fdf08 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.spec.ts @@ -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 { diff --git a/apps/electron-backend/src/app/events/database/epg-db.events.ts b/apps/electron-backend/src/app/events/database/epg-db.events.ts index 7d96345f1..fa11bec8e 100644 --- a/apps/electron-backend/src/app/events/database/epg-db.events.ts +++ b/apps/electron-backend/src/app/events/database/epg-db.events.ts @@ -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; diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts new file mode 100644 index 000000000..8e62f33a7 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts @@ -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()): 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 { + 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, + } + ); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-guide-query.service.ts b/apps/electron-backend/src/app/events/epg-guide-query.service.ts new file mode 100644 index 000000000..67cefbd42 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-query.service.ts @@ -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; + +/** + * 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 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> { + 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 { + 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 { + // Null prototype: a provider key such as `__proto__` must become an + // own property, not hit the legacy setter. + const result: Record = 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> { + const metadata = await this.resolver.resolveChannelMetadata( + window.channelIds, + window.sourceUrls.length > 0 + ? { sourceUrls: window.sourceUrls } + : {} + ); + const resolved = new Map(); + 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 { + const grouped = new Map(); + const seen = new Set(); + 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(); diff --git a/apps/electron-backend/src/app/events/epg-guide-window.util.ts b/apps/electron-backend/src/app/events/epg-guide-window.util.ts new file mode 100644 index 000000000..8cf5c335f --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide-window.util.ts @@ -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(); + 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, + }; +} diff --git a/apps/electron-backend/src/app/events/epg-guide.events.spec.ts b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts new file mode 100644 index 000000000..ccf491003 --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-guide.events.spec.ts @@ -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 +>(); + +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 + ) => { + ipcHandlers.set(channel, handler); + } + ); + + EpgEvents.bootstrapEpgEvents(); + }); + + function invoke(channel: string, args: unknown): Promise { + 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; + + 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']); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.ts b/apps/electron-backend/src/app/events/epg-mapping.service.ts index 7b767ce0a..2778507e9 100644 --- a/apps/electron-backend/src/app/events/epg-mapping.service.ts +++ b/apps/electron-backend/src/app/events/epg-mapping.service.ts @@ -34,14 +34,24 @@ export async function resolveChannelIds( channelIds: string[] ): Promise> { 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> { + 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. diff --git a/apps/electron-backend/src/app/events/epg-program-row.util.ts b/apps/electron-backend/src/app/events/epg-program-row.util.ts new file mode 100644 index 000000000..d4ad31efe --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-program-row.util.ts @@ -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()) + ); +} diff --git a/apps/electron-backend/src/app/events/epg-query.service.ts b/apps/electron-backend/src/app/events/epg-query.service.ts index c23764644..22544a29b 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -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> { + 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> { 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, - rows: EpgProgramRow[] + rows: EpgProgramSelectRow[] ): Set { const matchedChannelIds = new Set(); 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, candidateIdsByRequestedId: Map, - rows: EpgProgramRow[] + rows: EpgProgramSelectRow[] ): Set { const matchedCandidateIds = new Set(); 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 { - return db - .select() - .from(schema.epgPrograms) - .where( - this.withProgramSourceScope( - eq(schema.epgPrograms.channelId, channelId), - sourceUrls + ): Promise { + 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 { + ): Promise { if (sourceUrls.length === 0) { return []; } @@ -592,7 +555,7 @@ export class EpgQueryService { now: string, sourceUrls: string[], options: { legacyOnly?: boolean } = {} - ): Promise { + ): Promise { 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(); 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; diff --git a/apps/electron-backend/src/app/events/epg.events.ts b/apps/electron-backend/src/app/events/epg.events.ts index fb40baa91..91801b10b 100644 --- a/apps/electron-backend/src/app/events/epg.events.ts +++ b/apps/electron-backend/src/app/events/epg.events.ts @@ -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, + 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> { + 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 = 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 { + 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(); + 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 { diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 924c0a2d3..f09a299ce 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -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() diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index df9c49545..7601cbcf0 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "البث المباشر", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 7239c91d4..1b8e21c33 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "البث المباشر", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index ff82bccf8..54db75978 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Прамы эфір", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index b3ba34b20..ca612ef99 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dba844c76..ac42fd3d7 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b88314af5..cc7ce7ed2 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index f9eed4b71..5d4fa0409 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 664c34688..bef9faadd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index f124d7e43..802970bc2 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 500fc5288..17b624a02 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ee6d83bbd..6dc6ab3a3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 513200dae..0dc3c63e2 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5dbcaa5c2..7b8b26776 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 23f1c5005..03b0db6c8 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index ec5a90c4a..9ab426739 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8bd781ad2..24220cc25 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Прямой эфир", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index c7f6a8aee..638a39f8c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 26c86aefd..0dd368e2c 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "直播电视", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index d2ebfcd78..04bab7f42 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "直播電視", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index e6e8659ad..8b326b5a0 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -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 diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 39abd87b7..94c35e3b3 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -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 (`:`) — +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 diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index eed5d6c0b..6c236e9d9 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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 diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 843df5393..a53ca1215 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -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. diff --git a/docs/superpowers/plans/2026-09-06-epg-guide-m3u.md b/docs/superpowers/plans/2026-09-06-epg-guide-m3u.md new file mode 100644 index 000000000..42893b4c3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-epg-guide-m3u.md @@ -0,0 +1,4961 @@ +# Programme Guide (Multi-EPG) Redesign — M3U Host — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the SVG multi-EPG overlay with a host-agnostic `app-epg-guide` grid that shows the M3U playlist's own channels, switches playback on click, keeps the player docked on top, and looks like the EPG timeline. + +**Architecture:** `libs/ui/epg/src/lib/epg-guide/` holds the grid and a data contract (`EPG_GUIDE_SOURCE`). The M3U host (`VideoPlayerComponent`) provides an adapter for that contract, owns a `guideOpen` layout mode that CSS-reflows the player into a docked strip, and exposes four entry points. Two new Electron IPCs (`EPG_GET_PROGRAMS_FOR_CHANNELS`, `EPG_GET_PROGRAM_COVERAGE`) replace `EPG_GET_CHANNELS_BY_RANGE`. Spec: `docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md`. + +**Tech Stack:** Angular 20 signals + CDK virtual scroll, NgRx (`@iptvnator/m3u-state`), Electron IPC + Drizzle/SQLite, Jest (`run-web-esm-lib-tests.mjs` for web libs, `@nx/jest` for `electron-backend`), Playwright (`electron-backend-e2e`). + +--- + +## Conventions for every task + +- Run all commands from the worktree root: `/Users/4gray/Code/iptvnator/.claude/worktrees/multi-epg-view-feature-1efd4d`. +- **Running one web-lib spec.** `pnpm nx test ` on `ui-epg`, `playlist-m3u-feature-player`, `epg-data-access` and `services` always runs the whole project (the runner passes `--runTestsByPath` for every spec). To run one file: + ```bash + NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath + ``` +- **Running one backend spec:** `pnpm nx test electron-backend --testPathPatterns=`. +- Production TypeScript files stay under 300 lines (hard limit 400, blank lines and comments not counted). Never add a file to `tools/eslint/max-lines-baseline.mjs`. +- Use `@iptvnator/...` scoped aliases only. Signal-based inputs/outputs, `@if`/`@for` control flow. +- Commit after every task with the trailer `Co-Authored-By: Claude Fable 5.1 `. +- Existing helpers referenced below (do not re-implement): `getProgramTimeMs` (`libs/ui/epg/src/lib/epg-program.utils.ts`), `buildTimelineBlocks`/`buildTimelineAxis`/`classifyTimelineWhen` (`epg-timeline.utils.ts`), `buildTimelineRenderItems`/`tierFor` (`epg-timeline-render.util.ts`), `epgProviderClockMs`/`epgDisplayTimeMs` (`@iptvnator/shared/interfaces`), `getTodayEpgDateKey`/`parseEpgDateKey`/`shiftEpgDateKey` (`libs/ui/epg/src/lib/epg-date.ts`), `isTypingInInput` (`@iptvnator/portal/shared/util`), `resolveChannelEpgLookupKey` (`@iptvnator/m3u-state`), `EpgProgrammeDialogService` (`libs/ui/epg`), `queryByResolvedChannelIds`/`resolveChannelIds` (`apps/electron-backend/src/app/events/epg-mapping.service.ts`). + +## File structure + +**Created** + +| File | Responsibility | +| --- | --- | +| `apps/electron-backend/src/app/events/epg-guide-query.service.ts` | Window-scoped programme + coverage SQL for a batch of channel keys | +| `apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts` | Backend unit tests | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts` | `EPG_GUIDE_SOURCE` token and contract types | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts` (+ spec) | Day axis, ticks, now-x, block layout for one row | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts` (+ spec) | localStorage persistence for density/zoom/toggle/dock | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts` (+ spec) | Per-day programme cache, batched loading, coverage | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts` (+ spec) | Roving focus + key commands | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts/.html/.scss` | Toolbar | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts/.html/.scss` | One channel row | +| `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts/.html/.scss` (+ spec) | Grid shell | +| `libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts/.html/.scss` | Docked strip info block | +| `libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts` (+ spec) | M3U adapter for the contract | +| `apps/electron-backend-e2e/src/epg-guide.e2e.ts` | E2E | +| `.changes/epg-programme-guide.md` | Release note | + +**Modified** + +| File | Change | +| --- | --- | +| `libs/shared/interfaces/src/lib/electron-api.interface.ts` | New bridge methods/types; drop `getEpgChannelsByRange` + `ElectronBridgeEpgChannelWithPrograms` | +| `libs/shared/interfaces/src/lib/ipc-commands.ts` | Drop `EPG_GET_CHANNELS_BY_RANGE*` constants | +| `apps/electron-backend/src/app/api/main.preload.ts` (+ `.spec-data.ts`) | New methods, drop old | +| `apps/electron-backend/src/app/events/epg.events.ts` | Register two IPCs, drop old | +| `apps/electron-backend/src/app/events/epg-query.service.ts` | Drop `getChannelsByRange` | +| `libs/services/src/lib/runtime-capabilities.service.ts` (+ spec) | `supportsEpgGuide` replaces `supportsEpgChannelBrowser` | +| `libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts` (+ spec) | New bridge calls, drop old | +| `apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts` | Stub method rename | +| `libs/ui/epg/src/index.ts` | Export guide, drop multi-epg | +| `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts/.html/.scss` | `openGuide` output + Guide button | +| `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts/.html/.scss/.spec.ts/.spec-stubs.ts` | Guide mode, entry points | +| `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts`, `.../workspace-command-palette/workspace-command-palette.component.spec.ts` | Command id rename | +| `apps/web/src/assets/i18n/*.json` (19 files) | `EPG.GUIDE.*` keys, drop `TOP_MENU.OPEN_MULTI_EPG`, `WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION` | +| `apps/web/src/styles.scss` | Drop `#epg-navigation` drag-region rule | +| `libs/ui/components/src/lib/window-controls/window-controls.component.ts`, `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts` | Comment updates | +| `apps/electron-backend-e2e/src/epg.e2e.ts` | `getEpgChannelCount` uses `getEpgChannels` | +| `tools/eslint/max-lines-baseline.mjs` | Regenerated (multi-epg entry gone) | +| `docs/architecture/m3u-playlist-module.md`, `docs/architecture/workspace-shell.md`, `docs/architecture/pwa-self-hosted.md`, `CLAUDE.md` | Docs | + +**Deleted**: `libs/ui/epg/src/lib/multi-epg/` (all files). + +--- + +### Task 1: Backend guide query service (programmes + coverage for a channel batch) + +**Files:** +- Create: `apps/electron-backend/src/app/events/epg-guide-query.service.ts` +- Create: `apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts` + +Design notes: keys are resolved to XMLTV channel ids with the existing +`EpgQueryService.getChannelMetadata` (exact id → case-insensitive id → +display name, scoped then legacy). Programmes are read with one SQL over +`idx_epg_programs_time_range`. Like the M3U timeline (`EpgService.getChannelPrograms` +without options) the guide queries unscoped by default; `sourceUrls` stays in the +contract for portal hosts. Manual mappings are applied at the IPC boundary +(Task 2), not here. + +- [ ] **Step 1: Write the failing spec** + +```ts +// apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts +import { + EPG_GUIDE_MAX_CHANNELS_PER_REQUEST, + EpgGuideQueryService, + normalizeGuideWindow, +} from './epg-guide-query.service'; + +const getDatabase = jest.fn(); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../util/epg-logger', () => ({ + epgLogger: { error: jest.fn(), warn: jest.fn(), log: jest.fn() }, +})); + +function flattenSql(value: unknown, seen = new Set()): 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); + +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', () => { + 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'); + }); +}); + +describe('EpgGuideQueryService', () => { + const getChannelMetadata = jest.fn(); + let service: EpgGuideQueryService; + + beforeEach(() => { + getDatabase.mockReset(); + getChannelMetadata.mockReset(); + service = new EpgGuideQueryService({ getChannelMetadata }, '[Test]'); + }); + + it('returns an empty list per requested key for an invalid window', async () => { + const result = await service.getProgramsForChannels({ + channelIds: ['a', 'b'], + fromMs: TO, + toMs: FROM, + }); + expect(result).toEqual({ a: [], b: [] }); + expect(getChannelMetadata).not.toHaveBeenCalled(); + }); + + it('resolves keys through channel metadata and maps rows back onto every requested key', async () => { + getChannelMetadata.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(getChannelMetadata).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', + }); + 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', async () => { + getChannelMetadata.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(getChannelMetadata).toHaveBeenCalledWith(['a'], { + sourceUrls: ['https://guide.example.com/epg.xml'], + }); + expect(flattenSql(whereCalls[0])).toContain('source_url'); + }); + + it('fails soft when the database throws', async () => { + getChannelMetadata.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('reports coverage for the requested keys whose channel has a programme in the window', async () => { + getChannelMetadata.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'); + }); +}); +``` + +- [ ] **Step 2: Run the spec to verify it fails** + +Run: `pnpm nx test electron-backend --testPathPatterns=epg-guide-query.service` +Expected: FAIL — `Cannot find module './epg-guide-query.service'`. + +- [ ] **Step 3: Implement the service** + +```ts +// apps/electron-backend/src/app/events/epg-guide-query.service.ts +import { and, inArray, 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 { epgQueryService, EpgQueryService } from './epg-query.service'; + +/** The renderer splits larger batches; anything beyond this is dropped. */ +export const EPG_GUIDE_MAX_CHANNELS_PER_REQUEST = 100; + +/** Largest |ms| `Date` can serialize; a corrupt payload must not throw in SQL. */ +const MAX_SERIALIZABLE_MS = 8.64e15; + +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; + sourceUrls: string[]; +} + +interface GuideProgramRow { + channelId: string; + start: string; + stop: string; + title: string; + description: string | null; + category: string | null; + iconUrl: string | null; + rating: string | null; + episodeNum: string | null; +} + +type ChannelResolver = Pick; + +function isUsableInstant(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + Math.abs(value) <= MAX_SERIALIZABLE_MS + ); +} + +/** Validates and trims a request; `null` means "nothing to query". */ +export function normalizeGuideWindow( + request: EpgGuideWindowRequest +): NormalizedGuideWindow | null { + if ( + !isUsableInstant(request.fromMs) || + !isUsableInstant(request.toMs) || + request.fromMs >= request.toMs || + !Array.isArray(request.channelIds) + ) { + return null; + } + const channelIds = Array.from( + new Set( + request.channelIds + .map((id) => (typeof id === 'string' ? id.trim() : '')) + .filter((id) => id.length > 0) + ) + ).slice(0, EPG_GUIDE_MAX_CHANNELS_PER_REQUEST); + if (channelIds.length === 0) { + return null; + } + const sourceUrls = Array.from( + new Set( + (request.sourceUrls ?? []) + .map((url) => url.trim()) + .filter((url) => url.length > 0) + ) + ); + return { + channelIds, + fromIso: new Date(request.fromMs).toISOString(), + toIso: new Date(request.toMs).toISOString(), + sourceUrls, + }; +} + +/** + * 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]' + ) {} + + async getProgramsForChannels( + request: EpgGuideWindowRequest + ): Promise> { + const result = this.emptyResult(request.channelIds); + const window = normalizeGuideWindow(request); + if (!window) { + return result; + } + 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: GuideProgramRow[] = 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(this.windowCondition(epgIds, window)) + .orderBy(schema.epgPrograms.start); + const byEpgId = this.groupPrograms(rows); + for (const [requestedId, epgId] of resolved) { + result[requestedId] = byEpgId.get(epgId) ?? []; + } + } catch (error) { + epgLogger.error( + this.loggerLabel, + 'Error loading guide programmes:', + error + ); + } + return result; + } + + async getProgramCoverage( + request: EpgGuideWindowRequest + ): Promise { + const window = normalizeGuideWindow(request); + if (!window) { + return []; + } + 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(this.windowCondition(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 + ); + return []; + } + } + + private emptyResult(channelIds: string[]): Record { + const result: Record = {}; + for (const id of Array.isArray(channelIds) ? channelIds : []) { + if (typeof id === 'string' && id.trim().length > 0) { + result[id.trim()] = []; + } + } + return result; + } + + /** requested key → XMLTV channel id (keys without a match are absent). */ + private async resolveChannelIds( + window: NormalizedGuideWindow + ): Promise> { + const metadata = await this.resolver.getChannelMetadata( + window.channelIds, + window.sourceUrls.length > 0 + ? { sourceUrls: window.sourceUrls } + : {} + ); + const resolved = new Map(); + for (const requestedId of window.channelIds) { + const epgId = metadata[requestedId]?.id; + if (epgId) { + resolved.set(requestedId, epgId); + } + } + return resolved; + } + + /** + * Overlap test in SQLite `datetime()` so provider-local offsets in the + * stored ISO strings compare correctly against the UTC window bounds. + */ + private windowCondition( + epgIds: string[], + window: NormalizedGuideWindow + ): SQL { + const overlap = and( + inArray(schema.epgPrograms.channelId, epgIds), + 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, + inArray(schema.epgPrograms.sourceUrl, window.sourceUrls) + ) as SQL; + } + + /** Group by channel and collapse duplicate slots (same start + title). */ + private groupPrograms(rows: GuideProgramRow[]): Map { + const grouped = new Map(); + const seen = new Set(); + for (const row of rows) { + const key = `${row.channelId}|${row.start}|${row.title}`; + if (seen.has(key) || !row.start || !row.stop || !row.title) { + continue; + } + seen.add(key); + const list = grouped.get(row.channelId) ?? []; + list.push({ + 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, + }); + grouped.set(row.channelId, list); + } + return grouped; + } +} + +export const epgGuideQueryService = new EpgGuideQueryService(); +``` + +- [ ] **Step 4: Run the spec to verify it passes** + +Run: `pnpm nx test electron-backend --testPathPatterns=epg-guide-query.service` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/electron-backend/src/app/events/epg-guide-query.service.ts apps/electron-backend/src/app/events/epg-guide-query.service.spec.ts +git commit -m "feat(epg): add window-scoped guide programme queries + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 2: IPC, bridge contract, preload and runtime capability + +**Files:** +- Modify: `libs/shared/interfaces/src/lib/electron-api.interface.ts:366-412` (types) and `:797-810` (methods) +- Modify: `apps/electron-backend/src/app/events/epg.events.ts:1-20` (imports), `:100-106` (handler registration), `:252-264` (handler) +- Modify: `apps/electron-backend/src/app/api/main.preload.ts:661-676` +- Modify: `apps/electron-backend/src/app/api/main.preload.spec-data.ts:498-521` +- Modify: `libs/services/src/lib/runtime-capabilities.service.ts:94-139` and `libs/services/src/lib/runtime-capabilities.service.spec.ts` +- Modify: `libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts` and `.spec.ts` +- Modify: `apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts:243` +- Modify: `libs/shared/interfaces/src/lib/ipc-commands.ts:7-9` + +This task removes `EPG_GET_CHANNELS_BY_RANGE` at the same time so the bridge +never carries two guide feeds. The old grid still compiles against +`getChannelsByRange` until Task 10 deletes it, so keep `EpgQueryService.getChannelsByRange` +for now (Task 10 removes it) but drop every bridge/preload/contract mention here. + +- [ ] **Step 1: Add contract types and methods** + +In `libs/shared/interfaces/src/lib/electron-api.interface.ts`, right after +`ElectronBridgeCurrentProgramsOptions` (line ~381) add: + +```ts +export interface ElectronBridgeEpgGuideWindow + extends ElectronBridgeEpgLookupOptions { + /** Playlist channel lookup keys (tvg-id, else name), ≤100 per call. */ + channelIds: string[]; + /** Provider-clock window bounds in epoch ms. */ + fromMs: number; + toMs: number; +} +``` + +Delete the `ElectronBridgeEpgChannelWithPrograms` interface (line ~409) and, if +`ElectronBridgeEpgChannelSummary` is now unused elsewhere (`grep -rn ElectronBridgeEpgChannelSummary libs apps`), delete it too. + +Replace the `getEpgChannelsByRange` member (lines ~806-809) with: + +```ts + getEpgProgramsForChannels: ( + window: ElectronBridgeEpgGuideWindow + ) => Promise>; + getEpgProgramCoverage: ( + window: ElectronBridgeEpgGuideWindow + ) => Promise; +``` + +In `libs/shared/interfaces/src/lib/ipc-commands.ts` delete the two +`EPG_GET_CHANNELS_BY_RANGE*` constants (lines 7-9); nothing imports them. + +- [ ] **Step 2: Preload + spec-data** + +In `apps/electron-backend/src/app/api/main.preload.ts` replace the +`getEpgChannelsByRange` entry with: + +```ts + getEpgProgramsForChannels: (window: ElectronBridgeEpgGuideWindow) => + ipcRenderer.invoke('EPG_GET_PROGRAMS_FOR_CHANNELS', window), + getEpgProgramCoverage: (window: ElectronBridgeEpgGuideWindow) => + ipcRenderer.invoke('EPG_GET_PROGRAM_COVERAGE', window), +``` + +and add `ElectronBridgeEpgGuideWindow` to the `@iptvnator/shared/interfaces` import list at the top of the file. + +In `main.preload.spec-data.ts` replace the `getEpgChannelsByRange` case with: + +```ts + { + 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 }], + }, +``` + +- [ ] **Step 3: Register the IPC handlers** + +In `apps/electron-backend/src/app/events/epg.events.ts`: + +Imports — add `ElectronBridgeEpgGuideWindow` to the `@iptvnator/shared/interfaces` import, add +`import { epgGuideQueryService } from './epg-guide-query.service';`, and add +`resolveChannelIds` to the `./epg-mapping.service` import list. + +Replace the `EPG_GET_CHANNELS_BY_RANGE` registration block with: + +```ts + ipcMain.handle( + '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); + } + ); +``` + +Replace the `handleGetChannelsByRange` static method with: + +```ts + /** + * 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. + */ + private static async handleGetGuidePrograms( + args: ElectronBridgeEpgGuideWindow + ): Promise> { + 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, + }); + return Object.fromEntries( + requested.map((id) => [id, programs[mapping.get(id) ?? id] ?? []]) + ); + } + + private static async handleGetGuideCoverage( + args: ElectronBridgeEpgGuideWindow + ): Promise { + const requested = Array.isArray(args?.channelIds) ? args.channelIds : []; + const mapping = await resolveChannelIds(requested); + const resolvedIds = requested.map((id) => mapping.get(id) ?? id); + const covered = new Set( + await epgGuideQueryService.getProgramCoverage({ + ...args, + channelIds: resolvedIds, + }) + ); + return requested.filter((id) => covered.has(mapping.get(id) ?? id)); + } +``` + +- [ ] **Step 4: Runtime capability** + +In `libs/services/src/lib/runtime-capabilities.service.ts` replace the +`supportsEpgChannelBrowser` getter with: + +```ts + /** Both guide reads exist: programmes for a channel batch and coverage. */ + get supportsEpgGuide(): boolean { + return ( + this.hasElectronMethod('getEpgProgramsForChannels') && + this.hasElectronMethod('getEpgProgramCoverage') + ); + } +``` + +and inside `get supportsEpg()` replace `this.supportsEpgChannelBrowser &&` with `this.supportsEpgGuide &&`. + +In `runtime-capabilities.service.spec.ts` replace every `getEpgChannelsByRange: jest.fn()` stub with both +`getEpgProgramsForChannels: jest.fn(), getEpgProgramCoverage: jest.fn(),` and every +`expect(service.supportsEpgChannelBrowser)` with `expect(service.supportsEpgGuide)`. Add one case: + +```ts + it('does not report guide support with only one of the two guide reads', () => { + window.electron = { + getEpgProgramsForChannels: jest.fn(), + } as unknown as typeof window.electron; + const service = TestBed.inject(RuntimeCapabilitiesService); + expect(service.supportsEpgGuide).toBe(false); + }); +``` + +(Mirror how the surrounding tests construct `window.electron`; keep their style.) + +In `apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts:243` replace +`getEpgChannelsByRange: jest.fn().mockResolvedValue([]),` with +`getEpgProgramsForChannels: jest.fn().mockResolvedValue({}),` and +`getEpgProgramCoverage: jest.fn().mockResolvedValue([]),`. + +- [ ] **Step 5: Bridge service** + +In `libs/epg/data-access/src/lib/epg-runtime-bridge.service.ts`: + +- Import list: replace `ElectronBridgeEpgChannelWithPrograms` with `ElectronBridgeEpgGuideWindow`. +- `EpgElectronBridge` pick list: replace `'getEpgChannelsByRange'` with `'getEpgProgramsForChannels' | 'getEpgProgramCoverage'`. +- Replace the `supportsChannelBrowser` getter with `get supportsGuide(): boolean { return this.runtime.supportsEpgGuide; }`. +- Replace `getChannelsByRange` with: + +```ts + getProgramsForChannels( + window: ElectronBridgeEpgGuideWindow + ): Promise | null> { + if (!this.supportsGuide || window.channelIds.length === 0) { + return Promise.resolve(null); + } + return ( + this.bridge?.getEpgProgramsForChannels?.(window) ?? + Promise.resolve(null) + ); + } + + getProgramCoverage( + window: ElectronBridgeEpgGuideWindow + ): Promise { + if (!this.supportsGuide || window.channelIds.length === 0) { + return Promise.resolve(null); + } + return ( + this.bridge?.getEpgProgramCoverage?.(window) ?? + Promise.resolve(null) + ); + } +``` + +In `epg-runtime-bridge.service.spec.ts`: rename the capability stub field +`supportsEpgChannelBrowser` → `supportsEpgGuide`, and replace the +`getEpgChannelsByRange` test (lines ~125-165) with: + +```ts + it('forwards guide reads when the runtime supports them', async () => { + const getEpgProgramsForChannels = jest.fn().mockResolvedValue({ a: [] }); + const getEpgProgramCoverage = jest.fn().mockResolvedValue(['a']); + window.electron = { + getEpgProgramsForChannels, + getEpgProgramCoverage, + } as unknown as typeof window.electron; + runtimeCapabilities.supportsEpgGuide = true; + + const window_ = { channelIds: ['a'], fromMs: 1, toMs: 2 }; + await expect(service.getProgramsForChannels(window_)).resolves.toEqual({ a: [] }); + await expect(service.getProgramCoverage(window_)).resolves.toEqual(['a']); + expect(getEpgProgramsForChannels).toHaveBeenCalledWith(window_); + expect(getEpgProgramCoverage).toHaveBeenCalledWith(window_); + }); + + 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(); + }); +``` + +(Adapt variable names to the existing `describe` scaffolding: it already has `service` and `runtimeCapabilities`.) + +- [ ] **Step 6: Temporarily keep the old grid compiling** + +`libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts` calls +`epgBridge.supportsChannelBrowser` and `epgBridge.getChannelsByRange`. Until +Task 10 deletes it, change both call sites to `this.epgBridge.supportsGuide` +and `Promise.resolve([])` respectively so `typecheck` passes: + +```ts + if (!this.epgBridge.supportsGuide) { +``` +```ts + const response = await Promise.resolve( + [] as Array<{ id: string; displayName: string; iconUrl: string | null; programs: EpgProgram[] }> + ); +``` + +(`multi-epg-container.component.spec.ts` stubs `getChannelsByRange`; delete that stub line and the assertion on it, or `it.skip` the affected case — the file goes away in Task 10.) + +- [ ] **Step 7: Run the affected specs and typecheck** + +Run: +```bash +pnpm nx test electron-backend --testPathPatterns='main.preload|epg.events' +pnpm nx test services +pnpm nx test epg-data-access +pnpm run typecheck:ci +``` +Expected: all PASS; typecheck clean. + +- [ ] **Step 8: Commit** + +```bash +git add -A libs/shared/interfaces apps/electron-backend/src/app/api apps/electron-backend/src/app/events/epg.events.ts libs/services libs/epg/data-access apps/web/src/app/settings/test-stubs libs/ui/epg/src/lib/multi-epg +git commit -m "feat(epg): expose guide programme and coverage reads over the bridge + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 3: Guide contract, day-axis layout util and preferences (`libs/ui/epg`) + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts` + +- [ ] **Step 1: Write the contract (types only, no tests needed)** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-source.ts +import { InjectionToken, Signal } from '@angular/core'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; + +/** One row of the guide. `id` is host-stable (M3U: `Channel.id`). */ +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 { + channels: EpgGuideChannel[]; + fromMs: number; + toMs: number; +} + +export interface EpgGuideCatchUp { + canWatch(channel: EpgGuideChannel, program: EpgProgram): boolean; + watch(channel: EpgGuideChannel, program: EpgProgram): void; +} + +/** + * 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; + readonly scopes: Signal; + readonly scopeId: Signal; + setScope(id: string): void; + /** Programmes overlapping the window, keyed by `EpgGuideChannel.id`. */ + loadPrograms(window: EpgGuideWindow): Promise>; + /** Ids of channels with at least one programme in the window. */ + loadCoverage(window: EpgGuideWindow): Promise>; + readonly activeChannelId: Signal; + /** Switch playback; the guide stays open. */ + activate(channelId: string): void; + /** Optional programme search; the toolbar hides its field when absent. */ + searchPrograms?(query: string): Promise; + readonly catchUp?: EpgGuideCatchUp; +} + +export const EPG_GUIDE_SOURCE = new InjectionToken( + 'EPG_GUIDE_SOURCE' +); +``` + +- [ ] **Step 2: Write the failing layout spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts +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(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 - 1, 240)).toBeNull(); + expect(guideNowLeftPx(axis, axis.endMs, 240)).toBeNull(); + }); + + 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); + expect(blocks[2].tier).toBe('narrow'); + }); + + 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); + }); +}); +``` + +- [ ] **Step 3: Run the spec to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.spec.ts` +Expected: FAIL — cannot find module `./epg-guide-layout.util`. + +- [ ] **Step 4: Implement the layout util** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-layout.util.ts +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 = { + 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; + +/** 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; +} + +export function buildGuideDayAxis(dayKey: string): EpgGuideDayAxis { + const start = parseEpgDateKey(dayKey); + return { + dayKey, + startMs: start.getTime(), + endMs: addDays(start, 1).getTime(), + }; +} + +export function guideTrackWidthPx(hourWidthPx: number): number { + return hourWidthPx * 24; +} + +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); +} + +export function buildGuideTicks( + axis: TimelineAxis, + hourWidthPx: number +): EpgGuideTick[] { + const ticks: EpgGuideTick[] = []; + const start = new Date(axis.startMs); + for (let minute = 0; minute < 24 * 60; minute += 30) { + const ms = addMinutes(start, minute).getTime(); + ticks.push({ + ms, + leftPx: guideXForMs(axis, ms, hourWidthPx), + kind: minute % 60 === 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, minimum widths and the on-now fill. 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. + */ +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, { + allowGroup: false, + nowMs, + archivePlaybackAvailable: options.catchUpAvailable ?? false, + }); + return items.filter( + (item): item is TimelineRenderBlock => item.kind === 'block' + ); +} +``` + +- [ ] **Step 5: Run the layout spec to verify it passes** + +Same command as Step 3. Expected: PASS (5 tests). + +- [ ] **Step 6: Write the failing preferences spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts +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_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).density).toBe('comfortable'); + }); +}); +``` + +- [ ] **Step 7: Implement preferences** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.ts +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 zoom = Number(read(storage, EPG_GUIDE_ZOOM_KEY)); + return { + density: isDensity(density) ? density : 'comfortable', + zoom: clampGuideZoom(zoom === 0 ? Number.NaN : 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'); +} +``` + +- [ ] **Step 8: Run the preferences spec to verify it passes** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-preferences.spec.ts` +Expected: PASS (4 tests). + +- [ ] **Step 9: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-guide +git commit -m "feat(epg): add guide source contract, day layout maths and preferences + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 4: Programme cache service with batched loading and coverage + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts +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 { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('EpgGuideProgramsService', () => { + const channels = signal([]); + const loadPrograms = jest.fn>, [unknown]>(); + const loadCoverage = jest.fn>, [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); + }); + + 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) => 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); + channels.set([channel('a'), channel('z')]); + TestBed.flushEffects(); + await flush(); + expect(service.statusFor('a')).toBe('idle'); + expect(loadCoverage).toHaveBeenCalledTimes(2); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts` +Expected: FAIL — cannot find module. + +- [ ] **Step 3: Implement the service** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts +import { computed, effect, inject, Injectable, signal } from '@angular/core'; +import { 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 (the coverage IPC caps at 2000 keys). */ +export const EPG_GUIDE_COVERAGE_CHUNK = 1000; + +export type EpgGuideRowStatus = 'idle' | 'loading' | 'loaded' | 'none'; + +const EMPTY: EpgProgram[] = []; + +/** + * 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>( + new Map() + ); + private readonly statuses = signal>( + new Map() + ); + private readonly coverage = signal | null>(null); + + private window: { fromMs: number; toMs: number } | null = null; + private generation = 0; + + 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. + effect(() => { + this.source.channels(); + this.invalidate(); + this.requestCoverage(); + }); + } + + setWindow(fromMs: number, toMs: number): void { + if (this.window?.fromMs === fromMs && this.window?.toMs === toMs) { + return; + } + this.window = { fromMs, toMs }; + this.invalidate(); + this.requestCoverage(); + } + + programsFor(channelId: string): EpgProgram[] { + return this.programs().get(channelId) ?? EMPTY; + } + + statusFor(channelId: string): EpgGuideRowStatus { + return this.statuses().get(channelId) ?? 'idle'; + } + + /** Unknown (coverage not loaded yet) 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 window = this.window; + if (!window) { + return; + } + const pending = channels.filter( + (channel) => this.statusFor(channel.id) === 'idle' && channel.epgKey + ); + 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, ...window }, generation); + } + } + + private loadChunk(window: EpgGuideWindow, generation: number): void { + this.source + .loadPrograms(window) + .then( + (result) => result, + () => new Map() + ) + .then((result) => { + if (generation !== this.generation) { + return; + } + const next = new Map(this.programs()); + for (const channel of window.channels) { + next.set(channel.id, result.get(channel.id) ?? EMPTY); + } + this.programs.set(next); + this.patchStatuses( + window.channels.map((channel) => [channel.id, 'loaded']) + ); + }); + } + + private requestCoverage(): void { + const window = this.window; + if (!window) { + return; + } + const generation = this.generation; + const keyed = this.source + .channels() + .filter((channel) => channel.epgKey !== null); + 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, ...window }) + .then( + (covered) => covered, + () => new Set() + ) + ) + ).then((sets) => { + if (generation !== this.generation) { + return; + } + const merged = new Set(); + sets.forEach((set) => 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) => channel.epgKey === null) + .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); + } +} +``` + +Note on the first spec case: the constructor `effect` runs on the first +change-detection flush; `setWindow` sets `none` statuses itself through +`invalidate()`, which is why the test passes without flushing effects. + +- [ ] **Step 4: Run the spec to verify it passes** + +Same command as Step 2. Expected: PASS (6 tests). If the last case's second +`loadCoverage` call is missing, call `TestBed.flushEffects()` once right after +`TestBed.inject` in `beforeEach` so the initial effect run is consumed before +the assertions count calls. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.ts libs/ui/epg/src/lib/epg-guide/epg-guide-programs.service.spec.ts +git commit -m "feat(epg): cache guide programmes per day with batched loading + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 5: Keyboard controller + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts` + +- [ ] **Step 1: Write the failing spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +import { + EpgGuideKeyboardController, + EpgGuideKeyboardHost, +} from './epg-guide-keyboard.controller'; + +function key( + keyName: string, + init: Partial & { 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; + let controller: EpgGuideKeyboardController; + + beforeEach(() => { + host = { + rowCount: jest.fn(() => 5), + blockCount: jest.fn(() => 3), + activeRow: jest.fn(() => 2), + isBlocked: jest.fn(() => false), + 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(); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts` +Expected: FAIL — cannot find module. + +- [ ] **Step 3: Implement the controller** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts +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; + play(row: number): void; + details(row: number, block: number): void; + jumpNow(): void; + stepDay(direction: EpgDateNavigationDirection): void; + close(): void; +} + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + return ( + target.isContentEditable || + ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) + ); +} + +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. Returns whether the event was + * consumed so the caller can `preventDefault()`. + */ +export class EpgGuideKeyboardController { + readonly focus = signal(null); + + constructor(private readonly host: EpgGuideKeyboardHost) {} + + handle(event: KeyboardEvent): boolean { + if ( + event.defaultPrevented || + event.metaKey || + event.ctrlKey || + event.altKey || + isEditableTarget(event.target) || + this.host.isBlocked() + ) { + return false; + } + switch (event.key) { + case 'Escape': + this.host.close(); + return true; + 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; + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Same command as Step 2. Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.ts libs/ui/epg/src/lib/epg-guide/epg-guide-keyboard.controller.spec.ts +git commit -m "feat(epg): add guide keyboard navigation controller + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 6: Guide components (toolbar, row, shell, now-playing) + English i18n + +**Files:** +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.{ts,html,scss}` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.{ts,html,scss}` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide.component.{ts,html,scss}` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts` +- Create: `libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.{ts,html,scss}` +- Modify: `libs/ui/epg/src/index.ts` +- Modify: `apps/web/src/assets/i18n/en.json` (`EPG.GUIDE`, `EPG.TIMELINE.GUIDE`, `TOP_MENU.OPEN_EPG_GUIDE`, `WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION`) + +Visual reference: the Q2 mockup (https://claude.ai/code/artifact/0cac36f6-9027-46a7-bd0c-b264416e6acf). All colours come from `libs/ui/epg/src/lib/_epg-theme.scss` (`@use '../epg-theme' as *;`). + +- [ ] **Step 1: English i18n keys** + +In `apps/web/src/assets/i18n/en.json`: + +Inside `"EPG": { ... "TIMELINE": { ... } }` add to the `TIMELINE` object: +```json + "GUIDE": "Guide", + "OPEN_GUIDE": "Open the programme guide" +``` +and add a sibling object under `"EPG"`: +```json + "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" + } + }, +``` +In `"TOP_MENU"` replace `"OPEN_MULTI_EPG": "Open Multi-EPG view"` with `"OPEN_EPG_GUIDE": "Programme guide"`. +In `"WORKSPACE" → "SHELL" → "COMMANDS"` replace `"OPEN_MULTI_EPG_DESCRIPTION"` with `"OPEN_EPG_GUIDE_DESCRIPTION": "Open the programme guide for this playlist"`. +(Other locales: Task 11.) + +- [ ] **Step 2: Now-playing component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.ts +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + OnDestroy, + OnInit, + output, + signal, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +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, MatIconModule, 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(null); + readonly offsetMinutes = input(0); + readonly collapsed = input(false); + + readonly closeRequested = output(); + readonly collapsedChange = output(); + + 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; + } +} +``` + +```html + +
+
+
+ {{ channelName() }} + @if (isOnNow()) { + {{ 'EPG.TIMELINE.ON_NOW' | translate }} + } +
+ @if (program(); as program) { +
{{ program.title }}
+ @if (!collapsed() && program.desc) { +
{{ program.desc }}
+ } + @if (!collapsed() && progress() !== null) { +
+ {{ startMs() | date: 'HH:mm' }} + + {{ stopMs() | date: 'HH:mm' }} +
+ } + } @else { +
+ {{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }} +
+ } +
+
+ + +
+
+``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide-now-playing.component.scss +@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; } +``` + +- [ ] **Step 3: Toolbar component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.ts +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'; + +@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(); + readonly isToday = input(false); + readonly currentLocale = input('en'); + readonly scopes = input([]); + readonly scopeId = input(''); + readonly onlyWithEpg = input(false); + readonly coverageLoaded = input(false); + readonly density = input('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(); + readonly jumpNow = output(); + readonly scopeChange = output(); + readonly onlyWithEpgChange = output(); + readonly densityChange = output(); + readonly zoomChange = output(); + readonly filterChange = output(); + readonly searchQueryChange = output(); + + 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); + } +} +``` + +```html + +
+
+ + + {{ dayMs() | date: 'EEE, d MMM' : '' : currentLocale() }} + @if (isToday()) { {{ 'EPG.GUIDE.TODAY' | translate }} } + + +
+ + + + + + @for (scope of scopes(); track scope.id) { + + } + + + + + + + + + + + + + @if (searchEnabled()) { + + } +
+``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide-toolbar.component.scss +@use '../epg-theme' as *; + +:host { display: block; } +.guide-toolbar { + display: flex; align-items: center; gap: 10px; min-height: 52px; padding: 0 18px; + border-bottom: 1px solid $line; background: var(--app-content-bg); color: $text-primary; + button, label, input { font: inherit; } +} +.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; 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 { width: 230px; } +.guide-toolbar__count { font: 10.5px $font-mono; color: $text-tertiary; white-space: nowrap; } +``` + +Add to the same file, outside `:host` scoping, `::ng-deep .guide-toolbar__scope-menu { max-height: 60vh; .is-selected { color: var(--app-selection-color); font-weight: 600; } }` (CDK menus render outside the component). + +- [ ] **Step 4: Row component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.ts +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', + '[class.is-active]': 'active()', + '[class.is-focused]': 'rowFocused()', + '[class.is-compact]': 'density() === "compact"', + }, +}) +export class EpgGuideRowComponent { + readonly channel = input.required(); + readonly programs = input([]); + readonly status = input('idle'); + readonly axis = input.required(); + 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('comfortable'); + readonly rowFocused = input(false); + readonly focusedBlock = input(null); + + /** Single click on the channel cell or the on-now card. */ + readonly channelActivated = output(); + /** Double click: activate and close the guide. */ + readonly channelCommitted = output(); + readonly detailsRequested = output(); + readonly watchRequested = output(); + + 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.hourWidthPx())); + readonly showEmpty = computed( + () => + this.status() === 'none' || + (this.status() === 'loaded' && this.blocks().length === 0) + ); + readonly isLoading = computed(() => this.status() === 'loading'); + + onBlockClick(item: TimelineRenderBlock): void { + 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); + } +} +``` + +```html + +
+ {{ channel().number }} + + + {{ channel().name }} + @if (density() === 'comfortable') { + + @if (active()) { + ▶ {{ 'EPG.GUIDE.PLAYING' | translate }} + } @else if (nowBlock(); as now) { + {{ now.block.program.title }} + } @else if (showEmpty()) { + {{ 'EPG.GUIDE.NO_PROGRAMME_INFO' | translate }} + } + + } + + @if (active()) { + equalizer + } +
+ +
+ @if (showEmpty()) { + {{ 'EPG.GUIDE.NO_PROGRAMME_INFO_LONG' | translate }} + } @else if (isLoading()) { + + + + } + @for (item of blocks(); track item.key; let index = $index) { +
+ @if (item.block.when === 'now') { + + } + + {{ item.block.startMs | date: 'HH:mm' }} + @if (density() === 'comfortable') { – {{ item.block.stopMs | date: 'HH:mm' }} } + + {{ item.block.program.title }} + @if (item.block.when === 'now') { + {{ 'EPG.TIMELINE.ON_NOW' | translate }} + } + @if (item.canCatchUp && item.block.when === 'past') { + + } +
+ } +
+``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide-row.component.scss +@use '../epg-theme' as *; + +:host { + display: flex; height: 100%; 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; } +} +.epg-guide-row__channel { + position: sticky; left: 0; z-index: 2; flex: 0 0 var(--epg-guide-channel-col, 232px); + display: flex; align-items: center; gap: 10px; padding: 0 12px 0 14px; box-sizing: border-box; + background: inherit; border-right: 1px solid $line-strong; cursor: pointer; color: $text-primary; + :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 { 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; } } +``` + +- [ ] **Step 5: Guide shell component** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts +import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; +import { DatePipe } from '@angular/common'; +import { + afterNextRender, + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + HostListener, + inject, + OnDestroy, + OnInit, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { ListRange } from '@angular/cdk/collections'; +import { SettingsStore } from '@iptvnator/services'; +import { EpgProgram, epgProviderClockMs } from '@iptvnator/shared/interfaces'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; +import { + EpgDateNavigationDirection, + getTodayEpgDateKey, + shiftEpgDateKey, +} from '../epg-date'; +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 { EpgGuideKeyboardController } from './epg-guide-keyboard.controller'; +import { + buildGuideDayAxis, + buildGuideRowBlocks, + buildGuideTicks, + EPG_GUIDE_CHANNEL_COLUMN_PX, + EPG_GUIDE_ROW_BUFFER, + EPG_GUIDE_ROW_HEIGHT_PX, + EpgGuideDensity, + guideNowLeftPx, + guideTrackWidthPx, +} from './epg-guide-layout.util'; +import { + clampGuideZoom, + persistEpgGuidePreferences, + restoreEpgGuidePreferences, +} from './epg-guide-preferences'; +import { EpgGuideProgramsService } from './epg-guide-programs.service'; +import { EpgGuideRowComponent } from './epg-guide-row.component'; +import { EPG_GUIDE_SOURCE, EpgGuideChannel } from './epg-guide-source'; +import { EpgGuideToolbarComponent } from './epg-guide-toolbar.component'; + +const SEARCH_DEBOUNCE_MS = 300; +const SEARCH_MAX_RESULTS = 20; + +/** + * Multi-channel programme guide. Reads everything from `EPG_GUIDE_SOURCE`; + * owns the selected day, zoom, density, filters and keyboard navigation. + * Rows are virtualised; the channel column and the ruler are sticky. + */ +@Component({ + selector: 'app-epg-guide', + imports: [ + DatePipe, + MatButtonModule, + MatIconModule, + ScrollingModule, + TranslatePipe, + EpgGuideRowComponent, + EpgGuideToolbarComponent, + ], + providers: [EpgGuideProgramsService], + templateUrl: './epg-guide.component.html', + styleUrl: './epg-guide.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EpgGuideComponent implements OnInit, OnDestroy { + private readonly source = inject(EPG_GUIDE_SOURCE); + private readonly programsService = inject(EpgGuideProgramsService); + private readonly settingsStore = inject(SettingsStore); + private readonly programmeDialog = inject(EpgProgrammeDialogService); + private readonly dialog = inject(MatDialog); + private readonly translate = inject(TranslateService); + private readonly destroyRef = inject(DestroyRef); + + readonly close = output(); + readonly channelActivated = output(); + + readonly viewport = viewChild('viewport'); + + private readonly preferences = restoreEpgGuidePreferences(); + readonly dayKey = signal(getTodayEpgDateKey()); + readonly zoom = signal(this.preferences.zoom); + readonly density = signal(this.preferences.density); + readonly onlyWithEpg = signal(this.preferences.onlyWithEpg); + readonly filter = signal(''); + readonly searchQuery = signal(''); + readonly searchResults = signal([]); + readonly nowMs = signal(Date.now()); + readonly scrollLeft = signal(0); + + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); + readonly currentLocale = computed(() => { + this.languageTick(); + return this.translate.currentLang || this.translate.defaultLang || 'en'; + }); + readonly offsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes; + readonly scopes = this.source.scopes; + readonly scopeId = this.source.scopeId; + readonly activeChannelId = this.source.activeChannelId; + readonly coverageLoaded = this.programsService.coverageLoaded; + readonly searchEnabled = typeof this.source.searchPrograms === 'function'; + readonly catchUpAvailable = this.source.catchUp !== undefined; + readonly channelColumnPx = EPG_GUIDE_CHANNEL_COLUMN_PX; + + 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.zoom())); + readonly nowLeftPx = computed(() => + guideNowLeftPx(this.axis(), this.nowMs(), this.zoom()) + ); + readonly rowHeightPx = computed(() => EPG_GUIDE_ROW_HEIGHT_PX[this.density()]); + readonly totalCount = computed(() => this.source.channels().length); + readonly rows = computed(() => { + const needle = this.filter().trim().toLowerCase(); + const onlyWithEpg = this.onlyWithEpg(); + return this.source + .channels() + .filter( + (channel) => + (!needle || channel.name.toLowerCase().includes(needle)) && + (!onlyWithEpg || this.programsService.isCovered(channel.id)) + ); + }); + readonly activeRowIndex = computed(() => { + const activeId = this.activeChannelId(); + return activeId === null + ? -1 + : this.rows().findIndex((channel) => channel.id === activeId); + }); + + private readonly keyboard = new EpgGuideKeyboardController({ + rowCount: () => this.rows().length, + blockCount: (row) => this.blocksFor(row).length, + activeRow: () => this.activeRowIndex(), + isBlocked: () => this.dialog.openDialogs.length > 0, + play: (row) => this.commitRow(this.rows()[row]), + details: (row, block) => + this.openDetails(this.rows()[row], this.blocksFor(row)[block]), + jumpNow: () => this.jumpNow(), + stepDay: (direction) => this.stepDay(direction), + close: () => this.close.emit(), + }); + readonly focus = this.keyboard.focus; + + private renderedRange: ListRange | null = null; + private minuteTimer?: number; + private searchTimer?: number; + + constructor() { + effect(() => { + const axis = this.axis(); + const offset = this.offsetMinutes(); + this.programsService.setWindow( + epgProviderClockMs(axis.startMs, offset), + epgProviderClockMs(axis.endMs, offset) + ); + untracked(() => this.loadRenderedRange()); + }); + effect(() => { + this.rows(); + untracked(() => this.loadRenderedRange()); + }); + effect(() => + persistEpgGuidePreferences({ + density: this.density(), + zoom: this.zoom(), + onlyWithEpg: this.onlyWithEpg(), + }) + ); + effect(() => { + const viewport = this.viewport(); + if (!viewport) { + return; + } + untracked(() => + viewport.renderedRangeStream + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((range) => { + this.renderedRange = range; + this.loadRenderedRange(); + }) + ); + }); + afterNextRender(() => this.jumpNow(false)); + } + + ngOnInit(): void { + this.minuteTimer = window.setInterval( + () => this.nowMs.set(Date.now()), + 60_000 + ); + } + + ngOnDestroy(): void { + window.clearInterval(this.minuteTimer); + window.clearTimeout(this.searchTimer); + } + + @HostListener('document:keydown', ['$event']) + onKeydown(event: KeyboardEvent): void { + if (this.keyboard.handle(event)) { + event.preventDefault(); + this.revealFocus(); + } + } + + trackRow(_index: number, channel: EpgGuideChannel): string { + return `${channel.number}:${channel.id}`; + } + + programsFor(channelId: string): EpgProgram[] { + return this.programsService.programsFor(channelId); + } + + statusFor(channelId: string) { + return this.programsService.statusFor(channelId); + } + + focusedBlockFor(row: number): number | null { + const focused = this.focus(); + return focused?.row === row ? focused.block : null; + } + + onViewportScroll(event: Event): void { + this.scrollLeft.set((event.target as HTMLElement).scrollLeft); + } + + stepDay(direction: EpgDateNavigationDirection): void { + this.dayKey.set(shiftEpgDateKey(this.dayKey(), direction)); + this.focus.set(null); + } + + jumpNow(animate = true): void { + this.dayKey.set(getTodayEpgDateKey()); + this.nowMs.set(Date.now()); + const viewport = this.viewport(); + const nowX = this.nowLeftPx(); + if (!viewport || nowX === null) { + return; + } + const element = viewport.elementRef.nativeElement; + const visibleTrack = Math.max(0, element.clientWidth - this.channelColumnPx); + element.scrollTo({ + left: Math.max(0, nowX - visibleTrack / 3), + behavior: animate ? 'smooth' : 'auto', + }); + const activeRow = this.activeRowIndex(); + if (activeRow >= 0) { + viewport.scrollToIndex(Math.max(0, activeRow - 3), animate ? 'smooth' : 'auto'); + } + } + + setScope(scopeId: string): void { + this.source.setScope(scopeId); + this.focus.set(null); + } + + setOnlyWithEpg(value: boolean): void { + this.onlyWithEpg.set(value); + } + + setDensity(value: EpgGuideDensity): void { + this.density.set(value); + } + + setZoom(value: number): void { + this.zoom.set(clampGuideZoom(value)); + } + + setFilter(value: string): void { + this.filter.set(value); + this.focus.set(null); + } + + onSearchQueryChange(query: string): void { + this.searchQuery.set(query); + window.clearTimeout(this.searchTimer); + const term = query.trim(); + if (term.length < 2 || !this.source.searchPrograms) { + this.searchResults.set([]); + return; + } + this.searchTimer = window.setTimeout(async () => { + const results = await this.source.searchPrograms?.(term).catch(() => []); + if (this.searchQuery() === query) { + this.searchResults.set((results ?? []).slice(0, SEARCH_MAX_RESULTS)); + } + }, SEARCH_DEBOUNCE_MS); + } + + openSearchResult(program: EpgProgram): void { + this.programmeDialog.open({ ...program }).subscribe(); + } + + activateRow(channel: EpgGuideChannel | undefined): void { + if (!channel) { + return; + } + this.source.activate(channel.id); + this.channelActivated.emit(channel.id); + } + + commitRow(channel: EpgGuideChannel | undefined): void { + if (!channel) { + return; + } + this.activateRow(channel); + this.close.emit(); + } + + 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.activateRow(channel); + } else if (result === 'timeshift') { + this.source.catchUp?.watch(channel, item.block.program); + } + }); + } + + watch(channel: EpgGuideChannel, item: TimelineRenderBlock): void { + this.source.catchUp?.watch(channel, item.block.program); + } + + private blocksFor(row: number): TimelineRenderBlock[] { + const channel = this.rows()[row]; + if (!channel) { + return []; + } + return buildGuideRowBlocks(this.programsService.programsFor(channel.id), { + axis: this.axis(), + hourWidthPx: this.zoom(), + nowMs: this.nowMs(), + offsetMinutes: this.offsetMinutes(), + catchUpAvailable: this.catchUpAvailable, + }); + } + + private loadRenderedRange(): void { + const rows = this.rows(); + const range = this.renderedRange ?? { start: 0, end: Math.min(rows.length, 30) }; + const start = Math.max(0, range.start - EPG_GUIDE_ROW_BUFFER); + const end = Math.min(rows.length, range.end + EPG_GUIDE_ROW_BUFFER); + this.programsService.ensureLoaded(rows.slice(start, end)); + } + + /** Keep the keyboard focus target inside the viewport, both axes. */ + private revealFocus(): void { + const focused = this.focus(); + const viewport = this.viewport(); + if (!focused || !viewport) { + return; + } + const element = viewport.elementRef.nativeElement; + const rowTop = focused.row * this.rowHeightPx(); + const scrollTop = viewport.measureScrollOffset('top'); + if (rowTop < scrollTop || rowTop + this.rowHeightPx() > scrollTop + element.clientHeight) { + viewport.scrollToIndex(Math.max(0, focused.row - 2)); + } + if (focused.block !== null) { + const block = this.blocksFor(focused.row)[focused.block]; + if (!block) { + return; + } + const visibleLeft = element.scrollLeft; + const visibleWidth = element.clientWidth - this.channelColumnPx; + if (block.leftPx < visibleLeft || block.leftPx + block.widthPx > visibleLeft + visibleWidth) { + element.scrollTo({ left: Math.max(0, block.leftPx - 40), behavior: 'smooth' }); + } + } + } +} +``` + +If this file exceeds 300 lines after formatting, move `jumpNow`/`revealFocus` scrolling into `epg-guide-scroll.util.ts` as pure functions taking the viewport element and geometry — do not let it reach 400. + +```html + + + +@if (searchEnabled && searchQuery().trim().length >= 2) { +
+ @for (program of searchResults(); track program.channel + program.start) { + + } @empty { +
{{ 'EPG.GUIDE.NO_RESULTS' | translate }}
+ } +
+} + +
+
+
{{ 'EPG.GUIDE.CHANNEL' | translate }}
+
+
+ @for (tick of ticks(); track tick.ms) { +
+ {{ tick.ms | date: 'HH:mm' }} +
+ } + @if (nowLeftPx(); as nowX) { +
{{ nowMs() | date: 'HH:mm' }}
+ } +
+
+
+ + + + @if (rows().length === 0) { +
+ search_off +

{{ 'EPG.GUIDE.NO_CHANNELS' | translate: { query: filter() } }}

+ @if (filter()) { + + } +
+ } +
+ + @if (nowLeftPx(); as nowX) { +
+ } +
+ + +``` + +```scss +// libs/ui/epg/src/lib/epg-guide/epg-guide.component.scss +@use '../epg-theme' as *; + +:host { display: flex; flex-direction: column; height: 100%; min-height: 0; background: var(--app-content-bg); color: $text-primary; position: relative; } +.epg-guide__grid { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; } +.epg-guide__ruler { display: flex; height: 34px; flex: 0 0 auto; border-bottom: 1px solid $line-strong; background: var(--app-content-bg); z-index: 4; } +.epg-guide__corner { flex: 0 0 var(--epg-guide-channel-col); display: flex; align-items: center; padding: 0 14px; font: 500 10px $font-mono; letter-spacing: 0.1em; text-transform: uppercase; color: $text-tertiary; border-right: 1px solid $line-strong; box-sizing: border-box; } +.epg-guide__ruler-clip { flex: 1; overflow: hidden; position: relative; } +.epg-guide__ruler-track { position: relative; height: 100%; will-change: transform; } +.epg-guide__tick { position: absolute; top: 0; bottom: 0; padding-left: 8px; display: flex; align-items: center; border-left: 1px solid $line-strong; font: 11px $font-mono; color: $text-secondary; font-variant-numeric: tabular-nums; + &.is-half { border-left-color: $line; color: $text-tertiary; font-size: 10px; } } +.epg-guide__now-badge { position: absolute; top: 6px; transform: translateX(-50%); background: $accent-live; color: #1a0d0d; font: 600 10.5px $font-mono; padding: 3px 7px; border-radius: 5px; z-index: 5; font-variant-numeric: tabular-nums; } +.epg-guide__viewport { flex: 1; min-height: 0; overflow: auto; + ::ng-deep .cdk-virtual-scroll-content-wrapper { min-width: 100%; } } +.epg-guide__now-line { position: absolute; top: 34px; bottom: 0; width: 2px; background: $accent-live; box-shadow: 0 0 10px color-mix(in srgb, $accent-live 60%, transparent); pointer-events: none; z-index: 3; } +.epg-guide__no-rows { position: sticky; left: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 48px 16px; color: $text-tertiary; + mat-icon { font-size: 32px; width: 32px; height: 32px; } } +.epg-guide__keys { display: flex; align-items: center; gap: 18px; height: 34px; padding: 0 18px; border-top: 1px solid $line; font-size: 11px; color: $text-tertiary; flex: 0 0 auto; + kbd { font: 10px $font-mono; color: $text-secondary; border: 1px solid $line-strong; border-radius: 4px; padding: 1px 5px; margin-right: 5px; } } +.epg-guide__search-results { position: absolute; top: 52px; right: 18px; width: 420px; max-height: 50vh; overflow: auto; z-index: 20; background: $surface-1; border: 1px solid $line-strong; border-radius: 10px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35); padding: 6px; display: flex; flex-direction: column; gap: 2px; } +.epg-guide__search-result { display: flex; flex-direction: column; gap: 2px; text-align: left; padding: 8px 10px; border: 0; border-radius: 7px; background: transparent; color: $text-primary; cursor: pointer; font: inherit; + &:hover, &:focus-visible { background: $surface-3; outline: none; } } +.epg-guide__search-title { font-size: 13px; font-weight: 500; } +.epg-guide__search-meta { font: 10.5px $font-mono; color: $text-tertiary; } +.epg-guide__search-empty { padding: 14px; text-align: center; color: $text-tertiary; font-size: 12.5px; } +``` + +- [ ] **Step 6: Export from the library** + +In `libs/ui/epg/src/index.ts` add (keep the two `multi-epg` lines for now; Task 10 removes them): + +```ts +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'; +``` + +- [ ] **Step 7: Write the failing component spec** + +```ts +// libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { By } from '@angular/platform-browser'; +import { SettingsStore } from '@iptvnator/services'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, of } from 'rxjs'; +import { EpgProgrammeDialogService } from '../epg-programme-dialog.service'; +import { EpgGuideComponent } from './epg-guide.component'; +import { + EPG_GUIDE_DENSITY_KEY, + EPG_GUIDE_ONLY_WITH_EPG_KEY, +} from './epg-guide-preferences'; +import { + EPG_GUIDE_SOURCE, + EpgGuideChannel, + EpgGuideSource, +} from './epg-guide-source'; + +function channel(id: string, epgKey: string | null = id, number = 1): EpgGuideChannel { + return { id, number, name: `Channel ${id}`, logoUrl: null, epgKey }; +} + +function nowProgram(channelId: string): EpgProgram { + const start = new Date(Date.now() - 10 * 60_000); + const stop = new Date(Date.now() + 20 * 60_000); + return { + start: start.toISOString(), + stop: stop.toISOString(), + channel: channelId, + title: `${channelId} now`, + desc: null, + category: null, + }; +} + +async function settle(fixture: ComponentFixture): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); +} + +describe('EpgGuideComponent', () => { + let fixture: ComponentFixture; + let component: EpgGuideComponent; + const channels = signal([]); + const activeChannelId = signal(null); + const activate = jest.fn(); + const setScope = jest.fn(); + const dialogOpen = jest.fn(() => of(undefined)); + + beforeEach(() => { + localStorage.clear(); + activate.mockReset(); + setScope.mockReset(); + dialogOpen.mockClear(); + channels.set([channel('a', 'a', 1), channel('b', null, 2), channel('c', 'c', 3)]); + activeChannelId.set('a'); + const source: EpgGuideSource = { + channels, + scopes: signal([{ id: 'all', label: 'All channels', kind: 'all' }]), + scopeId: signal('all'), + setScope, + loadPrograms: async (window) => + new Map(window.channels.map((item) => [item.id, item.id === 'a' ? [nowProgram('a')] : []])), + loadCoverage: async () => new Set(['a']), + activeChannelId, + activate, + }; + TestBed.configureTestingModule({ + imports: [EpgGuideComponent], + providers: [ + { provide: EPG_GUIDE_SOURCE, useValue: source }, + { provide: EpgProgrammeDialogService, useValue: { open: dialogOpen } }, + { provide: MatDialog, useValue: { openDialogs: [] } }, + { provide: SettingsStore, useValue: { resolvedEpgOffsetMinutes: signal(0) } }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + }, + }, + ], + }); + fixture = TestBed.createComponent(EpgGuideComponent); + component = fixture.componentInstance; + }); + + it('lists the scope channels in order and marks the active one', async () => { + await settle(fixture); + expect(component.rows().map((row) => row.id)).toEqual(['a', 'b', 'c']); + expect(component.activeRowIndex()).toBe(0); + }); + + it('emits activate on click and activate + close on double click / Enter', async () => { + await settle(fixture); + const close = jest.fn(); + component.close.subscribe(close); + component.activateRow(component.rows()[2]); + expect(activate).toHaveBeenCalledWith('c'); + expect(close).not.toHaveBeenCalled(); + component.commitRow(component.rows()[2]); + expect(close).toHaveBeenCalledTimes(1); + component.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(activate).toHaveBeenLastCalledWith('a'); + expect(close).toHaveBeenCalledTimes(2); + }); + + it('hides only uncovered rows when "Only with EPG" is on, after coverage arrived', async () => { + await settle(fixture); + component.setOnlyWithEpg(true); + await settle(fixture); + expect(component.rows().map((row) => row.id)).toEqual(['a']); + expect(localStorage.getItem(EPG_GUIDE_ONLY_WITH_EPG_KEY)).toBe('1'); + }); + + it('filters by channel name and clears', async () => { + await settle(fixture); + component.setFilter('channel c'); + expect(component.rows().map((row) => row.id)).toEqual(['c']); + component.setFilter(''); + expect(component.rows()).toHaveLength(3); + }); + + it('persists density and zoom and restores them', async () => { + await settle(fixture); + component.setDensity('compact'); + component.setZoom(9_999); + await settle(fixture); + expect(localStorage.getItem(EPG_GUIDE_DENSITY_KEY)).toBe('compact'); + expect(component.zoom()).toBe(480); + expect(component.rowHeightPx()).toBe(44); + }); + + it('opens the programme dialog for a non-live card and activates on "live"', async () => { + await settle(fixture); + dialogOpen.mockReturnValueOnce(of('live')); + const row = component.rows()[0]; + const item = { + kind: 'block' as const, + key: 'k', + block: { + program: nowProgram('a'), + key: 'k', + startMs: 0, + stopMs: 1, + when: 'past' as const, + offsetMin: 0, + durationMin: 1, + }, + leftPx: 0, + widthPx: 10, + tier: 'wide' as const, + nowFillPercent: 0, + canCatchUp: false, + }; + component.openDetails(row, item); + expect(dialogOpen).toHaveBeenCalledWith( + expect.objectContaining({ channelName: 'Channel a', primaryAction: null, archiveUnavailableNote: true }) + ); + expect(activate).toHaveBeenCalledWith('a'); + }); + + it('closes on Escape and steps days with PageUp/PageDown', async () => { + await settle(fixture); + const close = jest.fn(); + component.close.subscribe(close); + const before = component.dayKey(); + component.onKeydown(new KeyboardEvent('keydown', { key: 'PageDown' })); + expect(component.dayKey()).not.toBe(before); + component.onKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(close).toHaveBeenCalled(); + expect(fixture.debugElement.query(By.css('app-epg-guide-toolbar'))).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 8: Run the spec** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-guide/epg-guide.component.spec.ts` +Expected: PASS (7 tests). If `TranslatePipe` needs more of `TranslateService`, extend the stub the way `epg-timeline.component.spec.ts` does rather than importing the real module. If `afterNextRender` throws in the test environment, wrap the `jumpNow(false)` call in `try/catch`-free guard `if (typeof window !== 'undefined')` — do not remove the auto-scroll. + +- [ ] **Step 9: Lint + run the whole ui-epg project** + +Run: `pnpm nx lint ui-epg && pnpm nx test ui-epg` +Expected: lint clean (every new file < 400 lines, no bare aliases), all specs PASS. + +- [ ] **Step 10: Commit** + +```bash +git add libs/ui/epg apps/web/src/assets/i18n/en.json +git commit -m "feat(epg): add the programme guide grid components + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 7: "Guide" button in the timeline toolbar + +**Files:** +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts:86-115` +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html:90-125` +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss:164-180` +- Modify: `libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts` + +- [ ] **Step 1: Write the failing spec** + +Append to `epg-timeline.component.spec.ts` inside the main `describe`: + +```ts + it('shows a Guide button only when the host offers a guide and emits openGuide', () => { + setInputs({ programs: [programAt(0, 120, 'Now')] }); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('.epg-timeline__guide') + ).toBeNull(); + + const openGuide = jest.fn(); + component.openGuide.subscribe(openGuide); + setInputs({ guideAvailable: true }); + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector( + '.epg-timeline__guide' + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + button.click(); + expect(openGuide).toHaveBeenCalledTimes(1); + }); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts` +Expected: FAIL — `guideAvailable` is not a known input. + +- [ ] **Step 3: Add input, output and button** + +In `epg-timeline.component.ts` after `readonly offsetMinutes = input(0);` add: + +```ts + /** The host can open a multi-channel guide; renders the Guide button. */ + readonly guideAvailable = input(false); +``` + +After `readonly collapsedChange = output();` add: + +```ts + readonly openGuide = output(); +``` + +In `epg-timeline.component.html`, inside `@if (showRibbonControls()) {` directly before the `epg-timeline__jump` button, add: + +```html + @if (guideAvailable()) { + + } +``` + +In `epg-timeline.component.scss` change the selector `.epg-timeline__jump {` to `.epg-timeline__jump,\n.epg-timeline__guide {` so both share the pill style, and add after that block: + +```scss +.epg-timeline__guide { + color: $accent-text; + border-color: $accent-blue; +} +``` + +In `video-player.spec-stubs.ts` add to `StubEpgTimelineComponent`: + +```ts + readonly guideAvailable = input(false); + readonly openGuide = output(); +``` + +- [ ] **Step 4: Run the timeline spec and the host spec** + +Run: +```bash +NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add libs/ui/epg/src/lib/epg-timeline libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +git commit -m "feat(epg): add a Guide button to the timeline toolbar + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 8: M3U adapter for `EPG_GUIDE_SOURCE` + +**Files:** +- Create: `libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts` +- Create: `libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts` + +The host binds its signals into the adapter (`bind()`), so the adapter has +no dependency on the 1600-line host component and can be unit-tested alone. + +- [ ] **Step 1: Write the failing spec** + +```ts +// libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +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 & { 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([]); + const favoriteIds = signal([]); + const activeChannel = signal(null); + const dispatch = jest.fn(); + const getProgramsForChannels = jest.fn(); + const getProgramCoverage = jest.fn(); + const searchPrograms = jest.fn(); + let service: M3uEpgGuideSourceService; + + beforeEach(() => { + dispatch.mockReset(); + getProgramsForChannels.mockReset(); + getProgramCoverage.mockReset(); + searchPrograms.mockReset(); + channels.set([ + makeChannel('a', { tvgId: 'a.tv', group: 'News' }), + makeChannel('b', { name: 'Beta', group: 'Sports' }), + makeChannel('c', { name: ' ', group: 'Sports' }), + ]); + favoriteIds.set(['b']); + activeChannel.set(channels()[0]); + TestBed.configureTestingModule({ + providers: [ + M3uEpgGuideSourceService, + { provide: Store, useValue: { dispatch } }, + { + provide: EpgRuntimeBridgeService, + useValue: { getProgramsForChannels, getProgramCoverage, searchPrograms }, + }, + { provide: SettingsStore, useValue: { stripCountryPrefix: signal(false) } }, + ], + }); + service = TestBed.inject(M3uEpgGuideSourceService); + service.bind({ channels, favoriteIds, activeChannel }); + }); + + 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'); + expect(service.channels().map((channel) => channel.id)).toEqual(['b', 'c']); + expect(service.channels()[0].number).toBe(1); + service.setScope('favorites'); + expect(service.channels().map((channel) => channel.id)).toEqual(['b']); + }); + + 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('a')?.[0].title).toBe('a.tv show'); + expect(programs.get('b')).toEqual([]); + expect(programs.has('c')).toBe(false); + + const covered = await service.loadCoverage(window); + expect(covered).toEqual(new Set(['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('mirrors the active channel and dispatches playback on activate', () => { + expect(service.activeChannelId()).toBe('a'); + service.activate('b'); + expect(dispatch).toHaveBeenCalledWith( + ChannelActions.setActiveChannel({ channel: channels()[1], startPlayback: true }) + ); + service.activate('missing'); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + 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('forwards programme search to the bridge', async () => { + searchPrograms.mockResolvedValue([program('x')]); + await expect(service.searchPrograms('news')).resolves.toHaveLength(1); + expect(searchPrograms).toHaveBeenCalledWith('news', 20); + }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.spec.ts` +Expected: FAIL — cannot find module. + +- [ ] **Step 3: Implement the adapter** + +```ts +// libs/playlist/m3u/feature-player/src/lib/epg-guide/m3u-epg-guide-source.service.ts +import { computed, inject, Injectable, Signal, signal } from '@angular/core'; +import { Store } from '@ngrx/store'; +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, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + EpgGuideChannel, + EpgGuideScope, + EpgGuideSource, + EpgGuideWindow, +} from '@iptvnator/ui/epg'; +import { createM3uChannelPlaybackRequest } from '../video-player/m3u-channel-playback-actions'; + +export interface M3uEpgGuideInputs { + /** Guide-eligible channels (host excludes radio and recognised movies). */ + channels: Signal; + favoriteIds: Signal; + activeChannel: Signal; +} + +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 inputs = signal(null); + private readonly scope = signal(SCOPE_ALL); + + readonly scopeId = this.scope.asReadonly(); + + private readonly allChannels = computed( + () => this.inputs()?.channels() ?? [] + ); + + readonly scopes = computed(() => { + const groups: string[] = []; + const seen = new Set(); + 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: 'All channels', kind: 'all' }, + { id: SCOPE_FAVORITES, label: '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()?.favoriteIds() ?? []); + return channels.filter((channel) => 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; + }); + + readonly channels = computed(() => { + const strip = this.settingsStore.stripCountryPrefix?.(); + return this.scopedChannels().map((channel, index) => ({ + id: channel.id, + number: index + 1, + name: applyChannelNameStrip(channel.name, strip) || channel.name, + logoUrl: channel.tvg?.logo?.trim() || null, + epgKey: resolveChannelEpgLookupKey(channel) || null, + })); + }); + + readonly activeChannelId = computed( + () => this.inputs()?.activeChannel()?.id ?? null + ); + + bind(inputs: M3uEpgGuideInputs): void { + this.inputs.set(inputs); + } + + /** Called by the host when the guide opens: mirror the sidebar view. */ + applyInitialScope(view: string): void { + if (view === SCOPE_FAVORITES) { + this.scope.set(SCOPE_FAVORITES); + return; + } + const activeGroup = this.inputs()?.activeChannel()?.group?.title?.trim(); + if (view === 'groups' && activeGroup) { + this.scope.set(`${GROUP_PREFIX}${activeGroup}`); + return; + } + this.scope.set(SCOPE_ALL); + } + + setScope(id: string): void { + if (this.scopes().some((scope) => scope.id === id)) { + this.scope.set(id); + } + } + + async loadPrograms( + window: EpgGuideWindow + ): Promise> { + const keyed = this.keyedChannels(window.channels); + const result = new Map(); + 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> { + const keyed = this.keyedChannels(window.channels); + const covered = new Set(); + 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; + } + + activate(channelId: string): void { + const channel = this.scopedChannels().find( + (candidate) => candidate.id === channelId + ); + if (channel) { + this.store.dispatch(createM3uChannelPlaybackRequest(channel)); + } + } + + async searchPrograms(query: string): Promise { + return (await this.epgBridge.searchPrograms(query, SEARCH_LIMIT)) ?? []; + } + + private keyedChannels( + channels: EpgGuideChannel[] + ): Array<[EpgGuideChannel, string]> { + return channels + .filter((channel) => channel.epgKey !== null) + .map((channel) => [channel, channel.epgKey as string]); + } +} +``` + +Scope labels "All channels" / "Favorites" must be translated: import +`TranslateService` and use `this.translate.instant('CHANNELS.ALL_CHANNELS')` / +`'CHANNELS.FAVORITES'` if those keys exist (`grep -n '"ALL_CHANNELS"\|"FAVORITES"' apps/web/src/assets/i18n/en.json`); otherwise add +`EPG.GUIDE.SCOPE_ALL: "All channels"` and `EPG.GUIDE.SCOPE_FAVORITES: "Favorites"` to `en.json` (and Task 11's locale pass) and use those. Provide `TranslateService` in the spec as `{ instant: (key: string) => key }` and adjust the label assertions to the keys. + +- [ ] **Step 4: Run the spec** + +Same command as Step 2. Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add libs/playlist/m3u/feature-player/src/lib/epg-guide +git commit -m "feat(m3u): adapt the playlist channel list to the guide contract + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 9: Host integration — guide mode, docked player, entry points + +**Files:** +- Modify: `libs/portal/shared/util/src/lib/workspace-header-context.service.ts` (optional `active`) +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html:118-129` and `.scss` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts:880-925` +- Modify: `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts` (rename fixture id `multi-epg` → `epg-guide`, label `Open programme guide`, icon `grid_view`) + +`video-player.component.ts` is baselined (1590 lines). Add only thin glue +here; logic lives in `M3uEpgGuideSourceService` (Task 8) and the guide +components. Net growth must stay under ~80 lines. + +- [ ] **Step 1: Header action "active" state** + +`workspace-header-context.service.ts` — add to `WorkspaceHeaderAction`: + +```ts + /** Pressed/highlighted state for toggling actions (e.g. the guide). */ + active?: () => boolean; +``` + +`workspace-shell-header.component.html` — on the `.header-shortcut` button add: + +```html + [class.is-active]="action.active?.() ?? false" + [attr.aria-pressed]="action.active ? action.active() : null" +``` + +`workspace-shell-header.component.scss` — append: + +```scss +.header-shortcut.is-active { + background: var(--app-selection-surface); + color: var(--app-selection-color); +} +``` + +- [ ] **Step 2: Write the failing host specs** + +In `video-player.component.spec.ts`: + +1. Add stubs next to the other stubs: + +```ts +@Component({ + selector: 'app-epg-guide', + standalone: true, + template: '
', +}) +class StubEpgGuideComponent { + readonly close = output(); + readonly channelActivated = output(); +} + +@Component({ + selector: 'app-epg-guide-now-playing', + standalone: true, + template: '', +}) +class StubEpgGuideNowPlayingComponent { + readonly channelName = input(''); + readonly program = input(null); + readonly offsetMinutes = input(0); + readonly collapsed = input(false); + readonly closeRequested = output(); + readonly collapsedChange = output(); +} +``` + +and add both to the `overrideComponent(...).set.imports` list. + +2. Add `getEpgProgramsForChannels: jest.fn().mockResolvedValue({}), getEpgProgramCoverage: jest.fn().mockResolvedValue([]),` wherever the spec builds `window.electron` for the Electron branch (search for `window.electron = {`), and `EpgRuntimeBridgeService` mock is unnecessary because the adapter goes through the real bridge which reads `window.electron`. Add `selectFavorites` to the `selectSignal` switch returning `signal([])`. + +3. Update the existing header-shortcut test: + +```ts + it('registers and clears the workspace guide header shortcut', () => { + fixture.detectChanges(); + expect(headerContext.action()).toEqual( + expect.objectContaining({ id: 'm3u-epg-guide', icon: 'grid_view' }) + ); + fixture.destroy(); + expect(headerContext.action()).toBeNull(); + }); +``` + +and in the PWA test rename the title to `'hides EPG controls and the guide header action in browser/PWA playback'` (assertions unchanged). + +4. New cases (place after the header-shortcut test; `syncStoreState(sampleChannel)` and `player.set(...)` are the existing helpers that mount an inline player): + +```ts + it('opens the guide in place of sidebar and timeline without remounting 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(); + + component.openGuide(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('app-epg-guide')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-guide-now-playing')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('app-sidebar')).toBeNull(); + 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(); + expect(fixture.nativeElement.querySelector('app-sidebar')).not.toBeNull(); + 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); + + 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(); + syncStoreState({ ...sampleChannel, radio: 'true' }); + 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'); + }); +``` + +`storeMock.dispatch` is the existing jest mock on the store stub. If `syncStoreState` cannot express `radio: 'true'`, set `activeChannel.set({...sampleChannel, radio: 'true'})` directly (the signal the mock returns for `selectActive`). + +- [ ] **Step 3: Run to verify they fail** + +Run: `NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts` +Expected: FAIL on `openGuide` / `guideOpen` not existing. + +- [ ] **Step 4: Host component changes** + +`video-player.component.ts`: + +Imports — remove `Overlay, OverlayRef`, `ComponentPortal`, `Injector`, and from `@iptvnator/ui/epg` remove `COMPONENT_OVERLAY_REF`, `MultiEpgContainerComponent`; add: + +```ts +import { + EPG_GUIDE_SOURCE, + EpgGuideComponent, + EpgGuideNowPlayingComponent, + persistEpgGuideDockCollapsed, + restoreEpgGuideDockCollapsed, +} from '@iptvnator/ui/epg'; +import { selectFavorites } from '@iptvnator/m3u-state'; // add to the existing m3u-state import list +import { M3uEpgGuideSourceService } from '../epg-guide/m3u-epg-guide-source.service'; +``` + +Constant rename: `const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';` → `const M3U_EPG_GUIDE_HEADER_ACTION_ID = 'm3u-epg-guide';` (update both usages). + +Decorator — add `EpgGuideComponent, EpgGuideNowPlayingComponent` to `imports`; add to `providers`: + +```ts + M3uEpgGuideSourceService, + { provide: EPG_GUIDE_SOURCE, useExisting: M3uEpgGuideSourceService }, +``` + +Fields — delete `private readonly overlay = inject(Overlay);` and `private overlayRef!: OverlayRef;`. Add after `fullscreenPanelChannels`: + +```ts + 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) + ) + ); + 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). */ + readonly guideNowPlayingProgram = computed( + () => this.activeEpgProgramOrNull() ?? this.epgProgram() ?? null + ); +``` + +Constructor (add to the existing constructor body, or create one if the class has none): + +```ts + this.guideSource.bind({ + channels: this.guideChannels, + favoriteIds: this.store.selectSignal(selectFavorites), + activeChannel: this.activeChannel, + }); + effect(() => { + if (!this.canOpenGuide()) { + untracked(() => this.closeGuide()); + } + }); +``` + +Methods — replace `openMultiEpgView()` entirely with: + +```ts + openGuide(): void { + if (!this.canOpenGuide() || this.guideOpen()) { + return; + } + this.guideSource.applyInitialScope(this.activeView()); + this.guideOpen.set(true); + } + + closeGuide(): void { + this.guideOpen.set(false); + } + + toggleGuide(): void { + if (this.guideOpen()) { + this.closeGuide(); + } else { + this.openGuide(); + } + } + + setGuideDockCollapsed(collapsed: boolean): void { + this.guideDockCollapsed.set(collapsed); + persistEpgGuideDockCollapsed(collapsed); + } + + @HostListener('document:fullscreenchange') + onFullscreenChange(): void { + if (this.guideOpen() && this.isLivePlayerFullscreen()) { + this.closeGuide(); + } + } +``` + +`handleKeyPress` — after the `[inert]` check and before the Cmd/Ctrl+B branch insert: + +```ts + 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. + if (isGuideKey) { + event.preventDefault(); + this.closeGuide(); + } + return; + } + if (isGuideKey && this.canOpenGuide()) { + event.preventDefault(); + this.openGuide(); + return; + } +``` + +`registerHeaderShortcut` — replace the `setAction` payload: + +```ts + this.workspaceHeaderContext.setAction({ + 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(), + palette: { + labelKey: 'TOP_MENU.OPEN_EPG_GUIDE', + descriptionKey: + 'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION', + keywords: ['epg', 'guide', 'schedule'], + priority: 10, + }, + run: () => this.toggleGuide(), + }); +``` + +`video-player.component.html`: + +- Wrap the whole `` in `@if (!guideOpen()) { … }`. +- `
` → `
`. +- The `sidebar-restore` `@if (isSidebarCollapsed())` → `@if (isSidebarCollapsed() && !guideOpen())`. +- Inside `
`, after the `@if (radio) … @else … app-web-player-view` block (still inside `.video-player`), add: + +```html + @if (guideOpen()) { + + } +``` + +- Directly after that `.video-player` block's closing `}` (the `@if (radio || inline)` branch), add the external-player variant of the strip: + +```html + @if (guideOpen() && !shouldShowInlinePlayer(activeChannel)) { +
+ +
+ } +``` + +- Replace `@if (supportsEpg && activeChannel.radio !== 'true') {` … `}` (the EPG block) with: + +```html + @if (guideOpen()) { + + } @else if (supportsEpg && activeChannel.radio !== 'true') { + … existing `.epg` block unchanged … + } +``` + +- On `` add `[guideAvailable]="canOpenGuide()"` and `(openGuide)="openGuide()"`. + +`video-player.component.scss` — append: + +```scss +// ─── Guide mode ───────────────────────────────────────────────────────────── +// 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; + 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; + } + } +} +``` + +- [ ] **Step 5: Palette and facade spec renames** + +`workspace-shell.facade.spec.ts:880-925`: rename the test to `'includes M3U navigation, playlist actions, and the programme guide on playlist routes'`, replace `'m3u-multi-epg'` → `'m3u-epg-guide'` (3 places), `'view_list'` → `'grid_view'`, `'TOP_MENU.OPEN_MULTI_EPG'` → `'TOP_MENU.OPEN_EPG_GUIDE'` (2), `'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION'` → `'WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION'`. + +`workspace-command-palette.component.spec.ts`: replace fixture id `'multi-epg'` → `'epg-guide'` (4 places), label `'Open Multi-EPG'` → `'Open programme guide'`, icon `'view_list'` → `'grid_view'`. + +- [ ] **Step 6: Run host, shell and lint** + +Run: +```bash +NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js --config jest.web-esm.workspace.ts --runTestsByPath libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +pnpm nx test workspace-shell-feature +pnpm nx lint playlist-m3u-feature-player workspace-shell-feature portal-shared-util +pnpm run typecheck:ci +``` +(`pnpm nx show projects | grep shell` gives the exact shell project name if it differs.) Expected: all PASS; lint clean. + +- [ ] **Step 7: Commit** + +```bash +git add libs/portal/shared/util/src/lib/workspace-header-context.service.ts libs/workspace/shell libs/playlist/m3u/feature-player +git commit -m "feat(m3u): open the programme guide in place with a docked player + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 10: Remove the old multi-EPG overlay and its plumbing + +**Files:** +- Delete: `libs/ui/epg/src/lib/multi-epg/` (3 files) +- Modify: `libs/ui/epg/src/index.ts` (drop the two `multi-epg` exports) +- Modify: `apps/electron-backend/src/app/events/epg-query.service.ts:404-451` (delete `getChannelsByRange`) +- Modify: `apps/electron-backend/src/app/events/epg-query.service.spec.ts` (delete any `getChannelsByRange` case; `grep -n getChannelsByRange`) +- Modify: `apps/electron-backend-e2e/src/epg.e2e.ts:719-724` +- Modify: `apps/web/src/styles.scss:106-108` +- Modify: `libs/ui/components/src/lib/window-controls/window-controls.component.ts:17-24,91-95` +- Modify: `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts:81-83` +- Modify: `tools/eslint/max-lines-baseline.mjs` (regenerated) + +- [ ] **Step 1: Delete the component and its exports** + +```bash +git rm -r libs/ui/epg/src/lib/multi-epg +``` + +Remove from `libs/ui/epg/src/index.ts`: +```ts +export * from './lib/multi-epg/multi-epg-container.component'; +export * from './lib/multi-epg/overlay-ref.token'; +``` + +- [ ] **Step 2: Delete the backend range query** + +In `epg-query.service.ts` delete the whole `getChannelsByRange` method (the block starting `async getChannelsByRange(` through its closing brace, ~48 lines). Delete its spec cases if any exist. + +- [ ] **Step 3: E2E helper** + +In `apps/electron-backend-e2e/src/epg.e2e.ts` replace `getEpgChannelCount`: + +```ts +async function getEpgChannelCount(page: Page): Promise { + return page.evaluate(async () => { + const result = await window.electron?.getEpgChannels?.(); + return Array.isArray(result?.channels) ? result.channels.length : 0; + }); +} +``` + +- [ ] **Step 4: Comments and styles that described the overlay** + +`apps/web/src/styles.scss`: delete the `body.frameless-platform #epg-navigation { … }` rule (lines 106-108). + +`window-controls.component.ts:17-24`: replace "above full-window content such as the multi-EPG overlay and dialog backdrops" with "above full-window content such as dialog backdrops"; line 94: replace "(dialogs, multi-EPG)" with "(dialogs, menus)". + +`epg-item-description.component.ts:81-83`: replace "the multi-EPG grid and its search" with "the programme guide grid and its search". + +- [ ] **Step 5: Regenerate the max-lines baseline** + +```bash +node tools/eslint/generate-max-lines-baseline.mjs +git diff --stat tools/eslint/max-lines-baseline.mjs +``` +Expected: exactly one line removed (`libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts`); no additions. If the generator lists any new file, split that file instead of committing the baseline. + +- [ ] **Step 6: Verify nothing references the old names** + +```bash +grep -rn 'MultiEpg\|multi-epg\|COMPONENT_OVERLAY_REF\|getChannelsByRange\|EPG_GET_CHANNELS_BY_RANGE\|supportsChannelBrowser\|OPEN_MULTI_EPG\|epg-navigation' apps libs tools --include='*.ts' --include='*.html' --include='*.scss' --include='*.mjs' --include='*.json' | grep -v node_modules +``` +Expected: only i18n JSON hits for `OPEN_MULTI_EPG` in the non-English locales (removed in Task 11) and nothing else. + +- [ ] **Step 7: Typecheck, lint, tests** + +```bash +pnpm run typecheck:ci +pnpm nx lint ui-epg electron-backend web +pnpm nx test ui-epg +pnpm nx test electron-backend --testPathPatterns='epg' +``` +Expected: clean / PASS. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "refactor(epg): remove the multi-EPG overlay and the channel-range IPC + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 11: Translations for all 19 locales + +**Files:** +- Modify: `apps/web/src/assets/i18n/*.json` (18 non-English locales) + +- [ ] **Step 1: Add the new keys to every locale** + +Invoke the repository skill `i18n-fill` (`Skill: i18n-fill`) and ask it to sync the gap for the keys added in Task 6 (and Task 8's scope labels if you added `EPG.GUIDE.SCOPE_ALL`/`SCOPE_FAVORITES`): `EPG.GUIDE.*`, `EPG.TIMELINE.GUIDE`, `EPG.TIMELINE.OPEN_GUIDE`, `TOP_MENU.OPEN_EPG_GUIDE`, `WORKSPACE.SHELL.COMMANDS.OPEN_EPG_GUIDE_DESCRIPTION`. The merger only ADDS keys, so existing ones are untouched. + +- [ ] **Step 2: Remove the two retired keys from every locale** + +Write the removal as a one-off script in the scratchpad and run it: + +```js +// scratchpad/remove-old-multi-epg-keys.mjs +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +const dir = 'apps/web/src/assets/i18n'; +for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) { + const path = `${dir}/${file}`; + const json = JSON.parse(readFileSync(path, 'utf8')); + delete json.TOP_MENU?.OPEN_MULTI_EPG; + delete json.WORKSPACE?.SHELL?.COMMANDS?.OPEN_MULTI_EPG_DESCRIPTION; + writeFileSync(path, JSON.stringify(json, null, 4) + '\n'); +} +``` + +Run: `node /remove-old-multi-epg-keys.mjs` and then `git diff --stat apps/web/src/assets/i18n` — every file should show only the intended key changes (compare the JSON indentation with `git diff` on `en.json`; if the repo uses a different indent, match it before committing). + +- [ ] **Step 3: Verify** + +```bash +grep -rn 'OPEN_MULTI_EPG' apps/web/src/assets/i18n | wc -l # expected: 0 +grep -c '"GUIDE": {' apps/web/src/assets/i18n/*.json # expected: 1 per file (19 lines) +pnpm nx test web --testPathPatterns=i18n 2>/dev/null || true # if an i18n consistency spec exists it must pass +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/assets/i18n +git commit -m "i18n(epg): translate the programme guide + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 12: Electron E2E for the guide + +**Files:** +- Create: `apps/electron-backend-e2e/src/epg-guide.e2e.ts` + +- [ ] **Step 1: Write the test** + +```ts +// apps/electron-backend-e2e/src/epg-guide.e2e.ts +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 ` + + ${channelName} + + ${title} + Guide E2E programme. + + +`; +} + +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(); + await expect(app.mainWindow.locator('app-sidebar')).toHaveCount(0); + 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('app-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(); + } +}); +``` + +Note: a channel switch through `ChannelActions.setActiveChannel` with an +inline player keeps `app-web-player-view` mounted (the marker assertion +proves it); if the marker disappears, the host template moved the player +inside an `@if` that toggles — fix the template, not the test. + +- [ ] **Step 2: Run the test** + +Run: `pnpm nx run electron-backend-e2e:e2e-ci--src/epg-guide.e2e.ts` +(If the atomized target is not generated for the new file, run `pnpm nx reset` first, then retry; the target list comes from the file system.) +Expected: 1 passed. Ports 3210/3211/4200 are shared across worktrees — make sure no other E2E run is active. + +- [ ] **Step 3: Commit** + +```bash +git add apps/electron-backend-e2e/src/epg-guide.e2e.ts +git commit -m "test(e2e): cover the programme guide flow + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 13: Docs, CLAUDE.md, spec touch-ups and release note + +**Files:** +- Modify: `docs/architecture/m3u-playlist-module.md:700-712,1060-1075` +- Modify: `docs/architecture/workspace-shell.md:265-272,400-410` +- Modify: `docs/architecture/pwa-self-hosted.md:114-116` +- Modify: `CLAUDE.md` (`ui/epg` bullet in Monorepo Structure; the EPG display-offset bullet under "EPG (Electronic Program Guide)") +- Modify: `docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md` (two deviations) +- Create: `.changes/epg-programme-guide.md` + +- [ ] **Step 1: m3u-playlist-module.md** + +Replace the bullet `- Multi-EPG modal view` (line ~1071) with `- Programme guide (multi-channel grid) in guide mode — see "Programme guide" below`, and add a section after "Archive / Catch-Up Playback": + +```markdown +### 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)`. +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. + +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 line (`epg-guide:dock-collapsed`). Nothing remounts, so +playback and native-view Embedded MPV bounds survive. Entry points: the +workspace header action (`m3u-epg-guide`), the command palette, the Guide +button in the timeline toolbar (`EpgTimelineComponent.openGuide`) and the `G` +key on the player page. Player fullscreen, radio, recognised movies and the +PWA close or withhold it. 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. Density +(`epg-guide:density`, comfortable 60 px / compact 44 px), zoom +(`epg-guide:zoom`, 120–480 px per hour) 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`. +``` + +Line ~706: "the programme dialog and the multi-EPG overlay are opened imperatively" → "the programme dialog and the programme guide are opened imperatively". + +- [ ] **Step 2: workspace-shell.md and pwa-self-hosted.md** + +`workspace-shell.md:268`: "above full-window content such as the multi-EPG cdk overlay and Material dialog backdrops" → "above full-window content such as Material dialog backdrops". +`workspace-shell.md:405`: "top-aligned drag regions (`.workspace-header`, multi-EPG `#epg-navigation`)" → "the top-aligned drag region (`.workspace-header`)". +`pwa-self-hosted.md:115`: "multi-EPG shortcuts" → "programme-guide entry points". + +- [ ] **Step 3: CLAUDE.md** + +- Monorepo Structure: `- **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)`. +- EPG section bullet on the display offset: "(the programme dialog and multi-EPG overlay read the store themselves)" → "(the programme dialog and the programme guide read the store themselves)". +- Add to the EPG section a bullet: + `- 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 timeline's Guide button 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").` +- Also grep `CLAUDE.md` for `getEpgChannelsByRange`/`multi-EPG` and fix every remaining mention. + +- [ ] **Step 4: Spec deviations** + +In `docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md`, "M3U Host" bullet 3: replace "`loadPrograms`/`loadCoverage` call the new bridge methods with the playlist's `sourceUrls`, as `EpgService.getCurrentProgramsForChannels` does, so playlist-scoped EPG wins." with "`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." and "`searchPrograms` uses `EPG_SEARCH_PROGRAMS` and filters results to the scope's resolved channel ids." with "`searchPrograms` uses `EPG_SEARCH_PROGRAMS` unfiltered (result click opens the programme dialog, as the previous overlay did)." + +- [ ] **Step 5: Release note** + +```markdown + +--- +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. Clicking a channel switches +playback while the player stays on screen above the grid; double-click plays +and closes. Open it from the new Guide button under the player, the header, the +command palette or the G key. Hide channels without EPG, pick comfortable or +compact rows, and navigate with the keyboard. +``` + +Run: `pnpm run release:notes:validate` — expected: OK. + +- [ ] **Step 6: Commit** + +```bash +git add docs CLAUDE.md .changes/epg-programme-guide.md +git commit -m "docs(epg): document the programme guide and its release note + +Co-Authored-By: Claude Fable 5.1 " +``` + +### Task 14: Final validation + +- [ ] **Step 1: Unit suites and lint for every touched project** + +```bash +pnpm run typecheck:ci +pnpm nx run-many -t lint -p ui-epg playlist-m3u-feature-player epg-data-access services electron-backend web workspace-shell-feature portal-shared-util +pnpm nx run-many -t test -p ui-epg playlist-m3u-feature-player epg-data-access services workspace-shell-feature +pnpm nx test electron-backend +pnpm run release:notes:validate +``` +Expected: all green. `git status` must show no untracked files. + +- [ ] **Step 2: E2E** + +```bash +pnpm nx run electron-backend-e2e:e2e-ci--src/epg-guide.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/epg.e2e.ts +pnpm nx run electron-backend-e2e:e2e-ci--src/epg-timeline-interaction.e2e.ts +``` +Expected: all passed. + +- [ ] **Step 3: Manual CDP check (Embedded MPV native-view bounds)** + +```bash +pnpm run serve:backend +``` +In the app: Settings → Playback → Embedded MPV (native-view); open an M3U playlist with a working stream; press `G`; confirm the video is confined to the 16:9 thumb in the docked strip, that "Collapse player" hides it and audio continues, that resizing the window keeps the thumb aligned, and that Esc restores the full player. Capture `agent-browser --cdp 9222 screenshot` of the open guide for the PR. + +- [ ] **Step 4: Open the PR** + +Push the branch and open a PR against `master` titled `feat(epg): rebuild the programme guide for M3U playlists`, body: what changed (issue #171 items, guide mode, new IPCs, removals), test evidence (commands above), screenshots, and the follow-ups: portal hosts (sub-project 2), `multi-epg-view.webp` recapture at release, M3U catch-up wiring. End the body with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + +--- + +## Self-review notes + +- Spec coverage: contract (T3), guide component incl. toolbar/row/now-playing/keyboard/density/zoom/persistence (T4–T6), timeline button (T7), M3U adapter + scopes (T8), host guide mode + four entry points + close rules (T9), backend IPCs + capability + removals (T1, T2, T10), i18n (T6, T11), E2E (T12), docs + release note (T13), validation (T14). +- Deviations from the spec are recorded in T13 Step 4 (unscoped queries; unfiltered search). +- Type consistency: `EpgGuideChannel/Scope/Window/Source` (T3) are used unchanged in T4, T6, T8; `EpgGuideRowStatus` comes from T4 and is consumed by T6's row; `TimelineRenderBlock` is the existing timeline type reused for guide blocks; `ElectronBridgeEpgGuideWindow` (T2) is the wire shape for both IPCs and matches `EpgGuideWindowRequest` (T1) field-for-field. diff --git a/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md new file mode 100644 index 000000000..1c63cfb73 --- /dev/null +++ b/docs/superpowers/specs/2026-09-06-epg-guide-m3u-design.md @@ -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; // scope-resolved, playlist order, radio/movies excluded by host + readonly scopes: Signal; + readonly scopeId: Signal; + setScope(id: string): void; + loadPrograms(window: EpgGuideWindow): Promise>; // keyed by channel.id + loadCoverage(window: EpgGuideWindow): Promise>; // channel.ids with ≥1 programme + readonly activeChannelId: Signal; + activate(channelId: string): void; // switch playback; the guide stays open + searchPrograms?(query: string): Promise; // 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; + `` 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`. 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 `
} @@ -163,7 +177,9 @@ {{ 'EPG.TIMELINE.ON_NOW' | translate }} - {{ now.program.title }} + {{ + now.program.title + }} @if (now.progress !== null) {
-
- - @for (item of filteredChannels(); track item.id; let i = $index) { - - - - - -
- @if (getChannelIcon(item)) { - - } - {{ getChannelName(item) }} -
-
-
-
- } -
-
- - - @for (hour of timeHeader; track hour) { - - - - {{ hour }}:00 - - - - - } - - - @for (item of filteredChannels(); track item.id; let i = $index) { - - @for (program of item.programs; track trackByProgram($index, program)) { - - - -
{{ program.title }}
-
-
- } -
- } - - - - - - {{ currentTimeLabel() }} - -
-
-
- - -@if (filteredChannels().length === 0 && channelFilter()) { -
- search_off -

No channels match "{{ channelFilter() }}"

- -
-} diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss deleted file mode 100644 index 94bf9af24..000000000 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.scss +++ /dev/null @@ -1,916 +0,0 @@ -// Color palette — every value resolves through the app's theme tokens so -// Multi-EPG looks like part of IPTVnator instead of a separate broadcast -// utility. Previously hard-coded #0a0a0f / #12121a / #1a1a24 made the grid -// switch to near-black while the rest of the app sat on --app-content-bg, -// which the UX audit called out as the screen "visually detaching" from -// the rest of the app. -$bg-deep: var(--app-content-bg); -$bg-surface: var(--app-widget-bg); -$bg-elevated: var(--app-widget-header-bg); -$bg-hover: var(--app-card-hover-bg); -$border-subtle: var(--app-widget-header-border); -$border-medium: var(--app-separator); - -// Accent colors — unified with the rest of the app: -// cyan stays on the "now" indicator only; blue is the primary; the old -// $8b5cf6 purple and $00d4aa teal are dropped per the UX audit. -$accent-primary: #5cd6ff; // Cyan — reserved for "now" indicator -$accent-now-glow: rgba(92, 214, 255, 0.45); // Cyan glow for the now-line -$accent-secondary: var(--app-selection-color); // Blue — programs -$accent-tertiary: var(--app-selection-color); // Blue — highlights -$accent-warning: #fbbf24; - -// Text colors — derived from app heading/body so light and dark themes -// stay in sync without a separate `.light-theme` override at the bottom -// (which was duplicating responsibility with the same token chain). -$text-primary: var(--app-heading-color); -$text-secondary: var(--app-body-color); -$text-muted: var(--app-muted-color); - -// Gradients -$gradient-program: linear-gradient( - 135deg, - rgba(79, 142, 255, 0.15) 0%, - rgba(79, 142, 255, 0.05) 100% -); -$gradient-program-hover: linear-gradient( - 135deg, - rgba(79, 142, 255, 0.3) 0%, - rgba(79, 142, 255, 0.15) 100% -); -$gradient-header: linear-gradient( - 180deg, - rgba(92, 214, 255, 0.08) 0%, - transparent 100% -); - -// Base container -.parent { - display: flex; - background: $bg-deep; - color: $text-primary; - font-family: Roboto, 'Helvetica Neue', sans-serif; - position: relative; - overflow-y: auto; - height: calc(100vh - 56px); - - // Custom scrollbar for vertical scroll - &::-webkit-scrollbar { - width: 8px; - } - - &::-webkit-scrollbar-track { - background: $bg-surface; - } - - &::-webkit-scrollbar-thumb { - background: rgba(47, 123, 255, 0.4); - border-radius: 4px; - - &:hover { - background: rgba(47, 123, 255, 0.6); - } - } -} - -// Navigation toolbar - Frosted glass effect -#epg-navigation { - display: flex; - align-items: center; - padding: 12px 16px 12px 80px; // Left padding for macOS traffic lights - background: rgba(18, 18, 26, 0.85); - backdrop-filter: blur(20px); - border-bottom: 1px solid $border-medium; - gap: 8px; - position: relative; - z-index: 100; - -webkit-app-region: drag; - - // Subtle glow at bottom - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 1px; - background: linear-gradient( - 90deg, - transparent 0%, - rgba(92, 214, 255, 0.3) 20%, - rgba(47, 123, 255, 0.3) 80%, - transparent 100% - ); - } - - // Button styling - exclude from drag region - button { - font-family: Roboto, 'Helvetica Neue', sans-serif; - font-weight: 500; - letter-spacing: 0.02em; - transition: all 0.2s ease; - -webkit-app-region: no-drag; - - &:not([disabled]):hover { - background: rgba(47, 123, 255, 0.15); - } - - &[disabled] { - opacity: 0.3; - } - - mat-icon { - font-size: 20px; - } - } -} - -// Search container - exclude from drag region -.search-container { - display: flex; - align-items: center; - gap: 8px; - margin-left: auto; - -webkit-app-region: no-drag; - - .channel-search { - display: flex; - align-items: center; - gap: 8px; - width: 200px; - height: 36px; - padding: 0 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid $border-subtle; - border-radius: 8px; - transition: all 0.2s ease; - - &:focus-within { - border-color: rgba(47, 123, 255, 0.5); - background: rgba(255, 255, 255, 0.08); - } - - .search-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: $text-muted; - flex-shrink: 0; - } - - input { - flex: 1; - background: transparent; - border: none; - outline: none; - font-size: 13px; - font-family: Roboto, 'Helvetica Neue', sans-serif; - color: $text-primary; - min-width: 0; - - &::placeholder { - color: $text-muted; - } - } - - .clear-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - border-radius: 50%; - cursor: pointer; - color: $text-muted; - transition: all 0.15s ease; - flex-shrink: 0; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: $text-primary; - } - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - } - } - } - - .filter-count { - font-size: 11px; - color: $text-muted; - font-family: 'JetBrains Mono', monospace; - white-space: nowrap; - } - - &.expanded { - .channel-search { - animation: slideIn 0.2s ease-out; - } - } -} - -@keyframes slideIn { - from { - opacity: 0; - width: 0; - transform: translateX(20px); - } - to { - opacity: 1; - width: 200px; - transform: translateX(0); - } -} - -// Program search container -.program-search-container { - display: flex; - align-items: center; - gap: 8px; - position: relative; - -webkit-app-region: no-drag; - - .program-search-input { - display: flex; - align-items: center; - gap: 8px; - width: 220px; - height: 36px; - padding: 0 12px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid $border-subtle; - border-radius: 8px; - transition: all 0.2s ease; - - &:focus-within { - border-color: rgba(47, 123, 255, 0.5); - background: rgba(255, 255, 255, 0.08); - } - - .search-icon { - font-size: 18px; - width: 18px; - height: 18px; - color: $accent-secondary; - flex-shrink: 0; - } - - input { - flex: 1; - background: transparent; - border: none; - outline: none; - font-size: 13px; - font-family: Roboto, 'Helvetica Neue', sans-serif; - color: $text-primary; - min-width: 0; - - &::placeholder { - color: $text-muted; - } - } - - .clear-btn { - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - border: none; - border-radius: 50%; - cursor: pointer; - color: $text-muted; - transition: all 0.15s ease; - flex-shrink: 0; - - &:hover { - background: rgba(255, 255, 255, 0.1); - color: $text-primary; - } - - mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - } - } - - .search-spinner { - width: 16px; - height: 16px; - border: 2px solid $border-subtle; - border-top-color: $accent-secondary; - border-radius: 50%; - animation: spin 0.8s linear infinite; - flex-shrink: 0; - } - } - - .program-search-results { - position: fixed; - top: 60px; - right: 16px; - width: 320px; - max-height: 400px; - overflow-y: auto; - background: $bg-elevated; - border: 1px solid $border-medium; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 10000; - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: rgba(47, 123, 255, 0.3); - border-radius: 3px; - } - - .search-result-item { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - padding: 12px 16px; - background: transparent; - border: none; - border-bottom: 1px solid $border-subtle; - cursor: pointer; - text-align: left; - transition: background 0.15s ease; - - &:last-child { - border-bottom: none; - } - - &:hover { - background: $bg-hover; - - .result-arrow { - opacity: 1; - transform: translateX(0); - } - } - - .result-info { - display: flex; - flex-direction: column; - gap: 4px; - overflow: hidden; - } - - .result-title { - font-size: 13px; - font-weight: 500; - color: $text-primary; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .result-meta { - display: flex; - gap: 8px; - font-size: 11px; - color: $text-muted; - } - - .result-time { - color: $accent-primary; - font-family: 'JetBrains Mono', monospace; - } - - .result-date { - color: $text-muted; - } - - .result-arrow { - font-size: 18px; - width: 18px; - height: 18px; - color: $accent-secondary; - opacity: 0; - transform: translateX(-4px); - transition: all 0.15s ease; - } - } - } - - .program-search-empty { - position: fixed; - top: 60px; - right: 16px; - width: 220px; - padding: 24px 16px; - background: $bg-elevated; - border: 1px solid $border-medium; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 10000; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - color: $text-muted; - - mat-icon { - font-size: 24px; - width: 24px; - height: 24px; - opacity: 0.5; - } - - span { - font-size: 12px; - } - } - - &.open { - .program-search-input { - animation: slideIn 0.2s ease-out; - } - } -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@keyframes highlightPulse { - 0%, 100% { - filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)); - } - 50% { - filter: drop-shadow(0 0 20px rgba(92, 214, 255, 0.9)) - drop-shadow(0 0 40px rgba(92, 214, 255, 0.4)); - } -} - -// No results state -.no-results { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; - color: $text-muted; - text-align: center; - z-index: 100; - - mat-icon { - font-size: 48px; - width: 48px; - height: 48px; - opacity: 0.5; - } - - p { - margin: 0; - font-size: 14px; - } - - button { - color: $accent-primary; - } -} - -// Today's date display -.today-date { - flex: 1; - font-family: 'JetBrains Mono', monospace; - font-size: 14px; - font-weight: 500; - color: $accent-primary; - text-align: center; - padding: 0 24px; - letter-spacing: 0.05em; - text-transform: uppercase; - - // Glowing text effect - text-shadow: 0 0 20px rgba(92, 214, 255, 0.3); -} - -// Channel column — bumped from 140px to 180px per the UX audit so long -// channel names ("13th Street Universal HD", "ARD alpha", etc.) stop being -// truncated to 5 characters. -#channels-column { - width: 180px; - flex: none; - border-right: 1px solid $border-medium; - background: $bg-surface; - position: relative; - z-index: 10; - min-height: calc(100vh - 6px); - - // Subtle shadow for depth - box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3); - - .channel { - cursor: pointer; - transition: all 0.15s ease; - - rect { - fill: $bg-surface; - stroke: $border-subtle; - transition: fill 0.15s ease; - } - - &:hover rect { - fill: $bg-elevated; - } - - &.active rect { - fill: rgba(47, 123, 255, 0.2); - stroke: rgba(47, 123, 255, 0.4); - } - - &.active .channel-name { - color: $accent-secondary; - font-weight: 600; - } - } - - .channel-name { - color: $text-primary; - padding: 8px 12px; - font-size: 12.5px; - font-weight: 500; - display: flex; - align-items: center; - gap: 10px; - height: 100%; - overflow: hidden; - letter-spacing: 0.01em; - line-height: 1.2; - - img { - // Smaller logo (24px) per audit recommendation so the channel - // name has room to breathe in the 180px column. - width: 24px !important; - height: 24px !important; - margin-right: 0 !important; - flex: 0 0 auto; - border-radius: 4px; - background: rgba(255, 255, 255, 0.05); - padding: 2px; - object-fit: contain; - } - - span { - // Allow up to two lines so "13th Street Universal HD" and - // similar long names wrap instead of disappearing into an - // ellipsis after 5 characters. - min-width: 0; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - word-break: break-word; - } - } - - image { - object-fit: contain; - background: $bg-elevated; - } -} - -// EPG container -#epg-container { - width: calc(100vw - 180px); - overflow-x: scroll; - overflow-y: clip; - background: $bg-deep; - min-height: calc(100vh - 6px); - - // Custom scrollbar - &::-webkit-scrollbar { - height: 8px; - } - - &::-webkit-scrollbar-track { - background: $bg-surface; - } - - &::-webkit-scrollbar-thumb { - background: rgba(47, 123, 255, 0.4); - border-radius: 4px; - - &:hover { - background: rgba(47, 123, 255, 0.6); - } - } -} - -// EPG SVG container -#epg-svg { - // Time header styling - g > rect[fill='#000'] { - fill: $bg-surface !important; - } - - g > text { - font-family: 'JetBrains Mono', monospace !important; - font-size: 11px !important; - font-weight: 500; - fill: $text-secondary !important; - letter-spacing: 0.1em; - } - - // Hour separator vertical lines - .hour-separator { - stroke: $border-subtle; - stroke-width: 1px; - pointer-events: none; - } - - // Program blocks - .program { - cursor: pointer; - - rect { - fill: $bg-elevated; - stroke: $border-subtle; - rx: 4; - ry: 4; - transition: all 0.15s ease; - } - - &:hover rect { - fill: $bg-hover; - stroke: rgba(47, 123, 255, 0.4); - filter: drop-shadow(0 0 8px rgba(47, 123, 255, 0.2)); - } - - // Highlighted program (from search) - &.highlighted rect { - fill: rgba(92, 214, 255, 0.25); - stroke: $accent-primary; - stroke-width: 2px; - animation: highlightPulse 1s ease-in-out 3; - } - - &.highlighted .program-title { - color: $accent-primary; - font-weight: 600; - } - } - - // Active channel row highlighting - .active-row .program rect { - fill: rgba(47, 123, 255, 0.15); - stroke: rgba(47, 123, 255, 0.3); - } - - .active-row .program:hover rect { - fill: rgba(47, 123, 255, 0.25); - } - - .program-title { - color: $text-primary; - padding: 6px 10px; - font-size: 11px; - font-weight: 400; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - pointer-events: none; - letter-spacing: 0.01em; - line-height: 1.4; - - // Subtle gradient text for depth - display: flex; - align-items: center; - height: 100%; - } - - // Current time line - glowing effect - line[stroke='red'] { - stroke: $accent-primary !important; - stroke-width: 2px; - filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) - drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); - - // Animated pulse - animation: timePulse 2s ease-in-out infinite; - } - - // Now-line time badge — a small filled pill sitting on top of the time - // header that reads "HH:MM". Keeps the cyan visual language of the line - // itself so the relationship is unmistakable. - .now-label { - pointer-events: none; - - rect { - fill: $accent-primary; - stroke: none; - filter: drop-shadow(0 0 6px $accent-now-glow); - } - - text { - fill: var(--app-shell-bg, #0d0f12); - font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, - monospace; - font-size: 10.5px; - font-weight: 700; - letter-spacing: 0.04em; - } - } - - // Currently-airing program — a thin cyan border on every program block - // whose time window contains the now-line. Audit recommended this to - // make "what's airing right now" scannable without finding the line - // first then tracing it down each row. - .program.is-now { - rect { - stroke: $accent-primary; - stroke-width: 1.5px; - } - } -} - -// Pulse animation for time line -@keyframes timePulse { - 0%, - 100% { - opacity: 1; - filter: drop-shadow(0 0 8px rgba(92, 214, 255, 0.6)) - drop-shadow(0 0 16px rgba(92, 214, 255, 0.3)); - } - 50% { - opacity: 0.8; - filter: drop-shadow(0 0 12px rgba(92, 214, 255, 0.8)) - drop-shadow(0 0 24px rgba(92, 214, 255, 0.4)); - } -} - -// Legacy classes cleanup -.program-item { - cursor: pointer; -} - -rect { - stroke: $border-subtle; - cursor: pointer; -} - -.channel > rect { - stroke: $border-subtle; -} - -.program-name, -.channel-name { - -webkit-line-clamp: 2; - height: 32px; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-box-orient: vertical; - padding: 6px 8px; -} - -#current-time-line { - stroke-width: 2px; - stroke: $accent-primary; -} - -// Dark theme override (already dark, but ensure consistency) -::ng-deep { - .dark-theme { - #epg-navigation { - background: rgba(18, 18, 26, 0.85); - } - } - - // Light theme adjustments - .light-theme { - #epg-navigation { - background: rgba(255, 255, 255, 0.9); - border-bottom-color: rgba(0, 0, 0, 0.1); - - .today-date { - color: #059669; - text-shadow: none; - } - } - - .parent { - background: #f8fafc; - - &::before { - background-image: - linear-gradient(rgba(0, 0, 0, 0.03) 1px, transparent 1px), - linear-gradient( - 90deg, - rgba(0, 0, 0, 0.03) 1px, - transparent 1px - ); - } - } - - #channels-column { - background: #ffffff; - border-right-color: rgba(0, 0, 0, 0.1); - box-shadow: 4px 0 20px rgba(0, 0, 0, 0.05); - - .channel rect { - fill: #ffffff; - stroke: rgba(0, 0, 0, 0.08); - } - - .channel:hover rect { - fill: #f1f5f9; - } - - .channel-name { - color: #1e293b; - } - } - - #epg-container { - background: #f8fafc; - } - - #epg-svg { - g > rect[fill='#000'] { - fill: #e2e8f0 !important; - } - - g > text { - fill: #64748b !important; - } - - .program { - rect { - fill: #ffffff; - stroke: rgba(0, 0, 0, 0.08); - } - - &:hover rect { - fill: #f1f5f9; - stroke: rgba(47, 123, 255, 0.4); - } - } - - .program-title { - color: #1e293b; - } - - line[stroke='red'] { - stroke: $accent-primary !important; - filter: drop-shadow(0 0 6px $accent-now-glow); - } - } - } -} - -// Responsive adjustments -@media (max-width: 768px) { - #epg-navigation { - padding-left: 70px; // Slightly reduced for smaller screens - } - - #channels-column { - width: 140px; - - .channel-name { - font-size: 11.5px; - padding: 6px 8px; - gap: 8px; - - img { - width: 20px !important; - height: 20px !important; - } - } - } - - #epg-container { - width: calc(100vw - 140px); - } - - .today-date { - font-size: 12px; - padding: 0 12px; - } -} diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts deleted file mode 100644 index 9535f69b6..000000000 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { signal } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { OverlayRef } from '@angular/cdk/overlay'; -import { MatDialog } from '@angular/material/dialog'; -import { TranslateService } from '@ngx-translate/core'; -import { of } from 'rxjs'; -import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; -import { SettingsStore } from '@iptvnator/services'; -import { - ElectronBridgeEpgChannelWithPrograms, - EpgProgram, -} from '@iptvnator/shared/interfaces'; -import { - MultiEpgContainerComponent, - isSelectedEpgDayToday, -} from './multi-epg-container.component'; -import { COMPONENT_OVERLAY_REF } from './overlay-ref.token'; - -describe('isSelectedEpgDayToday', () => { - it('returns true only when the selected EPG day is the actual current day', () => { - const now = new Date('2026-05-21T20:00:00.000Z'); - - expect(isSelectedEpgDayToday('20260521', now)).toBe(true); - expect(isSelectedEpgDayToday('20260520', now)).toBe(false); - expect(isSelectedEpgDayToday('20260522', now)).toBe(false); - }); -}); - -function programAt(start: Date, minutes: number, title: string): EpgProgram { - return { - start: start.toISOString(), - stop: new Date(start.getTime() + minutes * 60_000).toISOString(), - channel: 'c1', - title, - desc: null, - category: null, - }; -} - -describe('MultiEpgContainerComponent runtime gates', () => { - let fixture: ComponentFixture; - let component: MultiEpgContainerComponent; - let epgBridge: Partial; - const epgOffsetMinutes = signal(0); - - beforeEach(async () => { - epgOffsetMinutes.set(0); - epgBridge = { - getChannelsByRange: jest.fn().mockResolvedValue([]), - searchPrograms: jest.fn().mockResolvedValue([]), - supportsChannelBrowser: false, - supportsProgramSearch: false, - }; - - await TestBed.configureTestingModule({ - imports: [MultiEpgContainerComponent], - providers: [ - { provide: MatDialog, useValue: { open: jest.fn() } }, - { - provide: COMPONENT_OVERLAY_REF, - useValue: { detach: jest.fn() }, - }, - { - provide: OverlayRef, - useValue: { detach: jest.fn() }, - }, - { - provide: EpgRuntimeBridgeService, - useValue: epgBridge, - }, - { - provide: SettingsStore, - useValue: { resolvedEpgOffsetMinutes: epgOffsetMinutes }, - }, - { - provide: TranslateService, - useValue: { - currentLang: 'en', - defaultLang: 'en', - onLangChange: of(null), - }, - }, - ], - }) - .overrideComponent(MultiEpgContainerComponent, { - set: { template: '' }, - }) - .compileComponents(); - - fixture = TestBed.createComponent(MultiEpgContainerComponent); - component = fixture.componentInstance; - }); - - afterEach(() => { - fixture.destroy(); - jest.restoreAllMocks(); - jest.useRealTimers(); - }); - - it('lays programmes out in display time when an EPG offset is set', () => { - const morning = new Date(); - morning.setHours(10, 0, 0, 0); - const late = new Date(); - late.setHours(23, 30, 0, 0); - component.originalEpgData.set([ - { - id: 'c1', - displayName: 'Channel 1', - programs: [ - programAt(morning, 60, 'Morning'), - programAt(late, 60, 'Late'), - ], - } as unknown as ElectronBridgeEpgChannelWithPrograms, - ]); - - epgOffsetMinutes.set(90); - - // 23:30 + 90 min lands on the next day and leaves today's column; - // 10:00 + 90 min is drawn under 11:30. - const programs = component.channels()[0].programs; - expect(programs.map((program) => program.title)).toEqual(['Morning']); - expect(programs[0].startPosition).toBe(11.5 * component.hourWidth()); - expect(programs[0].width).toBe(component.hourWidth()); - }); - - it('does not request EPG channel ranges when the EPG bridge cannot browse channels', async () => { - jest.spyOn(console, 'warn').mockImplementation(() => undefined); - - await component.requestPrograms(); - - expect(epgBridge.getChannelsByRange).not.toHaveBeenCalled(); - expect(component.isLoading()).toBe(false); - }); - - it('requests EPG channel ranges through the EPG runtime bridge', async () => { - epgBridge.getChannelsByRange = jest.fn().mockResolvedValue([ - { - channel_id: 'channel-1', - display_name: 'Channel One', - programs: [], - }, - ]); - epgBridge.supportsChannelBrowser = true; - - await component.requestPrograms(); - - expect(epgBridge.getChannelsByRange).toHaveBeenCalledWith(0, 20); - expect(component.isLoading()).toBe(false); - }); - - it('does not search EPG programs when the EPG bridge cannot search programs', () => { - jest.useFakeTimers(); - - component.onProgramSearchInput({ - target: { value: 'news' }, - } as unknown as Event); - jest.advanceTimersByTime(600); - - expect(epgBridge.searchPrograms).not.toHaveBeenCalled(); - expect(component.isSearchingPrograms()).toBe(false); - expect(component.programSearchResults()).toEqual([]); - }); - - it('searches EPG programs through the EPG runtime bridge', async () => { - jest.useFakeTimers(); - const results = [ - { - channelId: 'channel-1', - start: '2026-05-22T10:00:00.000Z', - stop: '2026-05-22T11:00:00.000Z', - title: 'News', - }, - ]; - epgBridge.searchPrograms = jest.fn().mockResolvedValue(results); - epgBridge.supportsProgramSearch = true; - - component.onProgramSearchInput({ - target: { value: 'news' }, - } as unknown as Event); - jest.advanceTimersByTime(500); - await Promise.resolve(); - - expect(epgBridge.searchPrograms).toHaveBeenCalledWith('news', 20); - expect(component.programSearchResults()).toEqual(results); - expect(component.isSearchingPrograms()).toBe(false); - }); -}); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts deleted file mode 100644 index a43449ce8..000000000 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { DatePipe } from '@angular/common'; -import { OverlayRef } from '@angular/cdk/overlay'; - -import { - AfterViewInit, - ChangeDetectionStrategy, - Component, - computed, - ElementRef, - inject, - Input, - OnDestroy, - OnInit, - signal, - viewChild, -} from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDialog } from '@angular/material/dialog'; -import { MatIcon } from '@angular/material/icon'; -import { MatTooltip } from '@angular/material/tooltip'; -import { normalizeDateLocale } from '@iptvnator/pipes'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { addDays, differenceInMinutes, format, parse, subDays } from 'date-fns'; -import { Observable, Subscription, startWith } from 'rxjs'; -import { - Channel, - ElectronBridgeEpgChannelWithPrograms, - EpgChannel, - epgDisplayTimeMs, - EpgProgram, -} from '@iptvnator/shared/interfaces'; -import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; -import { SettingsStore } from '@iptvnator/services'; -import { getProgramTimeMs } from '../epg-program.utils'; -import { EpgItemDescriptionComponent } from '../epg-item-description/epg-item-description.component'; -import { COMPONENT_OVERLAY_REF } from './overlay-ref.token'; - -type MultiEpgChannel = ElectronBridgeEpgChannelWithPrograms; - -interface EnrichedProgram extends EpgProgram { - startDate: Date; - stopDate: Date; - startPosition: number; - width: number; -} - -interface EnrichedChannel extends MultiEpgChannel { - programs: EnrichedProgram[]; -} - -interface ProgramSearchResult extends EpgProgram { - channel_id?: string; - channelName?: string | null; - channel_name?: string | null; - display_name?: string | null; -} - -export function isSelectedEpgDayToday( - selectedDay: string, - now: Date = new Date() -): boolean { - return selectedDay === format(now, 'yyyyMMdd'); -} - -@Component({ - imports: [DatePipe, MatButtonModule, MatIcon, MatTooltip, TranslatePipe], - selector: 'app-multi-epg-container', - templateUrl: './multi-epg-container.component.html', - styleUrls: ['./multi-epg-container.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MultiEpgContainerComponent - implements OnInit, AfterViewInit, OnDestroy -{ - readonly epgContainer = viewChild.required('epgContainer'); - - @Input() set playlistChannels(value: Observable) { - if (this.playlistChannelsSubscription) { - this.playlistChannelsSubscription.unsubscribe(); - } - - if (value) { - this.playlistChannelsSubscription = value.subscribe(() => { - this.channelsLowerRange = 0; - this.originalEpgData.set([]); - this.isLastPage.set(false); - this.initializeVisibleChannels(); - this.requestPrograms(); - }); - } - } - - @Input() activeChannelId: string | null = null; - - // Signals - readonly hourWidth = signal(150); - readonly today = signal(format(new Date(), 'yyyyMMdd')); - readonly originalEpgData = signal([]); - readonly isLoading = signal(false); - readonly isLastPage = signal(false); - readonly channelFilter = signal(''); - readonly isSearchExpanded = signal(false); - - // Program search signals - readonly isProgramSearchOpen = signal(false); - readonly programSearchQuery = signal(''); - readonly programSearchResults = signal([]); - readonly isSearchingPrograms = signal(false); - readonly highlightedProgramKey = signal(null); - private searchDebounceTimer: ReturnType | null = null; - private readonly translate = inject(TranslateService); - private readonly languageTick = toSignal( - this.translate.onLangChange.pipe(startWith(null)), - { initialValue: null } - ); - readonly currentLocale = computed(() => { - this.languageTick(); - return normalizeDateLocale( - this.translate.currentLang || this.translate.defaultLang - ); - }); - readonly selectedDayDate = computed(() => - parse(this.today(), 'yyyyMMdd', new Date()) - ); - - // Computed signal for enriched channels - automatically updates when dependencies change - readonly channels = computed(() => this.enrichProgramData()); - - // Computed signal for filtered channels based on search term - readonly filteredChannels = computed(() => { - const filter = this.channelFilter().toLowerCase().trim(); - const allChannels = this.channels(); - - if (!filter) { - return allChannels; - } - - return allChannels.filter((channel) => { - const name = this.getChannelName(channel).toLowerCase(); - return name.includes(filter); - }); - }); - - // Computed signal for current time line position - readonly currentTimeLine = computed(() => { - const now = new Date(); - return (now.getHours() + now.getMinutes() / 60) * this.hourWidth(); - }); - - // "16:32"-style label rendered as a badge above the now-line. Recomputes - // on the same 60-second tick that drives `currentTimeLine` (both depend - // on hourWidth's update pulse from `ngOnInit`). - readonly currentTimeLabel = computed(() => { - // Subscribe to the same tick source as currentTimeLine. - this.hourWidth(); - const now = new Date(); - return `${now.getHours().toString().padStart(2, '0')}:${now - .getMinutes() - .toString() - .padStart(2, '0')}`; - }); - - // Constants - readonly timeHeader = Array.from({ length: 24 }, (_, i) => i); - readonly barHeight = 50; - - // Pagination state - visibleChannels = 20; - channelsLowerRange = 0; - channelsUpperRange = this.visibleChannels; - totalChannels = 0; - - private dateCache = new Map(); - private interval!: ReturnType; - private playlistChannelsSubscription?: Subscription; - - private readonly dialog = inject(MatDialog); - private readonly overlayRef = inject(COMPONENT_OVERLAY_REF); - private readonly epgBridge = inject(EpgRuntimeBridgeService); - private readonly settingsStore = inject(SettingsStore); - /** - * Attached to an overlay without host inputs, so the grid reads the EPG - * display offset itself (`epg-display-offset.util.ts`, display form). - */ - private readonly epgOffsetMinutes = - this.settingsStore.resolvedEpgOffsetMinutes; - - ngOnInit() { - // Update current time line every minute - this.interval = setInterval(() => { - // Force recomputation by updating hourWidth to same value - // This triggers the computed signal to recalculate - this.hourWidth.update((v) => v); - }, 60000); - } - - ngAfterViewInit(): void { - this.initializeVisibleChannels(); - this.scrollToCurrentTime(); - } - - private scrollToCurrentTime(): void { - const timeNow = new Date(); - const scrollPosition = - (timeNow.getHours() + timeNow.getMinutes() / 60) * this.hourWidth(); - - requestAnimationFrame(() => { - const container = document.getElementById('epg-container'); - if (container) { - container.scrollTo( - scrollPosition < 1000 ? 0 : scrollPosition - 150, - 0 - ); - } - }); - } - - private initializeVisibleChannels(): void { - const epgContainer = this.epgContainer(); - if (epgContainer) { - const containerHeight = epgContainer.nativeElement.offsetHeight; - const calculatedVisibleChannels = Math.floor( - (containerHeight - this.barHeight) / this.barHeight - ); - - this.visibleChannels = Math.max( - 10, - Math.min(calculatedVisibleChannels, 20) - ); - this.channelsUpperRange = this.visibleChannels; - } - } - - ngOnDestroy(): void { - clearInterval(this.interval); - - if (this.playlistChannelsSubscription) { - this.playlistChannelsSubscription.unsubscribe(); - } - - if (this.searchDebounceTimer) { - clearTimeout(this.searchDebounceTimer); - } - } - - trackByIndex(index: number): number { - return index; - } - - trackByProgram(_: number, program: EnrichedProgram): string { - return `${program.start}|${program?.title?.toString() ?? ''}`; - } - - /** - * Returns true when the now-line falls within this program's rendered - * span — used to add a `.is-now` class so the cyan border lights up - * every airing program at once. The template reads `currentTimeLine()` - * (a signal) here so the highlight refreshes on the same 60-second tick - * as the line itself. - */ - isProgramAiringNow(program: EnrichedProgram): boolean { - const nowX = this.currentTimeLine(); - if (!isSelectedEpgDayToday(this.today())) { - return false; - } - return ( - nowX >= program.startPosition && - nowX <= program.startPosition + program.width - ); - } - - async requestPrograms(): Promise { - if (!this.epgBridge.supportsChannelBrowser) { - console.warn('Multi-EPG not available in this runtime'); - return; - } - - if (this.isLoading() || this.isLastPage()) { - return; - } - - this.isLoading.set(true); - - try { - const response = await this.epgBridge.getChannelsByRange( - this.channelsLowerRange, - this.visibleChannels - ); - - if (response && Array.isArray(response)) { - // Append new data to existing data - this.originalEpgData.update((data) => [...data, ...response]); - - // Update isLastPage based on the number of channels received - this.isLastPage.set(response.length < this.visibleChannels); - - // Update range for next fetch - this.channelsLowerRange += response.length; - } - } catch (error) { - console.error('Error fetching EPG data:', error); - } finally { - this.isLoading.set(false); - } - } - - onScroll(event: Event): void { - const target = event.target as HTMLElement; - const scrollTop = target.scrollTop; - const scrollHeight = target.scrollHeight; - const clientHeight = target.clientHeight; - - // Load more when user scrolls to within 200px of the bottom - if (scrollHeight - scrollTop - clientHeight < 200) { - this.requestPrograms(); - } - } - - private getCachedDate(dateStr: string): Date { - let date = this.dateCache.get(dateStr); - if (!date) { - date = new Date(dateStr); - this.dateCache.set(dateStr, date); - } - return date; - } - - /** The cached raw date shifted into display time. */ - private getDisplayDate(dateStr: string, offsetMinutes: number): Date { - const raw = this.getCachedDate(dateStr); - return offsetMinutes === 0 - ? raw - : new Date(epgDisplayTimeMs(raw.getTime(), offsetMinutes)); - } - - /** Display-time epoch ms of a search-result boundary for the date pipe. */ - programDisplayMs(program: EpgProgram, side: 'start' | 'stop'): number { - return side === 'start' - ? getProgramTimeMs( - program.start, - program.startTimestamp, - this.epgOffsetMinutes() - ) - : getProgramTimeMs( - program.stop, - program.stopTimestamp, - this.epgOffsetMinutes() - ); - } - - private enrichProgramData(): EnrichedChannel[] { - const hourWidthValue = this.hourWidth(); - const todayValue = this.today(); - const data = this.originalEpgData(); - // Day filter and x-position both work in display time, so a shifted - // programme lands in the right day column under the right hour. - const offsetMinutes = this.epgOffsetMinutes(); - - return data.map((channel) => { - const filteredPrograms = (channel.programs || []) - .filter((item: EpgProgram) => { - const itemDate = format( - this.getDisplayDate(item.start, offsetMinutes), - 'yyyyMMdd' - ); - return itemDate === todayValue; - }) - .map((program: EpgProgram) => { - const startDate = this.getDisplayDate( - program.start, - offsetMinutes - ); - const stopDate = this.getDisplayDate( - program.stop, - offsetMinutes - ); - const startPosition = - (startDate.getHours() + startDate.getMinutes() / 60) * - hourWidthValue; - const duration = differenceInMinutes(stopDate, startDate); - const width = (duration * hourWidthValue) / 60; - - return { - ...program, - startDate, - stopDate, - startPosition, - width, - }; - }); - - return { - ...channel, - programs: filteredPrograms, - }; - }); - } - - /** - * Get display name from EpgChannel - */ - getChannelName(channel: EpgChannel | MultiEpgChannel): string { - if (typeof channel.displayName === 'string') { - return channel.displayName; - } - if ( - Array.isArray(channel.displayName) && - channel.displayName.length > 0 - ) { - return channel.displayName[0].value; - } - return ''; - } - - /** - * Get icon from EpgChannel - */ - getChannelIcon(channel: EpgChannel | MultiEpgChannel): string { - if ('iconUrl' in channel && channel.iconUrl) { - return channel.iconUrl; - } - if ( - 'icon' in channel && - Array.isArray(channel.icon) && - channel.icon.length > 0 - ) { - return channel.icon[0].src; - } - return ''; - } - - zoomIn(): void { - if (this.hourWidth() >= 800) return; - this.hourWidth.update((v) => v + 50); - } - - zoomOut(): void { - if (this.hourWidth() <= 50) return; - this.hourWidth.update((v) => v - 50); - } - - toggleSearch(): void { - this.isSearchExpanded.update((v) => !v); - if (!this.isSearchExpanded()) { - this.channelFilter.set(''); - } - } - - clearFilter(): void { - this.channelFilter.set(''); - } - - onFilterInput(event: Event): void { - const input = event.target as HTMLInputElement; - this.channelFilter.set(input.value); - } - - switchDay(direction: 'prev' | 'next'): void { - const currentDate = parse(this.today(), 'yyyyMMdd', new Date()); - this.today.set( - direction === 'prev' - ? format(subDays(currentDate, 1), 'yyyyMMdd') - : format(addDays(currentDate, 1), 'yyyyMMdd') - ); - } - - showDescription(program: EpgProgram): void { - this.dialog.open(EpgItemDescriptionComponent, { - width: '600px', - data: program, - }); - } - - // Program search methods - toggleProgramSearch(): void { - this.isProgramSearchOpen.update((v) => !v); - if (!this.isProgramSearchOpen()) { - this.programSearchQuery.set(''); - this.programSearchResults.set([]); - } - } - - onProgramSearchInput(event: Event): void { - const input = event.target as HTMLInputElement; - const query = input.value.trim(); - this.programSearchQuery.set(query); - - // Clear previous debounce timer - if (this.searchDebounceTimer) { - clearTimeout(this.searchDebounceTimer); - } - - if (query.length < 2) { - this.programSearchResults.set([]); - this.isSearchingPrograms.set(false); - return; - } - - if (!this.epgBridge.supportsProgramSearch) { - this.programSearchResults.set([]); - this.isSearchingPrograms.set(false); - return; - } - - // Debounce search by 500ms - show spinner only when search actually starts - this.searchDebounceTimer = setTimeout(async () => { - this.isSearchingPrograms.set(true); - try { - const results = await this.epgBridge.searchPrograms(query, 20); - this.programSearchResults.set( - (results as ProgramSearchResult[]) || [] - ); - } catch (error) { - console.error('Error searching programs:', error); - this.programSearchResults.set([]); - } finally { - this.isSearchingPrograms.set(false); - } - }, 500); - } - - clearProgramSearch(): void { - this.programSearchQuery.set(''); - this.programSearchResults.set([]); - } - - showProgramDetails(program: ProgramSearchResult): void { - // Get channel id (DB returns channel_id, EPG uses channel) - const channelId = program.channel_id || program.channel; - - // Find channel name - const channel = this.channels().find((ch) => ch.id === channelId); - const channelName = channel ? this.getChannelName(channel) : null; - - // Open the program details dialog with channel name - this.dialog.open(EpgItemDescriptionComponent, { - width: '800px', - data: { ...program, channelName }, - }); - } - - getProgramKey(program: ProgramSearchResult): string { - const channelId = program.channel_id || program.channel; - return `${channelId}|${program.start}`; - } - - close() { - this.overlayRef.detach(); - } -} diff --git a/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts b/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts deleted file mode 100644 index db768fed1..000000000 --- a/libs/ui/epg/src/lib/multi-epg/overlay-ref.token.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { OverlayRef } from '@angular/cdk/overlay'; -import { InjectionToken } from '@angular/core'; - -/** - * Injection token for providing OverlayRef to dynamically created overlay components - */ -export const COMPONENT_OVERLAY_REF = new InjectionToken( - 'COMPONENT_OVERLAY_REF' -); diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts index 638b34c89..92d46ddf2 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.spec.ts @@ -81,6 +81,34 @@ describe('ControlsShortcuts', () => { } }); + it('ignores every shortcut while the host surface suspends player shortcuts', () => { + const suspendedRegion = document.createElement('div'); + suspendedRegion.setAttribute('data-player-shortcuts-suspended', ''); + const host = document.createElement('div'); + suspendedRegion.appendChild(host); + document.body.appendChild(suspendedRegion); + ( + handlers as unknown as { hostElement: () => HTMLElement } + ).hostElement = () => host; + + try { + expect(dispatchKey(' ')).toBe(false); + expect(dispatchKey('ArrowUp')).toBe(false); + expect(dispatchKey('ArrowDown')).toBe(false); + expect(dispatchKey('f')).toBe(false); + expect(dispatchKey('m')).toBe(false); + dispatchKey('Escape'); + + expect(handlers.togglePaused).not.toHaveBeenCalled(); + expect(handlers.adjustVolume).not.toHaveBeenCalled(); + expect(handlers.toggleFullscreen).not.toHaveBeenCalled(); + expect(handlers.toggleMute).not.toHaveBeenCalled(); + expect(handlers.onEscape).not.toHaveBeenCalled(); + } finally { + suspendedRegion.remove(); + } + }); + it('handles shortcuts when the host is not inside an inert region', () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts index 058745b1e..0b2749d6d 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-shortcuts.ts @@ -3,8 +3,11 @@ export interface ControlsShortcutHandlers { /** * The player's host element, used to opt out of global shortcuts while * an ancestor is `inert` (e.g. behind the workspace's phone context - * drawer): inert strips pointer and Tab access, but document-level - * keydown listeners still fire, so shortcuts must check it themselves. + * drawer) or marked `data-player-shortcuts-suspended` (a host surface + * that owns the keyboard itself, such as the programme guide with the + * player docked): both strip the player of keyboard ownership, but + * document-level keydown listeners still fire, so shortcuts must check + * it themselves. */ hostElement?: () => HTMLElement | null; canTogglePaused: () => boolean; @@ -86,10 +89,17 @@ export class ControlsShortcuts { } // A player inside an inert region is outside the interaction model - // entirely — a modal surface above it owns the keyboard. This also - // covers Escape: the modal decides what Escape means, not the - // obscured player. - if (handlers.hostElement?.()?.closest('[inert]')) { + // entirely — a modal surface above it owns the keyboard. The same + // applies to a host surface that marks its player container + // `data-player-shortcuts-suspended` (the programme guide docks the + // player but keeps the arrows, Space and F for itself). This also + // covers Escape: the surface above decides what Escape means, not + // the player it obscures or demotes. + if ( + handlers + .hostElement?.() + ?.closest('[inert], [data-player-shortcuts-suspended]') + ) { return; } diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index d32411fd9..2484b133d 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -26,4 +26,5 @@ (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" (sidebarToggleRequested)="sidebarToggleRequested.emit()" + (selectedGroupChange)="selectedGroupChange.emit($event)" /> diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 24c79202c..b7821f4e5 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -32,6 +32,8 @@ export class SidebarComponent { readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); readonly sidebarToggleRequested = output(); + /** Groups view only: the group whose channels are currently listed. */ + readonly selectedGroupChange = output(); private readonly playlistContext = inject(PlaylistContextFacade); private readonly translate = inject(TranslateService); diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts index 27a81e5d8..42c1d6022 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts @@ -41,11 +41,11 @@ describe('WorkspaceCommandPaletteComponent', () => { run: () => undefined, }, { - id: 'multi-epg', - label: 'Open Multi-EPG', + id: 'epg-guide', + label: 'Open programme guide', description: '', group: 'view', - icon: 'view_list', + icon: 'grid_view', keywords: ['epg', 'guide'], priority: 0, visible: true, @@ -126,7 +126,7 @@ describe('WorkspaceCommandPaletteComponent', () => { component.query.set('guide'); expect(component.flatCommands().map((command) => command.id)).toEqual([ - 'multi-epg', + 'epg-guide', ]); }); @@ -135,7 +135,7 @@ describe('WorkspaceCommandPaletteComponent', () => { fixture.detectChanges(); expect(component.flatCommands().map((command) => command.id)).toEqual([ - 'multi-epg', + 'epg-guide', 'playlist-search', 'global-search', ]); @@ -158,7 +158,7 @@ describe('WorkspaceCommandPaletteComponent', () => { component.onCommandClick(command); expect(dialogRef.close).toHaveBeenCalledWith({ - commandId: 'multi-epg', + commandId: 'epg-guide', query: '', }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 7f80772df..cd7d38991 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -136,6 +136,9 @@ type="button" class="header-shortcut" mat-icon-button + [class.is-active]="action.active?.() ?? false" + [disabled]="action.disabled?.() ?? false" + [attr.aria-pressed]="action.active ? action.active() : null" (click)="onHeaderShortcutRequested()" [matTooltip]="action.tooltipKey | translate" [attr.aria-label]="action.ariaLabelKey | translate" diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss index 64d98a8bb..5e9ee3780 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.scss @@ -407,3 +407,8 @@ } } } + +.header-shortcut.is-active { + background: var(--app-selection-surface); + color: var(--app-selection-color); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 29cbb46aa..e0b056a7a 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -877,7 +877,7 @@ describe('WorkspaceShellFacade', () => { ); }); - it('includes M3U navigation, playlist actions, and Multi-EPG on playlist routes', () => { + it('includes M3U navigation, playlist actions, and the programme guide on playlist routes', () => { const headerContext = TestBed.inject(WorkspaceHeaderContextService); activePlaylistSignal.set({ @@ -889,14 +889,14 @@ describe('WorkspaceShellFacade', () => { }); facade.currentUrl.set('/workspace/playlists/pl-m3u/groups'); headerContext.setAction({ - id: 'm3u-multi-epg', - icon: 'view_list', - tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG', - ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG', + id: 'm3u-epg-guide', + icon: 'grid_view', + tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE', + ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE', 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, }, @@ -907,7 +907,7 @@ describe('WorkspaceShellFacade', () => { expect(commands.map((command) => command.id)).toEqual( expect.arrayContaining([ - 'm3u-multi-epg', + 'm3u-epg-guide', 'go-to-all', 'go-to-favorites', 'go-to-recent', @@ -915,13 +915,46 @@ describe('WorkspaceShellFacade', () => { ]) ); expect( - commands.find((command) => command.id === 'm3u-multi-epg')?.group + commands.find((command) => command.id === 'm3u-epg-guide')?.group ).toBe('view'); expect(commands.some((command) => command.id === 'account-info')).toBe( false ); }); + it('disables the programme guide command while the header action reports disabled', () => { + const headerContext = TestBed.inject(WorkspaceHeaderContextService); + + activePlaylistSignal.set({ + _id: 'pl-m3u', + title: 'Playlist M3U', + count: 10, + importDate: '2026-04-22T10:00:00.000Z', + autoRefresh: false, + }); + facade.currentUrl.set('/workspace/playlists/pl-m3u/groups'); + const guideCommandEnabled = () => + facade + .commandPaletteCommands() + .find((command) => command.id === 'm3u-epg-guide')?.enabled; + const registerGuideAction = (disabled: () => boolean) => + headerContext.setAction({ + id: 'm3u-epg-guide', + icon: 'grid_view', + tooltipKey: 'TOP_MENU.OPEN_EPG_GUIDE', + ariaLabelKey: 'TOP_MENU.OPEN_EPG_GUIDE', + disabled, + palette: { labelKey: 'TOP_MENU.OPEN_EPG_GUIDE' }, + run: jest.fn(), + }); + + registerGuideAction(() => true); + expect(guideCommandEnabled()).toBe(false); + + registerGuideAction(() => false); + expect(guideCommandEnabled()).toBe(true); + }); + it('opens the Xtream account dialog with credentials and session counts for the active playlist', () => { activePlaylistSignal.set({ _id: 'pl-xtream', diff --git a/tools/eslint/max-lines-baseline.mjs b/tools/eslint/max-lines-baseline.mjs index c609d81dc..1a44727aa 100644 --- a/tools/eslint/max-lines-baseline.mjs +++ b/tools/eslint/max-lines-baseline.mjs @@ -60,7 +60,6 @@ export const maxLinesBaseline = [ 'libs/shared/interfaces/src/lib/electron-api.interface.ts', 'libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts', 'libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts', - 'libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts', 'libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts', 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts', 'libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts',