mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
1 parent
ea7efdbd9b
commit
7d7d691ce4
34 files changed
+1747
-465
No files matched your search
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-13
@@ -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 {
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
<app-channel-list-loading-state
|
||||
[view]="activeView()"
|
||||
[showEpg]="shouldShowEpg()"
|
||||
[showHeader]="!compact()"
|
||||
/>
|
||||
} @else if (showChannelViews()) {
|
||||
@if (
|
||||
|
||||
+6
-13
@@ -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;
|
||||
}
|
||||
+8
-21
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+58
-26
@@ -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
|
||||
|
||||
+110
-76
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
});
|
||||
});
|
||||
+10
@@ -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';
|
||||
@@ -12,3 +12,4 @@
|
||||
@forward 'detail-view-actions';
|
||||
@forward 'nav-list';
|
||||
@forward 'focus-ring';
|
||||
@forward 'skeleton-shimmer' as skeleton-*;
|
||||
@@ -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>
|
||||
+126
@@ -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;
|
||||
}
|
||||
+91
@@ -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;
|
||||
}
|
||||
|
||||
+26
-72
@@ -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>
|
||||
-70
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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', () => {
|
||||
|
||||
+21
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user