Merge branch 'master' of https://github.com/4gray/iptvnator into claude/perf-dashboard-idle-ticks

# Conflicts:
#	libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.spec.ts
This commit is contained in:
4gray committed 2026-09-30 07:18:32 +02:00
commit 30b9fa9780
52 files changed
+1803 -155

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: dashboard
---
An M3U channel added to favorites right before opening the dashboard now
appears in the favorites rail immediately, instead of only after the next
reload.
@@ -0,0 +1,8 @@
---
type: perf
area: dashboard
---
Live programme progress bars on the dashboard now move without animating or
redrawing the whole page, so a dashboard left open with recent live channels
uses less CPU and GPU.
@@ -0,0 +1,8 @@
---
type: perf
area: electron
---
A minimized or hidden desktop window now lets the system slow down background
timers and animations, while playing video and radio keep running. A video
left playing in a minimized window no longer keeps the display awake.
+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: playback
---
The player's LIVE badge uses a slightly deeper red so its label is easier to read, and the record button now turns red while a recording runs. Both reds stay the same in the light and dark themes.
@@ -0,0 +1,6 @@
---
type: fix
area: settings
---
Parental lock screens are easier to use: keyboard focus is visible on the "N locked" rows and in the Stalker lock list, locked categories stand out in the management dialogs, a wrong PIN shows in red, and the lock dialogs fit on a phone. In the browser version the category dialog no longer shows hide/show controls that do nothing there.
+9
View File
@@ -435,6 +435,15 @@ jobs:
- name: Check i18n drift
run: pnpm run i18n:check
# Node specs for the benchmark and journey harness (about ten
# seconds). They live in the E2E app, which the unit coverage scope
# below treats as out of scope, so this step is not gated by it.
- name: Test the performance harness
run: pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
# A pull request whose changes cannot reach any Tier A test (docs,
# notes, other workflows, website, E2E and mock-server apps, release
# and packaging tooling, a scripts-only package.json edit) skips the
+8 -8
View File
@@ -16,7 +16,7 @@
"executor": "nx:run-commands",
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --test src/performance/*.spec.ts"
"command": "tsx --test \"src/performance/*.spec.ts\""
}
},
"benchmark-m3u-refresh-cancellation": {
@@ -26,7 +26,7 @@
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts"
"command": "playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts"
}
},
"benchmark-m3u-import": {
@@ -36,7 +36,7 @@
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts"
"command": "playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts"
}
},
"benchmark-xtream": {
@@ -46,7 +46,7 @@
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts"
"command": "playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts"
}
},
"benchmark-charset-parse": {
@@ -55,7 +55,7 @@
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --expose-gc --tsconfig tsconfig.json src/performance/charset-parse.benchmark.ts"
"command": "tsx --expose-gc --tsconfig tsconfig.json src/performance/charset-parse.benchmark.ts"
}
},
"journeys": {
@@ -65,7 +65,7 @@
"parallelism": false,
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.journeys.config.ts"
"command": "playwright test --config=playwright.journeys.config.ts"
}
},
"packaged-frame-copy-smoke": {
@@ -78,14 +78,14 @@
],
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts"
"command": "playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts"
}
},
"test-packaged-frame-copy-fixtures": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts"
"command": "tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts"
}
},
"lint": {
@@ -0,0 +1,242 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { buildElectronLaunchArgs } from './electron-test-fixtures';
import { terminateElectronProcess } from './electron-process-termination';
/**
* Launches the built app WITHOUT Playwright and drives it over plain Chrome
* DevTools Protocol sockets.
*
* Playwright sends `Emulation.setFocusEmulationEnabled` to every page it
* attaches to, and Chromium implements focus emulation by raising the page's
* capturer count, which pins the page visible. A test about
* `document.visibilityState` (or anything throttled while hidden) therefore
* cannot run through `launchElectronApp`. Raw `Runtime.evaluate` calls do
* not emulate anything, so this launch behaves like a user's.
*/
export interface UnautomatedElectronApp {
/** Evaluates an async function body in the main process with `electron`. */
evaluateInMain<T>(body: string): Promise<T>;
/** Evaluates an expression in the main window's page. */
evaluateInPage<T>(expression: string): Promise<T>;
close(): Promise<void>;
}
interface CdpSocket {
send(method: string, params?: object): Promise<Record<string, unknown>>;
close(): void;
}
const EXIT_WAIT_MS = 5_000;
/**
* `electron` for main-process evaluation. `process.mainModule` exists only
* when the app entry is CommonJS; a require created from the core `module`
* builtin resolves Electron's built-in module either way.
*/
const MAIN_PROCESS_ELECTRON = `process.getBuiltinModule('node:module').createRequire(process.execPath)('electron')`;
const DEVTOOLS_PATTERN = /DevTools listening on (ws:\/\/[^\s]+)/;
const INSPECTOR_PATTERN = /Debugger listening on (ws:\/\/[^\s]+)/;
const STARTUP_TIMEOUT_MS = 30_000;
async function openCdpSocket(url: string): Promise<CdpSocket> {
const socket = new WebSocket(url);
const pending = new Map<
number,
(message: Record<string, unknown>) => void
>();
let nextId = 0;
await new Promise<void>((resolve, reject) => {
socket.addEventListener('open', () => resolve(), { once: true });
socket.addEventListener(
'error',
() => reject(new Error(`CDP ${url}`)),
{
once: true,
}
);
});
socket.addEventListener('message', (event) => {
const message = JSON.parse(String(event.data)) as Record<
string,
unknown
>;
// Only replies to our own numbered requests settle a promise; events
// and unknown ids are ignored.
const id = message['id'];
if (typeof id !== 'number') return;
const settle = pending.get(id);
if (typeof settle !== 'function') return;
pending.delete(id);
settle(message);
});
return {
send: (method, params = {}) =>
new Promise((resolve) => {
const id = ++nextId;
pending.set(id, resolve);
socket.send(JSON.stringify({ id, method, params }));
}),
close: () => socket.close(),
};
}
function waitForEndpoints(
child: ChildProcess
): Promise<{ devtools: string; inspector: string }> {
return new Promise((resolve, reject) => {
let output = '';
const timer = setTimeout(
() => reject(new Error(`Electron did not start:\n${output}`)),
STARTUP_TIMEOUT_MS
);
child.stderr?.on('data', (chunk: Buffer) => {
output += chunk.toString();
const devtools = output.match(DEVTOOLS_PATTERN)?.[1];
const inspector = output.match(INSPECTOR_PATTERN)?.[1];
if (devtools && inspector) {
clearTimeout(timer);
resolve({ devtools, inspector });
}
});
child.once('exit', (code) => {
clearTimeout(timer);
reject(new Error(`Electron exited (${code}):\n${output}`));
});
});
}
async function findMainPage(devtools: string): Promise<string> {
const origin = new URL(devtools).host;
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
while (Date.now() < deadline) {
const targets = (await (
await fetch(`http://${origin}/json/list`)
).json()) as {
type: string;
url: string;
webSocketDebuggerUrl: string;
}[];
const page = targets.find(
(target) =>
target.type === 'page' && target.url.includes('index.html')
);
if (page) return page.webSocketDebuggerUrl;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error('The main window never loaded its page');
}
async function evaluate<T>(socket: CdpSocket, expression: string): Promise<T> {
const response = await socket.send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
const protocolError = response['error'] as { message?: string } | undefined;
if (protocolError) {
throw new Error(
`CDP Runtime.evaluate: ${protocolError.message ?? 'failed'}`
);
}
const result = response['result'] as {
result?: { value?: T };
exceptionDetails?: { text?: string };
};
if (result?.exceptionDetails) {
throw new Error(result.exceptionDetails.text ?? 'evaluation failed');
}
return result?.result?.value as T;
}
function waitForExit(
child: ChildProcess,
exited: Promise<void>,
timeoutMs: number
): Promise<boolean> {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve(true);
}
let timer: ReturnType<typeof setTimeout> | undefined;
return Promise.race([
exited.then(() => true),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]).finally(() => clearTimeout(timer));
}
/**
* Bounded like `closeElectronApplicationAndConfirmExit`: a stuck Electron
* must neither stall the worker nor keep holding the test profile.
*/
async function stopElectron(
child: ChildProcess,
exited: Promise<void>
): Promise<void> {
let lastKillError: unknown;
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
// Already gone (exited on its own, or during startup): nothing to
// kill. On Windows taskkill throws for a PID that no longer exists.
if (await waitForExit(child, exited, 0)) return;
try {
terminateElectronProcess(child, signal);
} catch (error) {
// The process may have exited between the check and the kill.
lastKillError = error;
}
if (await waitForExit(child, exited, EXIT_WAIT_MS)) return;
}
const killFailure =
lastKillError === undefined
? ''
: ` (last kill: ${String(lastKillError)})`;
throw new Error(
`Electron (pid ${child.pid}) did not exit after SIGTERM and SIGKILL${killFailure}`
);
}
export async function launchUnautomatedElectronApp(
dataDir: string
): Promise<UnautomatedElectronApp> {
// In a Node context the `electron` package resolves to its binary path.
const electronBinaryPath = require('electron') as unknown as string;
const child = spawn(
electronBinaryPath,
buildElectronLaunchArgs(['--remote-debugging-port=0', '--inspect=0']),
{
env: {
...process.env,
ELECTRON_IS_DEV: '0',
IPTVNATOR_E2E_DATA_DIR: dataDir,
NODE_ENV: 'test',
},
stdio: ['ignore', 'ignore', 'pipe'],
}
);
const exited = new Promise<void>((resolve) =>
child.once('exit', () => resolve())
);
try {
const { devtools, inspector } = await waitForEndpoints(child);
const main = await openCdpSocket(inspector);
const page = await openCdpSocket(await findMainPage(devtools));
return {
evaluateInMain: (body) =>
evaluate(
main,
`(async (electron) => { ${body} })(${MAIN_PROCESS_ELECTRON})`
),
evaluateInPage: (expression) => evaluate(page, expression),
close: async () => {
page.close();
main.close();
await stopElectron(child, exited);
},
};
} catch (error) {
// Cleanup must never replace the startup failure that explains the test.
await stopElectron(child, exited).catch((cleanupError: unknown) =>
console.warn('Unautomated Electron cleanup failed:', cleanupError)
);
throw error;
}
}
@@ -88,10 +88,23 @@ test('the performance harness target runs only Node performance specs', () => {
assert.equal(target.options?.['cwd'], 'apps/electron-backend-e2e');
assert.equal(
target.options?.['command'],
'pnpm exec tsx --test src/performance/*.spec.ts'
'tsx --test "src/performance/*.spec.ts"'
);
});
// A nested `pnpm exec` under `pnpm nx` can run from the workspace root instead
// of the target cwd (an older global pnpm with version switching disabled), so
// cwd-relative globs, specs and configs are not found. run-commands already
// puts node_modules/.bin on PATH.
test('electron-backend-e2e command targets call binaries without pnpm exec', () => {
for (const [name, target] of Object.entries(e2eProject.targets)) {
const command = target.options?.['command'];
if (typeof command !== 'string') continue;
assert.doesNotMatch(command, /\bpnpm exec\b/, name);
}
});
test('the web performance build keeps production renderer behavior with profiling source maps', () => {
const build = webProject.targets['build'];
const production = build.configurations?.['production'];
@@ -242,7 +255,7 @@ test('the cancellation benchmark command is pinned to its Playwright test file',
assert.equal(
target.options?.['command'],
'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts'
'playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts'
);
});
@@ -283,7 +296,7 @@ test('the initial M3U import benchmark command is pinned to its Playwright test
assert.equal(
target.options?.['command'],
'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts'
'playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts'
);
});
@@ -346,7 +346,7 @@ describe('Xtream Playwright and Nx plumbing', () => {
assert.equal(target?.['parallelism'], false);
assert.equal(
(target?.['options'] as Record<string, unknown>)?.['command'],
'pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts'
'playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts'
);
assert.equal(
(entrypoint.match(/runXtreamBenchmark\(\)/g) ?? []).length,
@@ -0,0 +1,147 @@
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { expect, test } from './electron-test-fixtures';
import {
launchUnautomatedElectronApp,
type UnautomatedElectronApp,
} from './electron-unautomated-launch';
// Linux CI runs Electron under xvfb without a window manager, so a minimize
// request never takes effect there (see window-controls.e2e.ts).
const isWindowManagerlessCi =
process.platform === 'linux' && !!process.env['CI'];
const videoFixtureUrl = pathToFileURL(
join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm')
).href;
const visibility = (app: UnautomatedElectronApp) =>
app.evaluateInPage<string>('document.visibilityState');
const setWindowState = (
app: UnautomatedElectronApp,
action: 'hide' | 'show' | 'minimize' | 'restore'
) =>
app.evaluateInMain(
`electron.BrowserWindow.getAllWindows()[0].${action}();`
);
/**
* The app creates its window with `show: false` and shows it on
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
*/
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
await expect
.poll(
() =>
app.evaluateInMain<boolean>(
'return electron.BrowserWindow.getAllWindows()[0]?.isVisible() ?? false;'
),
{ timeout: 30_000 }
)
.toBe(true);
await expect.poll(() => visibility(app)).toBe('visible');
}
/**
* The renderer must see a hidden or minimized window as hidden: idle timers
* pause on `visibilitychange`, and the playback keep-awake gate releases the
* display for a minimized window. A main window created with
* `backgroundThrottling: false` keeps reporting `visible` in both cases.
*
* Launched without Playwright: its focus emulation pins every page it
* attaches to as visible (see `electron-unautomated-launch.ts`).
*/
test.describe('Main window visibility', () => {
test('@electron reports a hidden window as hidden and a shown one as visible', async ({
dataDir,
}) => {
const app = await launchUnautomatedElectronApp(dataDir);
try {
await waitUntilShown(app);
await setWindowState(app, 'hide');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('hidden');
await setWindowState(app, 'show');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('visible');
} finally {
await app.close();
}
});
test('@electron reports a minimized window as hidden until it is restored', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchUnautomatedElectronApp(dataDir);
try {
await waitUntilShown(app);
await setWindowState(app, 'minimize');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('hidden');
await setWindowState(app, 'restore');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('visible');
} finally {
await app.close();
}
});
test('@electron releases the playback display lock while the window is hidden', async ({
dataDir,
}) => {
const app = await launchUnautomatedElectronApp(dataDir);
try {
await waitUntilShown(app);
// Record the display-sleep blockers the keep-awake service holds.
await app.evaluateInMain(`
const blocker = electron.powerSaveBlocker;
const active = (globalThis.__e2eDisplayBlockers = new Set());
const start = blocker.start.bind(blocker);
const stop = blocker.stop.bind(blocker);
blocker.start = (type) => {
const id = start(type);
if (type === 'prevent-display-sleep') active.add(id);
return id;
};
blocker.stop = (id) => {
active.delete(id);
return stop(id);
};
`);
const heldBlockers = () =>
app.evaluateInMain<number>(
'return globalThis.__e2eDisplayBlockers.size;'
);
await app.evaluateInPage(`(() => {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.src = ${JSON.stringify(videoFixtureUrl)};
document.body.append(video);
return video.play().then(() => true);
})()`);
await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(1);
await setWindowState(app, 'hide');
await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(0);
await setWindowState(app, 'show');
await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(1);
} finally {
await app.close();
}
});
});
+1 -1
View File
@@ -157,7 +157,7 @@ describe('Electron app security helpers', () => {
nodeIntegration: false,
sandbox: true,
webSecurity: true,
backgroundThrottling: false,
backgroundThrottling: true,
})
);
});
+5 -1
View File
@@ -126,7 +126,11 @@ export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructor
nodeIntegration: false,
sandbox: !frameCopyExperiment,
webSecurity: true,
backgroundThrottling: false,
// Chromium's default. A hidden or minimized window must report
// `document.hidden` so idle timers can pause and the playback
// keep-awake gate can release the display; Chromium itself keeps
// audible media and picture-in-picture running at full rate.
backgroundThrottling: true,
preload: join(__dirname, 'main.preload.js'),
};
}
+412
View File
@@ -0,0 +1,412 @@
import type { Locator, Page } from '@playwright/test';
import { join } from 'path';
import { expect, test } from './fixtures';
import { pressTab, surfaceContrast } from './e2e-helpers';
import {
addStalkerPortal,
addXtreamPortal,
interceptPwaProviderRequests,
resetPwaMockServers,
} from './sources-pwa.helpers';
/**
* Parental lock UI on app tokens (PWA).
*
* The lock rows, dialogs and PIN error once used Material system colours the
* theme never declares, so every one of them resolved to nothing: no focus
* ring after the outline reset, a dashed border and list border with no
* colour, a PIN error in body text, and dialogs 440–480px wide on a 375px
* phone. These tests pin the visible result in both themes:
*
* 1. Tabbing to each "N locked" row and each Stalker lock row shows a
* 2px ring with >= 3:1 contrast against the row and its surface.
* 2. The rail rows' dashed border and the lock list's border are painted.
* 3. The PIN error is a red with >= 4.5:1 contrast on the dialog.
* 4. The lock dialogs fit a 375px viewport without sideways overflow.
* 5. In the PWA the Xtream dialog shows lock controls only — hide/show is
* Electron-only — and its clear-search button has a name.
*
* Tag: @parental — run only this spec with:
* pnpm nx run web-e2e:e2e-ci--src/parental-lock-ui.e2e.ts
*/
const M3U_FIXTURE = join(__dirname, 'fixtures/test.m3u');
const PIN = '2468';
const PHONE = { width: 375, height: 812 };
const THEMES = ['light', 'dark'] as const;
async function setTheme(page: Page, theme: (typeof THEMES)[number]) {
await page.evaluate(
(dark) => document.body.classList.toggle('dark-theme', dark),
theme === 'dark'
);
}
async function enableParentalLock(page: Page): Promise<void> {
await page.locator('a[href$="/workspace/settings"]').click();
await page.waitForURL(/\/workspace\/settings\/general$/);
await page.locator('[data-test-id="settings-section-parental"]').click();
await page.waitForURL(/\/workspace\/settings\/parental$/);
const toggle = page.locator(
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
);
await toggle.click();
await page.locator('[data-test-id="parental-lock-pin"]').fill(PIN);
await page.locator('[data-test-id="parental-lock-pin-confirm"]').fill(PIN);
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
}
/** Answers the unlock prompt that a PIN-gated action opens. */
async function enterPin(page: Page, pin = PIN): Promise<void> {
const input = page.locator('[data-test-id="parental-lock-pin"]');
await expect(input).toBeVisible();
await input.fill(pin);
await page.locator('[data-test-id="parental-lock-pin-submit"]').click();
}
/**
* The dialog opened for `component`, once it holds focus: MatDialog moves
* focus to its first tabbable element only after the open animation, which
* would otherwise pull focus away from a row the test has just tabbed to.
*/
async function openedDialog(page: Page, component: string): Promise<Locator> {
const dialog = page.locator('mat-dialog-container', {
has: page.locator(component),
});
await expect
.poll(() =>
dialog.evaluate((el) => el.contains(document.activeElement))
)
.toBe(true);
return dialog;
}
/**
* WCAG contrast of one colour property against what is painted behind the
* element: white, each ancestor's background and, with `ownFill`, the
* element's own background (the approach of `surfaceContrast`).
*/
async function contrastBehind(
locator: Locator,
property: 'color' | 'outlineColor',
ownFill: boolean
): Promise<number> {
return locator.evaluate(
(element, [property, ownFill]) => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas is required');
const paint = (color: string) => {
context.fillStyle = color;
context.fillRect(0, 0, 1, 1);
};
const luminance = () =>
Array.from(context.getImageData(0, 0, 1, 1).data)
.slice(0, 3)
.map((value) => {
const channel = value / 255;
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, channel, index) =>
sum + channel * [0.2126, 0.7152, 0.0722][index],
0
);
const layers: Element[] = ownFill ? [element] : [];
for (let e = element.parentElement; e; e = e.parentElement) {
layers.unshift(e);
}
paint('white');
layers.forEach((layer) =>
paint(getComputedStyle(layer).backgroundColor)
);
const behind = luminance();
paint(getComputedStyle(element)[property]);
const front = luminance();
return (
(Math.max(front, behind) + 0.05) /
(Math.min(front, behind) + 0.05)
);
},
[property, ownFill] as const
);
}
/** Focus `from` and Tab (as that browser's user does) until `target` has focus. */
async function tabTo(
page: Page,
browserName: string,
from: Locator,
target: Locator,
maxTabs = 1
): Promise<void> {
await from.focus();
for (let i = 0; i < maxTabs; i++) {
await pressTab(page, browserName);
if (await target.evaluate((el) => el === document.activeElement)) {
break;
}
}
await expect(target).toBeFocused();
}
/**
* The ring sits inside the row (offset -2px), so it must stand out from the
* row's own fill and from the surface around the row.
*/
async function expectFocusRing(row: Locator): Promise<void> {
await expect(row).toBeFocused();
await expect(row).toHaveCSS('outline-style', 'solid');
await expect(row).toHaveCSS('outline-width', '2px');
const inside = await contrastBehind(row, 'outlineColor', true);
const outside = await contrastBehind(row, 'outlineColor', false);
expect(Math.min(inside, outside)).toBeGreaterThanOrEqual(3);
}
/** Colour and fill of a lock toggle, to tell locked from unlocked. */
async function toggleLook(toggle: Locator): Promise<string> {
return toggle.evaluate((el) => {
const style = getComputedStyle(el);
return `${style.color} ${style.backgroundColor}`;
});
}
async function expectPaintedBorder(
locator: Locator,
style: 'dashed' | 'solid'
): Promise<void> {
await expect(locator).toHaveCSS('border-top-style', style);
const { border } = await surfaceContrast(locator);
// 1 means the border colour is transparent against its own fill.
expect(border).toBeGreaterThan(1.15);
}
async function expectDialogFitsPhone(
page: Page,
dialog: Locator
): Promise<void> {
await page.setViewportSize(PHONE);
await expect
.poll(async () => {
const box = await dialog.boundingBox();
return box ? box.x >= 0 && box.x + box.width <= PHONE.width : false;
})
.toBe(true);
// A min-width on the content overflows the capped panel without
// widening the panel or the document, so measure the blocks inside.
const overflow = await dialog.evaluate((panel) => {
const edge = panel.getBoundingClientRect().right;
const blocks = panel.querySelectorAll(
'mat-dialog-content, mat-dialog-actions, .header-actions, ' +
'.search-inline, .categories-list'
);
return Math.max(
...Array.from(
blocks,
(block) => block.getBoundingClientRect().right - edge
)
);
});
expect(overflow).toBeLessThanOrEqual(0.5);
expect(
await page.evaluate(() => document.documentElement.scrollWidth)
).toBeLessThanOrEqual(PHONE.width);
}
test.describe('Parental lock UI', () => {
test.beforeEach(async ({ page, request }) => {
await resetPwaMockServers(request);
await interceptPwaProviderRequests(page);
await page.goto('/');
});
test('@parental @xtream PWA dialog, locked-categories row and PIN error', async ({
page,
browserName,
}) => {
test.slow();
await addXtreamPortal(page, 'Lock UI Portal');
const portalUrl = page.url();
await enableParentalLock(page);
// A reload starts locked: the unlock lives in memory only.
await page.goto(portalUrl);
const manage = page.locator(
'[data-test-id="context-manage-categories"]'
);
await expect(manage).toBeEnabled({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(
page,
'app-category-management-dialog'
);
const toggles = dialog.locator('[data-test-id^="category-lock-"]');
await expect(toggles.first()).toBeVisible();
// PWA: locks only, no hide/show selection controls.
await expect(
dialog.locator('[data-test-id="category-locked-count"]')
).toBeVisible();
await expect(dialog.locator('.bulk-actions')).toHaveCount(0);
await expect(dialog.locator('mat-checkbox')).toHaveCount(0);
await dialog.locator('.search-inline input').fill('a');
await dialog.getByRole('button', { name: 'Clear search' }).click();
await expect(dialog.locator('.search-inline input')).toHaveValue('');
// Locked reads as a state, not only as another glyph.
await toggles.first().click();
await expect(toggles.first()).toHaveAttribute('aria-pressed', 'true');
expect(await toggleLook(toggles.first())).not.toBe(
await toggleLook(toggles.nth(1))
);
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();
await page.locator('[data-test-id="header-parental-lock"]').click();
const lockedRow = page.locator(
'[data-test-id="context-locked-categories"]'
);
await expect(lockedRow).toBeVisible();
await tabTo(
page,
browserName,
page.locator('.context-panel .category-item').last(),
lockedRow
);
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(lockedRow);
await expectPaintedBorder(lockedRow, 'dashed');
}
// A wrong PIN reads as an error in both themes.
await lockedRow.click();
await enterPin(page, '0000');
const error = page.locator('[data-test-id="parental-lock-pin-error"]');
await expect(error).toBeVisible();
for (const theme of THEMES) {
await setTheme(page, theme);
const [r, g, b] = await error.evaluate((el) =>
(getComputedStyle(el).color.match(/\d+/g) ?? []).map(Number)
);
expect(r - Math.max(g, b)).toBeGreaterThan(50);
expect(await contrastBehind(error, 'color', true)).toBeGreaterThan(
4.5
);
}
await enterPin(page);
await expect(lockedRow).toBeHidden();
await manage.click();
await expect(toggles.first()).toBeVisible();
await expectDialogFitsPhone(page, dialog);
});
test('@parental @stalker lock dialog rows, list border and phone width', async ({
page,
browserName,
}) => {
test.slow();
await addStalkerPortal(page, 'Lock UI Stalker');
const portalUrl = page.url();
await enableParentalLock(page);
await page.goto(portalUrl);
const manage = page.locator(
'[data-test-id="context-manage-categories"]'
);
await expect(manage).toBeEnabled({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(
page,
'app-stalker-category-lock-dialog'
);
const rows = dialog.locator('.category-item');
await expect(rows.first()).toBeVisible();
const search = dialog.locator('.search-inline input');
await tabTo(page, browserName, search, rows.first());
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(rows.first());
await expectPaintedBorder(
dialog.locator('.categories-list'),
'solid'
);
}
// The next row takes the ring over when Tab moves on.
await pressTab(page, browserName);
await expectFocusRing(rows.nth(1));
await search.fill('a');
await dialog.getByRole('button', { name: 'Clear search' }).click();
await expect(search).toHaveValue('');
await expectDialogFitsPhone(page, dialog);
});
test('@parental @m3u locked-groups row', async ({ page, browserName }) => {
test.slow();
// The fixture's placeholder media hosts stay offline.
await page.route(
/^https?:\/\/(?:channel\.icons\.url|example\.channels|xml-url)\//,
(route) => route.abort()
);
await page.getByRole('button', { name: 'Add playlist' }).click();
const addDialog = page.locator('mat-dialog-container');
await addDialog.getByRole('radio', { name: /M3U file/i }).click();
await page.setInputFiles('input[type="file"]', M3U_FIXTURE);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
addDialog
.getByRole('button', { name: 'Add playlist', exact: true })
.click(),
]);
const groupsUrl = page.url().replace(/\/all$/, '/groups');
await enableParentalLock(page);
await page.goto(groupsUrl);
const manage = page.locator('.groups-nav-action--manage');
await expect(manage).toBeVisible({ timeout: 15_000 });
await manage.click();
await enterPin(page);
const dialog = await openedDialog(page, 'app-group-management-dialog');
const toggle = dialog.locator('[data-test-id="group-lock-News"]');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(await toggleLook(toggle)).not.toBe(
await toggleLook(
dialog.locator('[data-test-id="group-lock-Sport"]')
)
);
await dialog.getByRole('button', { name: 'Save' }).click();
await expect(dialog).toBeHidden();
await page.locator('[data-test-id="header-parental-lock"]').click();
const lockedRow = page.locator('[data-test-id="groups-locked-row"]');
await expect(lockedRow).toBeVisible();
await tabTo(
page,
browserName,
page.locator('.groups-nav-list .nav-item').last(),
lockedRow
);
for (const theme of THEMES) {
await setTheme(page, theme);
await expectFocusRing(lockedRow);
await expectPaintedBorder(lockedRow, 'dashed');
}
});
});
+4
View File
@@ -13,6 +13,10 @@ explicit hardened `webPreferences` object:
frame-copy experiment is the one path that disables the renderer sandbox
(`contextIsolation`/`nodeIntegration` stay hardened regardless)
- `webSecurity: true`
- `backgroundThrottling: true` — Chromium's default, kept explicit: a hidden
or minimized window must report `document.hidden` so idle timers pause and
the playback keep-awake gate works (see the player controls contract).
Audible media and picture-in-picture keep running at full rate regardless.
- `preload: apps/electron-backend/src/app/api/main.preload.ts`
Renderer code must use the preload bridge exposed as `window.electron`.
+3 -1
View File
@@ -89,7 +89,9 @@ app text. Loader/stall and transient feedback overlays own a light foreground
and dark scrim because they cover video. Video viewports remain black in both
themes and fullscreen; frame-copy and built-in shared controls keep their
light-on-dark overlay palette — the fixed `--pc-*` token set of the shared
dock (accent blue, cyan, violet and a light text ramp), never the app theme.
dock (accent blue, cyan, violet, the `--pc-live` / `--pc-danger` reds and a
light text ramp), never the app theme. The overlay styles in
`player-controls/` never read a `--mat-sys-*` token.
EPG timeline, list, empty states and programme details use the library-local
`libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators,
+8 -1
View File
@@ -374,7 +374,8 @@ so a cancelled or refused PIN leaves it showing the real state.
the PIN. The Xtream dialog loads its candidates through the
capability-selected data source (`IXtreamDataSource.getAllCategories`,
which the PWA source answers from its session cache or the API), so PWA
users can set locks too; the hide/show checkboxes remain Electron-only.
users can set locks too; the hide/show checkboxes, the selection count
and the Select/Deselect-all actions remain Electron-only.
A deliberate non-choice: no dedicated lock button in the rail header
(the header already carries search, sort and manage, and the lock is a
category-management concern) and no keyword-based "adult" recognition
@@ -415,6 +416,12 @@ so a cancelled or refused PIN leaves it showing the real state.
so a child cannot switch the idle relock off for the next unlock.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
- Styling uses app tokens only (the theme declares no `--mat-sys-*`); the
PIN error and the Stalker adult chip use a local red per theme. Keyboard
focus on a lock row is a 2px inset `--app-selection-color` ring, and the
lock dialogs open with `maxWidth: 'calc(100vw - 32px)'` and no content
min-width, so they fit a 375px phone. `apps/web-e2e/src/parental-lock-ui.e2e.ts`
checks rings, borders, the PIN error and phone width in both themes.
## Startup footprint
@@ -309,8 +309,11 @@ of its own: a timeline row above a three-column control row, sitting
directly on the video over the bottom scrim. The palette is a fixed set of
`--pc-*` custom properties on `:host` — accent blue `#4f8eff` for the
primary action and progress, cyan `#5cd6ff` for "something is on", violet
`#b599ff` for "a value was changed", and the `#e7ecf3` / `#9aa3b2` /
`#6b7384` text ramp. They are literal on purpose: the overlay is
`#b599ff` for "a value was changed", the `#e7ecf3` / `#9aa3b2` /
`#6b7384` text ramp, and two reds: `--pc-live` `#d32f2f` fills the LIVE
badge (white label 5.0:1), and `--pc-danger` `#ff5252` colours the active
record glyph and the recording status (6.2:1 on the glass over a black
frame). They are literal on purpose: the overlay is
theme-independent (see the UI guidelines' player theme boundary), and the
app's `--app-selection-color` is a different blue that would fight the video.
@@ -1783,6 +1786,9 @@ capture listeners because media events do not bubble. Release listeners also
attach to the tracked element: Chromium's pause after DOM removal never reaches
the document. A playing video holds a display-sleep lock only while the document
is visible or that video is in picture-in-picture, which survives minimization.
In Electron this relies on the main window keeping Chromium background
throttling on: with `backgroundThrottling: false` a minimized window keeps
reporting `visible`, so the gate would never release the display.
Electron uses main-process `powerSaveBlocker` through
`window.electron.setPlaybackKeepAwake`. The renderer vote clears on reload,
+7
View File
@@ -840,6 +840,13 @@ the documented 120 s default. Failing to ping never invalidates the session —
it only affects the portal's admin-panel "online" reporting — so ping failures
are logged and never retried or escalated.
The periodic ping ticks in a dedicated worker (`createBackgroundInterval`,
an inline blob worker allowed by the renderer CSP's `worker-src 'self' blob:`).
After five minutes hidden and silent, Chromium wakes page timers at most once
per minute, which would halve a 30 s cadence while the window is minimized;
worker timers are not subject to that page throttling. Where no worker can
start, the controller falls back to a page `setInterval`.
## Request Transport and `cmd` Encoding
Requests to an unreachable portal are short-circuited by the main process' host
+6 -1
View File
@@ -264,7 +264,12 @@ target `master` and for `master` pushes (dispatch it with
journey spec against the Xtream mock: J1 launch, then J2 open-source (a
second set of launches, each followed by the click on the portal card), both
written to the same summary file; its probe specs run with
`pnpm nx run electron-backend-e2e:test-performance-harness`. The contract, what
`pnpm nx run electron-backend-e2e:test-performance-harness`, which CI runs in
the `Unit Tests and Typechecks` job of `ci.yml` on every run. The
`electron-backend-e2e` command targets call `tsx` and `playwright` directly,
not through `pnpm exec`: under `pnpm nx`, a nested `pnpm exec` can run from the
workspace root instead of the target `cwd` and miss cwd-relative specs, globs
and configs. The contract, what
counts and how to add a counter or a journey are in the
[performance journeys](performance-journeys.md) document.
+9
View File
@@ -89,6 +89,15 @@ Electron can exit before cleanup begins; Playwright disposes its dispatcher,
so calling `electronApp.process()` at that point can throw even after a clean
exit. The retained handle still provides the actual exit code and signal.
Playwright sends `Emulation.setFocusEmulationEnabled` to every page it attaches
to, and Chromium implements focus emulation by raising the page's capturer
count. A window launched through `launchElectronApp` therefore always reports
`document.visibilityState === 'visible'` and is never background-throttled,
even when hidden or minimized. Tests of hidden-window behavior launch through
`apps/electron-backend-e2e/src/electron-unautomated-launch.ts`, which spawns
the app without Playwright and evaluates over raw CDP sockets
(`window-visibility.e2e.ts` is the example).
The Linux portable build uploads `packaged-frame-copy-smoke` reports and traces
even when the smoke fails. Check the paused-frame screenshot and trace before
classifying a zero rendered-frame signal as an infrastructure flake. A zero
@@ -0,0 +1,116 @@
import { createBackgroundInterval } from './background-interval';
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readonly posted: unknown[] = [];
terminated = false;
constructor(readonly url: string) {
FakeWorker.instances.push(this);
}
postMessage(message: unknown): void {
this.posted.push(message);
}
terminate(): void {
this.terminated = true;
}
tick(): void {
this.onmessage?.({ data: 0 } as MessageEvent);
}
}
describe('createBackgroundInterval', () => {
const scope = globalThis as unknown as {
Worker?: unknown;
URL: typeof URL & {
createObjectURL?: (blob: Blob) => string;
revokeObjectURL?: (url: string) => void;
};
};
const originalWorker = scope.Worker;
const originalCreate = scope.URL.createObjectURL;
const originalRevoke = scope.URL.revokeObjectURL;
beforeEach(() => {
jest.useFakeTimers();
FakeWorker.instances = [];
scope.URL.createObjectURL = jest.fn(() => 'blob:ticker');
scope.URL.revokeObjectURL = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
scope.Worker = originalWorker;
scope.URL.createObjectURL = originalCreate;
scope.URL.revokeObjectURL = originalRevoke;
});
it('falls back to a page interval where Worker is unavailable', () => {
delete scope.Worker;
const callback = jest.fn();
const stop = createBackgroundInterval(callback, 30_000);
jest.advanceTimersByTime(60_000);
expect(callback).toHaveBeenCalledTimes(2);
stop();
jest.advanceTimersByTime(60_000);
expect(callback).toHaveBeenCalledTimes(2);
});
it('ticks from a worker and schedules no page timer', () => {
scope.Worker = FakeWorker;
const callback = jest.fn();
const stop = createBackgroundInterval(callback, 30_000);
const [worker] = FakeWorker.instances;
expect(worker.url).toBe('blob:ticker');
expect(worker.posted).toEqual([30_000]);
expect(jest.getTimerCount()).toBe(0);
worker.tick();
worker.tick();
expect(callback).toHaveBeenCalledTimes(2);
stop();
expect(worker.terminated).toBe(true);
expect(scope.URL.revokeObjectURL).toHaveBeenCalledWith('blob:ticker');
});
it('falls back to a page interval when the worker fails to start', () => {
scope.Worker = FakeWorker;
const callback = jest.fn();
const stop = createBackgroundInterval(callback, 30_000);
const [worker] = FakeWorker.instances;
worker.onerror?.(new Event('error'));
expect(worker.terminated).toBe(true);
jest.advanceTimersByTime(30_000);
expect(callback).toHaveBeenCalledTimes(1);
stop();
jest.advanceTimersByTime(60_000);
expect(callback).toHaveBeenCalledTimes(1);
});
it('stays stopped when a worker error arrives after stop', () => {
scope.Worker = FakeWorker;
const callback = jest.fn();
const stop = createBackgroundInterval(callback, 30_000);
const [worker] = FakeWorker.instances;
const lateError = worker.onerror;
stop();
lateError?.(new Event('error'));
jest.advanceTimersByTime(90_000);
expect(callback).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
});
@@ -0,0 +1,77 @@
/**
* A repeating timer that keeps its cadence while the page is hidden.
*
* Chromium throttles timers on a hidden page's main thread, and after five
* minutes hidden and silent it wakes them at most once per minute. Timers in
* a dedicated worker are not subject to that page throttling, so the tick
* runs there and only the callback is posted back. Without `Worker` (tests,
* or a worker that cannot start) this falls back to a plain `setInterval`.
*
* The worker is an inline blob, allowed by the renderer CSP
* (`worker-src 'self' blob:`), so no bundler entry is needed.
*/
const TICKER_SOURCE = `
let timer;
onmessage = (event) => {
clearInterval(timer);
if (event.data > 0) timer = setInterval(() => postMessage(0), event.data);
};
`;
/** Starts calling `callback` every `periodMs`; returns the stop function. */
export function createBackgroundInterval(
callback: () => void,
periodMs: number
): () => void {
let fallback: ReturnType<typeof setInterval> | undefined;
let stopped = false;
const startFallback = () => {
// A worker error that arrives after stop() must not revive the tick.
if (stopped) return;
fallback ??= setInterval(callback, periodMs);
};
const ticker = startTickerWorker();
const stopWorker = () => {
ticker?.worker.terminate();
if (ticker) URL.revokeObjectURL(ticker.url);
};
if (ticker) {
ticker.worker.onmessage = () => callback();
// A worker that cannot load its script (a stricter CSP, say) fails
// asynchronously; never leave the caller without a tick.
ticker.worker.onerror = () => {
stopWorker();
startFallback();
};
ticker.worker.postMessage(periodMs);
} else {
startFallback();
}
return () => {
stopped = true;
if (ticker) {
ticker.worker.onmessage = null;
ticker.worker.onerror = null;
}
stopWorker();
if (fallback !== undefined) clearInterval(fallback);
};
}
function startTickerWorker(): { worker: Worker; url: string } | null {
if (
typeof Worker === 'undefined' ||
typeof URL?.createObjectURL !== 'function'
) {
return null;
}
const url = URL.createObjectURL(
new Blob([TICKER_SOURCE], { type: 'text/javascript' })
);
try {
return { worker: new Worker(url), url };
} catch {
URL.revokeObjectURL(url);
return null;
}
}
@@ -3,6 +3,7 @@ import {
type Playlist,
} from '@iptvnator/shared/interfaces';
import type { createLogger } from '@iptvnator/portal/shared/util';
import { createBackgroundInterval } from './background-interval';
/**
* The portal expects `get_events` every `watchdog_timeout` seconds — 120 by
@@ -30,13 +31,16 @@ export interface StalkerWatchdogDeps {
* Reads the persisted row for a playlist. The row is the source of truth
* for the configuration a ping authenticates as; see `resolvePlaylist`.
*/
readPersistedPlaylist: (playlistId: string) => Promise<Playlist | undefined>;
readPersistedPlaylist: (
playlistId: string
) => Promise<Playlist | undefined>;
logger: ReturnType<typeof createLogger>;
}
interface WatchdogTimers {
timeslotTimeout?: ReturnType<typeof setTimeout>;
interval?: ReturnType<typeof setInterval>;
/** Stops the periodic ping (see `createBackgroundInterval`). */
stopInterval?: () => void;
}
/**
@@ -98,7 +102,9 @@ export class StalkerWatchdogController {
* while persistence is pending (or failed), and pinging that would stop
* or misdirect the keepalive.
*/
registerPlaylistDecorator(decorator: (playlist: Playlist) => Playlist): void {
registerPlaylistDecorator(
decorator: (playlist: Playlist) => Playlist
): void {
this.playlistDecorator = decorator;
}
@@ -158,8 +164,8 @@ export class StalkerWatchdogController {
if (timers.timeslotTimeout) {
clearTimeout(timers.timeslotTimeout);
}
if (timers.interval) {
clearInterval(timers.interval);
if (timers.stopInterval) {
timers.stopInterval();
}
}
@@ -185,7 +191,10 @@ export class StalkerWatchdogController {
return;
}
current.timeslotTimeout = undefined;
current.interval = setInterval(() => {
// A worker-driven tick: a portal whose watchdog_timeout is below
// Chromium's one-minute wake-up limit for hidden pages must still
// see get_events on time while the window is minimized.
current.stopInterval = createBackgroundInterval(() => {
void this.sendPing(playlistId, '0');
}, periodSeconds * 1000);
};
@@ -282,7 +291,11 @@ function normalizeTiming(timing: {
MIN_PERIOD_SECONDS,
MAX_PERIOD_SECONDS
);
const timeslot = clamp(Math.floor(timing.timeslotSeconds ?? 0), 0, period - 1);
const timeslot = clamp(
Math.floor(timing.timeslotSeconds ?? 0),
0,
period - 1
);
return { periodSeconds: period, timeslotSeconds: timeslot };
}
@@ -47,6 +47,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -1,3 +1,13 @@
// App tokens only: the theme declares no Material system colours. The
// adult-chip red is local, with a value per theme (>= 4.5:1 on its tint).
:host {
--lock-dialog-error-color: #b3261e;
}
:host-context(.dark-theme) {
--lock-dialog-error-color: #ffb4ab;
}
.mat-mdc-dialog-content {
display: flex;
flex-direction: column;
@@ -7,7 +17,7 @@
.lock-dialog__description {
margin: 0;
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
}
.header-actions {
@@ -20,15 +30,16 @@
.selection-info {
font-size: 0.8125rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
strong {
color: var(--mat-sys-primary);
color: var(--app-heading-color);
}
}
.bulk-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
button {
@@ -43,19 +54,19 @@
padding: 0 10px;
min-height: 36px;
border-radius: 10px;
border: 1px solid var(--app-search-border, var(--mat-sys-outline-variant));
background: var(--app-search-bg, var(--mat-sys-surface-container));
border: 1px solid var(--app-search-border);
background: var(--app-search-bg);
cursor: text;
&:focus-within {
border-color: var(--mat-sys-primary);
border-color: var(--app-selection-color);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
}
input {
@@ -63,7 +74,7 @@
border: none;
outline: none;
background: transparent;
color: var(--mat-sys-on-surface);
color: var(--app-heading-color);
font: inherit;
font-size: 0.84rem;
min-width: 0;
@@ -92,7 +103,7 @@
min-height: 200px;
max-height: 50vh;
overflow-y: auto;
border: 1px solid var(--mat-sys-outline-variant);
border: 1px solid var(--app-separator);
border-radius: 8px;
}
@@ -105,29 +116,36 @@
min-height: 32px;
border: none;
background: transparent;
color: var(--mat-sys-on-surface);
color: var(--app-heading-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover {
background-color: var(--mat-sys-surface-container);
background-color: var(--app-card-hover-bg);
}
// The global reset strips every button outline; an inset ring keeps
// the focused row visible inside the list's clipped scroll box.
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
&:not(:last-child) {
border-bottom: 1px solid var(--mat-sys-surface-container);
border-bottom: 1px solid var(--app-widget-header-border);
}
&__icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
}
&--locked .category-item__icon {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
}
}
@@ -144,18 +162,22 @@
font-weight: 600;
padding: 1px 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--mat-sys-error) 16%, transparent);
color: var(--mat-sys-error);
background: color-mix(
in srgb,
var(--lock-dialog-error-color) 16%,
transparent
);
color: var(--lock-dialog-error-color);
}
.empty-message {
padding: 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font-size: 0.8125rem;
}
// No min-width: the opener caps the panel at the viewport on a phone.
:host ::ng-deep .mat-mdc-dialog-content {
min-width: 440px;
max-width: 600px;
}
@@ -65,4 +65,16 @@ describe('StalkerCategoryLockDialogComponent', () => {
expect(dialogRef.close).toHaveBeenCalledWith(false);
expect(parentalLock.setStalkerLocks).not.toHaveBeenCalled();
});
it('labels the clear-search button', () => {
component.searchTerm.set('new');
fixture.detectChanges();
const clear: HTMLButtonElement = fixture.nativeElement.querySelector(
'.search-inline__clear'
);
expect(clear.getAttribute('aria-label')).toBe(
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH'
);
});
});
@@ -7,48 +7,66 @@
<p>{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}</p>
</div>
} @else {
<div class="header-actions">
<div class="selection-info">
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> / {{ totalCount() }}
@if (showLocks()) {
<span class="selection-info__locks">
· {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
<!-- Hide/show is Electron-only: the PWA lists categories for their
lock toggles alone, so it shows no selection count or bulk
selection actions. -->
@if (supportsVisibility || showLocks()) {
<div class="header-actions">
<div class="selection-info">
@if (supportsVisibility) {
{{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}:
<strong>{{ selectedCount() }}</strong> /
{{ totalCount() }}
}
@if (showLocks()) {
<span
class="selection-info__locks"
data-test-id="category-locked-count"
>
@if (supportsVisibility) {
·
}
{{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}:
<strong>{{ lockedCount() }}</strong>
</span>
}
</div>
@if (supportsVisibility) {
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="
isSaving() || filteredSelectedCount() === 0
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
}
</div>
<div class="bulk-actions">
<button
mat-stroked-button
(click)="selectAll()"
[disabled]="
isSaving() ||
filteredCategories().length === 0 ||
allFilteredSelected()
"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.SELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.SELECT_ALL'
) | translate
}}
</button>
<button
mat-stroked-button
(click)="deselectAll()"
[disabled]="isSaving() || filteredSelectedCount() === 0"
>
{{
(searchTerm()
? 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_FILTERED'
: 'XTREAM.CATEGORY_MANAGEMENT.DESELECT_ALL'
) | translate
}}
</button>
</div>
</div>
}
<label class="search-inline">
<mat-icon class="search-inline__icon">search</mat-icon>
@@ -66,6 +84,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -155,7 +155,6 @@
:host ::ng-deep {
.mat-mdc-dialog-content {
min-width: 480px;
max-width: 600px;
max-height: 85vh !important;
}
@@ -179,7 +178,7 @@ mat-dialog-actions {
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
flex-shrink: 0;
mat-icon {
@@ -188,7 +187,10 @@ mat-dialog-actions {
height: 18px;
}
// Locked reads as state, not only as a different glyph: accent colour
// plus the selection tint behind it.
&--locked {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
background: var(--app-selection-surface);
}
}
@@ -1,6 +1,7 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateModule } from '@ngx-translate/core';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
@@ -37,6 +38,7 @@ describe('CategoryManagementDialogComponent', () => {
getAllCategories: jest.fn(),
};
const dialogRef = { close: jest.fn() };
const snackBar = { open: jest.fn() };
const parentalLock = {
enabled: signal(false),
active: signal(false),
@@ -50,13 +52,8 @@ describe('CategoryManagementDialogComponent', () => {
itemCounts: new Map(),
};
beforeEach(async () => {
jest.clearAllMocks();
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
data.contentType = 'live';
/** `supportsVisibility` false is the PWA: no SQLite hide/show. */
async function createDialog(supportsVisibility: boolean): Promise<void> {
await TestBed.configureTestingModule({
imports: [
CategoryManagementDialogComponent,
@@ -67,11 +64,14 @@ describe('CategoryManagementDialogComponent', () => {
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
useValue: {
supportsXtreamSqliteDataSource: supportsVisibility,
},
},
{ provide: MatDialogRef, useValue: dialogRef },
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: MatSnackBar, useValue: snackBar },
],
}).compileComponents();
fixture = TestBed.createComponent(CategoryManagementDialogComponent);
@@ -79,6 +79,17 @@ describe('CategoryManagementDialogComponent', () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
jest.clearAllMocks();
dataSource.getAllCategories.mockResolvedValue(categories);
db.updateCategoryVisibility.mockResolvedValue(undefined);
parentalLock.enabled.set(false);
parentalLock.active.set(false);
parentalLock.setXtreamLocks.mockResolvedValue(true);
data.contentType = 'live';
await createDialog(true);
});
function selectedIds() {
@@ -253,6 +264,119 @@ describe('CategoryManagementDialogComponent', () => {
]);
});
it('reports a failed lock write with the translated lock message', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
parentalLock.setXtreamLocks.mockResolvedValueOnce(false);
await component.save();
expect(snackBar.open).toHaveBeenCalledWith(
'PARENTAL_LOCK.SAVE_FAILED',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
expect(component.isSaving()).toBe(false);
});
it('does not blame the locks when the visibility write fails', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
db.updateCategoryVisibility.mockRejectedValueOnce(new Error('db'));
await component.save();
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'Failed to save category visibility',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
});
it('labels the clear-search button', () => {
component.searchTerm.set('fr');
fixture.detectChanges();
const clear: HTMLButtonElement = fixture.nativeElement.querySelector(
'.search-inline__clear'
);
expect(clear.getAttribute('aria-label')).toBe(
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH'
);
});
describe('in the PWA, where hide/show is unavailable', () => {
beforeEach(async () => {
TestBed.resetTestingModule();
parentalLock.enabled.set(true);
await createDialog(false);
});
function text(): string {
fixture.detectChanges();
return fixture.nativeElement.textContent;
}
it('shows only the lock controls, not the selection count or bulk actions', () => {
expect(component.showLocks()).toBe(true);
expect(bulkButtons()).toEqual([]);
expect(text()).not.toContain('XTREAM.CATEGORY_MANAGEMENT.SELECTED');
expect(
fixture.nativeElement.querySelector('mat-checkbox')
).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="category-locked-count"]'
).textContent
).toContain('PARENTAL_LOCK.LOCKED_COUNT');
expect(
fixture.nativeElement.querySelectorAll('.lock-toggle')
).toHaveLength(categories.length);
});
it('shows no header at all while the lock feature is off', async () => {
TestBed.resetTestingModule();
parentalLock.enabled.set(false);
await createDialog(false);
expect(
fixture.nativeElement.querySelector('.header-actions')
).toBeNull();
});
it('saves the lock draft and never writes visibility', async () => {
component.toggleLock(component.categories()[1]);
await component.save();
expect(db.updateCategoryVisibility).not.toHaveBeenCalled();
expect(parentalLock.setXtreamLocks).toHaveBeenCalledWith(
'mock-playlist',
'live',
[102]
);
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('reports a failed lock write with the translated lock message', async () => {
parentalLock.setXtreamLocks.mockRejectedValueOnce(
new Error('store')
);
await component.save();
expect(snackBar.open).toHaveBeenCalledWith(
'PARENTAL_LOCK.SAVE_FAILED',
'CLOSE',
{ duration: 3000 }
);
expect(dialogRef.close).not.toHaveBeenCalled();
});
});
it.each([
['live', 'live'],
['vod', 'movies'],
@@ -18,7 +18,7 @@ import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
@@ -67,6 +67,7 @@ export class CategoryManagementDialogComponent implements OnInit {
*/
readonly supportsVisibility = this.runtime.supportsXtreamSqliteDataSource;
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly dialogRef = inject(
MatDialogRef<CategoryManagementDialogComponent>
);
@@ -235,47 +236,74 @@ export class CategoryManagementDialogComponent implements OnInit {
this.isSaving.set(true);
try {
const categories = this.categories();
const toHide = categories
.filter((c) => !c.selected)
.map((c) => c.id);
const toShow = categories
.filter((c) => c.selected)
.map((c) => c.id);
if (this.supportsVisibility && toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
try {
await this.saveVisibility(categories);
} catch (error) {
this.logger.error('Error saving category visibility', error);
// No translation key exists for this message yet.
this.showSaveFailure('Failed to save category visibility');
return;
}
if (this.supportsVisibility && toShow.length > 0) {
await this.dbService.updateCategoryVisibility(toShow, false);
}
// A relock while the visibility writes ran closed the dialog:
// its lock draft is dropped. The lock store refuses a removal
// that still commits after a relock (it checks at commit time).
if (this.showLocks() && !this.parentalLock.active()) {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories
.filter((c) => c.lockedDraft)
.map((c) => c.xtream_id)
if (!(await this.saveLocks(categories))) {
this.showSaveFailure(
this.translate.instant('PARENTAL_LOCK.SAVE_FAILED')
);
if (!saved) {
throw new Error('Saving category locks failed');
}
return;
}
this.dialogRef.close(true);
} catch (error) {
this.logger.error('Error saving category visibility', error);
this.snackBar.open('Failed to save category visibility', 'Close', {
duration: 3000,
});
} finally {
this.isSaving.set(false);
}
}
private async saveVisibility(
categories: CategoryWithSelection[]
): Promise<void> {
if (!this.supportsVisibility) {
return;
}
const toHide = categories.filter((c) => !c.selected).map((c) => c.id);
const toShow = categories.filter((c) => c.selected).map((c) => c.id);
if (toHide.length > 0) {
await this.dbService.updateCategoryVisibility(toHide, true);
}
if (toShow.length > 0) {
await this.dbService.updateCategoryVisibility(toShow, false);
}
}
/** False only when a lock write was attempted and did not persist. */
private async saveLocks(
categories: CategoryWithSelection[]
): Promise<boolean> {
// A relock while the visibility writes ran closed the dialog: its
// lock draft is dropped. The lock store refuses a removal that
// still commits after a relock (it checks at commit time).
if (!this.showLocks() || this.parentalLock.active()) {
return true;
}
try {
const saved = await this.parentalLock.setXtreamLocks(
this.data.playlistId,
this.getDbType(),
categories.filter((c) => c.lockedDraft).map((c) => c.xtream_id)
);
if (!saved) {
this.logger.error('Category locks were not saved');
}
return saved;
} catch (error) {
this.logger.error('Error saving category locks', error);
return false;
}
}
private showSaveFailure(message: string): void {
this.snackBar.open(message, this.translate.instant('CLOSE'), {
duration: 3000,
});
}
cancel(): void {
this.dialogRef.close(false);
}
@@ -345,6 +345,49 @@ describe('PlaylistsService', () => {
expect(electron.dbGetAppPlaylists).not.toHaveBeenCalled();
});
it('reads resolved M3U favorites only after a queued favorites write lands', async () => {
let storedFavorites: string[] = [];
let releaseWrite!: () => void;
const writeGate = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
const electron = {
dbGetAppPlaylist: jest.fn(async () => {
await writeGate;
return { _id: 'playlist-1', favorites: storedFavorites };
}),
dbGetAppPlaylistFavoriteChannels: jest.fn(async () =>
storedFavorites.map((id) => ({ id }))
),
dbGetAppPlaylists: jest.fn(async () => []),
dbGetAppState: jest.fn(async (key: string) =>
key === SQLITE_PLAYLIST_MIGRATION_FLAG ||
key === STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
? '1'
: null
),
dbSetAppState: jest.fn(),
dbUpsertAppPlaylist: jest.fn(async (playlist: Playlist) => {
storedFavorites = playlist.favorites as string[];
}),
dbUpsertAppPlaylists: jest.fn(),
};
testWindow.electron = electron;
const service = createService();
const write = firstValueFrom(
service.updateFavorites('playlist-1', ['channel-1'])
);
const read = firstValueFrom(
service.getM3uFavoriteChannels('playlist-1')
);
await new Promise((resolve) => setTimeout(resolve));
releaseWrite();
await write;
await expect(read).resolves.toEqual([{ id: 'channel-1' }]);
});
it('falls back from resolved M3U favorites when SQLite playlist migration is incomplete', async () => {
const electron = {
dbGetAppPlaylist: jest.fn(),
@@ -1092,6 +1092,9 @@ export class PlaylistsService {
return from(
(async () => {
// A favorite toggled just before this read may still be queued;
// the queue tail never rejects.
await this.playlistWriteQueues.get(playlistId);
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
@@ -48,6 +48,9 @@
type="button"
mat-icon-button
class="search-inline__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearSearch()"
>
<mat-icon>close</mat-icon>
@@ -3,7 +3,6 @@
gap: 10px;
flex-direction: column;
overflow: hidden !important;
min-width: 480px;
max-width: 600px;
max-height: 85vh !important;
}
@@ -159,7 +158,7 @@
min-width: 28px;
padding: 0;
line-height: 28px;
color: var(--mat-sys-on-surface-variant);
color: var(--app-eyebrow-color);
flex-shrink: 0;
mat-icon {
@@ -168,7 +167,10 @@
height: 18px;
}
// Locked reads as state, not only as a different glyph: accent colour
// plus the selection tint behind it.
&--locked {
color: var(--mat-sys-primary);
color: var(--app-selection-color);
background: var(--app-selection-surface);
}
}
@@ -337,20 +337,26 @@
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: 1px dashed var(--mat-sys-outline-variant);
border: 1px dashed var(--app-separator);
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover,
// Hover tints like the rail's other rows. Keyboard focus keeps the rail
// background so the ring holds >= 3:1 against what surrounds it.
&:hover:not(:focus-visible) {
background: var(--app-selection-surface);
color: var(--app-heading-color);
}
&:focus-visible {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
outline: none;
color: var(--app-heading-color);
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
mat-icon {
@@ -538,6 +538,7 @@ describe('GroupsViewComponent', () => {
]),
}),
maxHeight: '90vh',
maxWidth: 'calc(100vw - 32px)',
width: '500px',
})
);
@@ -579,6 +579,7 @@ export class GroupsViewComponent {
...(lockedGroupTitles ? { lockedGroupTitles } : {}),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
@@ -1,3 +1,13 @@
// The theme declares no Material system colours, so the error colour is a
// local token with a value per theme (>= 6:1 on the dialog surface in both).
:host {
--pin-dialog-error-color: #b3261e;
}
:host-context(.dark-theme) {
--pin-dialog-error-color: #ffb4ab;
}
.pin-dialog__title {
display: flex;
align-items: center;
@@ -12,7 +22,7 @@
.pin-dialog__description {
margin: 0;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font-size: 0.875rem;
}
@@ -31,14 +41,14 @@
.pin-dialog__error {
margin: 0;
color: var(--mat-sys-error);
color: var(--pin-dialog-error-color);
font-size: 0.8125rem;
}
.pin-dialog__note {
margin: 0;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
}
@keyframes pin-dialog-shake {
@@ -12,6 +12,13 @@
--pc-accent-blue-hover: #2a66d6;
--pc-accent-cyan: #5cd6ff;
--pc-accent-violet: #b599ff;
// Reds, literal like the rest: the theme's error colour changes with the
// theme, and its dark-theme tone is too pale to carry a white label.
// White on the LIVE fill is 5.0:1; the earlier #e53935 gave only 4.2:1,
// under the 4.5:1 text floor. The record glyph and the recording status
// text read 6.2:1 on the glass over a black frame.
--pc-live: #d32f2f;
--pc-danger: #ff5252;
--pc-text: #e7ecf3;
--pc-text-secondary: #9aa3b2;
--pc-text-tertiary: #6b7384;
@@ -316,8 +323,13 @@
gap: 4px;
}
.player-controls__record-button--active {
color: var(--mat-sys-error, #ff5252) !important;
// The glyph is the only visible part of the button, and the icon-button rule
// below colours the mat-icon directly, so the red must name the icon too.
// A disabled button (loading, e.g. a reconnect mid-recording) keeps the muted
// disabled colour, so it does not look clickable.
.player-controls__record-button--active:not([disabled]),
.player-controls__record-button--active:not([disabled]) mat-icon {
color: var(--pc-danger) !important;
}
// Value chips (subtitles, speed): the two settings a viewer changes mid-film.
@@ -0,0 +1,150 @@
import { readdirSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// The overlay sits on video in both app themes, so its colours come from the
// fixed `--pc-*` palette. A theme token read here stays invisible only while
// the theme leaves it undeclared; once declared, the overlay follows the theme.
const CONTROLS_DIR = resolve(
process.cwd(),
'libs/ui/playback/src/lib/player-controls'
);
// Comments are dropped so prose that names a token can neither trip nor
// satisfy the checks below. A `//` counts only after whitespace, which keeps
// the `://` of a URL intact.
function stripComments(source: string): string {
return source
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|\s)\/\/[^\n]*/g, '$1');
}
const STYLE_SOURCES = new Map(
readdirSync(CONTROLS_DIR)
.filter((file) => file.endsWith('.scss'))
.map((file) => [
file,
stripComments(readFileSync(resolve(CONTROLS_DIR, file), 'utf8')),
])
);
const HOST_STYLES = STYLE_SOURCES.get('player-controls.component.scss') ?? '';
const TIMELINE_STYLES =
STYLE_SOURCES.get('player-timeline.component.scss') ?? '';
type Rgb = [number, number, number];
function paletteColor(token: string): { rgb: Rgb; alpha: number } {
const value =
HOST_STYLES.match(new RegExp(`${token}:\\s*([^;]+);`))?.[1].trim() ??
'';
const hex = value.match(/^#([0-9a-f]{6})$/i)?.[1];
if (hex) {
const rgb = [0, 2, 4].map((offset) =>
Number.parseInt(hex.slice(offset, offset + 2), 16)
) as Rgb;
return { rgb, alpha: 1 };
}
const rgba = value.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)$/);
if (rgba) {
const rgb = rgba.slice(1, 4).map(Number) as Rgb;
return { rgb, alpha: Number(rgba[4]) };
}
throw new Error(`${token} is not a literal palette colour: "${value}"`);
}
function contrastRatio(foreground: Rgb, background: Rgb): number {
const luminance = (rgb: Rgb): number => {
const [red, green, blue] = rgb.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
};
const values = [luminance(foreground), luminance(background)].sort(
(left, right) => right - left
);
return (values[0] + 0.05) / (values[1] + 0.05);
}
describe('player controls overlay palette', () => {
it('never reads the app theme system tokens', () => {
expect([...STYLE_SOURCES.keys()]).toEqual(
expect.arrayContaining([
'player-controls.component.scss',
'player-timeline.component.scss',
])
);
const offenders = [...STYLE_SOURCES]
.filter(([, source]) => source.includes('--mat-sys-'))
.map(([file]) => file);
expect(offenders).toEqual([]);
expect(
stripComments('// names --mat-sys-error\n/* and --mat-sys-x */')
).not.toContain('--mat-sys-');
expect(stripComments("url('https://example.com/a.svg')")).toBe(
"url('https://example.com/a.svg')"
);
});
it('draws the LIVE badge and the recording reds from the palette', () => {
expect(TIMELINE_STYLES).toMatch(
/\.player-controls__live-badge\s*\{[^}]*background:\s*var\(--pc-live\);/
);
expect(TIMELINE_STYLES).toMatch(
/\.player-controls__recording-status--active\s*\{[^}]*color:\s*var\(--pc-danger\);/
);
expect(TIMELINE_STYLES).toMatch(
/\.player-controls__recording-status--active mat-icon\s*\{[^}]*color:\s*var\(--pc-danger\);/
);
// The icon-button rule colours the mat-icon itself, so a red set only
// on the button never reaches the glyph.
expect(HOST_STYLES).toMatch(
/\.player-controls__record-button--active:not\(\[disabled\]\),\s*\.player-controls__record-button--active:not\(\[disabled\]\) mat-icon\s*\{[^}]*color:\s*var\(--pc-danger\)/
);
});
it('lets a disabled record button keep its muted colour while recording', () => {
// The red is `!important`, so an unscoped rule would also beat the
// disabled icon colour: a recording that reconnects (loading, so the
// button is disabled) would show a red glyph that cannot be clicked.
const activeRecordSelectors = [
...HOST_STYLES.matchAll(/([^{}]*)\{[^}]*var\(--pc-danger\)/g),
].flatMap(([, selectors]) =>
selectors
.split(',')
.map((selector) => selector.trim())
.filter((selector) =>
selector.includes('player-controls__record-button--active')
)
);
expect(activeRecordSelectors).toEqual([
'.player-controls__record-button--active:not([disabled])',
'.player-controls__record-button--active:not([disabled]) mat-icon',
]);
expect(HOST_STYLES).toMatch(
/:host :is\(button\[mat-icon-button\]\[disabled\]\) mat-icon\s*\{[^}]*color:\s*var\(--pc-text-tertiary\);/
);
});
it('keeps the palette reds readable on video', () => {
const white: Rgb = [255, 255, 255];
const glass = paletteColor('--pc-glass-bg');
const glassOnBlackFrame = glass.rgb.map(
(channel) => channel * glass.alpha
) as Rgb;
// The white LIVE label is small bold text: the 4.5:1 text floor.
expect(
contrastRatio(white, paletteColor('--pc-live').rgb)
).toBeGreaterThanOrEqual(4.5);
// "REC 0:12" is text too, so it gets the text floor as well, not
// just the 3:1 a glyph would need.
expect(
contrastRatio(paletteColor('--pc-danger').rgb, glassOnBlackFrame)
).toBeGreaterThanOrEqual(4.5);
});
});
@@ -141,12 +141,12 @@
}
.player-controls__recording-status--active {
color: var(--mat-sys-error, #ff5252);
color: var(--pc-danger);
font-weight: 700;
}
.player-controls__recording-status--active mat-icon {
color: var(--mat-sys-error, #ff5252);
color: var(--pc-danger);
}
.player-controls__live-badge {
@@ -156,7 +156,7 @@
padding: 2px 8px;
border-radius: 4px;
color: #ffffff;
background: var(--mat-sys-error, #e53935);
background: var(--pc-live);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -141,7 +141,7 @@
aria-valuemax="100"
[attr.aria-valuenow]="slide.progress"
>
<i [style.width.%]="slide.progress"></i>
<i [style.--hero-progress]="slide.progress"></i>
</span>
}
@@ -339,10 +339,15 @@
i {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: var(--app-selection-color, #78adff);
transition: width 0.4s ease;
// A full-width fill slid into place, neither a width nor animated: a
// live-EPG tick moves it by under one percent, so an animation was
// invisible yet cost a layout pass (width) or a burst of compositor
// frames (transform transition) on every tick.
transform: translateX(calc(var(--hero-progress, 0) * 1% - 100%));
}
&--live i {
@@ -254,6 +254,19 @@ describe('DashboardHeroComponent', () => {
).toBe('/workspace/a');
});
it('positions the progress fill through a custom property, never its width', () => {
// Animating width re-lays out the page on every live-EPG tick; the
// stylesheet slides the fill with a transform driven by this property.
slides.set([
{ ...slide('a', 'Live'), contentType: 'live', progress: 42 },
]);
render();
const fill = host().querySelector<HTMLElement>('.hero__progress i');
expect(fill?.style.getPropertyValue('--hero-progress')).toBe('42');
expect(fill?.style.width).toBe('');
});
it('renders nothing when there is nothing to feature', () => {
slides.set([]);
render();
@@ -126,7 +126,7 @@
"
>
<i
[style.width.%]="
[style.--live-progress]="
card.nowPlayingProgress ?? 0
"
></i>
@@ -727,10 +727,15 @@
i {
display: block;
width: 100%;
height: 100%;
background: var(--app-live-color, #ff8f8f);
border-radius: 2px;
transition: width 0.4s ease;
// A full-width fill slid into place, neither a width nor animated: a
// live-EPG tick moves it by under one percent, so an animation was
// invisible yet cost a layout pass (width) or a burst of compositor
// frames (transform transition) on every tick.
transform: translateX(calc(var(--live-progress, 0) * 1% - 100%));
}
&--idle i {
@@ -375,6 +375,34 @@ describe('DashboardRailComponent', () => {
);
});
it('positions the live progress fill through a custom property, never its width', async () => {
// Animating width re-lays out the page for every frame of the
// transition on each EPG tick; the stylesheet slides the fill with
// a compositor transform driven by this property instead.
installObservers(false);
const { fixture } = await render([
card({
id: 'known',
contentType: 'live',
nowPlayingProgress: 37.5,
}),
card({ id: 'unknown', contentType: 'live' }),
]);
const fills = Array.from(
(
fixture.nativeElement as HTMLElement
).querySelectorAll<HTMLElement>('.rail__channel-progress i')
);
expect(fills).toHaveLength(2);
expect(fills[0].style.getPropertyValue('--live-progress')).toBe(
'37.5'
);
expect(fills[1].style.getPropertyValue('--live-progress')).toBe(
'0'
);
expect(fills.map((fill) => fill.style.width)).toEqual(['', '']);
});
it('shows the placeholder only while a live card is pending its first answer', async () => {
installObservers(false);
const { fixture } = await render([
@@ -137,6 +137,9 @@
type="button"
mat-icon-button
class="context-search__clear"
[attr.aria-label]="
'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate
"
(click)="clearCategorySearch()"
>
<mat-icon>close</mat-icon>
@@ -271,20 +271,26 @@ app-workspace-context-category-view {
width: 100%;
margin-top: 4px;
padding: 8px 12px;
border: 1px dashed var(--mat-sys-outline-variant);
border: 1px dashed var(--app-separator);
border-radius: 10px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
color: var(--app-body-color);
font: inherit;
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
&:hover,
// Hover tints like the rail's other rows. Keyboard focus keeps the rail
// background so the ring holds >= 3:1 against what surrounds it.
&:hover:not(:focus-visible) {
background: var(--app-selection-surface);
color: var(--app-heading-color);
}
&:focus-visible {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
outline: none;
color: var(--app-heading-color);
outline: 2px solid var(--app-selection-color);
outline-offset: -2px;
}
mat-icon {
@@ -963,6 +963,39 @@ describe('WorkspaceContextPanelComponent', () => {
expect(dialog.open).not.toHaveBeenCalled();
});
it('opens the Xtream category dialog capped at the viewport width', async () => {
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'xtream-1',
});
fixture.componentRef.setInput('section', 'vod');
fixture.detectChanges();
const component = fixture.componentInstance;
Object.defineProperty(component, 'canManageXtreamCategories', {
configurable: true,
value: () => true,
});
jest.spyOn(
TestBed.inject(ParentalLockService),
'requestUnlock'
).mockResolvedValue(true);
component.loadXtreamCategoryDialog = jest.fn(
async () => class DialogStub {}
);
dialog.open.mockReturnValueOnce({ afterClosed: () => of(false) });
await component.openManageCategories();
// A 500px panel on a 375px phone would scroll sideways.
expect(dialog.open).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({
width: '500px',
maxWidth: 'calc(100vw - 32px)',
})
);
});
describe('Stalker lock dialog', () => {
function enableLock(): void {
const parentalLock = TestBed.inject(ParentalLockService);
@@ -1001,6 +1034,7 @@ describe('WorkspaceContextPanelComponent', () => {
{ category_id: '1', category_name: 'News' },
],
},
maxWidth: 'calc(100vw - 32px)',
})
);
});
@@ -567,6 +567,7 @@ export class WorkspaceContextPanelComponent {
this.dialog.open(dialogComponent, {
data: { playlistId: context.playlistId, contentType, categories },
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
}
@@ -620,6 +621,7 @@ export class WorkspaceContextPanelComponent {
itemCounts: this.xtreamStore.getCategoryItemCounts(),
},
width: '500px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '90vh',
});
dialogRef