feat(epg): rework live EPG panel into a horizontal timeline

Replace the vertical EPG list/panel with a single horizontal timeline ribbon
(`app-epg-timeline`) shared across all live surfaces — the M3U player, the
unified live tab, and the Xtream and Stalker layouts. It preloads a multi-day
window and supports scroll, zoom, day navigation, short-programme tiers and
per-state empty/error views; the programme-details dialog is restyled to match
and now shows the channel logo.

Timeline / UI
- Auto-focus the currently-airing programme on channel select — instant
  (`behavior: 'auto'`), centred, and deduped by programme-set identity so the
  30s tick / zoom / re-emits never cause flaky re-jumps.
- Wide-tier cards show a dimmed description preview under the title; title and
  preview clip on whole lines (`flex-shrink: 0` + `-webkit-line-clamp`), never
  mid-line.
- Dynamic bar subtitle: the now-playing / archive programme title instead of a
  static "Timeline" label.
- State-aware toolbar: hide Now + zoom when there is no ribbon, and the date
  stepper when the channel has no EPG on any day (hidden, not disabled).
- Empty-state fills and centres within the panel area instead of a fixed
  min-height that overflowed the compact inline panel and hid the buttons.

EPG current-programme lookup (electron-backend)
- Timezone-aware "airing now" comparison via SQLite `datetime()` (was a lexical
  string compare that broke for offset vs UTC `Z` timestamps).
- Unscoped fallback for resolved candidates whose programmes are tagged with an
  out-of-scope source (multi-source imports sharing XMLTV ids), so the sidebar
  matches the timeline.
- Match channel candidates by the raw (case-sensitive) key as well as `LOWER()`,
  since SQLite `LOWER()`/`COLLATE NOCASE` fold only ASCII — fixes Cyrillic /
  Greek channel names showing "No program information available".
- Overlap-based day membership (`hasProgramsForDateKey`) so a film spanning
  midnight keeps the ribbon on "today" while it airs.

Refactor
- Extract the duplicated channel-list EPG enrichment (progress + side-car map)
  into `channel-list-container/epg-enrichment.util.ts`, shared by the
  all-channels / groups / favorites / recent views; also fixes recent's and
  favorites' unguarded, NaN-prone progress maths.

Tests: ui-epg 75, components 77, electron-backend 432; lint + web build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 committed 2026-06-30 01:40:54 +02:00
1 parent b1119189e2
commit a048e56aca
60 files changed
+5523 -1191

No files matched your search

