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.
This commit is contained in:
4gray authored and GitHub committed 2026-07-01 21:09:49 +02:00
1 parent c0e9cd5de3
commit 401012eac4
67 files changed
+6086 -1194

No files matched your search

+34 -139
View File
@@ -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<typeof channelItemByTitle>[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<typeof channelItemByTitle>[0],
currentProgram: XtreamNormalizedEpgListing,
timeZone: string
): Promise<void> {
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<typeof channelItemByTitle>
) {
@@ -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));
@@ -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<ReturnType<typeof getDatabase>>;
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<string, string>,
result: Record<string, EpgProgram | null>
): string[] {
const pending = new Set<string>();
for (const [requestedId, candidateId] of candidateIdsByRequestedId) {
if (!result[requestedId]) {
pending.add(candidateId);
}
}
return Array.from(pending);
}
private assignCandidateCurrentProgramRows(
result: Record<string, EpgProgram | null>,
candidateIdsByRequestedId: Map<string, string>,
@@ -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<EpgChannelMetadata[]> {
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<EpgChannelMetadata[]> {
@@ -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<EpgChannelMetadata[]> {
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`, `)})
)
`;
+4 -5
View File
@@ -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);
});
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+36 -1
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+36 -1
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+37 -2
View File
@@ -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": "العربية",
+153 -4
View File
@@ -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
@@ -93,48 +93,34 @@
@let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel);
<div
class="epg"
[class.epg--inline]="canCollapseEpgPanel"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
@if (canCollapseEpgPanel) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
<div class="epg-content">
<app-epg-timeline
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
[showReturnToLive]="showReturnToLive()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
(programActivated)="onTimelineProgramActivated($event)"
(returnToLive)="returnToLivePlayback()"
>
<div class="epg-content">
<app-epg-list
[activeProgram]="activeEpgProgramOrNull()"
[selectedDate]="selectedLiveEpgDate()"
[showDateNavigator]="false"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
/>
</div>
</app-live-epg-panel>
} @else {
<div class="epg-content">
<app-epg-list
[activeProgram]="activeEpgProgramOrNull()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
/>
</div>
}
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
</div>
</div>
}
@@ -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: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
<ng-content />
`,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly showReturnToLive = input(false);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<'next' | 'prev'>();
readonly returnToLive = output<void>();
}
@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<number | null>(null);
readonly volumeChange = output<number>();
}
@@ -141,16 +122,33 @@ class StubWebPlayerViewComponent {
}
@Component({
selector: 'app-epg-list',
selector: 'app-epg-timeline',
standalone: true,
template: '',
})
class StubEpgListComponent {
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(false);
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly emptyReason = input<string | null>(null);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly summaryLabelKey = input('');
readonly programActivated = output<{
program?: EpgProgram;
type: 'timeshift' | 'live';
}>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
}
@Directive({
@@ -183,6 +181,11 @@ describe('VideoPlayerComponent', () => {
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
const epgServiceMock = {
currentEpgPrograms$: epgPrograms$.asObservable(),
getChannelMetadataForChannels: () => of(new Map()),
};
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
@@ -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 {
@@ -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<Channel | null>(() => {
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 <app-epg-list> 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);
}
@@ -72,59 +72,27 @@
@if (!isRadioSelection() && supportsEpg) {
<div
class="epg"
[class.epg--inline]="shouldUseInlinePlayer()"
[class.epg-collapsed]="
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
"
>
@if (shouldUseInlinePlayer()) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[showDateNavigator]="isM3uSelection()"
<div class="epg-content">
<app-epg-timeline
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archiveDays]="timelineArchiveDays()"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
[collapsed]="
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<div class="epg-content">
@if (isM3uSelection()) {
<app-epg-list
[controlledChannel]="currentM3uChannel()"
[controlledPrograms]="currentM3uPrograms()"
[archivePlaybackAvailable]="
currentM3uArchivePlaybackAvailable()
"
[selectedDate]="selectedLiveEpgDate()"
[showDateNavigator]="false"
(selectedDateChange)="
onLiveEpgSelectedDateChange($event)
"
/>
} @else {
<app-epg-view
[epgItems]="currentPortalEpgItems()"
/>
}
</div>
</app-live-epg-panel>
} @else {
<div class="epg-content">
@if (isM3uSelection()) {
<app-epg-list
[controlledChannel]="currentM3uChannel()"
[controlledPrograms]="currentM3uPrograms()"
[archivePlaybackAvailable]="
currentM3uArchivePlaybackAvailable()
"
/>
} @else {
<app-epg-view
[epgItems]="currentPortalEpgItems()"
/>
}
</div>
}
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
</div>
</div>
}
} @else {
@@ -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: '<div class="stub-epg-list"></div>',
selector: 'app-epg-timeline',
template: '<div class="stub-epg-timeline"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubEpgListComponent {
readonly controlledChannel = input<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly selectedDateChange = output<string>();
}
@Component({
selector: 'app-epg-view',
template: '<div class="stub-epg-view"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubEpgViewComponent {
readonly epgItems = input<EpgItem[]>([]);
}
@Component({
selector: 'app-live-epg-panel',
template: '<section class="stub-live-epg-panel"><ng-content /></section>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly summary = input<any>(null);
readonly summaryLabelKey = input('');
readonly selectedDateChange = output<string>();
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<EpgDateNavigationDirection>();
readonly programActivated = output<EpgProgram>();
}
@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 () => {
@@ -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<EpgProgram[]>(() =>
this.currentPortalEpgItems().map((item) => toEpgProgram(item))
);
readonly timelinePrograms = computed<EpgProgram[]>(() =>
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;
}
@@ -140,37 +140,12 @@
@let canCollapseEpgPanel = !!streamUrl() && isEmbeddedPlayer;
<div
class="epg"
[class.epg--inline]="canCollapseEpgPanel"
[class.epg-collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
>
@if (canCollapseEpgPanel) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
>
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel>
} @else {
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
}
<ng-container *ngTemplateOutlet="epgPanelContent" />
</div>
}
} @else {
@@ -181,25 +156,26 @@
}
</div>
<ng-template #epgPanelContent let-unifiedDateNavigator="unifiedDateNavigator">
<ng-template #epgPanelContent>
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else {
<app-epg-list
[controlledChannel]="controlledChannel()"
[controlledPrograms]="activeEpgPrograms()"
[controlledArchiveDays]="0"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
[showDateNavigator]="!unifiedDateNavigator"
(selectedDateChange)="
unifiedDateNavigator && onLiveEpgSelectedDateChange($event)
"
/>
}
<app-epg-timeline
[programs]="activeEpgPrograms()"
[channelName]="controlledChannel()?.name ?? activePlaybackTitle()"
[channelLogo]="activePlaybackArtwork()"
[sourceLabel]="'Stalker Portal'"
[archivePlaybackAvailable]="false"
[archiveDays]="0"
[loading]="isLoadingEpg()"
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="
!!streamUrl() &&
usesEmbeddedPlayer() &&
isLiveEpgPanelCollapsed()
"
[summary]="liveEpgPanelSummary()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
</div>
</ng-template>
@@ -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<unknown>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly selectedDateChange = output<string>();
}
@Component({
selector: 'app-live-epg-panel',
selector: 'app-epg-timeline',
standalone: true,
template: `
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
<ng-content />
`,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(false);
readonly loading = input(false);
readonly showDateNavigator = input(false);
readonly emptyReason = input<string>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<{ title?: string } | null>(null);
readonly summaryLabelKey = input('');
readonly selectedDateChange = output<string>();
readonly programActivated = output<EpgProgram>();
readonly returnToLive = output<void>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
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();
@@ -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,
@@ -108,40 +108,12 @@
@if (supportsEpg) {
<div
class="epg"
[class.epg--inline]="isEmbeddedPlayer"
[class.epg-collapsed]="
isEmbeddedPlayer && isLiveEpgPanelCollapsed()
"
>
@if (isEmbeddedPlayer) {
<app-live-epg-panel
[collapsed]="isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[loading]="isLoadingEpg()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
[showDateNavigator]="true"
[selectedDate]="selectedLiveEpgDate()"
[showReturnToLive]="showReturnToLive()"
(collapsedChange)="
onLiveEpgPanelCollapsedChange($event)
"
(dateNavigation)="onLiveEpgDateNavigation($event)"
(returnToLive)="returnToLivePlayback()"
>
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: true }
"
/>
</app-live-epg-panel>
} @else {
<ng-container
*ngTemplateOutlet="
epgPanelContent;
context: { unifiedDateNavigator: false }
"
/>
}
<ng-container *ngTemplateOutlet="epgPanelContent" />
</div>
}
} @else if (!showLiveChannelSidebar()) {
@@ -198,49 +170,26 @@
}
</div>
<ng-template #epgPanelContent let-unifiedDateNavigator="unifiedDateNavigator">
<ng-template #epgPanelContent>
<div class="epg-content">
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
} @else if (isElectron && controlledEpgPrograms().length > 0) {
@if (showArchiveUnavailableNotice()) {
<div class="archive-unavailable-banner" role="status">
<div class="archive-unavailable-banner__title">
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_TITLE' | translate
}}
</div>
<div class="archive-unavailable-banner__description">
{{
'EPG.ARCHIVE_PLAYBACK_UNAVAILABLE_DESCRIPTION'
| translate
}}
</div>
</div>
}
<app-epg-list
[controlledPrograms]="controlledEpgPrograms()"
[controlledArchiveDays]="controlledArchiveDays()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[activeProgram]="activeCatchupProgram()"
[selectedDate]="
unifiedDateNavigator ? selectedLiveEpgDate() : null
"
[showDateNavigator]="!unifiedDateNavigator"
(programActivated)="onProgramActivated($event)"
(selectedDateChange)="
unifiedDateNavigator && onLiveEpgSelectedDateChange($event)
"
/>
} @else if (!isElectron && epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
} @else {
<app-portal-empty-state
icon="event_busy"
[message]="'EPG.EPG_NOT_AVAILABLE_CHANNEL_TITLE' | translate"
/>
}
<app-epg-timeline
[programs]="controlledEpgPrograms()"
[channelName]="activePlayback()?.title ?? ''"
[channelLogo]="activePlayback()?.thumbnail ?? ''"
[sourceLabel]="'Xtream'"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archiveDays]="controlledArchiveDays()"
[activeProgram]="activeCatchupProgram()"
[isLivePlayback]="!showReturnToLive()"
[loading]="isLoadingEpg()"
[selectedDate]="selectedLiveEpgDate()"
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="onProgramActivated($event)"
(returnToLive)="returnToLivePlayback()"
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
/>
</div>
</ng-template>
@@ -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<EpgItem[]>([]);
}
@Component({
selector: 'app-epg-list',
standalone: true,
template: '',
})
class StubEpgListComponent {
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly controlledArchiveDays = input<number | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly selectedDate = input<string | null>(null);
readonly showDateNavigator = input(true);
readonly programActivated = output<EpgProgramActivationEvent>();
readonly selectedDateChange = output<string>();
}
@Component({
selector: 'app-live-epg-panel',
selector: 'app-epg-timeline',
standalone: true,
template: `
<div class="live-epg-panel-label">{{ summaryLabelKey() }}</div>
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
<button
class="live-epg-panel-return"
type="button"
[hidden]="!showReturnToLive()"
(click)="returnToLive.emit()"
>
Return to live
</button>
<ng-content />
`,
})
class StubLiveEpgPanelComponent {
readonly collapsed = input(false);
readonly summary = input<LiveEpgPanelSummary | null>(null);
class StubEpgTimelineComponent {
readonly programs = input<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly showDateNavigator = input(false);
readonly selectedDate = input<string | null>(null);
readonly showReturnToLive = input(false);
readonly collapsedChange = output<boolean>();
readonly dateNavigation = output<'next' | 'prev'>();
readonly collapsed = input(false);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('');
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly collapsedChange = output<boolean>();
}
@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
);
});
});
@@ -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,
@@ -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))
@@ -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<string, ChannelEpgMetadata>();
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;
}
@@ -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<string, EpgProgram | null>([['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<string, EpgProgram | null>([
['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,
});
});
});
});
@@ -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<string, EpgProgram | null>
): 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<string, EpgProgram | null>,
now: number = Date.now()
): Map<string, ChannelEpgMetadata> {
const result = new Map<string, ChannelEpgMetadata>();
channelEpgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: calculateEpgProgress(program, now),
});
});
return result;
}
@@ -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<string, ChannelEpgMetadata>();
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;
}
@@ -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<string, ChannelEpgMetadata>();
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<number>
@@ -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;
}
+15
View File
@@ -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';
@@ -1,78 +1,111 @@
<div class="program-dialog">
<!-- Header with channel and time -->
<header class="program-header">
<div class="time-block">
<div class="time-display">
<span class="time-start">{{
(epgProgram.startTimestamp
? epgProgram.startTimestamp * 1000
: epgProgram.start
) | date: 'HH:mm'
}}</span>
<span class="time-separator">—</span>
<span class="time-end">{{
(epgProgram.stopTimestamp
? epgProgram.stopTimestamp * 1000
: epgProgram.stop
) | date: 'HH:mm'
}}</span>
</div>
<div class="date-display">{{
(epgProgram.startTimestamp
? epgProgram.startTimestamp * 1000
: epgProgram.start
) | date: 'EEEE, MMMM d' : undefined : currentLocale()
}}</div>
</div>
<div class="epg-dialog">
<div class="epg-dialog__hero">
@if (channelName) {
<div class="channel-badge">
<span class="channel-icon">📺</span>
<span class="channel-name">{{ channelName }}</span>
</div>
<span class="epg-dialog__chan">
<mat-icon>live_tv</mat-icon>
<span class="epg-dialog__chan-name">{{ channelName }}</span>
</span>
}
</header>
<button
type="button"
class="epg-dialog__close"
mat-dialog-close
[attr.aria-label]="'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
<!-- Title section -->
<div class="program-title-section">
<h1 class="program-title">{{ epgProgram.title }}</h1>
@if (epgProgram.category) {
<span class="category-tag">{{ epgProgram.category }}</span>
@if (channelLogo && !logoFailed) {
<img
class="epg-dialog__logo"
[src]="channelLogo"
[alt]="channelName ?? ''"
(error)="logoFailed = true"
/>
} @else {
<mat-icon class="epg-dialog__hero-glyph">live_tv</mat-icon>
}
</div>
<!-- Content -->
<div class="program-content">
@if (epgProgram.desc) {
<p class="program-description">{{ epgProgram.desc }}</p>
}
<!-- Meta info grid -->
<div class="meta-grid">
@if (epgProgram.episodeNum) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.EPISODE' | translate }}</span>
<span class="meta-value">{{ epgProgram.episodeNum }}</span>
</div>
}
@if (epgProgram.rating) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM' | translate }}</span>
<span class="meta-value rating">{{ epgProgram.rating }}</span>
</div>
}
<div class="epg-dialog__body">
<div class="epg-dialog__meta">
<span>{{ startMs | date: 'EEE d MMM' : '' : currentLocale() }}</span>
<span class="epg-dialog__dot"></span>
<span
>{{ startMs | date: 'HH:mm' }} –
{{ stopMs | date: 'HH:mm' }}</span
>
@if (duration) {
<div class="meta-item">
<span class="meta-label">{{ 'EPG.PROGRAM_DIALOG.DURATION' | translate }}</span>
<span class="meta-value">{{ duration }}</span>
</div>
<span class="epg-dialog__dot"></span>
<span>{{ duration }}</span>
}
@if (epgProgram.category) {
<span class="epg-dialog__dot"></span>
<span>{{ epgProgram.category }}</span>
}
</div>
</div>
<!-- Footer -->
<footer class="program-footer">
<button class="close-button" mat-dialog-close>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</footer>
<h3 class="epg-dialog__title">{{ epgProgram.title }}</h3>
@if (epgProgram.desc) {
<p class="epg-dialog__desc">{{ epgProgram.desc }}</p>
}
@if (epgProgram.episodeNum || epgProgram.rating) {
<div class="epg-dialog__tags">
@if (epgProgram.episodeNum) {
<span class="epg-dialog__tag">
<small>{{
'EPG.PROGRAM_DIALOG.EPISODE' | translate
}}</small>
{{ epgProgram.episodeNum }}
</span>
}
@if (epgProgram.rating) {
<span class="epg-dialog__tag epg-dialog__tag--rating">
<small>{{
'EPG.PROGRAM_DIALOG.PARENTAL_RATING_SYSTEM'
| translate
}}</small>
{{ epgProgram.rating }}
</span>
}
</div>
}
<div class="epg-dialog__actions">
@if (primaryAction === 'live') {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--primary"
[mat-dialog-close]="'live'"
>
<mat-icon>play_arrow</mat-icon>
{{ 'EPG.TIMELINE.WATCH_LIVE' | translate }}
</button>
} @else if (primaryAction === 'timeshift') {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--primary"
[mat-dialog-close]="'timeshift'"
>
<mat-icon>replay</mat-icon>
{{ 'EPG.TIMELINE.WATCH_FROM_START' | translate }}
</button>
}
@if (archiveUnavailableNote) {
<span class="epg-dialog__note">{{
'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate
}}</span>
}
<span class="epg-dialog__spacer"></span>
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
mat-dialog-close
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</div>
</div>
</div>
@@ -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;
}
}
@@ -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'
@@ -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<EpgItemDialogData>(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);
}
@@ -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<EpgItemDialogAction | undefined> {
return this.dialog
.open(EpgItemDescriptionComponent, { width: '540px', data })
.afterClosed();
}
}
@@ -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;
}
@@ -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())}`;
}
@@ -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;
}
}
}
@@ -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');
});
});
@@ -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<EpgTimelineEmptyReason, 'none'>,
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) {
<div class="epg-empty">
<div class="epg-empty__icon" [class]="'tone-' + activePreset.tone">
<mat-icon>{{ activePreset.icon }}</mat-icon>
</div>
<div class="epg-empty__title">
{{ activePreset.titleKey | translate }}
</div>
<div class="epg-empty__sub">
{{ activePreset.subKey | translate }}
</div>
@switch (reason()) {
@case ('empty-day') {
<div class="epg-empty__actions">
<button
mat-flat-button
color="primary"
type="button"
(click)="jumpToday.emit()"
>
<mat-icon>my_location</mat-icon>
{{ 'EPG.TIMELINE.JUMP_TODAY' | translate }}
</button>
@if (hasOtherDays()) {
<button
mat-stroked-button
type="button"
(click)="jumpNearest.emit()"
>
{{ 'EPG.TIMELINE.JUMP_NEAREST' | translate }}
</button>
}
</div>
}
@case ('m3u-needs-setup') {
<div class="epg-empty__actions">
<button
mat-flat-button
color="primary"
type="button"
(click)="openSettings.emit()"
>
<mat-icon>settings</mat-icon>
{{ 'EPG.TIMELINE.OPEN_EPG_SETTINGS' | translate }}
</button>
</div>
}
@case ('error') {
<div class="epg-empty__actions">
<button
mat-flat-button
color="primary"
type="button"
(click)="retry.emit()"
>
<mat-icon>refresh</mat-icon>
{{ 'EPG.TIMELINE.RETRY' | translate }}
</button>
</div>
}
}
</div>
}
`,
styleUrl: './epg-timeline-empty-state.component.scss',
})
export class EpgTimelineEmptyStateComponent {
readonly reason = input.required<EpgTimelineEmptyReason>();
readonly hasOtherDays = input(false);
readonly jumpToday = output<void>();
readonly jumpNearest = output<void>();
readonly openSettings = output<void>();
readonly retry = output<void>();
readonly preset = computed<EmptyStatePreset | null>(() => {
const reason = this.reason();
return reason === 'none' ? null : PRESETS[reason];
});
}
@@ -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;
}
@@ -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();
});
});
});
@@ -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);
}
}
}
@@ -0,0 +1,134 @@
@for (tick of ticks(); track tick.ms) {
<div class="epg-timeline__tick" [style.left.px]="tick.offsetMin * scale()">
<span>{{ tick.ms | date: 'HH:mm' }}</span>
</div>
}
@for (divider of dividers(); track divider.ms) {
<div
class="epg-timeline__daydiv"
[style.left.px]="divider.offsetMin * scale()"
>
<span>{{
divider.dayMs | date: 'EEE d MMM' : '' : currentLocale()
}}</span>
</div>
}
@for (item of items(); track item.key) {
@if (item.kind === 'group') {
<button
type="button"
class="epg-timeline__group"
[style.left.px]="item.leftPx"
[style.width.px]="item.widthPx"
(click)="groupExpand.emit(item)"
>
<span class="epg-timeline__group-count">{{ item.count }}</span>
<span class="epg-timeline__group-label">{{
'EPG.TIMELINE.GROUP_SHORT' | translate
}}</span>
<span class="epg-timeline__group-hint">{{
'EPG.TIMELINE.GROUP_EXPAND' | translate
}}</span>
</button>
} @else {
<div
class="epg-timeline__block"
[attr.data-tier]="item.tier"
[class.is-past]="item.block.when === 'past'"
[class.is-now]="item.block.when === 'now'"
[class.is-future]="item.block.when === 'future'"
[class.is-playing]="isPlaying(item)"
[class.is-selected]="isSelected(item)"
[class.is-short]="item.block.durationMin < 10"
[style.left.px]="item.leftPx"
[style.width.px]="item.widthPx"
(click)="blockClick.emit(item.block)"
(keydown.enter)="onBlockActivate($event, item.block)"
(mouseenter)="onBlockEnter(item, $event)"
(mouseleave)="onBlockLeave()"
(focusin)="onBlockEnter(item, $event)"
(focusout)="onBlockLeave()"
tabindex="0"
role="button"
[attr.aria-label]="item.block.program.title"
>
@if (item.block.when === 'now') {
<div
class="epg-timeline__fill epg-timeline__fill--live"
[style.width.%]="item.nowFillPercent"
></div>
}
<div class="epg-timeline__block-top">
<div class="epg-timeline__block-time">
{{ item.block.startMs | date: 'HH:mm' }} –
{{ item.block.stopMs | date: 'HH:mm' }}
</div>
<div class="epg-timeline__block-title">
{{ item.block.program.title }}
</div>
@if (item.tier === 'wide' && item.block.program.desc) {
<p class="epg-timeline__block-desc">
{{ item.block.program.desc }}
</p>
}
</div>
<div class="epg-timeline__block-foot">
@if (item.block.when === 'now') {
<span class="epg-timeline__tag is-now">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
} @else if (isPlaying(item)) {
<span class="epg-timeline__tag is-arch">{{
'EPG.TIMELINE.FROM_ARCHIVE' | translate
}}</span>
} @else if (item.canCatchUp) {
<button
type="button"
class="epg-timeline__watch"
(click)="watchClick.emit(item.block); $event.stopPropagation()"
>
<mat-icon>replay</mat-icon>
{{ 'EPG.TIMELINE.WATCH' | translate }}
</button>
}
</div>
<button
type="button"
class="epg-timeline__info"
[attr.aria-label]="
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
"
(click)="infoClick.emit(item.block); $event.stopPropagation()"
>
<mat-icon>info_outline</mat-icon>
</button>
</div>
}
}
<div class="epg-timeline__playhead" [style.left.px]="playheadLeftPx()">
<span>{{ nowLabel() }}</span>
</div>
@let pop = popover();
@if (pop) {
<div
class="epg-timeline__pop"
[style.left.px]="pop.left"
[style.top.px]="pop.top"
role="tooltip"
>
<div class="epg-timeline__pop-time">
{{ pop.startMs | date: 'HH:mm' }} – {{ pop.stopMs | date: 'HH:mm' }} ·
{{ pop.durationMin }} {{ 'EPG.TIMELINE.MINUTES_SHORT' | translate }}
</div>
<div class="epg-timeline__pop-title">{{ pop.title }}</div>
@if (pop.desc) {
<div class="epg-timeline__pop-desc">{{ pop.desc }}</div>
}
</div>
}
@@ -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;
}
}
@@ -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> = {}
): 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<EpgTimelineTrackComponent>;
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);
});
});
@@ -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<TimelineRenderItem[]>([]);
readonly ticks = input<TimelineTick[]>([]);
readonly dividers = input<TimelineDayDivider[]>([]);
readonly scale = input(1);
readonly trackWidthPx = input(0);
readonly playheadLeftPx = input(0);
readonly nowLabel = input('');
readonly selectedKey = input<string | null>(null);
readonly activeProgram = input<EpgProgram | null>(null);
readonly currentLocale = input('en');
readonly blockClick = output<TimelineBlock>();
readonly watchClick = output<TimelineBlock>();
readonly infoClick = output<TimelineBlock>();
readonly groupExpand = output<TimelineRenderGroup>();
readonly popover = signal<PopoverState | null>(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);
}
}
@@ -0,0 +1,220 @@
@let state = renderState();
<div
class="epg-timeline"
[class.epg-timeline--no-archive]="!archivePlaybackAvailable()"
[class.epg-timeline--collapsed]="collapsed()"
>
<!-- single header bar: collapse + channel on the left, controls/summary on the right -->
<div class="epg-timeline__toolbar">
<button
type="button"
class="epg-timeline__collapse"
(click)="toggleCollapsed()"
[attr.aria-expanded]="!collapsed()"
[attr.aria-label]="
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
| translate
"
>
<span class="epg-timeline__collapse-icon">
<mat-icon>keyboard_arrow_down</mat-icon>
</span>
<span class="epg-timeline__heading">
<b>{{ channelName() }}</b>
@if (!collapsed() && hasSummary()) {
<span
class="epg-timeline__subtitle"
[class.is-arch]="!isLivePlayback()"
[attr.title]="summary()?.title"
>
@if (!isLivePlayback()) {
<mat-icon>history</mat-icon>
}
{{ summary()?.title }}
</span>
} @else {
<span>{{ sourceLabel() }}</span>
}
</span>
</button>
<span class="epg-timeline__spacer"></span>
@if (collapsed()) {
@if (loading()) {
<span class="epg-timeline__summary-title">{{
'EPG.LOADING' | translate
}}</span>
} @else if (hasSummary()) {
@if (isLivePlayback()) {
<span class="epg-timeline__summary-tag is-live">{{
'EPG.TIMELINE.ON_NOW' | translate
}}</span>
<span class="epg-timeline__summary-title">{{
summary()?.title
}}</span>
@if (progress() !== null) {
<span class="epg-timeline__summary-bar" aria-hidden="true">
<i [style.width.%]="progress()"></i>
</span>
}
@if (minutesLeft() !== null) {
<span class="epg-timeline__summary-meta"
>{{ minutesLeft() }}
{{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}</span
>
}
} @else {
<span class="epg-timeline__summary-tag is-arch">
<mat-icon>play_arrow</mat-icon>
{{ 'EPG.ARCHIVE_PLAYBACK' | translate }}
</span>
<span class="epg-timeline__summary-title">{{
summary()?.title
}}</span>
@if (hasTimeRange()) {
<span class="epg-timeline__summary-meta"
>{{ summary()?.start | date: 'HH:mm' }} –
{{ summary()?.stop | date: 'HH:mm' }}</span
>
}
}
} @else {
<span
class="epg-timeline__summary-title epg-timeline__summary-title--muted"
>{{ 'EPG.NO_PROGRAM_INFO' | translate }}</span
>
}
} @else {
@if (!isLivePlayback()) {
<button
type="button"
class="epg-timeline__livebtn"
(click)="returnToLive.emit()"
>
{{ 'EPG.TIMELINE.RETURN_TO_LIVE' | translate }}
</button>
}
@if (showRibbonControls()) {
<button
type="button"
class="epg-timeline__jump"
(click)="jumpToNow()"
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
>
<mat-icon>my_location</mat-icon>
{{ 'EPG.TIMELINE.NOW' | translate }}
</button>
<label
class="epg-timeline__zoom"
[matTooltip]="zoomLabelKey() | translate"
>
<mat-icon>zoom_in</mat-icon>
<input
#zoomInput
type="range"
[min]="zoomMin"
[max]="zoomMax"
[step]="zoomStep"
[value]="scale()"
(input)="onZoom(zoomInput.valueAsNumber)"
[attr.aria-label]="'EPG.TIMELINE.ZOOM' | translate"
/>
</label>
}
@if (showDateStepper()) {
<div class="epg-timeline__stepper">
<button
type="button"
class="epg-timeline__nav"
[attr.aria-label]="'EPG.PREVIOUS_DAY' | translate"
(click)="stepDay('prev')"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div class="epg-timeline__day">
<span class="epg-timeline__day-main">
@if (isViewToday()) {
{{ 'EPG.TIMELINE.TODAY' | translate }}
} @else {
{{
viewDate()
| date: 'EEE' : '' : currentLocale()
}}
}
</span>
<small>{{
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
}}</small>
</div>
<button
type="button"
class="epg-timeline__nav"
[attr.aria-label]="'EPG.NEXT_DAY' | translate"
(click)="stepDay('next')"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
}
}
</div>
@if (!collapsed()) {
<!-- Schedule-only notice (provider exposes history but no catch-up) -->
@if (state === 'ribbon' && !archivePlaybackAvailable()) {
<div class="epg-timeline__notice" role="note">
<mat-icon>info</mat-icon>
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
</div>
}
@switch (state) {
@case ('loading') {
<div class="epg-timeline__ribbon epg-timeline__ribbon--skeleton">
@for (width of skeletonWidths; track $index) {
<div
class="epg-timeline__sk"
[style.width.px]="width"
></div>
}
</div>
}
@case ('ribbon') {
<div
#ribbon
class="epg-timeline__ribbon app-scrollbar"
(scroll)="onRibbonScroll()"
>
<app-epg-timeline-track
[items]="renderItems()"
[ticks]="ticks()"
[dividers]="dividers()"
[scale]="scale()"
[trackWidthPx]="trackWidthPx()"
[playheadLeftPx]="playheadLeftPx()"
[nowLabel]="nowLabel()"
[selectedKey]="selectedKey()"
[activeProgram]="activeProgram()"
[currentLocale]="currentLocale()"
(blockClick)="onBlockClick($event)"
(watchClick)="onWatch($event)"
(infoClick)="openDetails($event)"
(groupExpand)="onGroupExpand($event)"
/>
</div>
}
@default {
<app-epg-timeline-empty-state
[reason]="emptyStateReason()"
[hasOtherDays]="hasOtherDays()"
(jumpToday)="jumpToNow()"
(jumpNearest)="jumpToNearestDay()"
(openSettings)="openEpgSettings.emit()"
(retry)="retry.emit()"
/>
}
}
}
</div>
@@ -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;
}
}
@@ -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<EpgTimelineComponent>;
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<string, unknown>): 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);
});
});
@@ -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<EpgProgram[]>([]);
readonly channelName = input('');
readonly channelLogo = input('');
readonly sourceLabel = input('Timeline');
readonly archivePlaybackAvailable = input(false);
readonly archiveDays = input(0);
readonly activeProgram = input<EpgProgram | null>(null);
readonly isLivePlayback = input(true);
readonly loading = input(false);
readonly emptyReason = input<EpgTimelineEmptyReason>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
readonly programActivated = output<EpgProgramActivationEvent>();
readonly returnToLive = output<void>();
readonly selectedDateChange = output<string>();
readonly openEpgSettings = output<void>();
readonly retry = output<void>();
readonly collapsedChange = output<boolean>();
private readonly programmeDialog = inject(EpgProgrammeDialogService);
private readonly translate = inject(TranslateService);
readonly ribbon = viewChild<ElementRef<HTMLElement>>('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<string | null>(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<RenderState>(() => {
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<EpgTimelineEmptyReason>(() => {
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));
}
}
@@ -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<typeof buildTimelineBlocks> {
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<typeof program> =>
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);
});
});
});
@@ -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';
+12 -2
View File
@@ -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;
}