mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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:
1 parent
b1119189e2
commit
a048e56aca
60 files changed
+5523
-1191
No files matched your search
+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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user