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:
4gray committed 2026-10-01 08:40:19 +02:00
commit d9446db053
72 files changed
+3976 -604

No files matched your search

+10 -1
View File
@@ -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
+142 -7
View File
@@ -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
+40 -9
View File
@@ -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
+5
View File
@@ -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
+5 -1
View File
@@ -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