mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
commit
30b9fa9780
52 files changed
+1803
-155
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -157,7 +157,7 @@ describe('Electron app security helpers', () => {
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
backgroundThrottling: false,
|
||||
backgroundThrottling: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+40
-18
@@ -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;
|
||||
}
|
||||
+12
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
+61
-40
@@ -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>
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+132
-8
@@ -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'],
|
||||
|
||||
+61
-33
@@ -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
|
||||
);
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+5
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+1
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
+13
-3
@@ -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([
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+34
@@ -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)',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user