fix(ui): skeletons that match the M3U header and dashboard rails (#1887)

* fix(ui): skeletons that match the M3U header and dashboard rails

Loading skeletons now take their boxes from the code that draws the
content, so the content lands where the skeleton was.

- M3U channel list: the loading state's headers, divider, groups rail
  and first row share `styles/_channel-list-layout.scss` with the All
  channels, context and groups views. The All channels list moved down
  by 19px when a playlist loaded; the group header was 79px against the
  real 69px (the panel header's 52px minimum plus the padding that
  `.sidebar .sidebar-header` gives it, now one mixin both include).
  Group rows start at the list's top-left like the virtual-scroll rows.
- Dashboard rails: `lib-dashboard-rail-skeleton` replaces six copies of
  the skeleton markup and takes the rail's layout and aspect ratio
  (channel cards for live favorites, 16:9 for sources, posters with a
  title and a meta line otherwise). Its header, track, cards and text
  line boxes come from `_dashboard-rail-geometry.scss`, which the rail
  itself now uses. The skeleton was 328px against the 337.8px poster
  rail and a 2:3 poster in front of the 83.5px channel cards.
- One shimmer in `libs/ui/styles/_skeleton-shimmer.scss`, mixed from
  `--app-on-surface`: the rail sweep between the widget surfaces was
  1.04:1 on the light theme. The dashboard hero and the detail
  skeletons (through a forward) use it too.
- Electron E2E `skeleton-geometry.e2e.ts` holds the IPC channel behind
  each loading state, compares skeleton and content boxes to 0.5px at
  1280x700 and caps the dashboard layout-shift score at 0.01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashboard): keep rail heights when a skeleton stands for a label-less or live rail

Review follow-up (Greptile, local):
- Rails that replace a skeleton (Continue Watching, sources, Xtream
  recently added, TMDB) keep an empty meta line under cards without
  labels (`reserveMetaLine`), as the skeleton always draws one; a
  Continue Watching rail of movies with no known duration came in
  shorter.
- The recent-content skeleton follows the recent rail it waits for:
  posters while Continue Watching is still loading with no cards,
  channel cards otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 08:16:38 +02:00
