diff --git a/.changes/dashboard-fresh-m3u-favorite.md b/.changes/dashboard-fresh-m3u-favorite.md new file mode 100644 index 000000000..68a104a3b --- /dev/null +++ b/.changes/dashboard-fresh-m3u-favorite.md @@ -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. diff --git a/.changes/dashboard-live-progress-compositor.md b/.changes/dashboard-live-progress-compositor.md new file mode 100644 index 000000000..d6d7d6ae1 --- /dev/null +++ b/.changes/dashboard-live-progress-compositor.md @@ -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. diff --git a/.changes/electron-hidden-window-throttling.md b/.changes/electron-hidden-window-throttling.md new file mode 100644 index 000000000..fbac80002 --- /dev/null +++ b/.changes/electron-hidden-window-throttling.md @@ -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. diff --git a/.changes/playback-overlay-reds.md b/.changes/playback-overlay-reds.md new file mode 100644 index 000000000..6e724ba07 --- /dev/null +++ b/.changes/playback-overlay-reds.md @@ -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. diff --git a/.changes/settings-parental-lock-ui-polish.md b/.changes/settings-parental-lock-ui-polish.md new file mode 100644 index 000000000..58c0e3a62 --- /dev/null +++ b/.changes/settings-parental-lock-ui-polish.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5a9381a6..07c0fd2b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 0ff4ff951..82e200a4f 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -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": { diff --git a/apps/electron-backend-e2e/src/electron-unautomated-launch.ts b/apps/electron-backend-e2e/src/electron-unautomated-launch.ts new file mode 100644 index 000000000..6f1d6428c --- /dev/null +++ b/apps/electron-backend-e2e/src/electron-unautomated-launch.ts @@ -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(body: string): Promise; + /** Evaluates an expression in the main window's page. */ + evaluateInPage(expression: string): Promise; + close(): Promise; +} + +interface CdpSocket { + send(method: string, params?: object): Promise>; + 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 { + const socket = new WebSocket(url); + const pending = new Map< + number, + (message: Record) => void + >(); + let nextId = 0; + await new Promise((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 { + 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(socket: CdpSocket, expression: string): Promise { + 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, + timeoutMs: number +): Promise { + if (child.exitCode !== null || child.signalCode !== null) { + return Promise.resolve(true); + } + let timer: ReturnType | undefined; + return Promise.race([ + exited.then(() => true), + new Promise((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 +): Promise { + 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 { + // 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((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; + } +} diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 34f157d5a..86eddec02 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -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' ); }); diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts index 8a2a4566b..425c72cf7 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts @@ -346,7 +346,7 @@ describe('Xtream Playwright and Nx plumbing', () => { assert.equal(target?.['parallelism'], false); assert.equal( (target?.['options'] as Record)?.['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, diff --git a/apps/electron-backend-e2e/src/window-visibility.e2e.ts b/apps/electron-backend-e2e/src/window-visibility.e2e.ts new file mode 100644 index 000000000..ae323c541 --- /dev/null +++ b/apps/electron-backend-e2e/src/window-visibility.e2e.ts @@ -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('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 { + await expect + .poll( + () => + app.evaluateInMain( + '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( + '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(); + } + }); +}); diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 029227e2e..7e597144d 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -157,7 +157,7 @@ describe('Electron app security helpers', () => { nodeIntegration: false, sandbox: true, webSecurity: true, - backgroundThrottling: false, + backgroundThrottling: true, }) ); }); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index e275e1e03..09b927e6c 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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'), }; } diff --git a/apps/web-e2e/src/parental-lock-ui.e2e.ts b/apps/web-e2e/src/parental-lock-ui.e2e.ts new file mode 100644 index 000000000..15a37f4bd --- /dev/null +++ b/apps/web-e2e/src/parental-lock-ui.e2e.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return toggle.evaluate((el) => { + const style = getComputedStyle(el); + return `${style.color} ${style.backgroundColor}`; + }); +} + +async function expectPaintedBorder( + locator: Locator, + style: 'dashed' | 'solid' +): Promise { + 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 { + 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'); + } + }); +}); diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md index 060bdcc0c..9826ca097 100644 --- a/docs/architecture/electron-security.md +++ b/docs/architecture/electron-security.md @@ -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`. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9397e23f7..3033a67cd 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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, diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index d049b35b0..d9b871fb0 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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 diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index b1a770685..353555151 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -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, diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index afcc9584c..9c089cf39 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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 diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 212b58406..7c5ff1fd3 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -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. diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index f4c594095..5882f2ba2 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -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 diff --git a/libs/portal/stalker/data-access/src/lib/background-interval.spec.ts b/libs/portal/stalker/data-access/src/lib/background-interval.spec.ts new file mode 100644 index 000000000..dc05d6505 --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/background-interval.spec.ts @@ -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); + }); +}); diff --git a/libs/portal/stalker/data-access/src/lib/background-interval.ts b/libs/portal/stalker/data-access/src/lib/background-interval.ts new file mode 100644 index 000000000..c51950eec --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/background-interval.ts @@ -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 | 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; + } +} diff --git a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts index 3e94a8708..d4af1f1d0 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts @@ -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; + readPersistedPlaylist: ( + playlistId: string + ) => Promise; logger: ReturnType; } interface WatchdogTimers { timeslotTimeout?: ReturnType; - interval?: ReturnType; + /** 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 }; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html index 4c77372d8..c8c50ebe9 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.html @@ -47,6 +47,9 @@ type="button" mat-icon-button class="search-inline__clear" + [attr.aria-label]=" + 'EMBEDDED_MPV.PLAYER.CLEAR_SEARCH' | translate + " (click)="clearSearch()" > close diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss index 6fa341147..6cda75f1b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.scss @@ -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; } diff --git a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts index 0e410f21b..e78aec3c5 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-category-lock-dialog/stalker-category-lock-dialog.component.spec.ts @@ -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' + ); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index 9ade8e26d..789ade247 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -7,48 +7,66 @@

{{ 'XTREAM.CATEGORY_MANAGEMENT.LOADING' | translate }}

} @else { -
-
- {{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}: - {{ selectedCount() }} / {{ totalCount() }} - @if (showLocks()) { - - · {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}: - {{ lockedCount() }} - + + @if (supportsVisibility || showLocks()) { +
+
+ @if (supportsVisibility) { + {{ 'XTREAM.CATEGORY_MANAGEMENT.SELECTED' | translate }}: + {{ selectedCount() }} / + {{ totalCount() }} + } + @if (showLocks()) { + + @if (supportsVisibility) { + · + } + {{ 'PARENTAL_LOCK.LOCKED_COUNT' | translate }}: + {{ lockedCount() }} + + } +
+ @if (supportsVisibility) { +
+ + +
}
-
- - -
-
+ }