mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
* 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>
109 lines
3.6 KiB
TypeScript
109 lines
3.6 KiB
TypeScript
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),
|
|
};
|
|
}
|