1 parent ea7efdbd9b
commit 7d7d691ce4
34 files changed
+1747 -465

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
---
type: fix
area: ui
---
Loading placeholders now take the size of what replaces them: an M3U
playlist's channel list no longer jumps down when it finishes loading, and
the dashboard's rail placeholders match the cards that arrive, so the rails
below stay put. Their shimmer is also easier to see in the light theme.
@@ -0,0 +1,108 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { LaunchedElectronApp } from './electron-test-fixtures';
import {
electronMainPath,
expect,
launchElectronApp,
} from './electron-test-fixtures';
interface ChannelHold {
/** Invocations parked behind the hold, waiting for `release`. */
waiting: number;
gate: Promise<void>;
release: () => void;
}
type HoldGlobal = typeof globalThis & {
__ipcChannelHolds?: Record<string, ChannelHold | undefined>;
__ipcChannelHoldInstalled?: string[];
};
// Serialized into the test bootstrap; all runtime dependencies are arguments.
function installChannelHolds(
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
channels: readonly string[]
): void {
const register = ipcMain.handle;
const scope = globalThis as HoldGlobal;
scope.__ipcChannelHolds = {};
scope.__ipcChannelHoldInstalled = [];
// Wraps the listener at registration through the public API; no
// Electron-private handler registry is read or mutated.
ipcMain.handle = function (channel, listener) {
if (!channels.includes(channel)) {
return register.call(this, channel, listener);
}
scope.__ipcChannelHoldInstalled?.push(channel);
return register.call(this, channel, async (...args) => {
const hold = scope.__ipcChannelHolds?.[channel];
if (hold) {
hold.waiting += 1;
await hold.gate;
}
return listener(...args);
});
};
}
/**
* Launches the app with `channels` wrapped so a test can park their
* invocations (`holdIpcChannel`) and keep a loading state on screen for as
* long as it measures it.
*/
export function launchElectronWithChannelHolds(
dataDir: string,
channels: readonly string[]
): Promise<LaunchedElectronApp> {
const entryPoint = join(dataDir, 'ipc-channel-holds.cjs');
writeFileSync(
entryPoint,
[
`(${installChannelHolds.toString()})(require('electron'), ${JSON.stringify(channels)});`,
`require(${JSON.stringify(electronMainPath)});`,
].join('\n')
);
return launchElectronApp(dataDir, { entryPoint });
}
/** Parks every later invocation of `channel` until the returned release. */
export async function holdIpcChannel(
app: LaunchedElectronApp,
channel: string
) {
await app.electronApp.evaluate((_electron, name) => {
const scope = globalThis as HoldGlobal;
if (!scope.__ipcChannelHoldInstalled?.includes(name)) {
throw new Error(`IPC channel ${name} has no hold installed`);
}
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const holds = (scope.__ipcChannelHolds ??= {});
holds[name] = { waiting: 0, gate, release };
}, channel);
return {
waitUntilHeld: () =>
expect
.poll(() =>
app.electronApp.evaluate(
(_electron, name) =>
(globalThis as HoldGlobal).__ipcChannelHolds?.[name]
?.waiting ?? 0,
channel
)
)
.toBeGreaterThan(0),
release: () =>
app.electronApp.evaluate((_electron, name) => {
const holds = (globalThis as HoldGlobal).__ipcChannelHolds;
const hold = holds?.[name];
if (!hold) throw new Error(`IPC channel ${name} is not held`);
delete holds[name];
hold.release();
}, channel),
};
}
@@ -0,0 +1,159 @@
import type { Page } from '@playwright/test';
import { expect } from './electron-test-fixtures';
export interface Box {
top: number;
left: number;
width: number;
height: number;
bottom: number;
}
export interface LayoutShiftReport {
score: number;
shifts: {
value: number;
sources: { node: string; deltaY: number; deltaHeight: number }[];
}[];
}
/** Header and row positions must match to this many pixels. */
export const GEOMETRY_TOLERANCE_PX = 0.5;
type ShiftWindow = typeof window & { __skeletonShifts?: LayoutShiftReport };
/** Border boxes of the first match of each selector, in viewport pixels. */
export async function measureBoxes<Key extends string>(
page: Page,
selectors: Record<Key, string>
): Promise<Record<Key, Box>> {
const boxes = await page.evaluate(
(map) => {
const result: Record<string, Box | null> = {};
for (const [key, selector] of Object.entries(map)) {
const rect = document
.querySelector(selector)
?.getBoundingClientRect();
result[key] = rect
? {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
bottom: rect.bottom,
}
: null;
}
return result;
},
selectors as Record<string, string>
);
for (const [key, box] of Object.entries(boxes)) {
expect(
box,
`${key} (${selectors[key as Key]}) is rendered`
).not.toBeNull();
}
return boxes as Record<Key, Box>;
}
/**
* Starts summing `layout-shift` entries from now on, with the nodes each
* shift moved, until `readLayoutShifts`.
*/
export async function observeLayoutShifts(page: Page): Promise<void> {
await page.evaluate(() => {
const report: LayoutShiftReport = { score: 0, shifts: [] };
(window as ShiftWindow).__skeletonShifts = report;
const nodeLabel = (node: Node | null): string => {
if (!(node instanceof Element)) return String(node?.nodeName);
const testId = node.getAttribute('data-test-id');
return [
node.tagName.toLowerCase(),
...[...node.classList].map((name) => `.${name}`),
testId ? `[data-test-id="${testId}"]` : '',
].join('');
};
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
sources?: {
node: Node | null;
previousRect: DOMRectReadOnly;
currentRect: DOMRectReadOnly;
}[];
})[]) {
report.score += entry.value;
report.shifts.push({
value: entry.value,
sources: (entry.sources ?? []).map((source) => ({
node: nodeLabel(source.node),
deltaY: source.currentRect.y - source.previousRect.y,
deltaHeight:
source.currentRect.height -
source.previousRect.height,
})),
});
}
}).observe({ type: 'layout-shift', buffered: false });
});
}
export async function readLayoutShifts(page: Page): Promise<LayoutShiftReport> {
const report = await page.evaluate(
() => (window as ShiftWindow).__skeletonShifts
);
if (!report) throw new Error('observeLayoutShifts was not called');
return report;
}
/**
* Resolves once the workspace content has not mutated for 300 ms (at most
* 3 s), then after one more frame, so pending layout shifts are delivered.
*/
export async function waitForContentQuiet(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
const target =
document.querySelector('main.workspace-content') ??
document.documentElement;
let quiet = 0;
const finish = () => {
observer.disconnect();
clearTimeout(cap);
clearTimeout(quiet);
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
);
};
const observer = new MutationObserver(() => {
clearTimeout(quiet);
quiet = window.setTimeout(finish, 300);
});
observer.observe(target, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
quiet = window.setTimeout(finish, 300);
const cap = window.setTimeout(finish, 3000);
})
);
}
/** `actual` within the geometry tolerance of `expected`, or a message
* naming both boxes. */
export function expectSameEdge(
label: string,
actual: number,
expected: number,
evidence: unknown
): void {
expect(
Math.abs(actual - expected),
`${label}: ${actual.toFixed(2)} vs skeleton ${expected.toFixed(2)}\n${JSON.stringify(evidence, null, 1)}`
).toBeLessThanOrEqual(GEOMETRY_TOLERANCE_PX);
}
@@ -0,0 +1,366 @@
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
m3uFixturePath,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
setSwitch,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { toggleFavoriteForChannel } from './dashboard-e2e-flows';
import {
holdIpcChannel,
launchElectronWithChannelHolds,
} from './ipc-channel-hold';
import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
import {
expectSameEdge,
measureBoxes,
observeLayoutShifts,
readLayoutShifts,
waitForContentQuiet,
} from './skeleton-geometry.e2e-support';
// ---------------------------------------------------------------------------
// A skeleton must hold the place of what it precedes: the same header
// heights, the same first-row position and the same rail heights, so content
// arrives without moving anything. Each loading state is kept on screen by
// holding the IPC channel that answers it, measured, then released and
// compared with what replaced it. The M3U skeleton swaps the whole channel
// list subtree, which the browser does not count as a layout shift at all,
// so its positions are compared directly; on the dashboard a rail that
// changes height moves the rails below it, which the layout-shift score
// catches as well.
// ---------------------------------------------------------------------------
const viewport = { width: 1280, height: 700 };
const playlistChannel = 'DB_GET_APP_PLAYLIST';
const favoritesChannel = 'DB_GET_ALL_GLOBAL_FAVORITES';
const recentlyAddedChannel = 'DB_GET_GLOBAL_RECENTLY_ADDED';
const maxDashboardShiftScore = 0.01;
const loadingState = 'app-channel-list-loading-state';
const firstSkeletonRow = `${loadingState} app-channel-list-item-skeleton`;
const firstChannelRow = 'app-channel-list-item';
async function openM3uFromDashboard(page: Page): Promise<void> {
await page
.getByTestId('dashboard-recent-sources-rail-card-link')
.first()
.click();
}
test.describe('Skeleton geometry', () => {
test('M3U channel list skeletons put the headers and the first row where the list lands', async ({
dataDir,
}) => {
test.setTimeout(120_000);
const app = await launchElectronWithChannelHolds(dataDir, [
playlistChannel,
]);
const page = app.mainWindow;
try {
await page.setViewportSize(viewport);
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await goToDashboard(page);
// All channels, the default view.
let hold = await holdIpcChannel(app, playlistChannel);
await openM3uFromDashboard(page);
await hold.waitUntilHeld();
await expect(
page.locator(`${loadingState} .channels-loading-header`)
).toBeVisible();
const allSkeleton = await measureBoxes(page, {
header: `${loadingState} .channels-loading-header`,
divider: `${loadingState} .channels-loading-divider`,
row: firstSkeletonRow,
});
await observeLayoutShifts(page);
await hold.release();
await expect(page.locator(firstChannelRow).first()).toBeVisible();
await waitForContentQuiet(page);
const all = await measureBoxes(page, {
header: '.all-channels-header',
divider: '.all-channels-divider',
row: firstChannelRow,
});
const allEvidence = { skeleton: allSkeleton, loaded: all };
expectSameEdge(
'All channels header top',
all.header.top,
allSkeleton.header.top,
allEvidence
);
expectSameEdge(
'All channels header height',
all.header.height,
allSkeleton.header.height,
allEvidence
);
expectSameEdge(
'All channels divider',
all.divider.bottom,
allSkeleton.divider.bottom,
allEvidence
);
expectSameEdge(
'All channels first row',
all.row.top,
allSkeleton.row.top,
allEvidence
);
expect((await readLayoutShifts(page)).score).toBe(0);
// Groups: entering the section again from the dashboard reloads
// the playlist, so the skeleton shows once more.
await openWorkspaceSection(page, 'Groups');
await expect(page.locator('.groups-content-header')).toBeVisible();
await goToDashboard(page);
hold = await holdIpcChannel(app, playlistChannel);
await page.goBack();
await hold.waitUntilHeld();
await expect(
page.locator(`${loadingState} .groups-loading-content__header`)
).toBeVisible();
const groupsSkeleton = await measureBoxes(page, {
navHeader: `${loadingState} .groups-loading-nav__header`,
// The second item: the selected first one is lifted by 1px.
navItem: `${loadingState} .groups-loading-nav__item:nth-child(2)`,
header: `${loadingState} .groups-loading-content__header`,
row: firstSkeletonRow,
});
await observeLayoutShifts(page);
await hold.release();
await expect(page.locator(firstChannelRow).first()).toBeVisible();
await waitForContentQuiet(page);
const groups = await measureBoxes(page, {
navHeader: '.groups-nav-header',
navItem: '.group-nav-item:nth-child(2)',
header: '.groups-content-header',
row: firstChannelRow,
});
const groupsEvidence = { skeleton: groupsSkeleton, loaded: groups };
expectSameEdge(
'Groups rail header height',
groups.navHeader.height,
groupsSkeleton.navHeader.height,
groupsEvidence
);
expectSameEdge(
'Groups rail second item',
groups.navItem.top,
groupsSkeleton.navItem.top,
groupsEvidence
);
expectSameEdge(
'Groups rail item height',
groups.navItem.height,
groupsSkeleton.navItem.height,
groupsEvidence
);
expectSameEdge(
'Group header top',
groups.header.top,
groupsSkeleton.header.top,
groupsEvidence
);
expectSameEdge(
'Group header height',
groups.header.height,
groupsSkeleton.header.height,
groupsEvidence
);
expectSameEdge(
'Group first row',
groups.row.top,
groupsSkeleton.row.top,
groupsEvidence
);
expect((await readLayoutShifts(page)).score).toBe(0);
} finally {
await closeElectronApp(app);
}
});
test('dashboard rail skeletons have the height of the rails that replace them', async ({
dataDir,
request,
}) => {
test.setTimeout(120_000);
await resetMockServers(request, ['xtream']);
const live = await fetchXtreamLiveFixture(request, {
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
const app = await launchElectronWithChannelHolds(dataDir, [
favoritesChannel,
recentlyAddedChannel,
]);
const page = app.mainWindow;
try {
await page.setViewportSize(viewport);
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, live.categoryName);
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
// A skeleton never shows above a rail that already has cards, and
// the sources rail is ready first: without it, the live
// favorites skeleton (channel cards) sits right above the
// recently-added one (posters), both in the viewport.
await openSettings(page);
await openSettingsSection(page, 'dashboard');
await setSwitch(
page
.getByTestId('toggle-dashboard-rail-recent-sources')
.locator('[role="switch"]'),
false
);
await saveSettings(page);
await goToDashboard(page);
await expect(
page.getByTestId('dashboard-xtream-recently-added-rail')
).toBeVisible({ timeout: 20_000 });
// Recently added is asked for once the favorites have loaded, so
// it is held only after the favorites are.
const favorites = await holdIpcChannel(app, favoritesChannel);
await reloadFromMainProcess(app);
await expectRendererReloadedOnRoute(
page,
/\/workspace\/dashboard$/
);
await page.setViewportSize(viewport);
await favorites.waitUntilHeld();
const recentlyAdded = await holdIpcChannel(
app,
recentlyAddedChannel
);
const liveSkeletonRail =
'[data-test-id="dashboard-live-favorites-rail-skeleton"]';
const addedSkeletonRail =
'[data-test-id="dashboard-xtream-recently-added-rail-skeleton"]';
await expect(page.locator(liveSkeletonRail)).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(addedSkeletonRail)).toBeVisible();
await waitForContentQuiet(page);
const skeleton = await measureBoxes(page, {
liveRail: liveSkeletonRail,
liveHeader: `${liveSkeletonRail} .rail-skeleton__header`,
liveCard: `${liveSkeletonRail} .rail-skeleton__card`,
addedRail: addedSkeletonRail,
addedHeader: `${addedSkeletonRail} .rail-skeleton__header`,
addedCard: `${addedSkeletonRail} .rail-skeleton__card`,
});
await observeLayoutShifts(page);
await favorites.release();
const liveRail = '[data-test-id="dashboard-live-favorites-rail"]';
await expect(page.locator(liveRail)).toBeVisible();
await recentlyAdded.waitUntilHeld();
await waitForContentQuiet(page);
const afterLive = await measureBoxes(page, {
liveRail,
liveHeader: `${liveRail} .rail__header`,
liveCard: `${liveRail} .rail__card`,
addedRail: addedSkeletonRail,
});
await recentlyAdded.release();
const addedRail =
'[data-test-id="dashboard-xtream-recently-added-rail"]';
await expect(page.locator(addedRail)).toBeVisible();
await waitForContentQuiet(page);
const loaded = await measureBoxes(page, {
addedRail,
addedHeader: `${addedRail} .rail__header`,
addedCard: `${addedRail} .rail__card`,
});
const shifts = await readLayoutShifts(page);
const evidence = { skeleton, afterLive, loaded, shifts };
expectSameEdge(
'live favorites rail height',
afterLive.liveRail.height,
skeleton.liveRail.height,
evidence
);
expectSameEdge(
'live favorites header height',
afterLive.liveHeader.height,
skeleton.liveHeader.height,
evidence
);
expectSameEdge(
'channel card width',
afterLive.liveCard.width,
skeleton.liveCard.width,
evidence
);
expectSameEdge(
'channel card height',
afterLive.liveCard.height,
skeleton.liveCard.height,
evidence
);
// Relative to the rail above it: the hero may still grow past its
// floor when its slides arrive, which moves both rails alike.
expectSameEdge(
'recently added skeleton stays below the live rail',
afterLive.addedRail.top - afterLive.liveRail.bottom,
skeleton.addedRail.top - skeleton.liveRail.bottom,
evidence
);
expectSameEdge(
'recently added rail height',
loaded.addedRail.height,
skeleton.addedRail.height,
evidence
);
expectSameEdge(
'recently added header height',
loaded.addedHeader.height,
skeleton.addedHeader.height,
evidence
);
expectSameEdge(
'poster card width',
loaded.addedCard.width,
skeleton.addedCard.width,
evidence
);
expectSameEdge(
'poster card height',
loaded.addedCard.height,
skeleton.addedCard.height,
evidence
);
expect(
shifts.score,
JSON.stringify(evidence, null, 1)
).toBeLessThanOrEqual(maxDashboardShiftScore);
} finally {
await closeElectronApp(app);
}
});
});
+30 -2
View File
@@ -745,6 +745,30 @@ mirrors the row/card geometry it precedes (see Channel List Item and Cover
Grids). The unified Favorites/Recent page gates this on `isLoading`, set only
while its item list is empty.
A skeleton takes its boxes from the code that draws the content, not from
copied values: the same Sass mixins for every box that sets a height (header
padding and minimum height, row and card size, track bleed, gaps), and for
text a line box of the real font size and line height with a shorter shimmer
bar inside it. Copied values drift: the M3U channel list moved down by 19 px
when its playlist loaded, and the dashboard rail skeletons were 2:3 posters
in front of 84 px channel cards.
- The channel list views and `app-channel-list-loading-state` share
`libs/ui/components/src/lib/styles/_channel-list-layout.scss`. The loading
state takes the view and hides its headers where the loaded view does
(`showHeader`, compact lists).
- The dashboard rails and `lib-dashboard-rail-skeleton` share
`libs/workspace/dashboard/feature/src/lib/rails/_dashboard-rail-geometry.scss`.
A skeleton takes the `layout` and `aspectRatio` of the rail it precedes:
channel cards for live favorites, 16:9 cards for sources, posters with a
title and a meta line otherwise.
- `apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts` holds the IPC
channel that answers each loading state, compares the skeleton's header,
first-row and rail boxes with the content's to 0.5 px at 1280×700, and keeps
the dashboard's layout-shift score from skeleton to content at or under
0.01. A whole subtree swapped for new nodes does not count as a layout
shift, so the positions are compared directly.
### Pages of independently loading blocks: delayed skeletons
The dashboard renders each rail as soon as its own data arrives, and several
@@ -770,9 +794,13 @@ page down. For the same reason it stays until every source that can fill it
has loaded, not only the first one. Use the same rules for any page that
stacks independently loading blocks.
Skeleton blocks use one shimmer, `libs/ui/styles/_skeleton-shimmer.scss`: a
fill mixed from `--app-on-surface` (8%, with a 16% highlight), so it reads on
both themes. The dashboard rails used to sweep between `--app-widget-bg` and
`--app-widget-header-bg`, 1.04:1 apart on the light theme.
The movie and series detail hero has an immediate skeleton too, built from
the shared shimmer (`libs/ui/components/src/lib/styles/_skeleton-shimmer.scss`)
at the loaded hero's geometry: stage height, bottom-aligned eyebrow, title,
the shared shimmer at the loaded hero's geometry: stage height, bottom-aligned eyebrow, title,
one row of chips, two description lines, the action row and two credit lines.
The Xtream series page adds an episodes section skeleton under it, and the
Stalker series hero holds the Play button's place
+3
View File
@@ -605,6 +605,9 @@ channel-list-container/
`setFavorites` action: doing so reads and rewrites the complete M3U payload
again just to store favorites that already came from SQLite.
- `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true.
Its headers and first row share the views' boxes
(`libs/ui/components/src/lib/styles/_channel-list-layout.scss`), so the list
does not move when the channels land.
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
- M3U favorites and recent collection rows preserve their full `Channel` payload on unified live items so the shared live list can open the read-only channel details context menu without reconstructing partial channel data.
+4 -1
View File
@@ -70,7 +70,10 @@ Render rules:
1. Dashboard rails render independently as their data sources resolve. The
page no longer uses `dashboardReady()` as a page-wide skeleton gate.
Initial hero/recent/favorites loading states render scoped skeletons so one
slow rail does not hide already available content.
slow rail does not hide already available content. A rail skeleton is a
`lib-dashboard-rail-skeleton` with the layout of the rail it precedes,
sized from the rail's own geometry partial, so the rail replaces it at the
same height (UI guidelines, Loading States).
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
full-bleed. All rails and the hero are skipped.
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
@@ -1,4 +1,5 @@
@use '@angular/material' as mat;
@use '../../styles/channel-list-layout' as layout;
:host {
display: flex;
@@ -33,23 +34,18 @@
}
.all-channels-header {
padding: 12px 16px 8px;
flex-shrink: 0;
@include layout.list-header-box;
}
.all-channels-header__top {
display: flex;
align-items: center;
gap: 8px;
@include layout.list-header-row($actions: true);
h2 {
@include layout.list-header-title-type;
flex: 1;
min-width: 0;
overflow: hidden;
margin: 0;
color: var(--mat-sys-on-surface);
font-size: 0.94rem;
font-weight: 600;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
@@ -66,11 +62,7 @@
}
.all-channels-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
@include layout.list-divider;
}
#all-channels {
@@ -2,6 +2,7 @@
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="shouldShowEpg()"
[showHeader]="!compact()"
/>
} @else if (showChannelViews()) {
@if (
@@ -1,3 +1,5 @@
@use '../styles/channel-list-layout' as layout;
:host {
display: flex;
flex-direction: column;
@@ -74,20 +76,15 @@
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 12px 16px 8px;
flex-shrink: 0;
@include layout.list-header-box;
}
.context-header__top {
display: flex;
align-items: center;
@include layout.list-header-row;
justify-content: space-between;
gap: 8px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
@include layout.list-header-title-type;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
@@ -95,9 +92,5 @@
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
@include layout.list-divider;
}
@@ -2,6 +2,7 @@
@use '../../../../../styles/portal-sidebar';
@use '../../../../../styles/panel-header' as panel;
@use '../../../../../styles/nav-list';
@use '../../styles/channel-list-layout' as layout;
:host {
display: flex;
@@ -56,25 +57,15 @@
}
.groups-nav-header {
padding: 4px 6px 12px;
flex-shrink: 0;
display: flex;
gap: 10px;
flex-direction: column;
@include layout.groups-nav-header-box;
}
.groups-nav-header__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
@include layout.groups-nav-header-row;
}
.groups-nav-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
@include layout.groups-nav-title-type;
color: var(--mat-sys-on-surface);
}
@@ -168,11 +159,6 @@
}
}
.groups-content-header {
@include panel.standard-panel-header();
border-top-left-radius: inherit;
}
.groups-content-header button {
@include mat.form-field-density(-5);
}
@@ -181,9 +167,7 @@
@include panel.standard-panel-meta();
&--stacked {
flex-direction: column;
align-items: flex-start;
gap: 4px;
@include layout.groups-content-meta-stacked;
}
}
@@ -261,7 +245,10 @@
border-top-left-radius: 16px;
}
// Outranks `.sidebar .sidebar-header` (portal-sidebar), so the header's box
// comes from the mixin the loading skeleton shares.
.groups-content-panel > .groups-content-header {
@include layout.groups-content-header-box;
border-top-left-radius: inherit;
}
@@ -7,37 +7,52 @@
<div class="groups-loading-layout" aria-hidden="true">
<aside class="groups-loading-nav">
<header class="groups-loading-nav__header">
<h2 class="groups-loading-nav__title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
<div class="groups-loading-nav__header-row">
<h2 class="groups-loading-nav__title">
{{ 'CHANNELS.GROUPS' | translate }}
</h2>
</div>
</header>
<div class="groups-loading-nav__list">
<div class="nav-list groups-loading-nav__list">
@for (row of groupRows; track row) {
<div class="groups-loading-nav__item">
<div class="nav-item groups-loading-nav__item">
<span
class="grid-skeleton-line groups-loading-nav__label"
[style.width.%]="groupLabelWidths[row]"
></span>
class="nav-item-label groups-loading-nav__label"
>
<span
class="grid-skeleton-line"
[style.width.%]="groupLabelWidths[row]"
></span>
</span>
<span
class="grid-skeleton-line groups-loading-nav__count"
></span>
class="nav-item-meta groups-loading-nav__count"
>
<span class="grid-skeleton-line"></span>
</span>
</div>
}
</div>
</aside>
<section class="groups-loading-content">
<header class="groups-loading-content__header">
<div class="groups-loading-content__meta">
<span
class="grid-skeleton-line groups-loading-content__title"
></span>
<span
class="grid-skeleton-line groups-loading-content__subtitle"
></span>
</div>
</header>
@if (showHeader()) {
<header class="groups-loading-content__header">
<div class="groups-loading-content__meta">
<span class="groups-loading-content__title">
<span class="grid-skeleton-line"></span>
</span>
<span class="groups-loading-content__subtitle">
<span class="grid-skeleton-line"></span>
</span>
</div>
@for (slot of headerActionSlots; track slot) {
<span
class="list-skeleton-star loading-header-action"
></span>
}
</header>
}
<div class="groups-loading-content__list">
<app-channel-list-skeleton
@@ -49,12 +64,29 @@
</section>
</div>
} @else {
<div class="channels-loading-state">
<div class="channels-loading-state__copy">
<div class="channels-loading-state__title">
{{ 'CHANNELS.LOADING' | translate }}
</div>
</div>
<div class="channels-loading-state" aria-hidden="true">
@if (showHeader()) {
<header class="channels-loading-header">
<div
class="channels-loading-header__row"
[class.channels-loading-header__row--actions]="
hasHeaderActions()
"
>
<span class="channels-loading-header__title">
<span class="grid-skeleton-line"></span>
</span>
@if (hasHeaderActions()) {
@for (slot of headerActionSlots; track slot) {
<span
class="list-skeleton-star loading-header-action"
></span>
}
}
</div>
</header>
<div class="channels-loading-divider"></div>
}
<div class="channels-loading-state__list">
<app-channel-list-skeleton
@@ -1,4 +1,10 @@
// Headers, rails and list paddings come from the same mixins and classes as
// the loaded views (`all-channels-view`, `groups-view`, the context header),
// so the list starts at the same height before and after the channels load.
@use '../../../../../portal/shared/ui/src/lib/styles/skeleton';
@use '../../../../styles/nav-list';
@use '../../../../styles/panel-header' as panel;
@use '../styles/channel-list-layout' as layout;
:host {
display: block;
@@ -27,26 +33,60 @@
border: 0;
}
// A text placeholder: the line box of the text it stands in for, with the
// shimmer bar centred in it.
%line-placeholder {
@include layout.empty-line-box;
> .grid-skeleton-line {
position: absolute;
top: 50%;
left: 0;
translate: 0 -50%;
}
}
.loading-header-action {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
}
// ── All channels and the favorites/recent views ────────────────────────────
.channels-loading-state {
display: grid;
gap: 14px;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px 12px 18px;
grid-template-rows: auto 1fr;
}
.channels-loading-state__copy {
padding: 0 4px;
.channels-loading-header {
@include layout.list-header-box;
}
.channels-loading-state__title,
.groups-loading-nav__title {
margin: 0;
font-size: 0.86rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
.channels-loading-header__row {
@include layout.list-header-row;
&--actions {
min-height: layout.$header-action-size;
}
}
.channels-loading-header__title {
@include layout.list-header-title-type;
@extend %line-placeholder;
flex: 1;
min-width: 0;
> .grid-skeleton-line {
width: min(132px, 60%);
height: 11px;
}
}
.channels-loading-divider {
@include layout.list-divider;
}
.channels-loading-state__list,
@@ -55,6 +95,11 @@
overflow: hidden;
}
.channels-loading-state__list {
flex: 1;
}
// ── Groups view ────────────────────────────────────────────────────────────
.groups-loading-layout {
display: flex;
width: 100%;
@@ -79,53 +124,44 @@
}
.groups-loading-nav__header {
padding: 4px 6px 12px;
flex-shrink: 0;
@include layout.groups-nav-header-box;
}
.groups-loading-nav__header-row {
@include layout.groups-nav-header-row;
}
.groups-loading-nav__title {
@include layout.groups-nav-title-type;
color: var(--mat-sys-on-surface);
}
// The rail's own `.nav-list` / `.nav-item` boxes, without their hover and
// pointer states.
.groups-loading-nav__list {
display: grid;
gap: 8px;
min-height: 0;
padding-top: 0;
padding-inline: 0 2px;
overflow: hidden;
}
.groups-loading-nav__item {
display: grid;
grid-template-columns: minmax(0, 1fr) 24px;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 10px;
border-radius: 12px;
background: color-mix(
in srgb,
var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%,
var(--app-widget-bg, var(--mat-sys-surface-container)) 12%
);
border: 1px solid
color-mix(
in srgb,
var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%,
transparent
);
}
.groups-loading-nav__label,
.groups-loading-nav__count,
.groups-loading-content__title,
.groups-loading-content__subtitle {
border-radius: 999px;
pointer-events: none;
}
.groups-loading-nav__label {
height: 9px;
@extend %line-placeholder;
> .grid-skeleton-line {
height: 9px;
}
}
.groups-loading-nav__count {
width: 22px;
height: 9px;
justify-self: end;
// Outranks `.nav-item span`, which gives every span `flex: 1`.
.groups-loading-nav__item > .groups-loading-nav__count {
@extend %line-placeholder;
flex: 0 0 22px;
> .grid-skeleton-line {
width: 100%;
height: 9px;
}
}
.groups-loading-content {
@@ -140,52 +176,50 @@
}
.groups-loading-content__header {
flex-shrink: 0;
min-height: 56px;
padding: 12px 16px 10px;
border-bottom: 1px solid var(--app-separator);
@include layout.groups-content-header-box;
border-top-left-radius: inherit;
}
.groups-loading-content__meta {
max-width: 180px;
display: grid;
gap: 8px;
@include panel.standard-panel-meta();
@include layout.groups-content-meta-stacked;
}
.groups-loading-content__title {
@include panel.standard-panel-title(0.94rem);
@extend %line-placeholder;
width: 100%;
max-width: 156px;
height: 12px;
> .grid-skeleton-line {
width: 100%;
height: 12px;
}
}
.groups-loading-content__subtitle {
width: 82px;
height: 9px;
@include panel.standard-panel-subtitle($background: transparent);
@extend %line-placeholder;
width: 64px;
> .grid-skeleton-line {
inset: 0;
height: auto;
translate: none;
}
}
// The loaded rows ignore their viewport's padding: the virtual scroll
// wrapper is positioned at its top-left corner. The skeleton rows start
// there too.
.groups-loading-content__list {
flex: 1;
min-height: 0;
align-content: start;
padding: 12px 8px 12px;
}
@container (max-width: 380px) {
.channels-loading-state {
padding-inline: 8px;
}
.groups-loading-nav {
min-width: 136px;
width: 42%;
padding-inline: 8px;
}
.groups-loading-content__header {
padding-inline: 12px;
}
.groups-loading-content__list {
padding-inline: 6px;
}
}
@@ -31,6 +31,59 @@ describe('ChannelListLoadingStateComponent', () => {
).toBeNull();
});
it('mirrors the All channels header: title row with two actions, then the divider', () => {
fixture.componentRef.setInput('view', 'all');
fixture.detectChanges();
const row: HTMLElement = fixture.nativeElement.querySelector(
'.channels-loading-header__row'
);
expect(
row.classList.contains('channels-loading-header__row--actions')
).toBe(true);
expect(row.querySelectorAll('.loading-header-action')).toHaveLength(2);
expect(
fixture.nativeElement.querySelector('.channels-loading-divider')
).not.toBeNull();
});
it('keeps the title row of the favorites and recent headers free of actions', () => {
fixture.componentRef.setInput('view', 'favorites');
fixture.detectChanges();
const row: HTMLElement = fixture.nativeElement.querySelector(
'.channels-loading-header__row'
);
expect(
row.classList.contains('channels-loading-header__row--actions')
).toBe(false);
expect(row.querySelector('.loading-header-action')).toBeNull();
});
it('drops the headers where the loaded view hides its own', () => {
fixture.componentRef.setInput('showHeader', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.channels-loading-header')
).toBeNull();
expect(
fixture.nativeElement.querySelector('.channels-loading-divider')
).toBeNull();
fixture.componentRef.setInput('view', 'groups');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'.groups-loading-content__header'
)
).toBeNull();
expect(
fixture.nativeElement.querySelector('.groups-loading-nav__header')
).not.toBeNull();
});
it('renders compact skeleton rows when the host disables EPG', () => {
fixture.componentRef.setInput('showEpg', false);
fixture.detectChanges();
@@ -59,5 +112,10 @@ describe('ChannelListLoadingStateComponent', () => {
expect(
fixture.nativeElement.querySelector('.groups-loading-content')
).not.toBeNull();
expect(
fixture.nativeElement.querySelectorAll(
'.groups-loading-content__header .loading-header-action'
)
).toHaveLength(2);
});
});
@@ -7,6 +7,11 @@ import {
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListSkeletonComponent } from '../channel-list-container/channel-list-skeleton/channel-list-skeleton.component';
/**
* First-load placeholder for a channel list. Its headers and rows use the
* boxes of the view it stands in for (`styles/_channel-list-layout.scss`),
* so the list does not move when the channels arrive.
*/
@Component({
selector: 'app-channel-list-loading-state',
templateUrl: './channel-list-loading-state.component.html',
@@ -17,8 +22,13 @@ import { ChannelListSkeletonComponent } from '../channel-list-container/channel-
export class ChannelListLoadingStateComponent {
readonly view = input<string>('all');
readonly showEpg = input(true);
/** False where the loaded view hides its header (compact lists). */
readonly showHeader = input(true);
readonly isGroupsView = computed(() => this.view() === 'groups');
/** Only the "All channels" header carries the sort and hide actions. */
readonly hasHeaderActions = computed(() => this.view() === 'all');
readonly headerActionSlots = [0, 1] as const;
readonly groupRows = Array.from({ length: 10 }, (_, index) => index);
readonly groupLabelWidths = [78, 66, 84, 58, 73, 69, 81, 62, 76, 71];
}
@@ -0,0 +1,99 @@
// Boxes shared by the channel list views (`all-channels-view`, `groups-view`,
// the favorites/recent context header of `channel-list-container`) and their
// loading skeleton (`channel-list-loading-state`). The skeleton draws its
// placeholders inside the same boxes, so its headers and first row sit where
// the loaded list puts them. They had drifted: the M3U list moved down by
// 19px when a playlist finished loading.
@use '../../../../styles/panel-header' as panel;
/// Material 3 icon button: the actions in the "All channels" header.
$header-action-size: 40px;
/// The groups rail's compact header actions.
$groups-nav-action-size: 28px;
// ── "All channels" and the favorites/recent context header ─────────────────
@mixin list-header-box {
padding: 12px 16px 8px;
flex-shrink: 0;
}
/// The title row; a row that holds the icon actions is as tall as they are.
@mixin list-header-row($actions: false) {
display: flex;
align-items: center;
gap: 8px;
@if $actions {
min-height: $header-action-size;
}
}
@mixin list-header-title-type {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
}
@mixin list-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
}
// ── Groups view ────────────────────────────────────────────────────────────
@mixin groups-nav-header-box {
padding: 4px 6px 12px;
flex-shrink: 0;
display: flex;
gap: 10px;
flex-direction: column;
}
@mixin groups-nav-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: $groups-nav-action-size;
}
@mixin groups-nav-title-type {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
}
/// The channel header beside the groups rail. Padding, gap and separator are
/// the values `.sidebar .sidebar-header` (portal-sidebar) has always given
/// it; the rule that includes this must outrank that selector, so the box
/// comes from here for the view and its skeleton alike. The panel header's
/// 52px minimum applies to the content box: the header is 69px tall.
@mixin groups-content-header-box {
@include panel.standard-panel-header(
$padding: 8px 12px,
$gap: 8px,
$border-color: var(--app-separator)
);
}
/// Group title above its channel count.
@mixin groups-content-meta-stacked {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
// ── Placeholders ───────────────────────────────────────────────────────────
/// An empty line box in the element's own font: a placeholder standing in
/// for one line of text is exactly as tall as that line. The visible bar is
/// a positioned child, so it never changes the line's height.
@mixin empty-line-box {
position: relative;
&::before {
content: '\200b';
}
}
@@ -1,48 +1,4 @@
// Placeholder blocks for detail-page loading states (hero, episode rows).
//
// A block is a flat fill a step above the page surface with a moving
// highlight a step higher, both mixed from the theme's on-surface ink so the
// placeholders read on the light and the dark theme alike. Under
// `prefers-reduced-motion` the highlight stays put.
//
// Include `keyframes` once in a component stylesheet that uses `block`:
// Angular scopes keyframes to the component that declares them.
$duration: 1.4s;
@mixin keyframes {
@keyframes skeleton-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: -100% 0;
}
}
}
@mixin block($radius: 4px) {
--skeleton-base: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
--skeleton-highlight: color-mix(
in srgb,
var(--app-on-surface) 11%,
transparent
);
display: block;
border-radius: $radius;
background: linear-gradient(
90deg,
var(--skeleton-base) 25%,
var(--skeleton-highlight) 50%,
var(--skeleton-base) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer $duration ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
background: var(--skeleton-base);
}
}
// The shared shimmer moved to `libs/ui/styles/_skeleton-shimmer.scss` so the
// dashboard skeletons use the same recipe; the detail skeletons keep their
// `@use '../styles/skeleton-shimmer'` through this forward.
@forward '../../../../styles/skeleton-shimmer';
+1
View File
@@ -12,3 +12,4 @@
@forward 'detail-view-actions';
@forward 'nav-list';
@forward 'focus-ring';
@forward 'skeleton-shimmer' as skeleton-*;
+59
View File
@@ -0,0 +1,59 @@
// The one shimmer recipe for loading placeholders: the dashboard hero and
// rails, and the detail-page hero, actions and episode rows.
//
// A block is a flat fill a step above whatever surface it sits on, with a
// moving highlight a step higher. Both are mixed from the theme's on-surface
// ink rather than from surface tokens: the light theme's widget and header
// surfaces are nearly the same white (1.04:1), so a sweep between them was
// invisible on the light page. On the page background the ink mix gives the
// base about 1.2:1 in both themes and the highlight 1.18:1 (light) and
// 1.26:1 (dark) over the base. Under `prefers-reduced-motion` the highlight
// stays put.
//
// Include `keyframes` once in a component stylesheet that uses `block`:
// Angular scopes keyframes to the component that declares them.
$duration: 1.4s;
$base-ink: 8%;
$highlight-ink: 16%;
@mixin keyframes {
@keyframes skeleton-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: -100% 0;
}
}
}
@mixin block($radius: 4px) {
--skeleton-base: color-mix(
in srgb,
var(--app-on-surface) #{$base-ink},
transparent
);
--skeleton-highlight: color-mix(
in srgb,
var(--app-on-surface) #{$highlight-ink},
transparent
);
display: block;
border-radius: $radius;
background: linear-gradient(
90deg,
var(--skeleton-base) 25%,
var(--skeleton-highlight) 50%,
var(--skeleton-base) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer $duration ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
background: var(--skeleton-base);
}
}
@@ -0,0 +1,190 @@
// Geometry shared by the dashboard rail (`lib-dashboard-rail`) and its
// loading skeleton (`lib-dashboard-rail-skeleton`): everything that decides
// a rail's height. The skeleton draws its placeholders inside the same boxes
// and text line boxes, so a rail that replaces its skeleton keeps the height
// and the rails below it stay where they are.
$stack-gap: 12px;
$header-min-height: 24px;
$card-stack-gap: 8px;
$meta-gap: 3px;
$card-title-font-size: 0.85rem;
$card-title-line-height: 1.3;
$card-meta-font-size: 0.72rem;
$card-meta-line-height: 1.4;
$channel-logo-size: 40px;
$channel-name-font-size: 0.85rem;
$channel-name-line-height: 1.2;
$channel-now-font-size: 0.72rem;
$channel-now-line-height: 1.35;
$channel-progress-font-size: 0.65rem;
$channel-progress-line-height: 1.3;
/// Height of one line of text set in `$font-size` / `$line-height`.
@function line-box($font-size, $line-height) {
@return $font-size * $line-height;
}
/// Card size tokens, read from the cover-size setting (`_cover-size.scss`).
@mixin host-tokens {
--rail-card-width: var(--cover-rail-width, 172px);
--rail-gap: var(--cover-gap, 14px);
--rail-radius: 12px;
// How far the scrolling track bleeds past the viewport on every side:
// room for a card's focus ring (2px outline + 3px offset) plus its 2px
// hover lift, so neither is clipped. The track clips partially visible
// cards at this outer edge, so the edge fades extend by the same amount.
--rail-bleed: 8px;
@media (max-width: 720px) {
--rail-card-width: min(var(--cover-rail-width, 172px), 140px);
}
}
/// The rail itself: header above the track.
@mixin stack {
display: flex;
flex-direction: column;
gap: $stack-gap;
}
@mixin header-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
min-height: $header-min-height;
}
/// The track bleeds by `--rail-bleed` on every side and takes it back with a
/// negative margin, so it adds no height of its own.
@mixin track-box {
display: flex;
align-items: flex-start;
gap: var(--rail-gap);
padding: var(--rail-bleed);
margin: calc(-1 * var(--rail-bleed));
}
/// Live channel cards: wider and much shorter than a poster.
@mixin channel-track-tokens {
--rail-card-width: var(--channel-rail-width, 280px);
--rail-gap: 10px;
}
// ── Cover cards: artwork, then the title and meta lines ────────────────────
@mixin card-box {
// `min-width: 0` keeps a long title from widening the flex item past
// its basis (flex items default to `min-width: auto`).
flex: 0 0 var(--rail-card-width);
width: var(--rail-card-width);
max-width: var(--rail-card-width);
min-width: 0;
display: flex;
flex-direction: column;
border-radius: var(--rail-radius);
}
@mixin card-stack {
display: flex;
flex-direction: column;
gap: $card-stack-gap;
min-width: 0;
}
/// The artwork frame; its aspect ratio comes from the rail's
/// `aspectRatio` input.
@mixin art-frame {
position: relative;
overflow: hidden;
border-radius: var(--rail-radius);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
@mixin meta-stack {
display: flex;
flex-direction: column;
gap: $meta-gap;
padding: 0 2px;
min-width: 0;
}
@mixin card-title-type {
font-size: $card-title-font-size;
line-height: $card-title-line-height;
}
@mixin card-meta-type {
font-size: $card-meta-font-size;
line-height: $card-meta-line-height;
}
/// The meta row is one meta line tall even when it holds only a chip.
@mixin card-meta-row-box {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
min-height: line-box($card-meta-font-size, $card-meta-line-height);
}
// ── Channel cards: logo row, then the live progress row ────────────────────
@mixin channel-card-box {
padding: 10px 12px;
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
border-radius: var(--rail-radius);
}
@mixin channel-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
@mixin channel-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
@mixin channel-logo-box {
flex: 0 0 auto;
width: $channel-logo-size;
height: $channel-logo-size;
border-radius: 8px;
}
@mixin channel-meta-stack {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
@mixin channel-name-type {
font-size: $channel-name-font-size;
line-height: $channel-name-line-height;
}
@mixin channel-now-type {
font-size: $channel-now-font-size;
line-height: $channel-now-line-height;
}
/// One progress-text line tall, with or without the time range.
@mixin channel-progress-row-box {
display: flex;
align-items: center;
gap: 8px;
font-size: $channel-progress-font-size;
line-height: $channel-progress-line-height;
min-height: line-box(
$channel-progress-font-size,
$channel-progress-line-height
);
}
@@ -9,6 +9,7 @@
// reverse, with the same geometry.
@use '../../../../../../ui/styles/detail-view-actions' as detail-actions;
@use '../../../../../../ui/styles/skeleton-shimmer' as shimmer;
// The host renders nothing when there is no slide and no skeleton; with
// `display: contents` it then adds no flex gap to the rails page either.
@@ -658,17 +659,10 @@
);
}
@include shimmer.keyframes;
.hero__skeleton {
display: block;
border-radius: 6px;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 0%,
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 16%, transparent) 50%,
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 100%
);
background-size: 200% 100%;
animation: hero-skeleton-shimmer 1.6s linear infinite;
@include shimmer.block(6px);
&--eyebrow {
width: 180px;
@@ -695,15 +689,6 @@
}
}
@keyframes hero-skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
// ── Narrow content area ────────────────────────────────────────────────────
@container dashboard (max-width: 720px) {
// The slide carries its own scrim block here; a tail below it would
@@ -769,8 +754,7 @@
transition: none;
}
.hero__content,
.hero__skeleton {
.hero__content {
animation: none;
}
}
@@ -0,0 +1,63 @@
<section
class="rail-skeleton"
aria-hidden="true"
aria-busy="true"
[attr.data-layout]="layout()"
[attr.data-test-id]="testId()"
>
<div class="rail-skeleton__header">
<span class="rail-skeleton__title"></span>
</div>
<div class="rail-skeleton__track" [attr.data-layout]="layout()">
@for (slot of slots; track slot) {
@if (layout() === 'channel') {
<div class="rail-skeleton__card rail-skeleton__card--channel">
<div class="rail-skeleton__channel-row">
<span class="rail-skeleton__channel-logo"></span>
<span class="rail-skeleton__channel-meta">
<span
class="rail-skeleton__line rail-skeleton__line--channel-name"
>
<span class="rail-skeleton__bar"></span>
</span>
<span
class="rail-skeleton__line rail-skeleton__line--channel-now"
>
<span
class="rail-skeleton__bar rail-skeleton__bar--short"
></span>
</span>
</span>
</div>
<div class="rail-skeleton__channel-progress-row">
<span
class="rail-skeleton__bar rail-skeleton__bar--progress"
></span>
</div>
</div>
} @else {
<div class="rail-skeleton__card">
<span
class="rail-skeleton__art"
[style.aspect-ratio]="aspectRatio()"
></span>
<span class="rail-skeleton__meta">
<span
class="rail-skeleton__line rail-skeleton__line--title"
>
<span class="rail-skeleton__bar"></span>
</span>
<span
class="rail-skeleton__line rail-skeleton__line--meta"
>
<span
class="rail-skeleton__bar rail-skeleton__bar--short"
></span>
</span>
</span>
</div>
}
}
</div>
</section>
@@ -0,0 +1,126 @@
// Same boxes as `dashboard-rail.component.scss`, from the shared geometry
// partial; only the fills differ. Text is stood in for by line boxes of the
// real line height with a shorter shimmer bar centred in each.
@use 'dashboard-rail-geometry' as geometry;
@use '../../../../../../ui/styles/skeleton-shimmer' as shimmer;
@include shimmer.keyframes;
:host {
display: block;
@include geometry.host-tokens;
}
.rail-skeleton {
@include geometry.stack;
}
.rail-skeleton__header {
@include geometry.header-box;
justify-content: flex-start;
}
.rail-skeleton__title {
@include shimmer.block(6px);
width: 180px;
max-width: 60%;
height: 16px;
}
.rail-skeleton__track {
@include geometry.track-box;
overflow: hidden;
&[data-layout='channel'] {
@include geometry.channel-track-tokens;
}
}
// ── Cover cards ────────────────────────────────────────────────────────────
.rail-skeleton__card {
@include geometry.card-box;
@include geometry.card-stack;
}
.rail-skeleton__art {
@include geometry.art-frame;
@include shimmer.block(var(--rail-radius));
}
.rail-skeleton__meta {
@include geometry.meta-stack;
}
.rail-skeleton__line {
display: flex;
align-items: center;
min-width: 0;
&--title {
height: geometry.line-box(
geometry.$card-title-font-size,
geometry.$card-title-line-height
);
}
&--meta {
height: geometry.line-box(
geometry.$card-meta-font-size,
geometry.$card-meta-line-height
);
}
&--channel-name {
height: geometry.line-box(
geometry.$channel-name-font-size,
geometry.$channel-name-line-height
);
}
&--channel-now {
height: geometry.line-box(
geometry.$channel-now-font-size,
geometry.$channel-now-line-height
);
}
}
.rail-skeleton__bar {
@include shimmer.block(999px);
width: 82%;
height: 9px;
&--short {
width: 56%;
height: 8px;
}
&--progress {
flex: 1;
height: 3px;
}
}
// ── Channel cards ──────────────────────────────────────────────────────────
.rail-skeleton__card--channel {
@include geometry.channel-card-box;
@include geometry.channel-stack;
background: var(--app-widget-bg, #1e2330);
}
.rail-skeleton__channel-row {
@include geometry.channel-row;
}
.rail-skeleton__channel-logo {
@include geometry.channel-logo-box;
@include shimmer.block(8px);
}
.rail-skeleton__channel-meta {
@include geometry.channel-meta-stack;
}
.rail-skeleton__channel-progress-row {
@include geometry.channel-progress-row-box;
}
@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardRailSkeletonComponent } from './dashboard-rail-skeleton.component';
import { SKELETON_CARDS_PER_RAIL } from './dashboard-rail.utils';
function geometryMixins(file: string): Set<string> {
const source = readFileSync(resolve(__dirname, file), 'utf8');
return new Set(
[...source.matchAll(/@include geometry\.([\w-]+)/g)].map(
([, name]) => name
)
);
}
describe('DashboardRailSkeletonComponent', () => {
let fixture: ComponentFixture<DashboardRailSkeletonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DashboardRailSkeletonComponent],
}).compileComponents();
fixture = TestBed.createComponent(DashboardRailSkeletonComponent);
});
const cards = (selector: string): HTMLElement[] => [
...fixture.nativeElement.querySelectorAll(selector),
];
it('stands in for a cover rail with artwork, a title line and a meta line', () => {
fixture.componentRef.setInput(
'testId',
'dashboard-xtream-recently-added-rail-skeleton'
);
fixture.detectChanges();
const section: HTMLElement =
fixture.nativeElement.querySelector('.rail-skeleton');
expect(section.getAttribute('aria-hidden')).toBe('true');
expect(section.dataset['testId']).toBe(
'dashboard-xtream-recently-added-rail-skeleton'
);
const covers = cards('.rail-skeleton__card');
expect(covers).toHaveLength(SKELETON_CARDS_PER_RAIL.length);
expect(
covers.every(
(card) =>
(card.querySelector('.rail-skeleton__art') as HTMLElement)
.style.aspectRatio === '2 / 3' &&
card.querySelector('.rail-skeleton__line--title') &&
card.querySelector('.rail-skeleton__line--meta')
)
).toBe(true);
});
it('takes the artwork ratio of the rail it precedes', () => {
fixture.componentRef.setInput('aspectRatio', '16 / 9');
fixture.detectChanges();
expect(
cards('.rail-skeleton__art').map((art) => art.style.aspectRatio)
).toEqual(SKELETON_CARDS_PER_RAIL.map(() => '16 / 9'));
});
it('draws channel cards for a live channel rail', () => {
fixture.componentRef.setInput('layout', 'channel');
fixture.detectChanges();
expect(cards('.rail-skeleton__card--channel')).toHaveLength(
SKELETON_CARDS_PER_RAIL.length
);
expect(cards('.rail-skeleton__art')).toHaveLength(0);
expect(
fixture.nativeElement
.querySelector('.rail-skeleton__track')
.getAttribute('data-layout')
).toBe('channel');
});
// The skeleton is only as tall as the rail while both take their boxes
// from the shared geometry partial; a value inlined in either drifts.
it('sizes every box with the geometry mixins the rail itself uses', () => {
const rail = geometryMixins('dashboard-rail.component.scss');
const skeleton = geometryMixins(
'dashboard-rail-skeleton.component.scss'
);
expect(skeleton.size).toBeGreaterThan(10);
expect([...skeleton].filter((name) => !rail.has(name))).toEqual([]);
});
});
@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import type { DashboardRailLayout } from './dashboard-rail.component';
import { SKELETON_CARDS_PER_RAIL } from './dashboard-rail.utils';
/**
* Placeholder for one dashboard rail while its data loads. It takes the
* `layout` and `aspectRatio` of the rail it stands in for and sizes its
* header, track and cards from the rail's own geometry partial, so the rail
* replaces it at the same height (see Loading States in the UI guidelines).
*/
@Component({
selector: 'lib-dashboard-rail-skeleton',
templateUrl: './dashboard-rail-skeleton.component.html',
styleUrl: './dashboard-rail-skeleton.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardRailSkeletonComponent {
readonly layout = input<DashboardRailLayout>('cover');
readonly aspectRatio = input<string>('2 / 3');
readonly testId = input<string | null>(null);
readonly slots = SKELETON_CARDS_PER_RAIL;
}
@@ -224,7 +224,8 @@
card.episodeBadge ||
card.remainingLabel ||
card.subtitle ||
card.expiryBadge
card.expiryBadge ||
reserveMetaLine()
) {
<span class="rail__card-meta-row">
@if (card.episodeBadge) {
@@ -1,30 +1,19 @@
@use '@angular/material' as mat;
// Everything that sets the rail's height comes from the geometry partial,
// which the loading skeleton (`lib-dashboard-rail-skeleton`) shares.
@use 'dashboard-rail-geometry' as geometry;
:host {
display: block;
--rail-card-width: var(--cover-rail-width, 172px);
--rail-gap: var(--cover-gap, 14px);
--rail-radius: 12px;
// How far the scrolling track bleeds past the viewport on every side:
// room for a card's focus ring (2px outline + 3px offset) plus its 2px
// hover lift, so neither is clipped. The track clips partially visible
// cards at this outer edge, so the edge fades extend by the same amount.
--rail-bleed: 8px;
@include geometry.host-tokens;
}
.rail {
display: flex;
flex-direction: column;
gap: 12px;
@include geometry.stack;
}
.rail__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 2px;
// Tighter height cap — keeps rail chapter markers compact
min-height: 24px;
@include geometry.header-box;
}
.rail__title {
@@ -136,16 +125,12 @@
}
.rail__track {
display: flex;
align-items: flex-start;
gap: var(--rail-gap);
@include geometry.track-box;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scroll-padding-inline-start: var(--rail-bleed);
scrollbar-width: none;
padding: var(--rail-bleed);
margin: calc(-1 * var(--rail-bleed));
&::-webkit-scrollbar {
display: none;
@@ -153,20 +138,10 @@
}
.rail__card {
// Pin the card size so long titles never expand the flex item.
// `min-width: 0` is needed because flex items default to `min-width: auto`
// (their intrinsic content size), which would otherwise override the
// flex-basis when the title wraps.
flex: 0 0 var(--rail-card-width);
width: var(--rail-card-width);
max-width: var(--rail-card-width);
min-width: 0;
@include geometry.card-box;
scroll-snap-align: start;
position: relative;
display: flex;
flex-direction: column;
color: inherit;
border-radius: var(--rail-radius);
transition:
transform 0.2s ease,
filter 0.2s ease;
@@ -187,10 +162,7 @@
}
.rail__card-link {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
@include geometry.card-stack;
color: inherit;
text-decoration: none;
border-radius: inherit;
@@ -202,11 +174,8 @@
}
.rail__art {
position: relative;
overflow: hidden;
border-radius: var(--rail-radius);
@include geometry.art-frame;
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
transition: box-shadow 0.2s ease;
@@ -338,17 +307,12 @@
}
.rail__meta {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0 2px;
min-width: 0;
@include geometry.meta-stack;
}
.rail__card-title {
font-size: 0.85rem;
@include geometry.card-title-type;
font-weight: 600;
line-height: 1.3;
color: var(--app-heading-color, #1a1d23);
white-space: nowrap;
overflow: hidden;
@@ -358,10 +322,7 @@
}
.rail__card-meta-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
@include geometry.card-meta-row-box;
}
.rail__card-episode {
@@ -414,11 +375,10 @@
// "12 min left" beside the episode chip: same tone as the subtitle, but it
// keeps its digits whole instead of truncating when the row runs short.
.rail__card-remaining {
@include geometry.card-meta-type;
flex: 0 1 auto;
min-width: 0;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.4;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
@@ -427,11 +387,10 @@
}
.rail__card-subtitle {
@include geometry.card-meta-type;
flex: 1 1 auto;
min-width: 0;
font-size: 0.72rem;
font-weight: 500;
line-height: 1.4;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
@@ -524,12 +483,6 @@
}
}
@media (max-width: 720px) {
:host {
--rail-card-width: min(var(--cover-rail-width, 172px), 140px);
}
}
// ── Channel layout (compact horizontal info row) ────────────────────────────
// Used by the "Live now on your favorites" rail. Two stacked rows:
// 1. logo + channel name + current programme title
@@ -538,16 +491,13 @@
// small (100–256px square) so a poster card wastes most of its height on
// padding. Mirrors the live strip in the v0.22 mockup.
.rail__track[data-layout='channel'] {
--rail-card-width: var(--channel-rail-width, 280px);
--rail-gap: 10px;
@include geometry.channel-track-tokens;
scroll-snap-type: x mandatory;
}
.rail__card--channel {
@include geometry.channel-card-box;
background: var(--app-widget-bg, #1e2330);
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
border-radius: var(--rail-radius);
padding: 10px 12px;
transition:
transform 0.2s ease,
border-color 0.2s ease,
@@ -567,9 +517,7 @@
}
.rail__channel-link {
display: flex;
flex-direction: column;
gap: 8px;
@include geometry.channel-stack;
color: inherit;
text-decoration: none;
border-radius: inherit;
@@ -581,17 +529,11 @@
}
.rail__channel-row {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
@include geometry.channel-row;
}
.rail__channel-logo {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 8px;
@include geometry.channel-logo-box;
background: var(--app-content-bg, #161a22);
display: flex;
align-items: center;
@@ -617,17 +559,12 @@
}
.rail__channel-meta {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
@include geometry.channel-meta-stack;
}
.rail__channel-name {
font-size: 0.85rem;
@include geometry.channel-name-type;
font-weight: 600;
line-height: 1.2;
color: var(--app-heading-color, #1a1d23);
white-space: nowrap;
overflow: hidden;
@@ -636,9 +573,8 @@
}
.rail__channel-now {
font-size: 0.72rem;
@include geometry.channel-now-type;
font-weight: 500;
line-height: 1.35;
color: var(--app-muted-color, #8891a4);
white-space: nowrap;
overflow: hidden;
@@ -679,10 +615,7 @@
}
.rail__channel-progress-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.65rem;
@include geometry.channel-progress-row-box;
color: var(--app-muted-color, #8891a4);
}
@@ -85,7 +85,10 @@ describe('DashboardRailComponent', () => {
};
});
const renderCards = async (items: DashboardRailCard[]) => {
const renderCards = async (
items: DashboardRailCard[],
reserveMetaLine = false
) => {
await TestBed.configureTestingModule({
imports: [DashboardRailComponent, TranslateModule.forRoot()],
providers: [
@@ -100,6 +103,7 @@ describe('DashboardRailComponent', () => {
const fixture = TestBed.createComponent(DashboardRailComponent);
fixture.componentRef.setInput('label', 'Sources');
fixture.componentRef.setInput('items', items);
fixture.componentRef.setInput('reserveMetaLine', reserveMetaLine);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
};
@@ -162,6 +166,21 @@ describe('DashboardRailComponent', () => {
expect(cards[0].querySelector('.rail__card-subtitle')).toBeNull();
expect(cards[1].querySelector('.rail__card-meta-row')).toBeNull();
});
// A rail that replaces its loading skeleton keeps the skeleton's
// meta line, so a rail of label-less cards is not shorter.
it('keeps an empty meta row when the rail reserves the meta line', async () => {
const element = await renderCards(
[card({ id: 'bare', title: 'Bare', contentType: 'movie' })],
true
);
const row = element.querySelector(
'.rail__card .rail__card-meta-row'
);
expect(row).not.toBeNull();
expect(row?.textContent?.trim()).toBe('');
});
});
describe('visible cards', () => {
@@ -155,6 +155,13 @@ export class DashboardRailComponent implements AfterViewInit, OnDestroy {
* the rail label. Falls back to `items().length` when not supplied.
*/
readonly totalCount = input<number | null>(null);
/**
* Keeps the meta line under every cover card even when it is empty. A
* rail that replaces a loading skeleton sets it: the skeleton always
* draws that line, and a rail of cards without labels (movies with no
* known duration) would otherwise come in shorter.
*/
readonly reserveMetaLine = input(false);
private readonly viewport =
viewChild.required<ElementRef<HTMLDivElement>>('viewport');
@@ -8,6 +8,7 @@ import {
import type {
DashboardRailAction,
DashboardRailCard,
DashboardRailLayout,
} from './dashboard-rail.component';
import type { DashboardRailsSettings } from '@iptvnator/shared/interfaces';
@@ -183,6 +184,22 @@ export function shouldShowRecentContentSkeleton(
);
}
/**
* The recent-content skeleton holds the place of the recent rail it waits
* for: Continue Watching (posters) while that rail is still loading with no
* cards, recently watched live (channel cards) otherwise.
*/
export function recentContentSkeletonLayout(
rails: DashboardRecentRailSettings,
input: DashboardRecentContentSkeletonInput
): DashboardRailLayout {
return rails.continueWatching &&
input.continueWatchingLoading &&
input.continueWatchingCount === 0
? 'cover'
: 'channel';
}
export interface DashboardLiveFavoritesSkeletonInput {
readonly globalFavoritesLoading: boolean;
}
@@ -35,6 +35,7 @@
[seeAllLink]="['/workspace/global-recent']"
[seeAllState]="continueWatchingSeeAllState()"
[testId]="'dashboard-continue-watching-rail'"
[reserveMetaLine]="true"
(actionSelected)="onContinueWatchingActionSelected($event)"
/>
}
@@ -58,18 +59,10 @@
"
/>
} @else if (railSkeletons.liveFavorites()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
<lib-dashboard-rail-skeleton
layout="channel"
testId="dashboard-live-favorites-rail-skeleton"
/>
}
@if (
@@ -110,18 +103,10 @@
}
@if (railSkeletons.recentContent()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
<lib-dashboard-rail-skeleton
[layout]="recentContentSkeletonLayout()"
testId="dashboard-recent-content-rail-skeleton"
/>
}
<!-- Sources rail -->
@@ -133,21 +118,14 @@
[seeAllLink]="['/workspace/sources']"
aspectRatio="16 / 9"
[testId]="'dashboard-recent-sources-rail'"
[reserveMetaLine]="true"
(actionSelected)="onSourceActionSelected($event)"
/>
} @else if (railSkeletons.sources()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
<lib-dashboard-rail-skeleton
aspectRatio="16 / 9"
testId="dashboard-recent-sources-rail-skeleton"
/>
}
<!-- Xtream recently-added rail (only relevant if there are Xtream playlists) -->
@@ -162,20 +140,12 @@
[items]="xtreamRecentlyAddedCards()"
[totalCount]="data.xtreamRecentlyAddedItems().length"
[testId]="'dashboard-xtream-recently-added-rail'"
[reserveMetaLine]="true"
/>
} @else if (railSkeletons.xtreamRecentlyAdded()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
<lib-dashboard-rail-skeleton
testId="dashboard-xtream-recently-added-rail-skeleton"
/>
}
<!-- TMDB recommendations rail ("Because you watched"; opt-in; Electron only) -->
@@ -188,20 +158,12 @@
[items]="recommendationCards()"
[totalCount]="recommendationCards().length"
[testId]="'dashboard-tmdb-recommendations-rail'"
[reserveMetaLine]="true"
/>
} @else if (railSkeletons.tmdbRecommendations()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
<lib-dashboard-rail-skeleton
testId="dashboard-tmdb-recommendations-rail-skeleton"
/>
}
<!-- TMDB trending rail (opt-in; Electron only — needs title matching) -->
@@ -211,20 +173,12 @@
[items]="trendingCards()"
[totalCount]="trendingCards().length"
[testId]="'dashboard-tmdb-trending-rail'"
[reserveMetaLine]="true"
/>
} @else if (railSkeletons.tmdbTrending()) {
<section
class="rails-page__skeleton-rail"
aria-hidden="true"
aria-busy="true"
>
<div class="rails-page__skeleton-header"></div>
<div class="rails-page__skeleton-track">
@for (slot of skeletonSlots; track slot) {
<div class="rails-page__skeleton-card"></div>
}
</div>
</section>
<lib-dashboard-rail-skeleton
testId="dashboard-tmdb-trending-rail-skeleton"
/>
}
}
</section>
@@ -44,71 +44,6 @@
overflow: hidden;
}
// ── Loading skeleton ────────────────────────────────────────────────────────
// Geometry mirrors the real hero + rails so the layout doesn't shift when
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
// defined below.
%skeleton-shimmer {
background: linear-gradient(
90deg,
var(--app-widget-bg, #1e2330) 0%,
var(--app-widget-header-bg, #232840) 50%,
var(--app-widget-bg, #1e2330) 100%
);
background-size: 200% 100%;
animation: dashboard-skeleton-shimmer 1.6s linear infinite;
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.05));
}
@keyframes dashboard-skeleton-shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
// ── Rail ghost ──────────────────────────────────────────────────────────────
.rails-page__skeleton-rail {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0 0;
}
.rails-page__skeleton-header {
@extend %skeleton-shimmer;
width: 180px;
height: 22px;
border-radius: 6px;
}
.rails-page__skeleton-track {
display: flex;
gap: var(--cover-gap, 14px);
padding: 4px 0 16px;
overflow: hidden;
}
.rails-page__skeleton-card {
@extend %skeleton-shimmer;
// Mirrors the real .rail__card width (--cover-rail-width) so geometry
// matches when cards arrive — no layout shift.
flex: 0 0 var(--cover-rail-width, 172px);
width: var(--cover-rail-width, 172px);
aspect-ratio: 2 / 3;
border-radius: 12px;
}
@media (prefers-reduced-motion: reduce) {
.rails-page__skeleton-header,
.rails-page__skeleton-card {
animation: none;
background-position: 0 0;
}
}
// ── Responsive ──────────────────────────────────────────────────────────────
@media (max-width: 720px) {
:host {
@@ -121,9 +56,4 @@
.rails-page {
gap: 24px;
}
.rails-page__skeleton-card {
flex: 0 0 min(var(--cover-rail-width, 172px), 140px);
width: min(var(--cover-rail-width, 172px), 140px);
}
}
@@ -6,6 +6,7 @@ import {
buildDashboardRailSeeAllState,
buildDashboardSourceActions,
liveRailTitleKeyForSource,
recentContentSkeletonLayout,
shouldShowLiveFavoritesSkeleton,
shouldShowRecentContentSkeleton,
} from './dashboard-rail.utils';
@@ -693,6 +694,38 @@ describe('recent content skeleton helper', () => {
})
).toBe(false);
});
it('draws posters while Continue Watching is the rail still loading, channel cards otherwise', () => {
const loading = {
continueWatchingCount: 0,
continueWatchingLoading: true,
recentLiveCount: 0,
globalRecentLoading: true,
};
expect(
recentContentSkeletonLayout(
DEFAULT_DASHBOARD_RAILS_SETTINGS,
loading
)
).toBe('cover');
expect(
recentContentSkeletonLayout(
{
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
continueWatching: false,
},
loading
)
).toBe('channel');
// Continue Watching already shows cards; only the live history waits.
expect(
recentContentSkeletonLayout(DEFAULT_DASHBOARD_RAILS_SETTINGS, {
...loading,
continueWatchingCount: 2,
})
).toBe('channel');
});
});
describe('live favorites skeleton helper', () => {
@@ -51,6 +51,7 @@ import {
} from '@iptvnator/workspace/dashboard/data-access';
import { createDashboardRailSkeletons } from './dashboard-rail-skeletons';
import { DashboardRailComponent } from './dashboard-rail.component';
import { DashboardRailSkeletonComponent } from './dashboard-rail-skeleton.component';
import type {
DashboardRailCard,
DashboardRailActionSelection,
@@ -76,9 +77,9 @@ import {
buildDashboardSourceActions,
liveRailTitleKeyForSource,
RAIL_ITEM_LIMIT,
recentContentSkeletonLayout,
shouldShowLiveFavoritesSkeleton,
shouldShowRecentContentSkeleton,
SKELETON_CARDS_PER_RAIL,
SKELETON_RAILS,
} from './dashboard-rail.utils';
import type {
@@ -91,6 +92,7 @@ import type {
imports: [
DashboardHeroComponent,
DashboardRailComponent,
DashboardRailSkeletonComponent,
EmptyStateComponent,
TranslatePipe,
],
@@ -133,7 +135,6 @@ export class WorkspaceDashboardRailsComponent {
readonly xtreamPlaylistCount = this.data.xtreamPlaylistCount;
readonly isElectron = this.runtime.isElectron;
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
readonly skeletonRails = SKELETON_RAILS;
readonly liveRailTitleKeyForSource = liveRailTitleKeyForSource;
readonly dashboardRails = computed(() =>
@@ -173,13 +174,25 @@ export class WorkspaceDashboardRailsComponent {
})
);
private readonly recentContentLoading = computed(() => ({
continueWatchingCount: this.continueWatchingCards().length,
continueWatchingLoading: !this.data.continueWatchingSettled(),
globalRecentLoading: this.data.globalRecentLoading(),
recentLiveCount: this.recentLiveCards().length,
}));
readonly showRecentContentSkeleton = computed(() =>
shouldShowRecentContentSkeleton(this.dashboardRails(), {
continueWatchingCount: this.continueWatchingCards().length,
continueWatchingLoading: !this.data.continueWatchingSettled(),
globalRecentLoading: this.data.globalRecentLoading(),
recentLiveCount: this.recentLiveCards().length,
})
shouldShowRecentContentSkeleton(
this.dashboardRails(),
this.recentContentLoading()
)
);
readonly recentContentSkeletonLayout = computed(() =>
recentContentSkeletonLayout(
this.dashboardRails(),
this.recentContentLoading()
)
);
// The live cards whose rails are enabled; the presenter adds the hero's