mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
c0e9cd5de3
commit
401012eac4
67 files changed
+6086
-1194
No files matched your search
@@ -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`, `)})
|
||||
)
|
||||
`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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": "العربية",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
+111
-51
@@ -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);
|
||||
}
|
||||
+15
-47
@@ -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 {
|
||||
|
||||
+66
-86
@@ -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 () => {
|
||||
|
||||
+63
-10
@@ -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;
|
||||
}
|
||||
+22
-46
@@ -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>
|
||||
+40
-46
@@ -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();
|
||||
|
||||
+3
-7
@@ -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,
|
||||
|
||||
+22
-73
@@ -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>
|
||||
+79
-92
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
-9
@@ -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))
|
||||
|
||||
+4
-52
@@ -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;
|
||||
}
|
||||
+3
-31
@@ -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;
|
||||
}
|
||||
|
||||
+3
-35
@@ -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>
|
||||
|
||||
+6
-20
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
+100
-67
@@ -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>
|
||||
+223
-251
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -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'
|
||||
|
||||
+40
-3
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user