mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(dashboard): portal EPG on the live rails, loaded lazily per visible card (#1638)
Xtream and Stalker live cards on the dashboard carried no XMLTV key, so the rails never asked anything for them and showed only the LIVE chip. Their programme now comes from the portal, one card at a time and only once the card is on screen. - `lib-dashboard-rail` reports the cards inside its viewport through an IntersectionObserver rooted at the track; `DashboardLiveEpgPresenter` unions them with the pinned hero row and hands the set to `DashboardPortalLiveEpgPresenter`. - `DashboardPortalLiveEpgService` runs the bounded queue — two requests in flight, 200 ms apart, one card each — through `StreamResolverService.loadEpgForItems`, publishing every answer the moment it lands, so the page never waits and a slow portal delays no other card. A card scrolled past before its turn is never requested. - A programme is trusted for 60 s, an empty answer for 30 s (the resolver reports a dead portal and a guide-less channel identically), and a completion captures both the display offset and the EPG source revision, requeueing itself when either moved. - The presenter hands its wanted set back on destroy. Desktop only: the shared collection resolver is gated on the local XMLTV bridge. - A shimmer placeholder shows only before a card's first portal answer; M3U cards keep the batched XMLTV lookup.
This commit is contained in:
1 parent
4e575a810e
commit
6578e4073c
19 files changed
+1937
-46
No files matched your search
@@ -1,4 +1,6 @@
|
||||
export * from './lib/dashboard-data.service';
|
||||
export * from './lib/dashboard-portal-live-epg.service';
|
||||
export * from './lib/dashboard-portal-live-epg.util';
|
||||
export * from './lib/dashboard-recommendations.service';
|
||||
export * from './lib/dashboard-recommendations.util';
|
||||
export * from './lib/dashboard-source-expiry.service';
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Subject } from 'rxjs';
|
||||
import type { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgSourceSettingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { StreamResolverService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
DASHBOARD_PORTAL_LIVE_EPG_TIMING,
|
||||
DashboardPortalLiveEpgService,
|
||||
} from './dashboard-portal-live-epg.service';
|
||||
import type { DashboardPortalLiveEpgEntry } from './dashboard-portal-live-epg.util';
|
||||
|
||||
interface Deferred {
|
||||
readonly key: string;
|
||||
resolve: (program: EpgProgram | null) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
const entry = (id: number): DashboardPortalLiveEpgEntry => ({
|
||||
key: `xtream::p::${id}`,
|
||||
item: {
|
||||
uid: `xtream::p::${id}`,
|
||||
name: `Channel ${id}`,
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'p',
|
||||
playlistName: 'Portal',
|
||||
xtreamId: id,
|
||||
tvgId: String(id),
|
||||
},
|
||||
});
|
||||
|
||||
const program = (title: string, stopIso?: string): EpgProgram =>
|
||||
({
|
||||
channel: 'x',
|
||||
title,
|
||||
start: '2026-05-23T10:00:00.000Z',
|
||||
stop: stopIso ?? '2026-05-23T11:00:00.000Z',
|
||||
}) as EpgProgram;
|
||||
|
||||
describe('DashboardPortalLiveEpgService', () => {
|
||||
let service: DashboardPortalLiveEpgService;
|
||||
let loadEpgForItems: jest.Mock;
|
||||
let deferred: Deferred[];
|
||||
let offsetMinutes: number;
|
||||
let sourceChanged: Subject<void>;
|
||||
let sourceRevision: number;
|
||||
let supportsEpgProgramLookup: boolean;
|
||||
|
||||
const { delayMs, ttlMs, emptyTtlMs, endedRefetchFloorMs } =
|
||||
DASHBOARD_PORTAL_LIVE_EPG_TIMING;
|
||||
|
||||
/** What a reconciliation does: bump the fence, then announce it. */
|
||||
const changeEpgSources = () => {
|
||||
sourceRevision++;
|
||||
sourceChanged.next();
|
||||
};
|
||||
|
||||
/** Let the queue loop take its next step (one inter-request delay). */
|
||||
const step = async (rounds = 1): Promise<void> => {
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
await jest.advanceTimersByTimeAsync(delayMs);
|
||||
}
|
||||
};
|
||||
|
||||
/** Settle the LATEST request for `key` — a key can be asked more than once. */
|
||||
const settle = async (key: string, value: EpgProgram | null) => {
|
||||
const request = [...deferred]
|
||||
.reverse()
|
||||
.find((request) => request.key === key);
|
||||
if (!request) throw new Error(`no request in flight for ${key}`);
|
||||
request.resolve(value);
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-05-23T10:30:00.000Z'));
|
||||
deferred = [];
|
||||
offsetMinutes = 0;
|
||||
sourceRevision = 0;
|
||||
supportsEpgProgramLookup = true;
|
||||
sourceChanged = new Subject<void>();
|
||||
loadEpgForItems = jest.fn((items: { tvgId?: string }[]) => {
|
||||
const key = `xtream::p::${items[0].tvgId}`;
|
||||
return new Promise<Map<string, EpgProgram | null>>(
|
||||
(resolve, reject) => {
|
||||
deferred.push({
|
||||
key,
|
||||
resolve: (value) =>
|
||||
resolve(new Map([[String(items[0].tvgId), value]])),
|
||||
reject,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardPortalLiveEpgService,
|
||||
{
|
||||
provide: StreamResolverService,
|
||||
useValue: { loadEpgForItems },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
resolvedEpgOffsetMinutes: () => offsetMinutes,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgSourceSettingsService,
|
||||
useValue: {
|
||||
changed$: sourceChanged,
|
||||
revision: () => sourceRevision,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
get supportsEpgProgramLookup() {
|
||||
return supportsEpgProgramLookup;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(DashboardPortalLiveEpgService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('answers each card as its own request lands, never waiting for the slowest', async () => {
|
||||
service.sync([entry(1), entry(2)]);
|
||||
expect(service.pending()).toEqual(
|
||||
new Set(['xtream::p::1', 'xtream::p::2'])
|
||||
);
|
||||
await step(2);
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
await settle('xtream::p::2', program('Fast answer'));
|
||||
expect(service.programs().get('xtream::p::2')?.title).toBe(
|
||||
'Fast answer'
|
||||
);
|
||||
expect(service.programs().has('xtream::p::1')).toBe(false);
|
||||
expect(service.pending()).toEqual(new Set(['xtream::p::1']));
|
||||
|
||||
await settle('xtream::p::1', null);
|
||||
expect(service.programs().get('xtream::p::1')).toBeNull();
|
||||
expect(service.pending().size).toBe(0);
|
||||
});
|
||||
|
||||
it('never has more than two requests in flight and spaces starts by the delay', async () => {
|
||||
service.sync([entry(1), entry(2), entry(3), entry(4)]);
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
await step(3);
|
||||
// Two in flight: the loop keeps waiting instead of starting a third.
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
await settle('xtream::p::1', null);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('drops a queued card that scrolled away before its turn and keeps the rest', async () => {
|
||||
service.sync([entry(1), entry(2), entry(3)]);
|
||||
// 1 started; 2 and 3 still queued.
|
||||
service.sync([entry(1), entry(3)]);
|
||||
expect(service.pending()).toEqual(
|
||||
new Set(['xtream::p::1', 'xtream::p::3'])
|
||||
);
|
||||
await step(2);
|
||||
const requested = loadEpgForItems.mock.calls.map(
|
||||
([items]) => items[0].tvgId
|
||||
);
|
||||
expect(requested).toEqual(['1', '3']);
|
||||
});
|
||||
|
||||
it('serves a fresh answer from cache and asks again once the TTL has passed', async () => {
|
||||
service.sync([entry(1)]);
|
||||
await settle('xtream::p::1', program('Cached'));
|
||||
await step();
|
||||
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.setSystemTime(Date.now() + ttlMs);
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('asks again when the programme on air has ended, but not more often than the floor', async () => {
|
||||
service.sync([entry(1)]);
|
||||
await settle(
|
||||
'xtream::p::1',
|
||||
program('Ending soon', '2026-05-23T10:35:00.000Z')
|
||||
);
|
||||
await step();
|
||||
|
||||
// Ended 5 min in, but the floor (30 s) already passed → refetch.
|
||||
jest.setSystemTime(Date.now() + 6 * 60_000);
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Portal keeps returning the ended row: not re-asked within the floor.
|
||||
await settle(
|
||||
'xtream::p::1',
|
||||
program('Still ended', '2026-05-23T10:35:00.000Z')
|
||||
);
|
||||
await step();
|
||||
jest.setSystemTime(Date.now() + endedRefetchFloorMs / 2);
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('expires an answer with no programme sooner than one with a programme', async () => {
|
||||
// The resolver reports a failed portal and a guide-less channel the
|
||||
// same way, so the short TTL is what lets an outage recover.
|
||||
service.sync([entry(1)]);
|
||||
await settle('xtream::p::1', null);
|
||||
await step();
|
||||
expect(service.programs().get('xtream::p::1')).toBeNull();
|
||||
|
||||
jest.setSystemTime(Date.now() + emptyTtlMs / 2);
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.setSystemTime(Date.now() + emptyTtlMs / 2);
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
// A programme keeps the full TTL, which outlives the empty one.
|
||||
await settle('xtream::p::1', program('On air'));
|
||||
await step();
|
||||
jest.setSystemTime(Date.now() + emptyTtlMs);
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('treats a rejected resolver call as an answer with no programme', async () => {
|
||||
service.sync([entry(1)]);
|
||||
deferred[0].reject(new Error('portal down'));
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(service.programs().get('xtream::p::1')).toBeNull();
|
||||
expect(service.pending().size).toBe(0);
|
||||
|
||||
jest.setSystemTime(Date.now() + emptyTtlMs);
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('retires an answer evaluated under a previous display offset and asks again', async () => {
|
||||
service.sync([entry(1)]);
|
||||
offsetMinutes = 60;
|
||||
await settle('xtream::p::1', program('Old clock'));
|
||||
await step();
|
||||
expect(service.programs().has('xtream::p::1')).toBe(false);
|
||||
// Still wanted → requeued under the new clock.
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
|
||||
await settle('xtream::p::1', program('New clock'));
|
||||
expect(service.programs().get('xtream::p::1')?.title).toBe('New clock');
|
||||
});
|
||||
|
||||
it('drops every answer when the EPG sources change and asks the wanted cards again', async () => {
|
||||
service.sync([entry(1)]);
|
||||
await settle('xtream::p::1', program('Before import'));
|
||||
await step();
|
||||
|
||||
changeEpgSources();
|
||||
expect(service.programs().size).toBe(0);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('discards an answer computed before an EPG source change and asks again', async () => {
|
||||
// The key is in flight when the sources change, so the retire pass
|
||||
// cannot requeue it; the completion must not publish the old guide's
|
||||
// answer and must ask again itself.
|
||||
service.sync([entry(1)]);
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(1);
|
||||
|
||||
changeEpgSources();
|
||||
await settle('xtream::p::1', program('Removed guide'));
|
||||
expect(service.programs().has('xtream::p::1')).toBe(false);
|
||||
|
||||
await step();
|
||||
expect(loadEpgForItems).toHaveBeenCalledTimes(2);
|
||||
await settle('xtream::p::1', program('Current guide'));
|
||||
expect(service.programs().get('xtream::p::1')?.title).toBe(
|
||||
'Current guide'
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing at all without the local EPG program-lookup capability', async () => {
|
||||
// PWA: the collection resolver is gated on the desktop XMLTV bridge
|
||||
// and answers nothing, so no request is worth queuing.
|
||||
supportsEpgProgramLookup = false;
|
||||
|
||||
service.sync([entry(1), entry(2)]);
|
||||
await step(3);
|
||||
|
||||
expect(loadEpgForItems).not.toHaveBeenCalled();
|
||||
expect(service.pending().size).toBe(0);
|
||||
expect(service.programs().size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { inject, Injectable, OnDestroy, signal } from '@angular/core';
|
||||
import {
|
||||
epgProviderClockMs,
|
||||
type EpgProgram,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgSourceSettingsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
} from '@iptvnator/services';
|
||||
import { StreamResolverService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
dashboardPortalLiveEpgProgramStopMs,
|
||||
resolveDashboardPortalLiveEpgProgram,
|
||||
type DashboardPortalLiveEpgEntry,
|
||||
} from './dashboard-portal-live-epg.util';
|
||||
|
||||
interface CachedProgram {
|
||||
readonly program: EpgProgram | null;
|
||||
readonly fetchedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same numbers `EpgQueueService` uses against real Xtream panels: two
|
||||
* requests in flight, 200 ms between starts. A programme is trusted for a
|
||||
* minute; a programme that ended is asked again, but never more often than
|
||||
* every 30 s in case the portal keeps returning the stale row.
|
||||
*
|
||||
* An answer with no programme expires sooner (`emptyTtlMs`) because the
|
||||
* collection resolver reports a failed portal and a channel with no guide
|
||||
* identically — it catches per-channel failures and files them as `null`.
|
||||
* There is therefore no failure cooldown to keep: the short TTL is what lets
|
||||
* a transient outage recover on the next tick, at the price of re-asking a
|
||||
* genuinely guide-less channel while its card stays on screen.
|
||||
*/
|
||||
export const DASHBOARD_PORTAL_LIVE_EPG_TIMING = Object.freeze({
|
||||
maxConcurrency: 2,
|
||||
delayMs: 200,
|
||||
ttlMs: 60_000,
|
||||
emptyTtlMs: 30_000,
|
||||
endedRefetchFloorMs: 30_000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Lazy, per-card "what is on air" for the dashboard's Xtream and Stalker
|
||||
* live cards. `sync()` receives the cards currently worth asking for — the
|
||||
* visible ones — and the service answers each through the collection
|
||||
* resolver one card at a time, publishing every answer the moment it lands
|
||||
* (`programs`) so a slow portal never delays a fast one. The queue holds
|
||||
* only wanted keys, so a card scrolled away before its turn is never
|
||||
* requested. One instance for the app: favourites and recent rows of the
|
||||
* same channel share the key and therefore the answer.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardPortalLiveEpgService implements OnDestroy {
|
||||
private readonly streamResolver = inject(StreamResolverService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly sourceSettings = inject(EpgSourceSettingsService);
|
||||
private readonly sourceSubscription =
|
||||
this.sourceSettings.changed$.subscribe(() => this.retireAnswers());
|
||||
|
||||
private readonly cache = new Map<string, CachedProgram>();
|
||||
private wanted = new Map<string, DashboardPortalLiveEpgEntry>();
|
||||
private queue: string[] = [];
|
||||
private readonly inFlight = new Set<string>();
|
||||
private processing = false;
|
||||
/** Display offset every cached answer was evaluated under. */
|
||||
private stateOffsetMinutes = this.offsetMinutes();
|
||||
|
||||
private readonly programsState = signal<
|
||||
ReadonlyMap<string, EpgProgram | null>
|
||||
>(new Map());
|
||||
private readonly pendingState = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** Answers by entry key; absent = not asked yet, `null` = nothing on air. */
|
||||
readonly programs = this.programsState.asReadonly();
|
||||
/** Keys queued or in flight — the cards that may show a placeholder. */
|
||||
readonly pending = this.pendingState.asReadonly();
|
||||
|
||||
/**
|
||||
* Replace the wanted set. Keys without a fresh answer are queued; keys no
|
||||
* longer wanted are dropped from the queue (an in-flight request is
|
||||
* allowed to finish and is cached for when the card scrolls back).
|
||||
*/
|
||||
sync(entries: readonly DashboardPortalLiveEpgEntry[]): void {
|
||||
// The collection resolver this queue asks through is gated on the
|
||||
// local XMLTV bridge (`supportsProgramLookup`, desktop only) and
|
||||
// answers nothing without it, so the PWA never queues at all rather
|
||||
// than filing an empty answer for every card.
|
||||
if (!this.runtime.supportsEpgProgramLookup) {
|
||||
return;
|
||||
}
|
||||
this.retireStateOfPreviousOffset();
|
||||
this.wanted = new Map(entries.map((entry) => [entry.key, entry]));
|
||||
this.queue = this.queue.filter((key) => this.wanted.has(key));
|
||||
const now = Date.now();
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
this.needsFetch(entry.key, now) &&
|
||||
!this.queue.includes(entry.key)
|
||||
) {
|
||||
this.queue.push(entry.key);
|
||||
}
|
||||
}
|
||||
this.publishPending();
|
||||
if (!this.processing && this.queue.length > 0) {
|
||||
void this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sourceSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
private offsetMinutes(): number {
|
||||
return this.settingsStore.resolvedEpgOffsetMinutes();
|
||||
}
|
||||
|
||||
private needsFetch(key: string, now: number): boolean {
|
||||
if (this.inFlight.has(key)) {
|
||||
return false;
|
||||
}
|
||||
const cached = this.cache.get(key);
|
||||
if (!cached) {
|
||||
return true;
|
||||
}
|
||||
const ttlMs = cached.program
|
||||
? DASHBOARD_PORTAL_LIVE_EPG_TIMING.ttlMs
|
||||
: DASHBOARD_PORTAL_LIVE_EPG_TIMING.emptyTtlMs;
|
||||
if (now - cached.fetchedAt >= ttlMs) {
|
||||
return true;
|
||||
}
|
||||
const stopMs = dashboardPortalLiveEpgProgramStopMs(cached.program);
|
||||
return (
|
||||
stopMs !== null &&
|
||||
epgProviderClockMs(now, this.stateOffsetMinutes) >= stopMs &&
|
||||
now - cached.fetchedAt >=
|
||||
DASHBOARD_PORTAL_LIVE_EPG_TIMING.endedRefetchFloorMs
|
||||
);
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
this.processing = true;
|
||||
try {
|
||||
while (this.queue.length > 0) {
|
||||
if (
|
||||
this.inFlight.size >=
|
||||
DASHBOARD_PORTAL_LIVE_EPG_TIMING.maxConcurrency
|
||||
) {
|
||||
await this.delay();
|
||||
continue;
|
||||
}
|
||||
const key = this.queue.shift();
|
||||
if (
|
||||
key == null ||
|
||||
!this.wanted.has(key) ||
|
||||
!this.needsFetch(key, Date.now())
|
||||
) {
|
||||
this.publishPending();
|
||||
continue;
|
||||
}
|
||||
this.inFlight.add(key);
|
||||
void this.fetch(key);
|
||||
await this.delay();
|
||||
}
|
||||
} finally {
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch(key: string): Promise<void> {
|
||||
const entry = this.wanted.get(key);
|
||||
if (!entry) {
|
||||
this.inFlight.delete(key);
|
||||
this.publishPending();
|
||||
return;
|
||||
}
|
||||
// Both facts this answer is evaluated against. The revision is the
|
||||
// same fence `EpgService.guard()` uses: a reconciliation bumps it, so
|
||||
// a result computed against the previous XMLTV source set can be told
|
||||
// apart from one computed against the current one.
|
||||
const offsetMinutes = this.offsetMinutes();
|
||||
const revision = this.sourceSettings.revision();
|
||||
let program: EpgProgram | null = null;
|
||||
try {
|
||||
const epgMap = await this.streamResolver.loadEpgForItems([
|
||||
entry.item,
|
||||
]);
|
||||
program = resolveDashboardPortalLiveEpgProgram(epgMap, entry);
|
||||
} catch {
|
||||
// The resolver files a failed portal as `null` itself, so this
|
||||
// only catches a resolver-level throw. Same answer either way,
|
||||
// and `emptyTtlMs` is what makes it recoverable.
|
||||
program = null;
|
||||
}
|
||||
this.inFlight.delete(key);
|
||||
|
||||
// A setting or the source set changed while the request was on the
|
||||
// wire: this answer belongs to the previous provider clock or the
|
||||
// previous guide. `retireAnswers()` could not requeue the key while
|
||||
// it was in flight, so the requeue happens here — otherwise the stale
|
||||
// answer would be published and trusted for a full TTL.
|
||||
if (
|
||||
offsetMinutes !== this.offsetMinutes() ||
|
||||
revision !== this.sourceSettings.revision()
|
||||
) {
|
||||
this.retireStateOfPreviousOffset();
|
||||
this.requeueIfWanted(key);
|
||||
return;
|
||||
}
|
||||
|
||||
this.cache.set(key, { program, fetchedAt: Date.now() });
|
||||
this.programsState.update((programs) => {
|
||||
const next = new Map(programs);
|
||||
next.set(key, program);
|
||||
return next;
|
||||
});
|
||||
this.publishPending();
|
||||
}
|
||||
|
||||
private requeueIfWanted(key: string): void {
|
||||
if (this.wanted.has(key) && !this.queue.includes(key)) {
|
||||
this.queue.push(key);
|
||||
}
|
||||
this.publishPending();
|
||||
if (!this.processing && this.queue.length > 0) {
|
||||
void this.processQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every answer here is "what is on at the provider clock", so a changed
|
||||
* display offset drops all of them. The caller's own `sync` (the
|
||||
* presenter re-syncs on an offset change) asks the wanted cards again.
|
||||
*/
|
||||
private retireStateOfPreviousOffset(): void {
|
||||
const current = this.offsetMinutes();
|
||||
if (current === this.stateOffsetMinutes) {
|
||||
return;
|
||||
}
|
||||
this.stateOffsetMinutes = current;
|
||||
this.dropAnswers();
|
||||
}
|
||||
|
||||
/** Removed or re-imported XMLTV: drop every answer and ask again now. */
|
||||
private retireAnswers(): void {
|
||||
this.dropAnswers();
|
||||
if (this.wanted.size > 0) {
|
||||
this.sync(Array.from(this.wanted.values()));
|
||||
}
|
||||
}
|
||||
|
||||
private dropAnswers(): void {
|
||||
this.cache.clear();
|
||||
this.programsState.set(new Map());
|
||||
}
|
||||
|
||||
private publishPending(): void {
|
||||
const pending = new Set<string>();
|
||||
for (const key of this.queue) {
|
||||
if (this.wanted.has(key)) pending.add(key);
|
||||
}
|
||||
for (const key of this.inFlight) {
|
||||
if (this.wanted.has(key)) pending.add(key);
|
||||
}
|
||||
this.pendingState.set(pending);
|
||||
}
|
||||
|
||||
private delay(): Promise<void> {
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(resolve, DASHBOARD_PORTAL_LIVE_EPG_TIMING.delayMs)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
EpgProgram,
|
||||
PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildDashboardPortalLiveEpgEntry,
|
||||
buildDashboardPortalLiveEpgKey,
|
||||
dashboardPortalLiveEpgProgramStopMs,
|
||||
resolveDashboardPortalLiveEpgProgram,
|
||||
} from './dashboard-portal-live-epg.util';
|
||||
|
||||
const baseItem = (overrides: Partial<PortalActivityItem>): PortalActivityItem =>
|
||||
({
|
||||
id: 'item-1',
|
||||
title: 'News 24',
|
||||
type: 'live',
|
||||
playlist_id: 'playlist-1',
|
||||
playlist_name: 'My portal',
|
||||
category_id: '7',
|
||||
xtream_id: 42,
|
||||
poster_url: 'https://example.com/logo.png',
|
||||
...overrides,
|
||||
}) as PortalActivityItem;
|
||||
|
||||
describe('buildDashboardPortalLiveEpgEntry', () => {
|
||||
it('builds an Xtream live entry keyed by the collection uid with the stream id as tvgId', () => {
|
||||
const entry = buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({ source: 'xtream', xtream_id: 42 })
|
||||
);
|
||||
|
||||
expect(entry?.key).toBe('xtream::playlist-1::42');
|
||||
expect(entry?.item).toMatchObject({
|
||||
uid: 'xtream::playlist-1::42',
|
||||
name: 'News 24',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'My portal',
|
||||
logo: 'https://example.com/logo.png',
|
||||
xtreamId: 42,
|
||||
tvgId: '42',
|
||||
categoryId: '7',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a numeric string Xtream id and rejects ids that cannot name a stream', () => {
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({ source: 'xtream', xtream_id: '17' })
|
||||
)?.item.xtreamId
|
||||
).toBe(17);
|
||||
for (const xtream_id of ['', 'abc', 0, -3, 1.5]) {
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({ source: 'xtream', xtream_id })
|
||||
)
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('builds a Stalker live entry from the stored portal item, keyed by the extracted id', () => {
|
||||
const stalkerItem = {
|
||||
id: 'ch-9',
|
||||
cmd: 'ffrt http://portal/9',
|
||||
radio: false,
|
||||
};
|
||||
const entry = buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({
|
||||
source: 'stalker',
|
||||
id: 'ch-9',
|
||||
xtream_id: 'ch-9',
|
||||
stalker_item: stalkerItem,
|
||||
})
|
||||
);
|
||||
|
||||
expect(entry?.key).toBe('stalker::playlist-1::ch-9');
|
||||
expect(entry?.item).toMatchObject({
|
||||
sourceType: 'stalker',
|
||||
stalkerId: 'ch-9',
|
||||
tvgId: 'ch-9',
|
||||
stalkerCmd: 'ffrt http://portal/9',
|
||||
stalkerItem,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips Stalker radio rows and rows without the stored item, like the collection resolver', () => {
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({
|
||||
source: 'stalker',
|
||||
stalker_item: { id: 'r-1', radio: 'true' },
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({ source: 'stalker', stalker_item: undefined })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves M3U rows and non-live rows to the XMLTV batch', () => {
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgEntry(baseItem({ source: 'm3u' }))
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({ source: 'xtream', type: 'movie' })
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgKey(baseItem({ source: 'xtream' }))
|
||||
).toBe('xtream::playlist-1::42');
|
||||
expect(
|
||||
buildDashboardPortalLiveEpgKey(baseItem({ source: 'm3u' }))
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDashboardPortalLiveEpgProgram', () => {
|
||||
it('reads the resolver map by the entry tvgId and treats a missing key as nothing on air', () => {
|
||||
const entry = buildDashboardPortalLiveEpgEntry(
|
||||
baseItem({ source: 'xtream', xtream_id: 42 })
|
||||
);
|
||||
const program = { title: 'Evening news' } as EpgProgram;
|
||||
if (!entry) throw new Error('expected an entry');
|
||||
|
||||
expect(
|
||||
resolveDashboardPortalLiveEpgProgram(
|
||||
new Map([['42', program]]),
|
||||
entry
|
||||
)
|
||||
).toBe(program);
|
||||
expect(
|
||||
resolveDashboardPortalLiveEpgProgram(new Map(), entry)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboardPortalLiveEpgProgramStopMs', () => {
|
||||
it('prefers the unix-seconds stop, falls back to the ISO string, and reports null otherwise', () => {
|
||||
expect(
|
||||
dashboardPortalLiveEpgProgramStopMs({
|
||||
stopTimestamp: 1_700_000_000,
|
||||
stop: '2026-01-01T00:00:00.000Z',
|
||||
} as EpgProgram)
|
||||
).toBe(1_700_000_000_000);
|
||||
expect(
|
||||
dashboardPortalLiveEpgProgramStopMs({
|
||||
stop: '2026-01-01T00:00:00.000Z',
|
||||
} as EpgProgram)
|
||||
).toBe(Date.parse('2026-01-01T00:00:00.000Z'));
|
||||
expect(
|
||||
dashboardPortalLiveEpgProgramStopMs({
|
||||
stop: 'garbage',
|
||||
} as EpgProgram)
|
||||
).toBeNull();
|
||||
expect(dashboardPortalLiveEpgProgramStopMs(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
isStalkerRadioItem,
|
||||
type EpgProgram,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildCollectionUid,
|
||||
type UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
|
||||
/**
|
||||
* One dashboard live card's portal EPG request: the key its answer is filed
|
||||
* under (the collection uid, stable across favourites and recent rows of the
|
||||
* same channel) and the item `StreamResolverService.loadEpgForItems` fetches
|
||||
* for — the same shape the "See all" pages hand it, so the two surfaces
|
||||
* resolve a channel identically.
|
||||
*/
|
||||
export interface DashboardPortalLiveEpgEntry {
|
||||
readonly key: string;
|
||||
readonly item: UnifiedCollectionItem;
|
||||
}
|
||||
|
||||
/** The key alone, for cards that only need to find their answer. */
|
||||
export function buildDashboardPortalLiveEpgKey(
|
||||
item: PortalActivityItem
|
||||
): string | null {
|
||||
return buildDashboardPortalLiveEpgEntry(item)?.key ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* M3U channels keep the dashboard's batched XMLTV lookup; Xtream and Stalker
|
||||
* live rows have no XMLTV key of their own and are answered by their portal.
|
||||
* Radio rows are skipped like the collection resolver skips them.
|
||||
*/
|
||||
export function buildDashboardPortalLiveEpgEntry(
|
||||
item: PortalActivityItem
|
||||
): DashboardPortalLiveEpgEntry | null {
|
||||
if (item.type !== 'live') {
|
||||
return null;
|
||||
}
|
||||
if (item.source === 'xtream') {
|
||||
return buildXtreamEntry(item);
|
||||
}
|
||||
if (item.source === 'stalker') {
|
||||
return buildStalkerEntry(item);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolver keys its map by the item's `tvgId`; the entry sets it to the
|
||||
* provider id, so a missing key means "asked, nothing on air".
|
||||
*/
|
||||
export function resolveDashboardPortalLiveEpgProgram(
|
||||
epgMap: ReadonlyMap<string, EpgProgram | null>,
|
||||
entry: DashboardPortalLiveEpgEntry
|
||||
): EpgProgram | null {
|
||||
const key = entry.item.tvgId?.trim();
|
||||
return key ? (epgMap.get(key) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the programme on air ends, in wall-clock milliseconds; `null` when the
|
||||
* row states no usable end. Reads the pre-computed unix-seconds field first,
|
||||
* then the ISO string, like the dashboard's own time-range helper.
|
||||
*/
|
||||
export function dashboardPortalLiveEpgProgramStopMs(
|
||||
program: EpgProgram | null
|
||||
): number | null {
|
||||
if (!program) {
|
||||
return null;
|
||||
}
|
||||
const cached = Number(program.stopTimestamp);
|
||||
if (Number.isFinite(cached) && cached > 0) {
|
||||
return cached * 1000;
|
||||
}
|
||||
const parsed = program.stop ? Date.parse(program.stop) : NaN;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function buildXtreamEntry(
|
||||
item: PortalActivityItem
|
||||
): DashboardPortalLiveEpgEntry | null {
|
||||
const xtreamId = Number(item.xtream_id);
|
||||
if (!Number.isInteger(xtreamId) || xtreamId <= 0) {
|
||||
return null;
|
||||
}
|
||||
const key = buildCollectionUid('xtream', item.playlist_id, xtreamId);
|
||||
return {
|
||||
key,
|
||||
item: {
|
||||
uid: key,
|
||||
name: item.title,
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: item.playlist_id,
|
||||
playlistName: item.playlist_name ?? 'Xtream',
|
||||
logo: item.poster_url ?? null,
|
||||
xtreamId,
|
||||
tvgId: String(xtreamId),
|
||||
categoryId: item.category_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildStalkerEntry(
|
||||
item: PortalActivityItem
|
||||
): DashboardPortalLiveEpgEntry | null {
|
||||
const raw = item.stalker_item;
|
||||
if (!raw || isStalkerRadioItem(raw)) {
|
||||
return null;
|
||||
}
|
||||
// Both dashboard mappers already store the extracted portal id as `id`.
|
||||
const stalkerId = String(item.id ?? '').trim();
|
||||
if (!stalkerId) {
|
||||
return null;
|
||||
}
|
||||
const key = buildCollectionUid('stalker', item.playlist_id, stalkerId);
|
||||
return {
|
||||
key,
|
||||
item: {
|
||||
uid: key,
|
||||
name: item.title,
|
||||
contentType: 'live',
|
||||
sourceType: 'stalker',
|
||||
playlistId: item.playlist_id,
|
||||
playlistName: item.playlist_name ?? 'Stalker',
|
||||
logo: item.poster_url ?? null,
|
||||
stalkerId,
|
||||
tvgId: stalkerId,
|
||||
stalkerCmd: raw.cmd,
|
||||
categoryId: item.category_id,
|
||||
stalkerItem: raw,
|
||||
},
|
||||
};
|
||||
}
|
||||
+127
-3
@@ -2,10 +2,16 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { EMPTY, of, throwError } from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
type EpgProgram,
|
||||
type PlaylistMeta,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
|
||||
const guideA = 'https://a.example/guide.xml';
|
||||
@@ -36,6 +42,18 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
let presenter: DashboardLiveEpgPresenter;
|
||||
let getCurrentProgramsForChannels: jest.Mock;
|
||||
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let dashboardRails: ReturnType<
|
||||
typeof signal<typeof DEFAULT_DASHBOARD_RAILS_SETTINGS>
|
||||
>;
|
||||
/** Portal answers are the sibling presenter's job; stub it out here. */
|
||||
let portal: {
|
||||
connect: jest.Mock;
|
||||
setPinnedKeys: jest.Mock;
|
||||
setVisibleCards: jest.Mock;
|
||||
programFor: jest.Mock;
|
||||
isPending: jest.Mock;
|
||||
};
|
||||
|
||||
const setup = (cards: DashboardRailCard[]) => {
|
||||
presenter.connect(signal(cards));
|
||||
@@ -55,12 +73,32 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
{ _id: 'portal', serverUrl: 'http://portal' } as PlaylistMeta,
|
||||
]);
|
||||
|
||||
recentItems = signal<PortalActivityItem[]>([]);
|
||||
dashboardRails = signal({ ...DEFAULT_DASHBOARD_RAILS_SETTINGS });
|
||||
portal = {
|
||||
connect: jest.fn(),
|
||||
setPinnedKeys: jest.fn(),
|
||||
setVisibleCards: jest.fn(),
|
||||
programFor: jest.fn(() => undefined),
|
||||
isPending: jest.fn(() => false),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardLiveEpgPresenter,
|
||||
{
|
||||
provide: DashboardPortalLiveEpgPresenter,
|
||||
useValue: portal,
|
||||
},
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: { playlists },
|
||||
useValue: {
|
||||
playlists,
|
||||
// The presenter also derives the portal source list.
|
||||
globalRecentItems: recentItems,
|
||||
globalFavoriteLiveItems: signal([]),
|
||||
globalRecentLiveItems: signal([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgService,
|
||||
@@ -68,7 +106,10 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { resolvedEpgOffsetMinutes: () => 0 },
|
||||
useValue: {
|
||||
resolvedEpgOffsetMinutes: () => 0,
|
||||
dashboardRails,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -136,6 +177,89 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the hero row only while the hero rail shows one', () => {
|
||||
const heroLive = {
|
||||
id: 'x-7',
|
||||
title: 'Hero channel',
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: 7,
|
||||
} as PortalActivityItem;
|
||||
recentItems.set([heroLive]);
|
||||
TestBed.tick();
|
||||
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith(['xtream::p::7']);
|
||||
|
||||
// With the rail hidden nothing is pinned: the first portal row is
|
||||
// then a favourite, and pinning it would keep asking for a card
|
||||
// nobody can see.
|
||||
dashboardRails.set({
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
hero: false,
|
||||
});
|
||||
TestBed.tick();
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([null]);
|
||||
});
|
||||
|
||||
it('prefers the portal answer and forwards what the portal presenter owns', () => {
|
||||
const xmltvCard = card({
|
||||
id: 'x',
|
||||
epgLookupKey: 'ard.de',
|
||||
epgPlaylistId: 'a',
|
||||
liveEpgSourceKey: 'xtream::p::7',
|
||||
});
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(
|
||||
new Map<string, EpgProgram | null>([
|
||||
['ard.de', program('From XMLTV')],
|
||||
])
|
||||
)
|
||||
);
|
||||
portal.programFor.mockImplementation(
|
||||
(key: string | null | undefined) =>
|
||||
key === 'xtream::p::7' ? program('From the portal') : undefined
|
||||
);
|
||||
|
||||
setup([xmltvCard]);
|
||||
|
||||
expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe(
|
||||
'From the portal'
|
||||
);
|
||||
|
||||
// A portal that answered "nothing on air" falls back to XMLTV.
|
||||
portal.programFor.mockReturnValue(null);
|
||||
expect(presenter.detailsFor(xmltvCard)?.nowPlayingTitle).toBe(
|
||||
'From XMLTV'
|
||||
);
|
||||
|
||||
// The placeholder is only for a card still awaiting its FIRST answer.
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(new Map<string, EpgProgram | null>())
|
||||
);
|
||||
setup([xmltvCard]);
|
||||
portal.programFor.mockReturnValue(undefined);
|
||||
portal.isPending.mockReturnValue(true);
|
||||
expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBe(
|
||||
'pending'
|
||||
);
|
||||
// An answered card keeps what it has instead of flashing.
|
||||
portal.programFor.mockReturnValue(program('Answered'));
|
||||
expect(presenter.enrich([xmltvCard])[0].nowPlayingState).toBeNull();
|
||||
expect(presenter.enrich([xmltvCard])[0].nowPlayingTitle).toBe(
|
||||
'Answered'
|
||||
);
|
||||
|
||||
// The portal source list and the hero pin are the presenter's own
|
||||
// job; the rails only report what they can see.
|
||||
expect(portal.connect).toHaveBeenCalled();
|
||||
presenter.setVisibleCards('favorites', [xmltvCard]);
|
||||
expect(portal.setVisibleCards).toHaveBeenCalledWith('favorites', [
|
||||
xmltvCard,
|
||||
]);
|
||||
});
|
||||
|
||||
it('never hands a card the programme another guide resolved for the same id', () => {
|
||||
const fromA = card({
|
||||
id: 'a',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
@@ -17,11 +19,19 @@ import {
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import type { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
normalizeDashboardRailsSettings,
|
||||
type EpgProgram,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import { normalizeEpgUrls } from '@iptvnator/shared/m3u-utils';
|
||||
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
|
||||
import {
|
||||
buildDashboardPortalLiveEpgKey,
|
||||
DashboardDataService,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
import {
|
||||
buildDashboardLiveEpgDetails,
|
||||
buildLiveEpgLookupGroups,
|
||||
@@ -33,6 +43,7 @@ import {
|
||||
type DashboardLiveEpgDetails,
|
||||
type DashboardLiveEpgLookupGroup,
|
||||
} from './dashboard-live-epg.utils';
|
||||
import { RAIL_ITEM_LIMIT } from './dashboard-rail.utils';
|
||||
|
||||
type ScopeAnswer = {
|
||||
readonly scopeKey: string;
|
||||
@@ -60,12 +71,19 @@ const emptyAnswer = (scopeKey: string): ScopeAnswer => ({
|
||||
* XMLTV, which is what the "See all" collection pages resolve against.
|
||||
* Without it a channel whose guide only exists in another playlist's XMLTV
|
||||
* showed no programme here while its "See all" row had one.
|
||||
*
|
||||
* It is also the one facade the rails talk to for live EPG: an Xtream or
|
||||
* Stalker card has no XMLTV key of its own, so its programme comes from
|
||||
* `DashboardPortalLiveEpgPresenter` and only falls back to the title match
|
||||
* here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardLiveEpgPresenter {
|
||||
private readonly data = inject(DashboardDataService);
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
/** Xtream/Stalker cards are answered by their portal, not by XMLTV. */
|
||||
private readonly portal = inject(DashboardPortalLiveEpgPresenter);
|
||||
|
||||
private readonly cards = signal<Signal<
|
||||
readonly DashboardRailCard[]
|
||||
@@ -113,24 +131,99 @@ export class DashboardLiveEpgPresenter {
|
||||
{ initialValue: new Map<string, EpgProgram | null>() }
|
||||
);
|
||||
|
||||
private readonly rails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
/** The live row behind the hero panel, when that rail shows one. */
|
||||
private readonly heroLiveItem = computed<PortalActivityItem | null>(() => {
|
||||
const hero = this.data.globalRecentItems()[0] ?? null;
|
||||
return this.rails().hero && hero?.type === 'live' ? hero : null;
|
||||
});
|
||||
|
||||
// The Xtream/Stalker live rows behind the hero and the two live rails.
|
||||
// Their programmes come from the portal, asked for lazily per visible
|
||||
// card; M3U rows stay on the XMLTV batch above.
|
||||
private readonly portalItems = computed<readonly PortalActivityItem[]>(
|
||||
() => {
|
||||
const rails = this.rails();
|
||||
const hero = this.heroLiveItem();
|
||||
return [
|
||||
...(hero ? [hero] : []),
|
||||
...(rails.liveFavorites
|
||||
? this.data
|
||||
.globalFavoriteLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
: []),
|
||||
...(rails.recentlyWatchedLive
|
||||
? this.data
|
||||
.globalRecentLiveItems()
|
||||
.slice(0, RAIL_ITEM_LIMIT)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.portal.connect(this.portalItems);
|
||||
// The hero sits at the top of the page and is never scrolled into
|
||||
// view, so its key is wanted regardless of what the rails report.
|
||||
// Only the hero: the first entry of `portalItems` is a favourite
|
||||
// when that rail is hidden, and pinning it would keep asking for a
|
||||
// card nobody can see.
|
||||
effect(() => {
|
||||
const hero = this.heroLiveItem();
|
||||
const heroKey = hero ? buildDashboardPortalLiveEpgKey(hero) : null;
|
||||
untracked(() => this.portal.setPinnedKeys([heroKey]));
|
||||
});
|
||||
}
|
||||
|
||||
/** The live cards whose rails are enabled, hero included. */
|
||||
connect(cards: Signal<readonly DashboardRailCard[]>): void {
|
||||
this.cards.set(cards);
|
||||
}
|
||||
|
||||
/** A rail reported the cards inside its viewport. */
|
||||
setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void {
|
||||
this.portal.setVisibleCards(railId, cards);
|
||||
}
|
||||
|
||||
/** The rails' cards with their "now on air" row filled in. */
|
||||
enrich(cards: readonly DashboardRailCard[]): DashboardRailCard[] {
|
||||
return cards.map((card) => {
|
||||
const details = this.detailsFor(card);
|
||||
// Placeholder only before the FIRST portal answer: a refresh
|
||||
// keeps the previous answer on screen instead of flashing.
|
||||
const pending = !details && this.isAwaitingFirstAnswer(card);
|
||||
if (!details && !pending) {
|
||||
return card;
|
||||
}
|
||||
return {
|
||||
...card,
|
||||
...(details ?? {}),
|
||||
nowPlayingState: pending ? 'pending' : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** `null` when nothing is known about the card's current programme. */
|
||||
detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null {
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
const program = getLiveEpgProgramForCard(
|
||||
card,
|
||||
this.programs(),
|
||||
liveEpgScopeKey(
|
||||
this.sourceUrlsForCard(card),
|
||||
liveEpgAllowsAnySource(card)
|
||||
)
|
||||
);
|
||||
// A portal answer wins. Its `null` ("asked, nothing on air") and
|
||||
// "not asked yet" both fall back to the XMLTV lookup, which for a
|
||||
// portal card can only ever be a title match.
|
||||
const program =
|
||||
this.portal.programFor(card.liveEpgSourceKey) ??
|
||||
getLiveEpgProgramForCard(
|
||||
card,
|
||||
this.programs(),
|
||||
liveEpgScopeKey(
|
||||
this.sourceUrlsForCard(card),
|
||||
liveEpgAllowsAnySource(card)
|
||||
)
|
||||
);
|
||||
// Recompute the now-window each tick so progress moves between
|
||||
// 30s ticks even if the program identity is unchanged.
|
||||
return buildDashboardLiveEpgDetails(
|
||||
@@ -140,6 +233,17 @@ export class DashboardLiveEpgPresenter {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True only before a card's FIRST portal answer, so a refresh keeps the
|
||||
* previous answer on screen instead of flashing a placeholder.
|
||||
*/
|
||||
private isAwaitingFirstAnswer(card: DashboardRailCard): boolean {
|
||||
return (
|
||||
this.portal.programFor(card.liveEpgSourceKey) === undefined &&
|
||||
this.portal.isPending(card.liveEpgSourceKey)
|
||||
);
|
||||
}
|
||||
|
||||
/** The XMLTV scope a live card's programme must be resolved in. */
|
||||
private sourceUrlsForCard(card: DashboardRailCard): string[] {
|
||||
const byPlaylistId = this.sourceUrlsByPlaylist();
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type {
|
||||
EpgProgram,
|
||||
PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
DashboardPortalLiveEpgService,
|
||||
type DashboardPortalLiveEpgEntry,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
|
||||
const xtreamLive = (id: number, playlist = 'p'): PortalActivityItem =>
|
||||
({
|
||||
id: `x-${id}`,
|
||||
title: `Channel ${id}`,
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: playlist,
|
||||
category_id: '1',
|
||||
xtream_id: id,
|
||||
}) as PortalActivityItem;
|
||||
|
||||
describe('DashboardPortalLiveEpgPresenter', () => {
|
||||
let presenter: DashboardPortalLiveEpgPresenter;
|
||||
let sync: jest.Mock;
|
||||
let programs: ReturnType<
|
||||
typeof signal<ReadonlyMap<string, EpgProgram | null>>
|
||||
>;
|
||||
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
|
||||
let offsetMinutes: ReturnType<typeof signal<number>>;
|
||||
|
||||
/** Keys of every sync call, sorted: the wanted set has no order. */
|
||||
const wantedKeys = (): string[][] =>
|
||||
sync.mock.calls.map(([entries]: [DashboardPortalLiveEpgEntry[]]) =>
|
||||
entries.map((entry) => entry.key).sort()
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
sync = jest.fn();
|
||||
programs = signal<ReadonlyMap<string, EpgProgram | null>>(new Map());
|
||||
pending = signal<ReadonlySet<string>>(new Set());
|
||||
offsetMinutes = signal(0);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardPortalLiveEpgPresenter,
|
||||
{
|
||||
provide: DashboardPortalLiveEpgService,
|
||||
useValue: { sync, programs, pending },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { resolvedEpgOffsetMinutes: offsetMinutes },
|
||||
},
|
||||
],
|
||||
});
|
||||
presenter = TestBed.inject(DashboardPortalLiveEpgPresenter);
|
||||
});
|
||||
|
||||
it('asks only for pinned and visible cards, deduplicated across rails', () => {
|
||||
const items = signal<readonly PortalActivityItem[]>([
|
||||
xtreamLive(1),
|
||||
xtreamLive(2),
|
||||
xtreamLive(3),
|
||||
// The same channel in the recent rail shares its key.
|
||||
xtreamLive(2),
|
||||
]);
|
||||
presenter.connect(items);
|
||||
TestBed.tick();
|
||||
expect(wantedKeys().at(-1)).toEqual([]);
|
||||
|
||||
presenter.setPinnedKeys(['xtream::p::1', null, undefined]);
|
||||
presenter.setVisibleCards('favorites', [
|
||||
{ id: 'f2', liveEpgSourceKey: 'xtream::p::2' },
|
||||
{ id: 'f3', liveEpgSourceKey: 'xtream::p::3' },
|
||||
] as never);
|
||||
presenter.setVisibleCards('recent', [
|
||||
{ id: 'r2', liveEpgSourceKey: 'xtream::p::2' },
|
||||
{ id: 'm3u', liveEpgSourceKey: null },
|
||||
] as never);
|
||||
TestBed.tick();
|
||||
expect(wantedKeys().at(-1)).toEqual([
|
||||
'xtream::p::1',
|
||||
'xtream::p::2',
|
||||
'xtream::p::3',
|
||||
]);
|
||||
|
||||
// Scrolled away: the favourites rail now shows only channel 3.
|
||||
presenter.setVisibleCards('favorites', [
|
||||
{ id: 'f3', liveEpgSourceKey: 'xtream::p::3' },
|
||||
] as never);
|
||||
TestBed.tick();
|
||||
expect(wantedKeys().at(-1)).toEqual([
|
||||
'xtream::p::1',
|
||||
'xtream::p::2',
|
||||
'xtream::p::3',
|
||||
]);
|
||||
presenter.setVisibleCards('recent', []);
|
||||
TestBed.tick();
|
||||
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1', 'xtream::p::3']);
|
||||
});
|
||||
|
||||
it('ignores visible keys whose item is no longer on the dashboard', () => {
|
||||
const items = signal<readonly PortalActivityItem[]>([xtreamLive(1)]);
|
||||
presenter.connect(items);
|
||||
presenter.setVisibleCards('favorites', [
|
||||
{ id: 'f1', liveEpgSourceKey: 'xtream::p::1' },
|
||||
{ id: 'gone', liveEpgSourceKey: 'xtream::p::9' },
|
||||
] as never);
|
||||
TestBed.tick();
|
||||
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']);
|
||||
|
||||
items.set([]);
|
||||
TestBed.tick();
|
||||
expect(wantedKeys().at(-1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-syncs the same wanted set when the display offset changes', () => {
|
||||
presenter.connect(
|
||||
signal<readonly PortalActivityItem[]>([xtreamLive(1)])
|
||||
);
|
||||
presenter.setPinnedKeys(['xtream::p::1']);
|
||||
TestBed.tick();
|
||||
const before = sync.mock.calls.length;
|
||||
|
||||
offsetMinutes.set(30);
|
||||
TestBed.tick();
|
||||
expect(sync.mock.calls.length).toBe(before + 1);
|
||||
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']);
|
||||
});
|
||||
|
||||
it('hands its wanted set back to the root service when the dashboard is destroyed', () => {
|
||||
presenter.connect(
|
||||
signal<readonly PortalActivityItem[]>([xtreamLive(1)])
|
||||
);
|
||||
presenter.setPinnedKeys(['xtream::p::1']);
|
||||
TestBed.tick();
|
||||
expect(wantedKeys().at(-1)).toEqual(['xtream::p::1']);
|
||||
|
||||
// The queue lives in the root service and would otherwise keep
|
||||
// asking for cards on a page the user has left.
|
||||
TestBed.resetTestingModule();
|
||||
expect(wantedKeys().at(-1)).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers a card from the service: undefined until asked, null when nothing is on air', () => {
|
||||
const program = { title: 'Now' } as EpgProgram;
|
||||
expect(presenter.programFor('xtream::p::1')).toBeUndefined();
|
||||
expect(presenter.programFor(null)).toBeUndefined();
|
||||
expect(presenter.isPending('xtream::p::1')).toBe(false);
|
||||
|
||||
pending.set(new Set(['xtream::p::1']));
|
||||
expect(presenter.isPending('xtream::p::1')).toBe(true);
|
||||
expect(presenter.isPending(undefined)).toBe(false);
|
||||
|
||||
programs.set(
|
||||
new Map([
|
||||
['xtream::p::1', program],
|
||||
['xtream::p::2', null],
|
||||
])
|
||||
);
|
||||
expect(presenter.programFor('xtream::p::1')).toBe(program);
|
||||
expect(presenter.programFor('xtream::p::2')).toBeNull();
|
||||
});
|
||||
});
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
type Signal,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, map } from 'rxjs';
|
||||
import type {
|
||||
EpgProgram,
|
||||
PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { SettingsStore } from '@iptvnator/services';
|
||||
import {
|
||||
buildDashboardPortalLiveEpgEntry,
|
||||
DashboardPortalLiveEpgService,
|
||||
type DashboardPortalLiveEpgEntry,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
import { LIVE_EPG_TICK_MS } from './dashboard-live-epg.utils';
|
||||
|
||||
/**
|
||||
* The rails component's view of `DashboardPortalLiveEpgService`: which
|
||||
* portal live cards exist, which of them are on screen, and the answer for
|
||||
* a card. Component-provided, so its wanted set dies with the dashboard.
|
||||
*
|
||||
* "On screen" is what the rails report through their visibility output,
|
||||
* plus the pinned keys (the hero card, always at the top). A card the user
|
||||
* never scrolls to is never asked for. The 30 s tick re-syncs so a programme
|
||||
* that ended is asked again and progress bars keep moving; a changed display
|
||||
* offset re-syncs at once because every cached answer was just retired.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardPortalLiveEpgPresenter {
|
||||
private readonly service = inject(DashboardPortalLiveEpgService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
|
||||
private readonly source = signal<Signal<
|
||||
readonly PortalActivityItem[]
|
||||
> | null>(null);
|
||||
private readonly visibleKeysByRail = signal<
|
||||
ReadonlyMap<string, ReadonlySet<string>>
|
||||
>(new Map());
|
||||
private readonly pinnedKeys = signal<ReadonlySet<string>>(new Set());
|
||||
|
||||
/** Heartbeat shared with the XMLTV batch: shifted by one so the first
|
||||
* emission differs from `initialValue` and is not swallowed. */
|
||||
readonly tick = toSignal(
|
||||
interval(LIVE_EPG_TICK_MS).pipe(map((tick) => tick + 1)),
|
||||
{ initialValue: 0 }
|
||||
);
|
||||
|
||||
private readonly entries = computed<
|
||||
ReadonlyMap<string, DashboardPortalLiveEpgEntry>
|
||||
>(() => {
|
||||
const source = this.source();
|
||||
const entries = new Map<string, DashboardPortalLiveEpgEntry>();
|
||||
for (const item of source?.() ?? []) {
|
||||
const entry = buildDashboardPortalLiveEpgEntry(item);
|
||||
if (entry && !entries.has(entry.key)) {
|
||||
entries.set(entry.key, entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
|
||||
private readonly wanted = computed<DashboardPortalLiveEpgEntry[]>(() => {
|
||||
const entries = this.entries();
|
||||
const keys = new Set<string>(this.pinnedKeys());
|
||||
for (const railKeys of this.visibleKeysByRail().values()) {
|
||||
for (const key of railKeys) keys.add(key);
|
||||
}
|
||||
const wanted: DashboardPortalLiveEpgEntry[] = [];
|
||||
for (const key of keys) {
|
||||
const entry = entries.get(key);
|
||||
if (entry) wanted.push(entry);
|
||||
}
|
||||
return wanted;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const wanted = this.wanted();
|
||||
this.tick();
|
||||
this.settingsStore.resolvedEpgOffsetMinutes();
|
||||
untracked(() => this.service.sync(wanted));
|
||||
});
|
||||
// The queue lives in the root service; this presenter owns what it
|
||||
// wants. Leaving the dashboard must hand that back, or the queue
|
||||
// would keep asking for cards on a page that is gone — and a later
|
||||
// source change would ask for them again.
|
||||
inject(DestroyRef).onDestroy(() => this.service.sync([]));
|
||||
}
|
||||
|
||||
/** The live rows every portal card on the dashboard is built from. */
|
||||
connect(source: Signal<readonly PortalActivityItem[]>): void {
|
||||
this.source.set(source);
|
||||
}
|
||||
|
||||
/** Keys wanted regardless of scrolling (the hero card). */
|
||||
setPinnedKeys(keys: readonly (string | null | undefined)[]): void {
|
||||
this.pinnedKeys.set(
|
||||
new Set(keys.filter((key): key is string => !!key))
|
||||
);
|
||||
}
|
||||
|
||||
/** A rail reported the cards inside its viewport. */
|
||||
setVisibleCards(railId: string, cards: readonly DashboardRailCard[]): void {
|
||||
const keys = new Set<string>();
|
||||
for (const card of cards) {
|
||||
if (card.liveEpgSourceKey) keys.add(card.liveEpgSourceKey);
|
||||
}
|
||||
this.visibleKeysByRail.update((byRail) => {
|
||||
const next = new Map(byRail);
|
||||
next.set(railId, keys);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `undefined` = not asked yet or still in flight, `null` = asked and
|
||||
* nothing on air, else the programme.
|
||||
*/
|
||||
programFor(key: string | null | undefined): EpgProgram | null | undefined {
|
||||
return key ? this.service.programs().get(key) : undefined;
|
||||
}
|
||||
|
||||
isPending(key: string | null | undefined): boolean {
|
||||
return !!key && this.service.pending().has(key);
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,7 @@
|
||||
'WORKSPACE.DASHBOARD.SEE_ALL_COUNT'
|
||||
| translate
|
||||
: {
|
||||
count:
|
||||
totalCount() ?? items().length,
|
||||
count: totalCount() ?? items().length,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
@@ -56,8 +55,10 @@
|
||||
@for (card of items(); track card.id) {
|
||||
@if (layout() === 'channel') {
|
||||
<div
|
||||
#cardEl
|
||||
class="rail__card rail__card--channel"
|
||||
role="listitem"
|
||||
[attr.data-card-id]="card.id"
|
||||
[attr.title]="cardTitle(card)"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card' : null
|
||||
@@ -99,6 +100,14 @@
|
||||
<span class="rail__channel-now">
|
||||
@if (card.nowPlayingTitle) {
|
||||
{{ card.nowPlayingTitle }}
|
||||
} @else if (
|
||||
card.nowPlayingState === 'pending'
|
||||
) {
|
||||
<span
|
||||
class="rail__channel-now-placeholder"
|
||||
aria-hidden="true"
|
||||
data-test-id="rail-now-placeholder"
|
||||
></span>
|
||||
} @else if (card.subtitle) {
|
||||
{{ card.subtitle }}
|
||||
}
|
||||
@@ -132,8 +141,10 @@
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
#cardEl
|
||||
class="rail__card"
|
||||
role="listitem"
|
||||
[attr.data-card-id]="card.id"
|
||||
[attr.title]="cardTitle(card)"
|
||||
[attr.data-test-id]="
|
||||
testId() ? testId() + '-card' : null
|
||||
@@ -177,7 +188,10 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (card.watchProgress !== null && card.watchProgress !== undefined) {
|
||||
@if (
|
||||
card.watchProgress !== null &&
|
||||
card.watchProgress !== undefined
|
||||
) {
|
||||
<span
|
||||
class="rail__art-progress"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -538,8 +538,7 @@
|
||||
|
||||
.rail__card--channel {
|
||||
background: var(--app-widget-bg, #1e2330);
|
||||
border: 1px solid
|
||||
var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
|
||||
border-radius: var(--rail-radius);
|
||||
padding: 10px 12px;
|
||||
transition:
|
||||
@@ -549,10 +548,7 @@
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(
|
||||
--app-selection-border,
|
||||
rgba(47, 123, 255, 0.4)
|
||||
);
|
||||
border-color: var(--app-selection-border, rgba(47, 123, 255, 0.4));
|
||||
box-shadow:
|
||||
0 6px 16px -10px rgba(0, 0, 0, 0.45),
|
||||
0 0 0 1px var(--app-selection-border, rgba(47, 123, 255, 0.3));
|
||||
@@ -643,6 +639,38 @@
|
||||
min-height: 1.35em;
|
||||
}
|
||||
|
||||
// Shown while a portal card's first "now on air" answer is on its way: a
|
||||
// quiet shimmer bar in the programme slot, sized like one line of text.
|
||||
.rail__channel-now-placeholder {
|
||||
display: inline-block;
|
||||
width: 62%;
|
||||
height: 0.66em;
|
||||
margin-top: 0.3em;
|
||||
border-radius: 4px;
|
||||
vertical-align: top;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--app-muted-color, #8891a4) 18%, transparent) 25%,
|
||||
color-mix(in srgb, var(--app-muted-color, #8891a4) 34%, transparent) 50%,
|
||||
color-mix(in srgb, var(--app-muted-color, #8891a4) 18%, transparent) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: rail-now-placeholder-shimmer 1.4s ease-in-out infinite;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rail-now-placeholder-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.rail__channel-progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -163,4 +163,212 @@ describe('DashboardRailComponent', () => {
|
||||
expect(cards[1].querySelector('.rail__card-meta-row')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('visible cards', () => {
|
||||
type ObserverCallback = (entries: IntersectionObserverEntry[]) => void;
|
||||
let observers: {
|
||||
callback: ObserverCallback;
|
||||
options: IntersectionObserverInit;
|
||||
observed: Element[];
|
||||
disconnect: jest.Mock;
|
||||
}[];
|
||||
|
||||
const installObservers = (available: boolean) => {
|
||||
observers = [];
|
||||
const scope = globalThis as unknown as {
|
||||
IntersectionObserver?: unknown;
|
||||
ResizeObserver: unknown;
|
||||
};
|
||||
scope.ResizeObserver = class {
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
};
|
||||
if (!available) {
|
||||
delete scope.IntersectionObserver;
|
||||
return;
|
||||
}
|
||||
scope.IntersectionObserver = class {
|
||||
observed: Element[] = [];
|
||||
disconnect = jest.fn();
|
||||
constructor(
|
||||
callback: ObserverCallback,
|
||||
options: IntersectionObserverInit
|
||||
) {
|
||||
observers.push({
|
||||
callback,
|
||||
options,
|
||||
observed: this.observed,
|
||||
disconnect: this.disconnect,
|
||||
});
|
||||
}
|
||||
observe(element: Element): void {
|
||||
this.observed.push(element);
|
||||
}
|
||||
unobserve = jest.fn();
|
||||
};
|
||||
};
|
||||
|
||||
const render = async (
|
||||
items: DashboardRailCard[],
|
||||
layout: 'channel' | 'cover' = 'channel'
|
||||
) => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DashboardRailComponent, TranslateModule.forRoot()],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: { stripCountryPrefix: signal(false) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(DashboardRailComponent);
|
||||
const visible: string[][] = [];
|
||||
fixture.componentInstance.visibleCardsChanged.subscribe((cards) =>
|
||||
visible.push(cards.map((card) => card.id))
|
||||
);
|
||||
fixture.componentRef.setInput('label', 'Live');
|
||||
fixture.componentRef.setInput('layout', layout);
|
||||
fixture.componentRef.setInput('items', items);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return { fixture, visible };
|
||||
};
|
||||
|
||||
const intersect = (
|
||||
observer: (typeof observers)[number],
|
||||
states: Record<string, boolean>
|
||||
) => {
|
||||
observer.callback(
|
||||
observer.observed
|
||||
.filter(
|
||||
(element) =>
|
||||
(element as HTMLElement).dataset['cardId']! in
|
||||
states
|
||||
)
|
||||
.map(
|
||||
(element) =>
|
||||
({
|
||||
target: element,
|
||||
isIntersecting:
|
||||
states[
|
||||
(element as HTMLElement).dataset[
|
||||
'cardId'
|
||||
]!
|
||||
],
|
||||
}) as IntersectionObserverEntry
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
it('reports only the cards the observer sees, in item order, inside the track viewport', async () => {
|
||||
installObservers(true);
|
||||
const { visible } = await render([
|
||||
card({ id: 'a', contentType: 'live' }),
|
||||
card({ id: 'b', contentType: 'live' }),
|
||||
card({ id: 'c', contentType: 'live' }),
|
||||
]);
|
||||
expect(observers).toHaveLength(1);
|
||||
const [observer] = observers;
|
||||
expect(observer.observed).toHaveLength(3);
|
||||
expect(
|
||||
(observer.options.root as HTMLElement).classList.contains(
|
||||
'rail__track'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(observer.options.rootMargin).toContain('160px');
|
||||
|
||||
intersect(observer, { c: true, a: true, b: false });
|
||||
expect(visible.at(-1)).toEqual(['a', 'c']);
|
||||
|
||||
// Scrolling: b enters, a leaves.
|
||||
intersect(observer, { b: true, a: false });
|
||||
expect(visible.at(-1)).toEqual(['b', 'c']);
|
||||
|
||||
// Same set again does not re-emit.
|
||||
const emissions = visible.length;
|
||||
intersect(observer, { b: true });
|
||||
expect(visible).toHaveLength(emissions);
|
||||
});
|
||||
|
||||
it('re-observes the rendered elements when the item set changes and forgets removed cards', async () => {
|
||||
installObservers(true);
|
||||
const { fixture, visible } = await render([
|
||||
card({ id: 'a', contentType: 'live' }),
|
||||
card({ id: 'b', contentType: 'live' }),
|
||||
]);
|
||||
const [observer] = observers;
|
||||
intersect(observer, { a: true, b: true });
|
||||
expect(visible.at(-1)).toEqual(['a', 'b']);
|
||||
|
||||
fixture.componentRef.setInput('items', [
|
||||
card({ id: 'b', contentType: 'live' }),
|
||||
card({ id: 'c', contentType: 'live' }),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
// One observer, disconnected and re-armed on the new elements.
|
||||
expect(observers).toHaveLength(1);
|
||||
expect(observer.disconnect).toHaveBeenCalled();
|
||||
expect(
|
||||
observer.observed
|
||||
.slice(-2)
|
||||
.map(
|
||||
(element) => (element as HTMLElement).dataset['cardId']
|
||||
)
|
||||
).toEqual(['b', 'c']);
|
||||
// `a` is gone from the visible set even before c is notified.
|
||||
expect(visible.at(-1)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('reports every card when IntersectionObserver is unavailable', async () => {
|
||||
installObservers(false);
|
||||
const { visible } = await render(
|
||||
[
|
||||
card({ id: 'a', contentType: 'movie' }),
|
||||
card({ id: 'b', contentType: 'movie' }),
|
||||
],
|
||||
'cover'
|
||||
);
|
||||
expect(visible.at(-1)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('shows the placeholder only while a live card is pending its first answer', async () => {
|
||||
installObservers(false);
|
||||
const { fixture } = await render([
|
||||
card({
|
||||
id: 'pending',
|
||||
contentType: 'live',
|
||||
subtitle: 'Xtream · TV',
|
||||
nowPlayingState: 'pending',
|
||||
}),
|
||||
card({
|
||||
id: 'answered',
|
||||
contentType: 'live',
|
||||
subtitle: 'Xtream · TV',
|
||||
nowPlayingTitle: 'Evening news',
|
||||
nowPlayingState: null,
|
||||
}),
|
||||
card({ id: 'none', contentType: 'live', subtitle: 'M3U · TV' }),
|
||||
]);
|
||||
const rows = Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
'.rail__channel-now'
|
||||
)
|
||||
);
|
||||
expect(
|
||||
rows[0].querySelector('.rail__channel-now-placeholder')
|
||||
).not.toBeNull();
|
||||
expect(rows[0].textContent?.trim()).toBe('');
|
||||
expect(rows[1].textContent?.trim()).toBe('Evening news');
|
||||
expect(rows[2].textContent?.trim()).toBe('M3U · TV');
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll(
|
||||
'.rail__channel-now-placeholder'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
viewChildren,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
@@ -47,6 +49,18 @@ export interface DashboardRailCard {
|
||||
* grouped and namespaced by that playlist's source scope.
|
||||
*/
|
||||
epgPlaylistId?: string;
|
||||
/**
|
||||
* Key of the portal (Xtream/Stalker) EPG answer for a live card, asked
|
||||
* for lazily once the card is on screen — see
|
||||
* `DashboardPortalLiveEpgPresenter`. Unset for M3U cards.
|
||||
*/
|
||||
liveEpgSourceKey?: string | null;
|
||||
/**
|
||||
* `'pending'` while the card's first portal answer is on its way: the
|
||||
* 'channel' layout shows a placeholder instead of the subtitle. A later
|
||||
* refresh keeps the previous answer on screen, so it never flashes.
|
||||
*/
|
||||
nowPlayingState?: 'pending' | null;
|
||||
|
||||
/**
|
||||
* Optional EPG enrichment shown by the 'channel' rail layout. Populated
|
||||
@@ -128,6 +142,13 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
readonly layout = input<DashboardRailLayout>('cover');
|
||||
readonly testId = input<string | null>(null);
|
||||
readonly actionSelected = output<DashboardRailActionSelection>();
|
||||
/**
|
||||
* The cards inside (or just beyond, see `rootMargin`) the rail's
|
||||
* viewport, in item order; emitted whenever that set changes. Lets the
|
||||
* host ask for per-card data — portal EPG — only for cards the user can
|
||||
* see. Environments without `IntersectionObserver` report every card.
|
||||
*/
|
||||
readonly visibleCardsChanged = output<DashboardRailCard[]>();
|
||||
/**
|
||||
* True total in the underlying dataset. Shown as a count badge next to
|
||||
* the rail label. Falls back to `items().length` when not supplied.
|
||||
@@ -136,6 +157,8 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
private readonly track =
|
||||
viewChild.required<ElementRef<HTMLDivElement>>('track');
|
||||
private readonly cardElements =
|
||||
viewChildren<ElementRef<HTMLElement>>('cardEl');
|
||||
|
||||
readonly canScrollLeft = signal(false);
|
||||
readonly canScrollRight = signal(false);
|
||||
@@ -143,6 +166,9 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
private readonly viewReady = signal(false);
|
||||
|
||||
private resizeObserver?: ResizeObserver;
|
||||
private intersectionObserver?: IntersectionObserver;
|
||||
private readonly visibleCardIds = new Set<string>();
|
||||
private lastVisibleSignature: string | null = null;
|
||||
private resetFrameId: number | null = null;
|
||||
private settleFrameId: number | null = null;
|
||||
|
||||
@@ -152,6 +178,14 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
if (!this.viewReady()) return;
|
||||
this.scheduleResetToStart();
|
||||
});
|
||||
// The rendered card set changed: watch the new elements. Reading
|
||||
// `items()` too keeps an id-only change (same elements, new cards)
|
||||
// from leaving a stale visible set behind.
|
||||
effect(() => {
|
||||
const elements = this.cardElements();
|
||||
this.items();
|
||||
untracked(() => this.observeCards(elements));
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -165,9 +199,73 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
this.intersectionObserver?.disconnect();
|
||||
this.cancelPendingReset();
|
||||
}
|
||||
|
||||
private observeCards(elements: readonly ElementRef<HTMLElement>[]): void {
|
||||
const renderedIds = new Set(
|
||||
elements.map((element) => element.nativeElement.dataset['cardId'])
|
||||
);
|
||||
for (const id of [...this.visibleCardIds]) {
|
||||
if (!renderedIds.has(id)) this.visibleCardIds.delete(id);
|
||||
}
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
for (const id of renderedIds) {
|
||||
if (id) this.visibleCardIds.add(id);
|
||||
}
|
||||
this.emitVisibleCards();
|
||||
return;
|
||||
}
|
||||
|
||||
this.intersectionObserver?.disconnect();
|
||||
// Cards that left the list are reported gone at once; the observer's
|
||||
// initial notifications then settle the cards that are still here.
|
||||
this.emitVisibleCards();
|
||||
if (elements.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Lazily created: the first non-empty card list means the track
|
||||
// exists. A margin of roughly one card lets the next card's answer
|
||||
// arrive before the user scrolls to it. Observing fires an initial
|
||||
// notification for every target, which settles the visible set.
|
||||
this.intersectionObserver ??= new IntersectionObserver(
|
||||
(entries) => this.onCardsIntersect(entries),
|
||||
{
|
||||
root: this.track().nativeElement,
|
||||
rootMargin: '0px 160px 0px 160px',
|
||||
threshold: 0,
|
||||
}
|
||||
);
|
||||
for (const element of elements) {
|
||||
this.intersectionObserver.observe(element.nativeElement);
|
||||
}
|
||||
}
|
||||
|
||||
private onCardsIntersect(entries: IntersectionObserverEntry[]): void {
|
||||
for (const entry of entries) {
|
||||
const id = (entry.target as HTMLElement).dataset['cardId'];
|
||||
if (!id) continue;
|
||||
if (entry.isIntersecting) {
|
||||
this.visibleCardIds.add(id);
|
||||
} else {
|
||||
this.visibleCardIds.delete(id);
|
||||
}
|
||||
}
|
||||
this.emitVisibleCards();
|
||||
}
|
||||
|
||||
private emitVisibleCards(): void {
|
||||
const visible = this.items().filter((card) =>
|
||||
this.visibleCardIds.has(card.id)
|
||||
);
|
||||
const signature = visible.map((card) => card.id).join(' | ||||