Merge branch 'master' into perf/j3-epg-ribbon-window

This commit is contained in:
4gray authored and GitHub committed 2026-10-05 23:11:34 +02:00
commit 7d9f1df106
171 files changed
+10425 -4483

No files matched your search

+60 -5
View File
@@ -383,15 +383,17 @@ meanwhile. The host reports busy-state back through the
A series-level counterpart lives in a `⋮` menu at the end of the same
header row (`SeasonWatchPresenter` in `libs/ui/components` owns the state
math for both scopes; the container component sits at the max-lines cap).
math for both scopes, which keeps the container component under the
max-lines cap).
`buildSeriesWatchToggleRequest` flattens every LOADED season with the same
mark/unmark semantics, and the direction is always the one the label
advertised (`markWatched: !seriesFullyWatched()`), never re-inferred from
data at persist time. Hosts route the request through the same machinery
as the season toggle — Xtream via the scope-parameterized
`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via the
extracted `runWatchToggleBatch` core — sharing the busy flag, the
ownership guards, and the catalog-badge refresh. Stalker lazy-VOD is the
`SerialDetailsSeasonWatchService.handle(..., scope)`, Stalker via
`StalkerSeriesWatchToggleService` and its `runStalkerWatchToggleBatch`
core — sharing the busy flag, the ownership guards, and the catalog-badge
refresh. Stalker lazy-VOD is the
special case: unopened seasons have empty episode lists, so the container
reports them through the `hasUnloadedSeasons` input (blocks the
"fully watched" verdict and switches the label to its countless variant),
@@ -412,7 +414,8 @@ series-toggle hydration join one in-flight request instead of
duplicating it (a second request's failure could abort a toggle whose
original request succeeded).
The host synchronously re-runs the position reconcile
(`applyReconciledSeriesPositions` — the effect-fed maps only update on
(`StalkerSeriesPositionsService.applyReconciledSeriesPositions` — the
effect-fed maps only update on
the next change-detection tick, and enqueuing against stale maps would
miss the hydrated episodes' legacy rows), rebuilds the request from the
now-complete seasons keeping the captured direction, and reports an
@@ -468,6 +471,58 @@ position telemetry overwrites this launch marker when available. This keeps the
last-watched season and episode correct even when an external player's progress
interface is unavailable; exact external timestamps remain best-effort.
## Forced External Launches From Detail Pages
The detail "…" menu's "Open in external player" sends the title to MPV/VLC
through `PortalPlayer.openExternalPlayback(playback, player)` whatever the
configured player is. The launch IPC cannot be cancelled, and until it
resolves the session is at most `launching` and may not have a closer yet.
Every detail host therefore keeps these rules:
- **One external player per owner.** Before launching, the host closes the
external session the page owns: the session of the same title on the
Stalker pages and the Xtream series page; the session it launched, else the
one matching its movie, on the Xtream movie page. Sessions the page does not
own are left alone. With instance reuse off, a second detached player would
otherwise start beside the first. Stalker hosts use
`replaceOwnedExternalSession` from
`@iptvnator/portal/shared/util`; the Xtream pages use
`closeRunningExternalSession` with the same outcome rules.
- **Unconfirmed teardown cancels the launch.** A live session without a
closer, or a close that rejects, leaves the running player in place and
nothing new launches.
- **Ownership is rechecked after every await.** Stream resolution, the close
and the launch IPC can each outlive the page or be superseded by a newer
start. A stale step stops without reporting, and a launch that resolves
stale closes the session it just opened.
- **No second player while a launch settles.** A repeat of the same launch is
ignored, or its control stays disabled. Movie pages refuse or disable every
other start of that title until the launch settles. Series pages hold the
latest episode choice and, once the launch settled, replace the player it
opened, only while that series is still on screen.
- **Pending starts are owner-scoped.** A start still resolving holds the
actions of its own title only: another title shown by the reused page is
not blocked by it. Movie hosts track starts with
`createPendingPlaybackStart` (`@iptvnator/portal/shared/util`): only the
latest start may clear the flag, and `isPendingFor(owner)` answers for one
owner. The Stalker movie hosts, whose starts wait on a portal round trip,
also `retire(owner)` when the selection leaves it, so a start that never
settles does not keep the flag set on a return to the same title. A movie's
"Reset progress" is scoped the same way.
- **Two gates are page-wide.** The Xtream movie page refuses Play, Start
over, source switches and the menu launch while an external launch it made
has not settled. A series page runs one watched or reset batch at a time,
whichever series is shown; the Stalker page also holds episode starts until
that batch settles.
Owner keys and queueing are provider contracts:
| Host | Contract |
| --- | --- |
| Xtream series | [Forced external launches from detail pages](./xtream-portal-compatibility.md#forced-external-launches-from-detail-pages) |
| Xtream movie | [Menu launch and reset follow the primary button](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button) |
| Stalker series and movies | [Forced External Launches](./stalker-portal.md#forced-external-launches) |
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
+34 -1
View File
@@ -185,6 +185,39 @@ support API from global workspace startup paths; use an explicit user action
or idle preparation path when a renderer surface only needs to reveal optional
Embedded MPV UI.
An unsupported answer can be `inconclusive`. The Linux native-view `mpv`
executable check runs `mpv --version` by bare name, so the support and prepare
handlers wait for the login shell PATH lookup (`startup/login-shell-path.ts`)
first. When that lookup runs out of its budget, the check runs on the
inherited PATH: `EmbeddedMpvNativeService` then reports a missing `mpv` as
`supported: false` with `inconclusive: true`, keeps doing so while the cached
result stands, and probes again once the shell answers. Every other answer,
including a missing `mpv` after the shell answered, is final. An inconclusive
answer is not a verdict on the machine: never persist a decision made from it.
Whatever holds on to one answer follows it through `watchEmbeddedMpvSupport()`
(`@iptvnator/shared/interfaces`), which asks again after
`EMBEDDED_MPV_SUPPORT_RECHECK_MS`, backing off to
`EMBEDDED_MPV_SUPPORT_RECHECK_MAX_MS`, until the answer is final:
- The settings store keeps a saved Embedded MPV selection while the answer is
inconclusive, falls back to the default player only on a final unsupported
answer, and never overwrites a player the user picked meanwhile.
- The player (`EmbeddedMpvSessionController`) and the settings page stay
mounted on one answer: a player mounted in that window starts playback by
itself once `mpv` is found, and the option appears without reopening the
page.
- The settings page also follows the answer for its search
(`SettingsSearchService.followEmbeddedMpvSupport()`) and ends that when it
closes: the Embedded MPV rows become searchable while the page stays open,
and nothing keeps asking once no surface shows them.
The command palette asks on demand, on every open, for its player commands and
its settings rows (`ensureEmbeddedMpvSupportLoaded()`). Only a final answer is
kept for the session; after an inconclusive one, or a failed request, the next
open asks again. An open palette is a snapshot of that moment: it does not wait
for a final answer, because a login shell that never answers would then keep
it from opening.
When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
For the native-view engine, the MPV video surface is a platform view/window,
@@ -967,7 +1000,7 @@ Defensive practice for this component:
Concrete bugs from the audit, recorded so they don't get reintroduced:
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `loadSupport()` already populates it including capabilities.
- **Infinite session-create loop.** `EmbeddedMpvSessionController.startSession` once wrote `this.support.set(prepared)` after the `prepareEmbeddedMpv` round-trip. The component's session-creation effect tracks `this.support()`, so the write fired the effect → cleanup disposed the session → new session was created → prepare ran again → support was set again. Symptom: endless "Loading stream…" spinner. Fix: do not write `support` inside `startSession`; the constructor's `watchSupport()` already populates it including capabilities.
- **Stream restart on volume change.** The session-creation effect once read `this.volume()` directly to pass to `startSession`'s `initialVolume`. Each volume tick re-ran the effect, disposing and recreating the session — for VOD/series this restarted playback from the beginning. Fix: read it via `untracked(() => this.volume())`. Subsequent volume changes flow through `controller.applyVolume()`, never through the effect graph.
- **Spurious `timeUpdate` re-emits and `volume.set` calls.** The session-fan-out effect calls `scheduleControlsHide()`, which reads `isPlaying`, `menus.anyOpen`, `statusLabel`, and `controlsVisible`. Those reads became tracked deps, so opening any popover, pausing, or hovering re-ran the body. No loop in isolation, but a parent that wires `timeUpdate` back into `playback.startTime` would have hit the volume-restart bug class. Fix: wrap the side-effect block in `untracked()` so the effect listens only to session changes.
- **2 Hz no-op stalled-tracker re-runs.** Position polling updates `session` around 2 Hz. Tracking the full session would re-run stalled logic for snapshots with unchanged status, so the controller tracks only `sessionStatus` and invokes `EmbeddedMpvStalledTracker.track` inside `untracked()`, avoiding full-session reruns.
+20 -3
View File
@@ -189,6 +189,18 @@ scrim after three idle seconds, with a 32px mute toggle in the corner. It
never starts under `prefers-reduced-motion` or with `saveData`, and stops
while the hero is off screen or the window is unfocused.
## Back Navigation
Page-level Back lives only in the workspace header's leading slot (see
[Header Back](./workspace-shell.md#header-back)). A routed page, or the shell
it renders in, registers it with `registerWorkspaceBack()` instead of drawing
an arrow, so Back keeps one position and one look on every page and never
floats over a scroll owner. Without a registration the header falls back to
browser history while an in-app previous page exists, and shows nothing
otherwise. An arrow that returns within a menu, dialog or player panel is not
page navigation and stays in that surface; an error state may repeat the
header's Back as a labelled recovery button beside its other actions.
## Electron Drag Regions
Every interactive descendant of a drag region—including buttons, links,
@@ -902,7 +914,10 @@ Prefer removing a control over shrinking everything around it:
- Counts and subtitles that a neighbouring control already states.
Never drop the only way back to a hidden surface. A collapse toggle that is
reachable by touch needs its restore affordance to be reachable too.
reachable by touch needs its restore affordance to be reachable too. For the
same reason the header's history Back yields to the context drawer toggle,
and Settings keeps the toggle beside its Back; only a detail page's Back,
whose list shows the toggle again, takes the toggle's slot.
## Typography
@@ -920,8 +935,10 @@ heavier faces would exceed the initial-bytes ratchet).
- JetBrains Mono text stays at 500 or lighter, also where it is a fallback
behind `ui-monospace` (only macOS resolves that). The check enforces this in
any rule that sets the family, directly or through a variable, or inherits
it from an enclosing rule. It cannot see what a mono modifier class inherits
from its base rule; set `font-weight: 500` there.
it from an enclosing rule. A mixin's family and weights count where it is
included, from its own stylesheet module or another one. It cannot see what
a mono modifier class inherits from its base rule; set `font-weight: 500`
there.
- Import whole `@fontsource/<family>/<weight>.css` files. The single-script
files such as `cyrillic-600.css` have no `unicode-range`, so a Cyrillic-only
face wins the weight match for Latin text in `Roboto, …` stacks and sends it
+2 -1
View File
@@ -208,7 +208,8 @@ window, and `evidence.idle.domMutations` the mutation records in the whole
document. The [idle work audit](idle-work-audit-2026-09.md) found Eager
components re-rendering on every such tick in a dev build; this counter
measures the ticks in the optimized build, so plan item C6 can show what
zoneless change detection removes.
zoneless change detection removes; its checklist is the
[zoneless migration](zoneless-migration.md).
The window opens when the settle window closes, so startup data still
landing is not idle work, and it is timed by a renderer `setTimeout`. The
+27 -20
View File
@@ -23,27 +23,29 @@ do not reach global player shortcuts. Descendant controls retain their native
keys and Tab order. Entering watch still scrolls to the top; Back and saved
catalog scroll positions retain the existing navigation contract below.
The shell owns the page's Back action, but the workspace header renders it.
While `backAvailable()` is true, the shell registers a target with
`WorkspaceBackNavigationService` (`@iptvnator/portal/shared/data-access`). The
target, the `WorkspaceBackTarget` contract in `@iptvnator/portal/shared/util`,
carries a label (the host's `backLabel`, else the translated "Back"), whether
Escape currently runs it, and `run()`, which emits `backClicked`. The service
keeps a stack in which the newest registration wins, and each release removes
only its own target. A loading shell replaced by the loaded one therefore
cannot clear its successor, whichever is destroyed first. The header shows the target as an `arrow_back`
icon button in its leading slot (`data-test-id="workspace-header-back"`), to
the right of the macOS traffic lights. That is where desktop apps and Material's
top app bar keep navigation. The header never scrolls, so the control stays
visible over long episode lists, and nothing floats over the scroll owner:
detail columns keep symmetric insets and their full width. The button has
Electron `no-drag` hit testing. In browse its tooltip and `aria-keyshortcuts`
The shell owns the page's Back action, but the workspace header renders it
(the general contract, other pages and the history fallback are in
[Header Back](./workspace-shell.md#header-back)). While `backAvailable()` is
true, the shell registers a target through `registerWorkspaceBack()`
(`@iptvnator/portal/shared/data-access`). It carries the host's `backLabel`
(else the translated "Back"), whether Escape currently runs it, and `run()`,
which emits `backClicked`. The newest registration wins and each release
removes only its own target, so a loading shell replaced by the loaded one
cannot clear its successor, whichever is destroyed first. The header shows the
target as an `arrow_back` icon button in its leading slot
(`data-test-id="workspace-header-back"`), to the right of the macOS traffic
lights. That is where desktop apps and Material's top app bar keep navigation.
The header never scrolls, so the control stays visible over long episode
lists, and nothing floats over the scroll owner: detail columns keep symmetric
insets and their full width. In browse its tooltip and `aria-keyshortcuts`
advertise Escape, and an Escape pressed on the focused button runs Back
itself, because the shell's browse Escape requires focus inside the page. At
≤640 px Back takes the context drawer toggle's slot (one navigation icon); the
list it returns to shows the toggle again. This replaced #1763's 72 px lane
reserved beside a sticky in-page arrow, along with its phone bar. Electron E2E
`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch.
list it returns to shows the toggle again, and there the history fallback
yields to it. This replaced #1763's 72 px lane reserved beside a sticky
in-page arrow, along with its phone bar. Electron E2E
`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch,
and the history fallback on the list Back returns to.
The header Back is route-level in both states: it emits `backClicked`
whether or not inline playback is active, so the arrow keeps one meaning and
@@ -67,8 +69,13 @@ keep working on the page.
Hosts without browse navigation set `backAvailable=false`: M3U uses its channel
sidebar, and collection bootstrap placeholders have no return handler. They
register no header Back in either state and have no browse Escape action; their
watch exits are the bar's Close player button and Escape. Loading/error shells
with a return handler keep Back available.
watch exits are the bar's Close player button and Escape. The header may still
show the history fallback there (a generic Back to the previous page, without
Escape) when the page was reached by in-app navigation. Loading/error shells
with a return handler keep Back available. The downloads offline and recording
error states additionally keep a labelled "Back to Downloads" button beside
Retry or Remove: it is the error state's recovery action and runs the same
handler as the header Back.
## Summary
+5 -1
View File
@@ -168,7 +168,11 @@ into `apps/website/public/blog/guides/screenshots/` instead of a release folder
guard a release shot does; the add-playlist dialog shots fill the form with the
mock's fictional `marketing` credentials and use a labeled hand-out for the
Auto-detect method rather than a `get.php?username=…` link, because G4 rejects
any URL carrying query credentials. Shots that walk into a Stalker portal
any URL carrying query credentials. The Xtream shot clicks **Test HTTPS and
HTTP** against the plain-`http://` mock, so no HTTPS probe is made, and fails
the run unless the status line reports an active portal; the mock does not
check passwords, so that verdict proves the scenario answered, not that the
password is right. Shots that walk into a Stalker portal
(`open-stalker-live`) make the run start the stalker-mock-server on port 3210
and seed its `marketing-demo` portal as a third source, which is why they are
never part of a release run. That scenario's MAC, `00:1A:79:00:00:07`, is the
+2 -1
View File
@@ -408,7 +408,8 @@ file running in parallel workers, so isolation is per-MAC rather than global:
else is talking to the server — a spec that used it would wipe a sibling
spec's session mid-test.
- `apps/web-e2e/src/stalker.e2e.ts` declares its shared scenario MACs in
`OWNED_MACS` and clears them in one batched request. The sibling specs that
`OWNED_MACS` (the constants live in `stalker-portal.fixture.ts`) and clears
them in one batched request. The sibling specs that
reach this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a
disjoint `00:1A:79:5F:*` range, so neither file can clear the other's state.
- Within each browser project, tests deliberately share content-scenario MACs
+65 -1
View File
@@ -484,7 +484,8 @@ the format at all, so a large share of working installations use a non-Infomir
MAC. Refusing one would stop those users adding or editing a portal that works
for them. The mock encodes the same split (`enforceMacFormat` is set only on
the strict endpoint; `/portal.php` ignores it), and `AUTH_REJECTED_MAC` in
`stalker.e2e.ts` depends on it — a non-Infomir MAC that must reach the strict
`stalker-portal.fixture.ts` (used by `stalker.e2e.ts`) depends on it — a
non-Infomir MAC that must reach the strict
endpoint and be refused _there_, not in the form.
In the edit dialog **both** passes — blur and submit — normalize only a MAC the
@@ -1558,6 +1559,69 @@ Core decision logic and normalization are centralized in:
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
- `libs/portal/stalker/data-access/src/lib/models/*.ts`
## Forced External Launches
"Open in external player" needs a `create_link` round trip before it reaches
MPV/VLC. The shared rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages);
the Stalker keys and queues are:
Series (`StalkerSeriesViewComponent`, `stalker-series-launch-queue.ts`):
- Pending starts and the launch queue's held choices are keyed by
`playlist:series` (`currentSeriesKey`). The view is reused across series
and provider ids collide across playlists, so one series settling never
drops what another holds.
- A start is pending for its series from the click until it settles. A forced
launch stays pending through the close of the previous player, the launch
and the release of a held choice. The pending flag disables the hero button
and the menu's external-player and watched rows.
- Before launching, an episode of the same series still running externally is
closed (`replaceOwnedExternalSession`). The request is rechecked after that
close and after the launch IPC; a superseded launch closes the session it
opened.
- An episode chosen while a forced launch of its series is mid-flight is held
(`StalkerSeriesLaunchQueue.hold`); the latest choice per series wins. On
release it is dropped when the series is no longer shown. Otherwise
`replacePlayer` closes what the launch opened before the choice starts, and
an unconfirmed close drops the choice.
- An episode chosen while a watched or reset batch runs is held in one slot
tagged with its series; the last choice wins. When the batch settles it
goes through the usual gates only if that series is still shown: episode
identities overlap across series.
Movies (`createStalkerVodDetailActions`, used by the catalog detail, the
collection detail and search):
- A repeat for the same `playlist:movie` while its launch is in flight is
ignored, also after leaving the movie and returning to it. Launches of
other movies are not held back.
- The launch joins the host's starts (`beginPendingStart`): it supersedes an
earlier start, is dropped once a later one begins, and keeps Play, Start
over, the watched toggle and the menu rows disabled until it settles.
- The resolved stream is discarded when the movie is no longer selected or a
newer start took over. Movie and series ids collide, so the catalog and
collection details include the content type in the selection check; in
search, a switch to a series changes the playback owner instead, which
supersedes the launch. Otherwise the movie's own external
session is replaced, the host's `beforeExternalLaunch` hook runs (the
catalog and collection details close their inline player there), and the
launch is sent. A launch that resolves after either condition changed
closes the session it opened; one that fails by then is not reported.
- "Reset progress" counts as a pending start of the movie until the write
lands, so a start made meanwhile cannot resume from the row being cleared.
- The pending start is owner-scoped (`createPendingPlaybackStart`). Each host
retires it when the selection leaves the owner; that clears the pending
flag, not the repeat guard of a launch still in flight.
Regression coverage: `stalker-series-launch-queue.spec.ts`,
`stalker-series-view.component.spec.ts`,
`stalker-series-view.season-watch.spec.ts`,
`stalker-vod-detail-actions.spec.ts`,
`stalker-vod-playback-controller.spec.ts` and, in
`libs/portal/shared/util/src/lib/`, `pending-playback-start.spec.ts` and
`replace-owned-external-session.spec.ts`.
## Favorites and Recently Viewed
Current implementation is shared via Stalker-specific helpers:
+1 -1
View File
@@ -257,7 +257,7 @@ Zero i18n keys. Zero UI change.
- populated in all three of `mergeVodInfoWithTmdb` (`:168`), `mergeSerieInfoWithTmdb` (`:215`), `mergeStalkerInfoWithTmdb` (`:257`)
- through `NormalizedVodMeta` (`libs/shared/interfaces/src/lib/vod-details-item.interface.ts`) + **both** normalizers in `vod-details-adapters.ts` — the single convergence point where Xtream and Stalker meet
**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **388 lines and is NOT in `tools/eslint/max-lines-baseline.mjs`** — roughly 12 lines of headroom against the hard 400 lint cap.
**Component.** New standalone `app-tmdb-extras-shelf` in `libs/ui/shared-portals`. It must **not** go inline: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` is **NOT in `tools/eslint/max-lines-baseline.mjs`** and stays under the hard 400 lint cap only because its state lives in sibling helpers (about 340 counted lines today).
**Render** into the `detail-extras` projection slot at **four** sites (`vod-details.component.html:225`, `serial-details.component.html:201`, `vod-details-route.component.html:247`, `stalker-series-view.component.html:202`) — note Xtream `serial-details` has no trailer block today, so it either gains one or the shelf lands inconsistently. Reuse the existing nocookie iframe + `| safe` pipe so the Electron Referer shim keeps working; clicking swaps the embed `src` rather than opening a new player. Cards use `https://img.youtube.com/vi/{key}/hqdefault.jpg` (CSP verified: `img-src` covers it, `frame-src https://www.youtube-nocookie.com` covers the embed). `@if (extras().length > 1)` … `@else` the existing single-trailer markup **verbatim**.
+26
View File
@@ -757,6 +757,32 @@ lookup comes back empty, because "never watched" is an answer: the button must
read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the
route's own row changes nothing; the loaded position already IS that copy's.
## Menu launch and reset follow the primary button
The "…" menu acts on the copy the primary button acts on. The shared launch
rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
- "Open in external player" and "Start over" are host-owned
(`VodDetailsMenuBindings.openExternal` / `restart`); the menu service never
builds a playback itself. The route forces MPV/VLC for the pinned copy from
that copy's own resume point (`playPinnedSource` with `player` and
`replacePlaying`), also while that copy already plays: it is relaunched,
never swapped for the route copy. Only an `unavailable` pin falls through
to the route copy's Resume or Play.
- "Reset progress" clears the row of `primaryTarget`: the pinned copy's own
row, otherwise the route copy's.
- Resets in flight are a list of targets
(`VodDetailsPlaybackService.pendingResets`, `vod-details-reset-target.ts`),
not one flag: the reused page can show another movie and come back, and
resets of one copy can overlap, so each reset removes only its own entry.
- A start is refused (`startResolvedPlayback`) while a launch this page made
has not settled or the list holds the copy the page currently acts on
(`resetTarget`). `startBlocked` disables Play, Start over and the menu
launch on those conditions and while a matched session is still
`launching`; the menu rows are also held while a start is pending. A reset
still writing for another copy does not block it.
## Provider codec metadata
`info.video` / `info.audio` come back in two shapes: the declared string array
+27 -1
View File
@@ -137,7 +137,33 @@ edges (no second, sharp copy); a live channel's logo sits on the right as key
art over its own wash. Series titles drop their season marker
(`splitSeasonSuffix`) — the `S1·E1` chip names the season. Chips are
`app-meta-chip`; the primary is the details pages' light primary
(`light-primary-button` from `libs/ui/styles`).
(`light-primary-button` from `libs/ui/styles`). With no artwork at all the
stage is a gradient in the title's hue (`--hero-hue`), light in the light
theme and near-black in the dark one; a dark gradient under the light
theme's page-coloured scrim read as a grey slab behind dark text.
Legibility: slide text stays at 4.5:1 or more over any artwork. The side
scrim holds 88% of the page colour up to the slide's right edge
(`--hero-text-edge`: the inset plus `min(560px, 55%)`, the slide's own
`max-width`) before it opens onto the art. In the narrow layout (`dashboard`
container ≤ 720px) the slide spans the width, so a full-bleed scrim sits
behind the text block (90%, fading in just above the eyebrow), the copy gets
a scrim-coloured text shadow, and the slide enters without a fade so that
scrim never flashes the art on a rotation. Body text is 85% of the heading
colour; the rating chip uses `--app-rating-color`, set per theme in
`m3-theme.scss`. Buttons end long labels in an ellipsis.
`dashboard-hero-legibility.e2e.ts` replaces every image with a black-and-white
checkerboard and measures each piece of slide text from the screen in both
themes, at a wide and a narrow width, for a backdrop, a blurred-poster, a
no-artwork and a live slide.
Semantics: the page has one stable, visually hidden `h1` ("Dashboard",
`dashboard-page-heading`); each slide title is an `h2`, like the rail titles.
Slide changes are announced by one polite live region
(`dashboard-hero-announcement`, position and title) that lives outside the
re-created slide and is silent while the slides rotate on their own. A
slide's progress bar is named after its title (a live slide: the programme)
and a title's reads "N% watched". The dots are 24px targets (WCAG 2.5.8).
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
advances. The fill animates `transform` only (a bar sliding in under the
+77 -5
View File
@@ -93,10 +93,9 @@ The shell is intentionally split into four persistent regions:
4. No brand mark: it only repeated the first workspace link (Dashboard,
or Sources when the dashboard is off).
2. Top header:
1. Leading Back slot, shown while the current page registers a target
with `WorkspaceBackNavigationService` (detail pages today). At
≤640 px it takes the context drawer toggle's place. See
[Portal Detail Navigation](./portal-detail-navigation.md).
1. Leading Back slot: the current page's registered Back, else browser
history while an in-app previous page exists, else nothing. See
[Header Back](#header-back).
2. Playlist switcher.
3. Route-aware search input and command palette trigger.
4. Add source action.
@@ -130,6 +129,71 @@ When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
## Header Back
The header's leading slot is the workspace's one page-level Back. Pages do not
render an arrow of their own: they register a `WorkspaceBackTarget`
(`@iptvnator/portal/shared/util`) with `WorkspaceBackNavigationService`
(`@iptvnator/portal/shared/data-access`), normally through
`registerWorkspaceBack()`, which registers for the calling component's
lifetime while its optional `available` predicate holds. The newest
registration wins, and each release removes only its own target. The button
(`data-test-id="workspace-header-back"`) sits beside the macOS traffic lights,
never scrolls, and has Electron `no-drag` hit testing. A target supplies its
label (else the translated "Back"), whether Escape on the page runs it, and
`run()`.
| Page | Registered by | Back runs | ≤640 px |
| --- | --- | --- | --- |
| Portal, collection, offline and recording details | `PortalDetailShellComponent` while `backAvailable()` | the host's `backClicked` | replaces the drawer toggle |
| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's `Location.back()` | (no drawer) |
| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) |
| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle |
Detail-page semantics (Escape, browse and watch) are in
[Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus).
**History fallback.** Without a registration, the header shows Back while the
previous history entry is an in-app one, and runs `Location.back()`. The
service reads that from the Navigation API: the previous entry must be
same-document (`NavigationHistoryEntry.sameDocument`), so the router pushed it
after this document loaded. Entries from before a reload or from another page
of the origin never count, and the fallback can neither leave nor reload the
app. `currententrychange` keeps it current through pushes, replacements,
traversals and guard-cancelled Back navigations that the router rewrites.
Without the Navigation API (older Safari and Firefox, jsdom) there is no
fallback; registered pages are unaffected. The fallback reads "Back" and
advertises no Escape, because no page handles one for it. Pages that set
`backAvailable=false`, such as M3U details, therefore show it too when they
were reached by navigation.
When there is nowhere to go, the slot is empty rather than a disabled arrow.
Sessions often start on a page that never navigates (an M3U playlist or live
TV), where a disabled arrow would stay for the whole session. The cost is one
shift of the switcher and search when Back first appears or leaves, which
happens only at the start of the history and together with a route change.
There is no Forward button: Stalker inline details are store state, not
history entries, so Forward would skip them.
**Phone width.** `phoneDrawerToggle` sets how Back shares the leading slot with
the context drawer toggle. `replace` (the default) takes the toggle's slot: a
detail page's drawer belongs to the list that Back returns to. `beside` keeps
both: the settings drawer holds the page's own sections. `yield` hides Back
while the toggle shows: the history fallback must not cost a category list its
only way into the drawer, and two navigation icons do not fit beside the
switcher. System and browser Back still work there.
**Left in place.** These controls stay inside their surface on purpose:
1. Downloads offline and recording detail error states keep their labelled
"Back to Downloads" button beside Retry or Remove. It is the error state's
recovery action, not page chrome; the header shows the same Back.
2. Back controls internal to a surface, which leave a panel rather than the
page: the Embedded MPV dock panel and the alternative-sources panel inside
the VOD "…" menu.
3. The M3U player sidebar's Home button, which renders only outside the
workspace shell.
## Context Panel Rules
The shell decides which secondary panel to show from the current route:
@@ -326,7 +390,15 @@ The Electron window hides the native title bar on all desktop platforms
(`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`):
1. macOS keeps the native traffic lights (`titleBarOverlay: true`,
`trafficLightPosition`); the renderer draws no window buttons.
`trafficLightPosition`); the renderer draws no window buttons. The lights
sit in the 56 px header band above the rail, so the macOS rail
(`.app-rail.is-macos`) starts its first link at 56 px: level with the
content area and the dashboard hero, with its hover surface clear of the
lights. App zoom scales CSS pixels but not the lights, so the rail
publishes the page zoom factor (`outerWidth / innerWidth`, refreshed on
`resize`) as `--rail-zoom-factor` and keeps at least 48 window pixels when
zoomed out. `window-controls.e2e.ts` checks the alignment and the gap at
default and minimum zoom on macOS.
2. Windows and Linux use renderer-drawn window controls
(`app-window-controls`, `libs/ui/components/src/lib/window-controls/`).
`frame` is intentionally left untouched so native resize borders and
@@ -405,3 +405,40 @@ deduplicated list, `hasMoreContent` derives from accumulated length vs
and the facade maps page 0 to the skeleton and later pages to the tail
spinner. These catalog/search surfaces use incremental loading instead of
page buttons.
## Forced external launches from detail pages
The "…" menu's MPV/VLC launch follows the shared rules in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
The movie page's pin and reset rules are in
[VOD Multi-Source](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button).
The series page keeps its launch state at module level in
`serial-details-external-launch.ts`, so it outlives a recreated page:
- The owner is `playlist:series` (`launchOwner()`). It changes when the page
shows another series and is null once the page is gone.
- Forced launches of one owner run on one chain. A later launch waits for the
earlier one to settle, closes the owner's running episode session and then
launches. Each step rechecks the owner, and a launch that resolves after the
page left the owner closes the session it opened.
- The duplicate guard is keyed by page token plus episode. The token
(`pageToken()`) is owner, page instance and visit, so a launch left behind
by an earlier visit of the same series does not swallow a launch from the
reopened page; that launch queues on the owner's chain.
- While a forced launch of the owner is pending (`forcedLaunchPending`), a
start that does not force a player is queued instead of started. One choice
is kept per owner, the latest wins, and it carries the host and `start` of
the page that made it. Once the chain settles, the player the launch opened
is closed first while the owner stays pending. The choice is dropped when
that page no longer shows the owner or the close was not confirmed.
- The pending flag also disables the menu's external-player row and the
season and series watched actions, and counts as active playback for "Reset
progress".
- The launch-position marker and a launch-failure message apply only while
the page token is unchanged.
Regression coverage: `serial-details-external-launch.spec.ts` (chain,
duplicate guard, queued choice), `serial-details-playback.service.spec.ts`
(page token) and, for the external-player and reset rows,
`libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts`.
+257
View File
@@ -0,0 +1,257 @@
# Zoneless change-detection migration
Working checklist for plan item C6 of the performance journeys plan: move the
renderer (`apps/web`) from zone.js to `provideZonelessChangeDetection()`.
The win is measured with the change-detection tick counters described in
[performance journeys](performance-journeys.md#change-detection-ticks); the
[idle work audit](idle-work-audit-2026-09.md) found the Eager roots that
re-render on every tick. Update this file in the same PR that converts an item.
The inventory was taken on `e8b181fce` (2026-10-04, Angular 22.1.6). Run
`pnpm nx run electron-backend-e2e:test-performance-harness` after editing the
Eager list: `zoneless-migration.spec.ts` fails when the list and the code
disagree, so a new Eager component cannot land unnoticed and a converted one
must be ticked here.
## Starting point
- **OnPush is already the default.** Since Angular 22 an unset
`changeDetection` means OnPush, and the old `Default` strategy is spelled
`ChangeDetectionStrategy.Eager`. Only components that set `Eager` are
checked on every tick. `ChangeDetectionStrategy.Default` is not used.
- **Renderer bootstrap.** `apps/web/src/app/app.config.ts` provides
`provideZoneChangeDetection({ eventCoalescing: true })` and
`apps/web/project.json` builds with `"polyfills": ["zone.js"]`.
`apps/remote-control-web` does the same; it is a separate app and outside
this migration unless a step says otherwise.
- **Unit tests already run zoneless.** Every `src/test-setup.ts` (apps/web,
apps/remote-control-web and 24 libs) calls `setupZonelessTestEnv` and loads
`zone.js`/`zone.js/testing` only for `fakeAsync` and `waitForAsync`. A
component that passes its specs is therefore not proof of zone-free
production behavior when the spec calls `fixture.detectChanges()` itself.
- **IPC callbacks never ran in the Angular zone.** `window.electron.on*`
listeners arrive through `contextBridge` and are not zone-patched, so every
one that works today already writes signals or calls `NgZone.run`.
- **Counters before the migration** (macOS, from
[performance journeys](performance-journeys.md#change-detection-ticks)):
`renderer.cdTicksToFirstCard` 20–21 (the one-tick zone.js race),
`renderer.cdTicksIdle30s` 3, `renderer.cdTicksToFirstPage` 22;
`renderer.cdTicksToPlaying` has no recorded run yet.
## PR sequence
1. [ ] Keep `@ngrx/store-devtools` out of production bundles (#1810, open). Not a
zone change; it lowered `renderer.initialBytes` before the migration
starts moving it.
2. [x] This inventory and its guard spec.
3. [ ] Per-project PRs, in this order, each converting the project's Eager
components and fixing its zone-dependent sites while zone.js stays on:
`libs/ui/*`, `libs/workspace/*`, `libs/playlist/*`, `libs/portal/*`,
playback (`libs/ui/playback`, `libs/playlist/m3u/feature-player`),
`apps/web`. Each reports the tick counters before and after and runs the
affected unit and E2E tests.
4. [ ] `provideZonelessChangeDetection()` behind a build-time
`fileReplacements` flag, off by default; all four journeys and the
Electron E2E suite run with it on.
5. [ ] Flag on by default, `zone.js` out of `polyfills`, new tick baselines
(`renderer.cdTicksIdle30s` and any counter that becomes deterministic once
the zone.js race is gone).
## Eager components
66 production files, 67 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
(or setting OnPush) once its template state is signals, signal inputs or
explicitly marked. The guard spec compares the unticked entries with the
files that still contain `ChangeDetectionStrategy.Eager`.
### apps/web (15)
- [ ] `apps/web/src/app/app.component.ts` (idle audit root)
- [ ] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root)
- [ ] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts`
- [ ] `apps/web/src/app/settings/settings.component.ts`
- [ ] `apps/web/src/app/settings/settings-about-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-backup-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-dashboard-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts`
- [ ] `apps/web/src/app/settings/settings-epg-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-general-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-playback-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-reset-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-tmdb-section.component.ts`
- [ ] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts`
### libs/ui (20 files, 21 components)
- [x] `libs/ui/components/src/lib/confirm-dialog/confirm-dialog.component.ts`
- [x] `libs/ui/components/src/lib/content-hero/content-hero.component.ts`
- [x] `libs/ui/components/src/lib/expandable-text/expandable-text.component.ts`
- [x] `libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts`
- [x] `libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`
- [x] `libs/ui/components/src/lib/progress-capsule/progress-capsule.component.ts`
- [x] `libs/ui/components/src/lib/season-container/episode-info-dialog.component.ts`
- [x] `libs/ui/components/src/lib/watched-badge/watched-badge.component.ts`
- [x] `libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.ts`
- [x] `libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts` (idle audit root; also `EpgTrustConfirmDialogComponent`)
- [x] `libs/ui/epg/src/lib/epg-source-status/epg-source-status.component.ts`
- [ ] `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` (`apps/remote-control-web` only)
- [ ] `libs/ui/playback/src/lib/art-player/art-player.component.ts`
- [ ] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
- [ ] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts`
- [ ] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts`
- [ ] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts`
- [ ] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts`
- [ ] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts`
- [ ] `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
`libs/ui/playback` (8) goes with the playback PR, not the `libs/ui` one.
### apps/remote-control-web (1)
- [ ] `apps/remote-control-web/src/app/app.ts` (separate app; converts with `remote-control.component.ts`)
### libs/workspace (7)
- [ ] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts`
- [ ] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
- [ ] `libs/workspace/shell/feature/src/lib/workspace-sources/workspace-sources.component.ts`
### libs/playlist (14)
- [ ] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts`
- [ ] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts`
- [ ] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
- [ ] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [ ] `libs/playlist/shared/ui/src/lib/source-health/source-health-indicator.component.ts`
`libs/playlist/m3u/feature-player` (2) goes with the playback PR.
### libs/portal (9)
- [ ] `libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts`
- [ ] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts`
- [ ] `libs/portal/shared/ui/src/lib/components/search-form/search-form.component.ts`
- [ ] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
- [ ] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- [ ] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
- [ ] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- [ ] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts`
- [ ] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts`
Test-only files that set Eager are not listed; they do not ship. The guard
skips every `*.spec.ts` / `*.test.ts` file with or without a suffix of one
or more segments (`*.spec-stubs.ts`, `*.test-helpers.ts`,
`*.test-data-stubs.ts`, …),
`test-setup.ts` and `test-stubs/` directories.
## Zone-dependent sites
Plain (non-signal) fields read by a template and written from a callback
that is not an Angular template event. Under zone.js the next tick happens to
refresh an Eager view; under zoneless nothing schedules one. Each fix makes
the field a signal (or a `computed`), or writes it through one.
| Done | Site | What depends on the zone | Owning PR |
| --- | --- | --- | --- |
| [ ] | `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` `onChannelNumberInput`/`clearChannelNumberInput` | 2 s `window.setTimeout` hides the channel-number overlay through plain `showChannelNumberOverlay`/`channelNumberInput` | playback |
| [ ] | same file, `applySettings` and the settings `effect()` | IndexedDB `storage.get(...).subscribe` and an effect assign plain `playerSettings`, which picks the player in the template | playback |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts` `checkPortalStatus` | plain `portalStatus` assigned after `await` in `ngOnInit` (PWA only: skipped when source health is supported) | playlist |
| [ ] | `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts` (EPG clear and EPG file pick handlers) | plain `playlist` reassigned after `await` | playlist |
| [ ] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; template getters read `control.value`, which is not signal-backed | playlist |
| [ ] | `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` (favorites load) | `favorites` Map filled in a `subscribe` without `markForCheck`; the component is OnPush already, so this is a latent bug today | portal |
| [ ] | `libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts` (favorites load) | same pattern; the neighbouring `favoriteMarks.changes$` handler does call `markForCheck` | portal |
| [ ] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
| [ ] | `apps/web/src/app/settings/settings-backup.facade.ts` (backup import) | `change` listener on a detached file input → `hydrateFromStore()`; section templates read `form().value.theme`/`coverSize` | apps/web |
| [ ] | `libs/ui/remote-control/src/lib/remote-control/remote-control.component.ts` | plain `isLoading`/`error`/`status` written after `await` and from a 2 s `setInterval` | only if `apps/remote-control-web` goes zoneless |
## Explicit zone and change-detector calls
They keep working under zoneless (`NgZone` becomes `NoopNgZone`, so `run`
and `runOutsideAngular` just call through). Remove them in the flip PR, not
before: with zone.js on they still matter.
- [ ] `apps/web/src/app/settings/settings-unload-guard.service.ts`: two
`zone.run` calls around the window-close dialog (IPC
`onWindowCloseRequested` and `beforeunload`).
- [ ] `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts`:
`runOutsideAngular(() => setInterval(...))` for the position poll;
`embedded-mpv-session-controller.position.spec.ts` asserts the call and
changes with it.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts`:
18 `ngZone.run(() => signal.set(...))` calls, all redundant around signal
writes.
- [ ] `libs/workspace/dashboard/data-access/src/lib/dashboard-source-expiry.service.ts`:
one `ngZone.run` around a signal update.
- `ChangeDetectorRef` in `stalker-live-stream-layout.component.ts`
(4 × `markForCheck`, 1 × `detectChanges` before measuring a row) and
`portal-channels-list.component.ts` (3 × `markForCheck`, 2 ×
`detectChanges`): correct under zoneless; replace the Maps with signals in
the portal PR if it stays small.
## Checked and signal-safe
No change needed; recorded so the flag PR knows where to look if a journey
regresses. Embedded MPV and external players are the riskiest paths because
their events arrive over IPC.
- **IPC listeners** (17 registrations): app update status, external player
sessions, window close and window state, player errors, playlist open
requests, embedded MPV sessions, playback history gate, downloads,
recordings, playlist refresh, DB operation and save progress, EPG progress,
playback position updates, channel change and remote-control commands. All
write signals, signal stores or NgRx, or have no UI state.
- **Player libraries** (video.js, mpegts.js, hls.js, artplayer, shaka, native
`<video>`): callbacks bump signals in the control adapters or emit outputs
whose parent handlers write signals.
- **Observers** (13 Intersection/Resize/Mutation observers) and **document
and window listeners** (~40): signals or DOM only. `@HostListener`
bindings are Angular listeners and mark their view.
- **Timers** (~130 `setTimeout`/`setInterval`/rAF/`queueMicrotask`): all
write signals, touch the DOM or focus, or have no UI state, apart from the
two in the table above.
- **Dialogs and snackbars** (20 `afterClosed`/`onAction` sites): signals,
stores, outputs or navigation, apart from the one in the table above.
- No production code uses `NgZone.onStable`, `onMicrotaskEmpty`, `isStable`,
`ApplicationRef.tick()`, `Zone.current` or `ngDoCheck`.
## Build, tests and runtime details
- [ ] `provideServiceWorker(..., { registrationStrategy:
'registerWhenStable:30000' })`: under zoneless "stable" means no pending
tasks. The 30 s bound still registers the worker; check the PWA build in
the flag PR.
- [ ] `change-detection-tick-counter.ts` wraps `ApplicationRef._tick`, which
the zoneless scheduler also calls, so the counters stay comparable.
- [ ] Specs that need zone.js: `fakeAsync` in
`playlist-switcher.component.spec.ts` and `stalker-live-navigation.spec.ts`,
`waitForAsync` in 13 files. They keep `zone.js/testing` until rewritten;
removing zone.js from the build polyfills does not affect them.
- [ ] Unreferenced leftovers to delete in the flip PR:
`apps/web/src/polyfills.ts`, `apps/web/src/polyfills-test.ts`,
`apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses
them).
## Measuring a PR
Build `electron-performance` and run the journeys as described in
[performance journeys](performance-journeys.md), then paste
`renderer.cdTicksToFirstCard`, `renderer.cdTicksIdle30s`,
`renderer.cdTicksToFirstPage` and `renderer.cdTicksToPlaying` before and
after. While zone.js is on, removing Eager does not change the number of
ticks, only the work per tick; expect the counters to stay put until the flag
PR and the template work (DOM mutations, profile time) to drop.
+1
View File
@@ -15,6 +15,7 @@ are not prerequisites for reading repository contracts.
| Angular conventions; docs and skills maintenance; local review before a pull request | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage`, `tools/typecheck` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Zoneless change detection, `ChangeDetectionStrategy.Eager` components, `NgZone` usage | [Zoneless migration](../architecture/zoneless-migration.md) | Read the checklist directly |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |