mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge remote-tracking branch 'origin/master' into perf/cd-tick-counters
# Conflicts: # apps/electron-backend-e2e/src/performance/journey-renderer-probe.spec.ts # apps/electron-backend-e2e/src/performance/journey-renderer-probe.ts
This commit is contained in:
commit
d9446db053
72 files changed
+3976
-604
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: detail
|
||||
---
|
||||
|
||||
On movie and series pages, the Back button no longer covers the start of the "Seasons and Episodes" heading or the corner of the player while you scroll, and in narrow windows the heading no longer gets squeezed onto several lines beside its buttons.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: player
|
||||
---
|
||||
|
||||
Player controls now show a clear focus ring in the light theme too. The
|
||||
settings panel works with arrow keys and screen readers, and the subtitle and
|
||||
speed buttons announce their current value. Settings headings are easier to
|
||||
read over bright video and wrap in long translations instead of overflowing.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: portals
|
||||
---
|
||||
|
||||
Switching to another playlist's copy of a movie no longer warns that the dub
|
||||
may differ when one copy's audio language is simply unknown (`und`).
|
||||
@@ -296,8 +296,8 @@ jobs:
|
||||
if: needs.performance-journeys-scope.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# The electron-performance build is the bulk of the time; each journey
|
||||
# (launch, open-source) is six fresh Electron processes plus one
|
||||
# seeding run.
|
||||
# (launch, open-source, playback) is six fresh Electron processes plus
|
||||
# one seeding run.
|
||||
timeout-minutes: 30
|
||||
# Warn-only for the first two weeks of plan item B3: a failure is
|
||||
# visible on the run but does not fail the workflow.
|
||||
|
||||
@@ -388,8 +388,9 @@ $ pnpm run perf:initial-bytes
|
||||
The contract behind that number is in
|
||||
[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md).
|
||||
|
||||
To benchmark the "launch to usable" journey (fresh Electron process on a
|
||||
seeded profile, exact renderer counters plus wall-clock), run:
|
||||
To benchmark the "launch to usable", "open a source" and "start playback"
|
||||
journeys (fresh Electron processes on a seeded profile against the local
|
||||
Xtream mock, exact renderer counters plus wall-clock), run:
|
||||
|
||||
```
|
||||
$ pnpm run perf:journeys
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The detail shell's sticky Back control floats over its scroll owner. While
|
||||
// it is shown, every content column reserves the control's lane, so no
|
||||
// scroll position may put it over the "Seasons and Episodes" heading or the
|
||||
// inline player's top-left corner. Tested at a wide and a narrow window; the
|
||||
// workspace rail and category panel leave the detail pane far narrower than
|
||||
// either, and the heading used to wrap beside its actions (two lines at
|
||||
// 1280px, three at 780px) with its first word under the arrow.
|
||||
//
|
||||
// A pane under 400px cannot spare the lane: a 700px window beside the
|
||||
// category panel, or a phone. There the control sits in an opaque sticky bar,
|
||||
// and whatever reaches the arrow's band must be hidden beneath the bar, never
|
||||
// visible beside the arrow.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const widths = [1280, 780];
|
||||
const compactWidths = [700, 375];
|
||||
const playerCorner = 56;
|
||||
/** The widest translation of the heading; it must fit wherever English does. */
|
||||
const widestLocale = 'nl';
|
||||
const widestHeading = (
|
||||
JSON.parse(
|
||||
readFileSync(
|
||||
join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`),
|
||||
'utf8'
|
||||
)
|
||||
) as { PORTALS: { SEASONS_AND_EPISODES: string } }
|
||||
).PORTALS.SEASONS_AND_EPISODES;
|
||||
|
||||
type Sweep = {
|
||||
overlaps: string[];
|
||||
/** Scroll positions where the target shared the arrow's vertical band. */
|
||||
beside: { player: number; title: number };
|
||||
};
|
||||
|
||||
/**
|
||||
* Scrolls the shell from top to bottom in steps shorter than the arrow and
|
||||
* records every position where the arrow's box intersects the heading or the
|
||||
* player's top-left corner. Reads layout synchronously after each scroll
|
||||
* write, so a hidden or throttled window cannot skip frames.
|
||||
*/
|
||||
function sweepDetail(shell: Locator): Promise<Sweep> {
|
||||
return shell.evaluate((element, corner) => {
|
||||
const back = element.querySelector('.shell__back-button');
|
||||
if (!back) throw new Error('The detail shows no Back control.');
|
||||
const sweep = {
|
||||
overlaps: [] as string[],
|
||||
beside: { player: 0, title: 0 },
|
||||
};
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let top = 0; ; top = Math.min(max, top + 24)) {
|
||||
element.scrollTop = top;
|
||||
const arrow = back.getBoundingClientRect();
|
||||
const title = element
|
||||
.querySelector('.section-title')
|
||||
?.getBoundingClientRect();
|
||||
const player = element
|
||||
.querySelector('app-portal-inline-player')
|
||||
?.getBoundingClientRect();
|
||||
const targets = {
|
||||
title,
|
||||
player: player && {
|
||||
left: player.left,
|
||||
top: player.top,
|
||||
right: player.left + corner,
|
||||
bottom: player.top + corner,
|
||||
},
|
||||
};
|
||||
for (const [name, box] of Object.entries(targets)) {
|
||||
if (!box) continue;
|
||||
const sameBand =
|
||||
box.top < arrow.bottom && arrow.top < box.bottom;
|
||||
if (!sameBand) continue;
|
||||
sweep.beside[name as keyof Sweep['beside']] += 1;
|
||||
if (box.left < arrow.right && arrow.left < box.right) {
|
||||
sweep.overlaps.push(`${name} at scrollTop ${top}`);
|
||||
}
|
||||
}
|
||||
if (top >= max) break;
|
||||
}
|
||||
element.scrollTop = 0;
|
||||
return sweep;
|
||||
}, playerCorner);
|
||||
}
|
||||
|
||||
type BarSweep = {
|
||||
background: string;
|
||||
/** Scroll positions where a target reached the bar's band. */
|
||||
beneath: number;
|
||||
/** Points of a target in the bar's band that hit content, not the bar. */
|
||||
exposed: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact counterpart of {@link sweepDetail}: the bar sits in flow and
|
||||
* content scrolls beneath it, so boxes do intersect. Instead, every sampled
|
||||
* point of the heading or the player's corner that is inside the bar's band
|
||||
* must hit-test to the bar: hidden beneath it, and unreachable by a tap.
|
||||
*/
|
||||
function sweepBar(shell: Locator): Promise<BarSweep> {
|
||||
return shell.evaluate((element, corner) => {
|
||||
const bar = element.querySelector('.shell__navigation');
|
||||
if (!bar) throw new Error('The detail shows no Back control.');
|
||||
const sweep = {
|
||||
background: getComputedStyle(bar).backgroundColor,
|
||||
beneath: 0,
|
||||
exposed: [] as string[],
|
||||
};
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let top = 0; ; top = Math.min(max, top + 24)) {
|
||||
element.scrollTop = top;
|
||||
const band = bar.getBoundingClientRect();
|
||||
const title = element
|
||||
.querySelector('.section-title')
|
||||
?.getBoundingClientRect();
|
||||
const player = element
|
||||
.querySelector('app-portal-inline-player')
|
||||
?.getBoundingClientRect();
|
||||
const targets = {
|
||||
title,
|
||||
player: player && {
|
||||
left: player.left,
|
||||
top: player.top,
|
||||
right: player.left + corner,
|
||||
bottom: player.top + corner,
|
||||
},
|
||||
};
|
||||
for (const [name, box] of Object.entries(targets)) {
|
||||
if (!box) continue;
|
||||
const from = Math.max(box.top, band.top);
|
||||
const to = Math.min(box.bottom, band.bottom);
|
||||
if (to <= from) continue;
|
||||
sweep.beneath += 1;
|
||||
const y = (from + to) / 2;
|
||||
for (const x of [box.left + 2, box.right - 2]) {
|
||||
const hit = element.ownerDocument.elementFromPoint(x, y);
|
||||
if (!hit || !bar.contains(hit)) {
|
||||
sweep.exposed.push(
|
||||
`${name} at scrollTop ${top} hits ${hit?.tagName}.${String(hit?.className)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (top >= max) break;
|
||||
}
|
||||
element.scrollTop = 0;
|
||||
return sweep;
|
||||
}, playerCorner);
|
||||
}
|
||||
|
||||
/** Line boxes of the heading's text; 1 means it did not wrap. */
|
||||
function headingLineCount(page: Page): Promise<number> {
|
||||
return page.locator('.section-title').evaluate((heading) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
const lines = [...range.getClientRects()]
|
||||
.filter((rect) => rect.width > 0)
|
||||
.map((rect) => Math.round(rect.top));
|
||||
return new Set(lines).size;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the browse↔watch morph, the player's fade-in and the workspace's own
|
||||
* transitions settle — crossing into the phone layout slides the category
|
||||
* drawer out over the page for 200ms. Bounded, so a paused animation
|
||||
* elsewhere cannot stall the test.
|
||||
*/
|
||||
async function settle(shell: Locator): Promise<void> {
|
||||
await shell.evaluate((element) =>
|
||||
Promise.race([
|
||||
Promise.all(
|
||||
element.ownerDocument
|
||||
.getAnimations()
|
||||
.filter(
|
||||
(animation) =>
|
||||
animation.effect?.getTiming().iterations !==
|
||||
Infinity
|
||||
)
|
||||
.map((animation) =>
|
||||
animation.finished.catch(() => undefined)
|
||||
)
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async function expectBackClearOfContent(
|
||||
page: Page,
|
||||
state: 'browse' | 'watch'
|
||||
): Promise<void> {
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
for (const width of widths) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
// Fail on the mode first: a lane assertion against the bar would
|
||||
// only report confusing intersections.
|
||||
await expect(shell, `${state} at ${width}px`).not.toHaveClass(
|
||||
/shell-host--compact/
|
||||
);
|
||||
await settle(shell);
|
||||
const sweep = await sweepDetail(shell);
|
||||
|
||||
expect(sweep.overlaps, `${state} at ${width}px`).toEqual([]);
|
||||
// The sweep has to carry the heading (and the player) past the arrow,
|
||||
// or the empty overlap list proves nothing.
|
||||
expect(sweep.beside.title, `${state} at ${width}px`).toBeGreaterThan(0);
|
||||
if (state === 'watch') {
|
||||
expect(
|
||||
sweep.beside.player,
|
||||
`${state} at ${width}px`
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
|
||||
}
|
||||
|
||||
for (const width of compactWidths) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await expect(shell, `${state} at ${width}px`).toHaveClass(
|
||||
/shell-host--compact/
|
||||
);
|
||||
await settle(shell);
|
||||
const sweep = await sweepBar(shell);
|
||||
|
||||
expect(sweep.exposed, `${state} at ${width}px`).toEqual([]);
|
||||
expect(sweep.beneath, `${state} at ${width}px`).toBeGreaterThan(0);
|
||||
// Opaque, or what scrolls beneath would show through.
|
||||
expect(sweep.background, `${state} at ${width}px`).toMatch(/^rgb\(/);
|
||||
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-checks the heading in the widest translation at the lane and bar widths.
|
||||
* Below them (a ~220px header beside the category panel) a translation wider
|
||||
* than the pane itself wraps by design rather than losing words to an
|
||||
* ellipsis.
|
||||
*/
|
||||
async function expectWidestHeadingOnOneLine(
|
||||
page: Page,
|
||||
detailUrl: string
|
||||
): Promise<void> {
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await openSettings(page);
|
||||
await page.getByTestId('select-language').click();
|
||||
await page.getByTestId(widestLocale).click();
|
||||
await saveSettings(page);
|
||||
await page.goBack();
|
||||
await page.waitForURL(detailUrl);
|
||||
await expect(page.locator('.section-title')).toHaveText(widestHeading, {
|
||||
timeout: 20_000,
|
||||
});
|
||||
for (const width of [...widths, ...compactWidths]) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
expect(
|
||||
await headingLineCount(page),
|
||||
`${widestLocale} at ${width}px`
|
||||
).toBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions move onto their own row before the heading wraps, at every
|
||||
* pane width that can hold the heading at all — including the widths where
|
||||
* the lane gives way to the bar.
|
||||
*/
|
||||
async function expectHeadingOnOneLine(page: Page): Promise<void> {
|
||||
const wrapped: number[] = [];
|
||||
for (let width = 680; width <= 1600; width += 20) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
if ((await headingLineCount(page)) !== 1) wrapped.push(width);
|
||||
}
|
||||
expect(wrapped).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe('Portal detail Back lane', () => {
|
||||
test('@xtream @electron keeps the Back arrow off the heading and the player while scrolling', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickFirstGridListCard(page);
|
||||
await page.waitForURL(
|
||||
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
|
||||
);
|
||||
|
||||
const detailUrl = page.url();
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
const episodes = page.locator('.episode-card');
|
||||
await expect(page.locator('.section-title')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(episodes.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expectBackClearOfContent(page, 'browse');
|
||||
await expectHeadingOnOneLine(page);
|
||||
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await episodes.first().click();
|
||||
await expect(shell).toHaveClass(/shell-host--watch/);
|
||||
await expect(
|
||||
shell.locator('app-portal-inline-player app-web-player-view')
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expectBackClearOfContent(page, 'watch');
|
||||
await expectWidestHeadingOnOneLine(page, detailUrl);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -86,12 +86,21 @@ function removeDirectory(directory: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** What a journey changes in the seeded profile; J1 and J2 use neither. */
|
||||
export interface LaunchJourneySeedOptions {
|
||||
/** Portal credentials; default: the mock's default account. */
|
||||
readonly portal?: Parameters<typeof addXtreamPortal>[1];
|
||||
/** Runs after both sources are imported, e.g. to change settings. */
|
||||
readonly configure?: (page: Page) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds one M3U source and one Xtream portal through the app's own dialogs
|
||||
* and returns the data directory to copy for every measured launch.
|
||||
*/
|
||||
export async function seedLaunchJourneyProfile(
|
||||
mockOrigin: string
|
||||
mockOrigin: string,
|
||||
options: LaunchJourneySeedOptions = {}
|
||||
): Promise<string> {
|
||||
const templateDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-journey-launch-seed-')
|
||||
@@ -104,8 +113,12 @@ export async function seedLaunchJourneyProfile(
|
||||
`${mockOrigin}/playlist.m3u`
|
||||
);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await addXtreamPortal(app.mainWindow, { serverUrl: mockOrigin });
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
...options.portal,
|
||||
serverUrl: mockOrigin,
|
||||
});
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await options.configure?.(app.mainWindow);
|
||||
} finally {
|
||||
await closeElectronAppAndConfirmExit(app);
|
||||
}
|
||||
|
||||
@@ -1,161 +1,44 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
import { defaultXtreamPortalName } from '../electron-test-fixtures';
|
||||
import {
|
||||
countJourneyMainIpcInFlight,
|
||||
JOURNEY_CLICK_QUIET_MS,
|
||||
waitForJourneyClickQuiet,
|
||||
waitForJourneyMockQuiet,
|
||||
} from '../performance/journey-click-settle';
|
||||
import {
|
||||
detachJourneyMainIpcCapture,
|
||||
installJourneyMainIpcCapture,
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
peekJourneyMainIpcCaptures,
|
||||
readJourneyMainIpcCapture,
|
||||
} from '../performance/journey-main-ipc-capture';
|
||||
import type { JourneyMockRequestLedger } from '../performance/journey-mock-request-ledger';
|
||||
import { waitForJourneyQuiet } from '../performance/journey-quiet-wait';
|
||||
import {
|
||||
armJourneyRendererProbe,
|
||||
createOpenSourceJourneyProbeOptions,
|
||||
waitForJourneyRendererProbe,
|
||||
type JourneyRendererProbeState,
|
||||
} from '../performance/journey-renderer-probe';
|
||||
import type {
|
||||
OpenSourceJourneyMeasurement,
|
||||
OpenSourceJourneySettle,
|
||||
} from '../performance/open-source-journey-record';
|
||||
import type { OpenSourceJourneyMeasurement } from '../performance/open-source-journey-record';
|
||||
import type { LaunchJourneySession } from './launch-journey-app';
|
||||
|
||||
/**
|
||||
* J2 "Open a source": runs inside a process that J1 has just launched, after
|
||||
* J1's counters are final. The app is first allowed to settle (no DOM
|
||||
* mutation, bridge call or mock request for `QUIET_MS`), so leftovers of the
|
||||
* startup are not attributed to the click. Then the Xtream portal card on
|
||||
* mutation, bridge call or mock request for `JOURNEY_CLICK_QUIET_MS`), so
|
||||
* leftovers of the startup are not attributed to the click. Then the Xtream portal card on
|
||||
* the dashboard is clicked and the probe, the IPC capture and the mock
|
||||
* request ledger measure until the category list and the first page of
|
||||
* items are painted.
|
||||
*/
|
||||
export const OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY =
|
||||
'__iptvnatorJourneyOpenSourceMainIpcCapture';
|
||||
const QUIET_MS = 1_000;
|
||||
const POLL_MS = 100;
|
||||
const SETTLE_TIMEOUT_MS = 30_000;
|
||||
const ERROR_PREFIX = 'open-source-journey';
|
||||
/**
|
||||
* After the mock settled, the ledger is watched this much longer before it
|
||||
* is read, so requests that arrive after the accepted quiet sample show up
|
||||
* in `httpRequestsAfterSettledByRoute` instead of vanishing unseen.
|
||||
*/
|
||||
const LATE_REQUEST_OBSERVATION_MS = QUIET_MS;
|
||||
|
||||
interface ActivitySample {
|
||||
readonly domMutations: number;
|
||||
readonly httpInFlight: number;
|
||||
readonly httpRequests: number;
|
||||
readonly ipcCalls: number;
|
||||
readonly ipcInFlight: number;
|
||||
}
|
||||
|
||||
async function readPreStartMutations(
|
||||
page: Page,
|
||||
stateKey: string
|
||||
): Promise<number> {
|
||||
return page.evaluate((key) => {
|
||||
const state = (globalThis as unknown as Record<string, unknown>)[
|
||||
key
|
||||
] as { preStart?: { domMutations?: number } } | undefined;
|
||||
const count = state?.preStart?.domMutations;
|
||||
if (typeof count !== 'number') {
|
||||
throw new Error('journey-renderer-probe-not-armed');
|
||||
}
|
||||
return count;
|
||||
}, stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until DOM, bridge and mock traffic have all been unchanged for
|
||||
* `QUIET_MS` with no mock request and no bridge call in flight: a slow
|
||||
* response or a pending bridge call can still change the DOM or trigger
|
||||
* follow-up work after the click. Pending bridge calls come from J1's
|
||||
* capture, which was installed before the document loaded and so has seen
|
||||
* every call start. An app that never settles fails the iteration instead
|
||||
* of producing a count that includes its background work.
|
||||
*/
|
||||
async function waitForQuiet(
|
||||
electronApp: ElectronApplication,
|
||||
page: Page,
|
||||
ledger: JourneyMockRequestLedger,
|
||||
probeStateKey: string
|
||||
): Promise<{
|
||||
/** Ledger position read by the accepted quiet sample itself. */
|
||||
readonly ledgerMark: number;
|
||||
readonly settle: OpenSourceJourneySettle;
|
||||
}> {
|
||||
const armMark = ledger.mark();
|
||||
const sample = async (): Promise<ActivitySample> => {
|
||||
// Both captures in one snapshot: a call counted by J2's capture is
|
||||
// then also pending in J1's, never counted with a stale in-flight 0.
|
||||
const [launchCapture, openSourceCapture] =
|
||||
await peekJourneyMainIpcCaptures(electronApp, [
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
]);
|
||||
if (launchCapture.unmatchedCompletions > 0) {
|
||||
throw new Error('open-source-journey-bridge-completions-unmatched');
|
||||
}
|
||||
return {
|
||||
domMutations: await readPreStartMutations(page, probeStateKey),
|
||||
httpInFlight: ledger.inFlight(),
|
||||
httpRequests: ledger.mark(),
|
||||
ipcCalls: openSourceCapture.callsBeforeStart,
|
||||
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
|
||||
};
|
||||
};
|
||||
const { sample: quiet, waitedMs } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.httpInFlight + activity.ipcInFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: QUIET_MS,
|
||||
sample,
|
||||
timeoutError: (activity) =>
|
||||
new Error(
|
||||
`open-source-journey-not-quiet: ${JSON.stringify(activity)}`
|
||||
),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
ledgerMark: quiet.httpRequests,
|
||||
settle: {
|
||||
preStartDomMutations: quiet.domMutations,
|
||||
preStartHttpRequests: quiet.httpRequests - armMark,
|
||||
preStartIpcCalls: quiet.ipcCalls,
|
||||
quietMs: QUIET_MS,
|
||||
waitedMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the mock has seen no new request for `QUIET_MS` and none is
|
||||
* in flight, so responses slower than the quiet interval and the requests
|
||||
* they trigger stay inside the measured window.
|
||||
*/
|
||||
async function waitForMockQuiet(
|
||||
ledger: JourneyMockRequestLedger
|
||||
): Promise<number> {
|
||||
const { sample: quiet } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.inFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: QUIET_MS,
|
||||
sample: async () => ({
|
||||
inFlight: ledger.inFlight(),
|
||||
requests: ledger.mark(),
|
||||
}),
|
||||
timeoutError: () => new Error('open-source-journey-mock-not-quiet'),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
// The window ends at the ledger position this accepted sample read. A
|
||||
// request that arrives after it was never seen in flight, so its
|
||||
// response and follow-ups are not waited for; counting it would make
|
||||
// the counter depend on when the ledger is read.
|
||||
return quiet.requests;
|
||||
}
|
||||
const LATE_REQUEST_OBSERVATION_MS = JOURNEY_CLICK_QUIET_MS;
|
||||
|
||||
/**
|
||||
* `spawnLedgerMark` is the ledger position taken before the process was
|
||||
@@ -194,12 +77,12 @@ export async function measureOpenSourceJourney(
|
||||
// reading the IPC capture. Requests from that position on but before
|
||||
// the renderer's click stamp arrived after the app settled, and the
|
||||
// record rejects such an iteration.
|
||||
const { ledgerMark: settledLedgerMark, settle } = await waitForQuiet(
|
||||
electronApp,
|
||||
mainWindow,
|
||||
ledger,
|
||||
probeOptions.stateKey
|
||||
);
|
||||
const { ledgerMark: settledLedgerMark, settle } =
|
||||
await waitForJourneyClickQuiet(electronApp, mainWindow, ledger, {
|
||||
errorPrefix: ERROR_PREFIX,
|
||||
ipcCaptureStateKey: OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
probeStateKey: probeOptions.stateKey,
|
||||
});
|
||||
// J1's capture was only needed to see pending launch calls while
|
||||
// settling; detached, it no longer runs for every J2 bridge call.
|
||||
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
|
||||
@@ -215,7 +98,10 @@ export async function measureOpenSourceJourney(
|
||||
OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
10_000
|
||||
);
|
||||
const settledAfterLedgerMark = await waitForMockQuiet(ledger);
|
||||
const settledAfterLedgerMark = await waitForJourneyMockQuiet(
|
||||
ledger,
|
||||
ERROR_PREFIX
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, LATE_REQUEST_OBSERVATION_MS)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
import { configureLiveFormat } from '../xtream-live-format.fixture';
|
||||
import {
|
||||
JOURNEY_CLICK_QUIET_MS,
|
||||
waitForJourneyClickQuiet,
|
||||
} from '../performance/journey-click-settle';
|
||||
import {
|
||||
detachJourneyMainIpcCapture,
|
||||
installJourneyMainIpcCapture,
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
readJourneyMainIpcCapture,
|
||||
} from '../performance/journey-main-ipc-capture';
|
||||
import type { JourneyMockRequestLedger } from '../performance/journey-mock-request-ledger';
|
||||
import {
|
||||
armJourneyRendererProbe,
|
||||
createPlaybackJourneyProbeOptions,
|
||||
JOURNEY_OPEN_SOURCE_START_SELECTOR,
|
||||
waitForJourneyRendererProbe,
|
||||
} from '../performance/journey-renderer-probe';
|
||||
import {
|
||||
PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS,
|
||||
type PlaybackJourneyMeasurement,
|
||||
} from '../performance/playback-journey-record';
|
||||
import type {
|
||||
LaunchJourneySeedOptions,
|
||||
LaunchJourneySession,
|
||||
} from './launch-journey-app';
|
||||
|
||||
/**
|
||||
* J3 "Playback": runs inside a process that J1 has just launched. The test
|
||||
* opens the Xtream portal's live section and its first category (not
|
||||
* measured), lets the app settle like J2, then clicks the first live channel
|
||||
* and measures until the HTML5 player's video element fires `playing`.
|
||||
*
|
||||
* The portal is the mock's `live-fallback` account, whose `.ts` live URLs
|
||||
* serve a local six-second H.264 baseline + AAC MPEG-TS fixture
|
||||
* (`apps/xtream-mock-server/src/fixtures/live.mpegts`). The HTML5 player
|
||||
* plays it through mpegts.js and Media Source Extensions, which Electron's
|
||||
* Chromium supports on every platform. The marketing accounts' live URLs
|
||||
* serve zero-filled bytes that no player can decode, and the other accounts
|
||||
* redirect to a public HLS stream. Contract:
|
||||
* docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
export const PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY =
|
||||
'__iptvnatorJourneyPlaybackMainIpcCapture';
|
||||
export const PLAYBACK_JOURNEY_PORTAL_NAME = 'Journey live portal';
|
||||
const ERROR_PREFIX = 'playback-journey';
|
||||
const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork';
|
||||
/**
|
||||
* The generated live catalog's channel and category logos point at
|
||||
* picsum.photos. They are cancelled in the main process, so no request of
|
||||
* the journey leaves the machine and a logo never loads, or fails, at a
|
||||
* different moment on a runner with a different network.
|
||||
*/
|
||||
const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*'];
|
||||
|
||||
/** Seeds J2's profile with the local-media portal and the HTML5 player. */
|
||||
export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = {
|
||||
// The built-in HTML5 player with the `ts` stream format: live URLs end
|
||||
// in `.ts`, which the mock serves from the local fixture.
|
||||
configure: (page) => configureLiveFormat(page, 'html5', 'ts'),
|
||||
portal: {
|
||||
name: PLAYBACK_JOURNEY_PORTAL_NAME,
|
||||
password: 'live-fallback',
|
||||
username: 'live-fallback',
|
||||
},
|
||||
};
|
||||
|
||||
async function blockExternalArtwork(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<void> {
|
||||
await electronApp.evaluate(
|
||||
({ session }, input) => {
|
||||
const target = globalThis as unknown as Record<string, unknown>;
|
||||
if (target[input.key] !== undefined) {
|
||||
throw new Error('playback-journey-artwork-block-installed');
|
||||
}
|
||||
const state = { cancelled: 0 };
|
||||
target[input.key] = state;
|
||||
// The app registers no onBeforeRequest listener of its own
|
||||
// (only onBeforeSendHeaders), so this replaces nothing.
|
||||
session.defaultSession.webRequest.onBeforeRequest(
|
||||
{ urls: input.urls },
|
||||
(_details, callback) => {
|
||||
state.cancelled += 1;
|
||||
callback({ cancel: true });
|
||||
}
|
||||
);
|
||||
},
|
||||
{ key: EXTERNAL_ARTWORK_STATE_KEY, urls: EXTERNAL_ARTWORK_URLS }
|
||||
);
|
||||
}
|
||||
|
||||
async function readCancelledExternalArtwork(
|
||||
electronApp: ElectronApplication
|
||||
): Promise<number> {
|
||||
return electronApp.evaluate(
|
||||
(_electron, key) =>
|
||||
(
|
||||
(globalThis as unknown as Record<string, unknown>)[key] as {
|
||||
cancelled: number;
|
||||
}
|
||||
).cancelled,
|
||||
EXTERNAL_ARTWORK_STATE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/** Dashboard card → live section → first category, as a user would. */
|
||||
async function openLiveCategory(page: Page, timeoutMs: number): Promise<void> {
|
||||
await page
|
||||
.locator(JOURNEY_OPEN_SOURCE_START_SELECTOR)
|
||||
.filter({ hasText: PLAYBACK_JOURNEY_PORTAL_NAME })
|
||||
.first()
|
||||
.click({ timeout: timeoutMs });
|
||||
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, {
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
await page
|
||||
.getByRole('link', { name: 'Live TV', exact: true })
|
||||
.click({ timeout: timeoutMs });
|
||||
await page
|
||||
.locator('app-workspace-context-panel .category-item')
|
||||
.first()
|
||||
.click({ timeout: timeoutMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* `spawnLedgerMark` is the ledger position taken before the process was
|
||||
* spawned, so the launch's and the navigation's mock traffic is kept as
|
||||
* evidence.
|
||||
*/
|
||||
export async function measurePlaybackJourney(
|
||||
session: LaunchJourneySession,
|
||||
ledger: JourneyMockRequestLedger,
|
||||
spawnLedgerMark: number,
|
||||
timeoutMs: number
|
||||
): Promise<PlaybackJourneyMeasurement> {
|
||||
const { electronApp, mainWindow } = session;
|
||||
const probeOptions = createPlaybackJourneyProbeOptions();
|
||||
const startClick = probeOptions.startClick;
|
||||
if (!startClick) {
|
||||
throw new Error('playback-journey-probe-without-start');
|
||||
}
|
||||
await blockExternalArtwork(electronApp);
|
||||
await openLiveCategory(mainWindow, timeoutMs);
|
||||
const channel = mainWindow.locator(startClick.selector).first();
|
||||
await channel.waitFor({ state: 'visible', timeout: timeoutMs });
|
||||
await installJourneyMainIpcCapture(electronApp, {
|
||||
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
sentinelId: probeOptions.sentinelId,
|
||||
sentinelMethod: probeOptions.sentinelMethod,
|
||||
startSentinelId: startClick.sentinelId,
|
||||
stateKey: PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
});
|
||||
await armJourneyRendererProbe(mainWindow, probeOptions);
|
||||
// Hover first so hover effects happen before the app settles.
|
||||
await channel.hover({ timeout: timeoutMs });
|
||||
const { ledgerMark: settledLedgerMark, settle } =
|
||||
await waitForJourneyClickQuiet(electronApp, mainWindow, ledger, {
|
||||
errorPrefix: ERROR_PREFIX,
|
||||
ipcCaptureStateKey: PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
probeStateKey: probeOptions.stateKey,
|
||||
});
|
||||
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
|
||||
await channel.click({ timeout: timeoutMs });
|
||||
const renderer = await waitForJourneyRendererProbe(
|
||||
mainWindow,
|
||||
probeOptions.stateKey,
|
||||
timeoutMs
|
||||
);
|
||||
const ipc = await readJourneyMainIpcCapture(
|
||||
electronApp,
|
||||
PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
10_000
|
||||
);
|
||||
const clickEpochMs = renderer.start?.epochMs;
|
||||
const playingEpochMs = renderer.terminal?.epochMs;
|
||||
if (clickEpochMs === undefined || playingEpochMs === undefined) {
|
||||
throw new Error('playback-journey-probe-incomplete');
|
||||
}
|
||||
// A live stream never leaves the mock quiet, so instead of J2's quiet
|
||||
// wait the ledger is watched for a fixed window after `playing`.
|
||||
const afterPlayingUntilEpochMs =
|
||||
playingEpochMs + PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS;
|
||||
const remainingMs =
|
||||
afterPlayingUntilEpochMs - (performance.timeOrigin + performance.now());
|
||||
if (remainingMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, remainingMs));
|
||||
}
|
||||
// The ledger stamps arrivals with this process's clock, the probe with
|
||||
// the renderer's; both read the same host clock (as in J2). The record
|
||||
// keeps the distance of the nearest request to each boundary, so a
|
||||
// count that a small clock difference could flip is visible.
|
||||
const sinceSpawn = ledger.since(spawnLedgerMark);
|
||||
const beforeClick = sinceSpawn.filter(
|
||||
(entry) => entry.epochMs < clickEpochMs
|
||||
);
|
||||
return {
|
||||
externalArtworkCancelled:
|
||||
await readCancelledExternalArtwork(electronApp),
|
||||
http: {
|
||||
afterPlaying: sinceSpawn.filter(
|
||||
(entry) =>
|
||||
entry.epochMs >= playingEpochMs &&
|
||||
entry.epochMs < afterPlayingUntilEpochMs
|
||||
),
|
||||
afterSettleBeforeClick: beforeClick.filter(
|
||||
(entry) => entry.sequence >= settledLedgerMark
|
||||
).length,
|
||||
beforeClick,
|
||||
toPlaying: sinceSpawn.filter(
|
||||
(entry) =>
|
||||
entry.epochMs >= clickEpochMs &&
|
||||
entry.epochMs < playingEpochMs
|
||||
),
|
||||
},
|
||||
ipc,
|
||||
pid: session.launch.pid,
|
||||
renderer,
|
||||
settle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { startJourneyMockRequestLedger } from '../performance/journey-mock-request-ledger';
|
||||
import type { JourneyIterationRecord } from '../performance/journey-summary';
|
||||
import {
|
||||
PLAYBACK_JOURNEY_ID,
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
toPlaybackIterationRecord,
|
||||
} from '../performance/playback-journey-record';
|
||||
import {
|
||||
JOURNEY_ITERATION_TIMEOUT_MS,
|
||||
JOURNEY_MEASURED_ITERATIONS,
|
||||
JOURNEY_WARMUP_ITERATIONS,
|
||||
logJourneyIteration,
|
||||
writeJourneyRunEntry,
|
||||
} from './journey-run';
|
||||
import {
|
||||
LAUNCH_JOURNEY_MOCK_ORIGIN,
|
||||
removeLaunchJourneyProfile,
|
||||
runLaunchJourney,
|
||||
seedLaunchJourneyProfile,
|
||||
} from './launch-journey-app';
|
||||
import {
|
||||
measurePlaybackJourney,
|
||||
PLAYBACK_JOURNEY_SEED,
|
||||
} from './playback-journey-app';
|
||||
|
||||
/**
|
||||
* J3 "Playback": click on a live channel of an Xtream portal until the
|
||||
* built-in HTML5 player's video element fires `playing`. Every iteration is
|
||||
* a fresh J1 launch on a copy of the seeded profile (J2's profile with the
|
||||
* portal on the mock's local-media `live-fallback` account and the HTML5
|
||||
* player selected); the click happens after the app has settled in the
|
||||
* portal's first live category.
|
||||
* Contract: docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('J3 start playback', async () => {
|
||||
const ledger = await startJourneyMockRequestLedger(
|
||||
LAUNCH_JOURNEY_MOCK_ORIGIN
|
||||
);
|
||||
const iterations: JourneyIterationRecord[] = [];
|
||||
let electronVersion = 'unknown';
|
||||
try {
|
||||
const templateDirectory = await seedLaunchJourneyProfile(
|
||||
ledger.origin,
|
||||
PLAYBACK_JOURNEY_SEED
|
||||
);
|
||||
try {
|
||||
const total =
|
||||
JOURNEY_WARMUP_ITERATIONS + JOURNEY_MEASURED_ITERATIONS;
|
||||
for (let index = 0; index < total; index += 1) {
|
||||
const warmup = index < JOURNEY_WARMUP_ITERATIONS;
|
||||
const spawnLedgerMark = ledger.mark();
|
||||
const { continuation, launch } = await runLaunchJourney(
|
||||
templateDirectory,
|
||||
JOURNEY_ITERATION_TIMEOUT_MS,
|
||||
// Like J2: no main-process counters or SQL hook.
|
||||
{ idleWindowMs: null, mainCounters: false },
|
||||
(session) =>
|
||||
measurePlaybackJourney(
|
||||
session,
|
||||
ledger,
|
||||
spawnLedgerMark,
|
||||
JOURNEY_ITERATION_TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
electronVersion = launch.electronVersion;
|
||||
const record = toPlaybackIterationRecord(
|
||||
index,
|
||||
warmup,
|
||||
continuation
|
||||
);
|
||||
iterations.push(record);
|
||||
logJourneyIteration(PLAYBACK_JOURNEY_ID, record);
|
||||
}
|
||||
} finally {
|
||||
await removeLaunchJourneyProfile(templateDirectory);
|
||||
}
|
||||
} finally {
|
||||
await ledger.close();
|
||||
}
|
||||
|
||||
await writeJourneyRunEntry(
|
||||
PLAYBACK_JOURNEY_ID,
|
||||
iterations,
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
electronVersion
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
countJourneyMainIpcInFlight,
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
peekJourneyMainIpcCaptures,
|
||||
} from './journey-main-ipc-capture';
|
||||
import type { JourneyMockRequestLedger } from './journey-mock-request-ledger';
|
||||
import { waitForJourneyQuiet } from './journey-quiet-wait';
|
||||
|
||||
/**
|
||||
* Settling for the click-started journeys (J2 "Open a source", J3
|
||||
* "Playback"). Before the click the app must be quiet, so leftovers of the
|
||||
* startup or of the navigation to the start screen are not attributed to
|
||||
* the click. Contract: docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
export const JOURNEY_CLICK_QUIET_MS = 1_000;
|
||||
const POLL_MS = 100;
|
||||
const SETTLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** How long the app was left alone before the click, and what it did. */
|
||||
export interface JourneyClickSettle {
|
||||
readonly preStartDomMutations: number;
|
||||
readonly preStartHttpRequests: number;
|
||||
readonly preStartIpcCalls: number;
|
||||
readonly quietMs: number;
|
||||
readonly waitedMs: number;
|
||||
}
|
||||
|
||||
export interface JourneyClickSettleOptions {
|
||||
/** Prefix of the timeout errors, e.g. `open-source-journey`. */
|
||||
readonly errorPrefix: string;
|
||||
/** State key of the journey's own IPC capture (with a start sentinel). */
|
||||
readonly ipcCaptureStateKey: string;
|
||||
readonly probeStateKey: string;
|
||||
}
|
||||
|
||||
interface ActivitySample {
|
||||
readonly domMutations: number;
|
||||
readonly httpInFlight: number;
|
||||
readonly httpRequests: number;
|
||||
readonly ipcCalls: number;
|
||||
readonly ipcInFlight: number;
|
||||
}
|
||||
|
||||
async function readPreStartMutations(
|
||||
page: Page,
|
||||
stateKey: string
|
||||
): Promise<number> {
|
||||
return page.evaluate((key) => {
|
||||
const state = (globalThis as unknown as Record<string, unknown>)[
|
||||
key
|
||||
] as { preStart?: { domMutations?: number } } | undefined;
|
||||
const count = state?.preStart?.domMutations;
|
||||
if (typeof count !== 'number') {
|
||||
throw new Error('journey-renderer-probe-not-armed');
|
||||
}
|
||||
return count;
|
||||
}, stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until DOM, bridge and mock traffic have all been unchanged for
|
||||
* `JOURNEY_CLICK_QUIET_MS` with no mock request and no bridge call in
|
||||
* flight: a slow response or a pending bridge call can still change the DOM
|
||||
* or trigger follow-up work after the click. Pending bridge calls come from
|
||||
* J1's capture, which was installed before the document loaded and so has
|
||||
* seen every call start. An app that never settles fails the iteration
|
||||
* instead of producing a count that includes its background work.
|
||||
*/
|
||||
export async function waitForJourneyClickQuiet(
|
||||
electronApp: ElectronApplication,
|
||||
page: Page,
|
||||
ledger: JourneyMockRequestLedger,
|
||||
options: JourneyClickSettleOptions
|
||||
): Promise<{
|
||||
/** Ledger position read by the accepted quiet sample itself. */
|
||||
readonly ledgerMark: number;
|
||||
readonly settle: JourneyClickSettle;
|
||||
}> {
|
||||
const armMark = ledger.mark();
|
||||
const sample = async (): Promise<ActivitySample> => {
|
||||
// Both captures in one snapshot: a call counted by the journey's
|
||||
// capture is then also pending in J1's, never counted with a stale
|
||||
// in-flight 0.
|
||||
const [launchCapture, journeyCapture] =
|
||||
await peekJourneyMainIpcCaptures(electronApp, [
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
options.ipcCaptureStateKey,
|
||||
]);
|
||||
if (launchCapture.unmatchedCompletions > 0) {
|
||||
throw new Error(
|
||||
`${options.errorPrefix}-bridge-completions-unmatched`
|
||||
);
|
||||
}
|
||||
return {
|
||||
domMutations: await readPreStartMutations(
|
||||
page,
|
||||
options.probeStateKey
|
||||
),
|
||||
httpInFlight: ledger.inFlight(),
|
||||
httpRequests: ledger.mark(),
|
||||
ipcCalls: journeyCapture.callsBeforeStart,
|
||||
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
|
||||
};
|
||||
};
|
||||
const { sample: quiet, waitedMs } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.httpInFlight + activity.ipcInFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: JOURNEY_CLICK_QUIET_MS,
|
||||
sample,
|
||||
timeoutError: (activity) =>
|
||||
new Error(
|
||||
`${options.errorPrefix}-not-quiet: ${JSON.stringify(activity)}`
|
||||
),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
ledgerMark: quiet.httpRequests,
|
||||
settle: {
|
||||
preStartDomMutations: quiet.domMutations,
|
||||
preStartHttpRequests: quiet.httpRequests - armMark,
|
||||
preStartIpcCalls: quiet.ipcCalls,
|
||||
quietMs: JOURNEY_CLICK_QUIET_MS,
|
||||
waitedMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the mock has seen no new request for `JOURNEY_CLICK_QUIET_MS`
|
||||
* and none is in flight, so responses slower than the quiet interval and
|
||||
* the requests they trigger stay inside the measured window.
|
||||
*/
|
||||
export async function waitForJourneyMockQuiet(
|
||||
ledger: JourneyMockRequestLedger,
|
||||
errorPrefix: string
|
||||
): Promise<number> {
|
||||
const { sample: quiet } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.inFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: JOURNEY_CLICK_QUIET_MS,
|
||||
sample: async () => ({
|
||||
inFlight: ledger.inFlight(),
|
||||
requests: ledger.mark(),
|
||||
}),
|
||||
timeoutError: () => new Error(`${errorPrefix}-mock-not-quiet`),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
// The window ends at the ledger position this accepted sample read. A
|
||||
// request that arrives after it was never seen in flight, so its
|
||||
// response and follow-ups are not waited for; counting it would make
|
||||
// the counter depend on when the ledger is read.
|
||||
return quiet.requests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects an iteration whose activity moved after the settle snapshot but
|
||||
* before the click (while Playwright ran its actionability checks): it
|
||||
* could complete after the click and be counted as the journey's. The probe
|
||||
* and the capture keep counting until the click itself, so they must still
|
||||
* match the snapshot. Returns the kinds that moved.
|
||||
*/
|
||||
export function journeyActivityBeforeClick(
|
||||
settle: JourneyClickSettle,
|
||||
observed: {
|
||||
readonly httpAfterSettleBeforeClick: number;
|
||||
readonly ipcCallsBeforeStart: number;
|
||||
readonly preStartDomMutations: number;
|
||||
}
|
||||
): string[] {
|
||||
return [
|
||||
observed.preStartDomMutations !== settle.preStartDomMutations
|
||||
? 'dom'
|
||||
: null,
|
||||
observed.ipcCallsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
|
||||
observed.httpAfterSettleBeforeClick > 0 ? 'http' : null,
|
||||
].filter((kind): kind is string => kind !== null);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import {
|
||||
assertJourneyRendererProbeState,
|
||||
createPlaybackJourneyProbeOptions,
|
||||
JOURNEY_IPC_SENTINEL_ID,
|
||||
JOURNEY_IPC_SENTINEL_METHOD,
|
||||
JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY,
|
||||
JOURNEY_PLAYBACK_END_SENTINEL_ID,
|
||||
JOURNEY_PLAYBACK_PROBE_STATE_KEY,
|
||||
JOURNEY_PLAYBACK_START_SENTINEL_ID,
|
||||
JOURNEY_PROBE_STATE_KEY,
|
||||
type JourneyRendererProbeOptions,
|
||||
} from './journey-renderer-probe';
|
||||
import {
|
||||
createFixtureFromDom,
|
||||
settle,
|
||||
type FakeObserver,
|
||||
type Fixture,
|
||||
} from './journey-renderer-probe.test-helpers';
|
||||
|
||||
const LIVE_URL = 'http://localhost/workspace/xtreams/playlist-1/live';
|
||||
|
||||
interface PlaybackFixture extends Fixture {
|
||||
readonly channel: HTMLElement;
|
||||
readonly player: HTMLElement;
|
||||
}
|
||||
|
||||
function createPlaybackFixture(
|
||||
overrides: Partial<JourneyRendererProbeOptions> = {}
|
||||
): PlaybackFixture {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><html><body><app-root>
|
||||
<app-live-stream-layout>
|
||||
<div data-test-id="channel-item"><span class="name">Channel 1</span></div>
|
||||
<app-web-player-view></app-web-player-view>
|
||||
</app-live-stream-layout></app-root></body></html>`,
|
||||
{ pretendToBeVisual: true, runScripts: 'outside-only', url: LIVE_URL }
|
||||
);
|
||||
const fixture = createFixtureFromDom(
|
||||
dom,
|
||||
{ ...createPlaybackJourneyProbeOptions(), ...overrides },
|
||||
true
|
||||
);
|
||||
const { document } = fixture.window;
|
||||
return {
|
||||
...fixture,
|
||||
channel: document.querySelector(
|
||||
'[data-test-id="channel-item"]'
|
||||
) as HTMLElement,
|
||||
player: document.querySelector('app-web-player-view') as HTMLElement,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the player does on the click: mount a video element. */
|
||||
function mountVideo(fixture: PlaybackFixture): HTMLVideoElement {
|
||||
const video = fixture.window.document.createElement('video');
|
||||
fixture.player.append(video);
|
||||
return video;
|
||||
}
|
||||
|
||||
function fire(fixture: Fixture, target: EventTarget, type: string): void {
|
||||
// Media events do not bubble; the probe must see them in capture.
|
||||
target.dispatchEvent(new fixture.window.Event(type, { bubbles: false }));
|
||||
}
|
||||
|
||||
test('playback options start at a live channel row and end on the player video', () => {
|
||||
const options = createPlaybackJourneyProbeOptions();
|
||||
assert.equal(options.journey, 'playback');
|
||||
assert.equal(options.stateKey, JOURNEY_PLAYBACK_PROBE_STATE_KEY);
|
||||
assert.notEqual(options.stateKey, JOURNEY_PROBE_STATE_KEY);
|
||||
assert.notEqual(options.stateKey, JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY);
|
||||
assert.equal(options.sentinelId, JOURNEY_PLAYBACK_END_SENTINEL_ID);
|
||||
assert.notEqual(options.sentinelId, JOURNEY_IPC_SENTINEL_ID);
|
||||
assert.equal(options.sentinelMethod, JOURNEY_IPC_SENTINEL_METHOD);
|
||||
assert.equal(
|
||||
options.startClick?.sentinelId,
|
||||
JOURNEY_PLAYBACK_START_SENTINEL_ID
|
||||
);
|
||||
assert.match(options.startClick?.selector ?? '', /channel-item/);
|
||||
assert.equal(options.cardSelector, 'app-web-player-view video');
|
||||
assert.deepEqual(options.media, {
|
||||
endEvent: 'playing',
|
||||
phaseEvents: ['loadedmetadata'],
|
||||
});
|
||||
});
|
||||
|
||||
test('ends at the playing event, not when the video becomes visible, and records loadedmetadata on the way', async () => {
|
||||
const fixture = createPlaybackFixture();
|
||||
let video: HTMLVideoElement | null = null;
|
||||
fixture.channel.addEventListener('click', () => {
|
||||
video = mountVideo(fixture);
|
||||
});
|
||||
(fixture.channel.querySelector('.name') as HTMLElement).click();
|
||||
await settle();
|
||||
assert.ok(video, 'the app mounted a video');
|
||||
const mounted = video as HTMLVideoElement;
|
||||
assert.equal(
|
||||
fixture.state().terminal,
|
||||
null,
|
||||
'a visible video is not enough'
|
||||
);
|
||||
assert.deepEqual(fixture.bridgeCalls, [JOURNEY_PLAYBACK_START_SENTINEL_ID]);
|
||||
|
||||
fire(fixture, mounted, 'loadedmetadata');
|
||||
await settle();
|
||||
const afterMetadata = fixture.state();
|
||||
assert.equal(afterMetadata.terminal, null);
|
||||
assert.equal(
|
||||
typeof afterMetadata.media?.phases['loadedmetadata'],
|
||||
'number'
|
||||
);
|
||||
|
||||
// Mutations queued in the same task as the event still count: the
|
||||
// probe takes them synchronously at the event.
|
||||
mounted.setAttribute('data-state', 'playing');
|
||||
fire(fixture, mounted, 'playing');
|
||||
mounted.setAttribute('data-state', 'after');
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.deepEqual(fixture.bridgeCalls, [
|
||||
JOURNEY_PLAYBACK_START_SENTINEL_ID,
|
||||
JOURNEY_PLAYBACK_END_SENTINEL_ID,
|
||||
]);
|
||||
assert.ok(state.start && state.terminal && state.media?.element);
|
||||
assert.equal(state.start.targetTestId, 'channel-item');
|
||||
assert.equal(state.terminal.cardTag, 'video');
|
||||
assert.equal(state.terminal.cardCount, 1);
|
||||
assert.deepEqual(state.terminal.companionCounts, []);
|
||||
assert.equal(state.terminal.pathname, '/workspace/xtreams/playlist-1/live');
|
||||
// video mounted (1) + data-state=playing (1); the later change is not.
|
||||
assert.equal(state.counters.domMutations, 2);
|
||||
const metadataEpochMs = state.media.phases['loadedmetadata'] as number;
|
||||
assert.ok(state.start.epochMs <= metadataEpochMs);
|
||||
assert.ok(metadataEpochMs <= state.terminal.epochMs);
|
||||
assert.equal(state.media.phases['playing'], state.terminal.epochMs);
|
||||
assert.equal(state.media.element.paused, true);
|
||||
assert.equal(state.final, true);
|
||||
assert.equal(state.settle.status, 'disabled');
|
||||
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
|
||||
});
|
||||
|
||||
test('ignores media events before the click and on other media elements', async () => {
|
||||
const fixture = createPlaybackFixture();
|
||||
const early = mountVideo(fixture);
|
||||
fire(fixture, early, 'loadedmetadata');
|
||||
fire(fixture, early, 'playing');
|
||||
await settle();
|
||||
assert.equal(fixture.state().start, null);
|
||||
assert.equal(fixture.state().terminal, null);
|
||||
|
||||
fixture.channel.click();
|
||||
const preview = fixture.window.document.createElement('video');
|
||||
fixture.window.document.body.append(preview);
|
||||
fire(fixture, preview, 'playing');
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.ok(state.start);
|
||||
assert.equal(state.terminal, null);
|
||||
assert.deepEqual(state.media?.phases, {});
|
||||
assert.deepEqual(fixture.bridgeCalls, [JOURNEY_PLAYBACK_START_SENTINEL_ID]);
|
||||
});
|
||||
|
||||
test('counts shifts and long tasks up to the playing event, not to the post-paint cutoff', async () => {
|
||||
const fixture = createPlaybackFixture();
|
||||
const [layoutShift, longTask] = fixture.observers as [
|
||||
FakeObserver,
|
||||
FakeObserver,
|
||||
];
|
||||
const now = () => fixture.window.performance.now();
|
||||
fixture.channel.click();
|
||||
const video = mountVideo(fixture);
|
||||
layoutShift.emit([
|
||||
{
|
||||
entryType: 'layout-shift',
|
||||
hadRecentInput: true,
|
||||
startTime: now(),
|
||||
value: 0.004,
|
||||
},
|
||||
]);
|
||||
longTask.emit([{ duration: 80, entryType: 'longtask', startTime: now() }]);
|
||||
// Leave room between the click and the event for the entries below.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
fire(fixture, video, 'playing');
|
||||
const playingMs =
|
||||
(fixture.rawState().terminal?.epochMs ?? 0) -
|
||||
fixture.window.performance.timeOrigin;
|
||||
// Delivered after the event: one started before it, one after.
|
||||
layoutShift.emit([
|
||||
{
|
||||
entryType: 'layout-shift',
|
||||
hadRecentInput: false,
|
||||
startTime: playingMs - 1,
|
||||
value: 0.002,
|
||||
},
|
||||
{
|
||||
entryType: 'layout-shift',
|
||||
hadRecentInput: false,
|
||||
startTime: playingMs + 5,
|
||||
value: 0.5,
|
||||
},
|
||||
]);
|
||||
longTask.emit([
|
||||
// The task that dispatched `playing` began before it and counts.
|
||||
{ duration: 60, entryType: 'longtask', startTime: playingMs - 10 },
|
||||
{ duration: 300, entryType: 'longtask', startTime: playingMs + 5 },
|
||||
]);
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.equal(state.final, true);
|
||||
assert.equal(state.counters.recentInputLayoutShiftScore, 0.004);
|
||||
assert.equal(state.counters.layoutShiftScore, 0.002);
|
||||
assert.equal(state.counters.longTasks, 2);
|
||||
assert.deepEqual(state.longTaskDurationsMs, [80, 60]);
|
||||
assert.ok((state.firstCardPaintEpochMs ?? 0) >= state.terminal!.epochMs);
|
||||
});
|
||||
|
||||
test('a media terminal without a click start is invalid', () => {
|
||||
const fixture = createPlaybackFixture({ startClick: undefined });
|
||||
assert.ok(
|
||||
fixture
|
||||
.state()
|
||||
.invalidReasons.includes('media-terminal-needs-start-click')
|
||||
);
|
||||
});
|
||||
|
||||
test('other journeys carry no media block', () => {
|
||||
const fixture = createPlaybackFixture({ media: undefined });
|
||||
assert.equal(fixture.state().media, null);
|
||||
});
|
||||
@@ -20,38 +20,14 @@ import {
|
||||
JOURNEY_SETTLE_ROOT_SELECTOR,
|
||||
journeyRendererProbeScript,
|
||||
type JourneyRendererProbeOptions,
|
||||
type JourneyRendererProbeState,
|
||||
} from './journey-renderer-probe';
|
||||
|
||||
interface FakeEntry {
|
||||
duration?: number;
|
||||
entryType: string;
|
||||
hadRecentInput?: boolean;
|
||||
sources?: {
|
||||
currentRect: { height: number; y: number };
|
||||
node: unknown;
|
||||
previousRect: { height: number; y: number };
|
||||
}[];
|
||||
startTime: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface FakeObserver {
|
||||
disconnected: boolean;
|
||||
emit(entries: FakeEntry[]): void;
|
||||
queue: FakeEntry[];
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly bridgeCalls: unknown[];
|
||||
readonly observers: FakeObserver[];
|
||||
/** The live state object inside the jsdom realm. */
|
||||
readonly rawState: () => JourneyRendererProbeState;
|
||||
/** A JSON clone, so assertions compare values across realms. */
|
||||
readonly state: () => JourneyRendererProbeState;
|
||||
readonly window: JSDOM['window'];
|
||||
}
|
||||
import {
|
||||
createFixtureFromDom,
|
||||
settle,
|
||||
type FakeEntry,
|
||||
type FakeObserver,
|
||||
type Fixture,
|
||||
} from './journey-renderer-probe.test-helpers';
|
||||
|
||||
const PAGE = `<!doctype html><html><head></head><body class="mat-app-background">
|
||||
<div id="initial-splash" role="status"><span>IPTVnator</span></div>
|
||||
@@ -64,55 +40,6 @@ const FAST_SETTLE = {
|
||||
rootSelector: JOURNEY_SETTLE_ROOT_SELECTOR,
|
||||
} as const;
|
||||
|
||||
function installFakePerformance(
|
||||
window: JSDOM['window'],
|
||||
observers: FakeObserver[]
|
||||
): void {
|
||||
class FakePerformanceObserver implements FakeObserver {
|
||||
disconnected = false;
|
||||
queue: FakeEntry[] = [];
|
||||
type: string | null = null;
|
||||
constructor(
|
||||
private readonly callback: (list: {
|
||||
getEntries(): FakeEntry[];
|
||||
}) => void
|
||||
) {
|
||||
observers.push(this);
|
||||
}
|
||||
observe(options: { type: string }): void {
|
||||
this.type = options.type;
|
||||
}
|
||||
takeRecords(): FakeEntry[] {
|
||||
const queued = this.queue;
|
||||
this.queue = [];
|
||||
return queued;
|
||||
}
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
emit(entries: FakeEntry[]): void {
|
||||
this.callback({ getEntries: () => entries });
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'PerformanceObserver', {
|
||||
configurable: true,
|
||||
value: FakePerformanceObserver,
|
||||
});
|
||||
Object.defineProperty(window.performance, 'getEntriesByType', {
|
||||
configurable: true,
|
||||
value: (type: string) =>
|
||||
type === 'navigation'
|
||||
? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }]
|
||||
: [],
|
||||
});
|
||||
// jsdom never lays out, so visibility is "connected to the document".
|
||||
window.HTMLElement.prototype.getClientRects = function getClientRects(
|
||||
this: HTMLElement
|
||||
) {
|
||||
return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(
|
||||
overrides: Partial<JourneyRendererProbeOptions> & {
|
||||
bridge?: boolean;
|
||||
@@ -140,53 +67,6 @@ function createFixture(
|
||||
);
|
||||
}
|
||||
|
||||
function createFixtureFromDom(
|
||||
dom: JSDOM,
|
||||
options: JourneyRendererProbeOptions,
|
||||
bridge: boolean
|
||||
): Fixture {
|
||||
const { window } = dom;
|
||||
const observers: FakeObserver[] = [];
|
||||
const bridgeCalls: unknown[] = [];
|
||||
installFakePerformance(window, observers);
|
||||
// tsx (esbuild keepNames) rewrites named inner functions as
|
||||
// `__name(fn, 'name')` when it transpiles the probe for this test runner.
|
||||
// Playwright's Babel transform, which serializes the probe for the real
|
||||
// browser, does not, so the shim is a test-runner concern only.
|
||||
Object.defineProperty(window, '__name', {
|
||||
configurable: true,
|
||||
value: (target: unknown) => target,
|
||||
});
|
||||
if (bridge) {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: Object.freeze({
|
||||
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
|
||||
bridgeCalls.push(id);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
onSomething: () => undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
window.eval(
|
||||
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})`
|
||||
);
|
||||
const rawState = (): JourneyRendererProbeState =>
|
||||
(window as unknown as Record<string, JourneyRendererProbeState>)[
|
||||
options.stateKey
|
||||
] as JourneyRendererProbeState;
|
||||
liveStates.push(rawState);
|
||||
return {
|
||||
bridgeCalls,
|
||||
observers,
|
||||
rawState,
|
||||
state: () =>
|
||||
JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState,
|
||||
window,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The electron-performance build's tick counter, installed in the fixture's
|
||||
* window the way `environment.performance.ts` installs it before bootstrap.
|
||||
@@ -200,34 +80,6 @@ function installTickCounter(fixture: Fixture): { count: number } {
|
||||
return counter;
|
||||
}
|
||||
|
||||
/** Probes created by this file, so `settle` can wait for their cutoff. */
|
||||
const liveStates: (() => JourneyRendererProbeState)[] = [];
|
||||
|
||||
/**
|
||||
* Waits `ms`, then until every probe that reached its terminal batch has also
|
||||
* passed the post-paint cutoff (a rAF plus a timer) and closed its settle
|
||||
* window. A fixed delay alone flakes when the harness runs all spec files in
|
||||
* parallel.
|
||||
*/
|
||||
async function settle(ms = 40): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (
|
||||
liveStates.some((read) => {
|
||||
const state = read();
|
||||
return (
|
||||
state.terminal !== null &&
|
||||
(!state.final ||
|
||||
state.settle.status === 'pending' ||
|
||||
state.idle.status === 'pending')
|
||||
);
|
||||
}) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function renderFirstCard(fixture: Fixture): void {
|
||||
const { document } = fixture.window;
|
||||
document.getElementById('initial-splash')?.remove();
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { JSDOM } from 'jsdom';
|
||||
|
||||
import {
|
||||
JOURNEY_IPC_SENTINEL_METHOD,
|
||||
journeyRendererProbeScript,
|
||||
type JourneyRendererProbeOptions,
|
||||
type JourneyRendererProbeState,
|
||||
} from './journey-renderer-probe';
|
||||
|
||||
/**
|
||||
* jsdom fixtures shared by the renderer probe specs: fake performance
|
||||
* observers, a frozen bridge that records sentinel calls, and a wait for
|
||||
* every probe's post-paint cutoff.
|
||||
*/
|
||||
export interface FakeEntry {
|
||||
duration?: number;
|
||||
entryType: string;
|
||||
hadRecentInput?: boolean;
|
||||
sources?: {
|
||||
currentRect: { height: number; y: number };
|
||||
node: unknown;
|
||||
previousRect: { height: number; y: number };
|
||||
}[];
|
||||
startTime: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface FakeObserver {
|
||||
disconnected: boolean;
|
||||
emit(entries: FakeEntry[]): void;
|
||||
queue: FakeEntry[];
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
export interface Fixture {
|
||||
readonly bridgeCalls: unknown[];
|
||||
readonly observers: FakeObserver[];
|
||||
/** The live state object inside the jsdom realm. */
|
||||
readonly rawState: () => JourneyRendererProbeState;
|
||||
/** A JSON clone, so assertions compare values across realms. */
|
||||
readonly state: () => JourneyRendererProbeState;
|
||||
readonly window: JSDOM['window'];
|
||||
}
|
||||
|
||||
export function installFakePerformance(
|
||||
window: JSDOM['window'],
|
||||
observers: FakeObserver[]
|
||||
): void {
|
||||
class FakePerformanceObserver implements FakeObserver {
|
||||
disconnected = false;
|
||||
queue: FakeEntry[] = [];
|
||||
type: string | null = null;
|
||||
constructor(
|
||||
private readonly callback: (list: {
|
||||
getEntries(): FakeEntry[];
|
||||
}) => void
|
||||
) {
|
||||
observers.push(this);
|
||||
}
|
||||
observe(options: { type: string }): void {
|
||||
this.type = options.type;
|
||||
}
|
||||
takeRecords(): FakeEntry[] {
|
||||
const queued = this.queue;
|
||||
this.queue = [];
|
||||
return queued;
|
||||
}
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
emit(entries: FakeEntry[]): void {
|
||||
this.callback({ getEntries: () => entries });
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'PerformanceObserver', {
|
||||
configurable: true,
|
||||
value: FakePerformanceObserver,
|
||||
});
|
||||
Object.defineProperty(window.performance, 'getEntriesByType', {
|
||||
configurable: true,
|
||||
value: (type: string) =>
|
||||
type === 'navigation'
|
||||
? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }]
|
||||
: [],
|
||||
});
|
||||
// jsdom never lays out, so visibility is "connected to the document".
|
||||
window.HTMLElement.prototype.getClientRects = function getClientRects(
|
||||
this: HTMLElement
|
||||
) {
|
||||
return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFixtureFromDom(
|
||||
dom: JSDOM,
|
||||
options: JourneyRendererProbeOptions,
|
||||
bridge: boolean
|
||||
): Fixture {
|
||||
const { window } = dom;
|
||||
const observers: FakeObserver[] = [];
|
||||
const bridgeCalls: unknown[] = [];
|
||||
installFakePerformance(window, observers);
|
||||
// tsx (esbuild keepNames) rewrites named inner functions as
|
||||
// `__name(fn, 'name')` when it transpiles the probe for this test runner.
|
||||
// Playwright's Babel transform, which serializes the probe for the real
|
||||
// browser, does not, so the shim is a test-runner concern only.
|
||||
Object.defineProperty(window, '__name', {
|
||||
configurable: true,
|
||||
value: (target: unknown) => target,
|
||||
});
|
||||
if (bridge) {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: Object.freeze({
|
||||
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
|
||||
bridgeCalls.push(id);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
onSomething: () => undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
window.eval(
|
||||
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})`
|
||||
);
|
||||
const rawState = (): JourneyRendererProbeState =>
|
||||
(window as unknown as Record<string, JourneyRendererProbeState>)[
|
||||
options.stateKey
|
||||
] as JourneyRendererProbeState;
|
||||
liveStates.push(rawState);
|
||||
return {
|
||||
bridgeCalls,
|
||||
observers,
|
||||
rawState,
|
||||
state: () =>
|
||||
JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState,
|
||||
window,
|
||||
};
|
||||
}
|
||||
|
||||
/** Probes created by this file, so `settle` can wait for their cutoff. */
|
||||
const liveStates: (() => JourneyRendererProbeState)[] = [];
|
||||
|
||||
/**
|
||||
* Waits `ms`, then until every probe that reached its terminal batch has also
|
||||
* passed the post-paint cutoff (a rAF plus a timer) and closed its settle
|
||||
* window. A fixed delay alone flakes when the harness runs all spec files in
|
||||
* parallel.
|
||||
*/
|
||||
export async function settle(ms = 40): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (
|
||||
liveStates.some((read) => {
|
||||
const state = read();
|
||||
return (
|
||||
state.terminal !== null &&
|
||||
(!state.final ||
|
||||
state.settle.status === 'pending' ||
|
||||
state.idle.status === 'pending')
|
||||
);
|
||||
}) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import type { Page } from '@playwright/test';
|
||||
* (J1) it keeps summing layout shifts after the first card until the page
|
||||
* has settled, for shifts such as collapsing skeletons that land later, and
|
||||
* with `options.idle` it then counts what the untouched page does for a
|
||||
* fixed window.
|
||||
* fixed window. With `options.media` (J3 "Playback") the terminal condition
|
||||
* is a media event (`playing`) on an element matching `cardSelector` instead
|
||||
* of that element becoming visible.
|
||||
*
|
||||
* Change-detection ticks are read from the counter that only the
|
||||
* `electron-performance` build of `apps/web` installs
|
||||
@@ -42,6 +44,17 @@ export const JOURNEY_OPEN_SOURCE_START_SENTINEL_ID =
|
||||
'__iptvnator-journey-open-source-start__';
|
||||
export const JOURNEY_OPEN_SOURCE_END_SENTINEL_ID =
|
||||
'__iptvnator-journey-open-source-end__';
|
||||
export const JOURNEY_PLAYBACK_PROBE_STATE_KEY =
|
||||
'__iptvnatorJourneyPlaybackProbe';
|
||||
export const JOURNEY_PLAYBACK_START_SENTINEL_ID =
|
||||
'__iptvnator-journey-playback-start__';
|
||||
export const JOURNEY_PLAYBACK_END_SENTINEL_ID =
|
||||
'__iptvnator-journey-playback-end__';
|
||||
/** A live channel row in the Xtream live layout. */
|
||||
export const JOURNEY_PLAYBACK_START_SELECTOR =
|
||||
'app-live-stream-layout [data-test-id="channel-item"]';
|
||||
/** The HTML5 player's video element inside the web player view. */
|
||||
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
|
||||
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
|
||||
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
|
||||
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
|
||||
@@ -73,6 +86,17 @@ export interface JourneyRendererProbeSettleOptions {
|
||||
readonly rootSelector: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A journey that ends on a media event rather than on visibility (J3). The
|
||||
* first `endEvent` after the start on an element matching `cardSelector`
|
||||
* is the terminal moment; the first of each `phaseEvents` after the start
|
||||
* is recorded under `media.phases`.
|
||||
*/
|
||||
export interface JourneyRendererProbeMediaOptions {
|
||||
readonly endEvent: string;
|
||||
readonly phaseEvents: readonly string[];
|
||||
}
|
||||
|
||||
export const JOURNEY_SETTLE_QUIET_MS = 500;
|
||||
export const JOURNEY_SETTLE_CAP_MS = 3_000;
|
||||
/** The workspace shell's content pane; the rail and header stay outside. */
|
||||
@@ -105,6 +129,8 @@ export interface JourneyRendererProbeOptions {
|
||||
/** Absent: no idle window. Needs `settle`, which it follows. */
|
||||
readonly idle?: JourneyRendererProbeIdleOptions;
|
||||
readonly journey: string;
|
||||
/** Absent: the journey ends when `cardSelector` becomes visible. */
|
||||
readonly media?: JourneyRendererProbeMediaOptions;
|
||||
/** Pathname fragment the terminal route must contain. */
|
||||
readonly routeFragment: string;
|
||||
readonly sentinelId: string;
|
||||
@@ -183,6 +209,20 @@ export interface JourneyRendererProbeState {
|
||||
readonly invalidReasons: string[];
|
||||
readonly journey: string;
|
||||
readonly longTaskDurationsMs: number[];
|
||||
/** Media-terminated journeys only; null otherwise. */
|
||||
readonly media: {
|
||||
/** At the terminal event, the element it fired on. */
|
||||
element: {
|
||||
readonly currentSrcScheme: string;
|
||||
readonly currentTime: number;
|
||||
readonly paused: boolean;
|
||||
readonly readyState: number;
|
||||
readonly videoHeight: number;
|
||||
readonly videoWidth: number;
|
||||
} | null;
|
||||
/** Event type → epoch of its first occurrence after the start. */
|
||||
readonly phases: Record<string, number>;
|
||||
} | null;
|
||||
navigation: {
|
||||
readonly domContentLoadedEpochMs: number;
|
||||
readonly loadEventEndEpochMs: number;
|
||||
@@ -243,6 +283,7 @@ export function journeyRendererProbeScript(
|
||||
}
|
||||
const epoch = (): number => performance.timeOrigin + performance.now();
|
||||
const startClick = options.startClick ?? null;
|
||||
const mediaOptions = options.media ?? null;
|
||||
const settleOptions = options.settle ?? null;
|
||||
const idleOptions = options.idle ?? null;
|
||||
const companionSelectors = options.companionSelectors ?? [];
|
||||
@@ -283,6 +324,7 @@ export function journeyRendererProbeScript(
|
||||
invalidReasons: [],
|
||||
journey: options.journey,
|
||||
longTaskDurationsMs: [],
|
||||
media: mediaOptions === null ? null : { element: null, phases: {} },
|
||||
navigation: null,
|
||||
preStart: { domMutations: 0, lastMutationEpochMs: null },
|
||||
schemaVersion: 1,
|
||||
@@ -315,6 +357,9 @@ export function journeyRendererProbeScript(
|
||||
) {
|
||||
state.invalidReasons.push('probe-installed-after-document-start');
|
||||
}
|
||||
if (mediaOptions !== null && startClick === null) {
|
||||
state.invalidReasons.push('media-terminal-needs-start-click');
|
||||
}
|
||||
// Performance entries before the journey's start belong to an earlier
|
||||
// journey (buffered entries included) and are dropped.
|
||||
let fromEpochMs =
|
||||
@@ -554,20 +599,27 @@ export function journeyRendererProbeScript(
|
||||
armQuiet();
|
||||
};
|
||||
|
||||
// A media journey's counters stop at the terminal event itself (the
|
||||
// task that dispatched it still overlaps and counts); the others count
|
||||
// until the post-paint cutoff.
|
||||
const finalize = (untilEpochMs: number): void => {
|
||||
const countUntilEpochMs =
|
||||
mediaOptions !== null && state.terminal !== null
|
||||
? state.terminal.epochMs
|
||||
: untilEpochMs;
|
||||
if (layoutShiftObserver) {
|
||||
const records = layoutShiftObserver.takeRecords();
|
||||
collectSettleShifts(records);
|
||||
acceptLayoutShift(
|
||||
[...pendingLayoutShifts, ...records],
|
||||
untilEpochMs
|
||||
countUntilEpochMs
|
||||
);
|
||||
if (settleOptions === null) layoutShiftObserver.disconnect();
|
||||
}
|
||||
if (longTaskObserver) {
|
||||
acceptLongTasks(
|
||||
[...pendingLongTasks, ...longTaskObserver.takeRecords()],
|
||||
untilEpochMs
|
||||
countUntilEpochMs
|
||||
);
|
||||
longTaskObserver.disconnect();
|
||||
}
|
||||
@@ -626,6 +678,7 @@ export function journeyRendererProbeScript(
|
||||
}
|
||||
state.counters.domMutations += records.length;
|
||||
if (
|
||||
mediaOptions !== null ||
|
||||
!location.pathname.includes(options.routeFragment) ||
|
||||
document.getElementById(options.splashId) !== null
|
||||
) {
|
||||
@@ -638,12 +691,19 @@ export function journeyRendererProbeScript(
|
||||
if (!isVisible(document.querySelector(selector))) return;
|
||||
companionCounts.push(document.querySelectorAll(selector).length);
|
||||
}
|
||||
end(card, companionCounts, epoch());
|
||||
});
|
||||
const end = (
|
||||
card: Element,
|
||||
companionCounts: number[],
|
||||
epochMs: number
|
||||
): void => {
|
||||
state.terminal = {
|
||||
cardCount: document.querySelectorAll(options.cardSelector).length,
|
||||
cardTag: card.tagName.toLowerCase(),
|
||||
cardTestId: card.getAttribute('data-test-id'),
|
||||
companionCounts,
|
||||
epochMs: epoch(),
|
||||
epochMs,
|
||||
pathname: location.pathname,
|
||||
};
|
||||
mutationObserver.disconnect();
|
||||
@@ -656,8 +716,9 @@ export function journeyRendererProbeScript(
|
||||
);
|
||||
}
|
||||
}
|
||||
// The tick that rendered the card ran before this microtask, so
|
||||
// it is included; no other tick can run in between.
|
||||
// A tick that rendered the card ran before this microtask (or,
|
||||
// for a media terminal, before the event's task), so it is
|
||||
// included; no other tick can run in between.
|
||||
const ticks = readTicks();
|
||||
if (ticks === null || ticksAtStart === null) {
|
||||
state.capabilities.changeDetectionTicks =
|
||||
@@ -673,7 +734,7 @@ export function journeyRendererProbeScript(
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => finalize(epoch()), 0);
|
||||
});
|
||||
});
|
||||
};
|
||||
mutationObserver.observe(document.documentElement ?? document, {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
@@ -713,6 +774,43 @@ export function journeyRendererProbeScript(
|
||||
window.removeEventListener('click', onClick, true);
|
||||
};
|
||||
window.addEventListener('click', onClick, true);
|
||||
if (mediaOptions === null) return;
|
||||
|
||||
// Media events do not bubble, but a capture listener on window still
|
||||
// sees them before any listener of the app. Mutations up to the event
|
||||
// are taken synchronously, so the count ends exactly at the event.
|
||||
const onMediaEvent = (event: Event): void => {
|
||||
const element = event.target;
|
||||
if (
|
||||
state.start === null ||
|
||||
state.terminal !== null ||
|
||||
state.media === null ||
|
||||
!(element instanceof HTMLMediaElement) ||
|
||||
!element.matches(options.cardSelector)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const at = epoch();
|
||||
state.media.phases[event.type] ??= at;
|
||||
if (event.type !== mediaOptions.endEvent) return;
|
||||
state.counters.domMutations += mutationObserver.takeRecords().length;
|
||||
const video = element as HTMLMediaElement & {
|
||||
videoHeight?: number;
|
||||
videoWidth?: number;
|
||||
};
|
||||
state.media.element = {
|
||||
currentSrcScheme: element.currentSrc.split(':')[0] ?? '',
|
||||
currentTime: element.currentTime,
|
||||
paused: element.paused,
|
||||
readyState: element.readyState,
|
||||
videoHeight: video.videoHeight ?? 0,
|
||||
videoWidth: video.videoWidth ?? 0,
|
||||
};
|
||||
end(element, [], at);
|
||||
};
|
||||
for (const type of [mediaOptions.endEvent, ...mediaOptions.phaseEvents]) {
|
||||
window.addEventListener(type, onMediaEvent, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -770,6 +868,29 @@ export function createOpenSourceJourneyProbeOptions(): JourneyRendererProbeOptio
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for J3 "Playback": the click on a live channel row starts the
|
||||
* journey; it ends at the first `playing` event of the HTML5 player's video
|
||||
* element, with `loadedmetadata` recorded as an intermediate phase.
|
||||
*/
|
||||
export function createPlaybackJourneyProbeOptions(): JourneyRendererProbeOptions {
|
||||
return {
|
||||
cardSelector: JOURNEY_PLAYBACK_VIDEO_SELECTOR,
|
||||
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
|
||||
journey: 'playback',
|
||||
media: { endEvent: 'playing', phaseEvents: ['loadedmetadata'] },
|
||||
routeFragment: '/workspace/xtreams/',
|
||||
sentinelId: JOURNEY_PLAYBACK_END_SENTINEL_ID,
|
||||
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
|
||||
splashId: 'initial-splash',
|
||||
startClick: {
|
||||
selector: JOURNEY_PLAYBACK_START_SELECTOR,
|
||||
sentinelId: JOURNEY_PLAYBACK_START_SENTINEL_ID,
|
||||
},
|
||||
stateKey: JOURNEY_PLAYBACK_PROBE_STATE_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the probe on a page that is still parked on `about:blank` by the
|
||||
* journey gate, so it is guaranteed to run at the start of the next document.
|
||||
|
||||
@@ -46,6 +46,7 @@ function measurement(
|
||||
invalidReasons: [],
|
||||
journey: 'launch',
|
||||
longTaskDurationsMs: [71.26, 120.04],
|
||||
media: null,
|
||||
navigation: {
|
||||
domContentLoadedEpochMs: 1_300,
|
||||
loadEventEndEpochMs: 1_400.26,
|
||||
|
||||
@@ -54,6 +54,7 @@ function measurement(
|
||||
invalidReasons: [],
|
||||
journey: 'open-source',
|
||||
longTaskDurationsMs: [61.26],
|
||||
media: null,
|
||||
navigation: null,
|
||||
preStart: { domMutations: 4, lastMutationEpochMs: 9_100 },
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
journeyActivityBeforeClick,
|
||||
type JourneyClickSettle,
|
||||
} from './journey-click-settle';
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import {
|
||||
countJourneyMockRoutes,
|
||||
@@ -37,13 +41,7 @@ export const OPEN_SOURCE_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
|
||||
});
|
||||
|
||||
/** How long the app was left alone before the click, and what it did. */
|
||||
export interface OpenSourceJourneySettle {
|
||||
readonly preStartDomMutations: number;
|
||||
readonly preStartHttpRequests: number;
|
||||
readonly preStartIpcCalls: number;
|
||||
readonly quietMs: number;
|
||||
readonly waitedMs: number;
|
||||
}
|
||||
export type OpenSourceJourneySettle = JourneyClickSettle;
|
||||
|
||||
export interface OpenSourceJourneyMeasurement {
|
||||
readonly http: {
|
||||
@@ -108,13 +106,11 @@ export function toOpenSourceIterationRecord(
|
||||
// the click and be counted as J2. The probe and the capture keep
|
||||
// counting until the click itself, so they must still match the
|
||||
// snapshot; otherwise the iteration is rejected.
|
||||
const lateActivity = [
|
||||
renderer.preStart.domMutations !== settle.preStartDomMutations
|
||||
? 'dom'
|
||||
: null,
|
||||
ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
|
||||
http.afterSettleBeforeClick > 0 ? 'http' : null,
|
||||
].filter((kind) => kind !== null);
|
||||
const lateActivity = journeyActivityBeforeClick(settle, {
|
||||
httpAfterSettleBeforeClick: http.afterSettleBeforeClick,
|
||||
ipcCallsBeforeStart: ipc.callsBeforeStart,
|
||||
preStartDomMutations: renderer.preStart.domMutations,
|
||||
});
|
||||
if (lateActivity.length > 0) {
|
||||
throw new Error(
|
||||
`open-source-journey-record-activity-before-click-${lateActivity.join('-')}`
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import type { JourneyMockRequest } from './journey-mock-request-ledger';
|
||||
import type { JourneyRendererProbeState } from './journey-renderer-probe';
|
||||
import { summarizeJourneyIterations } from './journey-summary';
|
||||
import {
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
toPlaybackIterationRecord,
|
||||
type PlaybackJourneyMeasurement,
|
||||
} from './playback-journey-record';
|
||||
|
||||
const LIVE_PATH = '/dist/apps/web/workspace/xtreams/playlist-1/live';
|
||||
const STREAM_ROUTE = '/live/:username/:password/10000.ts';
|
||||
|
||||
function request(sequence: number, route: string, epochMs: number) {
|
||||
return { epochMs, method: 'GET', route, sequence } as JourneyMockRequest;
|
||||
}
|
||||
|
||||
type Start = NonNullable<JourneyRendererProbeState['start']>;
|
||||
type Terminal = NonNullable<JourneyRendererProbeState['terminal']>;
|
||||
type Media = NonNullable<JourneyRendererProbeState['media']>;
|
||||
|
||||
function measurement(
|
||||
overrides: Partial<PlaybackJourneyMeasurement> = {}
|
||||
): PlaybackJourneyMeasurement {
|
||||
const renderer: JourneyRendererProbeState = {
|
||||
capabilities: {
|
||||
changeDetectionTicks: 'counted',
|
||||
layoutShift: true,
|
||||
longTask: true,
|
||||
observedTarget: 'documentElement',
|
||||
},
|
||||
counters: {
|
||||
changeDetectionTicks: 14,
|
||||
domMutations: 6_188,
|
||||
layoutShiftScore: 0,
|
||||
layoutShiftScoreSettled: 0,
|
||||
longTasks: 0,
|
||||
recentInputLayoutShiftScore: 0.00061,
|
||||
},
|
||||
final: true,
|
||||
firstCardPaintEpochMs: 10_360,
|
||||
idle: {
|
||||
domMutations: 0,
|
||||
endEpochMs: null,
|
||||
startEpochMs: null,
|
||||
status: 'disabled',
|
||||
ticks: null,
|
||||
},
|
||||
installed: {
|
||||
bridgePresent: true,
|
||||
documentElementPresent: true,
|
||||
epochMs: 9_000,
|
||||
readyState: 'complete',
|
||||
scriptCount: 12,
|
||||
},
|
||||
invalidReasons: [],
|
||||
journey: 'playback',
|
||||
longTaskDurationsMs: [],
|
||||
media: {
|
||||
element: {
|
||||
currentSrcScheme: 'blob',
|
||||
currentTime: 0.02133,
|
||||
paused: false,
|
||||
readyState: 4,
|
||||
videoHeight: 90,
|
||||
videoWidth: 160,
|
||||
},
|
||||
phases: { loadedmetadata: 10_095.04, playing: 10_349.84 },
|
||||
},
|
||||
navigation: null,
|
||||
preStart: { domMutations: 53, lastMutationEpochMs: 8_900 },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: 10_350, status: 'sent' },
|
||||
settle: {
|
||||
domMutations: 0,
|
||||
epochMs: null,
|
||||
lastMutationEpochMs: null,
|
||||
lateShifts: [],
|
||||
observedTarget: null,
|
||||
status: 'disabled',
|
||||
},
|
||||
start: {
|
||||
epochMs: 10_000,
|
||||
listenerEpochMs: 10_000.5,
|
||||
pathname: LIVE_PATH,
|
||||
sentinelStatus: 'sent',
|
||||
targetTag: 'div',
|
||||
targetTestId: 'channel-item',
|
||||
},
|
||||
terminal: {
|
||||
cardCount: 1,
|
||||
cardTag: 'video',
|
||||
cardTestId: null,
|
||||
companionCounts: [],
|
||||
epochMs: 10_349.84,
|
||||
pathname: LIVE_PATH,
|
||||
},
|
||||
};
|
||||
const ipc: JourneyMainIpcCaptureState = {
|
||||
callsAfterSentinel: 3,
|
||||
callsBeforeStart: 6,
|
||||
callsBeforeSentinel: 4,
|
||||
callsByMethod: {
|
||||
getEpgMapping: 1,
|
||||
setUserAgent: 1,
|
||||
updateRemoteControlStatus: 1,
|
||||
xtreamRequest: 1,
|
||||
},
|
||||
inFlightByMethod: {},
|
||||
installedEpochMs: 9_500,
|
||||
malformedEvents: 0,
|
||||
processStartEpochMs: 1_000,
|
||||
senderIds: [1],
|
||||
sentinel: { occurrences: 1, receivedEpochMs: 10_351 },
|
||||
start: { occurrences: 1, receivedEpochMs: 10_001 },
|
||||
unmatchedCompletions: 0,
|
||||
};
|
||||
return {
|
||||
externalArtworkCancelled: 8,
|
||||
http: {
|
||||
afterPlaying: [
|
||||
request(9, '/player_api.php?action=get_short_epg', 10_400),
|
||||
],
|
||||
afterSettleBeforeClick: 0,
|
||||
beforeClick: [
|
||||
request(0, '/player_api.php?action=get_live_streams', 8_000),
|
||||
],
|
||||
toPlaying: [
|
||||
request(7, STREAM_ROUTE, 10_020),
|
||||
request(
|
||||
8,
|
||||
'/player_api.php?action=get_simple_data_table',
|
||||
10_030
|
||||
),
|
||||
],
|
||||
},
|
||||
ipc,
|
||||
pid: 5151,
|
||||
renderer,
|
||||
settle: {
|
||||
preStartDomMutations: 53,
|
||||
preStartHttpRequests: 4,
|
||||
preStartIpcCalls: 6,
|
||||
quietMs: 1_000,
|
||||
waitedMs: 1_809,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withRenderer(
|
||||
patch: Partial<JourneyRendererProbeState>
|
||||
): PlaybackJourneyMeasurement {
|
||||
const base = measurement();
|
||||
return { ...base, renderer: { ...base.renderer, ...patch } };
|
||||
}
|
||||
|
||||
test('maps the media-terminated probe, IPC window and mock ledger to exact counters', () => {
|
||||
const record = toPlaybackIterationRecord(2, false, measurement());
|
||||
assert.equal(record.index, 2);
|
||||
assert.equal(record.warmup, false);
|
||||
assert.equal(record.pid, 5151);
|
||||
assert.deepEqual(record.counters, {
|
||||
'renderer.cdTicksToPlaying': 14,
|
||||
'renderer.domMutationsToPlaying': 6_188,
|
||||
'renderer.httpRequestsToPlaying': 2,
|
||||
'renderer.ipcCallsToPlaying': 4,
|
||||
'renderer.layoutShiftScore': 0.001,
|
||||
'renderer.longTasks': 0,
|
||||
});
|
||||
assert.deepEqual(record.wallClock, {
|
||||
clickToLoadedMetadataMs: 95,
|
||||
clickToPlayingMs: 349.8,
|
||||
});
|
||||
assert.deepEqual(record.evidence['httpRequestsByRoute'], {
|
||||
'/live/:username/:password/10000.ts': 1,
|
||||
'/player_api.php?action=get_simple_data_table': 1,
|
||||
});
|
||||
assert.deepEqual(record.evidence['httpRequestsAfterPlayingByRoute'], {
|
||||
'/player_api.php?action=get_short_epg': 1,
|
||||
});
|
||||
assert.deepEqual(record.evidence['httpRequestsBeforeClickByRoute'], {
|
||||
'/player_api.php?action=get_live_streams': 1,
|
||||
});
|
||||
assert.deepEqual(record.evidence['media'], {
|
||||
currentSrcScheme: 'blob',
|
||||
currentTime: 0.021,
|
||||
paused: false,
|
||||
readyState: 4,
|
||||
videoElements: 1,
|
||||
videoHeight: 90,
|
||||
videoWidth: 160,
|
||||
});
|
||||
assert.deepEqual(record.evidence['epochs'], {
|
||||
click: 10_000,
|
||||
clickListener: 10_000.5,
|
||||
loadedMetadata: 10_095.04,
|
||||
mainIpcSentinel: 10_351,
|
||||
mainIpcStart: 10_001,
|
||||
playing: 10_349.84,
|
||||
});
|
||||
// Click 10,000: last request before at 8,000, first after at 10,020.
|
||||
// Playing 10,349.84: last before at 10,030, first after at 10,400.
|
||||
assert.deepEqual(record.evidence['httpBoundaryMarginsMs'], {
|
||||
click: 20,
|
||||
playing: 50.2,
|
||||
});
|
||||
assert.equal(record.evidence['externalArtworkCancelled'], 8);
|
||||
assert.equal(record.evidence['ipcCallsAfterPlaying'], 3);
|
||||
});
|
||||
|
||||
test('rejects measurements that did not start at a live channel or did not play a video', () => {
|
||||
const base = measurement();
|
||||
const start = base.renderer.start as Start;
|
||||
const terminal = base.renderer.terminal as Terminal;
|
||||
const media = base.renderer.media as Media;
|
||||
const cases: [PlaybackJourneyMeasurement, RegExp][] = [
|
||||
[withRenderer({ start: null }), /incomplete-probe/],
|
||||
[withRenderer({ terminal: null }), /incomplete-probe/],
|
||||
[withRenderer({ media: null }), /incomplete-probe/],
|
||||
[{ ...base, ipc: { ...base.ipc, start: null } }, /ipc-without-start/],
|
||||
[
|
||||
withRenderer({
|
||||
start: { ...start, pathname: '/workspace/dashboard' },
|
||||
}),
|
||||
/start-route/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
start: {
|
||||
...start,
|
||||
pathname: '/workspace/xtreams/playlist-1/vod',
|
||||
},
|
||||
}),
|
||||
/start-route/,
|
||||
],
|
||||
[
|
||||
withRenderer({ terminal: { ...terminal, cardTag: 'div' } }),
|
||||
/not-a-video/,
|
||||
],
|
||||
[withRenderer({ media: { ...media, element: null } }), /not-a-video/],
|
||||
[withRenderer({ media: { ...media, phases: {} } }), /clock-order/],
|
||||
[
|
||||
withRenderer({
|
||||
media: {
|
||||
...media,
|
||||
phases: { loadedmetadata: terminal.epochMs + 1 },
|
||||
},
|
||||
}),
|
||||
/clock-order/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
media: {
|
||||
...media,
|
||||
phases: { loadedmetadata: start.epochMs - 1 },
|
||||
},
|
||||
}),
|
||||
/clock-order/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
terminal: { ...terminal, epochMs: start.epochMs },
|
||||
media: { ...media, phases: { loadedmetadata: start.epochMs } },
|
||||
}),
|
||||
/clock-order/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
capabilities: {
|
||||
...base.renderer.capabilities,
|
||||
changeDetectionTicks: 'unavailable-counter-missing',
|
||||
},
|
||||
counters: {
|
||||
...base.renderer.counters,
|
||||
changeDetectionTicks: null,
|
||||
},
|
||||
}),
|
||||
/cd-ticks-unavailable-counter-missing/,
|
||||
],
|
||||
];
|
||||
for (const [input, error] of cases) {
|
||||
assert.throws(() => toPlaybackIterationRecord(0, false, input), error);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires the stream from the local fixture inside the window', () => {
|
||||
const base = measurement();
|
||||
const withoutStream = {
|
||||
...base,
|
||||
http: {
|
||||
...base.http,
|
||||
toPlaying: base.http.toPlaying.filter(
|
||||
(entry) => entry.route !== STREAM_ROUTE
|
||||
),
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => toPlaybackIterationRecord(0, false, withoutStream),
|
||||
/no-local-stream/
|
||||
);
|
||||
const hls = {
|
||||
...base,
|
||||
http: {
|
||||
...base.http,
|
||||
toPlaying: [request(7, '/live/:username/:password/10000.m3u8', 1)],
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => toPlaybackIterationRecord(0, false, hls),
|
||||
/no-local-stream/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects activity that moved between the settle snapshot and the click', () => {
|
||||
const base = measurement();
|
||||
assert.throws(
|
||||
() =>
|
||||
toPlaybackIterationRecord(0, false, {
|
||||
...base,
|
||||
http: { ...base.http, afterSettleBeforeClick: 1 },
|
||||
ipc: { ...base.ipc, callsBeforeStart: 7 },
|
||||
renderer: {
|
||||
...base.renderer,
|
||||
preStart: { domMutations: 54, lastMutationEpochMs: 1 },
|
||||
},
|
||||
}),
|
||||
/activity-before-click-dom-ipc-http/
|
||||
);
|
||||
});
|
||||
|
||||
test('summarizes playback iterations with J3 counters and unavailable reasons', () => {
|
||||
const iterations = [0, 1, 2].map((index) =>
|
||||
toPlaybackIterationRecord(index, index === 0, {
|
||||
...measurement(),
|
||||
pid: 100 + index,
|
||||
})
|
||||
);
|
||||
const entry = summarizeJourneyIterations(
|
||||
iterations,
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS
|
||||
);
|
||||
assert.equal(entry.counters['renderer.ipcCallsToPlaying'], 4);
|
||||
assert.deepEqual(entry.counterStability['renderer.ipcCallsToPlaying'], {
|
||||
stable: true,
|
||||
values: [4, 4],
|
||||
});
|
||||
assert.equal(entry.wallClock['clickToPlayingMs.p50'], 349.8);
|
||||
assert.equal(entry.wallClock['clickToLoadedMetadataMs.p90'], 95);
|
||||
assert.equal(entry.counters['renderer.cdTicksToPlaying'], 14);
|
||||
assert.deepEqual(Object.keys(entry.unavailable).sort(), [
|
||||
'renderer.ipcSerialDepthToPlaying',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
journeyActivityBeforeClick,
|
||||
type JourneyClickSettle,
|
||||
} from './journey-click-settle';
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import {
|
||||
countJourneyMockRoutes,
|
||||
type JourneyMockRequest,
|
||||
} from './journey-mock-request-ledger';
|
||||
import type { JourneyRendererProbeState } from './journey-renderer-probe';
|
||||
import type { JourneyIterationRecord } from './journey-summary';
|
||||
|
||||
/**
|
||||
* Maps one measured "start playback" (renderer probe armed at the click and
|
||||
* ended by the video element's `playing` event, main IPC capture between the
|
||||
* start and end sentinels, mock request ledger) to the journey summary's
|
||||
* iteration record for J3.
|
||||
*/
|
||||
export const PLAYBACK_JOURNEY_ID = 'playback';
|
||||
|
||||
export const PLAYBACK_JOURNEY_COUNTER = {
|
||||
CD_TICKS: 'renderer.cdTicksToPlaying',
|
||||
DOM_MUTATIONS: 'renderer.domMutationsToPlaying',
|
||||
HTTP_REQUESTS: 'renderer.httpRequestsToPlaying',
|
||||
IPC_CALLS: 'renderer.ipcCallsToPlaying',
|
||||
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
|
||||
LONG_TASKS: 'renderer.longTasks',
|
||||
} as const;
|
||||
|
||||
export const PLAYBACK_JOURNEY_WALL_CLOCK = {
|
||||
/** Click until the video element's first `loadedmetadata`. */
|
||||
CLICK_TO_LOADED_METADATA: 'clickToLoadedMetadataMs',
|
||||
/** Click until its first `playing`. */
|
||||
CLICK_TO_PLAYING: 'clickToPlayingMs',
|
||||
} as const;
|
||||
|
||||
/** How long requests after `playing` are observed, for evidence only. */
|
||||
export const PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS = 1_000;
|
||||
|
||||
export const PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
|
||||
Record<string, string>
|
||||
> = Object.freeze({
|
||||
'renderer.ipcSerialDepthToPlaying':
|
||||
'The serial-depth helper is being added for J1 in a separate thread and is not on master yet; J3 adopts it once it lands.',
|
||||
});
|
||||
|
||||
export interface PlaybackJourneyMeasurement {
|
||||
/** Logo requests to picsum.photos cancelled in the main process. */
|
||||
readonly externalArtworkCancelled: number;
|
||||
readonly http: {
|
||||
/** Requests in the first second after `playing`. */
|
||||
readonly afterPlaying: readonly JourneyMockRequest[];
|
||||
/** Mock requests after the app settled but before the click stamp. */
|
||||
readonly afterSettleBeforeClick: number;
|
||||
/** Mock requests from the spawn (J1, navigation, settling). */
|
||||
readonly beforeClick: readonly JourneyMockRequest[];
|
||||
/** Mock requests from the click stamp until `playing`. */
|
||||
readonly toPlaying: readonly JourneyMockRequest[];
|
||||
};
|
||||
readonly ipc: JourneyMainIpcCaptureState;
|
||||
readonly pid: number;
|
||||
readonly renderer: JourneyRendererProbeState;
|
||||
readonly settle: JourneyClickSettle;
|
||||
}
|
||||
|
||||
const ROUTE_FRAGMENT = '/workspace/xtreams/';
|
||||
const LIVE_STREAM_ROUTE = /^\/live\/:username\/:password\/[^/]+\.ts$/;
|
||||
|
||||
/**
|
||||
* Distance of the nearest ledger request to a boundary on either side
|
||||
* (null when there is none). The ledger and the renderer stamp with
|
||||
* different processes' clocks; a small margin flags a count that a clock
|
||||
* difference could move across the boundary.
|
||||
*/
|
||||
function boundaryMarginMs(
|
||||
before: readonly JourneyMockRequest[],
|
||||
after: readonly JourneyMockRequest[],
|
||||
boundaryEpochMs: number
|
||||
): number | null {
|
||||
const distances = [
|
||||
...before.map((entry) => boundaryEpochMs - entry.epochMs),
|
||||
...after.map((entry) => entry.epochMs - boundaryEpochMs),
|
||||
];
|
||||
return distances.length === 0 ? null : roundTenth(Math.min(...distances));
|
||||
}
|
||||
|
||||
function roundTenth(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function roundThousandth(value: number): number {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
export function toPlaybackIterationRecord(
|
||||
index: number,
|
||||
warmup: boolean,
|
||||
measurement: PlaybackJourneyMeasurement
|
||||
): JourneyIterationRecord {
|
||||
const { http, ipc, renderer, settle } = measurement;
|
||||
const { media, start, terminal } = renderer;
|
||||
if (start === null || terminal === null || media === null) {
|
||||
throw new Error('playback-journey-record-incomplete-probe');
|
||||
}
|
||||
if (ipc.start === null) {
|
||||
throw new Error('playback-journey-record-ipc-without-start');
|
||||
}
|
||||
if (
|
||||
!start.pathname.includes(ROUTE_FRAGMENT) ||
|
||||
!start.pathname.includes('/live')
|
||||
) {
|
||||
throw new Error('playback-journey-record-start-route');
|
||||
}
|
||||
if (terminal.cardTag !== 'video' || media.element === null) {
|
||||
throw new Error('playback-journey-record-not-a-video');
|
||||
}
|
||||
const loadedMetadataEpochMs = media.phases['loadedmetadata'];
|
||||
const clickToPlayingMs = terminal.epochMs - start.epochMs;
|
||||
if (
|
||||
loadedMetadataEpochMs === undefined ||
|
||||
loadedMetadataEpochMs < start.epochMs ||
|
||||
loadedMetadataEpochMs > terminal.epochMs ||
|
||||
clickToPlayingMs <= 0
|
||||
) {
|
||||
throw new Error('playback-journey-record-clock-order');
|
||||
}
|
||||
// The stream itself must have come from the mock's local fixture; a
|
||||
// player that played something else did not measure this journey.
|
||||
if (!http.toPlaying.some((entry) => LIVE_STREAM_ROUTE.test(entry.route))) {
|
||||
throw new Error('playback-journey-record-no-local-stream');
|
||||
}
|
||||
const lateActivity = journeyActivityBeforeClick(settle, {
|
||||
httpAfterSettleBeforeClick: http.afterSettleBeforeClick,
|
||||
ipcCallsBeforeStart: ipc.callsBeforeStart,
|
||||
preStartDomMutations: renderer.preStart.domMutations,
|
||||
});
|
||||
if (lateActivity.length > 0) {
|
||||
throw new Error(
|
||||
`playback-journey-record-activity-before-click-${lateActivity.join('-')}`
|
||||
);
|
||||
}
|
||||
const cdTicks = renderer.counters.changeDetectionTicks;
|
||||
if (
|
||||
renderer.capabilities.changeDetectionTicks !== 'counted' ||
|
||||
cdTicks === null
|
||||
) {
|
||||
throw new Error(
|
||||
`playback-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}`
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
counters: Object.freeze({
|
||||
[PLAYBACK_JOURNEY_COUNTER.CD_TICKS]: cdTicks,
|
||||
[PLAYBACK_JOURNEY_COUNTER.DOM_MUTATIONS]:
|
||||
renderer.counters.domMutations,
|
||||
[PLAYBACK_JOURNEY_COUNTER.HTTP_REQUESTS]: http.toPlaying.length,
|
||||
[PLAYBACK_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
|
||||
// The click's 500 ms input window covers the start of the
|
||||
// journey, so shifts flagged hadRecentInput are included, as
|
||||
// in J2.
|
||||
[PLAYBACK_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth(
|
||||
renderer.counters.layoutShiftScore +
|
||||
renderer.counters.recentInputLayoutShiftScore
|
||||
),
|
||||
[PLAYBACK_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
|
||||
}),
|
||||
evidence: Object.freeze({
|
||||
capabilities: renderer.capabilities,
|
||||
epochs: Object.freeze({
|
||||
click: start.epochMs,
|
||||
clickListener: start.listenerEpochMs,
|
||||
loadedMetadata: loadedMetadataEpochMs,
|
||||
mainIpcSentinel: ipc.sentinel.receivedEpochMs,
|
||||
mainIpcStart: ipc.start.receivedEpochMs,
|
||||
playing: terminal.epochMs,
|
||||
}),
|
||||
externalArtworkCancelled: measurement.externalArtworkCancelled,
|
||||
httpRequestsAfterPlayingByRoute: countJourneyMockRoutes(
|
||||
http.afterPlaying
|
||||
),
|
||||
httpRequestsBeforeClickByRoute: countJourneyMockRoutes(
|
||||
http.beforeClick
|
||||
),
|
||||
httpRequestsByRoute: countJourneyMockRoutes(http.toPlaying),
|
||||
httpBoundaryMarginsMs: Object.freeze({
|
||||
click: boundaryMarginMs(
|
||||
http.beforeClick,
|
||||
http.toPlaying,
|
||||
start.epochMs
|
||||
),
|
||||
playing: boundaryMarginMs(
|
||||
http.toPlaying,
|
||||
http.afterPlaying,
|
||||
terminal.epochMs
|
||||
),
|
||||
}),
|
||||
ipcCallsAfterPlaying: ipc.callsAfterSentinel,
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
layoutShift: Object.freeze({
|
||||
recentInput: roundThousandth(
|
||||
renderer.counters.recentInputLayoutShiftScore
|
||||
),
|
||||
withoutRecentInput: roundThousandth(
|
||||
renderer.counters.layoutShiftScore
|
||||
),
|
||||
}),
|
||||
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
|
||||
media: Object.freeze({
|
||||
...media.element,
|
||||
currentTime: roundThousandth(media.element.currentTime),
|
||||
videoElements: terminal.cardCount,
|
||||
}),
|
||||
settle,
|
||||
start: Object.freeze({
|
||||
pathname: start.pathname,
|
||||
targetTag: start.targetTag,
|
||||
targetTestId: start.targetTestId,
|
||||
}),
|
||||
terminalPathname: terminal.pathname,
|
||||
}),
|
||||
index,
|
||||
pid: measurement.pid,
|
||||
wallClock: Object.freeze({
|
||||
[PLAYBACK_JOURNEY_WALL_CLOCK.CLICK_TO_LOADED_METADATA]: roundTenth(
|
||||
loadedMetadataEpochMs - start.epochMs
|
||||
),
|
||||
[PLAYBACK_JOURNEY_WALL_CLOCK.CLICK_TO_PLAYING]:
|
||||
roundTenth(clickToPlayingMs),
|
||||
}),
|
||||
warmup,
|
||||
});
|
||||
}
|
||||
@@ -416,6 +416,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
|
||||
.click();
|
||||
await expect(inlineVideo(page)).toHaveCount(0);
|
||||
await expect(shell.locator('.shell__back-button')).toHaveCount(0);
|
||||
// Without the arrow there is no lane to reserve: the hero keeps its own
|
||||
// inset instead of the 72px column portal details give the Back control.
|
||||
expect(
|
||||
await shell
|
||||
.locator('.hero__content')
|
||||
.evaluate((el) => getComputedStyle(el).paddingInlineStart)
|
||||
).toBe('40px');
|
||||
await shell.focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(playButton).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pressTab } from './e2e-helpers';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
importPlaylist,
|
||||
selectPlayer,
|
||||
serveClip,
|
||||
startClip,
|
||||
} from './player-settings-panel.fixture';
|
||||
|
||||
/**
|
||||
* Keyboard and assistive-technology access to the shared controls in both
|
||||
* app themes: every dock control and every settings choice shows a focus
|
||||
* ring in the overlay's own colour, each settings radio group is one Tab
|
||||
* stop with arrow-key navigation, the open panel passes axe, its headings
|
||||
* stay readable over bright frames, and long German and Russian headings
|
||||
* wrap inside the compact sheet. ArtPlayer is used because it offers the
|
||||
* fullest panel (subtitle file, size and colour, speed).
|
||||
*/
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
/** `--pc-text`, the overlay's text colour, as computed styles report it. */
|
||||
const RING_COLOR = 'rgb(231, 236, 243)';
|
||||
const AXE_SOURCE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8');
|
||||
|
||||
interface FocusStop {
|
||||
label: string;
|
||||
role: string | null;
|
||||
group: string | null;
|
||||
checked: string | null;
|
||||
iconButton: boolean;
|
||||
outline: string;
|
||||
outlineColor: string;
|
||||
}
|
||||
|
||||
/** Describes the focused element and the ring it draws. */
|
||||
function focusStop(page: Page): Promise<FocusStop | null> {
|
||||
return page.evaluate(() => {
|
||||
const element = document.activeElement;
|
||||
if (!(element instanceof HTMLElement)) return null;
|
||||
const style = getComputedStyle(element);
|
||||
const group = element.closest('[role="radiogroup"]');
|
||||
return {
|
||||
label:
|
||||
element.getAttribute('aria-label') ??
|
||||
element.textContent?.trim() ??
|
||||
'',
|
||||
role: element.getAttribute('role'),
|
||||
group: group?.getAttribute('aria-labelledby') ?? null,
|
||||
checked: element.getAttribute('aria-checked'),
|
||||
iconButton: element.matches('button[mat-icon-button]'),
|
||||
outline: `${style.outlineStyle} ${style.outlineWidth}`,
|
||||
outlineColor: style.outlineColor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function focusIsInside(page: Page, selector: string): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
(within) => !!document.activeElement?.closest(within),
|
||||
selector
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Presses Tab (or Shift+Tab) until focus lies inside `target`, or leaves
|
||||
* `within` when that is given, and returns every stop on the way — the
|
||||
* last one included.
|
||||
*/
|
||||
async function tabUntil(
|
||||
page: Page,
|
||||
browserName: string,
|
||||
options: {
|
||||
target?: string;
|
||||
within?: string;
|
||||
direction?: 'forward' | 'backward';
|
||||
max?: number;
|
||||
}
|
||||
): Promise<FocusStop[]> {
|
||||
const stops: FocusStop[] = [];
|
||||
for (let step = 0; step < (options.max ?? 20); step++) {
|
||||
await pressTab(page, browserName, options.direction);
|
||||
if (options.within && !(await focusIsInside(page, options.within))) {
|
||||
return stops;
|
||||
}
|
||||
const stop = await focusStop(page);
|
||||
if (stop) stops.push(stop);
|
||||
if (options.target && (await focusIsInside(page, options.target))) {
|
||||
return stops;
|
||||
}
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
async function applyTheme(page: Page, theme: 'light' | 'dark') {
|
||||
await page.evaluate((dark) => {
|
||||
document.body.classList.toggle('dark-theme', dark);
|
||||
}, theme === 'dark');
|
||||
}
|
||||
|
||||
async function expectNoAxeViolations(panel: Locator) {
|
||||
await panel.page().evaluate(AXE_SOURCE);
|
||||
const violations = await panel.evaluate(async (element) => {
|
||||
const axe = (window as unknown as { axe: typeof import('axe-core') })
|
||||
.axe;
|
||||
const results = await axe.run(element, { resultTypes: ['violations'] });
|
||||
return results.violations.map((violation) => ({
|
||||
id: violation.id,
|
||||
nodes: violation.nodes.map((node) => node.target.join(' ')),
|
||||
}));
|
||||
});
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heading contrast on the panel glass composited over a mid-grey and a white
|
||||
* frame — the video behind the panel can be anything.
|
||||
*/
|
||||
function headingContrast(panel: Locator) {
|
||||
return panel.evaluate((host) => {
|
||||
type Color = [number, number, number, number];
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
|
||||
const parse = (css: string): Color => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = css;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return [r, g, b, a / 255];
|
||||
};
|
||||
// `front` over an opaque `back`: the result is opaque too.
|
||||
const over = (front: Color, back: Color): Color => [
|
||||
front[0] * front[3] + back[0] * (1 - front[3]),
|
||||
front[1] * front[3] + back[1] * (1 - front[3]),
|
||||
front[2] * front[3] + back[2] * (1 - front[3]),
|
||||
1,
|
||||
];
|
||||
const luminance = (color: Color) => {
|
||||
const [r, g, b] = color.slice(0, 3).map((value) => {
|
||||
const s = value / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
};
|
||||
const ratio = (a: Color, b: Color) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const glass = parse(getComputedStyle(host).backgroundColor);
|
||||
return Array.from(host.querySelectorAll('h3, h4'), (heading) => {
|
||||
const text = parse(getComputedStyle(heading).color);
|
||||
const on = (frame: Color) => {
|
||||
const surface = over(glass, frame);
|
||||
return ratio(over(text, surface), surface);
|
||||
};
|
||||
return {
|
||||
heading: heading.textContent?.trim(),
|
||||
midGrey: on([128, 128, 128, 1]),
|
||||
white: on([255, 255, 255, 1]),
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('@web @playback keyboard reaches the dock and the settings radios with a visible ring in both themes', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
// Chips and the side panel need a player of at least 960px.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await serveClip(page);
|
||||
await selectPlayer(page, 'ArtPlayer');
|
||||
await importPlaylist(page);
|
||||
const { view, video, controls } = await startClip(page);
|
||||
// Park the pointer on the video away from the dock and the panel: a
|
||||
// control that slides in under a resting pointer shows its hover
|
||||
// tooltip, and a visible tooltip spends the next Escape on itself.
|
||||
await view.hover({ position: { x: 24, y: 24 } });
|
||||
const tune = controls.locator(
|
||||
'[data-test-id="player-controls-settings-button"]'
|
||||
);
|
||||
const panel = controls.getByRole('dialog', { name: 'Settings' });
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
|
||||
// --- The dock, from the timeline to the last control. -------------
|
||||
await controls
|
||||
.getByRole('slider', { name: 'Playback position' })
|
||||
.focus();
|
||||
const dockButtons = (
|
||||
await tabUntil(page, browserName, { within: 'app-player-controls' })
|
||||
).filter((stop) => stop.iconButton);
|
||||
// Every icon button wears the ring, whatever the app theme.
|
||||
expect(dockButtons.map((stop) => stop.outline)).toEqual(
|
||||
dockButtons.map(() => 'solid 2px')
|
||||
);
|
||||
expect(dockButtons.map((stop) => stop.outlineColor)).toEqual(
|
||||
dockButtons.map(() => RING_COLOR)
|
||||
);
|
||||
expect(dockButtons.map((stop) => stop.label)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'Mute',
|
||||
'Back 10 seconds',
|
||||
'Forward 10 seconds',
|
||||
'Settings',
|
||||
'Enter fullscreen',
|
||||
])
|
||||
);
|
||||
|
||||
// --- Open the panel from the keyboard: focus moves into it. -------
|
||||
await tabUntil(page, browserName, {
|
||||
target: '[data-test-id="player-controls-settings-button"]',
|
||||
direction: 'backward',
|
||||
max: 10,
|
||||
});
|
||||
await expect(tune).toBeFocused();
|
||||
await expect(tune).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${theme}-dock-focus.png`),
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toBeFocused();
|
||||
|
||||
// Tab enters every radio group once, on its checked option, and
|
||||
// every stop wears the ring.
|
||||
const panelStops = await tabUntil(page, browserName, {
|
||||
target: '[data-test-id="player-settings-speed"]',
|
||||
});
|
||||
expect(panelStops.map((stop) => stop.outline)).toEqual(
|
||||
panelStops.map(() => 'solid 2px')
|
||||
);
|
||||
expect(panelStops.map((stop) => stop.outlineColor)).toEqual(
|
||||
panelStops.map(() => RING_COLOR)
|
||||
);
|
||||
const radioStops = panelStops.filter((stop) => stop.role === 'radio');
|
||||
// Subtitle size, subtitle colour and speed: one stop each, checked.
|
||||
expect(radioStops.map((stop) => stop.checked)).toEqual([
|
||||
'true',
|
||||
'true',
|
||||
'true',
|
||||
]);
|
||||
expect(new Set(radioStops.map((stop) => stop.group)).size).toBe(3);
|
||||
|
||||
// --- Arrows move focus and apply the option they reach. ----------
|
||||
const speedGroup = panel.locator(
|
||||
'[data-test-id="player-settings-speed"] [role="radiogroup"]'
|
||||
);
|
||||
const rate = () =>
|
||||
video.evaluate((el: HTMLVideoElement) => el.playbackRate);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
const moved = await focusStop(page);
|
||||
expect(moved?.role).toBe('radio');
|
||||
await expect.poll(rate).toBe(Number.parseFloat(moved?.label ?? ''));
|
||||
await expect(
|
||||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||
).toHaveText(moved?.label ?? '');
|
||||
await page.keyboard.press('Home');
|
||||
expect((await focusStop(page))?.label).toBe('0.5×');
|
||||
await expect.poll(rate).toBe(0.5);
|
||||
await expect(
|
||||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||
).toHaveText('0.5×');
|
||||
// The ends wrap, as in a native radio group.
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
expect((await focusStop(page))?.label).toBe('2×');
|
||||
await expect.poll(rate).toBe(2);
|
||||
await expect(speedGroup.locator('[tabindex="0"]')).toHaveText('2×');
|
||||
// Leaving and re-entering lands on the checked option — the one the
|
||||
// engine reports, so wait for it to confirm the switch first.
|
||||
await expect(
|
||||
speedGroup.locator('[role="radio"][aria-checked="true"]')
|
||||
).toHaveText('2×');
|
||||
await pressTab(page, browserName, 'backward');
|
||||
await pressTab(page, browserName);
|
||||
expect((await focusStop(page))?.label).toBe('2×');
|
||||
|
||||
// --- A focused selected swatch differs from a selected one. -------
|
||||
await tabUntil(page, browserName, {
|
||||
target: '.player-settings__swatches',
|
||||
direction: 'backward',
|
||||
max: 5,
|
||||
});
|
||||
const focusedSwatch = panel.locator('.player-settings__swatch:focus');
|
||||
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||||
// The arrow checks the next swatch, which takes focus and the ring.
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(focusedSwatch).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(focusedSwatch).toHaveCSS('outline-style', 'solid');
|
||||
// Tab moves on to the speed group: the checked swatch keeps its
|
||||
// selected border but loses the ring.
|
||||
await pressTab(page, browserName);
|
||||
expect(
|
||||
await focusIsInside(page, '[data-test-id="player-settings-speed"]')
|
||||
).toBe(true);
|
||||
const selectedOnly = panel.locator(
|
||||
'.player-settings__swatch[aria-checked="true"]'
|
||||
);
|
||||
await expect(selectedOnly).toHaveCount(1);
|
||||
await expect(selectedOnly).not.toBeFocused();
|
||||
await expect(selectedOnly).toHaveCSS('outline-style', 'none');
|
||||
await expect(selectedOnly).toHaveCSS(
|
||||
'border-top-color',
|
||||
'rgb(255, 255, 255)'
|
||||
);
|
||||
|
||||
// --- Headings, axe and closing. ----------------------------------
|
||||
const contrast = await headingContrast(panel);
|
||||
expect(contrast.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
contrast.filter(
|
||||
(heading) => heading.midGrey < 4.5 || heading.white < 4.5
|
||||
)
|
||||
).toEqual([]);
|
||||
await expectNoAxeViolations(panel);
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${theme}-panel-focus.png`),
|
||||
});
|
||||
|
||||
// Focus is on a speed radio, which has no tooltip. Material spends
|
||||
// an Escape on any tooltip still showing (the swatch's fades out),
|
||||
// so wait for it to go before closing.
|
||||
await expect(page.locator('.mat-mdc-tooltip')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(tune).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
for (const [locale, speedHeading] of [
|
||||
['de', 'Wiedergabegeschwindigkeit'],
|
||||
['ru', 'Скорость воспроизведения'],
|
||||
] as const) {
|
||||
test(`@web @playback settings sheet headings wrap in the ${locale} locale`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
await serveClip(page);
|
||||
await selectPlayer(page, 'ArtPlayer');
|
||||
const playlistUrl = await importPlaylist(page);
|
||||
|
||||
await page.goto('/workspace/settings/general');
|
||||
await page.locator('[data-test-id="select-language"]').click();
|
||||
await page.locator(`mat-option[data-test-id="${locale}"]`).click();
|
||||
const save = page.locator('[data-test-id="save-settings"]');
|
||||
await save.click();
|
||||
await expect(save).toBeHidden();
|
||||
|
||||
await page.goto(playlistUrl);
|
||||
const { controls } = await startClip(page);
|
||||
await controls
|
||||
.locator('[data-test-id="player-controls-settings-button"]')
|
||||
.click();
|
||||
const sheet = controls.locator(
|
||||
'[data-test-id="player-controls-settings-panel"]'
|
||||
);
|
||||
await expect(sheet).toHaveClass(/player-controls__settings--sheet/);
|
||||
await expect(
|
||||
sheet.locator('[data-test-id="player-settings-speed"] h3')
|
||||
).toHaveText(speedHeading);
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
const headings = await sheet.evaluate((host) =>
|
||||
Array.from(host.querySelectorAll('h3, h4'), (heading) => {
|
||||
const box = heading.getBoundingClientRect();
|
||||
const control = heading.nextElementSibling;
|
||||
const style = getComputedStyle(heading);
|
||||
return {
|
||||
text: heading.textContent?.trim(),
|
||||
overflow: heading.scrollWidth - heading.clientWidth,
|
||||
// A group heading sits in the sheet's left column,
|
||||
// beside its control.
|
||||
intoControl:
|
||||
heading.tagName === 'H3' && control
|
||||
? box.right -
|
||||
control.getBoundingClientRect().left
|
||||
: 0,
|
||||
lines: Math.round(
|
||||
(box.height -
|
||||
Number.parseFloat(style.paddingTop) -
|
||||
Number.parseFloat(style.paddingBottom)) /
|
||||
Number.parseFloat(style.lineHeight)
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
for (const heading of headings) {
|
||||
expect(heading.overflow, heading.text).toBeLessThanOrEqual(0);
|
||||
expect(heading.intoControl, heading.text).toBeLessThanOrEqual(
|
||||
0
|
||||
);
|
||||
}
|
||||
expect(
|
||||
headings.find((heading) => heading.text === speedHeading)?.lines
|
||||
).toBeGreaterThan(1);
|
||||
await expectNoAxeViolations(sheet);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath(`${locale}-sheet.png`),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { expect, test } from './fixtures';
|
||||
import {
|
||||
importPlaylist,
|
||||
selectPlayer,
|
||||
serveClip,
|
||||
startClip,
|
||||
} from './player-settings-panel.fixture';
|
||||
|
||||
/**
|
||||
* The shared controls' settings panel: on a wide player the speed chip opens
|
||||
@@ -11,100 +14,8 @@ import { expect, test } from './fixtures';
|
||||
* remote media is involved.
|
||||
*/
|
||||
|
||||
const FIXTURE_HOST = 'https://player-settings-fixture.local';
|
||||
const PLAYLIST = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:-1 group-title="Movies",Settings Clip',
|
||||
`${FIXTURE_HOST}/clip.mp4`,
|
||||
].join('\n');
|
||||
|
||||
test.use({ serviceWorkers: 'block' });
|
||||
|
||||
async function serveClip(page: Page): Promise<void> {
|
||||
const clip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = clip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, clip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'content-type': 'video/webm',
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${clip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: clip.subarray(start, end + 1),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function selectHtml5Player(page: Page): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
const select = page.locator('[data-test-id="select-video-player"]');
|
||||
await expect(select).toBeVisible();
|
||||
const previous = await select.innerText();
|
||||
await select.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'HTML5 video player', exact: true })
|
||||
.click();
|
||||
if (!previous.includes('HTML5 video player')) {
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
}
|
||||
|
||||
async function importPlaylist(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||||
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channels')).toBeVisible();
|
||||
}
|
||||
|
||||
async function startClip(page: Page) {
|
||||
await page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: 'Settings Clip' })
|
||||
.click();
|
||||
const view = page.locator('app-web-player-view');
|
||||
const video = view.locator('video');
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate(
|
||||
(el: HTMLVideoElement) =>
|
||||
Number.isFinite(el.duration) && el.duration > 5
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
// Reveal the dock and keep it revealed for the assertions below.
|
||||
await view.hover();
|
||||
const controls = view.locator('app-player-controls');
|
||||
await controls.getByRole('button', { name: 'Pause', exact: true }).click();
|
||||
return { view, video, controls };
|
||||
}
|
||||
|
||||
test('@web @playback settings panel opens from the speed chip and applies in place', async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -112,7 +23,7 @@ test('@web @playback settings panel opens from the speed chip and applies in pla
|
||||
// Chips and the side panel need a player of at least 960px.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
await serveClip(page);
|
||||
await selectHtml5Player(page);
|
||||
await selectPlayer(page);
|
||||
await importPlaylist(page);
|
||||
const { video, controls } = await startClip(page);
|
||||
|
||||
@@ -164,7 +75,7 @@ test('@web @playback compact player folds the chips into a tune button with a bo
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
await serveClip(page);
|
||||
await selectHtml5Player(page);
|
||||
await selectPlayer(page);
|
||||
await importPlaylist(page);
|
||||
const { view, controls } = await startClip(page);
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* A one-channel M3U playlist whose only stream is the local test clip, served
|
||||
* through route interception: the shared-controls specs play it in a web
|
||||
* engine without any remote media.
|
||||
*/
|
||||
const FIXTURE_HOST = 'https://player-settings-fixture.local';
|
||||
export const CLIP_TITLE = 'Settings Clip';
|
||||
const PLAYLIST = [
|
||||
'#EXTM3U',
|
||||
`#EXTINF:-1 group-title="Movies",${CLIP_TITLE}`,
|
||||
`${FIXTURE_HOST}/clip.mp4`,
|
||||
].join('\n');
|
||||
|
||||
export async function serveClip(page: Page): Promise<void> {
|
||||
const clip = readFileSync(
|
||||
join(__dirname, 'fixtures/playback/episode.webm')
|
||||
);
|
||||
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
|
||||
const range = /^bytes=(\d*)-(\d*)$/.exec(
|
||||
route.request().headers()['range'] ?? ''
|
||||
);
|
||||
const last = clip.length - 1;
|
||||
const start = range?.[1]
|
||||
? Number(range[1])
|
||||
: range?.[2]
|
||||
? Math.max(0, clip.length - Number(range[2]))
|
||||
: 0;
|
||||
const end =
|
||||
range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last;
|
||||
await route.fulfill({
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
'content-type': 'video/webm',
|
||||
'accept-ranges': 'bytes',
|
||||
'content-length': String(end - start + 1),
|
||||
...(range
|
||||
? {
|
||||
'content-range': `bytes ${start}-${end}/${clip.length}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: clip.subarray(start, end + 1),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectPlayer(
|
||||
page: Page,
|
||||
player = 'HTML5 video player'
|
||||
): Promise<void> {
|
||||
await page.goto('/workspace/settings/playback');
|
||||
const select = page.locator('[data-test-id="select-video-player"]');
|
||||
await expect(select).toBeVisible();
|
||||
const previous = await select.innerText();
|
||||
await select.click();
|
||||
await page.getByRole('option', { name: player, exact: true }).click();
|
||||
if (!previous.includes(player)) {
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
}
|
||||
|
||||
/** Imports the playlist and returns the URL of its channel list. */
|
||||
export async function importPlaylist(page: Page): Promise<string> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
|
||||
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
|
||||
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
|
||||
]);
|
||||
await expect(page.getByText('1 channels')).toBeVisible();
|
||||
return page.url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the clip and pauses it with the dock revealed. The play button is
|
||||
* found by test id, so this works in every app language.
|
||||
*/
|
||||
export async function startClip(page: Page) {
|
||||
await page
|
||||
.locator('[data-test-id="channel-item"]')
|
||||
.filter({ hasText: CLIP_TITLE })
|
||||
.click();
|
||||
const view = page.locator('app-web-player-view');
|
||||
const video = view.locator('video');
|
||||
await expect
|
||||
.poll(() =>
|
||||
video.evaluate(
|
||||
(el: HTMLVideoElement) =>
|
||||
Number.isFinite(el.duration) &&
|
||||
el.duration > 5 &&
|
||||
!el.paused
|
||||
)
|
||||
)
|
||||
.toBe(true);
|
||||
// Reveal the dock and keep it revealed for the assertions below.
|
||||
await view.hover();
|
||||
const controls = view.locator('app-player-controls');
|
||||
await controls.locator('[data-test-id="player-controls-play"]').click();
|
||||
await expect
|
||||
.poll(() => video.evaluate((el: HTMLVideoElement) => el.paused))
|
||||
.toBe(true);
|
||||
return { view, video, controls };
|
||||
}
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "مسارات الصوت",
|
||||
"SUBTITLES": "الترجمات",
|
||||
"SUBTITLES_OFF": "إيقاف",
|
||||
"SUBTITLES_ON": "تشغيل",
|
||||
"QUALITY": "الجودة",
|
||||
"QUALITY_AUTO": "تلقائي",
|
||||
"TRACK_DEFAULT": "افتراضي",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "الترجمات: {{subtitles}}",
|
||||
"ASPECT_RATIO": "نسبة العرض إلى الارتفاع",
|
||||
"ASPECT_TOOLTIP": "النسبة: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "افتراضي",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "مسارات الصوت",
|
||||
"SUBTITLES": "الترجمة",
|
||||
"SUBTITLES_OFF": "معطلة",
|
||||
"SUBTITLES_ON": "مفعلة",
|
||||
"QUALITY": "الجودة",
|
||||
"QUALITY_AUTO": "تلقائي",
|
||||
"TRACK_DEFAULT": "افتراضي",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
"PLAYBACK_SPEED": "سرعة التشغيل",
|
||||
"SPEED_TOOLTIP": "السرعة: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "الترجمة: {{subtitles}}",
|
||||
"ASPECT_RATIO": "نسبة الأبعاد",
|
||||
"ASPECT_TOOLTIP": "الأبعاد: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "افتراضي",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Аўдыядарожкі",
|
||||
"SUBTITLES": "Субтытры",
|
||||
"SUBTITLES_OFF": "Выкл.",
|
||||
"SUBTITLES_ON": "Укл.",
|
||||
"QUALITY": "Якасць",
|
||||
"QUALITY_AUTO": "Аўта",
|
||||
"TRACK_DEFAULT": "Па змаўчанні",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
|
||||
"PLAYBACK_SPEED": "Хуткасць прайгравання",
|
||||
"SPEED_TOOLTIP": "Хуткасць: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Субтытры: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Суадносіны бакоў",
|
||||
"ASPECT_TOOLTIP": "Суадносіны: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Па змаўчанні",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Tonspuren",
|
||||
"SUBTITLES": "Untertitel",
|
||||
"SUBTITLES_OFF": "Aus",
|
||||
"SUBTITLES_ON": "An",
|
||||
"QUALITY": "Qualität",
|
||||
"QUALITY_AUTO": "Automatisch",
|
||||
"TRACK_DEFAULT": "Standard",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}",
|
||||
"PLAYBACK_SPEED": "Wiedergabegeschwindigkeit",
|
||||
"SPEED_TOOLTIP": "Geschwindigkeit: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Untertitel: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Seitenverhältnis",
|
||||
"ASPECT_TOOLTIP": "Seitenverhältnis: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Standard",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Κομμάτια ήχου",
|
||||
"SUBTITLES": "Υπότιτλοι",
|
||||
"SUBTITLES_OFF": "Ανενεργοί",
|
||||
"SUBTITLES_ON": "Ενεργοί",
|
||||
"QUALITY": "Ποιότητα",
|
||||
"QUALITY_AUTO": "Αυτόματη",
|
||||
"TRACK_DEFAULT": "Προεπιλογή",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
|
||||
"PLAYBACK_SPEED": "Ταχύτητα αναπαραγωγής",
|
||||
"SPEED_TOOLTIP": "Ταχύτητα: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Υπότιτλοι: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Αναλογία εικόνας",
|
||||
"ASPECT_TOOLTIP": "Αναλογία: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Προεπιλογή",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Audio tracks",
|
||||
"SUBTITLES": "Subtitles",
|
||||
"SUBTITLES_OFF": "Off",
|
||||
"SUBTITLES_ON": "On",
|
||||
"QUALITY": "Quality",
|
||||
"QUALITY_AUTO": "Auto",
|
||||
"TRACK_DEFAULT": "Default",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}",
|
||||
"PLAYBACK_SPEED": "Playback speed",
|
||||
"SPEED_TOOLTIP": "Speed: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Subtitles: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Aspect ratio",
|
||||
"ASPECT_TOOLTIP": "Aspect: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Default",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Pistas de audio",
|
||||
"SUBTITLES": "Subtítulos",
|
||||
"SUBTITLES_OFF": "Desactivados",
|
||||
"SUBTITLES_ON": "Activados",
|
||||
"QUALITY": "Calidad",
|
||||
"QUALITY_AUTO": "Automática",
|
||||
"TRACK_DEFAULT": "Predeterminada",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}",
|
||||
"PLAYBACK_SPEED": "Velocidad de reproducción",
|
||||
"SPEED_TOOLTIP": "Velocidad: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Subtítulos: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Relación de aspecto",
|
||||
"ASPECT_TOOLTIP": "Aspecto: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Predeterminada",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Pistes audio",
|
||||
"SUBTITLES": "Sous-titres",
|
||||
"SUBTITLES_OFF": "Désactivés",
|
||||
"SUBTITLES_ON": "Activés",
|
||||
"QUALITY": "Qualité",
|
||||
"QUALITY_AUTO": "Auto",
|
||||
"TRACK_DEFAULT": "Par défaut",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}",
|
||||
"PLAYBACK_SPEED": "Vitesse de lecture",
|
||||
"SPEED_TOOLTIP": "Vitesse : {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Sous-titres : {{subtitles}}",
|
||||
"ASPECT_RATIO": "Format d'image",
|
||||
"ASPECT_TOOLTIP": "Format : {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Par défaut",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Hangsávok",
|
||||
"SUBTITLES": "Feliratok",
|
||||
"SUBTITLES_OFF": "Kikapcsolva",
|
||||
"SUBTITLES_ON": "Bekapcsolva",
|
||||
"QUALITY": "Minőség",
|
||||
"QUALITY_AUTO": "Automatikus",
|
||||
"TRACK_DEFAULT": "Alapértelmezett",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv",
|
||||
"PLAYBACK_SPEED": "Lejátszási sebesség",
|
||||
"SPEED_TOOLTIP": "Sebesség: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Feliratok: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Képarány",
|
||||
"ASPECT_TOOLTIP": "Képarány: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Alapértelmezett",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Tracce audio",
|
||||
"SUBTITLES": "Sottotitoli",
|
||||
"SUBTITLES_OFF": "Disattivati",
|
||||
"SUBTITLES_ON": "Attivati",
|
||||
"QUALITY": "Qualità",
|
||||
"QUALITY_AUTO": "Automatica",
|
||||
"TRACK_DEFAULT": "Predefinita",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}",
|
||||
"PLAYBACK_SPEED": "Velocità di riproduzione",
|
||||
"SPEED_TOOLTIP": "Velocità: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Sottotitoli: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Proporzioni",
|
||||
"ASPECT_TOOLTIP": "Proporzioni: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Predefinite",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "音声トラック",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "オフ",
|
||||
"SUBTITLES_ON": "オン",
|
||||
"QUALITY": "画質",
|
||||
"QUALITY_AUTO": "自動",
|
||||
"TRACK_DEFAULT": "デフォルト",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "再生速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "アスペクト比",
|
||||
"ASPECT_TOOLTIP": "アスペクト比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "デフォルト",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "오디오 트랙",
|
||||
"SUBTITLES": "자막",
|
||||
"SUBTITLES_OFF": "끄기",
|
||||
"SUBTITLES_ON": "켜기",
|
||||
"QUALITY": "화질",
|
||||
"QUALITY_AUTO": "자동",
|
||||
"TRACK_DEFAULT": "기본",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
|
||||
"PLAYBACK_SPEED": "재생 속도",
|
||||
"SPEED_TOOLTIP": "속도: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "자막: {{subtitles}}",
|
||||
"ASPECT_RATIO": "화면 비율",
|
||||
"ASPECT_TOOLTIP": "비율: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "기본",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Audiosporen",
|
||||
"SUBTITLES": "Ondertiteling",
|
||||
"SUBTITLES_OFF": "Uit",
|
||||
"SUBTITLES_ON": "Aan",
|
||||
"QUALITY": "Kwaliteit",
|
||||
"QUALITY_AUTO": "Automatisch",
|
||||
"TRACK_DEFAULT": "Standaard",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}",
|
||||
"PLAYBACK_SPEED": "Afspeelsnelheid",
|
||||
"SPEED_TOOLTIP": "Snelheid: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Ondertiteling: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Beeldverhouding",
|
||||
"ASPECT_TOOLTIP": "Beeldverhouding: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Standaard",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Ścieżki audio",
|
||||
"SUBTITLES": "Napisy",
|
||||
"SUBTITLES_OFF": "Wyłączone",
|
||||
"SUBTITLES_ON": "Włączone",
|
||||
"QUALITY": "Jakość",
|
||||
"QUALITY_AUTO": "Automatycznie",
|
||||
"TRACK_DEFAULT": "Domyślna",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}",
|
||||
"PLAYBACK_SPEED": "Prędkość odtwarzania",
|
||||
"SPEED_TOOLTIP": "Prędkość: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Napisy: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Proporcje obrazu",
|
||||
"ASPECT_TOOLTIP": "Proporcje: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Domyślne",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Faixas de áudio",
|
||||
"SUBTITLES": "Legendas",
|
||||
"SUBTITLES_OFF": "Desativadas",
|
||||
"SUBTITLES_ON": "Ativadas",
|
||||
"QUALITY": "Qualidade",
|
||||
"QUALITY_AUTO": "Automática",
|
||||
"TRACK_DEFAULT": "Padrão",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}",
|
||||
"PLAYBACK_SPEED": "Velocidade de reprodução",
|
||||
"SPEED_TOOLTIP": "Velocidade: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Legendas: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Proporção da imagem",
|
||||
"ASPECT_TOOLTIP": "Proporção: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Padrão",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Аудиодорожки",
|
||||
"SUBTITLES": "Субтитры",
|
||||
"SUBTITLES_OFF": "Выкл.",
|
||||
"SUBTITLES_ON": "Вкл.",
|
||||
"QUALITY": "Качество",
|
||||
"QUALITY_AUTO": "Авто",
|
||||
"TRACK_DEFAULT": "По умолчанию",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
|
||||
"PLAYBACK_SPEED": "Скорость воспроизведения",
|
||||
"SPEED_TOOLTIP": "Скорость: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Субтитры: {{subtitles}}",
|
||||
"ASPECT_RATIO": "Соотношение сторон",
|
||||
"ASPECT_TOOLTIP": "Соотношение: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "По умолчанию",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "Ses parçaları",
|
||||
"SUBTITLES": "Altyazılar",
|
||||
"SUBTITLES_OFF": "Kapalı",
|
||||
"SUBTITLES_ON": "Açık",
|
||||
"QUALITY": "Kalite",
|
||||
"QUALITY_AUTO": "Otomatik",
|
||||
"TRACK_DEFAULT": "Varsayılan",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}",
|
||||
"PLAYBACK_SPEED": "Oynatma hızı",
|
||||
"SPEED_TOOLTIP": "Hız: {{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "Altyazılar: {{subtitles}}",
|
||||
"ASPECT_RATIO": "En boy oranı",
|
||||
"ASPECT_TOOLTIP": "En boy oranı: {{aspect}}",
|
||||
"ASPECT_DEFAULT": "Varsayılan",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "音轨",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "关闭",
|
||||
"SUBTITLES_ON": "开启",
|
||||
"QUALITY": "画质",
|
||||
"QUALITY_AUTO": "自动",
|
||||
"TRACK_DEFAULT": "默认",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "播放速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "宽高比",
|
||||
"ASPECT_TOOLTIP": "宽高比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "默认",
|
||||
|
||||
@@ -620,6 +620,7 @@
|
||||
"AUDIO_TRACKS": "音軌",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "關閉",
|
||||
"SUBTITLES_ON": "開啟",
|
||||
"QUALITY": "畫質",
|
||||
"QUALITY_AUTO": "自動",
|
||||
"TRACK_DEFAULT": "預設",
|
||||
@@ -627,6 +628,7 @@
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
"PLAYBACK_SPEED": "播放速度",
|
||||
"SPEED_TOOLTIP": "速度:{{speed}}",
|
||||
"SUBTITLES_TOOLTIP": "字幕:{{subtitles}}",
|
||||
"ASPECT_RATIO": "長寬比",
|
||||
"ASPECT_TOOLTIP": "長寬比:{{aspect}}",
|
||||
"ASPECT_DEFAULT": "預設",
|
||||
|
||||
@@ -87,3 +87,99 @@ describe('Playwright mock-server launch', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('Nx E2E task dependencies', () => {
|
||||
type Dependency =
|
||||
| string
|
||||
| {
|
||||
projects?: string | string[];
|
||||
dependencies?: boolean;
|
||||
target?: string;
|
||||
};
|
||||
type TargetConfig = {
|
||||
dependsOn?: Dependency[];
|
||||
continuous?: boolean;
|
||||
filter?: { projects?: string[] };
|
||||
};
|
||||
type ProjectJson = { targets: Record<string, TargetConfig> };
|
||||
const readJson = <T>(path: string) =>
|
||||
JSON.parse(readFileSync(join(process.cwd(), path), 'utf8')) as T;
|
||||
const nxJson = readJson<{
|
||||
targetDefaults: Record<string, TargetConfig | TargetConfig[]>;
|
||||
}>('nx.json');
|
||||
const appNames = readdirSync(join(process.cwd(), 'apps'));
|
||||
const e2eProjectFiles = appNames
|
||||
.filter((name) => name.endsWith('-e2e'))
|
||||
.map((name) => `apps/${name}/project.json`);
|
||||
// The E2E projects list the mocks as implicit dependencies, so a `^serve`
|
||||
// dependency schedules the mock serve tasks as well.
|
||||
const mockContinuousTargets = new Set(
|
||||
appNames
|
||||
.filter((name) => name.endsWith('-mock-server'))
|
||||
.flatMap((name) =>
|
||||
Object.entries(
|
||||
readJson<ProjectJson>(`apps/${name}/project.json`).targets
|
||||
)
|
||||
.filter(([, target]) => target.continuous)
|
||||
.map(([targetName]) => targetName)
|
||||
)
|
||||
);
|
||||
const dependsOnMock = (dependency: Dependency) =>
|
||||
typeof dependency === 'string'
|
||||
? dependency.includes('-mock-server:') ||
|
||||
(dependency.startsWith('^') &&
|
||||
mockContinuousTargets.has(dependency.slice(1)))
|
||||
: [dependency.projects ?? []]
|
||||
.flat()
|
||||
.some((project) => project.includes('-mock-server')) ||
|
||||
(dependency.dependencies === true &&
|
||||
mockContinuousTargets.has(dependency.target ?? ''));
|
||||
const perFileDefaultFor = (project: string) =>
|
||||
[nxJson.targetDefaults['e2e-ci--src/*.e2e.ts'] ?? []]
|
||||
.flat()
|
||||
.find((entry) => entry.filter?.projects?.includes(project));
|
||||
|
||||
// The Playwright configs start the mocks themselves, so @nx/playwright
|
||||
// infers their E2E targets as non-parallel, and Nx refuses to run a
|
||||
// non-parallel task that depends on a continuous `serve` task.
|
||||
it('never makes an E2E target depend on a mock-server task', () => {
|
||||
const e2eDefaults = Object.entries(nxJson.targetDefaults)
|
||||
.filter(([targetName]) => targetName.startsWith('e2e'))
|
||||
.flatMap(([targetName, config]) =>
|
||||
[config].flat().map((entry) => ({
|
||||
source: `nx.json ${targetName}`,
|
||||
config: entry,
|
||||
}))
|
||||
);
|
||||
const e2eProjectTargets = e2eProjectFiles.flatMap((path) =>
|
||||
Object.entries(readJson<ProjectJson>(path).targets).map(
|
||||
([targetName, config]) => ({
|
||||
source: `${path} ${targetName}`,
|
||||
config,
|
||||
})
|
||||
)
|
||||
);
|
||||
const mockDependencies = [...e2eDefaults, ...e2eProjectTargets]
|
||||
.map(({ source, config }) => ({
|
||||
source,
|
||||
dependencies: (config.dependsOn ?? []).filter(dependsOnMock),
|
||||
}))
|
||||
.filter(({ dependencies }) => dependencies.length > 0);
|
||||
|
||||
expect(e2eProjectFiles).toEqual(
|
||||
expect.arrayContaining([
|
||||
'apps/electron-backend-e2e/project.json',
|
||||
'apps/web-e2e/project.json',
|
||||
])
|
||||
);
|
||||
expect([...mockContinuousTargets]).toContain('serve');
|
||||
expect(mockDependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds the Electron app only before Electron per-file E2E targets', () => {
|
||||
expect(perFileDefaultFor('electron-backend-e2e')?.dependsOn).toEqual([
|
||||
{ projects: ['electron-backend'], target: 'build-e2e' },
|
||||
]);
|
||||
expect(perFileDefaultFor('web-e2e')?.dependsOn).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,9 @@ 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, 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.
|
||||
`player-controls/` never read a `--mat-sys-*` token, and their keyboard focus
|
||||
is a 2px `--pc-text` outline rather than Material's theme-coloured focus layer
|
||||
(`player-controls-keyboard.e2e.ts` checks it in both themes).
|
||||
|
||||
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,
|
||||
@@ -627,6 +629,13 @@ and `--app-selection-color`; hover uses the app's neutral surface treatment.
|
||||
Keep these treatments in the shared season components and detail-action
|
||||
partial so Xtream and Stalker share the same behavior.
|
||||
|
||||
The season header's actions wrap onto their own row, starting under the
|
||||
"Seasons and Episodes" heading, before the heading itself would wrap. The
|
||||
detail pane is narrower than the window (context panel, the sticky Back lane),
|
||||
so the header's own width decides, not a viewport breakpoint. A translation
|
||||
wider than the pane itself wraps rather than ellipsizing: unlike a fixed-height
|
||||
panel title, a content heading has room to wrap and should not lose words.
|
||||
|
||||
Browser regression coverage measures the composited neutral edges and selected
|
||||
toggle fill, in addition to capturing light/dark grid and list screenshots.
|
||||
Hero action edges use matching pixels from rendered screenshots with the border
|
||||
|
||||
@@ -19,9 +19,9 @@ live in `tools/performance/`.
|
||||
| J4 `search` | six-character query typed into global search | results list settled |
|
||||
|
||||
J1 is instrumented: `renderer.initialBytes` from the built output, and the
|
||||
runtime counters of the launch benchmark below. J2 is instrumented by its own
|
||||
spec (below). J3 and J4 follow the plan in `.plans/` and are added one thread
|
||||
at a time; each thread names its journey and counter in the PR description.
|
||||
runtime counters of the launch benchmark below. J2 and J3 are instrumented by
|
||||
their own specs (below). J4 follows the plan in `.plans/` and is added in its
|
||||
own thread; each thread names its journey and counter in the PR description.
|
||||
|
||||
## Running the journeys
|
||||
|
||||
@@ -448,6 +448,13 @@ values, so a new counter needs no schema change. A J1 runtime baseline is added
|
||||
once its counter is deterministic on the CI runner; the launch counters are
|
||||
not yet (see [Ratchet](#ratchet)), so the summary is evidence only.
|
||||
|
||||
J3 adds the `journeys.playback` entry with the same shape and no schema
|
||||
version change: `counters` and `wallClock` hold only plain numbers, and its
|
||||
iterations carry `evidence.media` (the video element at `playing`) and
|
||||
`evidence.epochs.loadedMetadata` / `.playing`. The renderer probe blob gained
|
||||
a `media` field (`null` for J1 and J2), which the probe's
|
||||
`schemaVersion` 1 readers ignore.
|
||||
|
||||
## J2 `open-source`: open a source to a browsable list
|
||||
|
||||
`open-source.journey.ts` reuses the J1 profile and process pattern: the
|
||||
@@ -550,6 +557,132 @@ give a click-to-settled count; that is left to a follow-up.
|
||||
|
||||
All epochs are taken in the renderer, so neither entry crosses a process clock.
|
||||
|
||||
## J3 `playback`: start playback to the first frame
|
||||
|
||||
`playback.journey.ts` follows J2: the profile is seeded once through the
|
||||
"Add playlist" dialogs (`seedLaunchJourneyProfile` with
|
||||
`PLAYBACK_JOURNEY_SEED`), every iteration copies it, spawns a fresh process
|
||||
through `runLaunchJourney` without main-process counters, and hands the
|
||||
running app to `measurePlaybackJourney` in
|
||||
`src/journeys/playback-journey-app.ts`. One warm-up and five measured
|
||||
iterations.
|
||||
|
||||
**Profile.** J2's M3U source plus an Xtream portal ("Journey live portal") on
|
||||
the mock's `live-fallback:live-fallback` account, behind the same request
|
||||
ledger proxy. Seeding also selects **Settings > Playback > Video player >
|
||||
HTML5 video player** and **Stream format > ts** through the settings page
|
||||
(`configureLiveFormat`), so live URLs end in `.ts`. Embedded MPV and external
|
||||
players are out of scope.
|
||||
|
||||
**Stream.** `/live/live-fallback/live-fallback/10000.ts` returns
|
||||
`apps/xtream-mock-server/src/fixtures/live.mpegts` from disk: six seconds of
|
||||
160x90 H.264 baseline video and AAC audio in MPEG-TS, about 300 KB. The HTML5
|
||||
player plays `.ts` through mpegts.js, which transmuxes to fragmented MP4 for
|
||||
Media Source Extensions; H.264 and AAC are among the codecs Electron's
|
||||
Chromium decodes on every platform, including the Linux runner, and the
|
||||
Electron E2E for the live-format fallback already plays this fixture there.
|
||||
Two other choices were rejected: the `marketing` and `marketing2` accounts
|
||||
serve live URLs from local bytes, but those bytes are zero-filled (a fixture
|
||||
for download screenshots, not media), so no player ever fires `playing`; every
|
||||
other account redirects streams to a public HLS test stream. The record
|
||||
fails an iteration whose click-to-`playing` window has no `.ts` request for
|
||||
a live stream, so a player that played something else is never measured.
|
||||
|
||||
**No request leaves the machine.** The generated live catalog's channel and
|
||||
category logos point at `picsum.photos`. Before navigating, the journey
|
||||
registers `session.defaultSession.webRequest.onBeforeRequest` for
|
||||
`*://picsum.photos/*` from the test side and cancels those requests (the app
|
||||
registers no `onBeforeRequest` listener of its own, so none is replaced). A
|
||||
logo therefore never loads, or fails, at a moment that depends on the
|
||||
runner's network; the number cancelled is kept as
|
||||
`evidence.externalArtworkCancelled`. Every other request goes to the mock
|
||||
through the ledger.
|
||||
|
||||
**Start.** After J1 has ended, the test clicks the portal's dashboard card,
|
||||
the **Live TV** link and the first category (not measured), then installs the
|
||||
IPC capture with a start sentinel, arms the probe, hovers the first
|
||||
`app-live-stream-layout [data-test-id="channel-item"]` and waits for the same
|
||||
1 s quiet as J2 (`src/performance/journey-click-settle.ts`, shared with J2).
|
||||
J1's capture is detached and the channel is clicked. The probe's capture-phase
|
||||
`click` listener stamps the start and sends
|
||||
`cancelSourceProbe('__iptvnator-journey-playback-start__')`. The record
|
||||
rejects activity between the settle snapshot and the click exactly as J2
|
||||
does.
|
||||
|
||||
**End.** The probe runs with `media: { endEvent: 'playing', phaseEvents:
|
||||
['loadedmetadata'] }`. Media events do not bubble, but a capture-phase
|
||||
listener on `window` sees them before any listener of the app. The first
|
||||
`playing` event after the start on an element matching
|
||||
`app-web-player-view video` ends the journey: pending mutation records are
|
||||
taken synchronously, the end sentinel
|
||||
`cancelSourceProbe('__iptvnator-journey-playback-end__')` is sent, and the
|
||||
element's state is recorded (`evidence.media`: `readyState`, `paused`,
|
||||
`currentTime`, intrinsic size, and `currentSrcScheme`, which is `blob` for
|
||||
Media Source playback). The first `loadedmetadata` on such an element after
|
||||
the start is recorded as a phase. A visible video element does not end the
|
||||
journey, and media events before the click or on other elements are
|
||||
ignored.
|
||||
|
||||
### Counters
|
||||
|
||||
| Counter | Source |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `renderer.ipcCallsToPlaying` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. |
|
||||
| `renderer.httpRequestsToPlaying` | Requests the ledger proxy received from the click stamp until the `playing` stamp, from either process (the stream request comes from the renderer, Xtream API calls from the main process). Both stamps are `performance.timeOrigin + performance.now()` of processes on the same host clock, as in J2. Unlike J2's counter the window ends at the terminal, not at a quiet mock: a live stream has no quiet end. Later requests are kept as `evidence.httpRequestsAfterPlayingByRoute`, the ones in the window as `evidence.httpRequestsByRoute`. |
|
||||
| `renderer.domMutationsToPlaying` | `MutationRecord`s from the click until the `playing` event, including records still queued when it fires. |
|
||||
| `renderer.cdTicksToPlaying` | `ApplicationRef` ticks from the click until the `playing` event, read like `renderer.cdTicksToFirstPage` in J2 (see [Change-detection ticks](#change-detection-ticks)). Not yet measured on a run; the counter shipped after J3's first measurements. |
|
||||
| `renderer.layoutShiftScore` | All `layout-shift` entries from the click until the `playing` event, including `hadRecentInput` ones (as J2), rounded to three decimals. Entries delivered up to the post-paint cutoff are read, but only those that started by the event count. |
|
||||
| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the `playing` event, so the task that dispatched the event counts. Evidence until shown to be stable on the runner. |
|
||||
|
||||
`renderer.httpRequestsToPlaying` compares the ledger's arrival stamps
|
||||
(test process) with the renderer's click and `playing` stamps. Both are
|
||||
`performance.timeOrigin + performance.now()` on the same host clock, but the
|
||||
two processes' time origins can differ by a fraction of a millisecond, so
|
||||
every iteration records `evidence.httpBoundaryMarginsMs`: the distance of
|
||||
the nearest request on either side of the click and of `playing`. A margin
|
||||
of a few milliseconds means a clock difference could move that request
|
||||
across the boundary. Locally the first request after the click arrives 3-6
|
||||
ms after its stamp (the click causes it, so it cannot precede the click) and
|
||||
the nearest request to `playing` is more than 170 ms away; both are well
|
||||
above a sub-millisecond origin difference. `evidence.httpRequestsAfterPlayingByRoute` covers a fixed
|
||||
window of 1 s after `playing` (the test waits that long before reading the
|
||||
ledger), not a quiet mock as in J2: a live stream has no quiet end.
|
||||
|
||||
One counter is listed under `unavailable`.
|
||||
`renderer.ipcSerialDepthToPlaying` is missing because the serial-depth
|
||||
helper (see [Startup work before the first card](#startup-work-before-the-first-card))
|
||||
was not on `master` when J3 landed; J3 adopts it once the J1 thread adds it.
|
||||
`main.sqlStatementsToPlaying` is not measured for the same reason as J2's
|
||||
SQL counter.
|
||||
|
||||
### Wall-clock
|
||||
|
||||
| Entry | Derivation |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clickToPlayingMs.p50/.p90` | `playing` epoch minus start epoch. |
|
||||
| `clickToLoadedMetadataMs.p50/.p90` | First `loadedmetadata` epoch minus start epoch: player setup, the stream request and the first transmuxed init segment. |
|
||||
|
||||
The difference of the two is stream start: buffering until the element can
|
||||
play. All epochs are taken in the renderer.
|
||||
|
||||
### First measurement
|
||||
|
||||
Local, macOS, 2026-09-30 (two `perf:journeys` runs, five measured iterations
|
||||
each): every counter identical in all ten, `renderer.ipcCallsToPlaying` 4
|
||||
(`getEpgMapping`, `xtreamRequest`, `updateRemoteControlStatus`,
|
||||
`setUserAgent`), `renderer.httpRequestsToPlaying` 2 (the `.ts` stream and
|
||||
`get_simple_data_table`), `renderer.domMutationsToPlaying` 6,188,
|
||||
`renderer.layoutShiftScore` 0.001, `renderer.longTasks` 0; P50
|
||||
click→`loadedmetadata` 92-94 ms and click→`playing` 239-257 ms. The warm-up
|
||||
iteration of the first run took the cold path (888 ms to `playing`, one more
|
||||
`updateRemoteControlStatus` call); warm-ups are excluded. About 6,000 of the
|
||||
mutations come from the EPG timeline rendering about 240 programme blocks
|
||||
from the `get_simple_data_table` response before the first frame. Whether
|
||||
that response and its render land before `playing` is a race on a slower
|
||||
machine, so check the runner's `counterStability` before trusting the
|
||||
mutation and request counts. No J3 baseline exists yet; J3 counters join the
|
||||
ratchet once three runner runs agree.
|
||||
|
||||
## `renderer.initialBytes`
|
||||
|
||||
The bytes a browser fetches before Angular can bootstrap, read from the built
|
||||
@@ -845,10 +978,12 @@ reports slow imports of non-Latin playlists.
|
||||
continues from J1 with `runLaunchJourney` and lets the app settle first,
|
||||
as `open-source-journey-app.ts` does.
|
||||
2. Give the journey its own probe options (`cardSelector`,
|
||||
`companionSelectors`, `routeFragment`, `startClick` for a click start) or
|
||||
extend `journey-renderer-probe.ts` when the end condition is not "elements
|
||||
became visible". Use a state key and sentinel ids of its own. Keep the
|
||||
probe self-contained: Playwright serializes it with `toString()`.
|
||||
`companionSelectors`, `routeFragment`, `startClick` for a click start,
|
||||
`media` for a media-event end such as J3's `playing`) or extend
|
||||
`journey-renderer-probe.ts` when the end condition is neither. Use a state
|
||||
key and sentinel ids of its own. Keep the probe self-contained: Playwright
|
||||
serializes it with `toString()`. A click-started journey settles with
|
||||
`waitForJourneyClickQuiet` from `journey-click-settle.ts`.
|
||||
3. Map the measurement to a `JourneyIterationRecord` in a
|
||||
`<journey>-journey-record.ts` under `src/performance/`; name counters
|
||||
`renderer.*` or `main.*`, and list counters you cannot measure under
|
||||
|
||||
@@ -297,7 +297,8 @@ It owns only transient presentation behavior:
|
||||
- `ControlsSettings` — the settings panel's groups, on/modified state and
|
||||
open/close transitions (`controls-settings-groups.ts` holds the pure
|
||||
group-availability rule);
|
||||
- `app-player-settings-panel` — the panel / bottom sheet presentation;
|
||||
- `app-player-settings-panel` — the panel / bottom sheet presentation,
|
||||
whose radio groups use `settings-radio-group.directive.ts`;
|
||||
- `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's
|
||||
gate and presentation; and
|
||||
- `controls-view-model.ts` — derived display state.
|
||||
@@ -346,7 +347,11 @@ app's `--app-selection-color` is a different blue that would fight the video.
|
||||
measured below 3:1 against the lighter fills.
|
||||
- **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through
|
||||
Material's `--mat-icon-button-*` tokens; their hover is a flat
|
||||
`rgba(255,255,255,.1)` layer.
|
||||
`rgba(255,255,255,.1)` layer. Keyboard focus draws a 2px `--pc-text`
|
||||
outline (`:focus-visible`), and Material's focus state layer is switched
|
||||
off: its colour comes from the app theme, and the light theme's dark layer
|
||||
left no visible focus on video. The settings panel's icon buttons use the
|
||||
same ring, inset by 2px because the panel body clips at its edge.
|
||||
|
||||
### Timeline segments
|
||||
|
||||
@@ -458,7 +463,10 @@ the panel the moment the last group disappears.
|
||||
|
||||
- **Roomy wide dock** (≥ 960px): two **value chips** precede `tune` — subtitles
|
||||
(`closed_caption` + the selected track's label, or "Off") and speed
|
||||
(`speed` + `1.25×`). Audio and aspect ratio have no chip: they are
|
||||
(`speed` + `1.25×`). A chip's accessible name and tooltip carry its
|
||||
value (`SUBTITLES_TOOLTIP` "Subtitles: English", `SPEED_TOOLTIP`
|
||||
"Speed: 1.25×"); `tune` has `aria-haspopup="dialog"` and
|
||||
`aria-expanded`. Audio and aspect ratio have no chip: they are
|
||||
panel-only. A chip click opens the panel **focused on its group**
|
||||
(`settingsFocus`; the group scrolls into view and wears a brief ring);
|
||||
right-click or long-press on the subtitle chip toggles subtitles without
|
||||
@@ -476,12 +484,34 @@ the panel the moment the last group disappears.
|
||||
picture and recording stay in the compact dock — the mock shows only
|
||||
`tune` + fullscreen there, but those two are engine features a viewer
|
||||
needs without opening anything.
|
||||
- **Inside**: list groups (audio, subtitles, quality) use `menuitemradio`
|
||||
rows with a check mark and a cyan selection; segmented groups (speed,
|
||||
aspect) use `radio` items with a violet selection, and a selected default
|
||||
(`1×`, the first aspect preset) stays neutral. The subtitle group carries
|
||||
the load-file action and the delay / size / color sections that the
|
||||
popover used to hold (same `player-controls-load-subtitle`,
|
||||
- **Inside**: every choice is a `radio` in a `radiogroup`
|
||||
(`SettingsRadioGroupDirective` / `SettingsRadioDirective`). List groups
|
||||
(audio, subtitles, quality) are rows with a check mark and a cyan
|
||||
selection; segmented groups (speed, aspect, subtitle size) have a violet
|
||||
selection, and a selected default (`1×`, the first aspect preset) stays
|
||||
neutral. Each group is one Tab stop — the checked option as the engine
|
||||
reports it, else the first — and arrows, Home and End move focus with a
|
||||
CDK `FocusKeyManager` (wrapping; the horizontal arrows follow `direction`)
|
||||
and check the option they reach, as a native radio group does: the
|
||||
directive clicks it, so the template's handler applies the choice, even
|
||||
onto an option the engine still reports as checked: returning to it must
|
||||
cancel a switch that is still pending. Focus
|
||||
changes also write the roving `tabindex` immediately, because a quick
|
||||
Shift+Tab, Tab can arrive before change detection updates the bindings. The dialog is named by its `h2` title (`aria-labelledby`), each
|
||||
radio group by its `h3` group heading or `h4` subheading, and the delay
|
||||
buttons form a labelled `group`. Headings read `--pc-text-secondary` on
|
||||
`--pc-glass-bg-dense` (`rgba(12,16,23,.86)`), 4.5:1 or more even over a
|
||||
white frame. They wrap with `overflow-wrap: anywhere` and `hyphens: auto`,
|
||||
so one long word (German "Wiedergabegeschwindigkeit") breaks inside the
|
||||
sheet's 84px heading column. Hyphenation needs a `lang` on `<html>`.
|
||||
With subtitles on but no track marked selected yet (the engine can report
|
||||
the switch before the track list), the subtitle chip reads "On"
|
||||
(`SUBTITLES_ON`), never "Off".
|
||||
Every focused option shows the `--pc-text` ring; a selected swatch has a
|
||||
white border with a dark inner gap, and focus adds an outer ring. The
|
||||
subtitle group carries the load-file action (a plain button outside the
|
||||
radio group) and the delay / size / color sections that the popover used
|
||||
to hold (same `player-controls-load-subtitle`,
|
||||
`player-controls-subtitle-delay`, `player-controls-subtitle-style` test
|
||||
ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from
|
||||
the keyboard (the opener is `:focus-visible`) it takes focus, a pointer
|
||||
@@ -1639,6 +1669,7 @@ libs/ui/playback/src/lib/player-controls/
|
||||
├── player-settings-panel.component.ts
|
||||
├── player-settings-panel.component.html
|
||||
├── player-settings-panel.component.scss
|
||||
├── settings-radio-group.directive.ts
|
||||
├── controls-fullscreen.ts
|
||||
├── controls-menu-selection.ts
|
||||
├── controls-menu-state.ts
|
||||
|
||||
@@ -30,6 +30,22 @@ The button has an opaque app-themed surface, visible keyboard focus, an Escape
|
||||
shortcut hint via native `title` and Electron `no-drag` hit testing. The hint
|
||||
does not create an overlay that could consume the first Escape press.
|
||||
|
||||
The control floats, so the content makes room for it: while it is shown, every
|
||||
content column (hero content, watch-state player, episodes, About and extras)
|
||||
starts after a 72 px lane — 16 px inset, 40 px button, 16 px gap — and no scroll
|
||||
position puts the arrow over a heading or the player. The shell derives the lane
|
||||
from the button's geometry and publishes it as `--detail-back-lane`, which
|
||||
`content-hero` and `content-about` read too; hosts with `backAvailable=false`
|
||||
keep their own inset. The lane is horizontal because watch state is short of
|
||||
height, not width. A pane narrower than 400 px is the exception — a phone, or a
|
||||
desktop pane beside the context panel — because a 72 px column there would clip
|
||||
the player's controls. The pane decides, not the viewport: a `ResizeObserver`
|
||||
on the shell's border box (scrollbar-independent) sets `shell-host--compact`,
|
||||
and the wrapper becomes an in-flow 56 px sticky bar on `--app-header-bg` that
|
||||
content scrolls beneath and that takes the taps over it. Electron E2E
|
||||
(`detail-back-lane.e2e.ts`) sweeps both states with the lane at 1280 and
|
||||
780 px and with the bar at 700 and 375 px.
|
||||
|
||||
The sticky control is route-level Back in both states: it emits `backClicked`
|
||||
whether or not inline playback is active, so the arrow keeps one meaning and
|
||||
the list is one click away while watching. Only Escape unwinds one level: watch
|
||||
|
||||
@@ -776,6 +776,11 @@ tag a language at all, so in practice it is usually silent — which is the
|
||||
honest state, and the same one the rest of this feature takes when it does not
|
||||
know.
|
||||
|
||||
Undetermined language tags (`und`, including regional forms such as `und-US`)
|
||||
are explicitly treated as unknown after normalization. This must hold whether
|
||||
the runtime's `Intl.Locale` returns no language or preserves `und`: neither
|
||||
case establishes a factual dub difference against a known language.
|
||||
|
||||
Switching sources through `startResolvedPlayback` closes the external session
|
||||
it LAUNCHED first — tracked separately so refreshes and overlapping handoffs
|
||||
cannot disown it within the same route session. The retained identity is ignored
|
||||
|
||||
@@ -579,7 +579,11 @@ for the serve target's `--tsconfig` flag and is required for the `@iptvnator/*`
|
||||
path aliases. `project-config.spec.ts` pins which configs start which mock,
|
||||
and fails if any of them launches a mock through Nx or a new config starts one
|
||||
without being listed there. The `serve` targets remain the entry point
|
||||
for starting a mock by hand. The web-e2e `web-backend` entry uses the same
|
||||
for starting a mock by hand, and no E2E target may depend on them: with no
|
||||
Nx-launched server, `@nx/playwright` infers the atomized `e2e-ci--*` targets
|
||||
as non-parallel, and Nx refuses to run a non-parallel task that depends on a
|
||||
continuous `serve` task. The same spec guards the `e2e*` entries of `nx.json`
|
||||
`targetDefaults` and every target in the `apps/*-e2e` `project.json` files. The web-e2e `web-backend` entry uses the same
|
||||
launch form; see [PWA web backend](pwa-self-hosted.md#web-backend).
|
||||
|
||||
### Request Interception
|
||||
|
||||
+42
-1
@@ -180,8 +180,10 @@ describe('applyApiMetadata', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
// ffprobe's marker for "we do not know".
|
||||
// ffprobe's marker for "we do not know", regardless of whether
|
||||
// this runtime's Intl.Locale preserves the language subtag.
|
||||
['und'],
|
||||
['und-US'],
|
||||
['Russian'],
|
||||
['en_US'],
|
||||
[''],
|
||||
@@ -360,6 +362,45 @@ describe('audioDiffersFactually', () => {
|
||||
expect(audioDiffersFactually(from, to)).toBe(false);
|
||||
});
|
||||
|
||||
it('stays silent when one side reports an undetermined language', () => {
|
||||
// `und` is ffprobe admitting it does not know; measured against a
|
||||
// stated English it is not a dub change.
|
||||
const from = candidate(
|
||||
applyApiMetadata(candidate(), { audioLanguage: 'und' })
|
||||
);
|
||||
const to = candidate(
|
||||
applyApiMetadata(candidate(), { audioLanguage: 'eng' })
|
||||
);
|
||||
|
||||
expect(audioDiffersFactually(from, to)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['und', 'und-US', 'UND'])(
|
||||
'stays silent for %s when Intl.Locale preserves und',
|
||||
(raw) => {
|
||||
const known = applyApiMetadata(candidate(), {
|
||||
audioLanguage: 'eng',
|
||||
});
|
||||
// Exercise the newer runtime behavior even on Node 22. The
|
||||
// captured Locale constructor still reads this prototype getter.
|
||||
const language = jest
|
||||
.spyOn(Intl.Locale.prototype, 'language', 'get')
|
||||
.mockReturnValue('und');
|
||||
|
||||
try {
|
||||
const unknown = applyApiMetadata(candidate(), {
|
||||
audioLanguage: raw,
|
||||
});
|
||||
|
||||
expect(unknown.audioLanguage).toBeUndefined();
|
||||
expect(audioDiffersFactually(unknown, known)).toBe(false);
|
||||
expect(audioDiffersFactually(known, unknown)).toBe(false);
|
||||
} finally {
|
||||
language.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('stays silent when both sides state the same track', () => {
|
||||
const from = candidate({
|
||||
audioLanguage: { value: 'rus', provenance: 'api' },
|
||||
|
||||
@@ -217,8 +217,11 @@ function keepFactual(field?: VodSourceField): VodSourceField | undefined {
|
||||
*
|
||||
* `Intl.Locale` does the canonicalizing: 639-2 collapses to 639-1 where one
|
||||
* exists, both German forms land on `de`, and regions drop away. `und` —
|
||||
* ffprobe's marker for undetermined — canonicalizes to nothing, which is
|
||||
* exactly right: it means the provider does not know either.
|
||||
* ffprobe's marker for undetermined — is declined explicitly: it means the
|
||||
* provider does not know either. Some runtimes return no language for this
|
||||
* tag; others (including Electron 43 and Node 26) preserve `und`. Declining
|
||||
* it in either case prevents "unknown vs. English" from becoming a dub
|
||||
* change, independently of the runtime's Intl implementation.
|
||||
*
|
||||
* Anything that survives with more than three characters is not a language
|
||||
* code (`Russian` parses as the subtag `russian`), so the comparison is
|
||||
@@ -232,7 +235,11 @@ function canonicalLanguage(raw: string | null | undefined): string | null {
|
||||
|
||||
try {
|
||||
const language = new LocaleCtor(value).language;
|
||||
return language && language.length <= 3 ? language : null;
|
||||
return language &&
|
||||
language.length <= 3 &&
|
||||
language !== UNDETERMINED_LANGUAGE
|
||||
? language
|
||||
: null;
|
||||
} catch {
|
||||
// Not a well-formed tag at all; saying nothing beats comparing junk.
|
||||
return null;
|
||||
@@ -253,6 +260,9 @@ const LocaleCtor = (
|
||||
}
|
||||
).Locale;
|
||||
|
||||
/** ISO 639-2 "undetermined"; see `canonicalLanguage`. */
|
||||
const UNDETERMINED_LANGUAGE = 'und';
|
||||
|
||||
function cleanString(raw: string | null | undefined): string | null {
|
||||
const trimmed = typeof raw === 'string' ? raw.trim() : '';
|
||||
return trimmed === '' ? null : trimmed;
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
padding: 140px 40px 60px;
|
||||
// Reserved by the detail shell while its sticky Back control shows.
|
||||
padding-inline-start: max(40px, var(--detail-back-lane, 0px));
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
@@ -511,6 +513,7 @@
|
||||
|
||||
&__content {
|
||||
padding: 60px 20px 40px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
@@ -552,6 +555,7 @@
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-inline: 16px;
|
||||
padding-inline-start: max(16px, var(--detail-back-lane, 0px));
|
||||
}
|
||||
|
||||
.poster {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
max-width: var(--content-max-width, 1400px);
|
||||
margin: 0 auto;
|
||||
padding: 24px 40px 40px;
|
||||
// The detail shell's sticky Back lane (0 when there is no Back).
|
||||
padding-inline-start: max(40px, var(--detail-back-lane, 0px));
|
||||
border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12));
|
||||
|
||||
&__heading {
|
||||
@@ -127,6 +129,7 @@
|
||||
@media (max-width: 800px) {
|
||||
.about {
|
||||
padding: 20px 20px 32px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
|
||||
|
||||
&__poster {
|
||||
width: 92px;
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
--tag-border: rgba(0, 0, 0, 0.12);
|
||||
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
|
||||
--content-max-width: 1400px;
|
||||
// The sticky Back control floats over the scroll owner. While it is shown,
|
||||
// every content column (hero, player, episodes, About, extras) starts
|
||||
// after its lane — inset + button + the same inset again — so nothing
|
||||
// scrolls beneath it. content-hero and content-about read the lane too.
|
||||
--shell-back-inset: 16px;
|
||||
--shell-back-size: 40px;
|
||||
--detail-back-lane: 0px;
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
@@ -30,6 +37,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
:host(.shell-host--back) {
|
||||
--detail-back-lane: calc(
|
||||
2 * var(--shell-back-inset) + var(--shell-back-size)
|
||||
);
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) {
|
||||
--surface-bg: #141414;
|
||||
--text-primary: #fff;
|
||||
@@ -43,7 +56,7 @@
|
||||
.shell__navigation {
|
||||
// Direct child of the scroll owner: sticky lasts through the entire page.
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
top: var(--shell-back-inset);
|
||||
height: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
@@ -51,15 +64,15 @@
|
||||
|
||||
.shell__back-button {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
inset-inline-start: var(--shell-back-inset);
|
||||
pointer-events: auto;
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: var(--shell-back-size);
|
||||
height: var(--shell-back-size);
|
||||
border-radius: 50%;
|
||||
background: var(--app-widget-bg, var(--surface-bg));
|
||||
color: var(--app-on-surface, var(--text-primary));
|
||||
@@ -122,6 +135,7 @@
|
||||
|
||||
&--active {
|
||||
padding: 16px 40px 0;
|
||||
padding-inline-start: max(40px, var(--detail-back-lane));
|
||||
animation: shell-fade-in 300ms ease 50ms both;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +144,7 @@
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
padding-inline-start: max(40px, var(--detail-back-lane));
|
||||
}
|
||||
|
||||
.shell__about {
|
||||
@@ -143,6 +158,7 @@
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 40px 40px;
|
||||
padding-inline-start: max(40px, var(--detail-back-lane));
|
||||
}
|
||||
|
||||
@keyframes shell-fade-in {
|
||||
@@ -159,14 +175,38 @@
|
||||
@media (max-width: 800px) {
|
||||
.shell__player--active {
|
||||
padding: 8px 12px 0;
|
||||
padding-inline-start: max(12px, var(--detail-back-lane));
|
||||
}
|
||||
|
||||
.shell__episodes {
|
||||
padding: 0 20px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane));
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
padding: 0 20px 32px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane));
|
||||
}
|
||||
}
|
||||
|
||||
// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside
|
||||
// the context panel; the component sets the class from the pane's width)
|
||||
// would clip the player's controls. There the control takes a row instead —
|
||||
// an opaque sticky bar in flow that content scrolls beneath, never alongside.
|
||||
:host(.shell-host--compact) {
|
||||
--detail-back-lane: 0px;
|
||||
|
||||
.shell__navigation {
|
||||
top: 0;
|
||||
height: calc(var(--shell-back-size) + 16px);
|
||||
background: var(--app-header-bg);
|
||||
// The bar hides what scrolls beneath it, so it must not let taps
|
||||
// through to that hidden content either.
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shell__back-button {
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -451,3 +451,67 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PortalDetailShellComponent pane width', () => {
|
||||
const original = globalThis.ResizeObserver;
|
||||
const disconnect = jest.fn();
|
||||
// The hero observes itself too, so keep each callback with its target.
|
||||
let observers: { callback: ResizeObserverCallback; target?: Element }[];
|
||||
|
||||
beforeEach(() => {
|
||||
observers = [];
|
||||
disconnect.mockClear();
|
||||
globalThis.ResizeObserver = class {
|
||||
private readonly entry: (typeof observers)[number];
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.entry = { callback };
|
||||
observers.push(this.entry);
|
||||
}
|
||||
observe(target: Element): void {
|
||||
this.entry.target = target;
|
||||
}
|
||||
unobserve = jest.fn();
|
||||
disconnect = disconnect;
|
||||
} as unknown as typeof ResizeObserver;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostComponent, TranslateModule.forRoot()],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = original;
|
||||
});
|
||||
|
||||
const resize = (shell: HTMLElement, inlineSize: number): void => {
|
||||
const observer = observers.find(({ target }) => target === shell);
|
||||
if (!observer) throw new Error('The shell does not observe itself.');
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
borderBoxSize: [{ inlineSize, blockSize: 800 }],
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver
|
||||
);
|
||||
};
|
||||
|
||||
it('trades the Back lane for a bar when the pane itself is narrow', () => {
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
const shell = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'app-portal-detail-shell'
|
||||
) as HTMLElement;
|
||||
|
||||
// A 780px window leaves the pane ~402px beside the context panel.
|
||||
resize(shell, 402);
|
||||
expect(shell.classList).not.toContain('shell-host--compact');
|
||||
// A 641px window leaves it ~260px: too narrow for the lane.
|
||||
resize(shell, 260);
|
||||
expect(shell.classList).toContain('shell-host--compact');
|
||||
resize(shell, 1280);
|
||||
expect(shell.classList).not.toContain('shell-host--compact');
|
||||
|
||||
fixture.destroy();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
Injector,
|
||||
computed,
|
||||
@@ -23,6 +24,14 @@ import {
|
||||
DetailTagsTemplateDirective,
|
||||
} from './detail-template.directives';
|
||||
|
||||
/**
|
||||
* Below this pane width the Back lane (16 + 40 + 16px) would leave the player
|
||||
* card under ~316px, where its control row clips, so the control takes a
|
||||
* sticky bar instead. The pane decides, not the viewport: beside the context
|
||||
* panel a desktop pane can be narrower than a phone.
|
||||
*/
|
||||
const COMPACT_SHELL_WIDTH = 400;
|
||||
|
||||
/**
|
||||
* Two-state layout shell for portal VOD/series detail pages.
|
||||
*
|
||||
@@ -61,12 +70,15 @@ import {
|
||||
// on body, while preserving already-handled events and overlay guards.
|
||||
'(keydown.escape)': 'onEscape($event)',
|
||||
'[class.shell-host--watch]': 'isWatch()',
|
||||
// Content columns reserve the sticky Back control's lane.
|
||||
'[class.shell-host--back]': 'backAvailable()',
|
||||
'(document:keydown.escape)': 'onEscape($event)',
|
||||
},
|
||||
})
|
||||
export class PortalDetailShellComponent {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly backButton =
|
||||
viewChild<ElementRef<HTMLButtonElement>>('backButton');
|
||||
|
||||
@@ -108,6 +120,7 @@ export class PortalDetailShellComponent {
|
||||
element.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
this.observeCompactWidth();
|
||||
let wasWatch = false;
|
||||
effect(() => {
|
||||
const watch = this.isWatch();
|
||||
@@ -241,4 +254,24 @@ export class PortalDetailShellComponent {
|
||||
}
|
||||
element.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles `shell-host--compact` straight on the host, so the class lands
|
||||
* in the same frame as the resize instead of after a change-detection
|
||||
* pass. The border box keeps the threshold independent of scrollbar width.
|
||||
*/
|
||||
private observeCompactWidth(): void {
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const element = this.host.nativeElement;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const width =
|
||||
entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth;
|
||||
element.classList.toggle(
|
||||
'shell-host--compact',
|
||||
width < COMPACT_SHELL_WIDTH
|
||||
);
|
||||
});
|
||||
observer.observe(element);
|
||||
this.destroyRef.onDestroy(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
// The actions move onto their own row before the title has to wrap. The
|
||||
// pane is narrower than the window (context panel, Back lane), so a
|
||||
// viewport breakpoint cannot tell when the two stop fitting side by side.
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
@@ -14,14 +18,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
// Beside the title the header's space-between keeps the actions at the
|
||||
// end; once they move to their own row they start under the title.
|
||||
&__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -117,7 +123,6 @@
|
||||
@media (max-width: 560px) {
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__actions {
|
||||
width: 100%;
|
||||
|
||||
@@ -366,6 +366,16 @@
|
||||
settings.available() && layout.roomy() && !settings.isOpen()
|
||||
) {
|
||||
@if (settings.groups().subtitles) {
|
||||
<!-- On with no track marked selected yet (the engine
|
||||
can report the id before the list) reads "On",
|
||||
never "Off". The name carries the value
|
||||
("Subtitles: English"); the label is the value. -->
|
||||
@let subtitleValue =
|
||||
settings.subtitleLabel() ??
|
||||
((settings.subtitlesOn()
|
||||
? 'EMBEDDED_MPV.PLAYER.SUBTITLES_ON'
|
||||
: 'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
|
||||
) | translate);
|
||||
<button
|
||||
type="button"
|
||||
class="player-controls__chip"
|
||||
@@ -378,10 +388,12 @@
|
||||
settings.onSubtitleChipContextMenu($event)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES_TOOLTIP'
|
||||
| translate: { subtitles: subtitleValue }
|
||||
"
|
||||
[matTooltip]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES_TOOLTIP'
|
||||
| translate: { subtitles: subtitleValue }
|
||||
"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
@@ -391,9 +403,7 @@
|
||||
: 'closed_caption_disabled'
|
||||
}}</mat-icon>
|
||||
<span class="player-controls__chip-label">{{
|
||||
settings.subtitleLabel() ??
|
||||
('EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
|
||||
| translate)
|
||||
subtitleValue
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@@ -407,7 +417,9 @@
|
||||
data-test-id="player-controls-speed-chip"
|
||||
(click)="settings.open('speed')"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
|
||||
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
|
||||
| translate
|
||||
: { speed: settings.speedLabel() }
|
||||
"
|
||||
[matTooltip]="
|
||||
'EMBEDDED_MPV.PLAYER.SPEED_TOOLTIP'
|
||||
@@ -434,6 +446,7 @@
|
||||
"
|
||||
data-test-id="player-controls-settings-button"
|
||||
(click)="settings.toggle()"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="settings.isOpen()"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SETTINGS' | translate
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
--pc-track: rgba(255, 255, 255, 0.18);
|
||||
--pc-hover: rgba(255, 255, 255, 0.1);
|
||||
--pc-glass-bg: rgba(12, 16, 23, 0.72);
|
||||
// The settings panel carries small secondary text over whatever frame is
|
||||
// playing: denser glass keeps it at 4.5:1 even over a white frame.
|
||||
--pc-glass-bg-dense: rgba(12, 16, 23, 0.86);
|
||||
--pc-glass-border: rgba(255, 255, 255, 0.09);
|
||||
--pc-font-mono:
|
||||
ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||
@@ -493,10 +496,20 @@
|
||||
--mat-icon-button-state-layer-size: 40px;
|
||||
--mat-icon-button-container-shape: 12px;
|
||||
--mat-icon-button-hover-state-layer-opacity: 0;
|
||||
// The ring below is the focus indicator; Material's focus layer would
|
||||
// take its colour from the app theme.
|
||||
--mat-icon-button-focus-state-layer-opacity: 0;
|
||||
border-radius: 12px;
|
||||
transition: background 120ms ease-out;
|
||||
}
|
||||
|
||||
// Keyboard focus in the overlay's own text colour, so it shows on video in
|
||||
// both app themes. The active `tune` button keeps its accent fill under it.
|
||||
:host :is(button[mat-icon-button]:focus-visible) {
|
||||
outline: 2px solid var(--pc-text);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
:host :is(button[mat-icon-button]) mat-icon {
|
||||
color: var(--pc-text);
|
||||
}
|
||||
|
||||
+3
-1
@@ -219,7 +219,9 @@ describe('PlayerControlsComponent settings panel', () => {
|
||||
'player-controls__settings--sheet'
|
||||
);
|
||||
expect(panel?.getAttribute('role')).toBe('dialog');
|
||||
expect(panel?.getAttribute('aria-label')).toBe('Settings');
|
||||
const title = panel?.querySelector('h2');
|
||||
expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id);
|
||||
expect(title?.textContent?.trim()).toBe('Settings');
|
||||
expect(
|
||||
query('[data-test-id="player-settings-speed"]')?.classList
|
||||
).toContain('player-settings__group--focused');
|
||||
|
||||
+4
-2
@@ -99,12 +99,14 @@ describe('PlayerControlsComponent subtitle settings', () => {
|
||||
});
|
||||
|
||||
it('renders the subtitle chip with zero tracks when external loading exists', () => {
|
||||
expect(query('[aria-label="Subtitles"]')).toBeNull();
|
||||
const chip = () =>
|
||||
query('[data-test-id="player-controls-subtitle-chip"]');
|
||||
expect(chip()).toBeNull();
|
||||
|
||||
setCapabilities({ externalSubtitles: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('[aria-label="Subtitles"]')).not.toBeNull();
|
||||
expect(chip()).not.toBeNull();
|
||||
openSubtitleMenu();
|
||||
// No track list entries: no Off row without a selectable track…
|
||||
expect(query('.player-settings__option--selected')).toBeNull();
|
||||
|
||||
@@ -30,6 +30,8 @@ const STYLE_SOURCES = new Map(
|
||||
const HOST_STYLES = STYLE_SOURCES.get('player-controls.component.scss') ?? '';
|
||||
const TIMELINE_STYLES =
|
||||
STYLE_SOURCES.get('player-timeline.component.scss') ?? '';
|
||||
const SETTINGS_STYLES =
|
||||
STYLE_SOURCES.get('player-settings-panel.component.scss') ?? '';
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
@@ -52,6 +54,24 @@ function paletteColor(token: string): { rgb: Rgb; alpha: number } {
|
||||
throw new Error(`${token} is not a literal palette colour: "${value}"`);
|
||||
}
|
||||
|
||||
/** A translucent palette colour composited over an opaque frame. */
|
||||
function over(color: { rgb: Rgb; alpha: number }, frame: Rgb): Rgb {
|
||||
return color.rgb.map(
|
||||
(channel, index) =>
|
||||
channel * color.alpha + frame[index] * (1 - color.alpha)
|
||||
) as Rgb;
|
||||
}
|
||||
|
||||
/** The declarations of the first rule whose selector list is exactly `selector`. */
|
||||
function ruleBody(source: string, selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return (
|
||||
source.match(
|
||||
new RegExp(`(?:^|[}\\s])${escaped}\\s*\\{([^}]*)\\}`)
|
||||
)?.[1] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
function contrastRatio(foreground: Rgb, background: Rgb): number {
|
||||
const luminance = (rgb: Rgb): number => {
|
||||
const [red, green, blue] = rgb.map((channel) => {
|
||||
@@ -130,6 +150,67 @@ describe('player controls overlay palette', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the settings headings at 4.5:1 on the panel glass over bright frames', () => {
|
||||
const midGrey: Rgb = [128, 128, 128];
|
||||
const white: Rgb = [255, 255, 255];
|
||||
const glass = paletteColor('--pc-glass-bg-dense');
|
||||
const secondary = paletteColor('--pc-text-secondary').rgb;
|
||||
|
||||
expect(ruleBody(SETTINGS_STYLES, ':host')).toMatch(
|
||||
/background:\s*var\(--pc-glass-bg-dense\b/
|
||||
);
|
||||
expect(
|
||||
ruleBody(
|
||||
SETTINGS_STYLES,
|
||||
'.player-settings__heading,\n.player-settings__subheading'
|
||||
)
|
||||
).toMatch(/color:\s*var\(--pc-text-secondary\b/);
|
||||
expect(SETTINGS_STYLES).not.toMatch(
|
||||
/__(sub)?heading\s*\{[^}]*--pc-text-tertiary/
|
||||
);
|
||||
for (const frame of [midGrey, white]) {
|
||||
expect(
|
||||
contrastRatio(secondary, over(glass, frame))
|
||||
).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
// The tertiary step the headings used to read failed on mid-grey.
|
||||
expect(
|
||||
contrastRatio(
|
||||
paletteColor('--pc-text-tertiary').rgb,
|
||||
over(paletteColor('--pc-glass-bg'), midGrey)
|
||||
)
|
||||
).toBeLessThan(4.5);
|
||||
});
|
||||
|
||||
it('draws keyboard focus on icon buttons in the overlay text colour', () => {
|
||||
const ring = /outline:\s*2px solid var\(--pc-text[,)]/;
|
||||
for (const source of [HOST_STYLES, SETTINGS_STYLES]) {
|
||||
expect(
|
||||
ruleBody(
|
||||
source,
|
||||
':host :is(button[mat-icon-button]:focus-visible)'
|
||||
)
|
||||
).toMatch(ring);
|
||||
// Material's focus layer takes the app theme's colour.
|
||||
expect(
|
||||
ruleBody(source, ':host :is(button[mat-icon-button])')
|
||||
).toMatch(/--mat-icon-button-focus-state-layer-opacity:\s*0;/);
|
||||
}
|
||||
});
|
||||
|
||||
it('tells a focused selected swatch from one that is only selected', () => {
|
||||
const selected = ruleBody(
|
||||
SETTINGS_STYLES,
|
||||
'.player-settings__swatch--selected'
|
||||
);
|
||||
const focused = ruleBody(
|
||||
SETTINGS_STYLES,
|
||||
'.player-settings__swatch:focus-visible'
|
||||
);
|
||||
expect(selected).not.toMatch(/outline/);
|
||||
expect(focused).toMatch(/outline:\s*2px solid var\(--pc-text[,)]/);
|
||||
});
|
||||
|
||||
it('keeps the palette reds readable on video', () => {
|
||||
const white: Rgb = [255, 255, 255];
|
||||
const glass = paletteColor('--pc-glass-bg');
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { WritableSignal, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
DEFAULT_PLAYER_CAPABILITIES,
|
||||
createEmptyControlsState,
|
||||
} from './player-controls-defaults';
|
||||
import { PlayerControlsComponent } from './player-controls.component';
|
||||
import type {
|
||||
PlayerControlsCapabilities,
|
||||
PlayerControlsCommands,
|
||||
PlayerControlsState,
|
||||
PlayerController,
|
||||
} from './player-controls.model';
|
||||
|
||||
function createFakeController() {
|
||||
const capabilities: WritableSignal<PlayerControlsCapabilities> = signal({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
audioTracks: true,
|
||||
subtitles: true,
|
||||
externalSubtitles: true,
|
||||
subtitleDelay: true,
|
||||
subtitleStyle: true,
|
||||
qualityLevels: true,
|
||||
playbackSpeed: true,
|
||||
aspectRatio: true,
|
||||
});
|
||||
const state: WritableSignal<PlayerControlsState> = signal({
|
||||
...createEmptyControlsState(),
|
||||
audioTracks: [
|
||||
{ id: 1, label: 'English', selected: true },
|
||||
{ id: 2, label: 'German', selected: false },
|
||||
],
|
||||
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
|
||||
qualityLevels: [
|
||||
{ id: 0, label: '720p', selected: false },
|
||||
{ id: 1, label: '1080p', selected: false },
|
||||
],
|
||||
});
|
||||
const commands = {
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
} as unknown as jest.Mocked<PlayerControlsCommands>;
|
||||
const controller: PlayerController = { capabilities, state, commands };
|
||||
return { controller, capabilities, state, commands };
|
||||
}
|
||||
|
||||
const KEY_CODES: Record<string, number> = {
|
||||
ArrowLeft: 37,
|
||||
ArrowUp: 38,
|
||||
ArrowRight: 39,
|
||||
ArrowDown: 40,
|
||||
Home: 36,
|
||||
End: 35,
|
||||
};
|
||||
|
||||
/** A real key press carries its legacy `keyCode`, which CDK key managers read. */
|
||||
function press(target: HTMLElement, key: string): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
Object.defineProperty(event, 'keyCode', { value: KEY_CODES[key] });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings panel as assistive technology and the keyboard meet it: a
|
||||
* dialog named by its heading, radio groups named by theirs with one Tab
|
||||
* stop each, arrow keys that move focus without applying, and dock chips
|
||||
* whose names carry their values.
|
||||
*/
|
||||
describe('PlayerSettingsPanelComponent accessibility', () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
let fixture: ComponentFixture<PlayerControlsComponent>;
|
||||
let component: PlayerControlsComponent;
|
||||
let fake: ReturnType<typeof createFakeController>;
|
||||
|
||||
const query = (selector: string) =>
|
||||
fixture.nativeElement.querySelector(selector) as HTMLElement | null;
|
||||
const radios = (group: string) =>
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
`[data-test-id="player-settings-${group}"] [role="radio"]`
|
||||
) as NodeListOf<HTMLButtonElement>
|
||||
);
|
||||
// List rows carry a check icon beside their label, segments are text
|
||||
// and colour swatches are named by `aria-label`.
|
||||
const label = (radio: Element) =>
|
||||
radio.getAttribute('aria-label') ??
|
||||
(radio.querySelector('span') ?? radio).textContent?.trim();
|
||||
const tabStops = (group: string) =>
|
||||
radios(group)
|
||||
.filter((radio) => radio.getAttribute('tabindex') === '0')
|
||||
.map(label);
|
||||
const openPanel = () => {
|
||||
component.settings.open();
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe(): void {
|
||||
/* noop */
|
||||
}
|
||||
unobserve(): void {
|
||||
/* noop */
|
||||
}
|
||||
disconnect(): void {
|
||||
/* noop */
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
localStorage.removeItem('volume');
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PlayerControlsComponent, TranslateModule.forRoot()],
|
||||
}).compileComponents();
|
||||
|
||||
const translate = TestBed.inject(TranslateService);
|
||||
translate.setTranslation('en', {
|
||||
EMBEDDED_MPV: {
|
||||
PLAYER: {
|
||||
SETTINGS: 'Settings',
|
||||
AUDIO_TRACKS: 'Audio tracks',
|
||||
SUBTITLES: 'Subtitles',
|
||||
SUBTITLES_OFF: 'Off',
|
||||
SUBTITLES_ON: 'On',
|
||||
SUBTITLES_TOOLTIP: 'Subtitles: {{subtitles}}',
|
||||
SUBTITLE_DELAY: 'Subtitle delay',
|
||||
SUBTITLE_SIZE: 'Subtitle size',
|
||||
SUBTITLE_COLOR: 'Subtitle color',
|
||||
SUBTITLE_COLOR_DEFAULT: 'Default',
|
||||
QUALITY: 'Quality',
|
||||
QUALITY_AUTO: 'Auto',
|
||||
PLAYBACK_SPEED: 'Playback speed',
|
||||
SPEED_TOOLTIP: 'Speed: {{speed}}',
|
||||
ASPECT_RATIO: 'Aspect ratio',
|
||||
ASPECT_DEFAULT: 'Default',
|
||||
},
|
||||
},
|
||||
});
|
||||
translate.use('en');
|
||||
|
||||
fake = createFakeController();
|
||||
fixture = TestBed.createComponent(PlayerControlsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('controller', fake.controller);
|
||||
fixture.detectChanges();
|
||||
document.body.appendChild(fixture.nativeElement);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.nativeElement.remove();
|
||||
fixture.destroy();
|
||||
globalThis.ResizeObserver = originalResizeObserver;
|
||||
});
|
||||
|
||||
it('names the dialog and every radio group by a real heading', () => {
|
||||
openPanel();
|
||||
const panel = query('[data-test-id="player-controls-settings-panel"]');
|
||||
const title = panel?.querySelector('h2');
|
||||
expect(panel?.getAttribute('aria-labelledby')).toBe(title?.id);
|
||||
expect(title?.textContent?.trim()).toBe('Settings');
|
||||
|
||||
const groups = Array.from(
|
||||
panel?.querySelectorAll('[role="radiogroup"]') ?? []
|
||||
).map((group) => {
|
||||
const heading = document.getElementById(
|
||||
group.getAttribute('aria-labelledby') ?? ''
|
||||
);
|
||||
return `${heading?.tagName} ${heading?.textContent?.trim()}`;
|
||||
});
|
||||
expect(groups).toEqual([
|
||||
'H3 Audio tracks',
|
||||
'H3 Subtitles',
|
||||
'H4 Subtitle size',
|
||||
'H4 Subtitle color',
|
||||
'H3 Quality',
|
||||
'H3 Playback speed',
|
||||
'H3 Aspect ratio',
|
||||
]);
|
||||
expect(
|
||||
document.getElementById(
|
||||
query(
|
||||
'[data-test-id="player-controls-subtitle-delay"] [role="group"]'
|
||||
)?.getAttribute('aria-labelledby') ?? ''
|
||||
)?.textContent
|
||||
).toContain('Subtitle delay');
|
||||
});
|
||||
|
||||
it('offers radios only, with the load-file action outside the group', () => {
|
||||
openPanel();
|
||||
const panel = query(
|
||||
'[data-test-id="player-controls-settings-panel"]'
|
||||
) as HTMLElement;
|
||||
|
||||
expect(panel.querySelectorAll('[role^="menuitem"]')).toHaveLength(0);
|
||||
const radioParents = Array.from(
|
||||
panel.querySelectorAll('[role="radio"]')
|
||||
).map((radio) => radio.parentElement?.getAttribute('role'));
|
||||
expect(radioParents.length).toBeGreaterThan(0);
|
||||
expect(new Set(radioParents)).toEqual(new Set(['radiogroup']));
|
||||
|
||||
const load = query('[data-test-id="player-controls-load-subtitle"]');
|
||||
expect(load?.hasAttribute('role')).toBe(false);
|
||||
expect(load?.closest('[role="radiogroup"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('gives each group one Tab stop on its checked option, else the first', () => {
|
||||
fake.state.update((state) => ({ ...state, playbackSpeed: 1.1 }));
|
||||
openPanel();
|
||||
|
||||
expect(tabStops('audio')).toEqual(['English']);
|
||||
// Tracks, then the subtitle size and colour groups of the section.
|
||||
expect(tabStops('subtitles')).toEqual(['Off', '100%', 'Default']);
|
||||
expect(tabStops('quality')).toEqual(['Auto']);
|
||||
// No preset matches 1.1×: the first option keeps the group reachable.
|
||||
expect(tabStops('speed')).toEqual(['0.5×']);
|
||||
expect(tabStops('aspect')).toEqual(['Default']);
|
||||
});
|
||||
|
||||
it('moves focus with the arrow keys, Home and End and checks the option reached', () => {
|
||||
openPanel();
|
||||
const checked = radios('speed').find(
|
||||
(radio) => radio.getAttribute('aria-checked') === 'true'
|
||||
) as HTMLButtonElement;
|
||||
checked.focus();
|
||||
expect(checked.textContent?.trim()).toBe('1×');
|
||||
|
||||
expect(press(checked, 'ArrowRight').defaultPrevented).toBe(true);
|
||||
fixture.detectChanges();
|
||||
expect(document.activeElement?.textContent?.trim()).toBe('1.25×');
|
||||
expect(tabStops('speed')).toEqual(['1.25×']);
|
||||
|
||||
// The ends wrap, as in a native radio group.
|
||||
const reached = [
|
||||
'ArrowDown',
|
||||
'ArrowUp',
|
||||
'ArrowLeft',
|
||||
'End',
|
||||
'ArrowRight',
|
||||
'ArrowLeft',
|
||||
'Home',
|
||||
].map((key) => {
|
||||
press(document.activeElement as HTMLElement, key);
|
||||
return document.activeElement?.textContent?.trim();
|
||||
});
|
||||
expect(reached).toEqual([
|
||||
'1.5×',
|
||||
'1.25×',
|
||||
'1×',
|
||||
'2×',
|
||||
'0.5×',
|
||||
'2×',
|
||||
'0.5×',
|
||||
]);
|
||||
// Every move applies its option, 1× included: the (fake) engine
|
||||
// still reports 1× checked, but 1.25× is the pending request.
|
||||
expect(
|
||||
fake.commands.setPlaybackSpeed.mock.calls.map(([speed]) => speed)
|
||||
).toEqual([1.25, 1.5, 1.25, 1, 2, 0.5, 2, 0.5]);
|
||||
});
|
||||
|
||||
it('keeps arrow keys inside their own group', () => {
|
||||
openPanel();
|
||||
const [english, german] = radios('audio');
|
||||
english.focus();
|
||||
press(english, 'ArrowDown');
|
||||
expect(document.activeElement).toBe(german);
|
||||
press(german, 'ArrowDown');
|
||||
expect(document.activeElement).toBe(english);
|
||||
// Returning before the engine confirms the first switch applies
|
||||
// English again; otherwise the pending switch would win.
|
||||
expect(fake.commands.setAudioTrack.mock.calls).toEqual([[2], [1]]);
|
||||
expect(fake.commands.setPlaybackSpeed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands the Tab stop back to the reported checked option when focus leaves', () => {
|
||||
// The fake engine never confirms the switch the arrow key requested,
|
||||
// so English stays the checked track.
|
||||
openPanel();
|
||||
const [english, german] = radios('audio');
|
||||
english.focus();
|
||||
press(english, 'ArrowDown');
|
||||
fixture.detectChanges();
|
||||
expect(tabStops('audio')).toEqual(['German']);
|
||||
expect(german.tabIndex).toBe(0);
|
||||
|
||||
query('[data-test-id="player-settings-close"]')?.focus();
|
||||
fixture.detectChanges();
|
||||
expect(tabStops('audio')).toEqual(['English']);
|
||||
});
|
||||
|
||||
it('reverses the horizontal arrows in a right-to-left layout', () => {
|
||||
openPanel();
|
||||
const speed = radios('speed');
|
||||
(speed[0].parentElement as HTMLElement).style.direction = 'rtl';
|
||||
speed[2].focus();
|
||||
|
||||
press(speed[2], 'ArrowLeft');
|
||||
expect(document.activeElement).toBe(speed[3]);
|
||||
press(speed[3], 'ArrowRight');
|
||||
expect(document.activeElement).toBe(speed[2]);
|
||||
});
|
||||
|
||||
it('names the chips with their current values', () => {
|
||||
const subtitleChip = () =>
|
||||
query('[data-test-id="player-controls-subtitle-chip"]');
|
||||
const speedChip = () =>
|
||||
query('[data-test-id="player-controls-speed-chip"]');
|
||||
expect(subtitleChip()?.getAttribute('aria-label')).toBe(
|
||||
'Subtitles: Off'
|
||||
);
|
||||
expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1×');
|
||||
|
||||
fake.state.update((state) => ({
|
||||
...state,
|
||||
subtitleTracks: [{ id: 5, label: 'Russian', selected: true }],
|
||||
subtitlesEnabled: true,
|
||||
playbackSpeed: 1.25,
|
||||
}));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(subtitleChip()?.getAttribute('aria-label')).toBe(
|
||||
'Subtitles: Russian'
|
||||
);
|
||||
expect(speedChip()?.getAttribute('aria-label')).toBe('Speed: 1.25×');
|
||||
});
|
||||
|
||||
it('names the subtitle chip "On" while no enabled track is marked selected', () => {
|
||||
// The engine can report subtitles on before the track list marks the
|
||||
// selected track; the chip must not claim they are off.
|
||||
fake.state.update((state) => ({
|
||||
...state,
|
||||
subtitleTracks: [{ id: 5, label: 'Russian', selected: false }],
|
||||
subtitlesEnabled: true,
|
||||
}));
|
||||
fixture.detectChanges();
|
||||
|
||||
const chip = query('[data-test-id="player-controls-subtitle-chip"]');
|
||||
expect(chip?.getAttribute('aria-label')).toBe('Subtitles: On');
|
||||
expect(
|
||||
chip?.querySelector('.player-controls__chip-label')?.textContent
|
||||
).toContain('On');
|
||||
});
|
||||
|
||||
it('announces that the tune button opens a dialog', () => {
|
||||
const tune = query('[data-test-id="player-controls-settings-button"]');
|
||||
expect(tune?.getAttribute('aria-haspopup')).toBe('dialog');
|
||||
expect(tune?.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,9 @@
|
||||
<div class="player-settings__grip" aria-hidden="true"></div>
|
||||
}
|
||||
<div class="player-settings__header">
|
||||
<div class="player-settings__title">{{ title() }}</div>
|
||||
<h2 class="player-settings__title" [id]="headingId('title')">
|
||||
{{ title() }}
|
||||
</h2>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
@@ -24,29 +26,26 @@
|
||||
data-group="audio"
|
||||
data-test-id="player-settings-audio"
|
||||
>
|
||||
<div class="player-settings__heading">
|
||||
<h3 class="player-settings__heading" [id]="headingId('audio')">
|
||||
<span
|
||||
class="player-settings__dot player-settings__dot--cyan"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }}
|
||||
</div>
|
||||
</h3>
|
||||
<div
|
||||
class="player-settings__options"
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate
|
||||
"
|
||||
appSettingsRadioGroup
|
||||
[attr.aria-labelledby]="headingId('audio')"
|
||||
>
|
||||
@for (track of state().audioTracks; track track.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
class="player-settings__option"
|
||||
[appSettingsRadio]="track.selected"
|
||||
[class.player-settings__option--selected]="
|
||||
track.selected
|
||||
"
|
||||
[attr.aria-checked]="track.selected"
|
||||
(click)="selection().audioTrack(track.id)"
|
||||
>
|
||||
<span>{{ track.label }}</span>
|
||||
@@ -66,82 +65,86 @@
|
||||
data-group="subtitles"
|
||||
data-test-id="player-settings-subtitles"
|
||||
>
|
||||
<div class="player-settings__heading">
|
||||
<h3 class="player-settings__heading" [id]="headingId('subtitles')">
|
||||
<span
|
||||
class="player-settings__dot player-settings__dot--cyan"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }}
|
||||
</div>
|
||||
<div
|
||||
class="player-settings__options"
|
||||
role="group"
|
||||
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate"
|
||||
>
|
||||
@if (
|
||||
capabilities().subtitles &&
|
||||
state().subtitleTracks.length > 0
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
class="player-settings__option player-settings__option--default"
|
||||
[class.player-settings__option--selected]="
|
||||
!state().subtitlesEnabled
|
||||
"
|
||||
[attr.aria-checked]="!state().subtitlesEnabled"
|
||||
(click)="selection().subtitleTrack(-1)"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
|
||||
}}</span>
|
||||
@if (!state().subtitlesEnabled) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@for (track of state().subtitleTracks; track track.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
class="player-settings__option"
|
||||
[class.player-settings__option--selected]="
|
||||
track.selected
|
||||
"
|
||||
[attr.aria-checked]="track.selected"
|
||||
(click)="selection().subtitleTrack(track.id)"
|
||||
>
|
||||
<span>{{ track.label }}</span>
|
||||
@if (track.selected) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (capabilities().externalSubtitles) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="player-settings__option player-settings__option--action"
|
||||
data-test-id="player-controls-load-subtitle"
|
||||
(click)="loadExternalSubtitle()"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
|
||||
}}</span>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</h3>
|
||||
@if (state().subtitleTracks.length > 0) {
|
||||
<div
|
||||
class="player-settings__options"
|
||||
appSettingsRadioGroup
|
||||
[attr.aria-labelledby]="headingId('subtitles')"
|
||||
>
|
||||
@if (capabilities().subtitles) {
|
||||
<button
|
||||
type="button"
|
||||
class="player-settings__option player-settings__option--default"
|
||||
[appSettingsRadio]="!state().subtitlesEnabled"
|
||||
[class.player-settings__option--selected]="
|
||||
!state().subtitlesEnabled
|
||||
"
|
||||
(click)="selection().subtitleTrack(-1)"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF' | translate
|
||||
}}</span>
|
||||
@if (!state().subtitlesEnabled) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@for (track of state().subtitleTracks; track track.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="player-settings__option"
|
||||
[appSettingsRadio]="track.selected"
|
||||
[class.player-settings__option--selected]="
|
||||
track.selected
|
||||
"
|
||||
(click)="selection().subtitleTrack(track.id)"
|
||||
>
|
||||
<span>{{ track.label }}</span>
|
||||
@if (track.selected) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (capabilities().externalSubtitles) {
|
||||
<!-- An action, not a choice: kept out of the radio group. -->
|
||||
<button
|
||||
type="button"
|
||||
class="player-settings__option player-settings__option--action"
|
||||
data-test-id="player-controls-load-subtitle"
|
||||
(click)="loadExternalSubtitle()"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE' | translate
|
||||
}}</span>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (capabilities().subtitleDelay) {
|
||||
<div
|
||||
class="player-settings__subsection"
|
||||
data-test-id="player-controls-subtitle-delay"
|
||||
>
|
||||
<div class="player-settings__subheading">
|
||||
<h4
|
||||
class="player-settings__subheading"
|
||||
[id]="headingId('subtitle-delay')"
|
||||
>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }}
|
||||
</div>
|
||||
<div class="player-settings__delay-row">
|
||||
</h4>
|
||||
<div
|
||||
class="player-settings__delay-row"
|
||||
role="group"
|
||||
[attr.aria-labelledby]="headingId('subtitle-delay')"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
@@ -212,15 +215,16 @@
|
||||
class="player-settings__subsection"
|
||||
data-test-id="player-controls-subtitle-style"
|
||||
>
|
||||
<div class="player-settings__subheading">
|
||||
<h4
|
||||
class="player-settings__subheading"
|
||||
[id]="headingId('subtitle-size')"
|
||||
>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }}
|
||||
</div>
|
||||
</h4>
|
||||
<div
|
||||
class="player-settings__seg"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate
|
||||
"
|
||||
appSettingsRadioGroup
|
||||
[attr.aria-labelledby]="headingId('subtitle-size')"
|
||||
>
|
||||
@for (
|
||||
preset of subtitleSizePresets;
|
||||
@@ -228,13 +232,12 @@
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="player-settings__seg-item"
|
||||
[class.player-settings__seg-item--selected]="
|
||||
[appSettingsRadio]="
|
||||
preset.value ===
|
||||
state().subtitleStyle.sizePercent
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
[class.player-settings__seg-item--selected]="
|
||||
preset.value ===
|
||||
state().subtitleStyle.sizePercent
|
||||
"
|
||||
@@ -246,15 +249,16 @@
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="player-settings__subheading">
|
||||
<h4
|
||||
class="player-settings__subheading"
|
||||
[id]="headingId('subtitle-color')"
|
||||
>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }}
|
||||
</div>
|
||||
</h4>
|
||||
<div
|
||||
class="player-settings__swatches"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate
|
||||
"
|
||||
appSettingsRadioGroup
|
||||
[attr.aria-labelledby]="headingId('subtitle-color')"
|
||||
>
|
||||
@for (
|
||||
preset of subtitleColorPresets;
|
||||
@@ -262,8 +266,10 @@
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="player-settings__swatch"
|
||||
[appSettingsRadio]="
|
||||
preset.value === state().subtitleStyle.color
|
||||
"
|
||||
[class.player-settings__swatch--default]="
|
||||
preset.value === null
|
||||
"
|
||||
@@ -271,9 +277,6 @@
|
||||
preset.value === state().subtitleStyle.color
|
||||
"
|
||||
[style.--swatch-color]="preset.value"
|
||||
[attr.aria-checked]="
|
||||
preset.value === state().subtitleStyle.color
|
||||
"
|
||||
[attr.aria-label]="preset.label | translate"
|
||||
[matTooltip]="preset.label | translate"
|
||||
(click)="
|
||||
@@ -294,26 +297,25 @@
|
||||
data-group="quality"
|
||||
data-test-id="player-settings-quality"
|
||||
>
|
||||
<div class="player-settings__heading">
|
||||
<h3 class="player-settings__heading" [id]="headingId('quality')">
|
||||
<span
|
||||
class="player-settings__dot player-settings__dot--violet"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
|
||||
</div>
|
||||
</h3>
|
||||
<div
|
||||
class="player-settings__options"
|
||||
role="group"
|
||||
[attr.aria-label]="'EMBEDDED_MPV.PLAYER.QUALITY' | translate"
|
||||
appSettingsRadioGroup
|
||||
[attr.aria-labelledby]="headingId('quality')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
class="player-settings__option player-settings__option--default"
|
||||
[appSettingsRadio]="state().qualityAutoEnabled"
|
||||
[class.player-settings__option--selected]="
|
||||
state().qualityAutoEnabled
|
||||
"
|
||||
[attr.aria-checked]="state().qualityAutoEnabled"
|
||||
(click)="selection().qualityLevel(-1)"
|
||||
>
|
||||
<span>{{
|
||||
@@ -326,12 +328,11 @@
|
||||
@for (level of state().qualityLevels; track level.id) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
class="player-settings__option"
|
||||
[appSettingsRadio]="level.selected"
|
||||
[class.player-settings__option--selected]="
|
||||
level.selected
|
||||
"
|
||||
[attr.aria-checked]="level.selected"
|
||||
(click)="selection().qualityLevel(level.id)"
|
||||
>
|
||||
<span>{{ level.label }}</span>
|
||||
@@ -351,34 +352,31 @@
|
||||
data-group="speed"
|
||||
data-test-id="player-settings-speed"
|
||||
>
|
||||
<div class="player-settings__heading">
|
||||
<h3 class="player-settings__heading" [id]="headingId('speed')">
|
||||
<span
|
||||
class="player-settings__dot player-settings__dot--violet"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }}
|
||||
</div>
|
||||
</h3>
|
||||
<div
|
||||
class="player-settings__seg"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate
|
||||
"
|
||||
appSettingsRadioGroup
|
||||
[attr.aria-labelledby]="headingId('speed')"
|
||||
>
|
||||
@for (preset of state().speedPresets; track preset.value) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="player-settings__seg-item"
|
||||
[appSettingsRadio]="
|
||||
preset.value === state().playbackSpeed
|
||||
"
|
||||
[class.player-settings__seg-item--default]="
|
||||
preset.value === 1
|
||||
"
|
||||
[class.player-settings__seg-item--selected]="
|
||||
preset.value === state().playbackSpeed
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
preset.value === state().playbackSpeed
|
||||
"
|
||||
(click)="selection().speed(preset.value)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
@@ -395,34 +393,31 @@
|
||||
data-group="aspect"
|
||||
data-test-id="player-settings-aspect"
|
||||
>
|
||||
<div class="player-settings__heading">
|
||||
<h3 class="player-settings__heading" [id]="headingId('aspect')">
|
||||
<span
|
||||
class="player-settings__dot player-settings__dot--neutral"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }}
|
||||
</div>
|
||||
</h3>
|
||||
<div
|
||||
class="player-settings__seg"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate
|
||||
"
|
||||
appSettingsRadioGroup
|
||||
[attr.aria-labelledby]="headingId('aspect')"
|
||||
>
|
||||
@for (preset of state().aspectPresets; track preset.value) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="player-settings__seg-item"
|
||||
[appSettingsRadio]="
|
||||
preset.value === state().aspectRatio
|
||||
"
|
||||
[class.player-settings__seg-item--default]="
|
||||
preset.value === defaultAspect()
|
||||
"
|
||||
[class.player-settings__seg-item--selected]="
|
||||
preset.value === state().aspectRatio
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
preset.value === state().aspectRatio
|
||||
"
|
||||
(click)="selection().aspect(preset.value)"
|
||||
>
|
||||
{{
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
box-sizing: border-box;
|
||||
padding: 18px 16px;
|
||||
color: var(--pc-text, #e7ecf3);
|
||||
background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72));
|
||||
background: var(--pc-glass-bg-dense, rgba(12, 16, 23, 0.86));
|
||||
border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09));
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
@@ -69,7 +69,10 @@
|
||||
}
|
||||
|
||||
.player-settings__title {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
@@ -90,6 +93,9 @@
|
||||
--mat-icon-button-container-shape: 10px;
|
||||
--mat-icon-button-icon-size: 20px;
|
||||
--mat-icon-button-hover-state-layer-opacity: 0;
|
||||
// The ring below is the focus indicator; Material's focus layer would
|
||||
// take its colour from the app theme.
|
||||
--mat-icon-button-focus-state-layer-opacity: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@@ -104,6 +110,13 @@
|
||||
background: var(--pc-hover, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
// Inset like the option rows: the body is the scroll owner and would clip
|
||||
// an outer ring on its edge.
|
||||
:host :is(button[mat-icon-button]:focus-visible) {
|
||||
outline: 2px solid var(--pc-text, #e7ecf3);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
:host :is(button[mat-icon-button][disabled]),
|
||||
:host :is(button[mat-icon-button][disabled]) mat-icon {
|
||||
color: var(--pc-text-tertiary, #6b7384);
|
||||
@@ -147,17 +160,33 @@
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
// Small caps text on glass over video: the secondary ramp step keeps it at
|
||||
// 4.5:1 or more over a mid-grey frame (the tertiary step measured 4.1:1 over
|
||||
// black). One long word ("Wiedergabegeschwindigkeit") breaks instead of
|
||||
// running out of the narrow sheet column; `hyphens` needs `<html lang>`.
|
||||
.player-settings__heading,
|
||||
.player-settings__subheading {
|
||||
margin: 0;
|
||||
color: var(--pc-text-secondary, #9aa3b2);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
.player-settings__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px 4px;
|
||||
color: var(--pc-text-tertiary, #6b7384);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-settings__heading .player-settings__dot {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.player-settings__dot {
|
||||
@@ -300,12 +329,6 @@
|
||||
|
||||
.player-settings__subheading {
|
||||
padding: 0 4px;
|
||||
color: var(--pc-text-tertiary, #6b7384);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.player-settings__delay-row {
|
||||
@@ -326,7 +349,9 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 4px 4px;
|
||||
// Room on every side for the focus ring (2px offset + 2px): the body
|
||||
// scrolls and would clip it at its edge.
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.player-settings__swatch {
|
||||
@@ -352,10 +377,22 @@
|
||||
rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.player-settings__swatch:hover,
|
||||
.player-settings__swatch:focus-visible,
|
||||
.player-settings__swatch:hover {
|
||||
border-color: #ffffff;
|
||||
}
|
||||
|
||||
// Selected: a white ring with a dark gap inside it, which reads on every
|
||||
// swatch colour, the white one included.
|
||||
.player-settings__swatch--selected {
|
||||
border-color: #ffffff;
|
||||
box-shadow: inset 0 0 0 2px rgba(12, 16, 23, 0.9);
|
||||
}
|
||||
|
||||
// Keyboard focus adds a ring outside the border, so a focused selected
|
||||
// swatch looks different from one that is only selected.
|
||||
.player-settings__swatch:focus-visible {
|
||||
outline: 2px solid var(--pc-text, #e7ecf3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
// Sheet density: two-column rows (heading | control) so five groups fit a
|
||||
@@ -367,15 +404,15 @@
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
// The heading column is narrow: let "Playback speed" wrap onto two lines
|
||||
// instead of running under the control.
|
||||
// The heading column is narrow: "Playback speed" wraps onto two lines, and a
|
||||
// single long word breaks (see the heading rule), instead of running under
|
||||
// the control.
|
||||
:host(.player-settings--sheet) .player-settings__heading {
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
padding: 6px 0 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.25;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
:host(.player-settings--sheet) .player-settings__heading .player-settings__dot {
|
||||
@@ -410,6 +447,13 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
// The load-file action sits outside the radio group: keep it in the control
|
||||
// column, on the same pill background.
|
||||
:host(.player-settings--sheet) .player-settings__option--action {
|
||||
grid-column: 2;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:host(.player-settings--sheet) .player-settings__seg-item {
|
||||
height: 24px;
|
||||
font-size: 11.5px;
|
||||
|
||||
@@ -19,6 +19,10 @@ import type { ControlsSettings } from './controls-settings';
|
||||
import type { SettingsGroup } from './controls-settings-groups';
|
||||
import type { ControlsSubtitleSettings } from './controls-subtitle-settings';
|
||||
import type { PlayerController } from './player-controls.model';
|
||||
import {
|
||||
SettingsRadioDirective,
|
||||
SettingsRadioGroupDirective,
|
||||
} from './settings-radio-group.directive';
|
||||
import {
|
||||
SUBTITLE_COLOR_PRESETS,
|
||||
SUBTITLE_DELAY_STEP_SECONDS,
|
||||
@@ -28,6 +32,8 @@ import {
|
||||
|
||||
export type PlayerSettingsPanelMode = 'panel' | 'sheet';
|
||||
|
||||
let nextPanelId = 0;
|
||||
|
||||
/**
|
||||
* The settings surface behind the dock's `tune` button: audio, subtitles
|
||||
* (tracks, file loading, delay, size and color), quality, speed and aspect
|
||||
@@ -39,14 +45,21 @@ export type PlayerSettingsPanelMode = 'panel' | 'sheet';
|
||||
selector: 'app-player-settings-panel',
|
||||
templateUrl: './player-settings-panel.component.html',
|
||||
styleUrl: './player-settings-panel.component.scss',
|
||||
imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe],
|
||||
imports: [
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
SettingsRadioDirective,
|
||||
SettingsRadioGroupDirective,
|
||||
TranslatePipe,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'player-settings',
|
||||
role: 'dialog',
|
||||
tabindex: '-1',
|
||||
'[class.player-settings--sheet]': 'mode() === "sheet"',
|
||||
'[attr.aria-label]': 'title()',
|
||||
'[attr.aria-labelledby]': 'headingId("title")',
|
||||
'(focusin)': 'focusInside = true',
|
||||
'(focusout)': 'onFocusOut($event)',
|
||||
},
|
||||
@@ -54,6 +67,8 @@ export type PlayerSettingsPanelMode = 'panel' | 'sheet';
|
||||
export class PlayerSettingsPanelComponent implements OnDestroy {
|
||||
private readonly host: HTMLElement =
|
||||
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
||||
/** Several players can be mounted at once: heading ids stay unique. */
|
||||
private readonly idPrefix = `player-settings-${nextPanelId++}`;
|
||||
readonly controller = input.required<PlayerController>();
|
||||
readonly settings = input.required<ControlsSettings>();
|
||||
readonly selection = input.required<ControlsMenuSelection>();
|
||||
@@ -133,6 +148,11 @@ export class PlayerSettingsPanelComponent implements OnDestroy {
|
||||
return this.focusGroup() === group;
|
||||
}
|
||||
|
||||
/** The id of a heading that names the dialog or one of its groups. */
|
||||
headingId(name: string): string {
|
||||
return `${this.idPrefix}-${name}`;
|
||||
}
|
||||
|
||||
loadExternalSubtitle(): void {
|
||||
if (!this.capabilities().externalSubtitles) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { FocusKeyManager, type FocusableOption } from '@angular/cdk/a11y';
|
||||
import {
|
||||
DestroyRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
Injector,
|
||||
computed,
|
||||
contentChildren,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
/** One option of a {@link SettingsRadioGroupDirective}: a native button. */
|
||||
@Directive({
|
||||
selector: 'button[appSettingsRadio]',
|
||||
host: {
|
||||
role: 'radio',
|
||||
'[attr.aria-checked]': 'checked()',
|
||||
'[attr.tabindex]': 'isTabStop() ? 0 : -1',
|
||||
'(focus)': 'onFocus()',
|
||||
},
|
||||
})
|
||||
export class SettingsRadioDirective implements FocusableOption {
|
||||
// Not required: a sibling's host binding reads every option's state
|
||||
// before the later options of the same pass have their inputs.
|
||||
readonly checked = input(false, { alias: 'appSettingsRadio' });
|
||||
private readonly group = inject(SettingsRadioGroupDirective);
|
||||
private readonly element: HTMLButtonElement =
|
||||
inject<ElementRef<HTMLButtonElement>>(ElementRef).nativeElement;
|
||||
readonly isTabStop = computed(() => this.group.tabStop() === this);
|
||||
|
||||
get disabled(): boolean {
|
||||
return this.element.disabled;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.element.focus();
|
||||
}
|
||||
|
||||
onFocus(): void {
|
||||
this.group.onOptionFocus(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the option through its own click handler, as a press would.
|
||||
* Unconditionally: the engine may still report this option checked
|
||||
* while a switch away from it is pending, and returning to it must
|
||||
* cancel that switch.
|
||||
*/
|
||||
check(): void {
|
||||
this.element.click();
|
||||
}
|
||||
|
||||
/** Immediate write of what the `tabindex` binding renders next. */
|
||||
setTabStop(isTabStop: boolean): void {
|
||||
this.element.tabIndex = isTabStop ? 0 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A radio group in the settings panel (audio, subtitles, quality, speed,
|
||||
* aspect, subtitle size and colour). One option is a Tab stop — the checked
|
||||
* one, or the first when none is — and the arrow keys, Home and End move
|
||||
* focus between the options with a CDK `FocusKeyManager` and check the
|
||||
* option they reach, as a native radio group does. Checking clicks the
|
||||
* option, so the template's handler applies the choice to the player.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appSettingsRadioGroup]',
|
||||
host: {
|
||||
role: 'radiogroup',
|
||||
'(keydown)': 'onKeydown($event)',
|
||||
'(focusout)': 'onFocusOut($event)',
|
||||
},
|
||||
})
|
||||
export class SettingsRadioGroupDirective {
|
||||
private readonly host: HTMLElement =
|
||||
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
||||
private readonly options = contentChildren(SettingsRadioDirective, {
|
||||
descendants: true,
|
||||
});
|
||||
/** The option keyboard focus moved to, while it stays in the group. */
|
||||
private readonly focused = signal<SettingsRadioDirective | null>(null);
|
||||
private readonly keyManager = new FocusKeyManager(
|
||||
this.options,
|
||||
inject(Injector)
|
||||
)
|
||||
.withWrap()
|
||||
.withVerticalOrientation()
|
||||
.withHomeAndEnd();
|
||||
|
||||
readonly tabStop = computed(() => {
|
||||
const options = this.options();
|
||||
const focused = this.focused();
|
||||
if (focused && options.includes(focused)) {
|
||||
return focused;
|
||||
}
|
||||
return options.find((option) => option.checked()) ?? options[0];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => this.keyManager.destroy());
|
||||
}
|
||||
|
||||
onOptionFocus(option: SettingsRadioDirective): void {
|
||||
this.focused.set(option);
|
||||
this.keyManager.updateActiveItem(option);
|
||||
this.applyTabStop();
|
||||
}
|
||||
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
const rtl = getComputedStyle(this.host).direction === 'rtl';
|
||||
this.keyManager.withHorizontalOrientation(rtl ? 'rtl' : 'ltr');
|
||||
const previous = this.keyManager.activeItem;
|
||||
this.keyManager.onKeydown(event);
|
||||
const next = this.keyManager.activeItem;
|
||||
if (next && next !== previous) {
|
||||
next.check();
|
||||
}
|
||||
}
|
||||
|
||||
/** Leaving the group hands the Tab stop back to the checked option. */
|
||||
onFocusOut(event: FocusEvent): void {
|
||||
const next = event.relatedTarget;
|
||||
if (!(next instanceof Node) || !this.host.contains(next)) {
|
||||
this.focused.set(null);
|
||||
this.applyTabStop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `tabindex` bindings follow on the next change detection, but a
|
||||
* quick Shift+Tab, Tab can arrive before it and would re-enter the group
|
||||
* on a stale stop, so focus changes also write it straight away.
|
||||
*/
|
||||
private applyTabStop(): void {
|
||||
const stop = this.tabStop();
|
||||
for (const option of this.options()) {
|
||||
option.setTabStop(option === stop);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,6 +203,9 @@
|
||||
|
||||
.player-shell__title-row {
|
||||
flex-direction: column;
|
||||
// Centered, the nowrap heading took its full text width and spilled
|
||||
// past both card edges; stretched, the title ellipsizes in the card.
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.player-shell__actions {
|
||||
|
||||
@@ -68,18 +68,21 @@
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["production", "^production"]
|
||||
},
|
||||
"e2e-ci--src/*.e2e.ts": {
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": ["electron-backend"],
|
||||
"target": "build-e2e"
|
||||
},
|
||||
{
|
||||
"projects": ["stalker-mock-server", "xtream-mock-server"],
|
||||
"target": "serve"
|
||||
}
|
||||
]
|
||||
}
|
||||
"e2e-ci--src/*.e2e.ts": [
|
||||
{
|
||||
"filter": { "projects": ["electron-backend-e2e"] },
|
||||
"dependsOn": [
|
||||
{
|
||||
"projects": ["electron-backend"],
|
||||
"target": "build-e2e"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"filter": { "projects": ["web-e2e"] },
|
||||
"dependsOn": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
"angular-eslint": "22.5.0",
|
||||
"astro": "7.2.10",
|
||||
"autoprefixer": "10.5.4",
|
||||
"axe-core": "4.13.0",
|
||||
"cookie": "2.0.1",
|
||||
"cors": "2.8.6",
|
||||
"drizzle-kit": "0.31.10",
|
||||
|
||||
Generated
+9
@@ -371,6 +371,9 @@ importers:
|
||||
autoprefixer:
|
||||
specifier: 10.5.4
|
||||
version: 10.5.4(postcss@8.5.26)
|
||||
axe-core:
|
||||
specifier: 4.13.0
|
||||
version: 4.13.0
|
||||
cookie:
|
||||
specifier: 2.0.1
|
||||
version: 2.0.1
|
||||
@@ -4901,6 +4904,10 @@ packages:
|
||||
aws4@1.13.2:
|
||||
resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
|
||||
|
||||
axe-core@4.13.0:
|
||||
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
axios@1.18.1:
|
||||
resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
|
||||
|
||||
@@ -14512,6 +14519,8 @@ snapshots:
|
||||
|
||||
aws4@1.13.2: {}
|
||||
|
||||
axe-core@4.13.0: {}
|
||||
|
||||
axios@1.18.1(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0):
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0(debug@4.4.3(supports-color@7.2.0))
|
||||
|
||||
Reference in new issue
Block a user