From 401012eac44cade084472aca4a4f8ffd7a01c487 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:09:49 +0200 Subject: [PATCH] feat(epg): rework live EPG panel into a horizontal timeline (#1102) Replaces the vertical EPG list with a shared horizontal `app-epg-timeline` ribbon across all live surfaces (M3U player, unified live tab, Xtream, Stalker): zoom, day navigation, short-programme grouping, catch-up/timeshift, and per-state empty views. Backend gains timezone-aware `datetime()` comparisons, unscoped source fallback, non-ASCII candidate matching, and chunked candidate queries. Timeline split into reusable, view-agnostic modules (archive/summary/dialog service/render util/scroll controller) for the future EPG list view. Fixes landed during review: - honor the controlled `selectedDate` input (seed via linkedSignal) - restore ribbon position across collapse/expand - keep the ribbon mounted when scrolling across a gap day - don't trigger block playback on Enter from nested watch/info buttons - don't reset timeshift playback on the 30s now-tick during EPG gaps Greptile 5/5 (safe to merge); Codex clean; CI green. --- .../src/xtream-epg.e2e.ts | 173 ++---- .../src/app/events/epg-query.service.spec.ts | 149 +++++ .../src/app/events/epg-query.service.ts | 148 +++-- apps/web-e2e/src/stalker.e2e.ts | 9 +- apps/web/src/assets/i18n/ar.json | 39 +- apps/web/src/assets/i18n/ary.json | 39 +- apps/web/src/assets/i18n/by.json | 39 +- apps/web/src/assets/i18n/de.json | 39 +- apps/web/src/assets/i18n/el.json | 39 +- apps/web/src/assets/i18n/en.json | 37 +- apps/web/src/assets/i18n/es.json | 39 +- apps/web/src/assets/i18n/fr.json | 39 +- apps/web/src/assets/i18n/it.json | 39 +- apps/web/src/assets/i18n/ja.json | 39 +- apps/web/src/assets/i18n/ko.json | 39 +- apps/web/src/assets/i18n/nl.json | 39 +- apps/web/src/assets/i18n/pl.json | 39 +- apps/web/src/assets/i18n/pt.json | 39 +- apps/web/src/assets/i18n/ru.json | 37 +- apps/web/src/assets/i18n/tr.json | 39 +- apps/web/src/assets/i18n/zh.json | 39 +- apps/web/src/assets/i18n/zhtw.json | 39 +- docs/architecture/m3u-playlist-module.md | 157 +++++- .../video-player/video-player.component.html | 56 +- .../video-player.component.spec.ts | 162 ++++-- .../video-player/video-player.component.ts | 136 ++++- .../unified-live-tab.component.html | 62 +- .../unified-live-tab.component.spec.ts | 152 +++-- .../unified-live-tab.component.ts | 73 ++- .../stalker-live-stream-layout.component.html | 68 +-- ...alker-live-stream-layout.component.spec.ts | 86 ++- .../stalker-live-stream-layout.component.ts | 10 +- .../live-stream-layout.component.html | 95 +--- .../live-stream-layout.component.spec.ts | 171 +++--- .../live-stream-layout.component.ts | 12 +- .../m3u-utils/src/lib/catchup.utils.spec.ts | 14 + .../all-channels-view.component.ts | 56 +- .../epg-enrichment.util.spec.ts | 90 +++ .../epg-enrichment.util.ts | 73 +++ .../favorites-view.component.ts | 34 +- .../groups-view/groups-view.component.ts | 38 +- .../recent-view/recent-view.component.ts | 26 +- libs/ui/epg/src/index.ts | 15 + .../epg-item-description.component.html | 167 +++--- .../epg-item-description.component.scss | 474 ++++++++-------- .../epg-item-description.component.spec.ts | 12 +- .../epg-item-description.component.ts | 43 +- .../src/lib/epg-programme-dialog.service.ts | 24 + .../src/lib/epg-timeline/epg-archive.util.ts | 51 ++ .../src/lib/epg-timeline/epg-summary.util.ts | 84 +++ .../epg-timeline-empty-state.component.scss | 73 +++ ...epg-timeline-empty-state.component.spec.ts | 48 ++ .../epg-timeline-empty-state.component.ts | 155 +++++ .../epg-timeline/epg-timeline-render.util.ts | 188 +++++++ .../epg-timeline-scroll.controller.spec.ts | 205 +++++++ .../epg-timeline-scroll.controller.ts | 163 ++++++ .../epg-timeline-track.component.html | 134 +++++ .../epg-timeline-track.component.scss | 531 ++++++++++++++++++ .../epg-timeline-track.component.spec.ts | 178 ++++++ .../epg-timeline-track.component.ts | 140 +++++ .../epg-timeline/epg-timeline.component.html | 220 ++++++++ .../epg-timeline/epg-timeline.component.scss | 433 ++++++++++++++ .../epg-timeline.component.spec.ts | 241 ++++++++ .../epg-timeline/epg-timeline.component.ts | 400 +++++++++++++ .../epg-timeline/epg-timeline.utils.spec.ts | 312 ++++++++++ .../lib/epg-timeline/epg-timeline.utils.ts | 227 ++++++++ libs/ui/styles/_portal-layout.scss | 14 +- 67 files changed, 6086 insertions(+), 1194 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.spec.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.ts create mode 100644 libs/ui/epg/src/lib/epg-programme-dialog.service.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts index 435b545ce..2510ea692 100644 --- a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -10,10 +10,7 @@ import { test, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; -import { - fetchXtreamEpgFixture, - XtreamNormalizedEpgListing, -} from './portal-mock-fixtures'; +import { fetchXtreamEpgFixture } from './portal-mock-fixtures'; const epgPortalName = 'Xtream EPG Fixture'; const epgCredentials = { @@ -22,7 +19,7 @@ const epgCredentials = { }; for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { - test(`@epg @xtream @electron renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({ + test(`@epg @xtream @electron renders Xtream EPG previews and the timeline schedule in ${timeZone}`, async ({ dataDir, request, }) => { @@ -57,6 +54,7 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { ).first(); await expect(channelRow).toBeVisible({ timeout: 20000 }); + // Sidebar channel list shows the per-channel "now" programme line. await expect .poll(async () => ( @@ -81,55 +79,42 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) { .toBeGreaterThan(0); await channelRow.click(); - await expect(app.mainWindow.locator('app-epg-list')).toBeVisible({ - timeout: 20000, - }); - - const currentDateKey = formatDateKeyInZone(Date.now(), timeZone); - const currentDayTitles = titlesForDate( - fixture.fullEpg, - currentDateKey, - timeZone - ); + await expect( + app.mainWindow.locator('app-epg-timeline') + ).toBeVisible({ timeout: 20000 }); + // The timeline renders the full multi-day window as blocks, + // sorted by start time (no per-day filtering — it scrolls). + const allTitles = [...fixture.fullEpg] + .sort((a, b) => a.startTimestamp - b.startTimestamp) + .map((listing) => listing.title); await expect - .poll(() => visibleProgramTitles(app.mainWindow)) - .toEqual(currentDayTitles); + .poll(() => timelineBlockTitles(app.mainWindow)) + .toEqual(allTitles); - await expectCurrentProgram( - app.mainWindow, - currentProgram, - timeZone - ); - - const firstFutureDateKey = getFirstFutureDateKey( - fixture.fullEpg, - currentDateKey, - timeZone - ); - expect(firstFutureDateKey).not.toBeNull(); - - const futureDayTitles = titlesForDate( - fixture.fullEpg, - firstFutureDateKey!, - timeZone - ); - const nextDayClicks = dayDifference( - currentDateKey, - firstFutureDateKey! - ); - - for (let index = 0; index < nextDayClicks; index += 1) { - await app.mainWindow + // The current programme is highlighted as the "now" block. + await expect( + app.mainWindow .locator( - 'app-live-epg-panel .next-day, app-epg-list .next-day' + 'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title' ) - .click(); - } + .first() + ).toHaveText(currentProgram.title); + // The date stepper advances the day label. + const dayLabel = app.mainWindow + .locator('app-epg-timeline .epg-timeline__day small') + .first(); + const beforeLabel = (await dayLabel.textContent())?.trim(); + await app.mainWindow + .locator( + 'app-epg-timeline .epg-timeline__stepper .epg-timeline__nav' + ) + .last() + .click(); await expect - .poll(() => visibleProgramTitles(app.mainWindow)) - .toEqual(futureDayTitles); + .poll(async () => (await dayLabel.textContent())?.trim()) + .not.toBe(beforeLabel); } finally { await closeElectronApp(app); } @@ -145,105 +130,15 @@ function formatTimeInZone(timestampSeconds: number, timeZone: string): string { }).format(new Date(timestampSeconds * 1000)); } -function formatDateKeyInZone( - timestampMilliseconds: number, - timeZone: string -): string { - const parts = new Intl.DateTimeFormat('en-CA', { - timeZone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(new Date(timestampMilliseconds)); - - const year = parts.find((part) => part.type === 'year')?.value; - const month = parts.find((part) => part.type === 'month')?.value; - const day = parts.find((part) => part.type === 'day')?.value; - - if (!year || !month || !day) { - throw new Error( - `Failed to format a date key for timezone ${timeZone}.` - ); - } - - return `${year}-${month}-${day}`; -} - -function titlesForDate( - listings: XtreamNormalizedEpgListing[], - dateKey: string, - timeZone: string -): string[] { - return listings - .filter( - (listing) => - formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) === - dateKey - ) - .map((listing) => listing.title); -} - -function getFirstFutureDateKey( - listings: XtreamNormalizedEpgListing[], - currentDateKey: string, - timeZone: string -): string | null { - const uniqueDateKeys = Array.from( - new Set( - listings.map((listing) => - formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) - ) - ) - ).sort(); - - return uniqueDateKeys.find((dateKey) => dateKey > currentDateKey) ?? null; -} - -function dayDifference(fromDateKey: string, toDateKey: string): number { - const from = Date.parse(`${fromDateKey}T00:00:00.000Z`); - const to = Date.parse(`${toDateKey}T00:00:00.000Z`); - return Math.round((to - from) / (24 * 60 * 60 * 1000)); -} - -async function visibleProgramTitles( +async function timelineBlockTitles( page: Parameters[0] ) { return page - .locator('app-epg-list .program-title') + .locator('app-epg-timeline .epg-timeline__block-title') .allInnerTexts() .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); } -async function expectCurrentProgram( - page: Parameters[0], - currentProgram: XtreamNormalizedEpgListing, - timeZone: string -): Promise { - const startTime = formatTimeInZone(currentProgram.startTimestamp, timeZone); - const stopTime = formatTimeInZone(currentProgram.stopTimestamp, timeZone); - const currentProgramRow = page - .locator('app-epg-list .program-item.current-program') - .first(); - - if (await currentProgramRow.isVisible().catch(() => false)) { - await expect(currentProgramRow.locator('.program-title')).toHaveText( - currentProgram.title - ); - await expect(currentProgramRow.locator('.time')).toHaveText( - `${startTime}–${stopTime}` - ); - return; - } - - const summary = page.locator('app-live-epg-panel').first(); - await expect(summary.locator('.live-epg-panel__title')).toHaveText( - currentProgram.title - ); - await expect(summary.locator('.live-epg-panel__time')).toHaveText( - `${startTime} - ${stopTime}` - ); -} - async function getProgressWidthPercent( row: ReturnType ) { diff --git a/apps/electron-backend/src/app/events/epg-query.service.spec.ts b/apps/electron-backend/src/app/events/epg-query.service.spec.ts index 92a2886bf..33350e748 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.spec.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.spec.ts @@ -231,6 +231,155 @@ describe('EpgQueryService', () => { expect(select).toHaveBeenCalledTimes(4); }); + it('compares current-programme windows with timezone-aware datetime() rather than raw strings', async () => { + const whereCalls: unknown[] = []; + const select = jest.fn().mockReturnValueOnce( + createLimitedSelectChain( + [ + { + id: 1, + channelId: 'ch', + start: '2026-06-29T23:25:00+03:00', + stop: '2026-06-30T00:30:00+03:00', + title: 'Now', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + sourceUrl: 'https://example.com/guide.xml', + }, + ], + whereCalls + ) + ); + getDatabase.mockResolvedValue({ select }); + + await service.getCurrentProgramsBatch(['ch'], { + sourceUrls: ['https://example.com/guide.xml'], + }); + + const flattened = whereCalls.map((condition) => + flattenSql(condition).toLowerCase() + ); + // Stored timestamps carry an offset (+03:00) while `now` is UTC (…Z); + // a raw string compare would be off by the offset, so both sides must be + // normalized with datetime(). + expect(flattened.some((condition) => condition.includes('datetime'))).toBe( + true + ); + }); + + it('falls back to an unscoped current-programme lookup when programmes are tagged with an out-of-scope source', async () => { + const whereCalls: unknown[] = []; + const currentProgram = { + id: 10, + channelId: '2265', + start: '2026-06-29T23:25:00+03:00', + stop: '2026-06-30T00:30:00+03:00', + title: 'Podcast Now', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + // Programmes tagged with epg.one, which is NOT in the active scope. + sourceUrl: 'http://epg.one/epg.xml.gz', + }; + const select = jest + .fn() + // 1 direct current (scoped) – no channel_id '360' + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) + // 2 direct current (legacy) + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) + // 3 candidate lookup – channel row '360' is in scope (its own source) + .mockReturnValueOnce( + createSelectChain( + [{ id: '2265', displayName: '360', iconUrl: null }], + whereCalls + ) + ) + // 4 candidate current (scoped) – empty, programmes are out of scope + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) + // 5 candidate current (legacy) – empty + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) + // 6 candidate current (UNSCOPED fallback) – finds the programme + .mockReturnValueOnce( + createLimitedSelectChain([currentProgram], whereCalls) + ); + + getDatabase.mockResolvedValue({ select }); + + const result = await service.getCurrentProgramsBatch(['360'], { + sourceUrls: ['http://epg.it999.ru/epg2.xml'], + }); + + expect(result['360']?.title).toBe('Podcast Now'); + expect(select).toHaveBeenCalledTimes(6); + }); + + it('resolves a Cyrillic channel by exact display-name (ASCII-only SQL LOWER would miss it)', async () => { + const whereCalls: unknown[] = []; + const select = jest + .fn() + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) // 1 direct current + .mockReturnValueOnce(createLimitedSelectChain([], whereCalls)) // 2 legacy current + .mockReturnValueOnce( + createSelectChain( + [{ id: '2407', displayName: 'Киноман', iconUrl: null }], + whereCalls + ) + ) // 3 candidate lookup + .mockReturnValueOnce( + createLimitedSelectChain( + [ + { + id: 1, + channelId: '2407', + start: '2026-06-29T23:10:00+03:00', + stop: '2026-06-30T00:50:00+03:00', + title: 'Зной', + description: null, + category: null, + iconUrl: null, + rating: null, + episodeNum: null, + sourceUrl: 'http://epg.it999.ru/epg2.xml', + }, + ], + whereCalls + ) + ); // 4 candidate current + + getDatabase.mockResolvedValue({ select }); + + const result = await service.getCurrentProgramsBatch(['Киноман'], { + sourceUrls: ['http://epg.it999.ru/epg2.xml'], + }); + + expect(result['Киноман']?.title).toBe('Зной'); + // The candidate lookup must include the RAW (non-lowercased) key so the + // exact match works — a LOWER()-only query would never match 'Киноман'. + const flattened = whereCalls.map((condition) => flattenSql(condition)); + expect(flattened.some((condition) => condition.includes('Киноман'))).toBe( + true + ); + }); + + it('chunks the candidate lookup to bound SQL parameters for large batches', async () => { + // Each candidate-lookup query binds every key up to 4× (raw/lower × + // id/displayName), so a large batch is split to stay under SQLite's + // SQLITE_LIMIT_VARIABLE_NUMBER. + const select = jest.fn(() => createSelectChain([], [])); + getDatabase.mockResolvedValue({ select }); + + const ids = Array.from({ length: 600 }, (_, i) => `ch-${i}`); + await service.getChannelMetadata(ids); // no sourceUrls → candidate lookup only + + // 600 unique keys → ceil(600 / 500) = 2 chunked candidate-lookup queries. + expect(select).toHaveBeenCalledTimes(2); + }); + it('scopes channel metadata by program source ownership for shared channels', async () => { const whereCalls: unknown[] = []; const select = jest.fn(() => createSelectChain([], whereCalls)); 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 8a87cfe5b..63db5b36e 100644 --- a/apps/electron-backend/src/app/events/epg-query.service.ts +++ b/apps/electron-backend/src/app/events/epg-query.service.ts @@ -1,14 +1,4 @@ -import { - and, - eq, - gte, - inArray, - isNull, - lte, - or, - sql, - type SQL, -} from 'drizzle-orm'; +import { and, eq, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; import { EpgChannelMetadata, EpgProgram } from '@iptvnator/shared/interfaces'; import { getDatabase } from '../database/connection'; import * as schema from '../database/schema'; @@ -29,6 +19,14 @@ interface EpgProgramRow { type EpgDatabase = Awaited>; type EpgChannelRow = typeof schema.epgChannels.$inferSelect; +/** + * Max channel keys per candidate-lookup query. The query binds each key up to + * four times (raw id / raw displayName / LOWER(id) / LOWER(displayName)), so we + * chunk to stay well under SQLite's `SQLITE_LIMIT_VARIABLE_NUMBER` (32766) even + * for very large playlists — at 500 that's ~2k bound parameters per statement. + */ +const CHANNEL_LOOKUP_CHUNK_SIZE = 500; + export class EpgQueryService { constructor(private readonly loggerLabel = '[EPG Events]') {} @@ -274,6 +272,32 @@ export class EpgQueryService { { legacyOnly: true } ) ); + + // Unscoped fallback (mirror the timeline): a candidate resolved + // by id/display-name but whose programmes are tagged with a + // source_url outside the active scope — e.g. multi-source EPG + // imports where epg_channels.source_url and epg_programs.source_url + // disagree — would otherwise stay empty in the list while the + // timeline (which never scopes by source) shows it. Retry only + // the still-unresolved resolved-candidate ids against all sources. + if (sourceUrls.length > 0) { + const pendingCandidateIds = this.pendingCandidateIds( + candidateIdsByRequestedId, + result + ); + if (pendingCandidateIds.length > 0) { + this.assignCandidateCurrentProgramRows( + result, + candidateIdsByRequestedId, + await this.selectCurrentProgramsForChannelIds( + db, + pendingCandidateIds, + now, + [] + ) + ); + } + } } for (const channelId of validIds) { @@ -329,17 +353,9 @@ export class EpgQueryService { const db = await getDatabase(); const sourceUrls = this.normalizeSourceUrls(options.sourceUrls); - const lowerKeys = Array.from( - new Set( - normalizedChannelIds.map((channelId) => - channelId.toLowerCase() - ) - ) - ); - const lowerKeyValues = lowerKeys.map((key) => sql`${key}`); let candidates = await this.selectChannelMetadataCandidates( db, - lowerKeyValues, + normalizedChannelIds, sourceUrls ); if (sourceUrls.length > 0) { @@ -355,9 +371,7 @@ export class EpgQueryService { ...candidates, ...(await this.selectChannelMetadataCandidates( db, - missingChannelIds - .map((channelId) => channelId.toLowerCase()) - .map((key) => sql`${key}`), + missingChannelIds, sourceUrls, { legacyOnly: true } )), @@ -468,6 +482,24 @@ export class EpgQueryService { return matchedChannelIds; } + /** + * Resolved candidate ids whose requested channel still has no programme in + * `result` — i.e. the channel was matched to an epg_channels row but no + * in-scope current programme was found for it yet. + */ + private pendingCandidateIds( + candidateIdsByRequestedId: Map, + result: Record + ): string[] { + const pending = new Set(); + for (const [requestedId, candidateId] of candidateIdsByRequestedId) { + if (!result[requestedId]) { + pending.add(candidateId); + } + } + return Array.from(pending); + } + private assignCandidateCurrentProgramRows( result: Record, candidateIdsByRequestedId: Map, @@ -557,8 +589,7 @@ export class EpgQueryService { this.withProgramSourceScope( and( inArray(schema.epgPrograms.channelId, channelIds), - lte(schema.epgPrograms.start, now), - gte(schema.epgPrograms.stop, now) + this.isAiringAt(now) ) as SQL, sourceUrls, options @@ -567,6 +598,20 @@ export class EpgQueryService { .limit(channelIds.length); } + /** + * Timezone-aware "currently airing at `now`" predicate. EPG start/stop are + * stored as ISO strings that often carry a UTC offset (e.g. `+03:00`) while + * `now` is built as UTC (`new Date().toISOString()`, `…Z`). A raw string + * comparison is lexical and therefore wrong by the offset, so we normalize + * both sides with SQLite `datetime()` (which converts every form to UTC). + */ + private isAiringAt(now: string): SQL { + return and( + sql`datetime(${schema.epgPrograms.start}) <= datetime(${now})`, + sql`datetime(${schema.epgPrograms.stop}) >= datetime(${now})` + ) as SQL; + } + private async selectChannelById( db: EpgDatabase, channelId: string, @@ -658,13 +703,9 @@ export class EpgQueryService { sourceUrls: string[], options: { legacyOnly?: boolean } = {} ): Promise { - const lowerKeyValues = Array.from( - new Set(channelIds.map((channelId) => channelId.toLowerCase())) - ).map((key) => sql`${key}`); - return this.selectChannelMetadataCandidates( db, - lowerKeyValues, + channelIds, sourceUrls, options ); @@ -672,7 +713,7 @@ export class EpgQueryService { private async selectChannelMetadataCandidates( db: EpgDatabase, - lowerKeyValues: SQL[], + keys: string[], sourceUrls: string[], options: { legacyOnly?: boolean } = {} ): Promise { @@ -680,10 +721,51 @@ export class EpgQueryService { return []; } + const uniqueKeys = Array.from(new Set(keys)); + if (uniqueKeys.length === 0) { + return []; + } + + // Chunk so the bound-parameter count stays well under SQLite's limit even + // for large playlists (the per-chunk query binds each key up to 4×). + const candidates: EpgChannelMetadata[] = []; + for (let i = 0; i < uniqueKeys.length; i += CHANNEL_LOOKUP_CHUNK_SIZE) { + candidates.push( + ...(await this.selectChannelMetadataCandidatesChunk( + db, + uniqueKeys.slice(i, i + CHANNEL_LOOKUP_CHUNK_SIZE), + sourceUrls, + options + )) + ); + } + return candidates; + } + + private async selectChannelMetadataCandidatesChunk( + db: EpgDatabase, + uniqueKeys: string[], + sourceUrls: string[], + options: { legacyOnly?: boolean } = {} + ): Promise { + const rawValues = uniqueKeys.map((key) => sql`${key}`); + const lowerValues = Array.from( + new Set(uniqueKeys.map((key) => key.toLowerCase())) + ).map((key) => sql`${key}`); + + // SQLite LOWER()/COLLATE NOCASE only fold ASCII, so for non-ASCII names + // (Cyrillic, Greek, …) the lowercased key never matches the stored value. + // Match the raw key case-sensitively too — parity with the timeline's + // exact display-name lookup — so those channels resolve; the LOWER() + // branch keeps ASCII case-insensitivity. The JS resolver + // (`resolveChannelMetadataCandidate`) then folds with full-Unicode + // toLowerCase(). const lookupCondition = sql` ( - LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerKeyValues, sql`, `)}) - OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerKeyValues, sql`, `)}) + ${schema.epgChannels.id} IN (${sql.join(rawValues, sql`, `)}) + OR ${schema.epgChannels.displayName} IN (${sql.join(rawValues, sql`, `)}) + OR LOWER(${schema.epgChannels.id}) IN (${sql.join(lowerValues, sql`, `)}) + OR LOWER(${schema.epgChannels.displayName}) IN (${sql.join(lowerValues, sql`, `)}) ) `; diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index eed328982..f7c7c56b3 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -238,8 +238,7 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => { await expect(page.locator('app-web-player-view')).toBeVisible({ timeout: 20_000, }); - await expect(page.locator('app-epg-list')).toHaveCount(0); - await expect(page.locator('app-live-epg-panel')).toHaveCount(0); + await expect(page.locator('app-epg-timeline')).toHaveCount(0); expect(epgInfoRequests).toHaveLength(0); expect(shortEpgRequests).toHaveLength(0); }); @@ -292,7 +291,7 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn await restoreButton.click(); await expect(stations.first()).toBeVisible(); - await expect(page.locator('app-epg-list')).toHaveCount(0); + await expect(page.locator('app-epg-timeline')).toHaveCount(0); expect(epgRequests).toHaveLength(0); }); @@ -325,11 +324,11 @@ test('@stalker PWA skips bulk EPG across channel switches', async ({ await channels.first().click(); await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); - await expect(page.locator('app-epg-list')).toHaveCount(0); + await expect(page.locator('app-epg-timeline')).toHaveCount(0); await channels.nth(1).click(); await expect(channels.nth(1)).toHaveClass(/active/, { timeout: 20_000 }); - await expect(page.locator('app-epg-list')).toHaveCount(0); + await expect(page.locator('app-epg-timeline')).toHaveCount(0); expect(epgInfoRequests).toHaveLength(0); expect(shortEpgRequests).toHaveLength(0); }); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index aa00ca8ed..8a35c14a5 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -218,6 +218,7 @@ "PLAYBACK": "التشغيل", "EPG_SOURCES": "مصادر EPG", "EPG_SOURCES_DESCRIPTION": "أضف رابطًا واحدًا أو أكثر كمصادر لـ EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "تفضيل مصادر EPG الخاصة بك لقوائم Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", "RETURN_TO_LIVE": "العودة إلى البث المباشر", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "اليوم", + "NOW": "الآن", + "JUMP_NOW": "الانتقال إلى الآن", + "RETURN_TO_LIVE": "العودة إلى البث المباشر", + "ON_NOW": "يُبث الآن", + "MINUTES_LEFT": "د. متبقية", + "FROM_ARCHIVE": "من الأرشيف", + "WATCH": "مشاهدة", + "WATCH_LIVE": "مشاهدة مباشرة", + "WATCH_FROM_START": "المشاهدة من البداية", + "ARCHIVE_UNAVAILABLE_NOTE": "التشغيل غير متاح — هذا المزود لا يوفر أرشيفًا.", + "SCHEDULE_ONLY_NOTICE": "الجدول فقط. يوفر هذا المزود سجل البرامج لكن دون catch-up — لا يمكن تشغيل البرامج السابقة.", + "EMPTY_DAY_TITLE": "لا يوجد برنامج لهذا اليوم", + "EMPTY_DAY_DESCRIPTION": "لم يصل جدول اليوم المحدد بعد. أيام أخرى تحتوي على بيانات.", + "JUMP_TODAY": "الانتقال إلى اليوم", + "JUMP_NEAREST": "أقرب يوم يحتوي على برنامج", + "CHANNEL_UNMAPPED_TITLE": "لا يوجد برنامج لهذه القناة", + "CHANNEL_UNMAPPED_DESCRIPTION": "هذه القناة غير مطابقة لمصدر EPG (لم يُعثر على tvg-id). القنوات الأخرى غير متأثرة.", + "PROVIDER_NO_EPG_TITLE": "المزود لا يوفر دليلًا", + "PROVIDER_NO_EPG_DESCRIPTION": "لا يوفر هذا المصدر دليل برامج لأي قناة. إذا كنت بحاجة إلى جدول، اطلبه من مزودك.", + "M3U_SETUP_TITLE": "توصيل دليل برامج", + "M3U_SETUP_DESCRIPTION": "تحتاج قوائم M3U إلى مصدر EPG خارجي (XMLTV). أضف رابط url-tvg وسيظهر الجدول هنا.", + "OPEN_EPG_SETTINGS": "فتح إعدادات EPG", + "ERROR_TITLE": "تعذّر تحميل الدليل", + "ERROR_DESCRIPTION": "لم يستجب مصدر EPG. تحقق من اتصالك أو حدّث المصدر.", + "RETRY": "إعادة المحاولة", + "MINUTES_SHORT": "د", + "ZOOM": "تكبير", + "ZOOM_DAY": "نظرة عامة على اليوم", + "ZOOM_HOURS": "حسب الساعة", + "ZOOM_DETAIL": "تفصيلي", + "GROUP_SHORT": "برامج قصيرة", + "GROUP_EXPAND": "عرض ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 794c07aa4..c4a702eac 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -218,6 +218,7 @@ "PLAYBACK": "التشغيل", "EPG_SOURCES": "مصادر EPG", "EPG_SOURCES_DESCRIPTION": "زيد رابط ولا أكثر كمصادر EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "فضّل مصادر EPG ديالك ل Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "تشغيل الأرشيف", "RETURN_TO_LIVE": "رجع للبث المباشر", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "اليوم", + "NOW": "دابا", + "JUMP_NOW": "نقز لدابا", + "RETURN_TO_LIVE": "رجع للبث المباشر", + "ON_NOW": "خدام دابا", + "MINUTES_LEFT": "د. باقية", + "FROM_ARCHIVE": "من الأرشيف", + "WATCH": "تفرج", + "WATCH_LIVE": "تفرج مباشر", + "WATCH_FROM_START": "تفرج من البداية", + "ARCHIVE_UNAVAILABLE_NOTE": "التشغيل ما متوفرش — هاد المزود ما كيوفرش أرشيف.", + "SCHEDULE_ONLY_NOTICE": "جدول البرامج فقط. هاد المزود كيوفر تاريخ البرامج ولكن ما كيوفرش catch-up — البرامج اللي فاتت ما يمكنش تشغيلها.", + "EMPTY_DAY_TITLE": "ما كاين حتى برنامج لهاد النهار", + "EMPTY_DAY_DESCRIPTION": "جدول البرامج ديال النهار اللي اختاريتي مازال ما وصلش. أيام أخرى عندهم بيانات.", + "JUMP_TODAY": "سير لليوم", + "JUMP_NEAREST": "أقرب نهار فيه برنامج", + "CHANNEL_UNMAPPED_TITLE": "ما كاين حتى برنامج لهاد القناة", + "CHANNEL_UNMAPPED_DESCRIPTION": "هاد القناة ما متطابقاش مع مصدر EPG (ما تلقاش tvg-id). القنوات الأخرى ما تأثراتش.", + "PROVIDER_NO_EPG_TITLE": "المزود ما كيوفرش دليل البرامج", + "PROVIDER_NO_EPG_DESCRIPTION": "هاد المصدر ما كيوفرش دليل تلفزيون لأي قناة. إلى بغيتي جدول البرامج، سول المزود ديالك.", + "M3U_SETUP_TITLE": "ربط دليل البرامج", + "M3U_SETUP_DESCRIPTION": "قوائم M3U كتحتاج مصدر EPG خارجي (XMLTV). زيد رابط url-tvg وجدول البرامج غادي يبان هنا.", + "OPEN_EPG_SETTINGS": "حل إعدادات EPG", + "ERROR_TITLE": "ما تقدرش يتشارجا الدليل", + "ERROR_DESCRIPTION": "مصدر EPG ما جاوبش. تحقق من الاتصال ديالك ولا حدّث المصدر.", + "RETRY": "حاول مرة أخرى", + "MINUTES_SHORT": "د", + "ZOOM": "تكبير", + "ZOOM_DAY": "نظرة عامة على النهار", + "ZOOM_HOURS": "بالساعة", + "ZOOM_DETAIL": "مفصّل", + "GROUP_SHORT": "برامج قصيرة", + "GROUP_EXPAND": "وري ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 468cb6cff..e2142f5e7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -218,6 +218,7 @@ "PLAYBACK": "Прайграванне", "EPG_SOURCES": "Крыніцы EPG", "EPG_SOURCES_DESCRIPTION": "Дадайце адзін або некалькі URL у якасці крыніц EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Аддаваць перавагу вашым крыніцам EPG для плэйлістаў Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Прайграванне архіва", "RETURN_TO_LIVE": "Вярнуцца да эфіру", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Сёння", + "NOW": "Цяпер", + "JUMP_NOW": "Перайсці да цяперашняга", + "RETURN_TO_LIVE": "Вярнуцца да эфіру", + "ON_NOW": "Цяпер у эфіры", + "MINUTES_LEFT": "хв засталося", + "FROM_ARCHIVE": "З архіва", + "WATCH": "Глядзець", + "WATCH_LIVE": "Глядзець эфір", + "WATCH_FROM_START": "Глядзець спачатку", + "ARCHIVE_UNAVAILABLE_NOTE": "Прайграванне недаступнае — гэты правайдэр не падае архіў.", + "SCHEDULE_ONLY_NOTICE": "Толькі праграма перадач. Гэты правайдэр падае гісторыю перадач, але не catch-up — мінулыя перадачы прайграць немагчыма.", + "EMPTY_DAY_TITLE": "На гэты дзень няма перадач", + "EMPTY_DAY_DESCRIPTION": "Праграма на выбраны дзень яшчэ не паступіла. Для іншых дзён даныя ёсць.", + "JUMP_TODAY": "Перайсці да сёння", + "JUMP_NEAREST": "Бліжэйшы дзень з праграмай", + "CHANNEL_UNMAPPED_TITLE": "Для гэтага канала няма праграмы", + "CHANNEL_UNMAPPED_DESCRIPTION": "Гэты канал не супастаўлены з крыніцай EPG (не знойдзена tvg-id). Іншыя каналы не закрануты.", + "PROVIDER_NO_EPG_TITLE": "Правайдэр не падае праграму перадач", + "PROVIDER_NO_EPG_DESCRIPTION": "Гэтая крыніца не падае праграму перадач ні для аднаго канала. Калі вам патрэбна праграма, звярніцеся да правайдэра.", + "M3U_SETUP_TITLE": "Падключыце праграму перадач", + "M3U_SETUP_DESCRIPTION": "Плэйлістам M3U патрэбна вонкавая крыніца EPG (XMLTV). Дадайце спасылку url-tvg, і праграма з'явіцца тут.", + "OPEN_EPG_SETTINGS": "Адкрыць налады EPG", + "ERROR_TITLE": "Не ўдалося загрузіць праграму перадач", + "ERROR_DESCRIPTION": "Крыніца EPG не адказала. Праверце злучэнне або абнавіце крыніцу.", + "RETRY": "Паўтарыць", + "MINUTES_SHORT": "хв", + "ZOOM": "Маштаб", + "ZOOM_DAY": "Агляд дня", + "ZOOM_HOURS": "Па гадзінах", + "ZOOM_DETAIL": "Падрабязна", + "GROUP_SHORT": "кароткіх", + "GROUP_EXPAND": "паказаць ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index c670fb1c0..25438869f 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -218,6 +218,7 @@ "PLAYBACK": "Wiedergabe", "EPG_SOURCES": "EPG-Quellen", "EPG_SOURCES_DESCRIPTION": "Eine oder mehrere URLs als EPG-Quellen hinzufügen", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Eigene EPG-Quellen bei Xtream-Playlists bevorzugen", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Zeigt Schnellzugriffe auf zuletzt verwendete Playlists und Portale.", "DASHBOARD_RAIL_XTREAM_ADDED": "Neu auf Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Zeigt die neuesten Xtream-Filme, Serien und Kanäle.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Archivwiedergabe", "RETURN_TO_LIVE": "Zurück zum Live-TV", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Heute", + "NOW": "Jetzt", + "JUMP_NOW": "Zu jetzt springen", + "RETURN_TO_LIVE": "Zurück zum Live-TV", + "ON_NOW": "Jetzt im Programm", + "MINUTES_LEFT": "Min. übrig", + "FROM_ARCHIVE": "Aus dem Archiv", + "WATCH": "Ansehen", + "WATCH_LIVE": "Live ansehen", + "WATCH_FROM_START": "Von Anfang an ansehen", + "ARCHIVE_UNAVAILABLE_NOTE": "Wiedergabe nicht verfügbar – dieser Anbieter stellt kein Archiv bereit.", + "SCHEDULE_ONLY_NOTICE": "Nur Programm. Dieser Anbieter stellt den Programmverlauf bereit, aber kein Catchup – vergangene Sendungen können nicht abgespielt werden.", + "EMPTY_DAY_TITLE": "Kein Programm für diesen Tag", + "EMPTY_DAY_DESCRIPTION": "Das Programm für den ausgewählten Tag ist noch nicht eingetroffen. Für andere Tage sind Daten vorhanden.", + "JUMP_TODAY": "Zu heute springen", + "JUMP_NEAREST": "Nächster Tag mit Programm", + "CHANNEL_UNMAPPED_TITLE": "Kein Programm für diesen Sender", + "CHANNEL_UNMAPPED_DESCRIPTION": "Dieser Sender ist nicht der EPG-Quelle zugeordnet (keine tvg-id gefunden). Andere Sender sind nicht betroffen.", + "PROVIDER_NO_EPG_TITLE": "Anbieter stellt keinen Programmführer bereit", + "PROVIDER_NO_EPG_DESCRIPTION": "Diese Quelle stellt für keinen Sender ein TV-Programm bereit. Wenn Sie ein Programm benötigen, wenden Sie sich an Ihren Anbieter.", + "M3U_SETUP_TITLE": "Programmführer verbinden", + "M3U_SETUP_DESCRIPTION": "M3U-Playlists benötigen eine externe EPG-Quelle (XMLTV). Fügen Sie einen url-tvg-Link hinzu, dann erscheint hier das Programm.", + "OPEN_EPG_SETTINGS": "EPG-Einstellungen öffnen", + "ERROR_TITLE": "Programmführer konnte nicht geladen werden", + "ERROR_DESCRIPTION": "Die EPG-Quelle hat nicht geantwortet. Überprüfen Sie Ihre Verbindung oder aktualisieren Sie die Quelle.", + "RETRY": "Erneut versuchen", + "MINUTES_SHORT": "Min.", + "ZOOM": "Zoom", + "ZOOM_DAY": "Tagesübersicht", + "ZOOM_HOURS": "Stundenweise", + "ZOOM_DETAIL": "Detailliert", + "GROUP_SHORT": "Kurzsendungen", + "GROUP_EXPAND": "anzeigen ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 763440cc5..c552f9991 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -218,6 +218,7 @@ "PLAYBACK": "Αναπαραγωγή", "EPG_SOURCES": "Πηγές EPG", "EPG_SOURCES_DESCRIPTION": "Προσθέστε μία ή περισσότερες διευθύνσεις URL ως πηγές EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Προτίμηση των δικών σου πηγών EPG για λίστες Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Αναπαραγωγή αρχείου", "RETURN_TO_LIVE": "Επιστροφή στη ζωντανή ροή", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Σήμερα", + "NOW": "Τώρα", + "JUMP_NOW": "Μετάβαση στο τώρα", + "RETURN_TO_LIVE": "Επιστροφή στη ζωντανή ροή", + "ON_NOW": "Τώρα στον αέρα", + "MINUTES_LEFT": "λ. ακόμη", + "FROM_ARCHIVE": "Από το αρχείο", + "WATCH": "Παρακολούθηση", + "WATCH_LIVE": "Παρακολούθηση ζωντανά", + "WATCH_FROM_START": "Παρακολούθηση από την αρχή", + "ARCHIVE_UNAVAILABLE_NOTE": "Η αναπαραγωγή δεν είναι διαθέσιμη — αυτός ο πάροχος δεν παρέχει αρχείο.", + "SCHEDULE_ONLY_NOTICE": "Μόνο πρόγραμμα. Αυτός ο πάροχος παρέχει ιστορικό προγράμματος αλλά όχι catch-up — τα προηγούμενα προγράμματα δεν μπορούν να αναπαραχθούν.", + "EMPTY_DAY_TITLE": "Δεν υπάρχει πρόγραμμα για αυτή την ημέρα", + "EMPTY_DAY_DESCRIPTION": "Το πρόγραμμα για την επιλεγμένη ημέρα δεν έχει φτάσει ακόμα. Άλλες ημέρες έχουν δεδομένα.", + "JUMP_TODAY": "Μετάβαση στο σήμερα", + "JUMP_NEAREST": "Πλησιέστερη ημέρα με πρόγραμμα", + "CHANNEL_UNMAPPED_TITLE": "Δεν υπάρχει πρόγραμμα για αυτό το κανάλι", + "CHANNEL_UNMAPPED_DESCRIPTION": "Αυτό το κανάλι δεν έχει αντιστοιχιστεί με την πηγή EPG (δεν βρέθηκε tvg-id). Τα άλλα κανάλια δεν επηρεάζονται.", + "PROVIDER_NO_EPG_TITLE": "Ο πάροχος δεν παρέχει οδηγό προγράμματος", + "PROVIDER_NO_EPG_DESCRIPTION": "Αυτή η πηγή δεν παρέχει τηλεοπτικό οδηγό για κανένα κανάλι. Αν χρειάζεστε πρόγραμμα, απευθυνθείτε στον πάροχό σας.", + "M3U_SETUP_TITLE": "Συνδέστε έναν οδηγό προγράμματος", + "M3U_SETUP_DESCRIPTION": "Οι λίστες αναπαραγωγής M3U χρειάζονται μια εξωτερική πηγή EPG (XMLTV). Προσθέστε έναν σύνδεσμο url-tvg και το πρόγραμμα θα εμφανιστεί εδώ.", + "OPEN_EPG_SETTINGS": "Άνοιγμα ρυθμίσεων EPG", + "ERROR_TITLE": "Δεν ήταν δυνατή η φόρτωση του οδηγού", + "ERROR_DESCRIPTION": "Η πηγή EPG δεν απάντησε. Ελέγξτε τη σύνδεσή σας ή ανανεώστε την πηγή.", + "RETRY": "Επανάληψη", + "MINUTES_SHORT": "λεπ", + "ZOOM": "Ζουμ", + "ZOOM_DAY": "Επισκόπηση ημέρας", + "ZOOM_HOURS": "Ανά ώρα", + "ZOOM_DETAIL": "Λεπτομερής", + "GROUP_SHORT": "σύντομα προγρ.", + "GROUP_EXPAND": "εμφάνιση ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b053f457b..fcf8eedfa 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Archive playback", "RETURN_TO_LIVE": "Return to live", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Today", + "NOW": "Now", + "JUMP_NOW": "Jump to now", + "RETURN_TO_LIVE": "Return to live", + "ON_NOW": "On now", + "MINUTES_LEFT": "min left", + "FROM_ARCHIVE": "From archive", + "WATCH": "Watch", + "WATCH_LIVE": "Watch live", + "WATCH_FROM_START": "Watch from start", + "ARCHIVE_UNAVAILABLE_NOTE": "Playback unavailable — this provider does not expose an archive.", + "SCHEDULE_ONLY_NOTICE": "Schedule only. This provider exposes programme history but not catch-up — past programmes can't be played.", + "EMPTY_DAY_TITLE": "No programme for this day", + "EMPTY_DAY_DESCRIPTION": "The schedule for the selected day hasn't arrived yet. Other days have data.", + "JUMP_TODAY": "Go to today", + "JUMP_NEAREST": "Nearest day with programme", + "CHANNEL_UNMAPPED_TITLE": "No programme for this channel", + "CHANNEL_UNMAPPED_DESCRIPTION": "This channel isn't matched to the EPG source (no tvg-id found). Other channels are unaffected.", + "PROVIDER_NO_EPG_TITLE": "Provider doesn't supply a guide", + "PROVIDER_NO_EPG_DESCRIPTION": "This source doesn't expose a TV guide for any channel. If you need a schedule, ask your provider.", + "M3U_SETUP_TITLE": "Connect a programme guide", + "M3U_SETUP_DESCRIPTION": "M3U playlists need an external EPG source (XMLTV). Add a url-tvg link and the schedule will appear here.", + "OPEN_EPG_SETTINGS": "Open EPG settings", + "ERROR_TITLE": "Couldn't load the guide", + "ERROR_DESCRIPTION": "The EPG source didn't respond. Check your connection or refresh the source.", + "RETRY": "Retry", + "MINUTES_SHORT": "min", + "ZOOM": "Zoom", + "ZOOM_DAY": "Day overview", + "ZOOM_HOURS": "By hour", + "ZOOM_DETAIL": "Detailed", + "GROUP_SHORT": "short progs", + "GROUP_EXPAND": "show ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index cf403ce52..ef4d64e29 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -218,6 +218,7 @@ "PLAYBACK": "Reproduccion", "EPG_SOURCES": "Fuentes EPG", "EPG_SOURCES_DESCRIPTION": "Agrega una o varias URL como fuentes EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Preferir tus fuentes EPG para listas Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Reproducción de archivo", "RETURN_TO_LIVE": "Volver al directo", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Hoy", + "NOW": "Ahora", + "JUMP_NOW": "Ir a ahora", + "RETURN_TO_LIVE": "Volver al directo", + "ON_NOW": "En emisión", + "MINUTES_LEFT": "min restantes", + "FROM_ARCHIVE": "Desde el archivo", + "WATCH": "Ver", + "WATCH_LIVE": "Ver en directo", + "WATCH_FROM_START": "Ver desde el inicio", + "ARCHIVE_UNAVAILABLE_NOTE": "Reproducción no disponible: este proveedor no expone un archivo.", + "SCHEDULE_ONLY_NOTICE": "Solo programación. Este proveedor expone el historial de programas, pero no catch-up: los programas pasados no se pueden reproducir.", + "EMPTY_DAY_TITLE": "No hay programación para este día", + "EMPTY_DAY_DESCRIPTION": "La programación del día seleccionado aún no ha llegado. Otros días tienen datos.", + "JUMP_TODAY": "Ir a hoy", + "JUMP_NEAREST": "Día más cercano con programación", + "CHANNEL_UNMAPPED_TITLE": "No hay programación para este canal", + "CHANNEL_UNMAPPED_DESCRIPTION": "Este canal no está vinculado a la fuente de EPG (no se encontró tvg-id). Otros canales no se ven afectados.", + "PROVIDER_NO_EPG_TITLE": "El proveedor no ofrece guía", + "PROVIDER_NO_EPG_DESCRIPTION": "Esta fuente no expone una guía de TV para ningún canal. Si necesitas una programación, consulta a tu proveedor.", + "M3U_SETUP_TITLE": "Conecta una guía de programas", + "M3U_SETUP_DESCRIPTION": "Las listas M3U necesitan una fuente de EPG externa (XMLTV). Añade un enlace url-tvg y la programación aparecerá aquí.", + "OPEN_EPG_SETTINGS": "Abrir ajustes de EPG", + "ERROR_TITLE": "No se pudo cargar la guía", + "ERROR_DESCRIPTION": "La fuente de EPG no respondió. Comprueba tu conexión o actualiza la fuente.", + "RETRY": "Reintentar", + "MINUTES_SHORT": "min", + "ZOOM": "Zoom", + "ZOOM_DAY": "Vista del día", + "ZOOM_HOURS": "Por hora", + "ZOOM_DETAIL": "Detallado", + "GROUP_SHORT": "progs cortos", + "GROUP_EXPAND": "ver ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ec96770ab..16be1d351 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -218,6 +218,7 @@ "PLAYBACK": "Lecture", "EPG_SOURCES": "Sources EPG", "EPG_SOURCES_DESCRIPTION": "Ajoutez une ou plusieurs URL comme sources EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Préférer vos sources EPG pour les playlists Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Lecture archive", "RETURN_TO_LIVE": "Retour au direct", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Aujourd'hui", + "NOW": "Maintenant", + "JUMP_NOW": "Aller à maintenant", + "RETURN_TO_LIVE": "Retour au direct", + "ON_NOW": "En ce moment", + "MINUTES_LEFT": "min restantes", + "FROM_ARCHIVE": "Depuis les archives", + "WATCH": "Regarder", + "WATCH_LIVE": "Regarder en direct", + "WATCH_FROM_START": "Regarder depuis le début", + "ARCHIVE_UNAVAILABLE_NOTE": "Lecture indisponible — ce fournisseur n'expose pas d'archive.", + "SCHEDULE_ONLY_NOTICE": "Programme uniquement. Ce fournisseur expose l'historique des programmes mais pas le catch-up — les programmes passés ne peuvent pas être lus.", + "EMPTY_DAY_TITLE": "Aucun programme pour ce jour", + "EMPTY_DAY_DESCRIPTION": "Le programme du jour sélectionné n'est pas encore arrivé. D'autres jours ont des données.", + "JUMP_TODAY": "Aller à aujourd'hui", + "JUMP_NEAREST": "Jour le plus proche avec programme", + "CHANNEL_UNMAPPED_TITLE": "Aucun programme pour cette chaîne", + "CHANNEL_UNMAPPED_DESCRIPTION": "Cette chaîne n'est pas associée à la source EPG (aucun tvg-id trouvé). Les autres chaînes ne sont pas affectées.", + "PROVIDER_NO_EPG_TITLE": "Le fournisseur ne fournit pas de guide", + "PROVIDER_NO_EPG_DESCRIPTION": "Cette source n'expose de guide TV pour aucune chaîne. Si vous avez besoin d'un programme, demandez à votre fournisseur.", + "M3U_SETUP_TITLE": "Connecter un guide des programmes", + "M3U_SETUP_DESCRIPTION": "Les listes M3U nécessitent une source EPG externe (XMLTV). Ajoutez un lien url-tvg et le programme apparaîtra ici.", + "OPEN_EPG_SETTINGS": "Ouvrir les paramètres EPG", + "ERROR_TITLE": "Impossible de charger le guide", + "ERROR_DESCRIPTION": "La source EPG n'a pas répondu. Vérifiez votre connexion ou actualisez la source.", + "RETRY": "Réessayer", + "MINUTES_SHORT": "min", + "ZOOM": "Zoom", + "ZOOM_DAY": "Vue de la journée", + "ZOOM_HOURS": "Par heure", + "ZOOM_DETAIL": "Détaillé", + "GROUP_SHORT": "progr. courts", + "GROUP_EXPAND": "afficher ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 1d7e174be..77ba6bd57 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -218,6 +218,7 @@ "PLAYBACK": "Riproduzione", "EPG_SOURCES": "Sorgenti EPG", "EPG_SOURCES_DESCRIPTION": "Aggiungi uno o piu URL come sorgenti EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Preferisci le tue sorgenti EPG per le playlist Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Riproduzione archivio", "RETURN_TO_LIVE": "Torna alla diretta", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Oggi", + "NOW": "Ora", + "JUMP_NOW": "Vai a ora", + "RETURN_TO_LIVE": "Torna alla diretta", + "ON_NOW": "In onda ora", + "MINUTES_LEFT": "min rimasti", + "FROM_ARCHIVE": "Dall'archivio", + "WATCH": "Guarda", + "WATCH_LIVE": "Guarda in diretta", + "WATCH_FROM_START": "Guarda dall'inizio", + "ARCHIVE_UNAVAILABLE_NOTE": "Riproduzione non disponibile — questo fornitore non espone un archivio.", + "SCHEDULE_ONLY_NOTICE": "Solo programmazione. Questo fornitore espone la cronologia dei programmi ma non il catch-up — i programmi passati non possono essere riprodotti.", + "EMPTY_DAY_TITLE": "Nessun programma per questo giorno", + "EMPTY_DAY_DESCRIPTION": "La programmazione per il giorno selezionato non è ancora arrivata. Altri giorni hanno dati.", + "JUMP_TODAY": "Vai a oggi", + "JUMP_NEAREST": "Giorno più vicino con programma", + "CHANNEL_UNMAPPED_TITLE": "Nessun programma per questo canale", + "CHANNEL_UNMAPPED_DESCRIPTION": "Questo canale non è associato alla sorgente EPG (nessun tvg-id trovato). Gli altri canali non sono interessati.", + "PROVIDER_NO_EPG_TITLE": "Il fornitore non fornisce una guida", + "PROVIDER_NO_EPG_DESCRIPTION": "Questa sorgente non espone una guida TV per alcun canale. Se ti serve una programmazione, chiedi al tuo fornitore.", + "M3U_SETUP_TITLE": "Connetti una guida programmi", + "M3U_SETUP_DESCRIPTION": "Le playlist M3U necessitano di una sorgente EPG esterna (XMLTV). Aggiungi un link url-tvg e la programmazione apparirà qui.", + "OPEN_EPG_SETTINGS": "Apri impostazioni EPG", + "ERROR_TITLE": "Impossibile caricare la guida", + "ERROR_DESCRIPTION": "La sorgente EPG non ha risposto. Controlla la connessione o aggiorna la sorgente.", + "RETRY": "Riprova", + "MINUTES_SHORT": "min", + "ZOOM": "Zoom", + "ZOOM_DAY": "Panoramica giorno", + "ZOOM_HOURS": "Per ora", + "ZOOM_DETAIL": "Dettagliato", + "GROUP_SHORT": "prog. brevi", + "GROUP_EXPAND": "mostra ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 8b23c50a0..05abf58c3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -218,6 +218,7 @@ "PLAYBACK": "再生", "EPG_SOURCES": "EPGソース", "EPG_SOURCES_DESCRIPTION": "EPGソースとして1つ以上のURLを追加", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Xtream プレイリストでアップロードした EPG を優先する", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "アーカイブ再生", "RETURN_TO_LIVE": "ライブに戻る", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "今日", + "NOW": "現在", + "JUMP_NOW": "現在へ移動", + "RETURN_TO_LIVE": "ライブに戻る", + "ON_NOW": "放送中", + "MINUTES_LEFT": "分残り", + "FROM_ARCHIVE": "アーカイブから", + "WATCH": "視聴", + "WATCH_LIVE": "ライブを視聴", + "WATCH_FROM_START": "最初から視聴", + "ARCHIVE_UNAVAILABLE_NOTE": "再生できません — このプロバイダーはアーカイブを提供していません。", + "SCHEDULE_ONLY_NOTICE": "番組表のみです。このプロバイダーは番組履歴を提供しますが catch-up には対応していないため、過去の番組は再生できません。", + "EMPTY_DAY_TITLE": "この日の番組はありません", + "EMPTY_DAY_DESCRIPTION": "選択した日の番組表はまだ届いていません。他の日にはデータがあります。", + "JUMP_TODAY": "今日へ移動", + "JUMP_NEAREST": "番組がある最も近い日", + "CHANNEL_UNMAPPED_TITLE": "このチャンネルの番組はありません", + "CHANNEL_UNMAPPED_DESCRIPTION": "このチャンネルは EPG ソースと一致していません(tvg-id が見つかりません)。他のチャンネルには影響しません。", + "PROVIDER_NO_EPG_TITLE": "プロバイダーは番組表を提供していません", + "PROVIDER_NO_EPG_DESCRIPTION": "このソースはどのチャンネルの番組表も提供していません。番組表が必要な場合はプロバイダーにお問い合わせください。", + "M3U_SETUP_TITLE": "番組表を接続", + "M3U_SETUP_DESCRIPTION": "M3U プレイリストには外部の EPG ソース(XMLTV)が必要です。url-tvg リンクを追加すると、ここに番組表が表示されます。", + "OPEN_EPG_SETTINGS": "EPG 設定を開く", + "ERROR_TITLE": "番組表を読み込めませんでした", + "ERROR_DESCRIPTION": "EPG ソースが応答しませんでした。接続を確認するか、ソースを更新してください。", + "RETRY": "再試行", + "MINUTES_SHORT": "分", + "ZOOM": "ズーム", + "ZOOM_DAY": "1日表示", + "ZOOM_HOURS": "時間単位", + "ZOOM_DETAIL": "詳細", + "GROUP_SHORT": "短い番組", + "GROUP_EXPAND": "表示 ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 288e55069..d8f4ddab9 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -218,6 +218,7 @@ "PLAYBACK": "재생", "EPG_SOURCES": "EPG 소스", "EPG_SOURCES_DESCRIPTION": "하나 이상의 URL을 EPG 소스로 추가합니다", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Xtream 재생 목록에서 업로드한 EPG 우선 사용", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "아카이브 재생", "RETURN_TO_LIVE": "라이브로 돌아가기", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "오늘", + "NOW": "지금", + "JUMP_NOW": "현재 시각으로 이동", + "RETURN_TO_LIVE": "라이브로 돌아가기", + "ON_NOW": "지금 방송 중", + "MINUTES_LEFT": "분 남음", + "FROM_ARCHIVE": "아카이브에서", + "WATCH": "시청", + "WATCH_LIVE": "라이브 시청", + "WATCH_FROM_START": "처음부터 시청", + "ARCHIVE_UNAVAILABLE_NOTE": "재생할 수 없습니다 — 이 공급자는 아카이브를 제공하지 않습니다.", + "SCHEDULE_ONLY_NOTICE": "편성표만 제공됩니다. 이 공급자는 프로그램 이력을 제공하지만 catch-up은 제공하지 않으므로 지난 프로그램은 재생할 수 없습니다.", + "EMPTY_DAY_TITLE": "이 날짜에는 프로그램이 없습니다", + "EMPTY_DAY_DESCRIPTION": "선택한 날짜의 편성표가 아직 도착하지 않았습니다. 다른 날짜에는 데이터가 있습니다.", + "JUMP_TODAY": "오늘로 이동", + "JUMP_NEAREST": "프로그램이 있는 가장 가까운 날짜", + "CHANNEL_UNMAPPED_TITLE": "이 채널에는 프로그램이 없습니다", + "CHANNEL_UNMAPPED_DESCRIPTION": "이 채널은 EPG 소스와 연결되지 않았습니다 (tvg-id를 찾을 수 없음). 다른 채널에는 영향이 없습니다.", + "PROVIDER_NO_EPG_TITLE": "공급자가 가이드를 제공하지 않습니다", + "PROVIDER_NO_EPG_DESCRIPTION": "이 소스는 어떤 채널에 대해서도 TV 가이드를 제공하지 않습니다. 편성표가 필요하면 공급자에게 문의하세요.", + "M3U_SETUP_TITLE": "프로그램 가이드 연결", + "M3U_SETUP_DESCRIPTION": "M3U 재생목록에는 외부 EPG 소스(XMLTV)가 필요합니다. url-tvg 링크를 추가하면 편성표가 여기에 표시됩니다.", + "OPEN_EPG_SETTINGS": "EPG 설정 열기", + "ERROR_TITLE": "가이드를 불러올 수 없습니다", + "ERROR_DESCRIPTION": "EPG 소스가 응답하지 않았습니다. 연결을 확인하거나 소스를 새로 고치세요.", + "RETRY": "다시 시도", + "MINUTES_SHORT": "분", + "ZOOM": "확대/축소", + "ZOOM_DAY": "하루 보기", + "ZOOM_HOURS": "시간별", + "ZOOM_DETAIL": "상세", + "GROUP_SHORT": "짧은 프로그램", + "GROUP_EXPAND": "보기 ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 237f1f6fb..820747178 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -218,6 +218,7 @@ "PLAYBACK": "Afspelen", "EPG_SOURCES": "EPG-bronnen", "EPG_SOURCES_DESCRIPTION": "Voeg een of meer URL's toe als EPG-bron", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Eigen EPG-bronnen verkiezen voor Xtream-afspeellijsten", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Archief afspelen", "RETURN_TO_LIVE": "Terug naar live", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Vandaag", + "NOW": "Nu", + "JUMP_NOW": "Ga naar nu", + "RETURN_TO_LIVE": "Terug naar live", + "ON_NOW": "Nu bezig", + "MINUTES_LEFT": "min resterend", + "FROM_ARCHIVE": "Uit archief", + "WATCH": "Bekijken", + "WATCH_LIVE": "Live bekijken", + "WATCH_FROM_START": "Vanaf begin bekijken", + "ARCHIVE_UNAVAILABLE_NOTE": "Afspelen niet beschikbaar — deze provider biedt geen archief.", + "SCHEDULE_ONLY_NOTICE": "Alleen schema. Deze provider toont programmageschiedenis maar geen catch-up — programma's uit het verleden kunnen niet worden afgespeeld.", + "EMPTY_DAY_TITLE": "Geen programma voor deze dag", + "EMPTY_DAY_DESCRIPTION": "Het schema voor de geselecteerde dag is nog niet binnen. Andere dagen hebben wel gegevens.", + "JUMP_TODAY": "Ga naar vandaag", + "JUMP_NEAREST": "Dichtstbijzijnde dag met programma", + "CHANNEL_UNMAPPED_TITLE": "Geen programma voor dit kanaal", + "CHANNEL_UNMAPPED_DESCRIPTION": "Dit kanaal is niet gekoppeld aan de EPG-bron (geen tvg-id gevonden). Andere kanalen worden niet beïnvloed.", + "PROVIDER_NO_EPG_TITLE": "Provider levert geen gids", + "PROVIDER_NO_EPG_DESCRIPTION": "Deze bron biedt voor geen enkel kanaal een TV-gids. Vraag je provider als je een schema nodig hebt.", + "M3U_SETUP_TITLE": "Een programmagids verbinden", + "M3U_SETUP_DESCRIPTION": "M3U-afspeellijsten hebben een externe EPG-bron (XMLTV) nodig. Voeg een url-tvg-link toe en het schema verschijnt hier.", + "OPEN_EPG_SETTINGS": "EPG-instellingen openen", + "ERROR_TITLE": "Gids kon niet worden geladen", + "ERROR_DESCRIPTION": "De EPG-bron reageerde niet. Controleer je verbinding of vernieuw de bron.", + "RETRY": "Opnieuw proberen", + "MINUTES_SHORT": "min", + "ZOOM": "Zoom", + "ZOOM_DAY": "Dagoverzicht", + "ZOOM_HOURS": "Per uur", + "ZOOM_DETAIL": "Gedetailleerd", + "GROUP_SHORT": "korte progr.", + "GROUP_EXPAND": "toon ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 46bfb5173..536538da8 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -218,6 +218,7 @@ "PLAYBACK": "Odtwarzanie", "EPG_SOURCES": "Źródła EPG", "EPG_SOURCES_DESCRIPTION": "Dodaj jeden lub więcej adresów URL jako źródła EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Preferuj własne źródła EPG dla playlist Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Odtwarzanie archiwum", "RETURN_TO_LIVE": "Wróć do transmisji na żywo", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Dzisiaj", + "NOW": "Teraz", + "JUMP_NOW": "Przejdź do teraz", + "RETURN_TO_LIVE": "Wróć do transmisji na żywo", + "ON_NOW": "Teraz na antenie", + "MINUTES_LEFT": "min pozostało", + "FROM_ARCHIVE": "Z archiwum", + "WATCH": "Oglądaj", + "WATCH_LIVE": "Oglądaj na żywo", + "WATCH_FROM_START": "Oglądaj od początku", + "ARCHIVE_UNAVAILABLE_NOTE": "Odtwarzanie niedostępne — ten dostawca nie udostępnia archiwum.", + "SCHEDULE_ONLY_NOTICE": "Tylko program. Ten dostawca udostępnia historię programów, ale nie catch-up — minionych programów nie można odtworzyć.", + "EMPTY_DAY_TITLE": "Brak programu na ten dzień", + "EMPTY_DAY_DESCRIPTION": "Program na wybrany dzień jeszcze nie dotarł. Inne dni mają dane.", + "JUMP_TODAY": "Przejdź do dzisiaj", + "JUMP_NEAREST": "Najbliższy dzień z programem", + "CHANNEL_UNMAPPED_TITLE": "Brak programu dla tego kanału", + "CHANNEL_UNMAPPED_DESCRIPTION": "Ten kanał nie jest dopasowany do źródła EPG (nie znaleziono tvg-id). Inne kanały nie są tym dotknięte.", + "PROVIDER_NO_EPG_TITLE": "Dostawca nie udostępnia przewodnika", + "PROVIDER_NO_EPG_DESCRIPTION": "To źródło nie udostępnia przewodnika po programach dla żadnego kanału. Jeśli potrzebujesz programu, zapytaj swojego dostawcę.", + "M3U_SETUP_TITLE": "Podłącz przewodnik po programach", + "M3U_SETUP_DESCRIPTION": "Listy odtwarzania M3U wymagają zewnętrznego źródła EPG (XMLTV). Dodaj link url-tvg, a program pojawi się tutaj.", + "OPEN_EPG_SETTINGS": "Otwórz ustawienia EPG", + "ERROR_TITLE": "Nie udało się załadować przewodnika", + "ERROR_DESCRIPTION": "Źródło EPG nie odpowiedziało. Sprawdź połączenie lub odśwież źródło.", + "RETRY": "Spróbuj ponownie", + "MINUTES_SHORT": "min", + "ZOOM": "Powiększenie", + "ZOOM_DAY": "Przegląd dnia", + "ZOOM_HOURS": "Co godzinę", + "ZOOM_DETAIL": "Szczegółowo", + "GROUP_SHORT": "krótkie progr.", + "GROUP_EXPAND": "pokaż ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index a1eac7812..6a17c720b 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -218,6 +218,7 @@ "PLAYBACK": "Reprodução", "EPG_SOURCES": "Fontes de EPG", "EPG_SOURCES_DESCRIPTION": "Adicione uma ou mais URLs como fontes de EPG", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Preferir as suas fontes EPG para listas Xtream", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Reprodução do arquivo", "RETURN_TO_LIVE": "Voltar ao vivo", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Hoje", + "NOW": "Agora", + "JUMP_NOW": "Ir para agora", + "RETURN_TO_LIVE": "Voltar ao vivo", + "ON_NOW": "No ar agora", + "MINUTES_LEFT": "min restantes", + "FROM_ARCHIVE": "Do arquivo", + "WATCH": "Assistir", + "WATCH_LIVE": "Assistir ao vivo", + "WATCH_FROM_START": "Assistir desde o início", + "ARCHIVE_UNAVAILABLE_NOTE": "Reprodução indisponível — este provedor não disponibiliza um arquivo.", + "SCHEDULE_ONLY_NOTICE": "Apenas programação. Este provedor expõe o histórico de programas, mas não catch-up — programas passados não podem ser reproduzidos.", + "EMPTY_DAY_TITLE": "Nenhum programa para este dia", + "EMPTY_DAY_DESCRIPTION": "A programação do dia selecionado ainda não chegou. Outros dias têm dados.", + "JUMP_TODAY": "Ir para hoje", + "JUMP_NEAREST": "Dia mais próximo com programação", + "CHANNEL_UNMAPPED_TITLE": "Nenhum programa para este canal", + "CHANNEL_UNMAPPED_DESCRIPTION": "Este canal não está vinculado à fonte de EPG (nenhum tvg-id encontrado). Outros canais não são afetados.", + "PROVIDER_NO_EPG_TITLE": "O provedor não fornece um guia", + "PROVIDER_NO_EPG_DESCRIPTION": "Esta fonte não disponibiliza um guia de TV para nenhum canal. Se você precisa de uma programação, contate seu provedor.", + "M3U_SETUP_TITLE": "Conectar um guia de programação", + "M3U_SETUP_DESCRIPTION": "Playlists M3U precisam de uma fonte de EPG externa (XMLTV). Adicione um link url-tvg e a programação aparecerá aqui.", + "OPEN_EPG_SETTINGS": "Abrir configurações de EPG", + "ERROR_TITLE": "Não foi possível carregar o guia", + "ERROR_DESCRIPTION": "A fonte de EPG não respondeu. Verifique sua conexão ou atualize a fonte.", + "RETRY": "Tentar novamente", + "MINUTES_SHORT": "min", + "ZOOM": "Zoom", + "ZOOM_DAY": "Visão geral do dia", + "ZOOM_HOURS": "Por hora", + "ZOOM_DETAIL": "Detalhado", + "GROUP_SHORT": "progs. curtos", + "GROUP_EXPAND": "mostrar ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index e7ca63e98..8e33f5544 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Архивная запись", "RETURN_TO_LIVE": "Вернуться к эфиру", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Сегодня", + "NOW": "Сейчас", + "JUMP_NOW": "Перейти к текущему", + "RETURN_TO_LIVE": "Вернуться к эфиру", + "ON_NOW": "Сейчас в эфире", + "MINUTES_LEFT": "мин осталось", + "FROM_ARCHIVE": "Из архива", + "WATCH": "Смотреть", + "WATCH_LIVE": "Смотреть в эфире", + "WATCH_FROM_START": "Смотреть с начала", + "ARCHIVE_UNAVAILABLE_NOTE": "Воспроизведение недоступно — этот провайдер не предоставляет архив.", + "SCHEDULE_ONLY_NOTICE": "Только расписание. Этот провайдер предоставляет историю программ, но не архив (catch-up) — прошедшие передачи воспроизвести нельзя.", + "EMPTY_DAY_TITLE": "Нет программы на этот день", + "EMPTY_DAY_DESCRIPTION": "Расписание на выбранный день ещё не получено. Для других дней данные есть.", + "JUMP_TODAY": "Перейти к сегодня", + "JUMP_NEAREST": "Ближайший день с программой", + "CHANNEL_UNMAPPED_TITLE": "Нет программы для этого канала", + "CHANNEL_UNMAPPED_DESCRIPTION": "Этот канал не сопоставлен с источником EPG (не найден tvg-id). Других каналов это не касается.", + "PROVIDER_NO_EPG_TITLE": "Провайдер не предоставляет программу", + "PROVIDER_NO_EPG_DESCRIPTION": "Этот источник не предоставляет программу передач ни для одного канала. Если вам нужно расписание, обратитесь к провайдеру.", + "M3U_SETUP_TITLE": "Подключите программу передач", + "M3U_SETUP_DESCRIPTION": "Для плейлистов M3U нужен внешний источник EPG (XMLTV). Добавьте ссылку url-tvg, и расписание появится здесь.", + "OPEN_EPG_SETTINGS": "Открыть настройки EPG", + "ERROR_TITLE": "Не удалось загрузить программу", + "ERROR_DESCRIPTION": "Источник EPG не ответил. Проверьте подключение или обновите источник.", + "RETRY": "Повторить", + "MINUTES_SHORT": "мин", + "ZOOM": "Масштаб", + "ZOOM_DAY": "Обзор дня", + "ZOOM_HOURS": "По часам", + "ZOOM_DETAIL": "Детально", + "GROUP_SHORT": "коротких", + "GROUP_EXPAND": "показать ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 880b08936..983481f44 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -218,6 +218,7 @@ "PLAYBACK": "Oynatma", "EPG_SOURCES": "EPG kaynakları", "EPG_SOURCES_DESCRIPTION": "EPG kaynağı olarak bir veya daha fazla URL ekleyin", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "Xtream oynatma listeleri için yüklediğin EPG'yi tercih et", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "Arşiv oynatma", "RETURN_TO_LIVE": "Canlı yayına dön", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "Bugün", + "NOW": "Şimdi", + "JUMP_NOW": "Şimdiye atla", + "RETURN_TO_LIVE": "Canlı yayına dön", + "ON_NOW": "Şimdi yayında", + "MINUTES_LEFT": "dk kaldı", + "FROM_ARCHIVE": "Arşivden", + "WATCH": "İzle", + "WATCH_LIVE": "Canlı izle", + "WATCH_FROM_START": "Baştan izle", + "ARCHIVE_UNAVAILABLE_NOTE": "Oynatma kullanılamıyor — bu sağlayıcı bir arşiv sunmuyor.", + "SCHEDULE_ONLY_NOTICE": "Yalnızca program çizelgesi. Bu sağlayıcı program geçmişini sunar ancak catch-up sunmaz — geçmiş programlar oynatılamaz.", + "EMPTY_DAY_TITLE": "Bu gün için program yok", + "EMPTY_DAY_DESCRIPTION": "Seçilen güne ait program çizelgesi henüz gelmedi. Diğer günlerde veri var.", + "JUMP_TODAY": "Bugüne git", + "JUMP_NEAREST": "Programı olan en yakın gün", + "CHANNEL_UNMAPPED_TITLE": "Bu kanal için program yok", + "CHANNEL_UNMAPPED_DESCRIPTION": "Bu kanal EPG kaynağıyla eşleştirilmedi (tvg-id bulunamadı). Diğer kanallar etkilenmez.", + "PROVIDER_NO_EPG_TITLE": "Sağlayıcı bir rehber sunmuyor", + "PROVIDER_NO_EPG_DESCRIPTION": "Bu kaynak hiçbir kanal için TV rehberi sunmuyor. Bir program çizelgesine ihtiyacınız varsa sağlayıcınıza sorun.", + "M3U_SETUP_TITLE": "Bir program rehberi bağlayın", + "M3U_SETUP_DESCRIPTION": "M3U oynatma listeleri harici bir EPG kaynağı (XMLTV) gerektirir. Bir url-tvg bağlantısı ekleyin; program çizelgesi burada görünecektir.", + "OPEN_EPG_SETTINGS": "EPG ayarlarını aç", + "ERROR_TITLE": "Rehber yüklenemedi", + "ERROR_DESCRIPTION": "EPG kaynağı yanıt vermedi. Bağlantınızı kontrol edin veya kaynağı yenileyin.", + "RETRY": "Tekrar dene", + "MINUTES_SHORT": "dk", + "ZOOM": "Yakınlaştırma", + "ZOOM_DAY": "Gün özeti", + "ZOOM_HOURS": "Saatlik", + "ZOOM_DETAIL": "Detaylı", + "GROUP_SHORT": "kısa program", + "GROUP_EXPAND": "göster ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index d0392b51e..c867c4c64 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -218,6 +218,7 @@ "PLAYBACK": "播放", "EPG_SOURCES": "EPG 源", "EPG_SOURCES_DESCRIPTION": "添加一个或多个 URL 作为 EPG 源", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "为 Xtream 播放列表优先使用上传的 EPG", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "回看播放", "RETURN_TO_LIVE": "返回直播", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "今天", + "NOW": "现在", + "JUMP_NOW": "跳转到现在", + "RETURN_TO_LIVE": "返回直播", + "ON_NOW": "正在播放", + "MINUTES_LEFT": "分钟剩余", + "FROM_ARCHIVE": "来自归档", + "WATCH": "观看", + "WATCH_LIVE": "观看直播", + "WATCH_FROM_START": "从头开始观看", + "ARCHIVE_UNAVAILABLE_NOTE": "无法播放 —— 此提供商未提供归档。", + "SCHEDULE_ONLY_NOTICE": "仅节目表。此提供商提供节目历史,但不支持 catch-up —— 无法播放过往节目。", + "EMPTY_DAY_TITLE": "当天暂无节目", + "EMPTY_DAY_DESCRIPTION": "所选日期的节目表尚未到达。其他日期已有数据。", + "JUMP_TODAY": "前往今天", + "JUMP_NEAREST": "最近有节目的日期", + "CHANNEL_UNMAPPED_TITLE": "此频道暂无节目", + "CHANNEL_UNMAPPED_DESCRIPTION": "此频道未与 EPG 源匹配(未找到 tvg-id)。其他频道不受影响。", + "PROVIDER_NO_EPG_TITLE": "提供商未提供节目指南", + "PROVIDER_NO_EPG_DESCRIPTION": "此源未为任何频道提供电视指南。如需节目表,请联系您的提供商。", + "M3U_SETUP_TITLE": "连接节目指南", + "M3U_SETUP_DESCRIPTION": "M3U 播放列表需要外部 EPG 源(XMLTV)。添加 url-tvg 链接后,节目表将显示在此处。", + "OPEN_EPG_SETTINGS": "打开 EPG 设置", + "ERROR_TITLE": "无法加载节目指南", + "ERROR_DESCRIPTION": "EPG 源未响应。请检查您的网络连接或刷新该源。", + "RETRY": "重试", + "MINUTES_SHORT": "分钟", + "ZOOM": "缩放", + "ZOOM_DAY": "全天概览", + "ZOOM_HOURS": "按小时", + "ZOOM_DETAIL": "详细", + "GROUP_SHORT": "个短节目", + "GROUP_EXPAND": "展开 ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 1a1bdd046..a88dcda85 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -218,6 +218,7 @@ "PLAYBACK": "播放", "EPG_SOURCES": "EPG 來源", "EPG_SOURCES_DESCRIPTION": "新增一個或多個網址作為 EPG 來源", + "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "EPG_EMPTY_TITLE": "No EPG sources yet", "EPG_EMPTY_HINT": "Add a URL to fetch program data for your channels.", "PREFER_UPLOADED_EPG_TITLE": "為 Xtream 播放清單優先使用上傳的 EPG", @@ -395,7 +396,6 @@ "DASHBOARD_RAIL_SOURCES_DESCRIPTION": "Show shortcuts to your most recently used playlists and portals.", "DASHBOARD_RAIL_XTREAM_ADDED": "Recently added on Xtream", "DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION": "Show the newest Xtream movies, series, and channels.", - "PLAYLIST_EPG_SOURCES_AUTO_SELECTED": "{{enabled}} of {{detected}} detected playlist EPG sources were auto-selected for import.", "APP_UPDATE_TITLE": "Application update", "APP_UPDATE_DESCRIPTION": "Check GitHub Releases for desktop app updates.", "APP_UPDATE_IDLE": "Ready to check for updates", @@ -617,7 +617,42 @@ "ARCHIVE_PLAYBACK": "回看播放", "RETURN_TO_LIVE": "返回直播", "COLLAPSE_PANEL": "Collapse EPG panel", - "EXPAND_PANEL": "Expand EPG panel" + "EXPAND_PANEL": "Expand EPG panel", + "TIMELINE": { + "TODAY": "今天", + "NOW": "現在", + "JUMP_NOW": "跳至現在", + "RETURN_TO_LIVE": "返回直播", + "ON_NOW": "現在播放", + "MINUTES_LEFT": "分鐘剩餘", + "FROM_ARCHIVE": "來自存檔", + "WATCH": "觀看", + "WATCH_LIVE": "觀看直播", + "WATCH_FROM_START": "從頭觀看", + "ARCHIVE_UNAVAILABLE_NOTE": "無法播放——此供應商未提供存檔。", + "SCHEDULE_ONLY_NOTICE": "僅提供節目表。此供應商提供節目歷史紀錄,但未提供 catch-up——無法播放過去的節目。", + "EMPTY_DAY_TITLE": "這一天沒有節目", + "EMPTY_DAY_DESCRIPTION": "所選日期的節目表尚未送達。其他日期有資料。", + "JUMP_TODAY": "前往今天", + "JUMP_NEAREST": "最接近且有節目的日期", + "CHANNEL_UNMAPPED_TITLE": "此頻道沒有節目", + "CHANNEL_UNMAPPED_DESCRIPTION": "此頻道未與 EPG 來源相符(找不到 tvg-id)。其他頻道不受影響。", + "PROVIDER_NO_EPG_TITLE": "供應商未提供節目表", + "PROVIDER_NO_EPG_DESCRIPTION": "此來源未對任何頻道提供電視節目表。如果您需要節目表,請洽詢您的供應商。", + "M3U_SETUP_TITLE": "連接節目表", + "M3U_SETUP_DESCRIPTION": "M3U 播放清單需要外部 EPG 來源(XMLTV)。新增 url-tvg 連結後,節目表就會顯示於此。", + "OPEN_EPG_SETTINGS": "開啟 EPG 設定", + "ERROR_TITLE": "無法載入節目表", + "ERROR_DESCRIPTION": "EPG 來源沒有回應。請檢查您的網路連線或重新整理來源。", + "RETRY": "重試", + "MINUTES_SHORT": "分鐘", + "ZOOM": "縮放", + "ZOOM_DAY": "日總覽", + "ZOOM_HOURS": "依小時", + "ZOOM_DETAIL": "詳細", + "GROUP_SHORT": "短節目", + "GROUP_EXPAND": "顯示 ›" + } }, "LANGUAGES": { "ARABIC": "العربية", diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 004eef3af..be56e4055 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -21,8 +21,8 @@ The M3U playlist module provides: │ libs/playlist/m3u/feature-player/src/lib/video-player/ │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌──────────────────────┐ ┌────────────────────┐ │ -│ │ Sidebar │ │ Video Player │ │ EPG List │ │ -│ │ │ │ (ArtPlayer/Video.js)│ │ (Right drawer) │ │ +│ │ Sidebar │ │ Video Player │ │ EPG Timeline │ │ +│ │ │ │ (ArtPlayer/Video.js)│ │ (panel below) │ │ │ │ ┌─────────┐ │ │ │ │ │ │ │ │ │Channel │ │ │ │ │ │ │ │ │ │List │ │ │ │ │ │ │ @@ -251,6 +251,138 @@ EPG lookup keys use the same precedence in both program and icon paths: 2. `tvg-name` 3. channel name +All four collection tabs (all-channels, groups, favorites, recent) share a single +EPG-enrichment implementation in `channel-list-container/epg-enrichment.util.ts`, +fed the same `channelEpgMap`/`channelIconMap` from the container — there is no +per-tab EPG logic: + +- `calculateEpgProgress(program, now?)` — clamped, **rounded** progress in + `[0, 100]`, guarded against missing/invalid timestamps and zero-length + programmes (never returns `NaN`). +- `resolveChannelEpgProgram(channel, channelEpgMap)` — the current programme for + a channel by its lookup key (used by the per-item recent view). +- `buildChannelEpgMetadataMap(channelEpgMap, now?)` — the side-car + `key → {epgProgram, progressPercentage}` map (used by all-channels/groups/ + favorites). Callers read their `progressTick()` signal first so the computed + re-runs on the ~30s tick. + +### EPG Timeline Panel + +The programme guide under the player is a horizontal **timeline ribbon** +(`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`) shared by all four live +surfaces: the M3U video player, the unified live tab, and the Xtream and Stalker +live-stream layouts. It replaces the former vertical `app-epg-list` / +`app-epg-view`. + +The component stays presentation-focused; the reusable, view-agnostic pieces +(so a future list view can share them) are split out and re-exported from +`@iptvnator/ui/epg`: + +- `epg-timeline.utils.ts` (axis/blocks/date helpers) + `epg-timeline-render.util.ts` + (short-programme tiers, grouping, zoom bounds) — the ribbon geometry. +- `epg-archive.util.ts` — catch-up gating (`isWithinArchiveWindow`, + `canCatchUpProgramme`, `epgDialogActionFor`) off `when`/`startMs` primitives. +- `epg-summary.util.ts` — `EpgTimelineSummary` + collapsed-summary progress maths + (`summaryProgress` / `summaryMinutesLeft` / …). +- `epg-programme-dialog.service.ts` — `EpgProgrammeDialogService`, opens the + shared details dialog and returns the chosen `live` / `timeshift` action. +- `epg-timeline-scroll.controller.ts` — `TimelineScrollController` (ribbon + scrolling + channel-select auto-focus); timeline-specific, kept out of the + component so it stays under the line ceiling. + +- **One channel, preloaded window.** The panel always shows a single channel. + Each provider returns a multi-day window in roughly one call (M3U + `GET_CHANNEL_PROGRAMS`; Stalker `get_epg_info`; Xtream `get_simple_data_table`), + so the whole ribbon is rendered up front and day navigation is **scroll within + the loaded window** — no per-day lazy fetch. The date stepper / "Now" jump + scroll the ribbon; the day label follows the scroll position. +- **Auto-focus on channel select.** When a channel's EPG (re)loads or the ribbon + (re)mounts, the timeline centres the **currently airing programme** in the + viewport **instantly** (`behavior: 'auto'`, no scroll animation) — selecting a + channel lands on "now" without the user pressing the Now button. The jump is + deduped by programme-set identity (`programsFocusKey`), so the 30s now-tick, + zoom changes, or a host re-emitting the same data never re-jump the viewport; + switching channels (or returning after viewing an empty-day channel) re-centres. + The explicit "Now" button still animates (`behavior: 'smooth'`) since it is a + deliberate user action. See `TimelineScrollController.maybeAutoFocus` / + `focusCurrentProgram` in `epg-timeline-scroll.controller.ts`. +- **Controlled component.** `app-epg-timeline` is presentation-only: it takes + `programs`, `archivePlaybackAvailable`, `archiveDays`, `activeProgram`, + `isLivePlayback`, `loading`, `emptyReason`, `selectedDate`, `collapsed`, + `summary` and emits `programActivated`, `returnToLive`, `selectedDateChange`, + `openEpgSettings`, `retry`, `collapsedChange`. The host layout owns playback, + persists the collapse state (`liveEpgPanelState` in localStorage), and (for + the M3U player) the `EpgActions.setCurrentEpgProgram` / `setEpgAvailableFlag` + / `setActiveEpgProgram` dispatches. The timeline owns the **single** panel + bar — collapse chevron + channel name on the left, return-to-live / jump / + date stepper on the right — and the collapsed inline summary; the former + `app-live-epg-panel` wrapper has been removed from the live layouts. +- **Dynamic bar subtitle.** Under the channel name the bar shows the + **now-playing programme title** when expanded and a `summary` exists + (`.epg-timeline__subtitle`) — readable title style, not the uppercase mono + label. During timeshift it switches to the archive programme with a `history` + icon and cyan accent (`.is-arch`). It falls back to the static `sourceLabel` + (`Timeline` / `Xtream` / `Stalker Portal`) only when collapsed or when the + channel has no programme. +- **State-aware toolbar controls.** The right-side controls are **hidden** (not + disabled) when they cannot act, so a channel with no EPG shows a clean bar + instead of dead controls: `showRibbonControls()` gates "Now" + zoom to the + `ribbon` state only (nothing to jump to or zoom otherwise), and + `showDateStepper()` keeps the date stepper for `ribbon` **and** `empty-day` + (the only states where the channel has EPG on some day), hiding it for the + no-EPG-anywhere states and while loading. Return-to-live is a playback control + (`!isLivePlayback()`) and is independent of EPG state. +- **State-driven affordances.** Blocks are coloured past / now / future, with a + red "now" playhead. Catch-up "Watch" appears on past blocks only when + `archivePlaybackAvailable` (Xtream `tv_archive`, M3U `catchup-*`); Stalker is + schedule-only (dimmed past + a notice, no false buttons). The "i" button opens + the shared `app-epg-item-description` dialog with a state-aware action. +- **Empty / error states.** `emptyReason` selects one of six states + (`loading` skeleton, `empty-day`, `channel-unmapped`, `provider-no-epg`, + `m3u-needs-setup`, `error`) via `app-epg-timeline-empty-state`. Icon tone is + neutral for info, blue for actionable, red for errors; an action button is + shown only when one really exists. The empty-state host `flex: 1`-fills the + area below the toolbar and centres within **that** (compact icon/title/sub), + rather than a fixed `min-height` that used to overflow the compact inline panel + and push the icon/text — and the `empty-day` action buttons — below the visible + edge. `empty-day` itself is decided by `hasProgramsForDateKey`, which is + **overlap-based** (a programme counts for a day when `[start, stop)` intersects + it, end-exclusive) — so a film that starts the previous evening and runs past + midnight still keeps the ribbon on "today" while it airs, matching the sidebar + (which matches by "airing now"); a start-date-only check used to drop to + `empty-day` after midnight even though the programme was on air. +- **Short-programme strategy.** In a proportional ribbon a 5-minute programme + would be an unreadable sliver, so `buildTimelineRenderItems` + (`epg-timeline.utils.ts`) applies a layered fix: (A) a **minimum block width** + (`TIMELINE_MIN_BLOCK_WIDTH_PX`); (B) width-adaptive **content tiers** — + `wide` (title + time, 3-line clamp) → `med` (one-line ellipsis) → `narrow` + (**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus + popover** revealing the full title + time + description for any non-`wide` + block (it flips above the block when the panel is near the screen bottom); + (D) a px-per-minute **zoom** slider (tick density adapts via + `timelineTickStepForScale`); and (E) **grouping** of ≥4 consecutive short + (<10 min) programmes into one dashed "N short" chip when zoomed out + (`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon + canvas lives in the child `app-epg-timeline-track`; the parent owns the + scroller, toolbar (incl. the zoom slider) and state. +- **Panel height & titles.** Block titles wrap onto as many lines as the card + height allows and are clipped (not single-line ellipsis); the foot ("ON NOW" + tag / "Watch") stays pinned at the bottom. With an inline player the guide is + a compact panel (`.epg.epg--inline` → `flex: 0 0 clamp(180px, 36vh, 264px)` + in `_portal-layout.scss`) so the player stays dominant; with an external + player the guide keeps `flex: 1` and fills the whole content area. +- **Wide-tier description preview.** When a block is the `wide` tier (rendered + width ≥ `132px`, i.e. long programmes and/or zoomed in) **and** the programme + has a `desc`, a dimmed (`--text-secondary`) preview of the description renders + under the title (`.epg-timeline__block-desc`). Gated to `wide` only, so + narrower cards and the moderate default zoom stay clean; the full description + still lives in the hover popover for every tier. To avoid ugly mid-line cuts, + wide-tier `time`/`title`/`desc` use `flex-shrink: 0` so flexbox can never + shrink them to a fractional height: each self-clips on **whole lines** with an + ellipsis via `-webkit-line-clamp` (title ≤ 2 lines, description ≤ 3) instead of + being cut mid-line by the parent's `overflow: hidden`. At the usual inline + panel height the title + 3-line preview fit without the parent clipping at all. + ### Playlist-Declared EPG Sources Some M3U providers declare XMLTV sources in the playlist header instead of @@ -321,8 +453,25 @@ These URLs are playlist-scoped by default: source scope. Batch current-program lookups use the same source-scoped per-channel TTL cache and order-insensitive in-flight batch deduplication before reaching IPC; missing exact channel-id matches are resolved with batched - case-insensitive id/display-name candidate queries rather than a per-channel - fallback loop. + id/display-name candidate queries rather than a per-channel fallback loop. + Those candidate queries match the **raw key case-sensitively** as well as via + `LOWER()`: SQLite's `LOWER()`/`COLLATE NOCASE` only fold ASCII, so for non-ASCII + names (Cyrillic, Greek, …) a `LOWER()`-only match would miss channels whose + M3U name and EPG `display_name` share the same casing — the raw exact match + keeps parity with the timeline's single-channel exact-display-name lookup, and + the JS `resolveChannelMetadataCandidate` then folds with full-Unicode + `toLowerCase()`. The "airing now" window is compared with timezone-aware SQLite + `datetime()` on both sides (`EpgQueryService.isAiringAt`), not raw string + comparison: stored EPG timestamps often carry an offset (e.g. `+03:00`) while + `now` is built as UTC (`…Z`), so a lexical compare would be wrong by the offset + and surface a stale (or no) current programme. After the scoped + legacy + candidate queries, any candidate that resolved by id/display-name but still has + no in-scope current programme is retried once **unscoped** (all sources), + mirroring the timeline's own unscoped `getChannelPrograms` lookup. This keeps + the channel-list "now" line consistent with the timeline when a channel's row + and its programmes carry different `source_url` values (shared XMLTV ids across + multiple imports), where the channel resolves in scope but its programmes are + tagged with a source that is not currently enabled. When upgrading an existing database whose historical programs have no `source_url`, scoped program and metadata queries try those legacy unscoped rows only after the requested source scope returns no result, so old EPG data diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index fbc1bec44..fad6768b3 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -93,48 +93,34 @@ @let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel);
- @if (canCollapseEpgPanel) { - + -
- -
-
- } @else { -
- -
- } + (selectedDateChange)=" + onLiveEpgSelectedDateChange($event) + " + (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + /> +
} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 8666bda41..bca91c1f5 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -25,6 +25,7 @@ import { selectChannelsLoading, selectCurrentEpgProgram, } from '@iptvnator/m3u-state'; +import { EpgService } from '@iptvnator/epg/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PORTAL_EXTERNAL_PLAYBACK, @@ -56,27 +57,6 @@ jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); -@Component({ - selector: 'app-live-epg-panel', - standalone: true, - template: ` -
{{ summary()?.title }}
- - `, -}) -class StubLiveEpgPanelComponent { - readonly collapsed = input(false); - readonly summary = input(null); - readonly loading = input(false); - readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM'); - readonly showDateNavigator = input(false); - readonly selectedDate = input(null); - readonly showReturnToLive = input(false); - readonly collapsedChange = output(); - readonly dateNavigation = output<'next' | 'prev'>(); - readonly returnToLive = output(); -} - @Component({ selector: 'app-channel-list-loading-state', standalone: true, @@ -121,6 +101,7 @@ class StubAudioPlayerComponent { readonly url = input(''); readonly icon = input(''); readonly channelName = input(''); + readonly channelLogo = input(''); readonly volume = input(null); readonly volumeChange = output(); } @@ -141,16 +122,33 @@ class StubWebPlayerViewComponent { } @Component({ - selector: 'app-epg-list', + selector: 'app-epg-timeline', standalone: true, template: '', }) -class StubEpgListComponent { - readonly activeProgram = input(null); - readonly selectedDate = input(null); - readonly showDateNavigator = input(false); +class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(true); + readonly loading = input(false); + readonly emptyReason = input(null); + readonly selectedDate = input(null); + readonly collapsed = input(false); + readonly summary = input(null); + readonly summaryLabelKey = input(''); + readonly programActivated = output<{ + program?: EpgProgram; + type: 'timeshift' | 'live'; + }>(); + readonly returnToLive = output(); readonly selectedDateChange = output(); + readonly openEpgSettings = output(); + readonly retry = output(); + readonly collapsedChange = output(); } @Directive({ @@ -183,6 +181,11 @@ describe('VideoPlayerComponent', () => { const channels$ = new BehaviorSubject([]); const activeChannel$ = new BehaviorSubject(null); const currentEpgProgram$ = new BehaviorSubject(null); + const epgPrograms$ = new BehaviorSubject([]); + const epgServiceMock = { + currentEpgPrograms$: epgPrograms$.asObservable(), + getChannelMetadataForChannels: () => of(new Map()), + }; const player = signal(VideoPlayer.VideoJs); const showCaptions = signal(false); @@ -313,6 +316,7 @@ describe('VideoPlayerComponent', () => { currentEpgProgram.set(null); activeEpgProgram.set(null); currentEpgProgram$.next(null); + epgPrograms$.next([]); overlayMock.create.mockClear(); overlayRef.attach.mockClear(); overlayRef.dispose.mockClear(); @@ -367,6 +371,10 @@ describe('VideoPlayerComponent', () => { provide: PlaylistsService, useValue: playlistsServiceMock, }, + { + provide: EpgService, + useValue: epgServiceMock, + }, { provide: PlaylistContextFacade, useValue: { @@ -405,8 +413,7 @@ describe('VideoPlayerComponent', () => { AsyncPipe, StubAudioPlayerComponent, StubChannelListLoadingStateComponent, - StubEpgListComponent, - StubLiveEpgPanelComponent, + StubEpgTimelineComponent, StubPortalEmptyStateComponent, StubResizableDirective, StubSidebarComponent, @@ -458,7 +465,7 @@ describe('VideoPlayerComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect( - fixture.nativeElement.querySelector('app-epg-list') + fixture.nativeElement.querySelector('app-epg-timeline') ).not.toBeNull(); }); @@ -478,7 +485,7 @@ describe('VideoPlayerComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); expect(headerContext.action()).toBeNull(); }); @@ -549,7 +556,7 @@ describe('VideoPlayerComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect( - fixture.nativeElement.querySelector('app-epg-list') + fixture.nativeElement.querySelector('app-epg-timeline') ).not.toBeNull(); }); @@ -561,11 +568,8 @@ describe('VideoPlayerComponent', () => { expect(fixture.nativeElement.querySelector('.video-player')).toBeNull(); expect( - fixture.nativeElement.querySelector('app-epg-list') + fixture.nativeElement.querySelector('app-epg-timeline') ).not.toBeNull(); - expect( - fixture.nativeElement.querySelector('app-live-epg-panel') - ).toBeNull(); expect( fixture.nativeElement .querySelector('.epg') @@ -633,18 +637,37 @@ describe('VideoPlayerComponent', () => { .querySelector('.epg') .classList.contains('epg-collapsed') ).toBe(true); + + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.collapsed()).toBe(true); }); - it('persists live EPG panel toggle changes', () => { - component.onLiveEpgPanelCollapsedChange(true); + it('persists live EPG panel toggle changes from the timeline', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + + timeline.componentInstance.collapsedChange.emit(true); + fixture.detectChanges(); expect(component.isLiveEpgPanelCollapsed()).toBe(true); + expect(timeline.componentInstance.collapsed()).toBe(true); expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( 'collapsed' ); - component.onLiveEpgPanelCollapsedChange(false); + timeline.componentInstance.collapsedChange.emit(false); + fixture.detectChanges(); + expect(component.isLiveEpgPanelCollapsed()).toBe(false); + expect(timeline.componentInstance.collapsed()).toBe(false); expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( 'expanded' ); @@ -658,10 +681,15 @@ describe('VideoPlayerComponent', () => { fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.live-epg-panel-summary') - .textContent - ).toContain('Current Show'); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.summary()).toEqual( + expect.objectContaining({ title: 'Current Show' }) + ); + expect(timeline.componentInstance.summaryLabelKey()).toBe( + 'EPG.CURRENT_PROGRAM' + ); }); it('uses the active playback override url when archive playback is active', () => { @@ -702,7 +730,7 @@ describe('VideoPlayerComponent', () => { fixture.detectChanges(); const epgList = fixture.debugElement.query( - By.directive(StubEpgListComponent) + By.directive(StubEpgTimelineComponent) ); expect(epgList.componentInstance.activeProgram()).toEqual( @@ -719,17 +747,17 @@ describe('VideoPlayerComponent', () => { fixture.detectChanges(); - const panel = fixture.debugElement.query( - By.directive(StubLiveEpgPanelComponent) + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) ); - expect(panel.componentInstance.summary()).toEqual( + expect(timeline.componentInstance.summary()).toEqual( expect.objectContaining({ title: 'Archived Show' }) ); - expect(panel.componentInstance.summaryLabelKey()).toBe( + expect(timeline.componentInstance.summaryLabelKey()).toBe( 'EPG.ARCHIVE_PLAYBACK' ); - expect(panel.componentInstance.showReturnToLive()).toBe(true); + expect(timeline.componentInstance.isLivePlayback()).toBe(false); }); it('dispatches return-to-live from the inline EPG panel', () => { @@ -740,10 +768,10 @@ describe('VideoPlayerComponent', () => { fixture.detectChanges(); - const panel = fixture.debugElement.query( - By.directive(StubLiveEpgPanelComponent) + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) ); - panel.componentInstance.returnToLive.emit(); + timeline.componentInstance.returnToLive.emit(); expect(storeMock.dispatch).toHaveBeenCalledWith( EpgActions.returnToLivePlayback() @@ -901,6 +929,38 @@ describe('VideoPlayerComponent', () => { }) ); }); + + it('does not reset active timeshift on the now-tick during an EPG gap', () => { + // In catch-up (activePlaybackUrl set) with programmes that don't cover + // "now", the 30s tick must NOT dispatch resetActiveEpgProgram — that + // reducer nulls activePlaybackUrl and would silently drop the user back + // to the live stream every 30 seconds. + syncStoreState(sampleChannel); + activePlaybackUrl.set('http://localhost/catchup.m3u8'); + fixture.detectChanges(); + storeMock.dispatch.mockClear(); + + epgPrograms$.next([buildProgram('Earlier')]); // no programme airing now + fixture.detectChanges(); + + expect(storeMock.dispatch).not.toHaveBeenCalledWith( + EpgActions.resetActiveEpgProgram() + ); + }); + + it('still clears stale EPG state on the now-tick when playing live', () => { + syncStoreState(sampleChannel); + activePlaybackUrl.set(null); // live, not timeshift + fixture.detectChanges(); + storeMock.dispatch.mockClear(); + + epgPrograms$.next([buildProgram('Earlier')]); + fixture.detectChanges(); + + expect(storeMock.dispatch).toHaveBeenCalledWith( + EpgActions.resetActiveEpgProgram() + ); + }); }); function buildProgram(title: string): EpgProgram { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index dbf067684..edda8c7f4 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -12,7 +12,7 @@ import { inject, signal, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -21,21 +21,27 @@ import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslatePipe } from '@ngx-translate/core'; import { ResizableDirective } from '@iptvnator/ui/components'; -import { isM3uCatchupPlaybackSupported } from '@iptvnator/shared/m3u-utils'; +import { + getM3uArchiveDays, + isM3uCatchupPlaybackSupported, +} from '@iptvnator/shared/m3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { COMPONENT_OVERLAY_REF, EpgDateNavigationDirection, - EpgListComponent, + EpgProgramActivationEvent, + EpgTimelineComponent, getTodayEpgDateKey, MultiEpgContainerComponent, shiftEpgDateKey, } from '@iptvnator/ui/epg'; +import { EpgService } from '@iptvnator/epg/data-access'; import { ChannelActions, EpgActions, PlaylistActions, buildExternalPlayerPayload, + resolveChannelEpgLookupKey, selectActive, selectActiveEpgProgram, selectActivePlaybackUrl, @@ -47,10 +53,13 @@ import { firstValueFrom, Observable, Subscription, + catchError, combineLatest, filter, map, + of, startWith, + switchMap, take, } from 'rxjs'; import { @@ -74,10 +83,7 @@ import { SidebarComponent, WebPlayerViewComponent, } from '@iptvnator/ui/playback'; -import { - LiveEpgPanelComponent, - LiveEpgPanelSummary, -} from '@iptvnator/ui/shared-portals'; +import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; import { DataService, @@ -116,8 +122,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460; AudioPlayerComponent, ChannelListLoadingStateComponent, CommonModule, - EpgListComponent, - LiveEpgPanelComponent, + EpgTimelineComponent, MatButtonModule, MatIconModule, MatTooltipModule, @@ -141,6 +146,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly settingsStore = inject(SettingsStore); private readonly storage = inject(StorageMap); private readonly store = inject(Store); + private readonly epgService = inject(EpgService); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); private readonly workspaceHeaderContext = inject( WorkspaceHeaderContextService @@ -164,6 +170,46 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly archivePlaybackAvailable = computed(() => isM3uCatchupPlaybackSupported(this.activeChannel()) ); + /** Full multi-day programme window for the active channel (timeline). */ + readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, { + initialValue: [] as EpgProgram[], + }); + // Shared helper skips blank strings (`tvg-rec=""` is a common default that + // `??` would not fall through), so a channel with only `timeshift`/ + // `catchup-days` still gets its real window instead of 0 (unbounded). + readonly epgArchiveDays = computed(() => + getM3uArchiveDays(this.activeChannel()) + ); + readonly timelineChannelName = computed( + () => this.activeChannel()?.name ?? '' + ); + /** Channel logo from the EPG feed (M3U playlists often lack tvg-logo). */ + private readonly epgChannelLogo = toSignal( + toObservable(this.activeChannel).pipe( + switchMap((channel) => { + const key = channel + ? resolveChannelEpgLookupKey(channel) + : ''; + if (!key) { + return of(''); + } + return this.epgService + .getChannelMetadataForChannels([key]) + .pipe( + map( + (metadata) => + metadata.get(key)?.iconUrl?.trim() || '' + ), + catchError(() => of('')) + ); + }) + ), + { initialValue: '' } + ); + readonly timelineChannelLogo = computed( + () => this.activeChannel()?.tvg?.logo?.trim() || this.epgChannelLogo() + ); + private readonly epgNowMs = signal(Date.now()); readonly playbackChannel = computed(() => { const activeChannel = this.activeChannel(); if (!activeChannel) { @@ -309,6 +355,50 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }; }); + // Keep "now" fresh so EPG state re-evaluates over time. + effect((onCleanup) => { + const intervalId = window.setInterval( + () => this.epgNowMs.set(Date.now()), + 30_000 + ); + onCleanup(() => clearInterval(intervalId)); + }); + + // Mirror the legacy uncontrolled epg-list store side effects so the + // toolbar, summary and diagnostics keep reflecting the live programme. + effect(() => { + const channel = this.activeChannel(); + const nowMs = this.epgNowMs(); + + // The old uncontrolled only existed (and only + // dispatched these) while a non-radio channel was active and EPG + // was supported. Outside that window it dispatched nothing, so the + // flag/current-program held their last value. Preserve that to + // avoid clobbering EPG state on radio/no-channel. + if (!channel || channel.radio === 'true' || !this.supportsEpg) { + return; + } + + const programs = this.epgPrograms(); + this.store.dispatch( + EpgActions.setEpgAvailableFlag({ value: programs.length > 0 }) + ); + + const currentProgram = findCurrentEpgProgram(programs, nowMs); + if (currentProgram) { + this.store.dispatch( + EpgActions.setCurrentEpgProgram({ program: currentProgram }) + ); + } else if (!this.activePlaybackUrl()) { + // No live programme right now. Only clear stale EPG state when + // NOT in catch-up/timeshift: resetActiveEpgProgram also nulls + // activePlaybackUrl, so firing it on every 30s tick would knock + // the user out of an in-progress archive playback whenever the + // channel has an EPG gap at the current clock time. + this.store.dispatch(EpgActions.resetActiveEpgProgram()); + } + }); + effect(() => { this.sidebarWidth.set( this.loadSidebarWidth(this.sidebarStorageKey()) @@ -561,6 +651,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { this.store.dispatch(EpgActions.returnToLivePlayback()); } + onTimelineProgramActivated(event: EpgProgramActivationEvent): void { + if (event.type === 'live') { + this.returnToLivePlayback(); + return; + } + this.store.dispatch( + EpgActions.setActiveEpgProgram({ program: event.program }) + ); + } + /** * Opens a playlist provided as a url param * e.g. iptvnat.or?url=http://... @@ -944,3 +1044,21 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }); } } + +function findCurrentEpgProgram( + programs: EpgProgram[], + nowMs: number +): EpgProgram | undefined { + return programs.find((program) => { + const start = epgTimeMs(program.start, program.startTimestamp); + const stop = epgTimeMs(program.stop, program.stopTimestamp); + return nowMs >= start && nowMs <= stop; + }); +} + +function epgTimeMs(isoValue: string, timestamp?: number | null): number { + if (Number.isFinite(timestamp) && Number(timestamp) > 0) { + return Number(timestamp) * 1000; + } + return Date.parse(isoValue); +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 1f2e5e557..5f42b7ef3 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -72,59 +72,27 @@ @if (!isRadioSelection() && supportsEpg) {
- @if (shouldUseInlinePlayer()) { - + -
- @if (isM3uSelection()) { - - } @else { - - } -
-
- } @else { -
- @if (isM3uSelection()) { - - } @else { - - } -
- } + [summary]="liveEpgPanelSummary()" + (selectedDateChange)="onLiveEpgSelectedDateChange($event)" + (collapsedChange)="onLiveEpgPanelCollapsedChange($event)" + /> +
} } @else { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts index 95b651e1c..a053e3579 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts @@ -17,8 +17,7 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { - EpgDateNavigationDirection, - EpgListComponent, + EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, } from '@iptvnator/ui/epg'; @@ -28,17 +27,11 @@ import { SettingsStore, } from '@iptvnator/services'; import { - Channel, EpgItem, EpgProgram, ResolvedPortalPlayback, VideoPlayer, } from '@iptvnator/shared/interfaces'; -import { - EpgViewComponent, - LiveEpgPanelComponent, - LiveEpgPanelSummary, -} from '@iptvnator/ui/shared-portals'; import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, PORTAL_PLAYER, @@ -86,41 +79,24 @@ class StubGlobalFavoritesListComponent { } @Component({ - selector: 'app-epg-list', - template: '
', + selector: 'app-epg-timeline', + template: '
', changeDetection: ChangeDetectionStrategy.OnPush, }) -class StubEpgListComponent { - readonly controlledChannel = input(null); - readonly controlledPrograms = input(null); - readonly archivePlaybackAvailable = input(null); +class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); readonly selectedDate = input(null); - readonly showDateNavigator = input(true); - readonly selectedDateChange = output(); -} - -@Component({ - selector: 'app-epg-view', - template: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubEpgViewComponent { - readonly epgItems = input([]); -} - -@Component({ - selector: 'app-live-epg-panel', - template: '
', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class StubLiveEpgPanelComponent { readonly collapsed = input(false); - readonly summary = input(null); - readonly loading = input(false); - readonly showDateNavigator = input(false); - readonly selectedDate = input(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly summary = input(null); + readonly summaryLabelKey = input(''); + readonly selectedDateChange = output(); readonly collapsedChange = output(); - readonly dateNavigation = output(); + readonly programActivated = output(); } @Component({ @@ -217,10 +193,8 @@ describe('UnifiedLiveTabComponent', () => { remove: { imports: [ AudioPlayerComponent, - EpgListComponent, - EpgViewComponent, + EpgTimelineComponent, GlobalFavoritesListComponent, - LiveEpgPanelComponent, ResizableDirective, WebPlayerViewComponent, ], @@ -228,10 +202,8 @@ describe('UnifiedLiveTabComponent', () => { add: { imports: [ StubAudioPlayerComponent, - StubEpgListComponent, - StubEpgViewComponent, + StubEpgTimelineComponent, StubGlobalFavoritesListComponent, - StubLiveEpgPanelComponent, StubResizableDirective, StubWebPlayerViewComponent, ], @@ -298,10 +270,11 @@ describe('UnifiedLiveTabComponent', () => { id: 'm3u-channel', }) ); - expect( - fixture.nativeElement.querySelector('app-epg-list') - ).not.toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; + expect(timeline.programs()).toEqual([buildProgram('M3U Show')]); + expect(timeline.archivePlaybackAvailable()).toBe(false); }); it('skips EPG loading and hides the EPG panel in browser/PWA playback', async () => { @@ -358,8 +331,9 @@ describe('UnifiedLiveTabComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); }); it('passes recent mode and favorite state to the shared live collection list', async () => { @@ -411,7 +385,7 @@ describe('UnifiedLiveTabComponent', () => { subscription.unsubscribe(); }); - it('wraps inline M3U EPG in the live panel with shared date navigation', async () => { + it('renders inline M3U EPG in the timeline with shared date navigation', async () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(true); const item = buildLiveItem('m3u'); const currentProgram = buildCurrentProgram('M3U Now'); @@ -455,29 +429,25 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - const panel = fixture.debugElement.query( - By.directive(StubLiveEpgPanelComponent) - ).componentInstance as StubLiveEpgPanelComponent; - const epgList = fixture.debugElement.query( - By.directive(StubEpgListComponent) - ).componentInstance as StubEpgListComponent; + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; - expect(panel.showDateNavigator()).toBe(true); - expect(panel.summary()).toEqual( + expect(timeline.summary()).toEqual( expect.objectContaining({ title: 'M3U Now' }) ); - expect(epgList.showDateNavigator()).toBe(false); - expect(epgList.selectedDate()).toBe(getTodayEpgDateKey()); - - panel.dateNavigation.emit('next'); - fixture.detectChanges(); + expect(timeline.programs()).toEqual([currentProgram]); + expect(timeline.selectedDate()).toBe(getTodayEpgDateKey()); const nextDate = shiftEpgDateKey(getTodayEpgDateKey(), 'next'); + timeline.selectedDateChange.emit(nextDate); + fixture.detectChanges(); + expect(component.selectedLiveEpgDate()).toBe(nextDate); - expect(epgList.selectedDate()).toBe(nextDate); + expect(timeline.selectedDate()).toBe(nextDate); }); - it('wraps inline portal EPG in the live panel without date navigation', async () => { + it('renders inline portal EPG in the timeline and flows collapse state through', async () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(true); const item = buildLiveItem('xtream'); streamResolver.resolveLiveDetail.mockResolvedValue({ @@ -501,23 +471,25 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - const panel = fixture.debugElement.query( - By.directive(StubLiveEpgPanelComponent) - ).componentInstance as StubLiveEpgPanelComponent; + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; - expect(panel.showDateNavigator()).toBe(false); - expect(panel.summary()).toEqual( + expect(timeline.summary()).toEqual( expect.objectContaining({ title: 'Xtream Now' }) ); - expect( - fixture.nativeElement.querySelector('app-epg-view') - ).not.toBeNull(); + expect(timeline.programs()).toEqual([ + expect.objectContaining({ title: 'Xtream Now' }), + ]); + expect(timeline.archivePlaybackAvailable()).toBe(false); + expect(timeline.collapsed()).toBe(false); - panel.collapsedChange.emit(true); + timeline.collapsedChange.emit(true); fixture.detectChanges(); expect(component.isLiveEpgPanelCollapsed()).toBe(true); expect(localStorage.getItem('live-epg-panel-state')).toBe('collapsed'); + expect(timeline.collapsed()).toBe(true); }); it('uses the shared web player wrapper for inline live playback diagnostics', async () => { @@ -700,8 +672,9 @@ describe('UnifiedLiveTabComponent', () => { expect( fixture.nativeElement.querySelector('app-audio-player') ).not.toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); }); it('renders inline audio for Stalker radio items and skips external playback', async () => { @@ -754,8 +727,9 @@ describe('UnifiedLiveTabComponent', () => { expect( fixture.nativeElement.querySelector('app-audio-player') ).not.toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); const audioPlayer = fixture.debugElement.query( By.directive(StubAudioPlayerComponent) @@ -789,10 +763,13 @@ describe('UnifiedLiveTabComponent', () => { await fixture.whenStable(); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); - expect( - fixture.nativeElement.querySelector('app-epg-view') - ).not.toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; + expect(timeline.programs()).toEqual([ + expect.objectContaining({ title: 'Xtream Show' }), + ]); + expect(timeline.archivePlaybackAvailable()).toBe(false); }); it('renders shared EPG view for Stalker items and records recent history', async () => { @@ -819,9 +796,12 @@ describe('UnifiedLiveTabComponent', () => { await fixture.whenStable(); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); - expect( - fixture.nativeElement.querySelector('app-epg-view') - ).not.toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; + expect(timeline.programs()).toEqual([ + expect.objectContaining({ title: 'Stalker Show' }), + ]); }); it('does not restart auto-open while the same live item is still resolving', async () => { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 5605f1292..005f59f05 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -13,7 +13,10 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslatePipe } from '@ngx-translate/core'; -import { isM3uCatchupPlaybackSupported } from '@iptvnator/shared/m3u-utils'; +import { + getM3uArchiveDays, + isM3uCatchupPlaybackSupported, +} from '@iptvnator/shared/m3u-utils'; import { DEFAULT_FAVORITES_CHANNEL_SORT_MODE, FavoritesChannelSortMode, @@ -33,7 +36,7 @@ import { } from '@iptvnator/portal/shared/data-access'; import { EpgDateNavigationDirection, - EpgListComponent, + EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, } from '@iptvnator/ui/epg'; @@ -47,11 +50,7 @@ import { import { ResizableDirective } from '@iptvnator/ui/components'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; -import { - EpgViewComponent, - LiveEpgPanelComponent, - LiveEpgPanelSummary, -} from '@iptvnator/ui/shared-portals'; +import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; @Component({ selector: 'app-unified-live-tab', @@ -60,10 +59,8 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ AudioPlayerComponent, - EpgListComponent, - EpgViewComponent, + EpgTimelineComponent, GlobalFavoritesListComponent, - LiveEpgPanelComponent, MatButtonModule, MatIconModule, MatProgressSpinnerModule, @@ -144,6 +141,43 @@ export class UnifiedLiveTabComponent { readonly currentM3uArchivePlaybackAvailable = computed(() => isM3uCatchupPlaybackSupported(this.currentM3uChannel()) ); + /** Portal EPG items normalised to the timeline programme shape. */ + readonly currentPortalEpgPrograms = computed(() => + this.currentPortalEpgItems().map((item) => toEpgProgram(item)) + ); + readonly timelinePrograms = computed(() => + this.isM3uSelection() + ? this.currentM3uPrograms() + : this.currentPortalEpgPrograms() + ); + readonly timelineChannelName = computed( + () => + this.currentM3uChannel()?.name ?? + this.activeDetail()?.playback?.title ?? + '' + ); + readonly timelineChannelLogo = computed( + () => + this.currentM3uChannel()?.tvg?.logo ?? + this.activeDetail()?.playback?.thumbnail ?? + '' + ); + readonly timelineArchiveAvailable = computed( + () => + this.isM3uSelection() && this.currentM3uArchivePlaybackAvailable() + ); + /** + * Catch-up window (days) for the active M3U channel, so the timeline can + * gate "Watch" to programmes inside it. Without this the timeline defaults + * `archiveDays` to 0 (treated as unlimited) and offers catch-up on + * programmes older than the channel's real `catchup-days`/`timeshift`/ + * `tvg-rec` window. 0 for portal selections (archive is M3U-only here). + */ + readonly timelineArchiveDays = computed(() => + this.timelineArchiveAvailable() + ? getM3uArchiveDays(this.currentM3uChannel()) + : 0 + ); readonly activeRadioChannel = computed(() => { const channel = this.activeDetail()?.channel ?? null; return channel?.radio === 'true' ? channel : null; @@ -539,3 +573,22 @@ export class UnifiedLiveTabComponent { return Number.isFinite(parsedDate) ? parsedDate : null; } } + +/** Normalise a portal EPG item into the flat timeline programme shape. */ +function toEpgProgram(item: EpgItem): EpgProgram { + return { + start: item.start, + stop: item.stop ?? item.end, + channel: item.channel_id ?? item.id, + title: item.title, + desc: item.description ?? null, + category: null, + startTimestamp: toTimestampSeconds(item.start_timestamp), + stopTimestamp: toTimestampSeconds(item.stop_timestamp), + }; +} + +function toTimestampSeconds(value: string | null | undefined): number | null { + const parsed = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index a48315165..9736602cd 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -140,37 +140,12 @@ @let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer;
- @if (canCollapseEpgPanel) { - - - - } @else { - - } +
} } @else { @@ -181,25 +156,26 @@ } - +
- @if (isLoadingEpg()) { -
- -
- } @else { - - } +
diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts index 8fdb120df..84d31d973 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts @@ -10,7 +10,7 @@ import { ResizableDirective, } from '@iptvnator/portal/shared/util'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; -import { EpgListComponent } from '@iptvnator/ui/epg'; +import { EpgTimelineComponent } from '@iptvnator/ui/epg'; import { AudioPlayerComponent } from '@iptvnator/ui/playback'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ChannelListItemComponent } from '@iptvnator/ui/components'; @@ -23,10 +23,6 @@ import { } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; -import { - LiveEpgPanelComponent, - LiveEpgPanelSummary, -} from '@iptvnator/ui/shared-portals'; import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; @Component({ @@ -74,36 +70,33 @@ class StubAudioPlayerComponent { } @Component({ - selector: 'app-epg-list', - standalone: true, - template: '', -}) -class StubEpgListComponent { - readonly controlledChannel = input(null); - readonly controlledPrograms = input(null); - readonly controlledArchiveDays = input(null); - readonly archivePlaybackAvailable = input(null); - readonly selectedDate = input(null); - readonly showDateNavigator = input(true); - readonly selectedDateChange = output(); -} - -@Component({ - selector: 'app-live-epg-panel', + selector: 'app-epg-timeline', standalone: true, template: `
{{ summary()?.title }}
- `, }) -class StubLiveEpgPanelComponent { - readonly collapsed = input(false); - readonly summary = input(null); +class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly sourceLabel = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(false); readonly loading = input(false); - readonly showDateNavigator = input(false); + readonly emptyReason = input('none'); readonly selectedDate = input(null); + readonly collapsed = input(false); + readonly summary = input<{ title?: string } | null>(null); + readonly summaryLabelKey = input(''); + readonly selectedDateChange = output(); + readonly programActivated = output(); + readonly returnToLive = output(); + readonly openEpgSettings = output(); + readonly retry = output(); readonly collapsedChange = output(); - readonly dateNavigation = output<'next' | 'prev'>(); } @Component({ @@ -348,8 +341,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ ChannelListItemComponent, AudioPlayerComponent, - EpgListComponent, - LiveEpgPanelComponent, + EpgTimelineComponent, PortalEmptyStateComponent, ResizableDirective, TranslatePipe, @@ -360,8 +352,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { imports: [ StubChannelListItemComponent, StubAudioPlayerComponent, - StubEpgListComponent, - StubLiveEpgPanelComponent, + StubEpgTimelineComponent, StubPortalEmptyStateComponent, StubResizableDirective, MockPipe( @@ -388,7 +379,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector('app-epg-list') + fixture.nativeElement.querySelector('app-epg-timeline') ).not.toBeNull(); expect( fixture.nativeElement.querySelector('.load-more-epg') @@ -412,10 +403,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); - expect( - fixture.nativeElement.querySelector('app-live-epg-panel') - ).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); }); it('restores the collapsed live EPG panel state after embedded playback starts', async () => { @@ -434,6 +422,11 @@ describe('StalkerLiveStreamLayoutComponent', () => { .querySelector('.epg') .classList.contains('epg-collapsed') ).toBe(true); + + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; + expect(timeline.collapsed()).toBe(true); }); it('persists live EPG panel toggle changes', () => { @@ -451,16 +444,17 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); }); - it('does not render the collapsible panel for external playback', async () => { + it('does not collapse the timeline for external playback', async () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(false); await component.playChannel(itvChannels()[0]); await fixture.whenStable(); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('app-live-epg-panel') - ).toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; + expect(timeline.collapsed()).toBe(false); expect( fixture.nativeElement .querySelector('.epg') @@ -564,7 +558,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); }); - it('renders the current EPG program in the collapsible panel summary', async () => { + it('provides the current EPG program to the timeline collapsed summary', async () => { fixture.detectChanges(); await fixture.whenStable(); @@ -572,10 +566,10 @@ describe('StalkerLiveStreamLayoutComponent', () => { await fixture.whenStable(); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.live-epg-panel-summary') - .textContent - ).toContain('Current Show'); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ).componentInstance as StubEpgTimelineComponent; + expect(timeline.summary()?.title).toBe('Current Show'); }); it('does not reset live channels when loading the next lazy page', async () => { @@ -649,7 +643,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(ensureBulkItvEpg).not.toHaveBeenCalled(); expect(fetchChannelEpg).not.toHaveBeenCalled(); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); expect( fixture.nativeElement.querySelector('app-audio-player') ).not.toBeNull(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index 713b8a6c2..4eba2e00e 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -39,7 +39,7 @@ import { } from '@iptvnator/shared/interfaces'; import { EpgDateNavigationDirection, - EpgListComponent, + EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, } from '@iptvnator/ui/epg'; @@ -48,10 +48,7 @@ import { type PlaybackFallbackRequest, WebPlayerViewComponent, } from '@iptvnator/ui/playback'; -import { - LiveEpgPanelComponent, - LiveEpgPanelSummary, -} from '@iptvnator/ui/shared-portals'; +import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; import { LiveLayoutSidebarStateService, PORTAL_PLAYER, @@ -84,8 +81,7 @@ type StalkerPlayableChannel = StalkerPortalItem & { AudioPlayerComponent, ChannelListItemComponent, ChannelListSkeletonComponent, - EpgListComponent, - LiveEpgPanelComponent, + EpgTimelineComponent, MatButtonModule, MatIconModule, MatProgressSpinnerModule, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 7819c4f66..16b343dc8 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -108,40 +108,12 @@ @if (supportsEpg) {
- @if (isEmbeddedPlayer) { - - - - } @else { - - } +
} } @else if (!showLiveChannelSidebar()) { @@ -198,49 +170,26 @@ } - +
- @if (isLoadingEpg()) { -
- -
- } @else if (isElectron && controlledEpgPrograms().length > 0) { - @if (showArchiveUnavailableNotice()) { -
-
- {{ - 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' | translate - }} -
-
- {{ - 'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION' - | translate - }} -
-
- } - - } @else if (!isElectron && epgItems().length > 0) { - - } @else { - - } +
diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index 6f89a2452..4abc1b0be 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -23,13 +23,12 @@ import { XtreamStore, XtreamUrlService, } from '@iptvnator/portal/xtream/data-access'; -import { EpgListComponent, EpgProgramActivationEvent } from '@iptvnator/ui/epg'; -import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; import { - EpgViewComponent, - LiveEpgPanelComponent, - LiveEpgPanelSummary, -} from '@iptvnator/ui/shared-portals'; + EpgProgramActivationEvent, + EpgTimelineComponent, + EpgTimelineSummary, +} from '@iptvnator/ui/epg'; +import { WebPlayerViewComponent } from '@iptvnator/ui/playback'; import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces'; import { GridListComponent } from '@iptvnator/portal/shared/ui'; import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component'; @@ -85,58 +84,31 @@ class StubWebPlayerViewComponent { } @Component({ - selector: 'app-epg-view', - standalone: true, - template: '', -}) -class StubEpgViewComponent { - readonly epgItems = input([]); -} - -@Component({ - selector: 'app-epg-list', - standalone: true, - template: '', -}) -class StubEpgListComponent { - readonly controlledPrograms = input(null); - readonly controlledArchiveDays = input(null); - readonly archivePlaybackAvailable = input(null); - readonly activeProgram = input(null); - readonly selectedDate = input(null); - readonly showDateNavigator = input(true); - readonly programActivated = output(); - readonly selectedDateChange = output(); -} - -@Component({ - selector: 'app-live-epg-panel', + selector: 'app-epg-timeline', standalone: true, template: `
{{ summaryLabelKey() }}
{{ summary()?.title }}
- - `, }) -class StubLiveEpgPanelComponent { - readonly collapsed = input(false); - readonly summary = input(null); +class StubEpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly sourceLabel = input(''); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(true); readonly loading = input(false); - readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM'); - readonly showDateNavigator = input(false); readonly selectedDate = input(null); - readonly showReturnToLive = input(false); - readonly collapsedChange = output(); - readonly dateNavigation = output<'next' | 'prev'>(); + readonly collapsed = input(false); + readonly summary = input(null); + readonly summaryLabelKey = input(''); + readonly programActivated = output(); readonly returnToLive = output(); + readonly selectedDateChange = output(); + readonly collapsedChange = output(); } @Directive({ @@ -329,9 +301,7 @@ describe('LiveStreamLayoutComponent', () => { .overrideComponent(LiveStreamLayoutComponent, { remove: { imports: [ - EpgListComponent, - EpgViewComponent, - LiveEpgPanelComponent, + EpgTimelineComponent, GridListComponent, PortalChannelsListComponent, ResizableDirective, @@ -341,9 +311,7 @@ describe('LiveStreamLayoutComponent', () => { }, add: { imports: [ - StubEpgListComponent, - StubEpgViewComponent, - StubLiveEpgPanelComponent, + StubEpgTimelineComponent, StubGridListComponent, StubPortalChannelsListComponent, StubResizableDirective, @@ -387,9 +355,8 @@ describe('LiveStreamLayoutComponent', () => { fixture.detectChanges(); expect( - fixture.nativeElement.querySelector('app-epg-list') + fixture.nativeElement.querySelector('app-epg-timeline') ).not.toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); it('hides the EPG panel in browser/PWA playback', () => { @@ -407,10 +374,8 @@ describe('LiveStreamLayoutComponent', () => { ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); expect( - fixture.nativeElement.querySelector('app-live-epg-panel') + fixture.nativeElement.querySelector('app-epg-timeline') ).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); it('restores the collapsed live EPG panel state for embedded playback', () => { @@ -423,6 +388,10 @@ describe('LiveStreamLayoutComponent', () => { fixture.detectChanges(); expect(component.isLiveEpgPanelCollapsed()).toBe(true); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.collapsed()).toBe(true); expect( fixture.nativeElement .querySelector('.epg') @@ -430,30 +399,39 @@ describe('LiveStreamLayoutComponent', () => { ).toBe(true); }); - it('persists live EPG panel toggle changes', () => { - component.onLiveEpgPanelCollapsedChange(true); + it('persists live EPG panel toggle changes from the timeline', () => { + component.playLive(sampleChannel); + fixture.detectChanges(); + + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + timeline.componentInstance.collapsedChange.emit(true); expect(component.isLiveEpgPanelCollapsed()).toBe(true); expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( 'collapsed' ); - component.onLiveEpgPanelCollapsedChange(false); + timeline.componentInstance.collapsedChange.emit(false); + expect(component.isLiveEpgPanelCollapsed()).toBe(false); expect(localStorage.getItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY)).toBe( 'expanded' ); }); - it('does not render the collapsible panel for external playback', () => { + it('keeps the EPG timeline expanded for external playback', () => { portalPlayer.isEmbeddedPlayer.mockReturnValue(false); component.playLive(sampleChannel); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('app-live-epg-panel') - ).toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline).not.toBeNull(); + expect(timeline.componentInstance.collapsed()).toBe(false); expect( fixture.nativeElement .querySelector('.epg') @@ -821,20 +799,20 @@ describe('LiveStreamLayoutComponent', () => { }); fixture.detectChanges(); - const panel = fixture.debugElement.query( - By.directive(StubLiveEpgPanelComponent) + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) ); - expect(panel.componentInstance.summary()).toEqual( + expect(timeline.componentInstance.summary()).toEqual( expect.objectContaining({ title: 'Archived Show', start: '2026-04-04T10:00:00.000Z', stop: '2026-04-04T11:00:00.000Z', }) ); - expect(panel.componentInstance.summaryLabelKey()).toBe( + expect(timeline.componentInstance.summaryLabelKey()).toBe( 'EPG.ARCHIVE_PLAYBACK' ); - expect(panel.componentInstance.showReturnToLive()).toBe(true); + expect(timeline.componentInstance.isLivePlayback()).toBe(false); expect( fixture.nativeElement.querySelector('.live-epg-panel-summary') .textContent @@ -872,10 +850,10 @@ describe('LiveStreamLayoutComponent', () => { }); fixture.detectChanges(); - const panel = fixture.debugElement.query( - By.directive(StubLiveEpgPanelComponent) + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) ); - panel.componentInstance.returnToLive.emit(); + timeline.componentInstance.returnToLive.emit(); fixture.detectChanges(); expect(component.activeCatchupProgram()).toBeNull(); @@ -885,11 +863,11 @@ describe('LiveStreamLayoutComponent', () => { isLive: true, }) ); - expect(panel.componentInstance.summary()?.title).toBe('Current Show'); - expect(panel.componentInstance.summaryLabelKey()).toBe( + expect(timeline.componentInstance.summary()?.title).toBe('Current Show'); + expect(timeline.componentInstance.summaryLabelKey()).toBe( 'EPG.CURRENT_PROGRAM' ); - expect(panel.componentInstance.showReturnToLive()).toBe(false); + expect(timeline.componentInstance.isLivePlayback()).toBe(true); }); it('publishes the active archive program in remote-control status', async () => { @@ -959,18 +937,20 @@ describe('LiveStreamLayoutComponent', () => { }); fixture.detectChanges(); - let epgList = fixture.debugElement.query( - By.directive(StubEpgListComponent) + let epgTimeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) ); - expect(epgList.componentInstance.activeProgram()).toEqual( + expect(epgTimeline.componentInstance.activeProgram()).toEqual( archivedProgram ); component.playLive(sampleChannel); fixture.detectChanges(); - epgList = fixture.debugElement.query(By.directive(StubEpgListComponent)); - expect(epgList.componentInstance.activeProgram()).toBeNull(); + epgTimeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(epgTimeline.componentInstance.activeProgram()).toBeNull(); }); it('starts external playback from remote channel navigation when double-click opening is enabled', () => { @@ -1025,9 +1005,12 @@ describe('LiveStreamLayoutComponent', () => { component.playLive(nonArchiveChannel); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.archive-unavailable-banner') - ).not.toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.archivePlaybackAvailable()).toBe( + false + ); }); it('hides the archive-unavailable notice when archive playback is available', () => { @@ -1050,9 +1033,10 @@ describe('LiveStreamLayoutComponent', () => { component.playLive(sampleChannel); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.archive-unavailable-banner') - ).toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true); }); it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => { @@ -1221,9 +1205,12 @@ describe('LiveStreamLayoutComponent', () => { component.playLive(nonArchiveChannel); fixture.detectChanges(); - expect( - fixture.nativeElement.querySelector('.archive-unavailable-banner') - ).toBeNull(); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.archivePlaybackAvailable()).toBe( + false + ); }); }); diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index a28d14b54..22ba83a40 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -49,8 +49,8 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { EpgDateNavigationDirection, - EpgListComponent, EpgProgramActivationEvent, + EpgTimelineComponent, getTodayEpgDateKey, shiftEpgDateKey, } from '@iptvnator/ui/epg'; @@ -59,11 +59,7 @@ import { type PlaybackFallbackRequest, WebPlayerViewComponent, } from '@iptvnator/ui/playback'; -import { - EpgViewComponent, - LiveEpgPanelComponent, - LiveEpgPanelSummary, -} from '@iptvnator/ui/shared-portals'; +import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; import { EpgItem, EpgProgram, @@ -95,9 +91,7 @@ interface XtreamLiveChannelItem { styleUrls: ['./live-stream-layout.component.scss'], providers: [LiveStreamAutoOpenStateService], imports: [ - EpgListComponent, - EpgViewComponent, - LiveEpgPanelComponent, + EpgTimelineComponent, MatButtonModule, MatIcon, MatMenuModule, diff --git a/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts index 19c9961df..676c415d0 100644 --- a/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts +++ b/libs/shared/m3u-utils/src/lib/catchup.utils.spec.ts @@ -66,6 +66,20 @@ describe('catchup.utils', () => { ).toBe(7); }); + it('skips a blank tvg-rec and uses a real timeshift value', () => { + // `tvg-rec=""` is a common parser default; a nullish (`??`) chain would + // stop on it and yield 0 (unbounded window). The non-blank helper skips + // it and returns the real timeshift days. + expect( + getM3uArchiveDays({ + ...baseChannel, + catchup: undefined, + timeshift: '7', + tvg: { ...baseChannel.tvg, rec: '' }, + }) + ).toBe(7); + }); + it('supports legacy same-stream shift playback when catchup type is shift', () => { expect(isM3uCatchupPlaybackSupported(baseChannel)).toBe(true); expect(resolveM3uCatchupUrl(baseChannel, archivedProgram, 1_775_820_000)) diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts index 2a7dcca62..4c840a4fb 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.ts @@ -26,20 +26,13 @@ import { sortPlaylistChannelItems, } from '../channel-list-sort.util'; import { resolveChannelLogo } from '../channel-logo-fallback.util'; +import { buildChannelEpgMetadataMap } from '../epg-enrichment.util'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode'; -/** - * Per-channel EPG metadata stored in a side-car map keyed by EPG lookup key. - * Replaces the older EnrichedChannel pattern that spread-cloned every channel - * on every progressTick (~30 s). - */ -export interface ChannelEpgMetadata { - epgProgram: EpgProgram | null | undefined; - progressPercentage: number; -} +export type { ChannelEpgMetadata } from '../epg-enrichment.util'; @Component({ selector: 'app-all-channels-view', @@ -137,18 +130,9 @@ export class AllChannelsViewComponent { * ~90K objects per tick on large M3U playlists. */ readonly epgMetadataMap = computed(() => { - const epgMap = this.channelEpgMap(); - // Read progressTick to create dependency for progress refresh + // Read progressTick to create a dependency for the ~30s progress refresh. this.progressTick(); - - const result = new Map(); - epgMap.forEach((program, channelId) => { - result.set(channelId, { - epgProgram: program, - progressPercentage: this.calculateProgress(program), - }); - }); - return result; + return buildChannelEpgMetadataMap(this.channelEpgMap()); }); /** Resolves the EPG lookup key the side-car map is keyed by. */ @@ -165,38 +149,6 @@ export class AllChannelsViewComponent { return resolveChannelLogo(channel, this.channelIconMap()); } - /** - * Calculates progress percentage for an EPG program - */ - private calculateProgress( - epgProgram: EpgProgram | null | undefined - ): number { - if (!epgProgram) { - return 0; - } - - const now = Date.now(); - const start = new Date(epgProgram.start).getTime(); - const stop = new Date(epgProgram.stop).getTime(); - - // Validate start/stop are finite numbers - if (!Number.isFinite(start) || !Number.isFinite(stop)) { - return 0; - } - - const total = stop - start; - - // Bail out if duration is zero or negative - if (total <= 0) { - return 0; - } - - // Clamp elapsed to [0, total] - const elapsed = Math.min(total, Math.max(0, now - start)); - - return Math.round((elapsed / total) * 100); - } - trackByFn(_: number, channel: Channel): string { return channel?.id; } diff --git a/libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.spec.ts b/libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.spec.ts new file mode 100644 index 000000000..f5a7ad2d0 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.spec.ts @@ -0,0 +1,90 @@ +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; +import { + buildChannelEpgMetadataMap, + calculateEpgProgress, + resolveChannelEpgProgram, +} from './epg-enrichment.util'; + +const BASE = Date.UTC(2026, 0, 1, 12, 0, 0); // 12:00 +const NOW = Date.UTC(2026, 0, 1, 12, 30, 0); // 12:30 + +function program(startOffsetMin: number, durationMin: number): EpgProgram { + const start = BASE + startOffsetMin * 60_000; + return { + start: new Date(start).toISOString(), + stop: new Date(start + durationMin * 60_000).toISOString(), + channel: 'ch', + title: 'P', + desc: null, + category: null, + }; +} + +describe('epg-enrichment.util', () => { + describe('calculateEpgProgress', () => { + it('returns 0 for a missing programme', () => { + expect(calculateEpgProgress(null, NOW)).toBe(0); + expect(calculateEpgProgress(undefined, NOW)).toBe(0); + }); + + it('returns a rounded mid-programme percentage', () => { + // 12:00–13:00, now 12:30 → 50% + expect(calculateEpgProgress(program(0, 60), NOW)).toBe(50); + // 12:00–13:30, now 12:30 → 33.33 → 33 (integer) + const v = calculateEpgProgress(program(0, 90), NOW); + expect(v).toBe(33); + expect(Number.isInteger(v)).toBe(true); + }); + + it('clamps to [0, 100] for future and finished programmes', () => { + expect(calculateEpgProgress(program(60, 60), NOW)).toBe(0); // 13:00–14:00 + expect(calculateEpgProgress(program(-120, 60), NOW)).toBe(100); // 10:00–11:00 + }); + + it('never returns NaN for zero-length or invalid timestamps', () => { + expect(calculateEpgProgress(program(0, 0), NOW)).toBe(0); + expect( + calculateEpgProgress( + { ...program(0, 60), start: 'nope', stop: 'nope' }, + NOW + ) + ).toBe(0); + }); + }); + + describe('resolveChannelEpgProgram', () => { + const channel = { + name: 'My Channel', + tvg: { id: '', name: '' }, + } as unknown as Channel; + + it('returns the programme for the channel lookup key', () => { + const p = program(0, 60); + const map = new Map([['My Channel', p]]); + expect(resolveChannelEpgProgram(channel, map)).toBe(p); + }); + + it('returns null when the channel has no map entry', () => { + expect(resolveChannelEpgProgram(channel, new Map())).toBeNull(); + }); + }); + + describe('buildChannelEpgMetadataMap', () => { + it('maps each entry to {epgProgram, progressPercentage}', () => { + const p = program(0, 60); + const map = new Map([ + ['a', p], + ['b', null], + ]); + const result = buildChannelEpgMetadataMap(map, NOW); + expect(result.get('a')).toEqual({ + epgProgram: p, + progressPercentage: 50, + }); + expect(result.get('b')).toEqual({ + epgProgram: null, + progressPercentage: 0, + }); + }); + }); +}); diff --git a/libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.ts b/libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.ts new file mode 100644 index 000000000..52520c2df --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/epg-enrichment.util.ts @@ -0,0 +1,73 @@ +import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; +import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; + +/** + * Per-channel EPG metadata stored in a side-car map keyed by EPG lookup key. + * Replaces the older EnrichedChannel pattern that spread-cloned every channel + * on every progressTick (~30 s). + */ +export interface ChannelEpgMetadata { + epgProgram: EpgProgram | null | undefined; + progressPercentage: number; +} + +/** + * Current-programme progress as a clamped, rounded percentage in [0, 100]. + * Guards against missing/invalid timestamps and zero-length programmes so a bad + * EPG row can never produce `NaN` or a runaway value. + */ +export function calculateEpgProgress( + epgProgram: EpgProgram | null | undefined, + now: number = Date.now() +): number { + if (!epgProgram) { + return 0; + } + + const start = new Date(epgProgram.start).getTime(); + const stop = new Date(epgProgram.stop).getTime(); + + if (!Number.isFinite(start) || !Number.isFinite(stop)) { + return 0; + } + + const total = stop - start; + if (total <= 0) { + return 0; + } + + const elapsed = Math.min(total, Math.max(0, now - start)); + return Math.round((elapsed / total) * 100); +} + +/** + * Resolves the current programme for a channel from the shared side-car EPG map + * (keyed by the channel's EPG lookup key). Returns `null` when the channel has + * no usable lookup key or no entry in the map. + */ +export function resolveChannelEpgProgram( + channel: Channel, + channelEpgMap: Map +): EpgProgram | null { + const key = resolveChannelEpgLookupKey(channel); + return key ? (channelEpgMap.get(key) ?? null) : null; +} + +/** + * Builds the side-car metadata map (lookup key → {programme, progress}) from the + * shared `channelEpgMap`. Callers should read their `progressTick()` signal + * first so the surrounding computed re-runs every tick. + */ +export function buildChannelEpgMetadataMap( + channelEpgMap: Map, + now: number = Date.now() +): Map { + const result = new Map(); + channelEpgMap.forEach((program, channelId) => { + result.set(channelId, { + epgProgram: program, + progressPercentage: calculateEpgProgress(program, now), + }); + }); + return result; +} diff --git a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts index 69c2f5059..fab2c9f9b 100644 --- a/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/favorites-view/favorites-view.component.ts @@ -19,7 +19,7 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { TranslatePipe } from '@ngx-translate/core'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; -import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component'; +import { buildChannelEpgMetadataMap } from '../epg-enrichment.util'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { resolveChannelLogo } from '../channel-logo-fallback.util'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; @@ -102,17 +102,9 @@ export class FavoritesViewComponent { * data. Replaces the previous spread-clone-every-channel pattern. */ readonly epgMetadataMap = computed(() => { - const epgMap = this.channelEpgMap(); + // Read progressTick to create a dependency for the ~30s progress refresh. this.progressTick(); - - const result = new Map(); - epgMap.forEach((program, channelId) => { - result.set(channelId, { - epgProgram: program, - progressPercentage: this.calculateProgress(program), - }); - }); - return result; + return buildChannelEpgMetadataMap(this.channelEpgMap()); }); /** Resolves the EPG lookup key the side-car map is keyed by. */ @@ -125,26 +117,6 @@ export class FavoritesViewComponent { return resolveChannelLogo(channel, this.channelIconMap()); } - /** - * Calculates progress percentage for an EPG program - */ - private calculateProgress( - epgProgram: EpgProgram | null | undefined - ): number { - if (!epgProgram) { - return 0; - } - - const now = new Date().getTime(); - const start = new Date(epgProgram.start).getTime(); - const stop = new Date(epgProgram.stop).getTime(); - - const total = stop - start; - const elapsed = now - start; - - return Math.min(100, Math.max(0, (elapsed / total) * 100)); - } - trackByFn(_: number, channel: Channel): string { return channel?.id; } diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts index 53cfbf5f0..87201268f 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.ts @@ -21,7 +21,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; -import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component'; +import { buildChannelEpgMetadataMap } from '../epg-enrichment.util'; import { PlaylistChannelSortMode, getPlaylistChannelSortModeLabel, @@ -345,17 +345,9 @@ export class GroupsViewComponent { * spread-clone-every-channel pattern. */ readonly epgMetadataMap = computed(() => { - const epgMap = this.channelEpgMap(); + // Read progressTick to create a dependency for the ~30s progress refresh. this.progressTick(); - - const result = new Map(); - epgMap.forEach((program, channelId) => { - result.set(channelId, { - epgProgram: program, - progressPercentage: this.calculateProgress(program), - }); - }); - return result; + return buildChannelEpgMetadataMap(this.channelEpgMap()); }); /** Resolves the EPG lookup key the side-car map is keyed by. */ @@ -537,30 +529,6 @@ export class GroupsViewComponent { return a.key.localeCompare(b.key); }; - private calculateProgress( - epgProgram: EpgProgram | null | undefined - ): number { - if (!epgProgram) { - return 0; - } - - const now = Date.now(); - const start = new Date(epgProgram.start).getTime(); - const stop = new Date(epgProgram.stop).getTime(); - - if (!Number.isFinite(start) || !Number.isFinite(stop)) { - return 0; - } - - const total = stop - start; - if (total <= 0) { - return 0; - } - - const elapsed = Math.min(total, Math.max(0, now - start)); - return Math.round((elapsed / total) * 100); - } - private emitSidebarWidthRequest( navWidth: number, emitter: OutputEmitterRef diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts index b0c304ceb..d93985528 100644 --- a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -12,10 +12,13 @@ import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; import { TranslatePipe } from '@ngx-translate/core'; -import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state'; import { Channel, EpgProgram } from '@iptvnator/shared/interfaces'; import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component'; import { resolveChannelLogo } from '../channel-logo-fallback.util'; +import { + calculateEpgProgress, + resolveChannelEpgProgram, +} from '../epg-enrichment.util'; import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; export interface RecentViewItem { @@ -82,35 +85,18 @@ export class RecentViewComponent { this.progressTick(); return recentItems.map(({ channel, viewedAt }) => { - const channelId = resolveChannelEpgLookupKey(channel); - const epgProgram = channelId ? epgMap.get(channelId) : null; + const epgProgram = resolveChannelEpgProgram(channel, epgMap); return { channel, viewedAt, epgProgram, logo: resolveChannelLogo(channel, iconMap), - progressPercentage: this.calculateProgress(epgProgram), + progressPercentage: calculateEpgProgress(epgProgram), }; }); }); - private calculateProgress( - epgProgram: EpgProgram | null | undefined - ): number { - if (!epgProgram) { - return 0; - } - - const now = new Date().getTime(); - const start = new Date(epgProgram.start).getTime(); - const stop = new Date(epgProgram.stop).getTime(); - const total = stop - start; - const elapsed = now - start; - - return Math.min(100, Math.max(0, (elapsed / total) * 100)); - } - trackByFn(_: number, item: RecentViewItem): string { return item.channel?.url; } diff --git a/libs/ui/epg/src/index.ts b/libs/ui/epg/src/index.ts index aa9f3a7b7..41ef73a48 100644 --- a/libs/ui/epg/src/index.ts +++ b/libs/ui/epg/src/index.ts @@ -1,6 +1,21 @@ export * from './lib/epg-list/epg-list.component'; export * from './lib/epg-list/epg-item-description/epg-item-description.component'; export * from './lib/epg-date'; +export * from './lib/epg-timeline/epg-timeline.component'; +export * from './lib/epg-timeline/epg-timeline-empty-state.component'; +export * from './lib/epg-timeline/epg-timeline-track.component'; +export * from './lib/epg-timeline/epg-timeline.utils'; +// Reusable EPG-view building blocks (shared by the timeline; a future list view +// can reuse them too). +export * from './lib/epg-timeline/epg-archive.util'; +export * from './lib/epg-programme-dialog.service'; +export { + formatClockTime, + summaryHasTimeRange, + summaryHasTitle, + summaryMinutesLeft, + summaryProgress, +} from './lib/epg-timeline/epg-summary.util'; export * from './lib/epg-progress-panel/epg-progress-panel.component'; export * from './lib/epg-source-status/epg-source-status.component'; export * from './lib/multi-epg/multi-epg-container.component'; diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html index f0f924001..c96db811a 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.html @@ -1,78 +1,111 @@ -
- -
-
-
- {{ - (epgProgram.startTimestamp - ? epgProgram.startTimestamp * 1000 - : epgProgram.start - ) | date: 'HH:mm' - }} - — - {{ - (epgProgram.stopTimestamp - ? epgProgram.stopTimestamp * 1000 - : epgProgram.stop - ) | date: 'HH:mm' - }} -
-
{{ - (epgProgram.startTimestamp - ? epgProgram.startTimestamp * 1000 - : epgProgram.start - ) | date: 'EEEE, MMMM d' : undefined : currentLocale() - }}
-
+
+
@if (channelName) { -
- 📺 - {{ channelName }} -
+ + live_tv + {{ channelName }} + } -
+ - -
-

{{ epgProgram.title }}

- @if (epgProgram.category) { - {{ epgProgram.category }} + @if (channelLogo && !logoFailed) { + + } @else { + live_tv }
- -
- @if (epgProgram.desc) { -

{{ epgProgram.desc }}

- } - - -
- @if (epgProgram.episodeNum) { -
- {{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }} - {{ epgProgram.episodeNum }} -
- } - @if (epgProgram.rating) { -
- {{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }} - {{ epgProgram.rating }} -
- } +
+
+ {{ startMs | date: 'EEE d MMM' : '' : currentLocale() }} + + {{ startMs | date: 'HH:mm' }} – + {{ stopMs | date: 'HH:mm' }} @if (duration) { -
- {{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }} - {{ duration }} -
+ + {{ duration }} + } + @if (epgProgram.category) { + + {{ epgProgram.category }} }
-
- -
- -
+

{{ epgProgram.title }}

+ + @if (epgProgram.desc) { +

{{ epgProgram.desc }}

+ } + + @if (epgProgram.episodeNum || epgProgram.rating) { +
+ @if (epgProgram.episodeNum) { + + {{ + 'EPG.PROGRAM_DIALOG.EPISODE' | translate + }} + {{ epgProgram.episodeNum }} + + } + @if (epgProgram.rating) { + + {{ + 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' + | translate + }} + {{ epgProgram.rating }} + + } +
+ } + +
+ @if (primaryAction === 'live') { + + } @else if (primaryAction === 'timeshift') { + + } + @if (archiveUnavailableNote) { + {{ + 'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate + }} + } + + +
+
diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.scss index 1fbdcf550..210f93bdb 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.scss @@ -1,300 +1,272 @@ -// Broadcast control room palette -$bg-deep: #0a0a0f; -$bg-surface: #12121a; -$bg-elevated: #1a1a24; -$accent-cyan: #00d4aa; -$accent-purple: #8b5cf6; -$text-primary: #f8fafc; -$text-secondary: rgba(248, 250, 252, 0.7); -$text-muted: rgba(248, 250, 252, 0.4); -$border-subtle: rgba(255, 255, 255, 0.08); +// Matches the EPG timeline design system (same tokens + fallbacks). +$surface-1: var(--surface-1, #0f141c); +$surface-2: var(--surface-2, #161d28); +$surface-3: var(--surface-3, #1d2533); +$text-primary: var(--text-primary, #e7ecf3); +$text-secondary: var(--text-secondary, #9aa3b2); +$text-tertiary: var(--text-tertiary, #6b7384); +$accent-blue: var(--accent-blue, #4f8eff); +$accent-cyan: var(--accent-cyan, #5cd6ff); +$line: var(--line, rgba(255, 255, 255, 0.06)); +$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1)); +$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); -.program-dialog { - background: linear-gradient(180deg, $bg-surface 0%, $bg-deep 100%); +.epg-dialog { + box-sizing: border-box; + width: 540px; + max-width: 100%; + background: $surface-1; color: $text-primary; - min-width: 380px; - max-width: 800px; - border-radius: 16px; + border: 1px solid $line-strong; + border-radius: var(--r-lg, 14px); overflow: hidden; - position: relative; + box-shadow: 0 40px 90px rgba(0, 0, 0, 0.6); - // Ambient glow effect - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 120px; - background: radial-gradient( - ellipse at top center, - rgba($accent-cyan, 0.12) 0%, - transparent 70% - ); - pointer-events: none; + * { + box-sizing: border-box; } } -// Header with time and channel -.program-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - padding: 24px 24px 16px; +/* ── hero ── */ +.epg-dialog__hero { position: relative; - z-index: 1; + height: 132px; + display: grid; + place-items: center; + border-bottom: 1px solid $line; + background: + radial-gradient( + 120% 150% at 78% 22%, + rgba(110, 60, 180, 0.4), + transparent 60% + ), + radial-gradient( + 130% 160% at 18% 18%, + rgba(30, 40, 70, 0.55), + transparent 55% + ), + #0b0e16; + } -.time-block { - display: flex; - flex-direction: column; - gap: 4px; +.epg-dialog__hero-glyph { + font-size: 40px; + width: 40px; + height: 40px; + color: rgba(255, 255, 255, 0.16); } -.time-display { +.epg-dialog__logo { + max-width: 56%; + max-height: 76px; + object-fit: contain; + /* a soft plate so logos on transparent/white art stay legible on the + dark gradient */ + padding: 8px 12px; + border-radius: 10px; + background: rgba(8, 11, 17, 0.42); + backdrop-filter: blur(2px); +} + +.epg-dialog__chan { + position: absolute; + left: 14px; + top: 12px; + max-width: calc(100% - 64px); + display: inline-flex; + align-items: center; + gap: 7px; + padding: 5px 11px; + border-radius: 999px; + background: rgba(8, 11, 17, 0.72); + border: 1px solid $line; + font-size: 12px; + color: $text-secondary; + backdrop-filter: blur(6px); + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + color: $accent-blue; + flex: 0 0 auto; + } +} + +.epg-dialog__chan-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.epg-dialog__close { + position: absolute; + right: 12px; + top: 12px; + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: none; + border-radius: 8px; + background: rgba(8, 11, 17, 0.72); + color: $text-secondary; + cursor: pointer; + backdrop-filter: blur(6px); + transition: + color 120ms, + background 120ms; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + + &:hover { + color: $text-primary; + background: $surface-3; + } +} + +/* ── body ── */ +.epg-dialog__body { + padding: 18px 22px 22px; +} + +.epg-dialog__meta { display: flex; align-items: center; + flex-wrap: wrap; gap: 8px; - font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; - font-size: 28px; - font-weight: 600; - letter-spacing: -0.02em; - - .time-start { - color: $accent-cyan; - text-shadow: 0 0 20px rgba($accent-cyan, 0.4); - } - - .time-separator { - color: $text-muted; - font-weight: 300; - } - - .time-end { - color: $text-secondary; - } + font-family: $font-mono; + font-size: 11.5px; + color: $text-tertiary; + margin-bottom: 10px; } -.date-display { - font-size: 13px; - color: $text-muted; - letter-spacing: 0.02em; +.epg-dialog__dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: currentColor; + opacity: 0.55; } -.channel-badge { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - background: rgba($accent-purple, 0.15); - border: 1px solid rgba($accent-purple, 0.3); - border-radius: 20px; - font-size: 13px; - font-weight: 500; - color: $text-primary; - - .channel-icon { - font-size: 14px; - } - - .channel-name { - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -// Title section -.program-title-section { - padding: 0 24px 20px; - border-bottom: 1px solid $border-subtle; -} - -.program-title { +.epg-dialog__title { margin: 0 0 12px; - font-size: 22px; + font-size: 21px; font-weight: 600; - line-height: 1.3; - color: $text-primary; + line-height: 1.25; letter-spacing: -0.01em; + color: $text-primary; } -.category-tag { - display: inline-block; - padding: 6px 12px; - background: rgba($accent-cyan, 0.1); - border: 1px solid rgba($accent-cyan, 0.25); - border-radius: 6px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - color: $accent-cyan; -} - -// Content section -.program-content { - padding: 20px 24px; -} - -.program-description { - margin: 0 0 24px; +.epg-dialog__desc { + margin: 0 0 18px; font-size: 14px; - line-height: 1.7; + line-height: 1.55; color: $text-secondary; } -// Meta grid -.meta-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 16px; -} - -.meta-item { +/* ── episode / rating tags ── */ +.epg-dialog__tags { display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 20px; +} + +.epg-dialog__tag { + display: inline-flex; flex-direction: column; - gap: 4px; - padding: 12px; - background: rgba(255, 255, 255, 0.03); + gap: 2px; + padding: 7px 11px; border-radius: 8px; - border: 1px solid $border-subtle; -} - -.meta-label { - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.1em; - color: $text-muted; -} - -.meta-value { - font-size: 14px; + background: $surface-2; + border: 1px solid $line; + font-size: 13px; font-weight: 500; color: $text-primary; - &.rating { - color: $accent-purple; + small { + font-family: $font-mono; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: $text-tertiary; + } + + &--rating { + color: $accent-cyan; } } -// Footer -.program-footer { - padding: 16px 24px 24px; +/* ── actions ── */ +.epg-dialog__actions { display: flex; - justify-content: flex-end; + align-items: center; + gap: 10px; } -.close-button { - padding: 10px 24px; - background: transparent; - border: 1px solid rgba($accent-cyan, 0.4); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: $accent-cyan; +.epg-dialog__spacer { + flex: 1; +} + +.epg-dialog__note { + font-size: 12.5px; + color: $text-tertiary; +} + +.epg-dialog__btn { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border-radius: 10px; + border: 1px solid transparent; + font: inherit; + font-size: 13.5px; + font-weight: 600; cursor: pointer; - transition: all 0.2s ease; - &:hover { - background: rgba($accent-cyan, 0.1); - border-color: $accent-cyan; - box-shadow: 0 0 20px rgba($accent-cyan, 0.2); - } -} - -// Override Material dialog styling -::ng-deep .mat-mdc-dialog-container { - --mdc-dialog-container-color: transparent; - --mdc-dialog-container-shape: 16px; - - .mdc-dialog__surface { - background: transparent !important; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6), - 0 0 0 1px rgba(255, 255, 255, 0.05) !important; - } -} - -// Light theme adjustments -::ng-deep .light-theme { - .program-dialog { - background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); - color: #1e293b; - - &::before { - background: radial-gradient( - ellipse at top center, - rgba(#059669, 0.08) 0%, - transparent 70% - ); - } + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; } - .time-display { - .time-start { - color: #059669; - text-shadow: none; - } - .time-end { - color: #64748b; - } - } - - .date-display { - color: #94a3b8; - } - - .channel-badge { - background: rgba(#7c3aed, 0.08); - border-color: rgba(#7c3aed, 0.2); - color: #1e293b; - } - - .program-title { - color: #0f172a; - } - - .program-title-section { - border-bottom-color: rgba(0, 0, 0, 0.06); - } - - .category-tag { - background: rgba(#059669, 0.08); - border-color: rgba(#059669, 0.2); - color: #059669; - } - - .program-description { - color: #475569; - } - - .meta-item { - background: rgba(0, 0, 0, 0.02); - border-color: rgba(0, 0, 0, 0.06); - } - - .meta-label { - color: #94a3b8; - } - - .meta-value { - color: #1e293b; - - &.rating { - color: #7c3aed; - } - } - - .close-button { - border-color: rgba(#059669, 0.4); - color: #059669; + &--primary { + background: $accent-blue; + color: #fff; &:hover { - background: rgba(#059669, 0.08); - border-color: #059669; - box-shadow: none; + filter: brightness(1.06); + } + } + + &--ghost { + background: transparent; + color: $text-primary; + border-color: $line-strong; + + &:hover { + background: $surface-2; } } } + +/* Let the dialog provide its own surface, border and shadow. */ +::ng-deep .mat-mdc-dialog-container { + --mdc-dialog-container-color: transparent; + + .mat-mdc-dialog-surface, + .mdc-dialog__surface { + background: transparent !important; + box-shadow: none !important; + border-radius: var(--r-lg, 14px) !important; + overflow: visible; + } +} diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts index 3633ede4b..72a3e3655 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.spec.ts @@ -46,21 +46,19 @@ describe('EpgItemDescriptionComponent', () => { it('should render epg details in the dialog', () => { fixture.detectChanges(); const titleElement = fixture.debugElement.query( - By.css('.program-title') + By.css('.epg-dialog__title') ); expect(titleElement.nativeElement.textContent.trim()).toContain( 'TV Show 1' ); - const categoryElement = fixture.debugElement.query( - By.css('.category-tag') - ); - expect(categoryElement.nativeElement.textContent.trim()).toContain( - 'Fun' + const metaElement = fixture.debugElement.query( + By.css('.epg-dialog__meta') ); + expect(metaElement.nativeElement.textContent).toContain('Fun'); const descElement = fixture.debugElement.query( - By.css('.program-description') + By.css('.epg-dialog__desc') ); expect(descElement.nativeElement.textContent.trim()).toContain( 'Highly interesting show about pets' diff --git a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts index 45bfcbddc..a4dfb2191 100644 --- a/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts +++ b/libs/ui/epg/src/lib/epg-list/epg-item-description/epg-item-description.component.ts @@ -1,7 +1,7 @@ import { DatePipe } from '@angular/common'; import { Component, computed, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { MatButtonModule } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { normalizeDateLocale } from '@iptvnator/pipes'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -9,17 +9,25 @@ import { differenceInMinutes } from 'date-fns'; import { startWith } from 'rxjs'; import { EpgProgram } from '@iptvnator/shared/interfaces'; -type EpgItemDialogData = EpgProgram & { +export type EpgItemDialogAction = 'live' | 'timeshift'; + +export type EpgItemDialogData = EpgProgram & { channelName?: string | null; channel_name?: string | null; display_name?: string | null; + /** Channel logo shown in the hero (falls back to a glyph when absent). */ + channelLogo?: string | null; + /** State-aware primary action; closes the dialog with this value. */ + primaryAction?: EpgItemDialogAction | null; + /** Show a "catch-up unavailable" note instead of an action button. */ + archiveUnavailableNote?: boolean; }; @Component({ selector: 'app-epg-item-description', templateUrl: './epg-item-description.component.html', styleUrls: ['./epg-item-description.component.scss'], - imports: [DatePipe, MatButtonModule, MatDialogModule, TranslatePipe], + imports: [DatePipe, MatDialogModule, MatIcon, TranslatePipe], }) export class EpgItemDescriptionComponent { dialogData = inject(MAT_DIALOG_DATA); @@ -31,7 +39,15 @@ export class EpgItemDescriptionComponent { epgProgram: EpgProgram; channelName: string | null = null; + channelLogo: string | null = null; + /** Set when the logo image fails to load → falls back to the glyph. */ + logoFailed = false; duration: string | null = null; + primaryAction: EpgItemDialogAction | null = null; + archiveUnavailableNote = false; + /** ms timestamps for the date pipe (prefer unix timestamp when present). */ + startMs = 0; + stopMs = 0; readonly currentLocale = computed(() => { this.languageTick(); return normalizeDateLocale( @@ -46,7 +62,21 @@ export class EpgItemDescriptionComponent { || this.dialogData.channel_name || this.dialogData.display_name || null; + // Prefer the channel logo; fall back to the programme/EPG icon + // (M3U playlists without tvg-logo still get an icon from the EPG feed). + this.channelLogo = + this.dialogData.channelLogo?.trim() || + this.epgProgram.iconUrl?.trim() || + null; this.duration = this.calculateDuration(); + this.primaryAction = this.dialogData.primaryAction ?? null; + this.archiveUnavailableNote = + this.dialogData.archiveUnavailableNote ?? false; + this.startMs = toMs( + this.epgProgram.start, + this.epgProgram.startTimestamp + ); + this.stopMs = toMs(this.epgProgram.stop, this.epgProgram.stopTimestamp); } private calculateDuration(): string | null { @@ -66,3 +96,10 @@ export class EpgItemDescriptionComponent { } } } + +function toMs(iso: string, timestamp?: number | null): number { + if (Number.isFinite(timestamp) && Number(timestamp) > 0) { + return Number(timestamp) * 1000; + } + return Date.parse(iso); +} diff --git a/libs/ui/epg/src/lib/epg-programme-dialog.service.ts b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts new file mode 100644 index 000000000..14deeda08 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts @@ -0,0 +1,24 @@ +import { inject, Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { + EpgItemDescriptionComponent, + EpgItemDialogAction, + EpgItemDialogData, +} from './epg-list/epg-item-description/epg-item-description.component'; + +/** + * Opens the shared programme-details dialog and returns the chosen action. + * Extracted so any EPG surface (the timeline today, a list view later) can open + * it the same way and react to `live` / `timeshift`. + */ +@Injectable({ providedIn: 'root' }) +export class EpgProgrammeDialogService { + private readonly dialog = inject(MatDialog); + + open(data: EpgItemDialogData): Observable { + return this.dialog + .open(EpgItemDescriptionComponent, { width: '540px', data }) + .afterClosed(); + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts new file mode 100644 index 000000000..17f0ce75b --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-archive.util.ts @@ -0,0 +1,51 @@ +import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component'; +import { TimelineWhen } from './epg-timeline.utils'; + +const DAY_MS = 24 * 60 * 60_000; + +/** + * Catch-up / archive gating, view-agnostic so both the timeline and a future + * list view can share it. Works off primitives (`when`, `startMs`) rather than + * a timeline block. + */ + +/** Whether a programme's start is inside the catch-up window (0 days = unlimited). */ +export function isWithinArchiveWindow( + startMs: number, + archiveDays: number, + nowMs: number +): boolean { + if (archiveDays <= 0) { + return true; // capability flagged but no explicit window + } + return startMs >= nowMs - archiveDays * DAY_MS; +} + +/** Whether a past programme is playable from the catch-up archive. */ +export function canCatchUpProgramme( + when: TimelineWhen, + startMs: number, + archivePlaybackAvailable: boolean, + archiveDays: number, + nowMs: number +): boolean { + return ( + archivePlaybackAvailable && + when === 'past' && + isWithinArchiveWindow(startMs, archiveDays, nowMs) + ); +} + +/** Primary action for the programme-details dialog given its playback state. */ +export function epgDialogActionFor( + when: TimelineWhen, + canCatchUp: boolean +): EpgItemDialogAction | null { + if (when === 'now') { + return 'live'; + } + if (canCatchUp) { + return 'timeshift'; + } + return null; +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts new file mode 100644 index 000000000..cfbed9b86 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-summary.util.ts @@ -0,0 +1,84 @@ +/** + * Collapsed-summary payload (the current / archive programme shown when the + * panel is collapsed). View-agnostic so a future list view can reuse the same + * summary shape and progress maths. + */ +export interface EpgTimelineSummary { + readonly title?: string | null; + readonly start?: string | number | Date | null; + readonly stop?: string | number | Date | null; + readonly progress?: number | null; +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +function toTimeMs( + value: string | number | Date | null | undefined +): number | null { + if (value === null || value === undefined || value === '') { + return null; + } + const parsed = + value instanceof Date + ? value.getTime() + : typeof value === 'number' + ? value + : Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** Whether the summary has a non-blank title. */ +export function summaryHasTitle( + summary: EpgTimelineSummary | null | undefined +): boolean { + const title = summary?.title; + return typeof title === 'string' && title.trim().length > 0; +} + +/** Whether the summary carries a start or stop time. */ +export function summaryHasTimeRange( + summary: EpgTimelineSummary | null | undefined +): boolean { + return !!summary?.start || !!summary?.stop; +} + +/** Progress 0–100 for the summary at `nowMs`, or null when it can't be computed. */ +export function summaryProgress( + summary: EpgTimelineSummary | null | undefined, + nowMs: number +): number | null { + if (!summary) { + return null; + } + const explicit = Number(summary.progress); + if (Number.isFinite(explicit)) { + return clampPercent(explicit); + } + const startMs = toTimeMs(summary.start); + const stopMs = toTimeMs(summary.stop); + if (startMs === null || stopMs === null || stopMs <= startMs) { + return null; + } + return clampPercent(((nowMs - startMs) / (stopMs - startMs)) * 100); +} + +/** Whole minutes left until the summary's stop, or null. */ +export function summaryMinutesLeft( + summary: EpgTimelineSummary | null | undefined, + nowMs: number +): number | null { + const stopMs = toTimeMs(summary?.stop); + if (stopMs === null) { + return null; + } + return Math.max(0, Math.round((stopMs - nowMs) / 60_000)); +} + +/** HH:MM clock label for a timestamp. */ +export function formatClockTime(ms: number): string { + const date = new Date(ms); + const pad = (value: number) => String(value).padStart(2, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss new file mode 100644 index 000000000..3f59d614a --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.scss @@ -0,0 +1,73 @@ +:host { + /* Fill the area below the toolbar and centre within THAT, instead of a fixed + min-height that overflowed the compact inline panel and pushed the icon / + text / action buttons below the visible edge. */ + flex: 1 1 auto; + min-height: 0; + display: grid; + place-items: center; + width: 100%; + overflow: hidden; + padding: 14px 22px; + background: #0a0e15; +} + +.epg-empty { + text-align: center; + max-width: 400px; + + &__icon { + width: 44px; + height: 44px; + border-radius: 13px; + display: grid; + place-items: center; + margin: 0 auto 10px; + background: var(--surface-2, #161d28); + border: 1px solid var(--line-strong, rgba(255, 255, 255, 0.1)); + color: var(--text-tertiary, #6b7384); + + mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + } + + &.tone-action { + background: rgba(79, 142, 255, 0.12); + border-color: rgba(79, 142, 255, 0.3); + color: var(--accent-blue, #4f8eff); + } + + &.tone-warn { + background: rgba(255, 91, 110, 0.1); + border-color: rgba(255, 91, 110, 0.28); + color: var(--accent-live, #ff5b6e); + } + } + + &__title { + font-size: 15.5px; + font-weight: 600; + color: var(--text-primary, #e7ecf3); + margin-bottom: 5px; + } + + &__sub { + font-size: 13px; + color: var(--text-secondary, #9aa3b2); + line-height: 1.45; + } + + &__actions { + margin-top: 12px; + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; + + mat-icon { + margin-right: 6px; + } + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.spec.ts new file mode 100644 index 000000000..ee299501f --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { EpgTimelineEmptyStateComponent } from './epg-timeline-empty-state.component'; + +describe('EpgTimelineEmptyStateComponent', () => { + function create(reason: string) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [EpgTimelineEmptyStateComponent], + providers: [ + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + get: () => new BehaviorSubject(''), + stream: () => new BehaviorSubject(''), + }, + }, + ], + }); + const fixture = TestBed.createComponent(EpgTimelineEmptyStateComponent); + fixture.componentRef.setInput('reason', reason); + return fixture.componentInstance; + } + + it('returns no preset for the "none" reason', () => { + expect(create('none').preset()).toBeNull(); + }); + + it('uses the action tone + plug icon for m3u setup', () => { + const preset = create('m3u-needs-setup').preset(); + expect(preset?.tone).toBe('action'); + expect(preset?.icon).toBe('cable'); + }); + + it('uses the warn tone for errors', () => { + expect(create('error').preset()?.tone).toBe('warn'); + }); + + it('uses a neutral tone for informational states', () => { + expect(create('provider-no-epg').preset()?.tone).toBe('neutral'); + expect(create('channel-unmapped').preset()?.tone).toBe('neutral'); + expect(create('empty-day').preset()?.tone).toBe('neutral'); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.ts new file mode 100644 index 000000000..24d36b490 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-empty-state.component.ts @@ -0,0 +1,155 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; + +/** + * Reasons the timeline ribbon has nothing to show. `none` means programmes are + * present and the ribbon renders instead of this component. + */ +export type EpgTimelineEmptyReason = + | 'none' + | 'empty-day' + | 'channel-unmapped' + | 'provider-no-epg' + | 'm3u-needs-setup' + | 'error'; + +type EmptyTone = 'neutral' | 'action' | 'warn'; + +interface EmptyStatePreset { + readonly icon: string; + readonly tone: EmptyTone; + readonly titleKey: string; + readonly subKey: string; +} + +const PRESETS: Record< + Exclude, + EmptyStatePreset +> = { + 'empty-day': { + icon: 'event_busy', + tone: 'neutral', + titleKey: 'EPG.TIMELINE.EMPTY_DAY_TITLE', + subKey: 'EPG.TIMELINE.EMPTY_DAY_DESCRIPTION', + }, + 'channel-unmapped': { + icon: 'tv_off', + tone: 'neutral', + titleKey: 'EPG.TIMELINE.CHANNEL_UNMAPPED_TITLE', + subKey: 'EPG.TIMELINE.CHANNEL_UNMAPPED_DESCRIPTION', + }, + 'provider-no-epg': { + icon: 'cell_tower', + tone: 'neutral', + titleKey: 'EPG.TIMELINE.PROVIDER_NO_EPG_TITLE', + subKey: 'EPG.TIMELINE.PROVIDER_NO_EPG_DESCRIPTION', + }, + 'm3u-needs-setup': { + icon: 'cable', + tone: 'action', + titleKey: 'EPG.TIMELINE.M3U_SETUP_TITLE', + subKey: 'EPG.TIMELINE.M3U_SETUP_DESCRIPTION', + }, + error: { + icon: 'error_outline', + tone: 'warn', + titleKey: 'EPG.TIMELINE.ERROR_TITLE', + subKey: 'EPG.TIMELINE.ERROR_DESCRIPTION', + }, +}; + +@Component({ + selector: 'app-epg-timeline-empty-state', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButton, MatIcon, TranslatePipe], + template: ` + @let activePreset = preset(); + @if (activePreset) { +
+
+ {{ activePreset.icon }} +
+
+ {{ activePreset.titleKey | translate }} +
+
+ {{ activePreset.subKey | translate }} +
+ + @switch (reason()) { + @case ('empty-day') { +
+ + @if (hasOtherDays()) { + + } +
+ } + @case ('m3u-needs-setup') { +
+ +
+ } + @case ('error') { +
+ +
+ } + } +
+ } + `, + styleUrl: './epg-timeline-empty-state.component.scss', +}) +export class EpgTimelineEmptyStateComponent { + readonly reason = input.required(); + readonly hasOtherDays = input(false); + + readonly jumpToday = output(); + readonly jumpNearest = output(); + readonly openSettings = output(); + readonly retry = output(); + + readonly preset = computed(() => { + const reason = this.reason(); + return reason === 'none' ? null : PRESETS[reason]; + }); +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts new file mode 100644 index 000000000..1c730a98a --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-render.util.ts @@ -0,0 +1,188 @@ +import { TIMELINE_MINUTE_MS, TimelineBlock } from './epg-timeline.utils'; + +// ───────────────────────── short-programme strategy ───────────────────────── +// A proportional ribbon makes a 5-minute programme a 2px sliver. The fixes: +// A — a minimum block width so every programme stays clickable; +// B — content tiers chosen by rendered width (wide → micro); +// E — grouping runs of ≥4 consecutive short programmes when zoomed out. +// (C — the hover popover — and D — the zoom slider — live in the components.) + +/** Smallest rendered block width; a sliver gets bumped up to this. */ +export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40; +/** Gap subtracted from each block so neighbours don't touch. */ +export const TIMELINE_BLOCK_GAP_PX = 4; +/** Zoom (px/min) slider bounds. */ +export const TIMELINE_ZOOM_MIN = 0.8; +export const TIMELINE_ZOOM_MAX = 6; +export const TIMELINE_ZOOM_STEP = 0.1; +/** Below this zoom, dense runs of short programmes collapse into a group chip. */ +export const TIMELINE_GROUP_ZOOM_MAX = 1.3; +/** Zoom applied when a group chip is expanded. */ +export const TIMELINE_GROUP_EXPAND_ZOOM = 3.4; +/** A programme is "short" below this duration (minutes). */ +export const TIMELINE_SHORT_MAX_MIN = 10; +/** Minimum consecutive shorts before they group. */ +export const TIMELINE_GROUP_MIN_RUN = 4; + +export type TimelineTier = 'wide' | 'med' | 'narrow' | 'micro'; + +export interface TimelineRenderBlock { + readonly kind: 'block'; + readonly key: string; + readonly block: TimelineBlock; + readonly leftPx: number; + readonly widthPx: number; + readonly tier: TimelineTier; + /** Live progress 0–100 for the on-now block, else 0. */ + readonly nowFillPercent: number; + /** Past programme playable from the catch-up archive. */ + readonly canCatchUp: boolean; +} + +export interface TimelineRenderGroup { + readonly kind: 'group'; + readonly key: string; + readonly leftPx: number; + readonly widthPx: number; + readonly count: number; + readonly startMs: number; + readonly stopMs: number; +} + +export type TimelineRenderItem = TimelineRenderBlock | TimelineRenderGroup; + +export interface TimelineRenderOptions { + readonly minWidthPx?: number; + readonly gapPx?: number; + readonly allowGroup?: boolean; + readonly nowMs?: number; + readonly archivePlaybackAvailable?: boolean; + /** Earliest start playable from the archive; -Infinity = whole past. */ + readonly archiveWindowStartMs?: number; +} + +/** Content tier for a block of the given rendered width. */ +export function tierFor(widthPx: number): TimelineTier { + if (widthPx >= 132) return 'wide'; + if (widthPx >= 70) return 'med'; + if (widthPx >= 30) return 'narrow'; + return 'micro'; +} + +/** Adaptive tick spacing (minutes) — denser as the user zooms in. */ +export function timelineTickStepForScale(scale: number): number { + if (scale < 2.2) return 120; + if (scale < 3.5) return 60; + if (scale < 5) return 30; + return 15; +} + +function nowFillFor(block: TimelineBlock, nowMs: number): number { + if (block.when !== 'now') { + return 0; + } + const total = block.stopMs - block.startMs; + if (total <= 0) { + return 0; + } + const pct = ((nowMs - block.startMs) / total) * 100; + return Math.min(100, Math.max(0, pct)); +} + +function toRenderBlock( + block: TimelineBlock, + scale: number, + minWidthPx: number, + gapPx: number, + nowMs: number, + archivePlaybackAvailable: boolean, + archiveWindowStartMs: number +): TimelineRenderBlock { + const rawWidth = Math.max(minWidthPx, block.durationMin * scale); + return { + kind: 'block', + key: block.key, + block, + leftPx: block.offsetMin * scale, + widthPx: Math.max(minWidthPx - gapPx, rawWidth - gapPx), + tier: tierFor(rawWidth), + nowFillPercent: nowFillFor(block, nowMs), + canCatchUp: + archivePlaybackAvailable && + block.when === 'past' && + block.startMs >= archiveWindowStartMs, + }; +} + +/** + * Turn timeline blocks into positioned render items, applying the minimum-width + * floor (A), per-width content tiers (B) and short-run grouping (E). + */ +export function buildTimelineRenderItems( + blocks: readonly TimelineBlock[], + scale: number, + options: TimelineRenderOptions = {} +): TimelineRenderItem[] { + const minWidthPx = options.minWidthPx ?? TIMELINE_MIN_BLOCK_WIDTH_PX; + const gapPx = options.gapPx ?? TIMELINE_BLOCK_GAP_PX; + const allowGroup = options.allowGroup ?? false; + const nowMs = options.nowMs ?? Date.now(); + const archiveAvailable = options.archivePlaybackAvailable ?? false; + const archiveWindowStartMs = + options.archiveWindowStartMs ?? Number.NEGATIVE_INFINITY; + + const items: TimelineRenderItem[] = []; + let run: TimelineBlock[] = []; + + const pushBlock = (block: TimelineBlock) => + items.push( + toRenderBlock( + block, + scale, + minWidthPx, + gapPx, + nowMs, + archiveAvailable, + archiveWindowStartMs + ) + ); + + const flushRun = () => { + if (run.length >= TIMELINE_GROUP_MIN_RUN) { + const first = run[0]; + const last = run[run.length - 1]; + const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale; + items.push({ + kind: 'group', + key: `group-${first.key}-${last.key}`, + leftPx: first.offsetMin * scale, + widthPx: Math.max(minWidthPx - gapPx, spanPx - gapPx), + count: run.length, + startMs: first.startMs, + stopMs: last.stopMs, + }); + } else { + run.forEach(pushBlock); + } + run = []; + }; + + for (const block of blocks) { + // Never fold the on-air programme into a "N short" group chip — that + // drops its `when: 'now'` highlight and leaves the auto-focused viewport + // centred on a chip with no visible "now" state. Keep it standalone. + if ( + allowGroup && + block.durationMin < TIMELINE_SHORT_MAX_MIN && + block.when !== 'now' + ) { + run.push(block); + continue; + } + flushRun(); + pushBlock(block); + } + flushRun(); + + return items; +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts new file mode 100644 index 000000000..aa7ae36bd --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.spec.ts @@ -0,0 +1,205 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + programsFocusKey, + TimelineScrollController, +} from './epg-timeline-scroll.controller'; +import { + buildTimelineAxis, + buildTimelineBlocks, + hasProgramsForDateKey, + TIMELINE_MINUTE_MS, +} from './epg-timeline.utils'; + +function programAt( + startOffsetMin: number, + durationMin: number, + title = 'P' +): EpgProgram { + const start = new Date(Date.now() + startOffsetMin * 60_000); + const stop = new Date(start.getTime() + durationMin * 60_000); + return { + start: start.toISOString(), + stop: stop.toISOString(), + channel: 'ch', + title, + desc: null, + category: null, + }; +} + +describe('TimelineScrollController', () => { + describe('programsFocusKey (channel-change auto-focus identity)', () => { + const programFor = (channel: string): EpgProgram => ({ + ...programAt(0, 60), + channel, + }); + + it('is empty for an empty programme set so no focus is attempted', () => { + expect(programsFocusKey([])).toBe(''); + }); + + it('is stable for the same programme set (no re-jump on re-render)', () => { + const programs = [programFor('a'), programFor('a')]; + expect(programsFocusKey(programs)).toBe( + programsFocusKey([...programs]) + ); + }); + + it('differs when the channel changes (re-focus on channel switch)', () => { + expect(programsFocusKey([programFor('alpha')])).not.toBe( + programsFocusKey([programFor('beta')]) + ); + }); + }); + + describe('maybeAutoFocus (channel-select centring)', () => { + let programs: EpgProgram[]; + let controller: TimelineScrollController; + let scrollSpy: jest.SpyInstance; + let commitDaySpy: jest.Mock; + + beforeEach(() => { + programs = []; + const nowMs = Date.now(); + commitDaySpy = jest.fn(); + controller = new TimelineScrollController({ + ribbon: () => undefined, + scale: () => 1, + axis: () => buildTimelineAxis(programs, nowMs), + blocks: () => + buildTimelineBlocks( + programs, + buildTimelineAxis(programs, nowMs), + nowMs + ), + nowMs: () => nowMs, + viewDayKey: () => 'today', + commitDay: commitDaySpy, + hasProgramsForDay: (dayKey) => + hasProgramsForDateKey(programs, dayKey), + }); + scrollSpy = jest + .spyOn(controller, 'scrollToOffset') + .mockImplementation(() => undefined); + }); + + function focus( + scroller: HTMLElement | undefined, + next: EpgProgram[] = programs + ): void { + programs = next; // keep axis/blocks accessors in sync with the arg + controller.maybeAutoFocus(scroller, next); + } + + it('centres the current programme instantly (no scroll animation)', () => { + focus({} as HTMLElement, [programAt(0, 120, 'Now')]); + + expect(scrollSpy).toHaveBeenCalledTimes(1); + const [, frac, smooth] = scrollSpy.mock.calls[0]; + expect(frac).toBe(0.5); // centred in the viewport + expect(smooth).toBe(false); // instant, no animation + }); + + it('does not re-focus while the same channel stays loaded', () => { + const p = [programAt(0, 120, 'Now')]; + const scroller = {} as HTMLElement; + focus(scroller, p); + focus(scroller, p); // host re-emits same data / now-tick + expect(scrollSpy).toHaveBeenCalledTimes(1); + }); + + it('restores position when the same channel ribbon remounts (collapse then expand)', () => { + // Collapse → expand recreates the ribbon as a *new* element at + // scrollLeft 0, so re-centre the viewed programme — but WITHOUT + // committing a day, so a user parked on another day is not snapped. + const p = [programAt(0, 120, 'Now')]; + focus({} as HTMLElement, p); // initial mount → focus #1 + focus(undefined, p); // collapse: ribbon unmounts + focus({} as HTMLElement, p); // expand: new element → restore + expect(scrollSpy).toHaveBeenCalledTimes(2); + expect(commitDaySpy).toHaveBeenCalledTimes(1); // only the initial focus + }); + + it('re-focuses when the channel changes', () => { + const scroller = {} as HTMLElement; + focus(scroller, [{ ...programAt(0, 120, 'A'), channel: 'a' }]); + focus(scroller, [{ ...programAt(0, 120, 'B'), channel: 'b' }]); + expect(scrollSpy).toHaveBeenCalledTimes(2); + }); + + it('does not focus or snap to today when today has no programmes', () => { + // Programmes only three days out → today is empty; auto-focus must + // leave the user's day navigation alone instead of forcing today — + // even across a collapse/expand remount (a new scroller element). + const p = [programAt(3 * 1440, 60)]; + focus({} as HTMLElement, p); + focus({} as HTMLElement, p); // remount → still no focus/snap + expect(scrollSpy).not.toHaveBeenCalled(); + expect(commitDaySpy).not.toHaveBeenCalled(); + }); + + it('skips focus entirely when the ribbon is not mounted', () => { + focus(undefined, [programAt(0, 120, 'Now')]); + expect(scrollSpy).not.toHaveBeenCalled(); + }); + }); + + describe('onRibbonScroll (gap-day handling)', () => { + let rafSpy: jest.SpyInstance; + let nowMs: number; + let programs: EpgProgram[]; + let scroller: { scrollLeft: number; clientWidth: number }; + + beforeEach(() => { + rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((cb: FrameRequestCallback) => { + cb(0); + return 1; + }); + nowMs = Date.now(); + programs = [programAt(0, 120, 'Now')]; + // Centre the viewport on "now" (today) — a real, in-axis day-key. + const axis = buildTimelineAxis(programs, nowMs); + scroller = { + scrollLeft: (nowMs - axis.startMs) / TIMELINE_MINUTE_MS, + clientWidth: 0, + }; + }); + + afterEach(() => rafSpy.mockRestore()); + + function controllerWith( + hasProgramsForDay: (dayKey: string) => boolean, + commitDay: jest.Mock + ): TimelineScrollController { + return new TimelineScrollController({ + ribbon: () => scroller as unknown as HTMLElement, + scale: () => 1, + axis: () => buildTimelineAxis(programs, nowMs), + blocks: () => + buildTimelineBlocks( + programs, + buildTimelineAxis(programs, nowMs), + nowMs + ), + nowMs: () => nowMs, + viewDayKey: () => 'today', + commitDay, + hasProgramsForDay, + }); + } + + it('commits the centred day when it has programmes', () => { + const commitDay = jest.fn(); + controllerWith(() => true, commitDay).onRibbonScroll(); + expect(commitDay).toHaveBeenCalledTimes(1); + }); + + it('does not commit a gap day (keeps the ribbon mounted mid-scroll)', () => { + const commitDay = jest.fn(); + controllerWith(() => false, commitDay).onRibbonScroll(); + expect(commitDay).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts new file mode 100644 index 000000000..78be6327e --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-scroll.controller.ts @@ -0,0 +1,163 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { getTodayEpgDateKey, parseEpgDateKey } from '../epg-date'; +import { + dayKeyAtOffset, + hasProgramsForDateKey, + TIMELINE_MINUTE_MS, + TimelineAxis, + TimelineBlock, +} from './epg-timeline.utils'; + +/** Accessors the controller reads from the host timeline component's signals. */ +export interface TimelineScrollContext { + readonly ribbon: () => HTMLElement | undefined; + readonly scale: () => number; + readonly axis: () => TimelineAxis; + readonly blocks: () => readonly TimelineBlock[]; + readonly nowMs: () => number; + readonly viewDayKey: () => string; + readonly commitDay: (dayKey: string) => void; + /** Whether the given day-key has any programme in the loaded window. */ + readonly hasProgramsForDay: (dayKey: string) => boolean; +} + +/** Stable identity of a channel's programme set (changes when the channel does). */ +export function programsFocusKey(programs: readonly EpgProgram[]): string { + if (programs.length === 0) { + return ''; + } + const first = programs[0]; + const last = programs[programs.length - 1]; + return `${first.channel ?? ''}|${programs.length}|${first.start}|${last.stop}`; +} + +/** + * Owns the ribbon's horizontal scrolling and the channel-select auto-focus, + * keeping that stateful logic out of the presentation component. Instantiated + * with accessors to the component's signals. + */ +export class TimelineScrollController { + private scrollFrame = 0; + private lastFocusKey: string | null = null; + private lastScroller: HTMLElement | null = null; + + constructor(private readonly ctx: TimelineScrollContext) {} + + /** Centre the current programme (or "now" if none airing) in the viewport. */ + focusCurrentProgram(smooth: boolean): void { + const axis = this.ctx.axis(); + const current = this.ctx.blocks().find((block) => block.when === 'now'); + const targetMs = current + ? (current.startMs + current.stopMs) / 2 + : this.ctx.nowMs(); + const offsetMin = (targetMs - axis.startMs) / TIMELINE_MINUTE_MS; + this.scrollToOffset(offsetMin, 0.5, smooth); + } + + scrollToDateKey(dateKey: string, frac: number, smooth = true): void { + const axis = this.ctx.axis(); + const noonMs = + parseEpgDateKey(dateKey).getTime() + 12 * 60 * TIMELINE_MINUTE_MS; + const offsetMin = (noonMs - axis.startMs) / TIMELINE_MINUTE_MS; + this.scrollToOffset(offsetMin, frac, smooth); + } + + scrollToOffset(offsetMin: number, frac: number, smooth = true): void { + requestAnimationFrame(() => { + const scroller = this.ctx.ribbon(); + if (!scroller) { + return; + } + const left = + offsetMin * this.ctx.scale() - scroller.clientWidth * frac; + scroller.scrollTo({ + left: Math.max(0, left), + behavior: smooth ? 'smooth' : 'auto', + }); + }); + } + + /** Update the centred day as the ribbon scrolls (rAF-throttled). */ + onRibbonScroll(): void { + if (this.scrollFrame) { + return; + } + this.scrollFrame = requestAnimationFrame(() => { + this.scrollFrame = 0; + const scroller = this.ctx.ribbon(); + if (!scroller) { + return; + } + const centerOffsetMin = + (scroller.scrollLeft + scroller.clientWidth / 2) / + this.ctx.scale(); + const dayKey = dayKeyAtOffset(this.ctx.axis(), centerOffsetMin); + // Skip gap days: committing a day with no programmes flips the host + // to `empty-day` and unmounts the ribbon mid-scroll, stranding the + // user before the later programmes. Keep the last populated day + // centred until they scroll into another day that has content. + if ( + dayKey && + dayKey !== this.ctx.viewDayKey() && + this.ctx.hasProgramsForDay(dayKey) + ) { + this.ctx.commitDay(dayKey); + } + }); + } + + /** + * Centre the current programme when a channel's EPG (re)loads or the ribbon + * (re)mounts. Deduped by programme-set identity so the 30s "now" tick, zoom + * changes, or a host re-emitting the same data never re-jump the viewport. + */ + maybeAutoFocus( + scroller: HTMLElement | undefined, + programs: readonly EpgProgram[] + ): void { + const key = programsFocusKey(programs); + // Nothing to centre without a ribbon or programmes. + if (!scroller || !key) { + return; + } + if (key === this.lastFocusKey) { + // Same channel already focused. A *new* scroller element means the + // ribbon was unmounted then remounted (e.g. the inline panel was + // collapsed and re-expanded), which resets scrollLeft to 0 — restore + // the viewed day without committing a different day, so the user + // lands back on their programme rather than the far-left of the + // guide. The same element (data re-emit / now-tick) is left alone so + // we never yank the viewport out from under the user. + if (scroller !== this.lastScroller) { + this.lastScroller = scroller; + this.restorePosition(); + } + return; + } + const todayKey = getTodayEpgDateKey(); + // Only auto-focus when today actually has a programme to centre on. + // Otherwise (today empty, data on other days) leave the user's day + // navigation alone instead of forcing the view back to an empty today. + if (!hasProgramsForDateKey(programs, todayKey)) { + return; + } + this.lastFocusKey = key; + this.lastScroller = scroller; + this.ctx.commitDay(todayKey); + // Instant: land already centred, no annoying scroll animation. + this.focusCurrentProgram(false); + } + + /** + * Re-centre the currently-viewed day after the ribbon remounts (which resets + * scrollLeft). Restores "now" for today, otherwise the viewed day — never + * commits a different day, so a user parked on another day stays there. + */ + private restorePosition(): void { + if (this.ctx.viewDayKey() === getTodayEpgDateKey()) { + this.focusCurrentProgram(false); + } else { + this.scrollToDateKey(this.ctx.viewDayKey(), 0.5, false); + } + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html new file mode 100644 index 000000000..ce0709cc6 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.html @@ -0,0 +1,134 @@ +@for (tick of ticks(); track tick.ms) { +
+ {{ tick.ms | date: 'HH:mm' }} +
+} +@for (divider of dividers(); track divider.ms) { +
+ {{ + divider.dayMs | date: 'EEE d MMM' : '' : currentLocale() + }} +
+} + +@for (item of items(); track item.key) { + @if (item.kind === 'group') { + + } @else { +
+ @if (item.block.when === 'now') { +
+ } + +
+
+ {{ item.block.startMs | date: 'HH:mm' }} – + {{ item.block.stopMs | date: 'HH:mm' }} +
+
+ {{ item.block.program.title }} +
+ @if (item.tier === 'wide' && item.block.program.desc) { +

+ {{ item.block.program.desc }} +

+ } +
+ +
+ @if (item.block.when === 'now') { + {{ + 'EPG.TIMELINE.ON_NOW' | translate + }} + } @else if (isPlaying(item)) { + {{ + 'EPG.TIMELINE.FROM_ARCHIVE' | translate + }} + } @else if (item.canCatchUp) { + + } +
+ + +
+ } +} + +
+ {{ nowLabel() }} +
+ +@let pop = popover(); +@if (pop) { + +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss new file mode 100644 index 000000000..801603a49 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss @@ -0,0 +1,531 @@ +$line: var(--line, rgba(255, 255, 255, 0.06)); +$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1)); +$surface-1: var(--surface-1, #0f141c); +$surface-2: var(--surface-2, #161d28); +$surface-3: var(--surface-3, #1d2533); +$text-primary: var(--text-primary, #e7ecf3); +$text-secondary: var(--text-secondary, #9aa3b2); +$text-tertiary: var(--text-tertiary, #6b7384); +$accent-blue: var(--accent-blue, #4f8eff); +$accent-cyan: var(--accent-cyan, #5cd6ff); +$accent-live: var(--accent-live, #ff5b6e); +$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); + +:host { + box-sizing: border-box; + position: relative; + display: block; + height: 100%; + min-height: 96px; +} + +:host * { + box-sizing: border-box; +} + +/* ── grid ── */ +.epg-timeline__tick, +.epg-timeline__daydiv { + position: absolute; + top: 0; + bottom: 0; +} + +.epg-timeline__tick { + border-left: 1px solid $line; + + span { + position: absolute; + top: 6px; + left: 7px; + font-family: $font-mono; + font-size: 10.5px; + color: $text-tertiary; + } +} + +.epg-timeline__daydiv { + border-left: 1px solid rgba(79, 142, 255, 0.5); + + span { + position: absolute; + top: 6px; + left: 8px; + font-family: $font-mono; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + color: $accent-blue; + white-space: nowrap; + text-transform: uppercase; + } +} + +/* ── blocks ── */ +.epg-timeline__block { + position: absolute; + top: 38px; + bottom: 18px; + border-radius: 11px; + padding: 13px 14px; + overflow: hidden; + cursor: pointer; + border: 1px solid $line-strong; + background: $surface-1; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 6px; + transform: translateZ(0); + backface-visibility: hidden; + transition: + transform 120ms, + border-color 120ms, + background 120ms; + + &:hover { + transform: translate3d(0, -2px, 0); + border-color: $accent-blue; + z-index: 5; + } + + &:focus-visible { + outline: 2px solid $accent-blue; + outline-offset: 1px; + z-index: 5; + } + + &.is-past { + opacity: 0.52; + background: transparent; + + &:hover { + opacity: 0.9; + } + } + + &.is-now { + background: rgba(79, 142, 255, 0.1); + border-color: $accent-blue; + opacity: 1; + + .epg-timeline__block-title { + color: #fff; + font-weight: 600; + } + } + + &.is-playing { + background: rgba(79, 142, 255, 0.18); + border-color: $accent-blue; + opacity: 1; + + .epg-timeline__block-title { + color: #fff; + font-weight: 600; + } + } + + &.is-selected { + box-shadow: inset 0 0 0 2px $accent-blue; + } + + /* short programmes get a faint amber wash so they read as "brief" */ + &.is-short:not(.is-now):not(.is-playing) { + background: rgba(255, 210, 74, 0.06); + } +} + +.epg-timeline__fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + z-index: 0; + pointer-events: none; + + &--live { + background: rgba(92, 214, 255, 0.12); + border-right: 2px solid $accent-cyan; + } +} + +/* block-top fills the card height so the title can use every line that fits; + the foot is pinned at the bottom. */ +.epg-timeline__block-top { + position: relative; + z-index: 1; + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: 4px; + overflow: hidden; +} + +.epg-timeline__block-foot { + position: relative; + z-index: 1; + flex: 0 0 auto; +} + +.epg-timeline__block-time { + font-family: $font-mono; + font-size: 11px; + color: $text-tertiary; + white-space: nowrap; +} + +.epg-timeline__block-title { + font-size: 13px; + font-weight: 500; + line-height: 1.3; + color: $text-primary; + /* wrap onto as many lines as the card height allows, then clip (the + parent block-top has overflow:hidden). The generous line-clamp only + adds an ellipsis to genuinely long titles. */ + display: -webkit-box; + -webkit-line-clamp: 8; + line-clamp: 8; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +/* Wide-tier cards with a short title have spare vertical room — fill it with a + dimmed description preview that sits under the title. Only rendered for + tier=wide (see template). */ +.epg-timeline__block-desc { + flex: 0 0 auto; + min-height: 0; + margin: 0; + color: $text-secondary; + font-size: 11px; + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + overflow-wrap: anywhere; +} + +/* Whole-line clipping for wide cards: lock time/title/desc with flex-shrink:0 so + flexbox can never shrink them to a fractional (mid-line) height — each element + self-clips on whole lines with an ellipsis (via -webkit-line-clamp) instead of + being cut mid-line by the parent's overflow:hidden. The title takes ≤2 lines, + the description ≤3, leaving the rest as clean empty space above the foot. */ +.epg-timeline__block[data-tier='wide'] { + .epg-timeline__block-time, + .epg-timeline__block-title, + .epg-timeline__block-desc { + flex: 0 0 auto; + } + + .epg-timeline__block-title { + -webkit-line-clamp: 2; + line-clamp: 2; + } +} + +/* ── content tiers (B) ── */ +/* wide & med both wrap multi-line; the difference is only the available width. + (No single-line override for med — short titles should use the height.) */ + +/* narrow — vertical title, no time, centred */ +.epg-timeline__block[data-tier='narrow'] { + padding: 10px 3px; + align-items: center; + justify-content: center; + + .epg-timeline__block-time, + .epg-timeline__block-foot, + .epg-timeline__info { + display: none; + } + + .epg-timeline__block-top { + display: flex; + justify-content: center; + max-height: 100%; + overflow: hidden; + } + + .epg-timeline__block-title { + writing-mode: vertical-rl; + transform: rotate(180deg); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-height: 100%; + font-size: 11px; + color: $text-secondary; + display: block; + -webkit-line-clamp: initial; + } + + &.is-now .epg-timeline__block-title, + &.is-playing .epg-timeline__block-title { + color: #fff; + } +} + +/* micro — no text, just a marker tick */ +.epg-timeline__block[data-tier='micro'] { + padding: 0; + align-items: center; + justify-content: flex-start; + background: $surface-2; + + .epg-timeline__block-top, + .epg-timeline__block-foot, + .epg-timeline__info { + display: none; + } + + &::before { + content: ''; + width: 3px; + height: 22px; + border-radius: 2px; + background: $text-tertiary; + margin-top: 14px; + } + + &.is-now::before, + &.is-playing::before { + background: $accent-blue; + } +} + +/* ── tags / actions ── */ +.epg-timeline__tag { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: $font-mono; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + + &.is-now { + color: $accent-live; + + &::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: $accent-live; + box-shadow: 0 0 6px $accent-live; + } + } + + &.is-arch { + color: $accent-blue; + } +} + +.epg-timeline__watch { + display: none; + align-items: center; + gap: 6px; + height: 27px; + padding: 0 11px; + border-radius: 999px; + background: $surface-2; + border: 1px solid $line-strong; + color: $text-primary; + font: inherit; + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:hover { + border-color: $accent-blue; + color: $accent-blue; + } +} + +.epg-timeline__block:hover .epg-timeline__watch { + display: inline-flex; +} + +.epg-timeline__info { + position: absolute; + top: 9px; + right: 9px; + z-index: 2; + width: 26px; + height: 26px; + display: grid; + place-items: center; + border: none; + border-radius: 7px; + background: rgba(8, 11, 17, 0.66); + color: $text-secondary; + cursor: pointer; + opacity: 0; + transition: + opacity 120ms, + background 120ms, + color 120ms; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + &:hover { + background: $surface-3; + color: $text-primary; + } +} + +.epg-timeline__block:hover .epg-timeline__info, +.epg-timeline__block:focus-within .epg-timeline__info { + opacity: 1; +} + +/* ── group-of-shorts chip (E) ── */ +.epg-timeline__group { + position: absolute; + top: 38px; + bottom: 18px; + border-radius: 11px; + padding: 11px 10px; + cursor: pointer; + border: 1px dashed $line-strong; + background: repeating-linear-gradient( + 135deg, + $surface-1 0 7px, + $surface-2 7px 14px + ); + color: $text-primary; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + font: inherit; + text-align: left; + overflow: hidden; + + &:hover { + border-color: $accent-blue; + z-index: 5; + } +} + +.epg-timeline__group-count { + font-size: 15px; + font-weight: 700; + color: $text-primary; +} + +.epg-timeline__group-label { + font-size: 10.5px; + color: $text-tertiary; + line-height: 1.2; +} + +.epg-timeline__group-hint { + margin-top: auto; + font-family: $font-mono; + font-size: 9px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: $accent-blue; + white-space: nowrap; +} + +/* ── playhead ── */ +.epg-timeline__playhead { + position: absolute; + top: 30px; + bottom: 0; + width: 2px; + background: $accent-live; + box-shadow: 0 0 10px rgba(255, 91, 110, 0.7); + z-index: 6; + pointer-events: none; + + &::before { + content: ''; + position: absolute; + top: -5px; + left: -4px; + width: 10px; + height: 10px; + border-radius: 50%; + background: $accent-live; + box-shadow: 0 0 8px $accent-live; + } + + span { + position: absolute; + top: -22px; + left: 50%; + transform: translateX(-50%); + font-family: $font-mono; + font-size: 9.5px; + font-weight: 700; + color: #fff; + background: $accent-live; + padding: 1px 5px; + border-radius: 4px; + white-space: nowrap; + } +} + +/* ── hover/focus popover (C) ── */ +.epg-timeline__pop { + position: fixed; + z-index: 1200; + width: 248px; + background: $surface-1; + border: 1px solid $line-strong; + border-radius: var(--r-md, 10px); + box-shadow: 0 20px 44px rgba(0, 0, 0, 0.6); + padding: 13px 14px; + pointer-events: none; +} + +.epg-timeline__pop-time { + font-family: $font-mono; + font-size: 10.5px; + color: $accent-cyan; + margin-bottom: 5px; +} + +.epg-timeline__pop-title { + font-size: 13.5px; + font-weight: 600; + line-height: 1.3; + color: $text-primary; + margin-bottom: 6px; +} + +.epg-timeline__pop-desc { + font-size: 12px; + color: $text-secondary; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 4; + line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@media (prefers-reduced-motion: reduce) { + .epg-timeline__block { + transition: none; + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts new file mode 100644 index 000000000..21a63db30 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.spec.ts @@ -0,0 +1,178 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { EpgTimelineTrackComponent } from './epg-timeline-track.component'; +import { TimelineRenderBlock } from './epg-timeline.utils'; + +function renderBlock( + overrides: Partial = {} +): TimelineRenderBlock { + const startMs = Date.now(); + return { + kind: 'block', + key: 'k1', + leftPx: 0, + widthPx: 40, + tier: 'narrow', + nowFillPercent: 0, + canCatchUp: false, + block: { + key: 'k1', + program: { + start: new Date(startMs).toISOString(), + stop: new Date(startMs + 5 * 60_000).toISOString(), + channel: 'ch', + title: 'Breaking News', + desc: 'A short bulletin.', + category: null, + }, + startMs, + stopMs: startMs + 5 * 60_000, + when: 'future', + offsetMin: 0, + durationMin: 5, + }, + ...overrides, + }; +} + +describe('EpgTimelineTrackComponent', () => { + let fixture: ComponentFixture; + let component: EpgTimelineTrackComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EpgTimelineTrackComponent], + providers: [ + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + onTranslationChange: new BehaviorSubject(null), + onDefaultLangChange: new BehaviorSubject(null), + get: () => new BehaviorSubject(''), + stream: () => new BehaviorSubject(''), + instant: (key: string) => key, + }, + }, + ], + }); + fixture = TestBed.createComponent(EpgTimelineTrackComponent); + component = fixture.componentInstance; + }); + + it('shows a popover for a non-wide block on enter', () => { + const item = renderBlock({ tier: 'narrow' }); + const event = { + currentTarget: { + getBoundingClientRect: () => ({ + left: 100, + width: 40, + top: 60, + bottom: 200, + }), + }, + } as unknown as Event; + + component.onBlockEnter(item, event); + expect(component.popover()?.title).toBe('Breaking News'); + component.onBlockLeave(); + expect(component.popover()).toBeNull(); + }); + + it('never shows a popover for a wide block', () => { + const item = renderBlock({ tier: 'wide' }); + component.onBlockEnter(item, { + currentTarget: { + getBoundingClientRect: () => ({ left: 0, width: 200, bottom: 0 }), + }, + } as unknown as Event); + expect(component.popover()).toBeNull(); + }); + + function wideBlock( + desc: string | null, + title = 'Short' + ): TimelineRenderBlock { + const base = renderBlock({ tier: 'wide', widthPx: 200 }); + return { + ...base, + block: { + ...base.block, + program: { ...base.block.program, title, desc }, + }, + }; + } + + it('renders a dimmed description preview inside a wide block with a description', () => { + fixture.componentRef.setInput('items', [ + wideBlock('First sentence of the description.'), + ]); + fixture.detectChanges(); + + const desc = fixture.nativeElement.querySelector( + '.epg-timeline__block-desc' + ); + expect(desc).toBeTruthy(); + expect(desc.textContent.trim()).toContain('First sentence'); + }); + + it('omits the description preview for non-wide tiers', () => { + // renderBlock() defaults to a 'med' tier with a non-empty desc. + fixture.componentRef.setInput('items', [ + renderBlock({ tier: 'med', widthPx: 80 }), + ]); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.epg-timeline__block-desc') + ).toBeNull(); + }); + + it('omits the description preview for a wide block without a description', () => { + fixture.componentRef.setInput('items', [wideBlock(null)]); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('.epg-timeline__block-desc') + ).toBeNull(); + }); + + it('marks the active programme as playing', () => { + const item = renderBlock(); + fixture.componentRef.setInput('items', [item]); + fixture.componentRef.setInput('activeProgram', item.block.program); + expect(component.isPlaying(item)).toBe(true); + expect(component.isSelected(item)).toBe(false); + + fixture.componentRef.setInput('selectedKey', item.block.key); + expect(component.isSelected(item)).toBe(true); + }); + + it('activates the block on Enter only when the block itself is focused', () => { + const item = renderBlock(); + const emitted: unknown[] = []; + component.blockClick.subscribe((b) => emitted.push(b)); + + const node = {} as EventTarget; + // Enter with the block itself as the target → activates. + component.onBlockActivate( + { target: node, currentTarget: node } as unknown as Event, + item.block + ); + expect(emitted).toHaveLength(1); + + // Enter bubbled up from a nested watch/info button → ignored, so a + // keyboard user gets the button's own action, not timeshift playback. + component.onBlockActivate( + { + target: {} as EventTarget, + currentTarget: node, + } as unknown as Event, + item.block + ); + expect(emitted).toHaveLength(1); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts new file mode 100644 index 000000000..ef5bd719b --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.ts @@ -0,0 +1,140 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + HostBinding, + input, + output, + signal, +} from '@angular/core'; +import { MatIcon } from '@angular/material/icon'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslatePipe } from '@ngx-translate/core'; +import { areProgramsSame } from '../epg-list/epg-list.utils'; +import { + TimelineBlock, + TimelineDayDivider, + TimelineRenderBlock, + TimelineRenderGroup, + TimelineRenderItem, + TimelineTick, +} from './epg-timeline.utils'; + +interface PopoverState { + readonly title: string; + readonly desc: string | null; + readonly startMs: number; + readonly stopMs: number; + readonly durationMin: number; + readonly left: number; + readonly top: number; +} + +/** + * The scrolling ribbon canvas: ticks, day dividers, programme blocks (with + * width-adaptive content tiers), short-run group chips, the live playhead, and + * the hover/focus popover that reveals truncated programme names. + */ +@Component({ + selector: 'app-epg-timeline-track', + templateUrl: './epg-timeline-track.component.html', + styleUrl: './epg-timeline-track.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DatePipe, MatIcon, TranslatePipe], +}) +export class EpgTimelineTrackComponent { + readonly items = input([]); + readonly ticks = input([]); + readonly dividers = input([]); + readonly scale = input(1); + readonly trackWidthPx = input(0); + readonly playheadLeftPx = input(0); + readonly nowLabel = input(''); + readonly selectedKey = input(null); + readonly activeProgram = input(null); + readonly currentLocale = input('en'); + + readonly blockClick = output(); + readonly watchClick = output(); + readonly infoClick = output(); + readonly groupExpand = output(); + + readonly popover = signal(null); + + @HostBinding('style.width.px') get hostWidth(): number { + return this.trackWidthPx(); + } + + readonly activeKey = computed(() => { + const active = this.activeProgram(); + if (!active) { + return null; + } + const match = this.items().find( + (item): item is TimelineRenderBlock => + item.kind === 'block' && + areProgramsSame(item.block.program, active) + ); + return match?.key ?? null; + }); + + isSelected(item: TimelineRenderBlock): boolean { + return this.selectedKey() === item.block.key; + } + + isPlaying(item: TimelineRenderBlock): boolean { + return this.activeKey() === item.key; + } + + /** + * Emit block activation for keyboard Enter only when the block itself is + * focused. Enter on a nested watch/info button bubbles here too — ignore it + * so keyboard users get the button's own action, matching mouse clicks + * (which `stopPropagation()` on those buttons). + */ + onBlockActivate(event: Event, block: TimelineBlock): void { + if (event.target === event.currentTarget) { + this.blockClick.emit(block); + } + } + + onBlockEnter(item: TimelineRenderBlock, event: Event): void { + if (item.tier === 'wide') { + this.popover.set(null); + return; + } + const target = event.currentTarget as HTMLElement | null; + if (!target) { + return; + } + const rect = target.getBoundingClientRect(); + const width = 248; + const estHeight = 132; + // The EPG panel sits at the bottom of the screen, so flip the popover + // above the block when there isn't room for it below. + const below = rect.bottom + 8; + const flipAbove = below + estHeight > window.innerHeight; + this.popover.set({ + title: item.block.program.title, + desc: item.block.program.desc, + startMs: item.block.startMs, + stopMs: item.block.stopMs, + durationMin: Math.round(item.block.durationMin), + left: Math.max( + 12, + Math.min( + window.innerWidth - width - 12, + rect.left + rect.width / 2 - width / 2 + ) + ), + top: flipAbove + ? Math.max(12, rect.top - estHeight - 8) + : below, + }); + } + + onBlockLeave(): void { + this.popover.set(null); + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html new file mode 100644 index 000000000..cabb39a71 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html @@ -0,0 +1,220 @@ +@let state = renderState(); + +
+ +
+ + + + + @if (collapsed()) { + @if (loading()) { + {{ + 'EPG.LOADING' | translate + }} + } @else if (hasSummary()) { + @if (isLivePlayback()) { + {{ + 'EPG.TIMELINE.ON_NOW' | translate + }} + {{ + summary()?.title + }} + @if (progress() !== null) { + + } + @if (minutesLeft() !== null) { + {{ minutesLeft() }} + {{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }} + } + } @else { + + play_arrow + {{ 'EPG.ARCHIVE_PLAYBACK' | translate }} + + {{ + summary()?.title + }} + @if (hasTimeRange()) { + {{ summary()?.start | date: 'HH:mm' }} – + {{ summary()?.stop | date: 'HH:mm' }} + } + } + } @else { + {{ 'EPG.NO_PROGRAM_INFO' | translate }} + } + } @else { + @if (!isLivePlayback()) { + + } + @if (showRibbonControls()) { + + + } + @if (showDateStepper()) { +
+ +
+ + @if (isViewToday()) { + {{ 'EPG.TIMELINE.TODAY' | translate }} + } @else { + {{ + viewDate() + | date: 'EEE' : '' : currentLocale() + }} + } + + {{ + viewDate() | date: 'EEE, d MMM' : '' : currentLocale() + }} +
+ +
+ } + } +
+ + @if (!collapsed()) { + + @if (state === 'ribbon' && !archivePlaybackAvailable()) { +
+ info + {{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }} +
+ } + + @switch (state) { + @case ('loading') { +
+ @for (width of skeletonWidths; track $index) { +
+ } +
+ } + @case ('ribbon') { +
+ +
+ } + @default { + + } + } + } +
diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss new file mode 100644 index 000000000..70421dd4c --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss @@ -0,0 +1,433 @@ +$line: var(--line, rgba(255, 255, 255, 0.06)); +$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1)); +$surface-1: var(--surface-1, #0f141c); +$surface-2: var(--surface-2, #161d28); +$surface-3: var(--surface-3, #1d2533); +$text-primary: var(--text-primary, #e7ecf3); +$text-secondary: var(--text-secondary, #9aa3b2); +$text-tertiary: var(--text-tertiary, #6b7384); +$accent-blue: var(--accent-blue, #4f8eff); +$accent-cyan: var(--accent-cyan, #5cd6ff); +$accent-live: var(--accent-live, #ff5b6e); +$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace); + +/* This app has no global border-box reset; the timeline relies on it. */ +.epg-timeline, +.epg-timeline * { + box-sizing: border-box; +} + +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: #0a0e15; +} + +.epg-timeline { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +/* ── toolbar (single header bar) ── */ +.epg-timeline__toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 16px; + border-bottom: 1px solid $line; + background: $surface-1; + flex: 0 0 auto; +} + +.epg-timeline--collapsed .epg-timeline__toolbar { + border-bottom: none; +} + +.epg-timeline__spacer { + flex: 1; + min-width: 8px; +} + +/* collapse toggle + channel name */ +.epg-timeline__collapse { + display: inline-flex; + align-items: center; + gap: 9px; + background: none; + border: none; + color: $text-primary; + cursor: pointer; + padding: 4px 6px; + border-radius: 7px; + font: inherit; + min-width: 0; + max-width: 60%; + + &:hover { + background: $surface-2; + } +} + +.epg-timeline__collapse-icon { + width: 18px; + height: 18px; + display: grid; + place-items: center; + color: $text-secondary; + flex: 0 0 auto; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + transition: transform 200ms; + } +} + +.epg-timeline--collapsed .epg-timeline__collapse-icon mat-icon { + transform: rotate(-90deg); +} + +.epg-timeline__heading { + text-align: left; + line-height: 1.15; + min-width: 0; + overflow: hidden; + + b { + font-size: 13px; + font-weight: 600; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + font-family: $font-mono; + font-size: 9.5px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $text-tertiary; + } + + /* dynamic subtitle: the now-playing / archive programme title — a readable + title, not the uppercase mono source label. Nested for higher specificity + than the generic `span` rule above. */ + .epg-timeline__subtitle { + display: flex; + align-items: center; + gap: 4px; + font-family: inherit; + font-size: 11.5px; + letter-spacing: normal; + text-transform: none; + color: $text-secondary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + mat-icon { + flex: 0 0 auto; + width: 13px; + height: 13px; + font-size: 13px; + line-height: 13px; + } + + &.is-arch { + color: $accent-cyan; + } + } +} + +/* ── right-side controls ── */ +.epg-timeline__livebtn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 14px; + border-radius: 999px; + background: rgba(255, 91, 110, 0.14); + border: 1px solid rgba(255, 91, 110, 0.32); + color: $accent-live; + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + flex: 0 0 auto; + + &::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 50%; + background: $accent-live; + box-shadow: 0 0 7px $accent-live; + } +} + +.epg-timeline__jump { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 13px; + border-radius: 999px; + background: $surface-2; + border: 1px solid $line-strong; + color: $text-secondary; + font: inherit; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + flex: 0 0 auto; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:hover { + color: $text-primary; + border-color: $accent-blue; + } +} + +.epg-timeline__stepper { + display: inline-flex; + align-items: center; + gap: 4px; + height: 34px; + background: $surface-2; + border: 1px solid $line; + border-radius: 999px; + padding: 2px; + flex: 0 0 auto; +} + +.epg-timeline__nav { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border: none; + border-radius: 50%; + background: transparent; + color: $text-secondary; + cursor: pointer; + + mat-icon { + font-size: 15px; + width: 15px; + height: 15px; + } + + &:hover { + background: $surface-3; + color: $text-primary; + } +} + +.epg-timeline__day { + font-size: 12.5px; + font-weight: 500; + padding: 0 6px; + min-width: 112px; + text-align: center; + line-height: 1.1; + color: $text-primary; + + small { + display: block; + font-family: $font-mono; + font-size: 9px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $text-tertiary; + margin-top: 1px; + } +} + +/* ── collapsed inline summary ── */ +.epg-timeline__summary-tag { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: $font-mono; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + flex: 0 0 auto; + + mat-icon { + font-size: 13px; + width: 13px; + height: 13px; + } + + &.is-live { + color: $accent-live; + + &::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: $accent-live; + box-shadow: 0 0 6px $accent-live; + } + } + + &.is-arch { + color: $accent-blue; + } +} + +.epg-timeline__summary-title { + font-size: 13px; + font-weight: 600; + color: $text-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1; + + &--muted { + color: $text-tertiary; + font-weight: 500; + } +} + +.epg-timeline__summary-bar { + width: 84px; + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.12); + overflow: hidden; + flex: 0 0 auto; + + i { + display: block; + height: 100%; + background: $accent-cyan; + } +} + +.epg-timeline__summary-meta { + font-family: $font-mono; + font-size: 11px; + color: $text-tertiary; + flex: 0 0 auto; +} + +/* ── schedule-only notice ── */ +.epg-timeline__notice { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + background: rgba(79, 142, 255, 0.07); + border-bottom: 1px solid $line; + color: $text-secondary; + font-size: 12.5px; + flex: 0 0 auto; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: $accent-blue; + flex: 0 0 auto; + } +} + +/* ── zoom slider ── */ +.epg-timeline__zoom { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 12px; + border-radius: 999px; + background: $surface-2; + border: 1px solid $line; + color: $text-tertiary; + flex: 0 0 auto; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + input[type='range'] { + width: 96px; + accent-color: $accent-blue; + cursor: pointer; + } +} + +/* ── ribbon ── */ +.epg-timeline__ribbon { + flex: 1 1 auto; + min-height: 96px; + overflow-x: auto; + overflow-y: hidden; + background: #0a0e15; +} + +.epg-timeline__ribbon > app-epg-timeline-track { + display: block; + height: 100%; +} + +/* ── skeleton ── */ +.epg-timeline__ribbon--skeleton { + display: flex; + align-items: center; + gap: 10px; + padding: 22px 18px; + overflow: hidden; +} + +.epg-timeline__sk { + height: 128px; + border-radius: 11px; + border: 1px solid $line; + background: linear-gradient( + 100deg, + $surface-1 30%, + $surface-2 50%, + $surface-1 70% + ); + background-size: 220% 100%; + animation: epg-timeline-shimmer 1.4s linear infinite; + flex: 0 0 auto; +} + +@keyframes epg-timeline-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -20% 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .epg-timeline__sk { + animation: none; + } + .epg-timeline__block { + transition: none; + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts new file mode 100644 index 000000000..54d88a3b5 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.spec.ts @@ -0,0 +1,241 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, of } from 'rxjs'; +import { EpgTimelineComponent } from './epg-timeline.component'; +import { TimelineBlock } from './epg-timeline.utils'; + +function programAt( + startOffsetMin: number, + durationMin: number, + title = 'P' +): EpgProgram { + const start = new Date(Date.now() + startOffsetMin * 60_000); + const stop = new Date(start.getTime() + durationMin * 60_000); + return { + start: start.toISOString(), + stop: stop.toISOString(), + channel: 'ch', + title, + desc: null, + category: null, + }; +} + +/** Local yyyy-MM-dd day-key for a programme start (matches the component). */ +function localDateKey(iso: string): string { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +describe('EpgTimelineComponent', () => { + let fixture: ComponentFixture; + let component: EpgTimelineComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [EpgTimelineComponent], + providers: [ + { + provide: MatDialog, + useValue: { open: () => ({ afterClosed: () => of(undefined) }) }, + }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + onLangChange: new BehaviorSubject(null), + }, + }, + ], + }); + + fixture = TestBed.createComponent(EpgTimelineComponent); + component = fixture.componentInstance; + }); + + function setInputs(inputs: Record): void { + for (const [key, value] of Object.entries(inputs)) { + fixture.componentRef.setInput(key, value); + } + } + + it('renders the ribbon when programmes exist for the current day', () => { + // start at "now" (offset 0) so the date-key is always today, even when + // the suite runs just after local midnight. + setInputs({ programs: [programAt(0, 120, 'Now')] }); + expect(component.renderState()).toBe('ribbon'); + expect(component.emptyStateReason()).toBe('none'); + }); + + it('shows the loading state regardless of programmes', () => { + setInputs({ programs: [programAt(0, 60)], loading: true }); + expect(component.renderState()).toBe('loading'); + }); + + it('falls back to channel-unmapped when there are no programmes', () => { + setInputs({ programs: [] }); + expect(component.renderState()).toBe('channel-unmapped'); + expect(component.emptyStateReason()).toBe('channel-unmapped'); + }); + + it('honours an explicit empty reason from the host', () => { + setInputs({ programs: [], emptyReason: 'provider-no-epg' }); + expect(component.renderState()).toBe('provider-no-epg'); + }); + + it('reports empty-day when programmes exist but not for the viewed day', () => { + // A programme three days from now: today has nothing. + setInputs({ programs: [programAt(3 * 1440, 60)] }); + expect(component.renderState()).toBe('empty-day'); + }); + + it('honours a controlled non-today selectedDate instead of resetting to today', () => { + // Data three days out (today empty). Without honouring the controlled + // input the panel would show empty-day for today; with it, the ribbon + // renders on the host-selected day. + const future = programAt(3 * 1440, 60); + setInputs({ + programs: [future], + selectedDate: localDateKey(future.start), + }); + expect(component.renderState()).toBe('ribbon'); + }); + + it('follows a programmatic selectedDate change from the host', () => { + const future = programAt(3 * 1440, 60); + setInputs({ programs: [future] }); + expect(component.renderState()).toBe('empty-day'); // viewing today + setInputs({ selectedDate: localDateKey(future.start) }); + expect(component.renderState()).toBe('ribbon'); // followed the host + }); + + it('shows the ribbon controls (Now + zoom) only while a ribbon renders', () => { + setInputs({ programs: [programAt(0, 120, 'Now')] }); + expect(component.showRibbonControls()).toBe(true); + + setInputs({ programs: [programAt(3 * 1440, 60)] }); // empty-day + expect(component.showRibbonControls()).toBe(false); + + setInputs({ programs: [] }); // channel-unmapped + expect(component.showRibbonControls()).toBe(false); + + setInputs({ programs: [programAt(0, 120)], loading: true }); + expect(component.showRibbonControls()).toBe(false); + }); + + it('shows the date stepper for ribbon + empty-day, hides it with no EPG anywhere', () => { + setInputs({ programs: [programAt(0, 120)] }); + expect(component.showDateStepper()).toBe(true); // ribbon + + setInputs({ programs: [programAt(3 * 1440, 60)] }); + expect(component.renderState()).toBe('empty-day'); + expect(component.showDateStepper()).toBe(true); + + setInputs({ programs: [] }); + expect(component.renderState()).toBe('channel-unmapped'); + expect(component.showDateStepper()).toBe(false); + + setInputs({ programs: [], emptyReason: 'provider-no-epg' }); + expect(component.showDateStepper()).toBe(false); + }); + + it('only offers catch-up on past blocks when archive is available', () => { + setInputs({ + programs: [programAt(-180, 60, 'Earlier')], + archivePlaybackAvailable: true, + archiveDays: 7, + }); + const past = component.blocks().find((b) => b.when === 'past'); + expect(past).toBeDefined(); + expect(component.canCatchUp(past as TimelineBlock)).toBe(true); + + setInputs({ archivePlaybackAvailable: false }); + expect(component.canCatchUp(past as TimelineBlock)).toBe(false); + }); + + it('emits a timeshift activation when a catch-up block is clicked', () => { + setInputs({ + programs: [programAt(-180, 60, 'Earlier')], + archivePlaybackAvailable: true, + archiveDays: 7, + }); + const past = component.blocks().find((b) => b.when === 'past'); + expect(past).toBeDefined(); + const events: string[] = []; + component.programActivated.subscribe((e) => events.push(e.type)); + + component.onBlockClick(past as TimelineBlock); + expect(events).toEqual(['timeshift']); + }); + + it('emits returnToLive when the on-now block is clicked', () => { + setInputs({ programs: [programAt(-30, 60, 'Live')] }); + const now = component.blocks().find((b) => b.when === 'now'); + expect(now).toBeDefined(); + let returned = false; + component.returnToLive.subscribe(() => (returned = true)); + + component.onBlockClick(now as TimelineBlock); + expect(returned).toBe(true); + }); + + it('emits the centred day when stepping forward', () => { + setInputs({ programs: [programAt(-30, 60)] }); + const emitted: string[] = []; + component.selectedDateChange.subscribe((d) => emitted.push(d)); + + component.stepDay('next'); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('toggles the collapsed state through the output', () => { + setInputs({ collapsed: false }); + const emitted: boolean[] = []; + component.collapsedChange.subscribe((v) => emitted.push(v)); + + component.toggleCollapsed(); + expect(emitted).toEqual([true]); + }); + + it('derives a live progress percentage from the summary', () => { + const start = new Date(Date.now() - 30 * 60_000).toISOString(); + const stop = new Date(Date.now() + 30 * 60_000).toISOString(); + setInputs({ collapsed: true, summary: { title: 'Now', start, stop } }); + + expect(component.hasSummary()).toBe(true); + expect(component.progress()).toBeGreaterThan(40); + expect(component.progress()).toBeLessThan(60); + expect(component.minutesLeft()).toBeGreaterThan(0); + }); + + it('clamps the zoom and rescales render items', () => { + setInputs({ programs: [programAt(-60, 120, 'Wide')] }); + const before = component.renderItems().length; + + component.onZoom(99); + expect(component.scale()).toBe(component.zoomMax); + component.onZoom(0); + expect(component.scale()).toBe(component.zoomMin); + // render items still produced after zooming + expect(component.renderItems().length).toBe(before); + }); + + it('expands a group chip by zooming in', () => { + setInputs({ programs: [programAt(-60, 120, 'Wide')] }); + component.onGroupExpand({ + kind: 'group', + key: 'g', + leftPx: 0, + widthPx: 40, + count: 4, + startMs: Date.now() - 60 * 60_000, + stopMs: Date.now(), + }); + expect(component.scale()).toBeGreaterThan(3); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts new file mode 100644 index 000000000..6f70851ae --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.ts @@ -0,0 +1,400 @@ +import { DatePipe } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + ElementRef, + inject, + input, + linkedSignal, + output, + signal, + untracked, + viewChild, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { normalizeDateLocale } from '@iptvnator/pipes'; +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { startWith } from 'rxjs'; +import { + EpgDateNavigationDirection, + getTodayEpgDateKey, + parseEpgDateKey, + shiftEpgDateKey, +} from '../epg-date'; +import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component'; +import type { EpgProgramActivationEvent } from '../epg-list/epg-list.component'; +import { EpgProgrammeDialogService } from '../epg-programme-dialog.service'; +import { + EpgTimelineSummary, + formatClockTime, + summaryHasTimeRange, + summaryHasTitle, + summaryMinutesLeft, + summaryProgress, +} from './epg-summary.util'; +import { + canCatchUpProgramme, + epgDialogActionFor, +} from './epg-archive.util'; +import { + EpgTimelineEmptyReason, + EpgTimelineEmptyStateComponent, +} from './epg-timeline-empty-state.component'; +import { TimelineScrollController } from './epg-timeline-scroll.controller'; +import { EpgTimelineTrackComponent } from './epg-timeline-track.component'; +import { + buildTimelineAxis, + buildTimelineBlocks, + buildTimelineDayDividers, + buildTimelineRenderItems, + buildTimelineTicks, + hasProgramsForDateKey, + nearestDateKeyWithPrograms, + TIMELINE_DEFAULT_SCALE, + TIMELINE_GROUP_EXPAND_ZOOM, + TIMELINE_GROUP_ZOOM_MAX, + TIMELINE_MINUTE_MS, + TIMELINE_ZOOM_MAX, + TIMELINE_ZOOM_MIN, + TIMELINE_ZOOM_STEP, + TimelineBlock, + TimelineRenderGroup, + timelineTickStepForScale, +} from './epg-timeline.utils'; + +type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason; + +export type { EpgTimelineSummary } from './epg-summary.util'; + +@Component({ + selector: 'app-epg-timeline', + templateUrl: './epg-timeline.component.html', + styleUrl: './epg-timeline.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DatePipe, + EpgTimelineEmptyStateComponent, + EpgTimelineTrackComponent, + MatIcon, + MatTooltip, + TranslatePipe, + ], +}) +export class EpgTimelineComponent { + readonly programs = input([]); + readonly channelName = input(''); + readonly channelLogo = input(''); + readonly sourceLabel = input('Timeline'); + readonly archivePlaybackAvailable = input(false); + readonly archiveDays = input(0); + readonly activeProgram = input(null); + readonly isLivePlayback = input(true); + readonly loading = input(false); + readonly emptyReason = input('none'); + readonly selectedDate = input(null); + readonly collapsed = input(false); + readonly summary = input(null); + readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM'); + + readonly programActivated = output(); + readonly returnToLive = output(); + readonly selectedDateChange = output(); + readonly openEpgSettings = output(); + readonly retry = output(); + readonly collapsedChange = output(); + + private readonly programmeDialog = inject(EpgProgrammeDialogService); + private readonly translate = inject(TranslateService); + + readonly ribbon = viewChild>('ribbon'); + /** px-per-minute zoom (D). */ + readonly scale = signal(TIMELINE_DEFAULT_SCALE); + readonly zoomMin = TIMELINE_ZOOM_MIN; + readonly zoomMax = TIMELINE_ZOOM_MAX; + readonly zoomStep = TIMELINE_ZOOM_STEP; + readonly skeletonWidths = [120, 170, 150, 200, 140, 180]; + + private readonly nowMs = signal(Date.now()); + readonly selectedKey = signal(null); + /** Day centred in the ribbon, seeded from the controlled `selectedDate` so + * a non-today date survives (re)mount and follows host changes. */ + private readonly viewDayKey = linkedSignal(() => { + const key = this.selectedDate()?.trim(); + return key ? key : getTodayEpgDateKey(); + }); + + /** Ribbon scrolling + channel-select auto-focus, extracted from the view. */ + private readonly scroll = new TimelineScrollController({ + ribbon: () => this.ribbon()?.nativeElement, + scale: () => this.scale(), + axis: () => this.axis(), + blocks: () => this.blocks(), + nowMs: () => this.nowMs(), + viewDayKey: () => this.viewDayKey(), + commitDay: (dayKey) => this.commitDay(dayKey), + hasProgramsForDay: (dayKey) => + hasProgramsForDateKey(this.programs(), dayKey), + }); + + 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 axis = computed(() => + buildTimelineAxis(this.programs(), this.nowMs()) + ); + readonly blocks = computed(() => + buildTimelineBlocks(this.programs(), this.axis(), this.nowMs()) + ); + private readonly archiveWindowStartMs = computed(() => { + const days = this.archiveDays(); + return days > 0 + ? this.nowMs() - days * 24 * 60 * TIMELINE_MINUTE_MS + : Number.NEGATIVE_INFINITY; + }); + readonly renderItems = computed(() => + buildTimelineRenderItems(this.blocks(), this.scale(), { + allowGroup: this.scale() < TIMELINE_GROUP_ZOOM_MAX, + nowMs: this.nowMs(), + archivePlaybackAvailable: this.archivePlaybackAvailable(), + archiveWindowStartMs: this.archiveWindowStartMs(), + }) + ); + readonly ticks = computed(() => + buildTimelineTicks(this.axis(), timelineTickStepForScale(this.scale())) + ); + readonly dividers = computed(() => buildTimelineDayDividers(this.axis())); + readonly trackWidthPx = computed(() => { + const axis = this.axis(); + return ((axis.endMs - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale(); + }); + readonly playheadLeftPx = computed(() => { + const axis = this.axis(); + return ((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale(); + }); + readonly zoomLabelKey = computed(() => { + const scale = this.scale(); + if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'EPG.TIMELINE.ZOOM_DAY'; + if (scale < 3) return 'EPG.TIMELINE.ZOOM_HOURS'; + return 'EPG.TIMELINE.ZOOM_DETAIL'; + }); + readonly nowLabel = computed(() => formatClockTime(this.nowMs())); + + readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey())); + readonly isViewToday = computed( + () => this.viewDayKey() === getTodayEpgDateKey() + ); + + readonly renderState = computed(() => { + if (this.loading()) { + return 'loading'; + } + const reason = this.emptyReason(); + if (reason !== 'none') { + return reason; + } + if (this.programs().length === 0) { + return 'channel-unmapped'; + } + if (!hasProgramsForDateKey(this.programs(), this.viewDayKey())) { + return 'empty-day'; + } + return 'ribbon'; + }); + readonly emptyStateReason = computed(() => { + const state = this.renderState(); + return state === 'loading' || state === 'ribbon' ? 'none' : state; + }); + readonly hasOtherDays = computed(() => this.programs().length > 0); + + /** + * "Now" + zoom act on the ribbon, so they only make sense when a ribbon is + * rendered. Hidden (not disabled) otherwise — a disabled control implies it + * could become usable, but with no programmes for the day there is nothing + * to jump to or zoom. + */ + readonly showRibbonControls = computed(() => this.renderState() === 'ribbon'); + + /** + * The date stepper navigates between days, which is only meaningful when the + * channel has EPG on *some* day: the live ribbon, or `empty-day` (data + * exists, just not the viewed day). Hidden for the no-EPG-anywhere states + * (`channel-unmapped`, `provider-no-epg`, `m3u-needs-setup`, `error`) and + * while loading. + */ + readonly showDateStepper = computed(() => { + const state = this.renderState(); + return state === 'ribbon' || state === 'empty-day'; + }); + + // ── collapsed-summary state ── + readonly hasSummary = computed(() => summaryHasTitle(this.summary())); + readonly hasTimeRange = computed(() => + summaryHasTimeRange(this.summary()) + ); + readonly progress = computed(() => + summaryProgress(this.summary(), this.nowMs()) + ); + readonly minutesLeft = computed(() => + summaryMinutesLeft(this.summary(), this.nowMs()) + ); + + constructor() { + effect((onCleanup) => { + const intervalId = window.setInterval( + () => this.nowMs.set(Date.now()), + 30_000 + ); + onCleanup(() => clearInterval(intervalId)); + }); + + // Auto-focus the current programme whenever a channel's EPG (re)loads + // or the ribbon (re)mounts — this is what makes selecting a channel land + // on "now" without the user pressing the Now button. Tracked deps are + // only `ribbon` + `programs`; the body runs untracked so 30s "now" ticks + // and zoom changes never re-trigger a jump. + effect(() => { + const scroller = this.ribbon()?.nativeElement; + const programs = this.programs(); + untracked(() => this.scroll.maybeAutoFocus(scroller, programs)); + }); + } + + toggleCollapsed(): void { + this.collapsedChange.emit(!this.collapsed()); + } + + onZoom(value: number): void { + const requested = Number(value); + const next = Number.isFinite(requested) + ? Math.min(this.zoomMax, Math.max(this.zoomMin, requested)) + : this.scale(); + // Keep the viewport centre stable across a zoom change. + const scroller = this.ribbon()?.nativeElement; + const prev = this.scale(); + const centreMin = scroller + ? (scroller.scrollLeft + scroller.clientWidth / 2) / prev + : null; + this.scale.set(next); + if (scroller && centreMin !== null) { + requestAnimationFrame(() => { + scroller.scrollLeft = + centreMin * next - scroller.clientWidth / 2; + }); + } + } + + onGroupExpand(group: TimelineRenderGroup): void { + this.scale.set(TIMELINE_GROUP_EXPAND_ZOOM); + const axis = this.axis(); + const centreMs = (group.startMs + group.stopMs) / 2; + const offsetMin = (centreMs - axis.startMs) / TIMELINE_MINUTE_MS; + this.scroll.scrollToOffset(offsetMin, 0.5); + } + + canCatchUp(block: TimelineBlock): boolean { + return canCatchUpProgramme( + block.when, + block.startMs, + this.archivePlaybackAvailable(), + this.archiveDays(), + this.nowMs() + ); + } + + onBlockClick(block: TimelineBlock): void { + this.selectedKey.set(block.key); + if (block.when === 'now') { + this.returnToLive.emit(); + return; + } + if (this.canCatchUp(block)) { + this.programActivated.emit({ + program: block.program, + type: 'timeshift', + }); + } + } + + onWatch(block: TimelineBlock): void { + this.selectedKey.set(block.key); + this.programActivated.emit({ + program: block.program, + type: 'timeshift', + }); + } + + openDetails(block: TimelineBlock): void { + this.programmeDialog + .open({ + ...block.program, + channelName: this.channelName(), + channelLogo: this.channelLogo(), + primaryAction: this.dialogActionFor(block), + archiveUnavailableNote: + block.when === 'past' && !this.archivePlaybackAvailable(), + }) + .subscribe((result: EpgItemDialogAction | undefined) => { + if (result === 'live') { + this.returnToLive.emit(); + } else if (result === 'timeshift') { + this.selectedKey.set(block.key); + this.programActivated.emit({ + program: block.program, + type: 'timeshift', + }); + } + }); + } + + stepDay(direction: EpgDateNavigationDirection): void { + const nextKey = shiftEpgDateKey(this.viewDayKey(), direction); + this.commitDay(nextKey); + this.scroll.scrollToDateKey(nextKey, 0.5); + } + + jumpToNow(): void { + this.commitDay(getTodayEpgDateKey()); + // Deliberate user action → animate so the movement reads. + this.scroll.focusCurrentProgram(true); + } + + jumpToNearestDay(): void { + const nearest = nearestDateKeyWithPrograms( + this.programs(), + this.nowMs() + ); + if (nearest) { + this.commitDay(nearest); + this.scroll.scrollToDateKey(nearest, 0.5); + } + } + + onRibbonScroll(): void { + this.scroll.onRibbonScroll(); + } + + private commitDay(dayKey: string): void { + if (dayKey === this.viewDayKey()) { + return; + } + this.viewDayKey.set(dayKey); + this.selectedDateChange.emit(dayKey); + } + + private dialogActionFor(block: TimelineBlock): EpgItemDialogAction | null { + return epgDialogActionFor(block.when, this.canCatchUp(block)); + } +} diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts new file mode 100644 index 000000000..f8edc1ca5 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.spec.ts @@ -0,0 +1,312 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { + buildTimelineAxis, + buildTimelineBlocks, + buildTimelineDayDividers, + buildTimelineRenderItems, + buildTimelineTicks, + classifyTimelineWhen, + dayKeyAtOffset, + hasProgramsForDateKey, + nearestDateKeyWithPrograms, + TIMELINE_MINUTE_MS, + TIMELINE_MIN_BLOCK_WIDTH_PX, + TimelineRenderBlock, + TimelineRenderGroup, + tierFor, + timelineTickStepForScale, +} from './epg-timeline.utils'; + +function localIso( + year: number, + month: number, + day: number, + hour: number, + minute = 0 +): string { + return new Date(year, month - 1, day, hour, minute, 0, 0).toISOString(); +} + +function program(start: string, stop: string, title = 'P'): EpgProgram { + return { + start, + stop, + channel: 'ch', + title, + desc: null, + category: null, + }; +} + +const NOW = new Date(2026, 5, 28, 12, 0, 0, 0).getTime(); // 28 Jun 2026, 12:00 local + +describe('epg-timeline.utils', () => { + describe('buildTimelineAxis', () => { + it('spans from the earliest start to the day after the latest stop', () => { + const programs = [ + program(localIso(2026, 6, 27, 22), localIso(2026, 6, 28, 0)), + program(localIso(2026, 6, 29, 20), localIso(2026, 6, 29, 23)), + ]; + + const axis = buildTimelineAxis(programs, NOW); + + expect(new Date(axis.startMs).getDate()).toBe(27); + expect(new Date(axis.startMs).getHours()).toBe(0); + // end is local midnight strictly after the last stop (30 Jun 00:00) + expect(new Date(axis.endMs).getDate()).toBe(30); + expect(new Date(axis.endMs).getHours()).toBe(0); + }); + + it('always includes the current day even without programmes', () => { + const axis = buildTimelineAxis([], NOW); + expect(axis.startMs).toBeLessThanOrEqual(NOW); + expect(axis.endMs).toBeGreaterThan(NOW); + }); + }); + + describe('classifyTimelineWhen', () => { + it('classifies past, now and future relative to now', () => { + expect(classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)).toBe( + 'past' + ); + expect(classifyTimelineWhen(NOW - 60000, NOW + 60000, NOW)).toBe( + 'now' + ); + expect(classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)).toBe( + 'future' + ); + }); + }); + + describe('buildTimelineBlocks', () => { + it('positions blocks by minute offset and drops invalid rows', () => { + const programs = [ + program(localIso(2026, 6, 28, 11), localIso(2026, 6, 28, 13)), + program('not-a-date', 'still-not'), + ]; + const axis = buildTimelineAxis(programs, NOW); + const blocks = buildTimelineBlocks(programs, axis, NOW); + + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block.when).toBe('now'); + expect(block.durationMin).toBe(120); + const expectedOffset = (block.startMs - axis.startMs) / TIMELINE_MINUTE_MS; + expect(block.offsetMin).toBeCloseTo(expectedOffset, 5); + }); + + it('sorts blocks chronologically', () => { + const programs = [ + program(localIso(2026, 6, 28, 18), localIso(2026, 6, 28, 19), 'late'), + program(localIso(2026, 6, 28, 8), localIso(2026, 6, 28, 9), 'early'), + ]; + const axis = buildTimelineAxis(programs, NOW); + const blocks = buildTimelineBlocks(programs, axis, NOW); + expect(blocks.map((b) => b.program.title)).toEqual(['early', 'late']); + }); + }); + + describe('ticks and dividers', () => { + it('emits a day divider per local midnight and skips midnight ticks', () => { + const programs = [ + program(localIso(2026, 6, 28, 1), localIso(2026, 6, 28, 23)), + ]; + const axis = buildTimelineAxis(programs, NOW); + const dividers = buildTimelineDayDividers(axis); + const ticks = buildTimelineTicks(axis); + + // single day window → exactly one divider (the day start) + expect(dividers).toHaveLength(1); + expect(new Date(dividers[0].dayMs).getHours()).toBe(0); + // no tick lands on local midnight + expect( + ticks.every((t) => new Date(t.ms).getHours() % 2 === 0) + ).toBe(true); + expect(ticks.some((t) => new Date(t.ms).getHours() === 0)).toBe( + false + ); + }); + }); + + describe('date helpers', () => { + it('reports whether a date key has programmes', () => { + const programs = [ + program(localIso(2026, 6, 28, 10), localIso(2026, 6, 28, 11)), + ]; + expect(hasProgramsForDateKey(programs, '2026-06-28')).toBe(true); + expect(hasProgramsForDateKey(programs, '2026-06-29')).toBe(false); + }); + + it('counts a midnight-spanning programme for both days (overlap, not start-date)', () => { + // 29 Jun 23:10 → 30 Jun 02:14: airing past midnight. A start-date + // check would key it to the 29th only, so "today" (the 30th) wrongly + // fell back to the empty-day state while it was on air. + const programs = [ + program( + localIso(2026, 6, 29, 23, 10), + localIso(2026, 6, 30, 2, 14) + ), + ]; + expect(hasProgramsForDateKey(programs, '2026-06-29')).toBe(true); + expect(hasProgramsForDateKey(programs, '2026-06-30')).toBe(true); + expect(hasProgramsForDateKey(programs, '2026-06-28')).toBe(false); + expect(hasProgramsForDateKey(programs, '2026-07-01')).toBe(false); + }); + + it('treats the day boundary as exclusive at the end', () => { + // ends exactly at the 29th 00:00 → belongs to the 28th, not the 29th. + const programs = [ + program(localIso(2026, 6, 28, 22), localIso(2026, 6, 29, 0)), + ]; + expect(hasProgramsForDateKey(programs, '2026-06-28')).toBe(true); + expect(hasProgramsForDateKey(programs, '2026-06-29')).toBe(false); + }); + + it('maps an axis offset back to a local day key', () => { + const axis = buildTimelineAxis( + [program(localIso(2026, 6, 28, 10), localIso(2026, 6, 28, 11))], + NOW + ); + const offsetForNoon = (12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS; + expect(dayKeyAtOffset(axis, offsetForNoon)).toBe('2026-06-28'); + }); + + it('finds the nearest day with programmes', () => { + const programs = [ + program(localIso(2026, 6, 25, 10), localIso(2026, 6, 25, 11)), + program(localIso(2026, 6, 30, 10), localIso(2026, 6, 30, 11)), + ]; + // Jun 30 10:00 (~1d22h away) is closer to Jun 28 12:00 than Jun 25. + expect(nearestDateKeyWithPrograms(programs, NOW)).toBe('2026-06-30'); + }); + }); + + describe('short-programme strategy', () => { + it('classifies content tiers by rendered width', () => { + expect(tierFor(200)).toBe('wide'); + expect(tierFor(90)).toBe('med'); + expect(tierFor(40)).toBe('narrow'); + expect(tierFor(12)).toBe('micro'); + }); + + it('densifies tick spacing as the user zooms in', () => { + expect(timelineTickStepForScale(1)).toBe(120); + expect(timelineTickStepForScale(2.5)).toBe(60); + expect(timelineTickStepForScale(4)).toBe(30); + expect(timelineTickStepForScale(6)).toBe(15); + }); + + function blocksFor(durations: number[]): ReturnType { + let startMin = 6 * 60; // 06:00 + const programs = durations.map((dur) => { + const p = program( + localIso(2026, 6, 28, Math.floor(startMin / 60), startMin % 60), + localIso( + 2026, + 6, + 28, + Math.floor((startMin + dur) / 60), + (startMin + dur) % 60 + ) + ); + startMin += dur; + return p; + }); + const axis = buildTimelineAxis(programs, NOW); + return buildTimelineBlocks(programs, axis, NOW); + } + + it('enforces a minimum width so short programmes stay clickable', () => { + const blocks = blocksFor([5, 200]); // a 5-min sliver + a long block + const items = buildTimelineRenderItems(blocks, 1, { + nowMs: NOW, + }) as TimelineRenderBlock[]; + // 5 min * scale 1 = 5px raw, floored to the min-width minus gap. + expect(items[0].widthPx).toBeGreaterThanOrEqual( + TIMELINE_MIN_BLOCK_WIDTH_PX - 8 + ); + expect(items[0].tier).toBe('narrow'); + // the long block (200px) keeps its full proportional content + expect(items[1].tier).toBe('wide'); + }); + + it('groups >=4 consecutive shorts when grouping is allowed', () => { + const blocks = blocksFor([5, 5, 5, 5, 120]); + const grouped = buildTimelineRenderItems(blocks, 1, { + allowGroup: true, + nowMs: NOW, + }); + const group = grouped.find( + (i): i is TimelineRenderGroup => i.kind === 'group' + ); + expect(group).toBeDefined(); + expect(group?.count).toBe(4); + // the long programme stays an individual block + expect(grouped.filter((i) => i.kind === 'block')).toHaveLength(1); + }); + + it('keeps the on-air programme out of a short-run group chip', () => { + // Four past 5-min shorts then the on-air short. The old behaviour + // folded all five into one chip and lost the `when: 'now'` highlight. + const mk = ( + sh: number, + sm: number, + eh: number, + em: number + ): ReturnType => + program(localIso(2026, 6, 28, sh, sm), localIso(2026, 6, 28, eh, em)); + const programs = [ + mk(11, 38, 11, 43), + mk(11, 43, 11, 48), + mk(11, 48, 11, 53), + mk(11, 53, 11, 58), + mk(11, 58, 12, 3), // on air — NOW is 12:00 + ]; + const axis = buildTimelineAxis(programs, NOW); + const blocks = buildTimelineBlocks(programs, axis, NOW); + const items = buildTimelineRenderItems(blocks, 1, { + allowGroup: true, + nowMs: NOW, + }); + + const group = items.find( + (i): i is TimelineRenderGroup => i.kind === 'group' + ); + expect(group?.count).toBe(4); // the four past shorts still group + const nowBlock = items.find( + (i): i is TimelineRenderBlock => + i.kind === 'block' && i.block.when === 'now' + ); + expect(nowBlock).toBeDefined(); // on-air short stays a standalone block + }); + + it('does not group when grouping is disallowed (zoomed in)', () => { + const blocks = blocksFor([5, 5, 5, 5, 120]); + const items = buildTimelineRenderItems(blocks, 4, { + allowGroup: false, + nowMs: NOW, + }); + expect(items.every((i) => i.kind === 'block')).toBe(true); + expect(items).toHaveLength(5); + }); + + it('flags catch-up only for past blocks inside the archive window', () => { + const blocks = blocksFor([120]).map((b) => ({ + ...b, + when: 'past' as const, + })); + const withArchive = buildTimelineRenderItems(blocks, 1, { + nowMs: NOW, + archivePlaybackAvailable: true, + archiveWindowStartMs: Number.NEGATIVE_INFINITY, + }) as TimelineRenderBlock[]; + expect(withArchive[0].canCatchUp).toBe(true); + + const noArchive = buildTimelineRenderItems(blocks, 1, { + nowMs: NOW, + archivePlaybackAvailable: false, + }) as TimelineRenderBlock[]; + expect(noArchive[0].canCatchUp).toBe(false); + }); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts new file mode 100644 index 000000000..0b7411e36 --- /dev/null +++ b/libs/ui/epg/src/lib/epg-timeline/epg-timeline.utils.ts @@ -0,0 +1,227 @@ +import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { addDays, addMinutes, startOfDay } from 'date-fns'; +import { parseEpgDateKey } from '../epg-date'; +import { getProgramDateKey, getProgramTimeMs } from '../epg-list/epg-list.utils'; + +export const TIMELINE_MINUTE_MS = 60_000; +/** Pixels per minute on the ribbon (matches the prototype's SCALE). */ +export const TIMELINE_DEFAULT_SCALE = 1.75; +/** Hour grid spacing in minutes (a tick every two hours). */ +export const TIMELINE_TICK_STEP_MIN = 120; + +export type TimelineWhen = 'past' | 'now' | 'future'; + +export interface TimelineAxis { + /** Local-midnight ms of the first day shown. */ + readonly startMs: number; + /** Local-midnight ms just after the last day shown. */ + readonly endMs: number; +} + +export interface TimelineBlock { + readonly program: EpgProgram; + readonly key: string; + readonly startMs: number; + readonly stopMs: number; + readonly when: TimelineWhen; + /** Offset in minutes from the axis start. */ + readonly offsetMin: number; + /** Duration in minutes. */ + readonly durationMin: number; +} + +export interface TimelineTick { + readonly ms: number; + readonly offsetMin: number; +} + +export interface TimelineDayDivider extends TimelineTick { + /** Local-midnight ms of the day this divider opens. */ + readonly dayMs: number; +} + +function minutesBetween(fromMs: number, toMs: number): number { + return (toMs - fromMs) / TIMELINE_MINUTE_MS; +} + +/** + * Compute the local-day bounded axis spanning every loaded programme, always + * widened to include "now" so the playhead stays on the ribbon. + */ +export function buildTimelineAxis( + programs: readonly EpgProgram[], + nowMs: number +): TimelineAxis { + let minMs = nowMs; + let maxMs = nowMs; + + for (const program of programs) { + const startMs = getProgramTimeMs(program.start, program.startTimestamp); + const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + if (Number.isFinite(startMs)) { + minMs = Math.min(minMs, startMs); + } + if (Number.isFinite(stopMs)) { + maxMs = Math.max(maxMs, stopMs); + } + } + + const startMs = startOfDay(minMs).getTime(); + // End is the local midnight strictly after the last moment shown. + const endMs = addDays(startOfDay(maxMs), 1).getTime(); + + return { startMs, endMs }; +} + +export function classifyTimelineWhen( + startMs: number, + stopMs: number, + nowMs: number +): TimelineWhen { + if (stopMs <= nowMs) { + return 'past'; + } + if (startMs <= nowMs) { + return 'now'; + } + return 'future'; +} + +export function buildTimelineBlocks( + programs: readonly EpgProgram[], + axis: TimelineAxis, + nowMs: number +): TimelineBlock[] { + const blocks: TimelineBlock[] = []; + + programs.forEach((program, index) => { + const startMs = getProgramTimeMs(program.start, program.startTimestamp); + const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + if ( + !Number.isFinite(startMs) || + !Number.isFinite(stopMs) || + stopMs <= startMs + ) { + return; + } + + blocks.push({ + program, + key: `${startMs}-${stopMs}-${index}`, + startMs, + stopMs, + when: classifyTimelineWhen(startMs, stopMs, nowMs), + offsetMin: minutesBetween(axis.startMs, startMs), + durationMin: minutesBetween(startMs, stopMs), + }); + }); + + return blocks.sort((left, right) => left.startMs - right.startMs); +} + +/** Time ticks at `stepMin` spacing, excluding local midnights (those are dividers). */ +export function buildTimelineTicks( + axis: TimelineAxis, + stepMin: number = TIMELINE_TICK_STEP_MIN +): TimelineTick[] { + const ticks: TimelineTick[] = []; + const step = stepMin > 0 ? stepMin : TIMELINE_TICK_STEP_MIN; + let cursor = new Date(axis.startMs); + + while (cursor.getTime() < axis.endMs) { + const dayStart = startOfDay(cursor); + for (let minute = step; minute < 1440; minute += step) { + const tickMs = addMinutes(dayStart, minute).getTime(); + if (tickMs >= axis.endMs) { + break; + } + ticks.push({ + ms: tickMs, + offsetMin: minutesBetween(axis.startMs, tickMs), + }); + } + cursor = addDays(dayStart, 1); + } + + return ticks; +} + +export function buildTimelineDayDividers(axis: TimelineAxis): TimelineDayDivider[] { + const dividers: TimelineDayDivider[] = []; + let cursor = startOfDay(axis.startMs); + + while (cursor.getTime() < axis.endMs) { + const dayMs = cursor.getTime(); + dividers.push({ + ms: dayMs, + dayMs, + offsetMin: minutesBetween(axis.startMs, dayMs), + }); + cursor = addDays(cursor, 1); + } + + return dividers; +} + +/** Local-day key (yyyy-MM-dd) for the day currently centred in the viewport. */ +export function dayKeyAtOffset( + axis: TimelineAxis, + offsetMin: number +): string { + const ms = axis.startMs + offsetMin * TIMELINE_MINUTE_MS; + return getProgramDateKey(new Date(ms).toISOString()); +} + +/** + * Whether any programme overlaps the given local day. Overlap-based (not just + * start-date), so a programme that starts the previous evening and runs past + * midnight — e.g. a 23:10–02:14 film airing now — still counts for *today*. A + * start-date-only check would key it to yesterday and the ribbon would wrongly + * fall back to the `empty-day` state even though something is on air (the + * sidebar, which matches by "airing now", would still show it — that asymmetry + * was the bug). + */ +export function hasProgramsForDateKey( + programs: readonly EpgProgram[], + dateKey: string +): boolean { + const dayStart = parseEpgDateKey(dateKey); + const dayStartMs = dayStart.getTime(); + const dayEndMs = addDays(dayStart, 1).getTime(); // exclusive + + return programs.some((program) => { + const startMs = getProgramTimeMs(program.start, program.startTimestamp); + const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp); + if (!Number.isFinite(startMs) || !Number.isFinite(stopMs)) { + return false; + } + return startMs < dayEndMs && stopMs > dayStartMs; + }); +} + +/** Nearest date key (yyyy-MM-dd) that actually has programmes, or null. */ +export function nearestDateKeyWithPrograms( + programs: readonly EpgProgram[], + referenceMs: number +): string | null { + let bestKey: string | null = null; + let bestDelta = Number.POSITIVE_INFINITY; + + for (const program of programs) { + const startMs = getProgramTimeMs(program.start, program.startTimestamp); + if (!Number.isFinite(startMs)) { + continue; + } + const delta = Math.abs(startMs - referenceMs); + if (delta < bestDelta) { + bestDelta = delta; + bestKey = getProgramDateKey(program.start, program.startTimestamp); + } + } + + return bestKey; +} + +// Short-programme render strategy (tiers, grouping, zoom bounds) lives in a +// sibling module; re-exported here so existing imports keep working. +export * from './epg-timeline-render.util'; diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index 3ef3a351c..7293d319d 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -125,12 +125,21 @@ flex-basis 0.18s ease, min-height 0.18s ease; + // Inline (player visible): the guide is a compact panel below the + // player, not half the screen. The responsive clamp keeps the player + // dominant on tall screens and shrinks the panel on short ones. + // External player: no .epg--inline → keeps flex:1 above and fills. + &.epg--inline { + flex: 0 0 var(--epg-inline-height, clamp(180px, 36vh, 264px)); + } + &.epg-collapsed { flex: 0 0 56px; min-height: 56px; } - > app-live-epg-panel { + > app-live-epg-panel, + > app-epg-timeline { flex: 1; min-height: 0; } @@ -144,7 +153,8 @@ flex-direction: column; > app-epg-view, - > app-live-epg-panel { + > app-live-epg-panel, + > app-epg-timeline { flex: 1; min-height: 0; }