@@ -26,20 +26,13 @@ import {
sortPlaylistChannelItems,
} from '../channel-list-sort.util';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
const ALL_CHANNELS_SORT_STORAGE_KEY = 'm3u-all-channels-sort-mode';
/**
* Per-channel EPG metadata stored in a side-car map keyed by EPG lookup key.
* Replaces the older EnrichedChannel pattern that spread-cloned every channel
* on every progressTick (~30 s).
*/
export interface ChannelEpgMetadata {
epgProgram: EpgProgram | null | undefined;
progressPercentage: number;
}
export type { ChannelEpgMetadata } from '../epg-enrichment.util';
@Component({
selector: 'app-all-channels-view',
@@ -137,18 +130,9 @@ export class AllChannelsViewComponent {
* ~90K objects per tick on large M3U playlists.
*/
readonly epgMetadataMap = computed(() => {
const epgMap = this.channelEpgMap();
// Read progressTick to create dependency for progress refresh
// Read progressTick to create a dependency for the ~30s progress refresh.
this.progressTick();
const result = new Map<string, ChannelEpgMetadata>();
epgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: this.calculateProgress(program),
});
});
return result;
return buildChannelEpgMetadataMap(this.channelEpgMap());
});
/** Resolves the EPG lookup key the side-car map is keyed by. */
@@ -165,38 +149,6 @@ export class AllChannelsViewComponent {
return resolveChannelLogo(channel, this.channelIconMap());
}
/**
* Calculates progress percentage for an EPG program
*/
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = Date.now();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
// Validate start/stop are finite numbers
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
return 0;
}
const total = stop - start;
// Bail out if duration is zero or negative
if (total <= 0) {
return 0;
}
// Clamp elapsed to [0, total]
const elapsed = Math.min(total, Math.max(0, now - start));
return Math.round((elapsed / total) * 100);
}
trackByFn(_: number, channel: Channel): string {
return channel?.id;
}
@@ -0,0 +1,90 @@
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import {
buildChannelEpgMetadataMap,
calculateEpgProgress,
resolveChannelEpgProgram,
} from './epg-enrichment.util';
const BASE = Date.UTC(2026, 0, 1, 12, 0, 0); // 12:00
const NOW = Date.UTC(2026, 0, 1, 12, 30, 0); // 12:30
function program(startOffsetMin: number, durationMin: number): EpgProgram {
const start = BASE + startOffsetMin * 60_000;
return {
start: new Date(start).toISOString(),
stop: new Date(start + durationMin * 60_000).toISOString(),
channel: 'ch',
title: 'P',
desc: null,
category: null,
};
}
describe('epg-enrichment.util', () => {
describe('calculateEpgProgress', () => {
it('returns 0 for a missing programme', () => {
expect(calculateEpgProgress(null, NOW)).toBe(0);
expect(calculateEpgProgress(undefined, NOW)).toBe(0);
});
it('returns a rounded mid-programme percentage', () => {
// 12:00–13:00, now 12:30 → 50%
expect(calculateEpgProgress(program(0, 60), NOW)).toBe(50);
// 12:00–13:30, now 12:30 → 33.33 → 33 (integer)
const v = calculateEpgProgress(program(0, 90), NOW);
expect(v).toBe(33);
expect(Number.isInteger(v)).toBe(true);
});
it('clamps to [0, 100] for future and finished programmes', () => {
expect(calculateEpgProgress(program(60, 60), NOW)).toBe(0); // 13:00–14:00
expect(calculateEpgProgress(program(-120, 60), NOW)).toBe(100); // 10:00–11:00
});
it('never returns NaN for zero-length or invalid timestamps', () => {
expect(calculateEpgProgress(program(0, 0), NOW)).toBe(0);
expect(
calculateEpgProgress(
{ ...program(0, 60), start: 'nope', stop: 'nope' },
NOW
)
).toBe(0);
});
});
describe('resolveChannelEpgProgram', () => {
const channel = {
name: 'My Channel',
tvg: { id: '', name: '' },
} as unknown as Channel;
it('returns the programme for the channel lookup key', () => {
const p = program(0, 60);
const map = new Map<string, EpgProgram | null>([['My Channel', p]]);
expect(resolveChannelEpgProgram(channel, map)).toBe(p);
});
it('returns null when the channel has no map entry', () => {
expect(resolveChannelEpgProgram(channel, new Map())).toBeNull();
});
});
describe('buildChannelEpgMetadataMap', () => {
it('maps each entry to {epgProgram, progressPercentage}', () => {
const p = program(0, 60);
const map = new Map<string, EpgProgram | null>([
['a', p],
['b', null],
]);
const result = buildChannelEpgMetadataMap(map, NOW);
expect(result.get('a')).toEqual({
epgProgram: p,
progressPercentage: 50,
});
expect(result.get('b')).toEqual({
epgProgram: null,
progressPercentage: 0,
});
});
});
});
@@ -0,0 +1,73 @@
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
/**
* Per-channel EPG metadata stored in a side-car map keyed by EPG lookup key.
* Replaces the older EnrichedChannel pattern that spread-cloned every channel
* on every progressTick (~30 s).
*/
export interface ChannelEpgMetadata {
epgProgram: EpgProgram | null | undefined;
progressPercentage: number;
}
/**
* Current-programme progress as a clamped, rounded percentage in [0, 100].
* Guards against missing/invalid timestamps and zero-length programmes so a bad
* EPG row can never produce `NaN` or a runaway value.
*/
export function calculateEpgProgress(
epgProgram: EpgProgram | null | undefined,
now: number = Date.now()
): number {
if (!epgProgram) {
return 0;
}
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
return 0;
}
const total = stop - start;
if (total <= 0) {
return 0;
}
const elapsed = Math.min(total, Math.max(0, now - start));
return Math.round((elapsed / total) * 100);
}
/**
* Resolves the current programme for a channel from the shared side-car EPG map
* (keyed by the channel's EPG lookup key). Returns `null` when the channel has
* no usable lookup key or no entry in the map.
*/
export function resolveChannelEpgProgram(
channel: Channel,
channelEpgMap: Map<string, EpgProgram | null>
): EpgProgram | null {
const key = resolveChannelEpgLookupKey(channel);
return key ? (channelEpgMap.get(key) ?? null) : null;
}
/**
* Builds the side-car metadata map (lookup key → {programme, progress}) from the
* shared `channelEpgMap`. Callers should read their `progressTick()` signal
* first so the surrounding computed re-runs every tick.
*/
export function buildChannelEpgMetadataMap(
channelEpgMap: Map<string, EpgProgram | null>,
now: number = Date.now()
): Map<string, ChannelEpgMetadata> {
const result = new Map<string, ChannelEpgMetadata>();
channelEpgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: calculateEpgProgress(program, now),
});
});
return result;
}
@@ -19,7 +19,7 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
@@ -102,17 +102,9 @@ export class FavoritesViewComponent {
* data. Replaces the previous spread-clone-every-channel pattern.
*/
readonly epgMetadataMap = computed(() => {
const epgMap = this.channelEpgMap();
// Read progressTick to create a dependency for the ~30s progress refresh.
this.progressTick();
const result = new Map<string, ChannelEpgMetadata>();
epgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: this.calculateProgress(program),
});
});
return result;
return buildChannelEpgMetadataMap(this.channelEpgMap());
});
/** Resolves the EPG lookup key the side-car map is keyed by. */
@@ -125,26 +117,6 @@ export class FavoritesViewComponent {
return resolveChannelLogo(channel, this.channelIconMap());
}
/**
* Calculates progress percentage for an EPG program
*/
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = new Date().getTime();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
trackByFn(_: number, channel: Channel): string {
return channel?.id;
}
@@ -21,7 +21,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { ChannelEpgMetadata } from '../all-channels-view/all-channels-view.component';
import { buildChannelEpgMetadataMap } from '../epg-enrichment.util';
import {
PlaylistChannelSortMode,
getPlaylistChannelSortModeLabel,
@@ -345,17 +345,9 @@ export class GroupsViewComponent {
* spread-clone-every-channel pattern.
*/
readonly epgMetadataMap = computed(() => {
const epgMap = this.channelEpgMap();
// Read progressTick to create a dependency for the ~30s progress refresh.
this.progressTick();
const result = new Map<string, ChannelEpgMetadata>();
epgMap.forEach((program, channelId) => {
result.set(channelId, {
epgProgram: program,
progressPercentage: this.calculateProgress(program),
});
});
return result;
return buildChannelEpgMetadataMap(this.channelEpgMap());
});
/** Resolves the EPG lookup key the side-car map is keyed by. */
@@ -537,30 +529,6 @@ export class GroupsViewComponent {
return a.key.localeCompare(b.key);
};
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = Date.now();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
if (!Number.isFinite(start) || !Number.isFinite(stop)) {
return 0;
}
const total = stop - start;
if (total <= 0) {
return 0;
}
const elapsed = Math.min(total, Math.max(0, now - start));
return Math.round((elapsed / total) * 100);
}
private emitSidebarWidthRequest(
navWidth: number,
emitter: OutputEmitterRef<number>
@@ -12,10 +12,13 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { TranslatePipe } from '@ngx-translate/core';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { Channel, EpgProgram } from '@iptvnator/shared/interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { resolveChannelLogo } from '../channel-logo-fallback.util';
import {
calculateEpgProgress,
resolveChannelEpgProgram,
} from '../epg-enrichment.util';
import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component';
export interface RecentViewItem {
@@ -82,35 +85,18 @@ export class RecentViewComponent {
this.progressTick();
return recentItems.map(({ channel, viewedAt }) => {
const channelId = resolveChannelEpgLookupKey(channel);
const epgProgram = channelId ? epgMap.get(channelId) : null;
const epgProgram = resolveChannelEpgProgram(channel, epgMap);
return {
channel,
viewedAt,
epgProgram,
logo: resolveChannelLogo(channel, iconMap),
progressPercentage: this.calculateProgress(epgProgram),
progressPercentage: calculateEpgProgress(epgProgram),
};
});
});
private calculateProgress(
epgProgram: EpgProgram | null | undefined
): number {
if (!epgProgram) {
return 0;
}
const now = new Date().getTime();
const start = new Date(epgProgram.start).getTime();
const stop = new Date(epgProgram.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
trackByFn(_: number, item: RecentViewItem): string {
return item.channel?.url;
}