mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(dashboard): address PR review feedback
This commit is contained in:
1 parent
7083bd2af4
commit
2ecb5c28f4
30 files changed
+340
-104
No files matched your search
@@ -110,7 +110,8 @@
|
||||
<span
|
||||
class="rail__channel-progress"
|
||||
[class.rail__channel-progress--idle]="
|
||||
card.nowPlayingProgress == null
|
||||
card.nowPlayingProgress === null ||
|
||||
card.nowPlayingProgress === undefined
|
||||
"
|
||||
>
|
||||
<i
|
||||
@@ -173,7 +174,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (card.watchProgress != null) {
|
||||
@if (card.watchProgress !== null && card.watchProgress !== undefined) {
|
||||
<span
|
||||
class="rail__art-progress"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface DashboardRailCard {
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
actions?: DashboardRailAction[];
|
||||
epgLookupKey?: string;
|
||||
|
||||
/**
|
||||
* Optional EPG enrichment shown by the 'channel' rail layout. Populated
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (item.watchProgress != null) {
|
||||
@if (item.watchProgress !== null && item.watchProgress !== undefined) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
|
||||
+82
@@ -1,12 +1,17 @@
|
||||
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildLiveEpgLookupKeys,
|
||||
buildPlaybackPositionReloadKey,
|
||||
buildDashboardSourceActions,
|
||||
calcEpgProgress,
|
||||
formatEpgTimeRange,
|
||||
formatRemainingLabel,
|
||||
getLiveEpgProgramForCard,
|
||||
liveRailTitleKeyForSource,
|
||||
playbackProgressPercent,
|
||||
resolveDashboardHeroArtwork,
|
||||
} from './workspace-dashboard-rails.component';
|
||||
import type { DashboardRailCard } from './dashboard-rail.component';
|
||||
|
||||
describe('buildDashboardSourceActions', () => {
|
||||
const basePlaylist = {
|
||||
@@ -201,6 +206,83 @@ describe('EPG enrichment helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live rail helpers', () => {
|
||||
const channelCard = (
|
||||
overrides: Partial<DashboardRailCard> = {}
|
||||
): DashboardRailCard => ({
|
||||
id: 'card-1',
|
||||
title: 'Display Channel',
|
||||
subtitle: 'M3U · Live',
|
||||
icon: 'live_tv',
|
||||
contentType: 'live',
|
||||
link: ['/workspace', 'playlists', 'm3u-1'],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('uses explicit EPG lookup keys before falling back to card titles', () => {
|
||||
expect(
|
||||
buildLiveEpgLookupKeys([
|
||||
channelCard({
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
}),
|
||||
channelCard({
|
||||
id: 'card-2',
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
}),
|
||||
channelCard({
|
||||
id: 'card-3',
|
||||
title: 'Fallback News',
|
||||
}),
|
||||
])
|
||||
).toEqual(['ard.de', 'Fallback News']);
|
||||
});
|
||||
|
||||
it('reads EPG programs by explicit lookup key instead of display title', () => {
|
||||
const program = { title: 'Tagesschau' } as EpgProgram;
|
||||
const wrongProgram = { title: 'Wrong channel' } as EpgProgram;
|
||||
const card = channelCard({
|
||||
title: 'Das Erste HD',
|
||||
epgLookupKey: 'ard.de',
|
||||
});
|
||||
|
||||
expect(
|
||||
getLiveEpgProgramForCard(
|
||||
card,
|
||||
new Map<string, EpgProgram | null>([
|
||||
['Das Erste HD', wrongProgram],
|
||||
['ard.de', program],
|
||||
])
|
||||
)
|
||||
).toBe(program);
|
||||
});
|
||||
|
||||
it('uses honest, semantically named title keys for favorite and recent live rails', () => {
|
||||
expect(liveRailTitleKeyForSource('favorites')).toBe(
|
||||
'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
);
|
||||
expect(liveRailTitleKeyForSource('recent')).toBe(
|
||||
'WORKSPACE.DASHBOARD.LIVE_RECENT'
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a stable playback-position reload key from VOD and series items only', () => {
|
||||
const first = buildPlaybackPositionReloadKey([
|
||||
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
|
||||
{ playlist_id: 'live', xtream_id: 'stream-url', type: 'live' },
|
||||
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
|
||||
]);
|
||||
const second = buildPlaybackPositionReloadKey([
|
||||
{ playlist_id: 'a', xtream_id: 10, type: 'movie' },
|
||||
{ playlist_id: 'b', xtream_id: 20, type: 'series' },
|
||||
]);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toBe('a::movie::10|b::series::20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('playback-position helpers', () => {
|
||||
describe('playbackProgressPercent', () => {
|
||||
it('returns null when there is no position or no duration', () => {
|
||||
|
||||
+68
-30
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { interval, of, startWith, switchMap } from 'rxjs';
|
||||
@@ -258,6 +259,56 @@ export function calcEpgProgress(
|
||||
return Math.max(0, Math.min(100, ratio * 100));
|
||||
}
|
||||
|
||||
function liveEpgLookupKeyForCard(card: DashboardRailCard): string {
|
||||
return card.epgLookupKey?.trim() || card.title.trim();
|
||||
}
|
||||
|
||||
export function buildLiveEpgLookupKeys(
|
||||
cards: readonly DashboardRailCard[]
|
||||
): string[] {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const card of cards) {
|
||||
const key = liveEpgLookupKeyForCard(card);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function getLiveEpgProgramForCard(
|
||||
card: DashboardRailCard,
|
||||
epgMap: ReadonlyMap<string, EpgProgram | null>
|
||||
): EpgProgram | null {
|
||||
const key = liveEpgLookupKeyForCard(card);
|
||||
const program = epgMap.get(key);
|
||||
if (program) {
|
||||
return program;
|
||||
}
|
||||
|
||||
const titleKey = card.title.trim();
|
||||
return key !== titleKey ? (epgMap.get(titleKey) ?? null) : null;
|
||||
}
|
||||
|
||||
export function liveRailTitleKeyForSource(
|
||||
source: 'favorites' | 'recent'
|
||||
): string {
|
||||
return source === 'favorites'
|
||||
? 'WORKSPACE.DASHBOARD.LIVE_FAVORITES'
|
||||
: 'WORKSPACE.DASHBOARD.LIVE_RECENT';
|
||||
}
|
||||
|
||||
export function buildPlaybackPositionReloadKey(
|
||||
items: readonly Pick<GlobalRecentItem, 'playlist_id' | 'type' | 'xtream_id'>[]
|
||||
): string {
|
||||
return items
|
||||
.filter((item) => item.type === 'movie' || item.type === 'series')
|
||||
.map((item) => `${item.playlist_id}::${item.type}::${item.xtream_id}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
// ── Playback-position helpers (used by both hero + Continue Watching cards)
|
||||
// — kept as plain functions so they're easy to unit-test without mounting the
|
||||
// component or the data service.
|
||||
@@ -431,9 +482,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
// we're rendering favorites, "Continue with live TV" when we're falling
|
||||
// back to recently-watched. Always honest about what the user is seeing.
|
||||
readonly liveRailTitleKey = computed(() =>
|
||||
this.liveRailSource() === 'favorites'
|
||||
? 'WORKSPACE.DASHBOARD.LIVE_RECENT'
|
||||
: 'WORKSPACE.DASHBOARD.LIVE_CONTINUE'
|
||||
liveRailTitleKeyForSource(this.liveRailSource())
|
||||
);
|
||||
|
||||
readonly liveRailTotalCount = computed(() =>
|
||||
@@ -442,22 +491,17 @@ export class WorkspaceDashboardRailsComponent {
|
||||
: this.data.globalRecentLiveItems().length
|
||||
);
|
||||
|
||||
// Best-effort EPG lookup keyed by the channel's display name. This works
|
||||
// for M3U sources whose XMLTV channel ids resolve through the existing
|
||||
// `resolveChannelEpgLookupKey()` (tvg-id → tvg-name → name) chain.
|
||||
// Best-effort EPG lookup keyed by the app-wide M3U XMLTV chain
|
||||
// (tvg-id -> tvg-name -> name), with the card title as a final fallback.
|
||||
// Xtream/Stalker live items often have no XMLTV side-channel and will
|
||||
// simply return null — the card renders without the program row.
|
||||
private readonly liveChannelLookupKeys = computed(() => {
|
||||
const seen = new Set<string>();
|
||||
const keys: string[] = [];
|
||||
for (const card of this.liveOnFavoritesCards()) {
|
||||
const key = card.title.trim();
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
private readonly liveChannelLookupKeys = computed(() =>
|
||||
buildLiveEpgLookupKeys(this.liveOnFavoritesCards())
|
||||
);
|
||||
|
||||
private readonly playbackPositionReloadKey = computed(() =>
|
||||
buildPlaybackPositionReloadKey(this.data.globalRecentVodItems())
|
||||
);
|
||||
|
||||
// Re-fetch on rail change AND on a 30s heartbeat so the progress bar
|
||||
// catches the boundary between programs without a full page revisit.
|
||||
@@ -486,7 +530,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
// 30s ticks even if the program identity is unchanged.
|
||||
const now = Date.now();
|
||||
return this.liveOnFavoritesCards().map((card) => {
|
||||
const program = epgMap.get(card.title.trim()) ?? null;
|
||||
const program = getLiveEpgProgramForCard(card, epgMap);
|
||||
if (!program) {
|
||||
return card;
|
||||
}
|
||||
@@ -552,19 +596,11 @@ export class WorkspaceDashboardRailsComponent {
|
||||
void this.data.reloadXtreamRecentlyAddedItems(RAIL_ITEM_LIMIT);
|
||||
});
|
||||
|
||||
// Reload playback positions whenever the set of VOD/series recent
|
||||
// items changes (new playlists with watch history, removed playlist,
|
||||
// newly tracked content). Using just the count + the playlist ids as
|
||||
// the dependency keeps unrelated tick churn out of the IPC path.
|
||||
// Reload playback positions when the VOD/series recent set changes.
|
||||
// The primitive key keeps live-only recent churn out of the IPC path.
|
||||
effect(() => {
|
||||
const playlistKeys = new Set<string>();
|
||||
for (const item of this.data.globalRecentVodItems()) {
|
||||
playlistKeys.add(item.playlist_id);
|
||||
}
|
||||
void this.data.reloadPlaybackPositions();
|
||||
// Sort so the effect dep is stable across re-renders for the
|
||||
// same set; otherwise the computed identity flips every tick.
|
||||
void [...playlistKeys].sort().join('|');
|
||||
this.playbackPositionReloadKey();
|
||||
untracked(() => void this.data.reloadPlaybackPositions());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -634,6 +670,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
link: this.data.getRecentItemLink(item),
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
watchProgress,
|
||||
@@ -649,6 +686,7 @@ export class WorkspaceDashboardRailsComponent {
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
contentType: item.type,
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
link: this.data.getGlobalFavoriteLink(item),
|
||||
state: this.data.getGlobalFavoriteNavigationState(item),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user