fix(dashboard): address rail review feedback

This commit is contained in:
4gray committed 2026-06-14 11:27:17 +02:00
1 parent 101d9ba162
commit 97678d4a66
3 files changed
+167 -17

No files matched your search

@@ -240,13 +240,7 @@
/>
}
@if (
dashboardRails().continueWatching &&
dashboardRails().recentlyWatchedLive &&
continueWatchingCards().length === 0 &&
recentLiveCards().length === 0 &&
data.globalRecentLoading()
) {
@if (showRecentContentSkeleton()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
@@ -1,7 +1,9 @@
import type { EpgProgram, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { DEFAULT_DASHBOARD_RAILS_SETTINGS } from '@iptvnator/shared/interfaces';
import {
buildDashboardCollectionViewState,
buildDashboardLiveEpgDetails,
buildLiveEpgCardsForEnabledRails,
buildLiveEpgLookupKeys,
buildPlaybackPositionReloadKey,
buildDashboardSourceActions,
@@ -14,6 +16,7 @@ import {
playbackProgressPercent,
resolveDashboardHeroArtwork,
buildDashboardRailSeeAllState,
shouldShowRecentContentSkeleton,
} from './workspace-dashboard-rails.component';
import type { DashboardRailCard } from './dashboard-rail.component';
import { COLLECTION_VIEW_STATE_KEY } from '@iptvnator/portal/shared/util';
@@ -354,6 +357,108 @@ describe('Live rail helpers', () => {
expect(first).toBe(second);
expect(first).toBe('a::movie::10|b::series::20');
});
it('omits hero cards from live EPG lookup sources when the hero rail is disabled', () => {
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
const favorite = channelCard({
id: 'favorite',
epgLookupKey: 'favorite',
});
const recent = channelCard({ id: 'recent', epgLookupKey: 'recent' });
expect(
buildLiveEpgCardsForEnabledRails(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
hero: false,
},
hero,
[favorite],
[recent]
).map((card) => card.id)
).toEqual(['favorite', 'recent']);
});
it('omits disabled live rails from live EPG lookup sources', () => {
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
const favorite = channelCard({
id: 'favorite',
epgLookupKey: 'favorite',
});
const recent = channelCard({ id: 'recent', epgLookupKey: 'recent' });
expect(
buildLiveEpgCardsForEnabledRails(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
liveFavorites: false,
recentlyWatchedLive: false,
},
hero,
[favorite],
[recent]
).map((card) => card.id)
).toEqual(['hero']);
});
});
describe('recent content skeleton helper', () => {
it('shows the loading skeleton for Continue Watching when recently watched live is disabled', () => {
expect(
shouldShowRecentContentSkeleton(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
recentlyWatchedLive: false,
},
{
continueWatchingCount: 0,
recentLiveCount: 0,
globalRecentLoading: true,
}
)
).toBe(true);
});
it('shows the loading skeleton for recently watched live when Continue Watching is disabled', () => {
expect(
shouldShowRecentContentSkeleton(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
continueWatching: false,
},
{
continueWatchingCount: 0,
recentLiveCount: 0,
globalRecentLoading: true,
}
)
).toBe(true);
});
it('hides the loading skeleton when no enabled recent rail is waiting for data', () => {
expect(
shouldShowRecentContentSkeleton(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
continueWatching: false,
recentlyWatchedLive: false,
},
{
continueWatchingCount: 0,
recentLiveCount: 0,
globalRecentLoading: true,
}
)
).toBe(false);
expect(
shouldShowRecentContentSkeleton(DEFAULT_DASHBOARD_RAILS_SETTINGS, {
continueWatchingCount: 1,
recentLiveCount: 1,
globalRecentLoading: true,
})
).toBe(false);
});
});
describe('playback-position helpers', () => {
@@ -11,7 +11,8 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { interval, of, startWith, switchMap } from 'rxjs';
import { EpgService } from '@iptvnator/epg/data-access';
import {
EpgProgram,
type DashboardRailsSettings,
type EpgProgram,
normalizeDashboardRailsSettings,
} from '@iptvnator/shared/interfaces';
import { MatButtonModule } from '@angular/material/button';
@@ -323,6 +324,24 @@ export function buildLiveEpgLookupKeys(
return keys;
}
type DashboardLiveEpgRailSettings = Pick<
DashboardRailsSettings,
'hero' | 'liveFavorites' | 'recentlyWatchedLive'
>;
export function buildLiveEpgCardsForEnabledRails(
rails: DashboardLiveEpgRailSettings,
heroLiveCard: DashboardRailCard | null,
liveFavoriteCards: readonly DashboardRailCard[],
recentLiveCards: readonly DashboardRailCard[]
): DashboardRailCard[] {
return [
...(rails.hero && heroLiveCard ? [heroLiveCard] : []),
...(rails.liveFavorites ? liveFavoriteCards : []),
...(rails.recentlyWatchedLive ? recentLiveCards : []),
];
}
export function getLiveEpgProgramForCard(
card: DashboardRailCard,
epgMap: ReadonlyMap<string, EpgProgram | null>
@@ -393,6 +412,31 @@ export function isContinueWatchingRecentItem(
return item.type === 'movie' || item.type === 'series';
}
type DashboardRecentRailSettings = Pick<
DashboardRailsSettings,
'continueWatching' | 'recentlyWatchedLive'
>;
export interface DashboardRecentContentSkeletonInput {
readonly continueWatchingCount: number;
readonly globalRecentLoading: boolean;
readonly recentLiveCount: number;
}
export function shouldShowRecentContentSkeleton(
rails: DashboardRecentRailSettings,
input: DashboardRecentContentSkeletonInput
): boolean {
if (!input.globalRecentLoading) {
return false;
}
return (
(rails.continueWatching && input.continueWatchingCount === 0) ||
(rails.recentlyWatchedLive && input.recentLiveCount === 0)
);
}
// ── 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.
@@ -592,21 +636,28 @@ export class WorkspaceDashboardRailsComponent {
.map((item) => this.toRecentCard(item))
);
readonly showRecentContentSkeleton = computed(() =>
shouldShowRecentContentSkeleton(this.dashboardRails(), {
continueWatchingCount: this.continueWatchingCards().length,
globalRecentLoading: this.data.globalRecentLoading(),
recentLiveCount: this.recentLiveCards().length,
})
);
// 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 heroLiveCard = this.heroLiveCard();
return buildLiveEpgLookupKeys([
...(heroLiveCard ? [heroLiveCard] : []),
...(this.dashboardRails().liveFavorites
? this.liveFavoriteCards()
: []),
...(this.dashboardRails().recentlyWatchedLive
? this.recentLiveCards()
: []),
]);
return buildLiveEpgLookupKeys(
buildLiveEpgCardsForEnabledRails(
this.dashboardRails(),
heroLiveCard,
this.liveFavoriteCards(),
this.recentLiveCards()
)
);
});
private readonly playbackPositionReloadKey = computed(() =>