Merge remote-tracking branch 'origin/master' into claude/nice-feistel-0670b5

# Conflicts:
#	libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts
This commit is contained in:
4gray committed 2026-10-13 08:16:50 +02:00
commit 4e0d669daf
1055 files changed
+70122 -15121

No files matched your search

+123 -27
View File
@@ -157,6 +157,14 @@ Contracts:
- Hosts provide hero chips/meta/actions as `*appDetailTags` / `*appDetailMeta` /
`*appDetailActions` templates; the shell stamps them into the hero and again
into the About block. Degradation stays "missing → not rendered".
- Provider stand-ins count as missing: `N/A` (any case), a lone `-`, and a
`0` IMDb/Kinopoisk score are dropped where provider rows are mapped, by
`readProviderText` / `readProviderRating`
(`libs/shared/interfaces/src/lib/provider-metadata.util.ts`) — in
`createStalkerInfo()`, the `vod-details-adapters.ts` normalizers and the
Xtream store's detail selection. Templates keep plain `@if` guards; do not
teach them the placeholder list. Widen the list only with evidence of a
provider sending the new stand-in.
- The shell never wraps the `[detail-player]` slot in a conditional; the
host's `@if (inlinePlayback())` is the only creator/destroyer of the
player subtree, so shell state changes cannot recreate the `<video>`.
@@ -269,9 +277,15 @@ seasons-grid + "Back to seasons" level. A season is auto-selected
(inline-playing episode's season → most recently updated in-progress
episode's season → earliest season with unwatched episodes → latest
non-empty season; Stalker lazy-VOD series with unhydrated seasons pin
the fallback to the first season because their watched state is unknown,
the fallback to the first season still to load or holding episodes
(`unloadedSeasonKeys`: a season the portal answered empty is passed over)
because a pending season's watched state is unknown,
and the empty→loaded positions flip caused by the session's own watched
toggles never re-resolves the selection) and the
toggles never re-resolves the selection). Extras (season 0, by the
episodes' season or, unloaded, the key: `isExtrasSeason`) steer none of
the last three while the series' other seasons hold, or may still hold,
episodes, as with its next episode, so the page does not open on Specials;
only a playing extra opens its season. The
auto-selection emits `seasonSelected`,
so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch,
TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open
@@ -280,19 +294,16 @@ episode" chip appears when the playing episode is outside the opened
season. Season descriptions come from `get_series_info` seasons (Xtream)
or `TmdbEnrichmentService.getSeason` (Stalker).
The tabs and description sit in a **season card** with an optional cover
column: the selected season's own poster (`seasonPosters` input, keyed like
`seasonDescriptions`; TMDB season poster first, provider `seasons[].cover_big`
next — see "Season/Episode Enrichment" in `tmdb-metadata-enrichment.md`).
The column is sized by `--season-cover-width` (96 / 120 / 144px for
`Settings.coverSize` small / medium / large, `apps/web/src/_cover-size.scss`;
medium matches the About block's poster) and is not rendered at all — the
card collapses to one column and the tabs render exactly as before — when
the selected season has no poster, when the item has a single season (that
poster is the show poster a few hundred pixels below the hero), or when the
image request failed. The hero poster never follows the season: the show
keeps its identity element, the season gets its own picture next to its own
text. The fullscreen episode panel shows the same poster as a compact season
The season picker carries no artwork: chips up to four seasons, a menu
button with "N episodes · M watched" rows from five. The selected season's
synopsis sits under the header, offset to the episode number column, only
when it is non-empty and does not repeat the series description. The season
posters (`seasonPosters` input, keyed like `seasonDescriptions`; TMDB season
poster first, provider `seasons[].cover_big` next — see "Season/Episode
Enrichment" in `tmdb-metadata-enrichment.md`) are not drawn on the detail
page; the container only uses them to recognise an episode still that is the
season cover again. The hero poster never follows the season.
The fullscreen episode panel shows the same poster as a compact season
strip (poster, season name, episode count — `PORTALS.EPISODE_COUNT_ONE` /
`PORTALS.EPISODE_COUNT_OTHER`) above its season tabs, under the
same gates, from `PortalInlinePlayerComponent.seasonPosters` through
@@ -351,7 +362,8 @@ Both hosts delegate to `createVodWatchedToggle()` in
Catalog cards derive their corner badge from one shared `PortalWatchState`
(`unwatched` / `in-progress` / `watched`, `portal-watch-state.ts`): both
facades map a movie's position through `watchStateFromProgressPercent` /
`resolvePortalWatchState` (90% threshold, shared with the Resume rule),
`resolvePortalWatchState` (`PORTAL_WATCHED_PROGRESS_PERCENT`, 90%, shared
with the Resume rule and the dashboard's Continue Watching rail),
and a series through `resolvePortalSeriesWatchState`, which reports at
most `in-progress` — the list payload never carries the episode total, so
"every episode watched" is not decidable there.
@@ -526,23 +538,36 @@ Owner keys and queueing are provider contracts:
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
`libs/portal/shared/util/src/lib/series-quick-start.ts`.
The helper flattens the loaded season/episode map, sorts seasons and episodes in
natural order, and returns the hero CTA state.
`libs/portal/shared/util/src/lib/series-quick-start.ts`, built on
`getSeriesNextUp` (`series-next-up.ts`), which the dashboard's Continue
Watching rail also follows. The helper flattens the loaded season/episode map,
sorts seasons and episodes in natural order, and returns the hero CTA state.
Current contract:
- the CTA shows the action label plus a compact episode target such as
`S01E02 · Episode title`
- if an episode is in progress, resume the latest updated in-progress episode
with its saved offset
- if the newest activity is an episode left unfinished, resume it with its
saved offset (recency reads SQLite `updated_at`, UTC without a zone, as UTC;
both hosts date the rows they write themselves with
`stampPlaybackPositionNow`, so the episode just played ranks newest)
- if the newest episode entry is a successful external-player launch marker
with no meaningful progress yet, target it with `Play episode N` instead of
falling back to the first episode
- if no episode is in progress, play the first unwatched episode in season order
- otherwise play the first unwatched episode after the episode watched last,
as Plex's On Deck and Jellyfin's Next Up do: an earlier episode skipped or
left unfinished is passed over, and a next episode started before resumes
- extras (season 0) stand apart when the series has other seasons: they never
come next, are never resumed from here (not even one left unfinished) and
never keep the series from completing; after only extras were played the
CTA plays the first episode of the run. A series filed under season 0 alone
has them as its run
- if watched episodes end at a season boundary, play the first episode of the
next loaded season
- if every loaded episode is watched, render a disabled completed state
- when no unwatched episode follows the one watched last, offer the first one
skipped before it (never an extra); with none left, render a disabled
completed state
- with nothing played yet, play the first episode of the run in season order
The click path must continue through each detail host's normal episode playback
method so recent-item updates, inline/external player selection, resume offsets,
@@ -695,7 +720,7 @@ external-player workflows; it is not copied from the HLS error payload into
the evidence or technical details. HLS startup development logs are event-only:
they do not include provider-supplied channel names or source URLs.
Shaka Player `5.2.4` errors cross a separate structured boundary before the
Shaka Player `5.2.12` errors cross a separate structured boundary before the
HTML5 or ArtPlayer DASH session emits a diagnostic. Version-locked tests assert
the installed Shaka version plus the public `Severity`, `Category`, and selected
online-playback `Code` values used by the boundary. Evidence retains only
@@ -749,7 +774,7 @@ allowlisted display name.
`network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
mpegts.js `1.8.1` errors cross one shared structured boundary before the HTML5,
mpegts.js `1.8.2` errors cross one shared structured boundary before the HTML5,
Video.js, or ArtPlayer owner emits a diagnostic. Version-locked tests compare
the installed public `ErrorTypes` and `ErrorDetails` exports with the accepted
contract. Evidence retains only an exact type/detail pair, terminal
@@ -787,6 +812,17 @@ also drives a concise browser-support hint for codecs that Chromium/Electron
commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2
video.
The panel's host is a size container over the player, so its spacing, its
narrow-width rules and a compact layout for players up to 560px tall all
follow the player box rather than the window. The M3U inline player above its
EPG panel is about 400px tall in a 720px window; there the whole surface fits,
including each card's hint and status lines and the utilities. A shorter
player scrolls the surface from its top. Card labels wrap instead of being
ellipsized, because translated actions are wider than a card column.
`apps/electron-backend-e2e/src/playback-diagnostic-layout.e2e.ts` writes every
locale's card labels and status line into the cards and fails on any
truncated label or clipped content.
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
The HTML5 player and ArtPlayer choose their source engine from one URL rule,
@@ -1001,6 +1037,61 @@ is also idempotent: it returns the closed snapshot without invoking the saved
closer, and a later process error cannot revive it as a visible failure. Reused
MPV commands use the socket captured for their exact child, so a delayed close
cannot send `quit` to a replacement process through a newer global socket.
Every reused MPV command carries a `request_id` and waits, within the same 2 s
IPC bound, for mpv's matching newline-delimited reply; event lines and replies
to other requests on that connection are skipped. An error reply or a missing
reply fails the handoff exactly like a socket error: the reused child is torn
down and a fresh launch follows. `loadfile` marks the content as possibly
changed and moves ownership of the child to the attempted session from the
moment it is written, whatever mpv replies, and stops the previous session's
position poll there, including a read already awaiting mpv, so an exit or a
stale Stop for the previous session during
the reply wait settles the right session and no poll can save the new stream's
position under the old content; a child that exited while its reply was in
flight is returned as the session its exit handler settled, never reported as
opened and never replaced by a fresh launch under that terminal session.
MPV launches run one at a time. A reused launch waits for mpv's replies and a
failed one for its teardown and fallback spawn, so a launch that arrives
meanwhile, such as a quick channel switch, starts only once the previous one
has settled and acts on the player that one left behind; two launches never
act on the same child. Each launch begins its session at once, so the newest
launch is the active session; the renderer lets a `launching` update take the
dock only when that launch did not start before the current one, by the
session's `launchOrder` (which separates launches in the same millisecond) or
else its `startedAt`, so an older launch still settling cannot pull the dock
back. Only the newest queued launch
runs: an older
one that comes up while a newer one waits behind it settles closed without
reaching mpv or taking the dock. Stop on a launch still waiting in the queue
cancels it and every launch queued before it; on the newest queued launch it
also closes, through their own closers, the in-flight launch and the session
owning the stored player at that moment (read from the per-child mapping, which
a failed attempt hands back to the previous owner during its teardown), so
their exact-child, retry and settle rules apply. Because launches run one at a
time, nothing else acts on a reused child while an attempt is in flight, so
that attempt's own Stop always reaches its child until the attempt settles,
whatever the per-child mapping names (the previous owner before `loadfile` or
during a failed attempt's teardown, possibly closed already); after it settles,
ownership decides as before, except that a Stop of that attempt which could
not confirm the exit keeps the right to retry that exact child. These rules apply
and nothing starts playing after Stop. Those targets are fixed at the first
Stop, so a retried Stop never reaches a player started later, and a late Stop
for an older queued launch cancels only that launch. The cancelled launch
settles only once its own Stop has: if the exit cannot be confirmed it stays a
closable error, so Stop can be retried. App shutdown cancels the whole queue.
A queued launch that finds the teardown gate closed, because the launch before
it could not confirm its child's exit, restores the session that still owns
that child, preferring the last launch that started (with reuse off its
detached child is not stored anywhere else); if that is not possible, it stays
a closable error so Stop can still tear the child down. Stopping the stored player, when reuse is disabled or
the app quits, cancels an in-flight reuse attempt: it dispatches no further
commands, never falls back to a fresh reusable player, also after waiting for
a failed child's exit, and settles its own session as closed once the child's
exit is confirmed, or as a closable error when it is not. No `seek` follows
`loadfile`: mpv rejects a seek before the file is loaded, so the resume offset
travels as the per-file `start` option. The `loadfile` reply only acknowledges
the command; a stream that later fails to open is reported through the reused
child's output and exit, not through the reply.
If Stop is observed before a pending MPV content command or VLC enqueue command
is dispatched, that command is skipped. A source handoff also fails closed
while a live session has no closer (`canClose: false`); renderer Dismiss is not
@@ -1114,8 +1205,13 @@ External MPV/VLC integration is split across focused main-process modules:
session tracking, trace logging, renderer notifications, playback-position
forwarding, and user-facing start errors.
- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns fresh MPV
launches and progress polling; `mpv-reusable-process.ts` owns the tracked
child, captured socket, remapping, retryable close, and reuse handoff.
launches; `mpv-position-polling.ts` owns progress polling over the IPC socket
and `mpv-output-errors.ts` the child's stdout/stderr error reporting;
`mpv-reusable-process.ts` owns the tracked
child, captured socket, remapping, retryable close, and reuse handoff;
`mpv-ipc-command.ts` owns the bounded request/reply IPC command the reuse
handoff and its quit use; `mpv-launch-queue.ts` runs MPV launches one at a
time and owns queued-launch cancellation and Stop.
- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns fresh VLC
launches and progress polling; `vlc-reusable-process.ts` owns the tracked
child, RC-port remapping, retryable close, and reuse handoff, while
+22 -2
View File
@@ -520,6 +520,8 @@ Audio tracks are discovered from MPV's `track-list` property. The selected track
Subtitle tracks mirror the audio-track contract: same `track-list` source, same parsing pipeline, but selected through MPV's `sid` property. A `trackId` of `-1` from the renderer is interpreted as "disable subtitles" and translated to `sid=no` at the addon boundary. Playback speed is observed and set through MPV's `speed` property, clamped at the addon to `[0.25, 4.0]`. Aspect override uses MPV's `video-aspect-override` property as a passthrough string ("no", "16:9", "4:3", "21:9", "2.35:1"). All four properties (`sid`, `speed`, `video-aspect-override`, plus `aid`) are observed at session init so renderer state stays in sync with the native side without needing extra round-trips.
File chapters come from MPV's `chapter-list` property, observed as a node next to `track-list` by the macOS and Windows addons and the frame-copy helper, and cleared on every `START_FILE` so a replacement file never shows the previous one's chapters. Each snapshot carries `chapters: [{ timeSeconds, title? }]` (the helper always emits the key, because the adapter merges snapshot events field by field); `EmbeddedMpvNativeService` validates it with `normalizeEmbeddedMpvChapters` into `EmbeddedMpvSession.chapters`, and an older binary without the field yields `[]`. The Linux `mpv --wid` poller does not read chapters. The renderer maps them to timeline segments — see "Timeline segments" in the [player controls contract](player-controls-contract.md).
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. Linux intentionally does not export libmpv-only optional controls while it uses the process-isolated `mpv --wid` backend.
Linux audio-track discovery works differently from macOS/Windows because the hand-rolled JSON IPC reply parser only understands scalar `data` values: the poll loop reads `track-list/count` every tick and walks the scalar `track-list/N/{type,id,title,lang,default,forced}` sub-properties only when the count changes. The selected track is reconciled from the scalar `aid` property on every tick (`aid` reads back non-numeric when audio is disabled, which maps to "no selection"). Track switching still goes through `set_property aid` over the same socket.
@@ -634,6 +636,13 @@ button disabled state at current-season boundaries.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
`ended` alone does not prove the episode is over: mpv reports a connection the
server closed (and ffmpeg could not reopen) as EOF too, once its cache runs
dry. `EmbeddedMpvPlayerComponent` therefore emits `playbackEnded` only for an
`ended` session that carries no `reconnect`; while the main process reloads a
VOD that ended well before its duration (see Network Auto-Reconnect), the
episode continues instead of autoplay skipping its rest (issue #1902).
## Session Options (Extra libmpv Options)
`Settings > Playback > Extra embedded MPV options` is a free-form textarea,
@@ -699,8 +708,9 @@ included.
`EmbeddedMpvReconnectCoordinator`
(`apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts`) reloads
the last user-requested playback when a session reports a stream loss: an
`error` status, or `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses). It is driven by
`error` status, `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses), or `ended` on VOD well
before the known duration. It is driven by
`refreshSession()` status transitions, so it covers every engine, and it is
captured per session from `Settings.embeddedMpvAutoReconnect` (default on;
mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
@@ -714,6 +724,16 @@ mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
the IPC socket so a stream that ended reports `ended` rather than the
`paused` that keep-open would otherwise look like, and an mpv process that
exits abnormally reports `error`.
- A VOD `ended` counts only when more than `max(10 s, 1 % of the duration)`
remained (`isEmbeddedMpvEarlyEof`): the margin absorbs container rounding
and the whole-second snapshot values, and with no known duration an EOF is
always the end. A reload the engine accepted records where it resumed; an
early EOF before playback gets 5 s past that point (and with no user seek
in between) ended where the file does (a duration mpv estimated from the
bitrate can overshoot) and is left as the end, so autoplay still continues
after one attempt instead of a reload loop. A refused reload records
nothing, so the original EOF keeps its backoff.
With the setting off, an early EOF stays a plain `ended`.
- Backoff is 2 s, 4 s, 8 s, 16 s, 30 s, 30 s, at most six attempts per
outage. The budget resets only after 30 s of uninterrupted `playing`, so a
stream that flaps every few seconds runs out of attempts instead of being
+1 -1
View File
@@ -210,7 +210,7 @@ user who leaves that screen open pays them indefinitely, minimized included.
| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up |
| --- | --- | --- | --- | --- | --- | --- |
| [mpv-session.service.ts:140–141](../../apps/electron-backend/src/app/events/mpv-session.service.ts) | External MPV position poll | 2 s delay, then 5 s | Two IPC-socket round-trips and one `playback-position-update` | Yes while playing. The early-exit leak found here (an unstored start-delay handle leaving an orphaned 5 s poll) is **resolved by #1720**. Still open: in reuse mode the poll keeps querying an idle MPV (no IPC while `time-pos` is null). | Static | Early-exit leak: done (#1720). Reuse mode: needs an idle signal from MPV before the poll can stop; not worth its own thread at two local socket calls per 5 s. |
| [mpv-position-polling.ts](../../apps/electron-backend/src/app/events/mpv-position-polling.ts) | External MPV position poll | 2 s delay, then 5 s | Two IPC-socket round-trips and one `playback-position-update` | Yes while playing. The early-exit leak found here (an unstored start-delay handle leaving an orphaned 5 s poll) is **resolved by #1720**. Still open: in reuse mode the poll keeps querying an idle MPV (no IPC while `time-pos` is null). | Static | Early-exit leak: done (#1720). Reuse mode: needs an idle signal from MPV before the poll can stop; not worth its own thread at two local socket calls per 5 s. |
| [vlc-session.service.ts:81–82](../../apps/electron-backend/src/app/events/vlc-session.service.ts) | External VLC position poll | 1.5 s delay, then 2 s | Up to three localhost TCP connections, then IPC | Yes while playing. The early-exit leak (orphaned 2 s poll) is **resolved by #1720**. | Static | Done (#1720). |
| [embedded-mpv-native.service.ts:1068](../../apps/electron-backend/src/app/services/embedded-mpv-native.service.ts) | Embedded MPV session snapshot poll | 500 ms while a session exists | Native snapshot, diff, and IPC only on change | Yes during playback. It is cleared when the last session closes. | Static | None |
| [embedded-mpv-session-controller.ts:197](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts) | Renderer-side bounds poll for a native-view embedded MPV session: compares `getBoundingClientRect()` with the last synced bounds | 500 ms while a native-view session is open (skipped for frame-copy) | One layout read outside the Angular zone; on drift a rAF and a `setEmbeddedMpvBounds` IPC | Yes during playback: a position-only layout shift would otherwise leave the native view misplaced | Static | Cover with the throttling thread: it kept its rate while minimized under `backgroundThrottling: false`. |
+338 -19
View File
@@ -62,9 +62,11 @@ in `apps/web/src/m3-theme.scss`):
- `--app-shell-bg` / `--app-rail-bg` / `--app-header-bg` / `--app-content-bg`
- `--app-widget-bg` / `--app-widget-header-bg` — panels and popovers
- `--app-card-hover-bg` — raised or hovered rows
- `--app-focus-ring` — the keyboard focus ring (see Keyboard Focus)
- `--app-widget-border` / `--app-rail-border` — hairlines
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
- `--app-success-color` — done/watched status (episode watched toggle)
Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the
@@ -86,6 +88,20 @@ tokens, so the retired `--mdc-*` names compile but do nothing;
a spec loads as raw CSS cannot use Sass modules; it declares the `--mat-*`
token directly and says why.
The app theme is the `.dark-theme` class that `SettingsService` puts on
`<body>`; a "System" choice resolves the OS preference there and nowhere
else. Component styles never use a `prefers-color-scheme` query, which
follows the OS: with the app set to dark on a light OS it paints light colours
into the dark theme. Read `--app-*` tokens, which switch with the class, or
use `:host-context(.dark-theme)` for a rule that only one theme needs. The
class is on `<body>`, not the root, so `:root:not(.dark-theme)` always
matches. On a surface, a hairline or thin fill written as white `rgba()`
vanishes on light backgrounds: mix `--app-on-surface` at the same alpha, and
use `--app-widget-bg` for a card. Every `var(--name)` read needs a
declaration somewhere, or it silently falls back;
`pnpm run styles:theme-references:validate` (CI) rejects both undeclared
reads and colour-scheme queries.
Existing hard-coded layout and selection colors are migration debt, not
patterns to copy.
@@ -151,6 +167,45 @@ Use this pattern for:
Do not add extra badges, left rails, or second selection systems unless there is a strong reason.
## Single-Choice Menus And Status Chips
A `mat-menu` that picks one value, such as a sort order or a rating
threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
`@iptvnator/ui/components`:
```html
<button mat-menu-item [appMenuItemRadio]="mode() === option.mode">
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.labelKey | translate }}</span>
</button>
```
- The row becomes a `menuitemradio` whose `aria-checked` follows the bound
state, so E2E tests select it with `getByRole('menuitemradio')`.
- Every row renders the check slot as its first icon. Only the checked row
shows it. Material projects every `<mat-icon>` of a menu item, and of a
`mat-button`, ahead of the label. A check rendered only on the chosen row
therefore shifted that row's label. In a button, put a trailing icon after
the label with `iconPositionEnd`.
- A semantic icon, if any, comes after the check slot.
- A menu with several groups, such as the catalog refine menu, wraps each
group in `role="group"`. The group is named by its section title, and
`role="separator"` sits between groups.
A chip that shows the active state next to its menu trigger, such as the
catalog sort chip, shows its icon and a short value at every width, for
example "Newest" or "9.0+". Do not swap between a full and a compact label
with container queries: long translations still cut off the full label.
Screen readers must still get the full text, such as "Sort: Date Added
(Latest First)":
- A chip that is a button carries it in `aria-label`.
- A plain `div` chip cannot be named, and screen readers may ignore its
`aria-label`. Put the full text in a `.visually-hidden` span and mark the
short label `aria-hidden="true"`.
## Detail Views
VOD and series detail screens share `app-portal-detail-shell` and
@@ -158,8 +213,11 @@ VOD and series detail screens share `app-portal-detail-shell` and
kind label ("Movie · playlist") → title → chips → description (three lines,
"More") → resume bar → action row → credits, with the poster bottom-aligned
on the left and the backdrop filling the hero behind a two-layer scrim built
from `--app-content-bg`. The hero keeps `min(480px, 60vh)` of stage for a
16:9 backdrop. Without one, or when the provider sends the poster as the
from `--app-content-bg`. The hero keeps `min(520px, 60vh)` of stage for a
16:9 backdrop. The shell gives it a 140px `--hero-tail` and pulls the first
section up over it, so the artwork continues under that section's heading and
the vertical scrim ends on the exact page surface there, never as a band at
the hero's edge. Without one, or when the provider sends the poster as the
backdrop, the hero is compact (`hero--compact`, sized by its content) over
the blurred poster. The layout is decided once per title, so a backdrop that
TMDB enrichment adds a moment later fills the compact hero instead of
@@ -177,7 +235,10 @@ keys, Escape, hosts the alternative-sources panel), `app-detail-credits`
`app-detail-rail`/`app-similar-rail` (hidden scrollbar, prev/next arrows,
title + year) and `TrailerDialogService`. The dashboard hero reuses the same
light primary (`light-primary-button` in `libs/ui/styles/_detail-view-actions.scss`)
and chip. Series titles drop their season marker (`splitSeasonSuffix`) into a
and chip, and the same kind of tail: its artwork runs 160px under the first
rail, whose heading keeps a trace of it, and reaches the page colour before
that rail's cards (their scroll-edge fades are drawn in the page colour), with
no tail in the narrow layout. Series titles drop their season marker (`splitSeasonSuffix`) into a
"Season N" chip. Rows a provider cannot serve are left out of the menu, never
disabled. The page-level Sass mixin (`libs/ui/styles/_detail-view.scss`)
only carries the page shell, meta items and the episodes section.
@@ -195,7 +256,10 @@ 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
floats over a scroll owner. A page whose Back is history Back calls
`WorkspaceBackNavigationService.back()` with its parent route rather than
`Location.back()`, so Back still leads somewhere when the page opened the
session. 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
@@ -209,6 +273,69 @@ directive-generated `.resize-handle` sets this centrally in `resizable.scss`.
The shared live-layout sidebar reserves 8 px at its right edge so the inward
half of the 12 px resize handle cannot cover the channel scrollbar.
## Resizable Sidebar Widths
Workspace context panels and Favorites share one `sidebar-width` key (its
legacy aliases map onto it) but have different limits. `ResizableDirective`
renders the stored width clamped to the host's own limits and never writes
that clamped value back: only a drag that changes the width persists, so a
narrow sidebar such as Settings cannot shrink the width a wider one saved.
A drag starts from the host's computed CSS `width`, the box that `style.width`
sizes, not from `offsetWidth`. Hosts may be `content-box` (the workspace
context panels and the Favorites sidebar) or `border-box` (live-layout and
groups rails); starting from the border box widened a padded `content-box`
host by its padding and border on the first move.
## Keyboard Focus
- **Always a ring for the keyboard, none for the pointer.**
`apps/web/src/styles.scss` removes the outline only under
`:focus:not(:focus-visible)` and draws a fallback ring on every other
`:focus-visible` element: 2px of `--app-focus-ring` at a 2px offset, from
`focus-ring-declarations` in `libs/ui/styles/_focus-ring.scss`. Both rules
sit in `:where()`, so any component rule that styles focus wins. Never
bring back a global `outline: none`: `pnpm run styles:focus-visible:validate`
(CI) rejects one on an unscoped selector, and a missing fallback.
- **Token.** `--app-focus-ring` is declared per theme in `m3-theme.scss`,
deeper than the selection blue in light, so it keeps 3:1 on every app
surface and selection tint; `apps/web/src/m3-theme.spec.ts` measures it.
- **A ring of your own.** A component that needs the ring elsewhere or at
another offset includes the mixin (`$offset`); a stylesheet a spec loads
as raw CSS uses `var(--app-focus-ring)`. The colour is always the token,
for every focus indicator: `tools/nx/check-focus-ring-colour.mjs`, part of
the same validate script, reads a focus rule's outline, its unblurred ring
or line shadows and its border colours, and rejects any colour other than
the token (also inside `color-mix()`), the player's `--pc-*` palette or a
neutral boundary, except the few exceptions it lists with their reasons. Draw it on
the element that owns `overflow: hidden` (the cards' `card-focus-ring`),
or inside the element (a negative offset) when a clipping parent would cut
it. When an ancestor or a box shadow draws the ring, set `outline: none` on
the focused element so it never shows two. Never use `outline` as a
decorative border on something focusable: any outline outranks the
fallback, so the element loses its ring (use `border` or a spread
`box-shadow`, as the selected season tab does).
- **Removing the outline.** A component rule that sets `outline: none` also
outranks the fallback, so it must show focus another way: a search field's
wrapper takes a token border on `:focus-within`, optionally with a soft
token halo (the command palette underlines its row instead), a ring drawn
on another element, or a highlight on an item inside an arrow-key
composite.
- **Material.** Buttons, switches, button toggles and checkboxes take the
ring over their 12% focus state layer (global rules in `styles.scss`).
Menu items and select options keep Material's highlight, since arrow keys,
not Tab, move within them.
- **Over video** the ring is the player's text colour, not the theme's:
the player controls, vendor chrome, the fullscreen channel and episode
panels and the Up Next rail include `focus-ring-token` from
`_player-palette.scss`.
- **Check.** `keyboard-focus-ring.e2e.ts` (Electron) tabs through the detail
actions and season tabs, a catalog grid with its refinement chips, the
Sources list, the live EPG list, Settings and the command palette in both
themes. Each stop must draw exactly one ring in the token's colour, uncut
by an ancestor's `overflow: hidden`, at 3:1 where it sits on a flat
colour, and a click must draw none.
## Keyboard Scrolling and Channel Focus
`ChannelScrollFocusDirective` belongs on the actual channel scroll owner,
@@ -300,10 +427,7 @@ Movie and series covers render in three surfaces: the catalog grid
recent card (`app-content-card`, same lib) and the dashboard rails. All of
them size from the `--cover-grid-min-width` / `--cover-rail-width` /
`--cover-gap` tokens that `Settings.coverSize` writes onto `<html>` as
`data-cover-size` (`apps/web/src/_cover-size.scss`). The same file carries
`--season-cover-width` (96 / 120 / 144px) for the season cover beside the
season tabs on series detail pages; medium equals the About block's 120px
poster so browse and watch share one secondary-poster size.
`data-cover-size` (`apps/web/src/_cover-size.scss`).
### Posters-only wall
@@ -382,6 +506,18 @@ remain local when the meaning is explicit.
layouts keep the full EPG-only panel.
- Keep the EPG content mounted while collapsed so current-program state can
continue updating.
- The ribbon renders only the blocks, ticks and day dividers within half a
viewport of the visible range (`TimelineWindowController` in
`epg-timeline-window.controller.ts`); the track keeps the full schedule's
width, so positions, the scrollbar and scroll-to-now are unchanged. A
channel's full schedule is often a few hundred programmes, and rendering
them all competed with the stream's first frame (J3 in
[performance journeys](performance-journeys.md#j3-playback-start-playback-to-the-first-frame)).
Tests and features must not assume an off-screen programme is in the DOM:
scroll the ribbon to it first. Keyboard access must keep working: focusing
a block scrolls it into view, which renders its neighbours before the next
Tab (`epg-timeline-interaction.e2e.ts` walks past the rendered range both
ways).
### Collapsible Live Sidebar
@@ -570,7 +706,7 @@ A title's watch progress (its resume share, `progressPercent`) has exactly one
colour per context, and never a literal of its own:
- **App chrome** — dashboard rail cards and the hero, catalog grids and season
episodes (`app-progress-capsule`, and the season list rows' own fill):
episodes (`app-progress-capsule`, and the episode items' own 3px bar):
`--app-progress-color`, declared per theme in `apps/web/src/m3-theme.scss`
as that theme's `--app-selection-color`, and declared again inside
`.dark-theme` because a derived custom property resolves where it is
@@ -631,6 +767,30 @@ mirrors the row/card geometry it precedes (see Channel List Item and Cover
Grids). The unified Favorites/Recent page gates this on `isLoading`, set only
while its item list is empty.
A skeleton takes its boxes from the code that draws the content, not from
copied values: the same Sass mixins for every box that sets a height (header
padding and minimum height, row and card size, track bleed, gaps), and for
text a line box of the real font size and line height with a shorter shimmer
bar inside it. Copied values drift: the M3U channel list moved down by 19 px
when its playlist loaded, and the dashboard rail skeletons were 2:3 posters
in front of 84 px channel cards.
- The channel list views and `app-channel-list-loading-state` share
`libs/ui/components/src/lib/styles/_channel-list-layout.scss`. The loading
state takes the view and hides its headers where the loaded view does
(`showHeader`, compact lists).
- The dashboard rails and `lib-dashboard-rail-skeleton` share
`libs/workspace/dashboard/feature/src/lib/rails/_dashboard-rail-geometry.scss`.
A skeleton takes the `layout` and `aspectRatio` of the rail it precedes:
channel cards for live favorites, 16:9 cards for sources, posters with a
title and a meta line otherwise.
- `apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts` holds the IPC
channel that answers each loading state, compares the skeleton's header,
first-row and rail boxes with the content's to 0.5 px at 1280×700, and keeps
the dashboard's layout-shift score from skeleton to content at or under
0.01. A whole subtree swapped for new nodes does not count as a layout
shift, so the positions are compared directly.
### Pages of independently loading blocks: delayed skeletons
The dashboard renders each rail as soon as its own data arrives, and several
@@ -656,6 +816,18 @@ page down. For the same reason it stays until every source that can fill it
has loaded, not only the first one. Use the same rules for any page that
stacks independently loading blocks.
Skeleton blocks use one shimmer, `libs/ui/styles/_skeleton-shimmer.scss`: a
fill mixed from `--app-on-surface` (8%, with a 16% highlight), so it reads on
both themes. The dashboard rails used to sweep between `--app-widget-bg` and
`--app-widget-header-bg`, 1.04:1 apart on the light theme.
The movie and series detail hero has an immediate skeleton too, built from
the shared shimmer at the loaded hero's geometry: stage height, bottom-aligned eyebrow, title,
one row of chips, two description lines, the action row and two credit lines.
The Xtream series page adds an episodes section skeleton under it, and the
Stalker series hero holds the Play button's place
(`app-detail-action-skeleton`) while its seasons load.
### Reload with content on screen: non-destructive indicator
A reload of a list that is already rendered (the collection page's
@@ -706,17 +878,55 @@ If the label is too long for the rail, shorten the label key instead of shrinkin
The action row never wraps on a desktop window: the primary, the Trailer
button, then the 44px icon buttons and the "…" menu. Season and series
actions (mark watched, download season, reset progress) live in that menu
and drive the season container's presenters; the container's header is
"Episodes" with the season pills and the episode count, plus the grid/list
toggle. The checked toggle uses `--app-selection-surface` and
`--app-selection-color`; hover uses the app's neutral surface treatment.
and drive the season container's presenters.
Episode cards are flat: a 16:9 thumbnail (with a light hairline so its edge
survives the light theme) and a 3px watched bar at its bottom edge, "N. Title", the plot clamped to two lines and a "42 min ·
18m left / watched" line. List rows keep a subtle neutral fill. The selected
season's cover and synopsis sit in a compact strip under the header only
when present. Keep these treatments in the shared season components so
Xtream and Stalker share the same behavior.
Every section below the hero (Episodes, Cast & crew, Similar) uses
`app-detail-section-header`: an 18px/600 title, a muted 12.5px tabular
counter, a lead slot (the season pills or dropdown, a 30px pill) and an end
slot (the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px
between a header and its content and 56px after the last one. The Episodes
counter reads "N episodes · M watched". The list/grid switch is a segmented
track whose checked segment is a neutral raised fill with heading ink; the
list is the default view.
Episodes render through `app-episode-item` as a flat list row or grid card,
never a bordered box. A row is a number column, a 168px 16:9 thumbnail (with
a light hairline border so its edge survives the light theme), the title with
a "46 min · 12 Jan" meta line (time left once started, "Resume at 12:34" when
no runtime is known) and the plot clamped to
two lines at 74ch, and a 112px action slot. Rows are separated by a hairline
that gives way around a hovered, focused or highlighted row. Watched episodes
carry a check badge on the thumbnail and muted text, and never a bar; a
started one gets a 3px bar and the time left. Mark watched, download and "…"
(Episode details, Play from beginning) are always laid out and only fade in on
`:hover`/`:focus-within`, so nothing shifts. The whole item is one stretched
button: Tab focuses it and Enter plays. The playing episode, else the most
recently watched unfinished one, is highlighted. Without distinct stills the
thumbnails show a dimmed fallback, not a column of bright posters. The season
picker never shows a season poster: chips up to four seasons, a menu button
from five whose rows read "Season N" and "N episodes · M watched" (the second
part left out at zero, and no count at all for a lazy Stalker VOD season the
portal has not answered yet — `seasonLoadStates`). The selected season's
synopsis sits under the header,
offset 52px to the number column and 24px above the list, only when present;
one that repeats the series description (or is cut short from it) is dropped,
and without one no row renders.
A season whose final episode data has no plot and no usable still (a still
equal to the series poster or the season cover counts as none) renders bare:
44px rows of number, title, meta and an inline check or 72px bar, the same
action slot, and no grid toggle. Only some stills or plots missing keeps full
rows (dimmed tile, line left out). While the provider list is outstanding,
and while a TMDB lookup that could still fill a season that would otherwise
render bare is outstanding, the episodes are skeleton rows at the full row's
geometry (`app-episode-skeleton`); a season the provider already describes
renders at once and the metadata lands in place. The state is decided from
data (`episode-meta-state.util.ts`); the metadata wait is only capped
(`EPISODE_METADATA_WAIT_MS`, 4s) because TMDB requests carry no timeout, and
it never applies to a season without episodes, which no host enriches. At a
pane width of 480px or less the thumbnail shrinks to 96px and the actions
take their own row under the text. Keep these treatments in the shared season
components so Xtream and Stalker share the same behavior.
Browser regression coverage measures the composited neutral edges and selected
toggle fill, in addition to capturing light/dark grid and list screenshots.
@@ -727,6 +937,37 @@ flat ancestor-color compositing is only appropriate outside that layered hero.
## Settings Surfaces
Settings use the same system but are flatter than content-heavy views.
Reference: `apps/web/src/app/settings/settings.component.scss` and the
per-theme tokens in `apps/web/src/_settings-theme.scss`.
### Structure
- The sidebar is a 20px "Settings" title over four groups (App, Library,
Devices, Data), with About pinned to the footer beside the installed
version and an update badge. The active item is a soft selection fill with
tinted text and no border, so the list never shifts. Back lives in the
workspace header and Esc runs it. Section pages are defined once in
`SETTINGS_SECTION_DEFINITIONS` (`libs/workspace/shell/util`).
- Each page is a title (26px) and one-line subtitle, then sections: a 14px
heading, an optional description and action buttons, and a card.
- Rows (`.setting-item`) are a label column (13.5px title, description
capped at 56ch) and one right-aligned control column. Put every control in
that column, including segmented controls; the page is a size container
and rows stack under 600px of pane width. Keep `data-setting-id` on every row: settings search and the
command palette reveal rows by it, and the registry spec checks the index
against the templates.
- Controls: `.settings-select` is a 220px outlined select without a floating
label (the row title names it; pass `aria-labelledby`); boolean settings
are `mat-slide-toggle` switches; segmented choices use `.theme-switcher`
as a pill; `.settings-chip` marks rows that apply after a restart or are
experimental; `.settings-callout` (`--info`, `--warn`) sits under the row
it explains inside `.setting-item__sub` and only while that choice is made.
- Destructive actions keep the confirm dialog from the Dialogs section; the
row itself uses `.app-destructive-button` on a stroked button.
- The save bar floats over the content column while the form is dirty with
the change count, Discard and Save (Cmd/Ctrl+S); the sidebar marks pages
with staged edits. A saved change that waits for a restart shows a sticky
notice at the top of the page.
### Light Theme
@@ -782,6 +1023,13 @@ Settings use the same system but are flatter than content-heavy views.
is "Cancel …", pass `cancelLabel` "Close" so the two buttons do not read
alike. `theme-tokens.e2e.ts` checks the label and the error fill in both
themes.
- **One control per action.** A page offers one control for a destructive
action. Clearing a favorites or recently viewed tab goes through
`createClearCollectionAction` (`unified-collection-clear-action.ts` in
`portal/shared/ui`), from the collection page's button and from the command
palette alike; the shell
header does not repeat it. `recent.e2e.ts` checks the single control and
the single confirmation on M3U, Xtream and Stalker recently viewed pages.
## Forms
@@ -805,6 +1053,28 @@ vocabulary, so a field reads the same wherever it appears:
outcomes that close the dialog. `add-source-forms.e2e.ts` in `web-e2e`
covers the shared labels, the toggle and the URL errors.
## Translated Text
The UI language can change while views stay mounted: Settings → Language
switches it at runtime, and on a cold start the translation file can arrive
after the first render. Text on screen must follow the current language:
- Keep translation keys, not translated strings, in stores, services and
data models. When the app adds an entry to provider data, give it a
`labelKey` that the view renders with `| translate` and leave its provider
name empty. The Stalker every-item genre is an example.
- Prefer `| translate` in the template. A `computed` that calls
`TranslateService.instant` must also read `injectTranslationTick()` from
`@iptvnator/services`. It ticks on the three events the translate pipe
listens to: `onLangChange`, `onDefaultLangChange` and
`onTranslationChange`. `onLangChange` alone is not enough. A start-up
without a saved language never calls `use()`, so only
`onDefaultLangChange` fires when the dictionary lands, and the computed
would keep showing raw keys. Without any tick, the computed keeps the
language it first ran in. State factories such as `createSeriesHeroState`
and `createVodDetailsHeroState` take the tick as `language`.
- `instant` is fine at event time, for snackbar, dialog and confirm text.
## Source Type Icons
`SOURCE_TYPE_ICONS` in `@iptvnator/shared/interfaces` is the only source of
@@ -814,6 +1084,35 @@ text) and `subject` (pasted text in the add flow). Use
`getPlaylistSourceIcon()` for a stored playlist. An icon never stands for two
providers, and the Dashboard rail icon is never a provider icon.
## Icon Names
`<mat-icon>` draws its text through the ligatures of the Material Icons font
from `material-design-icons-iconfont`, imported in `apps/web/src/styles.scss`.
The font renders a name it does not know as plain text, so pick names from its
`dist/fonts/MaterialIcons-Regular.json` codepoints file: newer Material Symbols
names such as `file_off` or `arrow_outward` are not in it.
`pnpm run styles:icon-ligatures:validate` (CI) checks static `<mat-icon>` text,
the string results of its bindings, `icon` / `*Icon` inputs, and TypeScript
values named `icon`, `*Icon`, `*_ICON`, `*Icons` or `*_ICONS`. Name a member
that feeds an icon that way, so the check can see its values.
## Icon-Only Buttons
A `<button>` whose only content is a `<mat-icon>` needs an accessible name:
the ligature text is hidden from assistive technology, and a `matTooltip`
only adds a description that appears on hover or focus. Give it a translated
`aria-label`, usually `[attr.aria-label]` bound to the same key as its
tooltip, or `aria-labelledby`. A toggle keeps one label and reports its state
with `aria-pressed` (the channel row's star is "Favorite", pressed or not),
as the password visibility toggle does; its tooltip may still name the next
action. `pnpm run a11y:icon-buttons:validate` (CI) checks `.html` and inline
templates, looking through control flow, `ng-container` and spinners; content
it cannot see into (`ng-content`, another component, an image) counts as
named, and text under `aria-hidden="true"` never does.
`icon-button-names.e2e.ts` in `web-e2e` runs axe's `button-name` rule
on a channel list, the channel details dialog, Sources, the playlist info
dialog and an Xtream search.
## Phone Layout
`640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than
@@ -988,6 +1287,10 @@ Avoid these:
- making entire panes scroll when only the list should scroll
- using dark translucent fills unchanged in light theme
- solving cramped sidebars with smaller fonts instead of shorter labels
- rounding a scroller's corners (`border-radius` with `overflow`) when it holds
composited effects such as `backdrop-filter`: each effect gets a clip mask
the size of the scroller; paint the corner instead (see the content surface
corner in the [workspace shell contract](workspace-shell.md#content-surface-corner))
## Definition Of Done For UI Changes
@@ -999,6 +1302,22 @@ A visual change is not done until:
4. Scroll behavior is correct.
5. The result was checked in the running app for layout-sensitive work.
### Source row keyboard access
A Sources row (`app-playlist-item`) opens on a click anywhere on it, and it
also holds a drag handle, a health indicator and actions. Like
`app-content-card`, it opens from the keyboard through a dedicated element
that is a sibling of those controls: the title and meta block
`.playlist-content` (`role="button"`, `tabindex="0"`, named by the source
title, `aria-current` on the active source, `aria-disabled` while busy).
Enter and Space open the source (Space prevents the page scroll), and Tab
reaches each row before its actions. Never put `role="button"` or key
handlers on the whole row: the actions would sit inside a button, and their
Enter and Space would bubble into it. The row draws the ring for that
element, inset (`$offset: -2px`), because the list's scroller would cut a
ring drawn outside the full-width row. `sources-keyboard.e2e.ts` (Electron)
tabs to a row, checks its ring and opens the source with Enter.
### Network source indicators (Electron)
The switcher and source rows use `SourceHealthIndicatorComponent`: green means
+17 -1
View File
@@ -605,6 +605,9 @@ channel-list-container/
`setFavorites` action: doing so reads and rewrites the complete M3U payload
again just to store favorites that already came from SQLite.
- `ChannelListContainerComponent` now renders a dedicated skeleton state while `channelsLoading` is true.
Its headers and first row share the views' boxes
(`libs/ui/components/src/lib/styles/_channel-list-layout.scss`), so the list
does not move when the channels land.
- `ChannelListContainerComponent` no longer clears `channels` on destroy; route/session code is the single owner of shared list lifecycle during navigation.
- The dedicated `/workspace/playlists/:id/favorites` and `/workspace/playlists/:id/recent` collection routes do not drive the shared sidebar channel list; they default to the `playlist` scope so rail links always open the current playlist view, not the last persisted global scope.
- M3U favorites and recent collection rows preserve their full `Channel` payload on unified live items so the shared live list can open the read-only channel details context menu without reconstructing partial channel data.
@@ -630,6 +633,19 @@ channel-list-container/
- `PlaylistsService.updatePlaylistMeta()` persists `hiddenGroupTitles` into the
stored playlist payload, and M3U refresh/update flows preserve the existing
value when refreshed playlist data omits the field.
- Electron loads the playlist store from `DB_GET_APP_PLAYLIST_METAS`, which
reads columns only, so `hiddenGroupTitles` is absent from the store after a
restart. `M3uWorkspaceRouteSession` copies it from the full playlist read into
the store (`updatePlaylistMeta` with `persist: false`) before publishing the
channels, unless the store value changed while that read was queued (#1894).
A restored or deep-linked route can finish that read before the startup
inventory, which waits for settings and EPG reconciliation, publishes the
playlist; the hydration then waits for the entity, since `updateOne` drops an
update for a missing one. The startup screen keeps the router outlet hidden
until that inventory is published, and the hydration runs in the same store
update, so the rail never renders a hidden group. A save's meta write joins
the per-playlist queue that `getPlaylist()` reads through, so a reopened
route reads the saved value.
- The groups route keeps the manage action reachable even when every group is
hidden by separating "playlist has no groups" from "no visible/search-matching
groups" empty states.
@@ -1513,7 +1529,7 @@ does not change the saved player preference. Clear DASH needs this routing too.
engine: lazy `import('shaka-player')` on first use (the module is a separate
lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation
queue + generation guard against channel-switch races. The DOM-free Shaka
`5.2.4` public-error boundary lives in `libs/playback/util`; it version-locks
`5.2.12` public-error boundary lives in `libs/playback/util`; it version-locks
its allowlisted
severity/category/code values, emits only structured sanitized
`PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events,
+583 -47
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 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.
runtime counters of the launch benchmark below. J2, J3 and J4 are
instrumented by their own specs (below). Each thread names its journey and
counter in the PR description.
## Running the journeys
@@ -89,7 +89,14 @@ main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
show a blank window and freeze its `ready-to-show` counter before its own
document exists. Electron emits the event again for the real document's
first paint because the window is still hidden, which is the moment
production sees. The gate also keeps the listener the app registers with
production sees. The app also shows its window at the main frame's
`did-finish-load` when that comes first (see
[When the window is shown](#when-the-window-is-shown)), so the gate keeps
the `did-finish-load` listeners registered before the gated load (the
app's) away from the `about:blank` load as well
(`evidence.rendererGateDidFinishLoadHeldOnBlank`, 1 per launch); Electron's
own listener that resolves `loadURL('about:blank')` is registered later
and still runs. The gate also keeps the listener the app registers with
`ipcMain.handle('performance:read-counters')`, so the test can call it from
the main process.
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
@@ -168,8 +175,8 @@ closed or closed before the cutoff. J2's probe has no settle window
most 20): the time after the first card, the value and, for each source the
browser attributes the shift to, the node (`tag.class[data-test-id]`; a
component host such as `lib-dashboard-rail` takes its first child's test id)
and its vertical move. A late shift can therefore be traced to its component
from the summary alone.
and its move (`deltaX`, `deltaY`, `deltaWidth`, `deltaHeight`). A late shift
can therefore be traced to its component from the summary alone.
First local measurement (macOS, 2026-09-29, `master` with #1738): all
windows closed on `quiet`, `renderer.layoutShiftScore` stayed 0, and
@@ -199,6 +206,12 @@ the playlist inventory has loaded. After the fix (macOS, 2026-09-30): both
counters were 0 in all 12 iterations of two runs, every window closed on
`quiet` and `lateShifts` was empty.
On the runner the flicker was only visible on J1's fast path: on the slow
path the window got its first frame only after the hero had already
changed, so the settle window opened after the shifts (see
[When the window is shown](#when-the-window-is-shown)). With both fixes,
all 18 iterations of three runner runs read 0 (`stable: true`).
#### Idle window
After the settle point J1 leaves the dashboard alone for
@@ -292,10 +305,12 @@ registers the `performance:read-counters` IPC handler. Without the flag
nothing is counted, no listener is attached and the handler does not exist;
the preload never exposes the channel. SQL statements are counted only with
`IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement
execution: the launch journey sets both (the flags are built in
`journey-launch-environment.ts`), while J2's launches and the M3U, refresh
and Xtream benchmarks do not set the SQL flag and keep measuring unwrapped
statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes,
execution: the launch journey and J4, which reports
`renderer.sqlStatementsPerSearch`, set both (the flags are built in
`journey-launch-environment.ts`), while J2's and J3's launches and the M3U,
refresh and Xtream benchmarks do not set the SQL flag and keep measuring
unwrapped statements. A harness test fails if any other source sets the SQL
flag. After the renderer probe completes,
`journey-main-counters.ts` calls the handler through `electronApp.evaluate`
and the gate's tap.
@@ -469,6 +484,76 @@ waits for the playlist migrations, the inventory read and
`reconcileEpgSources`. No baseline yet: the counter is promoted only after a
PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3).
### When the window is shown
J1 on the CI runner was bimodal from the first runner measurements (#1717)
until 2026-10-01: 6 of 14 `master` runs between 2026-09-30 and 2026-10-01
mixed two paths. On the slow path the first card came with 18 bridge
calls and 1,018 DOM mutations, about 940 ms after the load event. On the
fast path it came with 15 calls and 559 mutations, 280-500 ms after it.
The race also marked `renderer.ipcSerialDepthToFirstCard` (9 vs 6),
`renderer.cdTicksToFirstCard` (31 vs 21), `renderer.cdTicksIdle30s`,
`main.sqlStatementsBeforeReadyToShow` (119 vs 93) and
`renderer.layoutShiftScoreSettled` as `stable: false`.
The three extra calls (`downloadsGetDefaultFolder` and two
`dbGetGlobalRecentlyAdded`, after `dbGetAllGlobalFavorites`) were not what
the card waited for. They only had time to finish before the card. What
ordered the card was when the hidden window got a frame. In every one of
the 48 iterations of those eight runs (two of them #1782's), `ready-to-show`
came within 180 ms of the load event on the fast path (usually about 15 ms),
and 4-5 ms after the first card on the slow path. The app showed its window only on
`ready-to-show`, and `main.ts` removes the splash in a
`requestAnimationFrame`, which the journey's end condition waits for. On
the slow path the dashboard had rendered and its data had arrived, but the
window was still hidden, no frame came, and the splash stayed.
A minimal Electron 43.3.0 app under Xvfb in a Debian container reproduces
it deterministically. It has the same hidden window, splash and
`requestAnimationFrame` removal, plus a 3.5 MB module script before the
first frame. Its window got no frame for about a second after load, and the
`requestAnimationFrame` and `ready-to-show` both landed at about 1.25 s, in
5 of 5 launches. Without the large script, `ready-to-show` came at load. A
`backgroundColor` alone changed nothing. Showing the window at
`did-finish-load` made the `requestAnimationFrame` run on time in 5 of 5.
#1782's skeleton gates do not touch this ordering: its own run 36917107231
still had one fast iteration among slow ones.
The fix is in the app, so it applies to users and not only to the
journey. `apps/electron-backend/src/app/services/main-window-first-show.ts`
shows the window at `ready-to-show` or the main frame's `did-finish-load`,
whichever comes first. The window's `backgroundColor` is the splash colour,
so showing it before the first paint does not flash. `ready-to-show` still
fires after the early show (on the runner 10-190 ms after load), so
`main.sqlStatementsBeforeReadyToShow` keeps its meaning.
Validation (Principle 3, the same journey on the same runner): three
dispatched runs of the fix (36928706097, 36928716010, 36928725392) and the
run of the commit that added the baselines (36930457538) took the fast path
in all 24 iterations, with 15 calls and 559 mutations each.
| Runs | Slow iterations | `spawnToFirstCardMs.p50` | load → card |
| ----------------------------------------------------------- | --------------- | ------------------------- | ----------------------------- |
| `master` and #1782, 2026-09-30 to 10-01 (8 runs, see above) | 29 of 40 | 1,478-1,613 ms (one 760) | ~940 ms slow, 280-500 ms fast |
| this fix (4 runs) | 0 of 20 | 988, 1,139, 923, 1,205 ms | 360-515 ms |
The eight earlier runs are `master` 36768881838, 36814964563, 36842198653,
36861129953, 36861409057 and 36915979562, and #1782's 36816552353 and
36917107231. The runner's own speed moves `spawnToDidFinishLoadMs.p50` between 430 and
710 ms from run to run, so compare load → card rather than absolute numbers.
The one fast master run (36915979562, P50 760 ms) had a fast runner and four
fast iterations.
The fix first merged (#1788) into #1782's branch after #1782 had already
reached `master`, so it landed again on its own. Measured again on `master`
at bc5a7fcbf, which by then carried the redesigned dashboard hero (#1792):
three dispatched runs (37192092882, 37192097790, 37192103151) took the fast
path in all 18 iterations, with 15 calls and 558 mutations each, 466-528 ms
from load to the first card and `spawnToFirstCardMs.p50` 1,149, 1,122 and
1,170 ms. `master` without the fix was still bimodal then: its last six push
runs before 2738bc28a had 30 of 36 iterations on the slow path (18 calls,
1,031-1,033 mutations, about 940 ms from load to the card).
### Summary schema
```json
@@ -544,9 +629,10 @@ PR that lowers it also lowers `spawnToFirstCardMs` (Principle 3).
numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
`tools/performance/journey-baselines.json`. The summary writer checks only
that every measured iteration reports the same counter names with finite
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.
values, so a new counter needs no schema change. A runtime baseline is added
once its counter is deterministic on the CI runner; the enforced ones and the
reasons for the others are under
[Enforced journey counters](#enforced-journey-counters).
J3 adds the `journeys.playback` entry with the same shape and no schema
version change: `counters` and `wallClock` hold only plain numbers, and its
@@ -555,6 +641,13 @@ iterations carry `evidence.media` (the video element at `playing`) and
a `media` field (`null` for J1 and J2), which the probe's
`schemaVersion` 1 readers ignore.
J4 adds the `journeys.search` entry, again with the same shape. Its
iterations carry `evidence.perKeystroke` (one entry per typed key, see
[J4](#j4-search-type-a-query-until-the-results-settle)), which the CI job
summary prints as a table for the first measured iteration. J4 has its own
renderer probe (`search-journey-probe.ts`, `schemaVersion` 1); the shared
probe is unchanged.
## J2 `open-source`: open a source to a browsable list
`open-source.journey.ts` reuses the J1 profile and process pattern: the
@@ -611,6 +704,11 @@ source's default section, which is VOD (`getPlaylistLink` links to
and the first page is the "All items" grid. The plan's "live category list"
would need a second click and is not measured; the landed section is
recorded under `evidence.firstPage.section`.
The seeded portal is already imported, so the click only reads the saved
catalog and finishes well inside the shell's show delay for the import
overlay ([workspace shell](workspace-shell.md)): a J2 click must not mount
`app-workspace-shell-import-overlay`, and before that delay it flashed for
about two frames between the skeleton and the first page.
**HTTP requests to the mock.** The J2 profile is seeded with the origin of a
loopback proxy in the test process
@@ -635,7 +733,7 @@ strings and stream paths carry credentials and are never stored.
| `renderer.ipcCallsToFirstPage` | Bridge `start` trace events between the start and end sentinels, counted by a second `journey-main-ipc-capture.ts` instance installed with `startSentinelId`. Calls before the start marker are tallied separately (`callsBeforeStart`); a start marker that is missing, repeated or received after the end sentinel fails the iteration. |
| `renderer.domMutationsToFirstPage` | `MutationRecord`s from the click until the terminal batch. Records produced before the click (hover, settling) are taken from the observer at the start and counted under `evidence.settle` instead. |
| `renderer.cdTicksToFirstPage` | `ApplicationRef` ticks from the click until the terminal batch: the counter's running total read in the capture-phase click listener, before the app handles the click, subtracted from its value at the terminal batch (see [Change-detection ticks](#change-detection-ticks)). |
| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`. |
| `renderer.layoutShiftScore` | Sum of all `layout-shift` entries from the click until the post-paint cutoff, rounded to three decimals. Unlike J1 it includes entries with `hadRecentInput === true`: the journey is a response to the click and runs inside the 500 ms input window, so the CLS filter would always read 0. The split is under `evidence.layoutShift`, and `evidence.layoutShift.shifts` lists the first 20 counted shifts (`shiftCount` is the total) with their value, `hadRecentInput`, time since the click and the nodes that moved (`tag.class[data-test-id]` and their `deltaX`, `deltaY`, `deltaWidth` and `deltaHeight`, as J1's late shifts). |
| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the cutoff. The task that dispatches the click began before the event's timestamp and still counts; buffered J1 tasks that ended before the click are dropped. Evidence until it is shown to be stable on the CI runner, as for J1. |
| `main.mockHttpRequestsToSettled` | Requests the proxy received from the click until, after the terminal batch, no new request had arrived for 1 s and none was in flight (a response slower than that, and what it triggers, stays inside the window). The window ends at the ledger position read by that accepted quiet sample; a request arriving after it was never seen in flight, so it goes to `evidence.httpRequestsAfterSettledByRoute` instead of the counter. The ledger is read 1 s after that sample, so that late traffic is actually observed. The window starts at the renderer's click stamp, the same boundary as every other J2 counter, not when Playwright began its actionability checks; the proxy stamps requests with the test process's wall clock, and both processes read the same host clock. Bounding by the terminal would compare the test process's clock with the renderer's, so the count up to the terminal epoch is evidence only (`evidence.httpRequestsToFirstPage`); `evidence.httpRequestsByRoute` names the requests. |
@@ -728,9 +826,10 @@ ignored.
| Counter | Source |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `renderer.ipcCallsToPlaying` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. |
| `renderer.ipcSerialDepthToPlaying` | `computeJourneyIpcSerialDepth` over the capture's timeline between the start and end sentinels, as `renderer.ipcSerialDepthToResults` in J4 (see [Serial IPC depth](#serial-ipc-depth)). `evidence.ipcSerialDepth.chain` and `evidence.ipcTimeline` show the calls; `evidence.ipcTimelineAmbiguousCompletions` counts completions the start marker left ambiguous. |
| `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.cdTicksToPlaying` | `ApplicationRef` ticks from the click until the `playing` event, read like `renderer.cdTicksToFirstPage` in J2 (see [Change-detection ticks](#change-detection-ticks)). Shipped after J3's first measurements; the runner reads 15 to 19, varying within a run. |
| `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. |
@@ -748,10 +847,7 @@ above a sub-millisecond origin difference. `evidence.httpRequestsAfterPlayingByR
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.
No counter is listed under `unavailable`.
`main.sqlStatementsToPlaying` is not measured for the same reason as J2's
SQL counter.
@@ -780,8 +876,244 @@ 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.
mutation and request counts. On the runner `renderer.httpRequestsToPlaying`
and `renderer.layoutShiftScore` are enforced as guards; see
[Enforced journey counters](#enforced-journey-counters).
#1817 (6c8f5028c, 2026-10-07) made the EPG timeline render only the
programmes near the visible range. On the runner
`renderer.domMutationsToPlaying` fell from 6,182 to 731 and
`renderer.longTasks` from 1 to 0 in every measured iteration. The ten
`master` runs from #1817 to 5c7a8a572 read 0 long tasks and either 731 or
734 mutations per run, except one iteration with a fifth bridge call and
748 mutations.
`renderer.ipcSerialDepthToPlaying` came later. One local macOS
`perf:journeys` run on 2026-10-10 read 3 in all six iterations, warm-up
included, with `depthLowerBound` 3, no ambiguous completion and no call in
flight at `playing`. The chain was `getEpgMapping` → `xtreamRequest` →
`updateRemoteControlStatus`; the last is the second of two
`updateRemoteControlStatus` calls in the window (five calls in all). The
runner reads 4: #1862's CI run (38027151670) measured it in all six
iterations, with four calls in the window and the chain `getEpgMapping` →
`xtreamRequest` → `updateRemoteControlStatus` → `setUserAgent`.
## J4 `search`: type a query until the results settle
`search.journey.ts` follows J3: the profile is seeded once through the "Add
playlist" dialogs (`seedLaunchJourneyProfile` with `SEARCH_JOURNEY_SEED`),
every iteration copies it, spawns a fresh process through `runLaunchJourney`
and hands the running app to `measureSearchJourney` in
`src/journeys/search-journey-app.ts`. One warm-up and five measured
iterations. Unlike J2 and J3 the launch runs with the main-process counters
(`mainCounters: true`, so `IPTVNATOR_PERF_CAPTURE` and
`IPTVNATOR_PERF_COUNT_SQL`), because SQL statements are a J4 counter; every
statement then runs through the counting hook.
**Profile.** J1's M3U source plus an Xtream portal ("Journey search portal")
on the mock's existing `large:large` scenario: 60 categories of 200 items,
so 4,000 live channels, 4,000 movies and 4,000 series. No fixture was added
for the journey. The query is `system`, which matches 170 series titles of
that deterministic catalog: more than global search's first page of 100, so
the page is full and more results are available. Six characters take the
title FTS path of `globalSearch`
(`apps/electron-backend/src/app/database/operations/content.operations.ts`);
the M3U arm runs as well, because live content is included, but none of the
four fixture channels matches. Poster artwork points at `picsum.photos` and
is cancelled in the main process as in J3
(`src/performance/journey-external-artwork.ts`, shared by both journeys);
the count is kept as `evidence.externalArtworkCancelled`. The mock is not
put behind the request ledger: global search reads only the local database.
**What typing does.** The header search box
(`app-workspace-shell-header .search-field input[type="search"]`) applies
its term after `SEARCH_INPUT_DEBOUNCE_MS` (300 ms,
`WorkspaceShellSearchSyncService`) and writes it to the URL as `q`. On
`/workspace/search` (`app.routes.ts`, Electron only) `SearchResultsComponent`
runs `executeSearch` as soon as the applied term reaches the store, with no
debounce of its own, which calls the `dbGlobalSearch` bridge method once
with a limit of 101. A newer term supersedes a query still in flight: its
result is dropped by request version.
**Start.** After J1 has ended, the test clicks the rail's **Global search**
link and focuses the header search box (not measured), installs the IPC
capture with a start sentinel, arms the probe
(`src/performance/search-journey-probe.ts`) and waits until the app has
been quiet for 1 s: no DOM mutation, no new or pending bridge call, and an
unchanged `main.sqlStatements` total (30 s timeout, which fails the
iteration). J1's capture is detached. The test then types the query with one
`keyboard.type` call per character on a fixed schedule, 100 ms apart from
the first key (well below the 300 ms debounce, as steady typing would be),
and samples the main process just before each key. The probe's
capture-phase `keydown` listener on `window` stamps every key in the search
box before the app sees it; the first one starts the journey and sends
`cancelSourceProbe('__iptvnator-journey-search-start__')`. The record
rejects an iteration whose DOM mutations, bridge calls or SQL statements
moved between the quiet snapshot and the first key.
**End.** "No DOM mutation for 200 ms after the last keystroke" alone would
end inside the 300 ms of debounce, before any query ran: nothing in the DOM
changes while the term waits. So after the sixth key the probe waits for
the results of the final term to be shown (the path ends with
`/workspace/search`, the URL `q` is the query, no `.loading-state` is
rendered and an `app-content-card` in the results container is visible). The
mutation batch that first meets that condition opens a 200 ms quiet window,
and every later batch restarts it. When the window elapses the journey has
settled: the settled moment is the last mutation batch, the counters stop at
the confirmation, and the probe sends
`cancelSourceProbe('__iptvnator-journey-search-end__')`. Without a settle
within 15 s of the last key the probe marks the iteration invalid
(`settle-timeout`).
The DOM alone cannot tell the final term's results from an earlier term's
that are still shown while the final term debounces, so the main process
also checks. A listener on the renderer-API trace channel stamps every
`dbGlobalSearch` event on arrival, with the term and result length that the
preload's summaries carry. The record requires that the last query started
between the start and end sentinels is for the final term and completed
before the end sentinel (`final-query-not-run`, `final-query-incomplete`).
The final query's start shows the loading state, which keeps the quiet
window closed until its results replace the old ones.
`evidence.finalQuery` keeps its term, result length and duration. The
record also rejects an iteration in which two keydowns were more than
250 ms apart (`typing-cadence`; the gaps are `evidence.keyIntervalsMs`).
At that point a late key on a busy machine could let the 300 ms debounce
apply an intermediate term, which steady typing does not.
A settle that times out fails the iteration with what the probe and the
main process saw: the URL `q`, the input's value, the results view, the
bridge calls, renderer console errors, the SQL totals before each key, and
the traced `dbGlobalSearch` calls with their terms and result lengths.
### Counters
| Counter | Source |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `renderer.ipcCallsPerSearch` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. `evidence.ipcCallsByMethod` names them. |
| `renderer.sqlStatementsPerSearch` | `main.sqlStatements` (main thread and database worker, see [Main-process counters](#main-process-counters)) read in the main process when the start sentinel and the end sentinel arrive (a listener on the trace channel calls the counters handler through the gate, which reads the registry synchronously), so the count covers exactly the IPC capture's window and never work just before the first key or after the settle. The worker reports its count before the response it belongs to, so the statements of a query are counted before its results reach the renderer. The two values are `evidence.sqlAtSentinels`; statements from the end sentinel until 500 ms after the test read the summary are kept as `evidence.sqlStatementsAfterSettled`. |
| `renderer.ipcSerialDepthToResults` | `computeJourneyIpcSerialDepth` over the capture's timeline between the sentinels (see [Serial IPC depth](#serial-ipc-depth)); `evidence.ipcSerialDepth.chain` and `evidence.ipcTimeline` show the calls. |
| `renderer.domMutationsToResults` | `MutationRecord`s from the first keydown until the quiet window was confirmed (by definition none arrive inside it). |
| `renderer.cdTicksToResults` | `ApplicationRef` ticks from the first keydown (read in the capture-phase listener, before the app handles the key) until the confirmation (see [Change-detection ticks](#change-detection-ticks)). |
| `renderer.layoutShiftScore` | All `layout-shift` entries from the first keydown until the confirmation, including `hadRecentInput` ones (typing is input, as in J2 and J3), rounded to three decimals; the split is under `evidence.layoutShift`. |
| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the first keydown to the confirmation. Evidence until shown to be stable on the runner. |
`evidence.perKeystroke` breaks the journey down by key: for each typed
character, what happened from that key until the next one (the last entry:
until the settle). `domMutations` and `cdTicks` are split at the renderer's
keydown stamps. `ipcCalls`, `queryCalls` (`dbGlobalSearch` calls) and
`sqlStatements` are differences of the main-process samples taken just
before each key, so their boundaries sit a few milliseconds before the
renderer's. A search with working debounce shows zeros for the first five
keys and one query after the last; a search that queried on every key from
the second character on would show a `dbGlobalSearch` call and its
statements in each of those entries, even when a later key superseded the
result.
No counter is listed under `unavailable`. HTTP requests are not a J4
counter: global search does not touch the network.
### Wall-clock
| Entry | Derivation |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `firstKeystrokeToFirstResultMs.p50/.p90` | First mutation batch with a visible result card (for any term) minus the first keydown. |
| `lastKeystrokeToSettledMs.p50/.p90` | Settled moment (the last mutation batch before the 200 ms quiet window) minus the last keydown. The quiet window itself is not included. |
Both are taken in the renderer. Both include the 300 ms the term waits in
the shell; the first also includes the 500 ms of typing. Until the single
debounce below, the wait was 650 ms (350 ms in the shell, then 300 ms in the
results component).
### First measurement
Local, macOS, 2026-10-04, three full `perf:journeys` runs plus repeated J4
runs. In the runs where J4 completed (the first and third full runs), every
counter was identical in all ten measured iterations except one:
`renderer.ipcCallsPerSearch` 1 (`dbGlobalSearch`),
`renderer.sqlStatementsPerSearch` 2, `renderer.ipcSerialDepthToResults` 1,
`renderer.domMutationsToResults` 402 (100 cards on the first page),
`renderer.layoutShiftScore` 0 and `renderer.longTasks` 0.
`renderer.cdTicksToResults` read 14 in all five iterations of the third run
and 13, 18, 13, 15, 13 in the first, so check the runner's
`counterStability` before trusting it. P50/P90
`lastKeystrokeToSettledMs` 686/692 ms and `firstKeystrokeToFirstResultMs`
1,178/1,184 ms in the third run (699/720 and 1,193/1,210 in the first).
Per keystroke, the first five keys caused one change-detection tick each
and nothing else: no bridge call, no SQL statement and no DOM mutation.
All of the work followed the sixth key. Search therefore does not run a
query per keystroke. The debounce does dominate the wall clock: about
650 ms of the 686 ms from the last key to settled is the two stacked
debounces (350 ms in the shell, then 300 ms in the results component). The
query itself, from the bridge call to the rendered first page, takes the
remaining 30-40 ms.
### One debounce
The results component's own 300 ms timer is gone: the shell debounce is
now the only one, and global search runs as soon as the shell applies the
term. `SEARCH_INPUT_DEBOUNCE_MS` went from 350 to 300 ms. It has to stay
above the gap between keys of steady typing, or a word typed in one go
queries once per key; 300 ms covers an average typist (about 200-250 ms
between keys) with some margin, while the query and first page take only
30-40 ms of the remaining time. The route sync in the component reads the
store term untracked, so a term the shell applied before it writes `q` is
no longer pushed back to the previous `q` and queried again.
Local, macOS, 2026-10-11, three J4 runs per build (five measured
iterations each), the same machine, master `633fde3e0` against this
change:
| Measurement | Two debounces | One debounce |
| ------------------------------------ | -------------------- | -------------------- |
| `lastKeystrokeToSettledMs` P50 / P90 | 692-695 / 695-697 ms | 336-337 / 337-339 ms |
| `firstKeystrokeToFirstResultMs` P50 | 1,190 ms | 832-834 ms |
| `renderer.ipcCallsPerSearch` | 1 | 1 |
| `renderer.sqlStatementsPerSearch` | 2 | 2 |
| `renderer.domMutationsToResults` | 402 | 392 |
Per keystroke (query calls / SQL / DOM mutations / CD ticks) at 100 ms per
key, keys 1-5 are unchanged at `0/0/0/1`; key 6 is `1/2/402/9` before and
`1/2/392/7` after.
Slower typing was measured with a scratch change to the journey (key
interval and cadence guard raised, quiet window 1 s; not committed). At
400 ms per key both builds run one query per key from the second
character, five in all with 2 statements each: the shell debounce already
expired between keys before this change, and the results timer only
delayed each query. Before, the queries trail the keys by 650 ms (keys
`y s t e m` read `0 1 1 1 2`); after, each follows its own key. At 325 ms
per key the two builds differ: before, one query in 17 of 20 iterations
(two or four when a gap stretched past 350 ms); after, five. Typing with
gaps between 300 and 350 ms is the only range where this change adds
queries; each is a 2-statement query whose stale result is dropped.
About one launch in sixty showed the empty-results view: the single
`dbGlobalSearch` call went out with the same arguments (`system`, all three
types, hidden categories included, limit 101) and the main process answered
with an empty array in about 20 ms, with no renderer error. The database
was unchanged from passing launches (the same 119 statements before the
first key, the usual 2 for the query, none afterwards), and every failure
traced was the first launch after seeding. The journey fails such an
iteration instead of measuring it, so a full run occasionally fails J4.
The cause was the seed, not the query. The portal's import starts only
after its account check answers, and a freshly started mock generates the
`large` catalog on that first request (up to about 10 s on a busy machine).
`waitForXtreamImportToFinish` gave the loading overlay 5 s to appear and
then accepted "no overlay" as finished, so the seed closed the app while
the series import was still running. The template then held 4,000 live
channels and 4,000 movies but no series, and `system` matches only series.
A run stops at its first failed iteration, which is why the failure always
showed at the first launch after seeding. The wait now also requires the
catalog to be complete the way the app judges it on its next start
(`apps/electron-backend-e2e/src/xtream-import-wait.ts`): every type's
persisted status (`xtream-import-status:<id>:<type>`) reads `completed` and
the type has rows, unless the portal has no categories of that type. Rows
are required because a refresh deletes the catalog without resetting the
status. `xtream-import-wait.e2e.ts` holds the account check past the overlay
window and fails without it. Two J4 counters are enforced since #1899
(see [Enforced journey counters](#enforced-journey-counters)).
## `renderer.initialBytes`
@@ -813,6 +1145,17 @@ pnpm --silent run perf:initial-bytes -- --json # machine-readable; --silent ke
node tools/performance/measure-initial-bytes.mjs --summary dist/performance/journey-summary.json
```
Development-only code must stay out of the counter by construction, not by a
runtime flag: `if (!AppConfig.production)` keeps the imported module in
`main.js` because the optimizer does not fold the property read. The NgRx
store devtools therefore come from
`apps/web/src/environments/store-devtools.providers.ts`, an empty list in
every build, which only the `development`, `electron-e2e` and
`electron-e2e-zoneless` configurations replace with
`store-devtools.providers.dev.ts`; a build-config test in
`performance-build-config.spec.ts` keeps it that way. Removing the static
import lowered the counter by 12,565 bytes.
`--summary` writes the journey summary shape (`journeys.<journey>.counters`)
that the ratchet checker consumes. `--dist <dir>` points the script at another
build output, for example the `electron-performance` configuration.
@@ -859,7 +1202,10 @@ that file:
- a measurement below its baseline passes and prints a "tighten" hint;
- a measured counter without a baseline is noted, not failed;
- checking nothing fails: an empty baselines file, or `--only` naming an
entry that does not exist, cannot exit 0.
entry that does not exist, cannot exit 0;
- an optional `note` (a string) is printed with the entry's failure; journey
entries use it to mark a guard that is not validated against wall-clock
(see [Enforced journey counters](#enforced-journey-counters)).
`--only <journey>/<counter>` (repeatable) restricts the check to the named
baselines. A script that measures one counter writes its own summary file
@@ -957,25 +1303,135 @@ Pushes to `master` and manual dispatches always run it. The job is warn-only (`c
weeks (plan item B3): a regression marks the job failed without failing the
workflow. Making it required is a maintainer decision.
No J1 runtime counter is enforced yet. Three dispatched runs on 2026-09-27
(CI runs 36271875209, 36271879955 and 36271884616) reported the same summary
values, `renderer.ipcCallsToFirstCard` 16 and
`renderer.domMutationsToFirstCard` 939, but the third run marked both
`stable: false`: its warm-up and one measured iteration reached the first
card in about 750 ms with 13 bridge calls and 576 mutations, the others in
about 1,400 ms with 16 and 939. The three extra calls
(`downloadsGetDefaultFolder` and two `dbGetGlobalRecentlyAdded`) land before
or after the first card depending on that race, so neither counter is
promoted until the race is understood and the counters are deterministic.
`renderer.layoutShiftScore` (0) and `renderer.longTasks` (2) were identical
in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from
1,401 to 1,674 ms. All four stay evidence for now. Runner counters also
differ from a Mac (12 and 571 there, the fast path without the Linux-only
`getWindowState` call), so take J1 baseline values from the runner only.
`renderer.layoutShiftScoreSettled` has no baseline either: the runner read
it as `stable: false` because the dashboard hero flicker it reported was a
race there (see [Settle window](#settle-window)). That flicker is fixed; add
the runner's number once runner runs read it as `stable` too.
After the `Run the performance journeys` step, the job runs
`check-journey-ratchet.mjs --only …` on the summary that step wrote, for the
journey entries of `journey-baselines.json` (every entry except
`renderer.initialBytes`, which the `Initial bytes ratchet` job checks). A
`performance-tools` test keeps that `--only` list equal to those entries, so
a baseline cannot be added without being enforced. The step is in the job,
not in the composite action, so the weekly tightening still measures a run
that would fail it. While the job is warn-only, a regression fails the job
and not the workflow.
#### Enforced journey counters
Two J1 entries are validated (Principle 3) and carry no note:
`launch/renderer.ipcCallsToFirstCard` 15 and
`launch/renderer.domMutationsToFirstCard` 558 (#1828, `evidenceRun`
37192092882). #1828 removed the launch race (the window shown at
`did-finish-load`, see [When the window is shown](#when-the-window-is-shown)):
three dispatched runs on `master` read 15 / 558 in all 18 iterations, and
load to the first card went from about 940 ms to 466-528 ms. Slow-path
summaries (18 / 1,018 or more) fail the check.
A counter is enforced once it was identical in every measured iteration of
every recent `master` run. The other entries were identical in all 55
measured iterations of the 11 `master` runs from 2026-10-03 08:25 to
2026-10-04 06:55 (CI runs 37109621784 to 37184230956), with `slack` 0:
| Entry | Value | Week (69 runs since 2026-09-27) |
| -------------------------------------------- | ----- | ------------------------------------------------------------------------- |
| `launch/main.modulesRegisteredBeforeWindow` | 2 | identical |
| `launch/renderer.layoutShiftScore` | 0 | identical |
| `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) |
| `open-source/main.mockHttpRequestsToSettled` | 1 | identical |
| `open-source/renderer.ipcCallsToFirstPage` | 17 | identical |
| `open-source/renderer.layoutShiftScore` | 0 | 0.221, then 0.222; 0.233 since #1814; 0 since #1895 (see below) |
| `playback/renderer.httpRequestsToPlaying` | 2 | identical |
| `playback/renderer.layoutShiftScore` | 0.001 | identical |
`open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233
in every iteration of every `master` run from 84aef83a6 (#1814, page Back
buttons moved into the header) on, so its value was 0.233 with `evidenceRun`
37372780064 (b78224376). Since #1895 it is 0 (`evidenceRun` 38147072417:
all 5 measured iterations on the runner read 0 with no shift recorded).
Where the 0.233 came from (`evidence.layoutShift.shifts`, a 1280×720 window;
the score of a shift is the area of every node that moved times the largest
distance any of them moved, as a fraction of the viewport):
| Shift, ms after the click | Nodes that moved | Score |
| ------------------------- | ---------------------------------------------------------- | ------ |
| ~40 (page swap) | `main.workspace-content` x 60 → 383, width −323 | 0.222 |
| | switcher, `label.search-field`, `div.header-actions` x +44 | 0.011 |
| ~50-70 (categories) | `div.context-header__actions` x −60, width +60 | 0.0001 |
The first two are one frame: the frame that replaces the dashboard with the
portal page also inserts the 323 px context panel the dashboard has no room
for, and the history Back that #1814 shows once an in-app previous entry
exists. The header items move only 44 px, but a shift is scored with the
largest distance in it, the content pane's 323 px, which is why Back added
0.011 rather than about 0.0015. The third is the category search and sort
buttons appearing when the categories arrive. The skeletons of the category
list and the grid are replaced by content without any shift. Nothing was
hidden behind the startup overlay: the "Syncing playlist" overlay of the
cached catalog load flashed for about two frames after the shift and covered
no further one. It no longer mounts on a J2 click (see the J2 section).
The content frame and the header controls now slide into place instead of
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
applies in the same frame and only a transform animates, which Chromium does
not record as a layout shift. The category search and sort buttons render
disabled while the categories load. Locally (macOS, 2026-10-11) and on the runner
J2 then read 0 in every iteration with no shift recorded, J1 0 / 0, J3 0.001
and J4 0;
`renderer.domMutationsToFirstPage` rose by 22 (the slide's class and style
changes) and the wall-clock entries did not move. Under
`prefers-reduced-motion` there is no slide, so a user with that preference
still sees the jump that the journey no longer measures.
The two J4 entries, `search/renderer.ipcCallsPerSearch` 1 and
`search/renderer.sqlStatementsPerSearch` 2 (#1899, `evidenceRun`
38165392046), were identical in all 816 J4 iterations of the 136 CI journey
jobs since #1816 and in all 18 iterations of three runs of #1899. They guard
the debounce: a search that queried per keystroke, or ran a second query for
the same term, raises them. Like the entries in the table they carry the
guard-only note: #1899 halved the J4 wall clock without moving either.
Being deterministic is not the same as being validated. Principle 3 of the
plan promotes a counter to a guardrail once a PR has shown that lowering it
lowered the journey's wall-clock. None of these counters has that evidence
yet: `main.modulesRegisteredBeforeWindow` waits for the deferred IPC
registration (plan item C4), the layout-shift scores measure visual
stability rather than time, and no PR has moved a J2 or J3 counter. Each
entry therefore carries
`"note": "guard only, not validated: …"`. A guard stops a regression of a
deterministic number, and the checker prints the note with a failure; it
says nothing about whether lowering that number makes the journey faster.
When a PR shows that link, it drops the note and names the evidence in
`evidencePr`. `renderer.initialBytes` predates the note and carries none;
this document records no wall-clock change for it either.
Not enforced, with the reason:
- J1 `renderer.ipcSerialDepthToFirstCard` (6): bimodal on `master` until
#1828 (9 or 6) and identical in its three dispatched runs; a candidate
once `master` runs agree.
- J1 `main.sqlStatementsBeforeReadyToShow`: 93 or 95 even without the launch
race, because the download and recording recovery races `ready-to-show`
(plan item A2).
- Every `renderer.longTasks` (J1 2 or 1, J2 0 with one 1 earlier in the
week, J3 1 or 2 until #1817 and 0 since): a long task is a task over
50 ms, so the count follows runner speed, not work.
- Every `cdTicks` counter: the zoneless migration (plan item C6) changes
them.
- J2 `renderer.domMutationsToFirstPage`: 1,602 or 1,603 between runs of
recent commits.
- J3 `renderer.ipcCallsToPlaying` (4 or 5) and
`renderer.domMutationsToPlaying` (6,182, 6,183 or 6,199 before #1817;
731, 732 or 734 since, 748 with a fifth call): not identical.
- J3 `renderer.ipcSerialDepthToPlaying` (4 on the runner, 3 on a Mac):
added after these runs and measured in one runner run so far; a
candidate once `master` runs agree.
- J4 `renderer.domMutationsToResults` (402 on `master`, 392 since #1899),
`renderer.ipcSerialDepthToResults` (1) and `renderer.layoutShiftScore`
(0): identical in the three #1899 runs; candidates once `master` runs
agree.
- J4 `renderer.cdTicksToResults` (11 to 13) and `renderer.longTasks`: see
the general reasons above.
Runner counters differ from a Mac (the Linux-only `getWindowState` call, for
one), so take every journey baseline value from the runner only.
### Weekly tightening
@@ -1000,7 +1456,11 @@ its job; its entries are then unmeasured in that run. A final job runs
`check-baseline-direction.mjs` without `--allow-increase`, which both the
script and the job check;
- a lowered entry gets `updatedAt`, `measuredWith` and `evidenceRun` (the
workflow run URL); `evidencePr` is set to the tightening PR once it exists.
workflow run URL); `evidencePr` is set to the tightening PR once it exists;
every other field, including a `note`, is kept, so a guard stays marked as
not validated after it is lowered;
- a counter already at 0 is never lowered; a layout-shift score is lowered
to the three-decimal value the summary reports.
When the file changed and the run is on `master`, the job pushes
`automation/performance-ratchet` and opens (or updates) a pull request with
@@ -1019,8 +1479,10 @@ dispatches workflows that exist on the default branch, so before the first
merge of a new or renamed workflow add a temporary `push` trigger for the
branch and drop it before review, as #1760 did. Review the pull request like a manual
tightening: if `master` moved since the measured commit, the
`Initial bytes ratchet` job on the pull request is what shows that the new
value still holds (the concurrent-merge effect above).
`Initial bytes ratchet` job (for `renderer.initialBytes`) and the
`Performance journeys` job (for the journey counters) on the pull request
are what show that the new values still hold (the concurrent-merge effect
above).
## Charset parse benchmark
@@ -1059,6 +1521,68 @@ threshold on Node 22 and inside Electron 43, so D2 regex prefilters were not
applied. Rerun the benchmark after changing either parser or when a user
reports slow imports of non-Latin playlists.
## Compositing budget
Chromium rasterizes every composited layer into tiles and gives a desktop
renderer 512 MB of tile memory (1 GB on a display at least 3500 device pixels
wide). Tiles needed for the visible frame that do not fit log
`cc/tiles/tile_manager.cc: WARNING: tile memory limits exceeded, some content
may not draw` and leave blank tiles while scrolling. On a 2x display a layer
the size of the content area costs about 24 MB, so a dozen full-size layers
spend the budget. The expensive layers are rarely the obvious ones: a
`border-radius` on a scroller makes Blink clip every composited effect inside
it (`backdrop-filter`, `filter` with a render surface, a running transform or
opacity animation, `isolation: isolate`, `will-change`) through a mask layer
synthesized per effect, each the size of the whole clipped area, because its
shader path for rounded clips needs four equal radii on macOS and a
translation-only transform between the clip and the effect
(`PropertyTreeManager::ShaderBasedRRect`). The workspace shell therefore
paints its content corner instead of clipping it
([workspace shell](workspace-shell.md#content-surface-corner)).
Two instruments share `src/performance/compositing-probe.ts`, which reads
the layer tree over the Chrome DevTools Protocol (`LayerTree`: bounds in
device pixels, compositing reasons and owner nodes, available only after
Blink's first layerization with the domain enabled) and the renderer's
`cc/tile_memory` from a memory-infra dump (`Tracing.requestMemoryDump`). A
synthesized clip mask is a drawing layer with no owner node and no
compositing reason; a content layer always carries a reason, and may lack an
owner node when its first paint chunk belongs to an anonymous box.
- `src/compositing.e2e.ts` is the deterministic guard, run with the Electron
E2E suite on every platform: on Live TV, a movie detail, a series detail
and the dashboard no synthesized mask may span half the content area, and
the backdrop-filtered controls must really be composited layers, so the
check cannot pass on a page with nothing to clip. Blink synthesizes the
masks before any GPU work; under `--disable-gpu` (the Linux CI launch) the
old scroller radius still produced seven.
- `pnpm run perf:compositing` (`electron-backend-e2e:compositing-report`,
`playwright.compositing.config.ts`, Xtream mock on `127.0.0.1:3233`,
override with `IPTVNATOR_COMPOSITING_XTREAM_MOCK_PORT`) measures. One fresh
profile is seeded through the app's dialogs with a live, a movie and a
series favourite, artwork is served from memory, and the window is 1600x1000
CSS pixels. For Live TV, the movie and series details, the dashboard (idle,
during a crossfade, scrolled) and settings it records tile and image
memory, composited layer counts, the eight largest layers with their
owners and reasons, synthesized masks and the `tile memory limits
exceeded` lines on stderr, into
`dist/performance/compositing/<YYYYMMDDTHHMMSSZ>/summary.json` and a table on
the console. The pool keeps freed tiles for a few seconds, so each reading
waits `TILE_POOL_SETTLE_MS` after its route settles.
Megabytes depend on the window size, the device scale factor and the GPU
path, so they are evidence for a PR, not a ratchet: the Linux runner at 1x
cannot reproduce a 2x laptop. Layer and mask counts on the fixed mock are
comparable across machines; wiring them into the [ratchet](#ratchet) needs a
baseline measured on the canonical runner first. A PR that adds or changes
`backdrop-filter`, `filter`, `will-change`, `isolation`, a transform or
opacity animation, a `mask` or `clip-path`, or a `border-radius` on an
element with `overflow`, runs the report before and after and quotes the
affected routes. Reference, 2026-10-09 on a 16" MacBook Pro at 2x before and
after the painted corner: dashboard idle 537 to 242 MB, during a slide switch
598 to 303 MB, scrolled 655 to 165 MB; movie detail 110 to 89 MB; series
detail 187 to 143 MB.
## Adding a counter
1. Produce the value from the built output or from a deterministic probe, not
@@ -1067,7 +1591,13 @@ reports slow imports of non-Latin playlists.
3. Cover the extraction and the failure modes with `node --test` and register
the test file in `tools/performance/project.json`.
4. Validate the counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
lowering it moved wall-clock in the same journey. A counter that is
deterministic but not validated may be enforced as a guard: its baseline
entry carries a `note` saying so (see
[Enforced journey counters](#enforced-journey-counters)).
5. A journey counter's baseline is enforced only when it is also in the
`--only` list of the `Performance journeys` job in `ci.yml`; the
`performance-tools` tests fail when the two differ.
## Adding a journey
@@ -1080,8 +1610,10 @@ reports slow imports of non-Latin playlists.
2. Give the journey its own probe options (`cardSelector`,
`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
`journey-renderer-probe.ts` when the end condition is neither. A journey
whose start or end does not fit that probe gets a probe of its own, as
J4's typed start and quiet-window end do (`search-journey-probe.ts`). 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
@@ -1094,6 +1626,10 @@ reports slow imports of non-Latin playlists.
change.
5. Cover the probe with jsdom fixtures and the record and summary code with
`node:test` (`pnpm nx run electron-backend-e2e:test-performance-harness`).
`tsx` runs those specs without type checking, so also run
`pnpm run typecheck:spec electron-backend-e2e`. Its
`apps/electron-backend-e2e/tsconfig.spec.json` includes every TypeScript
file in the project, so a new journey file needs no `include` change.
6. Validate a counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
+69 -14
View File
@@ -288,9 +288,11 @@ It owns only transient presentation behavior:
- `ControlsShortcuts` — document keyboard routing;
- `ControlsSurface` — pointer/click/double-click surface interactions;
- `ControlsTimeline` — scrub state and timeline projections;
- `ControlsTimelineHover` — the time under the pointer over the timeline;
- `ControlsTimelineLabel` — where the timeline label points: the time under
a hovering pointer, else the slider value while the keyboard or a drag
moves it, plus the pure `clampTimelineLabelLeft` placement;
- `app-player-timeline` — presentation of the timeline row (current time,
segment track, knob, hover label, remaining time / LIVE, recording
segment track, knob, label, remaining time / LIVE, recording
status); scrub `input`/`change` events go back to the controls component,
which owns reveal and seeking;
- `ControlsLayout` — the compact/wide dock mode from the host's width;
@@ -314,7 +316,10 @@ primary action and progress, cyan `#5cd6ff` for "something is on", violet
`#6b7384` text ramp, and two reds: `--pc-live` `#d32f2f` fills the LIVE
badge (white label 5.0:1), and `--pc-danger` `#ff5252` colours the active
record glyph and the recording status (6.2:1 on the glass over a black
frame). They are literal on purpose: the overlay is
frame). The seek bar's unplayed track is the opaque `--pc-timeline-track`
`#313437` and its segment separators are `--pc-timeline-separator` white,
so their contrast does not depend on the frame (see Timeline segments).
They are literal on purpose: the overlay is
theme-independent (see the UI guidelines' player theme boundary), and the
app's `--app-selection-color` is a different blue that would fight the video.
Watch progress reads `--pc-progress`, the accent blue declared by the
@@ -327,13 +332,20 @@ one colour in every player surface (UI guidelines, "Watch progress colour").
a white knob ringed in translucent blue) · remaining time as `−7:03`
(`formatRemainingTime`; the LIVE badge replaces it on live streams and
`--:--` stands in while no duration is known) · the recording status.
The badge text is the translated `LIVE_BADGE`, and both it and the
`--:--` placeholder (`UNKNOWN_TIME_TEXT`) are `role="img"` elements named
`LIVE_STREAM` / `DURATION_UNKNOWN`: a name on a role-less span is never
announced, and the dashes are not words.
The `<input type="range">` stays as the interaction and accessibility
layer, invisible and full-size over the drawn track: dragging, arrow
keys, `aria-valuetext` and the focus ring (drawn on the track through
`:has(:focus-visible)`) all belong to it, so scrubbing semantics are
unchanged. Hovering the bar with a mouse shows a white marker and a
`1:40` label above the pointer (`ControlsTimelineHover`); touch never
hovers and a non-seekable timeline never labels.
`1:40` label above the pointer. Keyboard focus on the slider
(`:focus-visible`, or any seek key) and a drag preview — the only way
touch, which never hovers, gets it — show the same label at the slider
value instead, without the marker (`ControlsTimelineLabel`; a hovering
pointer wins). A non-seekable timeline never labels.
- **Control row**: `minmax(0,1fr) auto minmax(0,1fr)`. Left: the volume
button, with the slider **inline** (72px) in the wide mode and behind
the hover/tap popover in the compact mode — inline, the button is a
@@ -359,13 +371,12 @@ one colour in every player surface (UI guidelines, "Watch progress colour").
### Timeline segments
The track is drawn as a row of segments, one flex item per segment with
`flex-grow` equal to its share of the duration and its own accent fill, so
The track is drawn as a row of segments, each with its own accent fill, so
a film's chapters or a catch-up recording's programmes read directly off
the bar. Each segment is placed absolutely at its time position (`left` =
start percent, `width` = share minus the 3px gap every segment but the
start percent, `width` = share minus the 2px gap every segment but the
last keeps), so a drawn boundary sits exactly where the linear seek input
and the hover label change segment; a segment shorter than the gap
and the label change segment; a segment shorter than the gap
collapses instead of pushing its neighbours. The optional `timelineSegments` input
(`PlayerTimelineSegment { startSeconds, endSeconds, title }`) supplies
them; `normalizeTimelineSegments` (`controls-timeline-segments.ts`) clamps
@@ -374,8 +385,51 @@ the previous end and fills every gap with an untitled segment so the row
always covers `[0, duration]`. Without segments — live playback, VOD and
series — the row is one untitled segment, which is the plain bar.
`ControlsTimeline` owns the normalized list and the per-segment fill for the
current scrub or playback value; the hover label becomes `Chapter 2 · 12:40`
over a titled segment. mpv's chapter list is not a producer yet.
current scrub or playback value; the label becomes `Chapter 2 · 12:40`
over a titled segment.
Boundaries hold 3:1 over any frame. Segments only round the bar's two
ends, and every gap (2px, `TIMELINE_SEGMENT_GAP_PX`) is filled by a
separator (`separatorLeft`, never left of the track's start, where a
boundary in the bar's first pixels would put it) in
`--pc-timeline-separator` white: 12.5:1 on
the opaque `--pc-timeline-track`, 3.2:1 on the `--pc-progress` fill, which
keeps 4.0:1 on the track. The former translucent track with see-through
gaps measured 1.3–1.6:1 at its boundaries, and its fill fell to 1.1:1 over
a bright frame (`player-controls.palette.spec.ts` pins the ratios).
The names reach every user, whichever source drew the segments. The
slider's `aria-valuetext` is the translated `TIMELINE_SEGMENT_POSITION`
(`{{title}} · {{time}}`) whenever the scrub or playback value — the
keyboard's target included — lies in a titled segment
(`ControlsTimeline.segmentTitle`), and the plain time otherwise. The visible
label (`aria-hidden`; it mirrors the valuetext) is two parts: the title
ellipsizes at `min(320px, 60cqw)` and the time never truncates. The
stylesheet centres it on its anchor; once laid out,
`PlayerTimelineComponent` moves it by its measured width
(`clampTimelineLabelLeft`, an `afterRenderEffect`) so it stays 8px inside
the player (`.player-controls-host`) — past the bar's ends when needed, as
the bar is narrower than the player. A `ResizeObserver` on the bar and the
player re-places it when only the size changes (a paused player whose
slider the keyboard holds, in a narrowed window). `xtream-catchup-timeline-label.e2e.ts`
tabs to the bar with a 120-character programme title at 1280 and 800px, in
both themes and in de and ru.
**Chapter producer.** Embedded MPV reports the file's own chapters: the
addons and the frame-copy helper observe mpv `chapter-list` and publish it
as `EmbeddedMpvSession.chapters` (`{ timeSeconds, title? }`, validated in
main by `normalizeEmbeddedMpvChapters`). `buildChapterTimelineSegments`
(`chapter-timeline-segments.ts`) runs each chapter from its start to the
next one's, the last to the duration, orders them and drops repeated or
out-of-range starts; an untitled chapter becomes an untitled segment, and
no chapters or no finite duration yields `null`.
`EmbeddedMpvPlayerComponent.controlsTimelineSegments`
(`embeddedMpvTimelineSegments`) passes the host's segments when it
supplies any and the chapter segments otherwise, so catch-up programmes
always win; `sameTimelineSegments` keeps a re-polled, unchanged list from
counting as a change. The Linux native-view `mpv --wid` backend reads no
chapters, and the web engines report none (streamed HLS/DASH carries no
chapter track), so their VOD and series stay one plain bar.
**Catch-up producer.** Archive playback of a live channel passes the EPG
programmes overlapping its archive window.
@@ -422,8 +476,9 @@ episode: `upNextThresholdSeconds` is 4% of the duration clamped to
`timelineSegments` carry a closing-credits chapter in the last third
(`creditsStartSeconds`: "Credits", "Outro", "Ending", an anime `ED`, and a
few localized names), the card appears when it starts instead, at most
5 min before the end. No engine reports file chapters today, so in practice
the adaptive lead applies. The countdown shows whole minutes, and seconds
5 min before the end. Embedded MPV under the frame-copy engine supplies
file chapters (see the chapter producer above); every other engine, and a
file without a credits chapter, uses the adaptive lead. The countdown shows whole minutes, and seconds
in the last minute. A
still that fails to load falls back to the label tile. A
click emits `nextEpisodeRequested` directly — not through the
@@ -1661,7 +1716,7 @@ libs/ui/playback/src/lib/player-controls/
├── controls-feedback.ts
├── controls-format.utils.ts
├── controls-layout.ts
├── controls-timeline-hover.ts
├── controls-timeline-label.ts
├── controls-timeline-segments.ts
├── controls-settings.ts
├── controls-settings-groups.ts
+40 -12
View File
@@ -5,13 +5,13 @@ settings screen.
## Entry Points
- UI: `/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup-section.component.ts`
- UI: `apps/web/src/app/settings/settings-backup-section.component.ts`
(embedded in `settings.component.html`), with the file read/handoff in
`/Users/4gray/Code/iptvnator/apps/web/src/app/settings/settings-backup.facade.ts`
- Backup service: `/Users/4gray/Code/iptvnator/libs/services/src/lib/playlist-backup.service.ts`
- Manifest types: `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
`apps/web/src/app/settings/settings-backup.facade.ts`
- Backup service: `libs/services/src/lib/playlist-backup.service.ts`
- Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
- Xtream pending restore storage:
`/Users/4gray/Code/iptvnator/libs/services/src/lib/xtream-pending-restore.service.ts`
`libs/services/src/lib/xtream-pending-restore.service.ts`
## Manifest Contract
@@ -63,6 +63,19 @@ Xtream backups export only connection metadata plus portable user state.
- favorites by `{ contentType, xtreamId, addedAt?, position? }`
- recently viewed by `{ contentType, xtreamId, viewedAt }`
- playback positions as `PlaybackPositionData[]`
- optional VOD source pins by `{ matchKey, contentId, updatedAt? }`
An imported Xtream archive can wait for its catalog to download. Export reads
that pending snapshot before consulting the catalog: its hidden categories,
favorites, history, and positions are authoritative, including empty lists.
Pending pins are authoritative when present; an absent `sourcePins` field keeps
the existing store's pins when that store is available, or stays absent when it
is unavailable. Re-export neither applies nor consumes the pending snapshot,
so it also works before the portal has been opened and in browser runtimes.
Parental category locks still come from the current lock store.
A failed pending-storage or playback-position read fails export. An unreadable
collection must never be serialized as a successful empty collection.
Explicitly excluded:
@@ -164,11 +177,10 @@ worker IPC boundary in the snake_case wire shape declared by
`XCategoryFromDb`/`XtreamCategoryFromDb`; the category operations project
their Drizzle rows explicitly to keep that contract true.
Clearing the playlist's existing pins goes through a dedicated
delete-by-playlist operation, not the keyed clear: that one caps its key list
to bound an IN clause, so a playlist with more pinned movies than the cap kept
the surplus while still reporting success. A failure now fails the entry
rather than leaving the union of old and archived pins.
Replacing the playlist's pins deletes by playlist and writes the replacements
in one transaction. It does not use the capped keyed-clear operation, which
could leave surplus pins behind. Any replacement failure fails the entry and
retains the pending snapshot for retry.
`sourcePins` (VOD multi-source) is the one **optional** collection, and the
normalizer preserves that: an absent field stays absent rather than becoming
@@ -190,12 +202,28 @@ Electron restore behavior:
1. Category import reads pending hidden-category state while saving categories.
2. After content import, favorites/recent state is restored by typed
`{ contentType, xtreamId }` matching.
3. Playback positions are cleared and re-applied from backup state.
3. Playback positions are replaced for the destination playlist in one SQLite
transaction through `DB_REPLACE_ALL_PLAYBACK_POSITIONS`. Empty means clear;
a failed insert rolls back the deletion and all earlier inserts.
4. VOD source pins are re-applied against the IMPORTED playlist id.
For existing Xtream playlists with a fully populated offline cache, backup
import applies the restore immediately. Otherwise the typed restore payload is
left pending until the next Xtream initialization/import.
left pending until the next Xtream initialization/import. Both paths use strict
playback-position replacement: unavailable storage, rejected IPC, or an
unsuccessful response fail the restore and leave the pending snapshot intact.
Atomic restore support is checked separately from the ordinary Xtream SQLite
capability gate. An older preload missing the replacement method keeps using
SQLite for playback and backup export. Import parks the snapshot first, then
rejects unsupported atomic restore before checking catalog completeness or
writing category visibility, collections, positions, or pins. Deferred restore
performs the same preflight before applying user state. The retained snapshot
can be retried once atomic replacement becomes available. Ordinary playback
keeps its existing capability checks and best-effort persistence API.
The whole restore is not one database transaction; already applied
categories/favorites/history can
remain after a later failure, but the snapshot remains available to retry the
complete restore. It is consumed only after every restore step succeeds.
## Current UX
+17 -1
View File
@@ -270,6 +270,22 @@ handler as the header Back.
- Do not force both portals into the same browse/detail behavior unless the full
portal detail architecture is being changed.
### Arrival and asynchronous ownership
The shared catalog view initializes the category and consumes its Stalker
handoff together, after the facade's optional `routeReady` signal allows it.
Stalker exposes the route session's readiness, withheld from NavigationStart
until the destination playlist and section are installed. Cancelled navigation
reconciles the current route. Reused category routes also reinitialize when
their playlist changes; readiness changes alone must not close an open detail
on query-only navigation.
Collection detail wrappers invalidate pending playlist loads before restoring
their store snapshot on destruction. Route sessions must compare the actual
store owner with the route, rather than trusting only their last initialized
playlist id. Xtream playlist reads likewise discard completions superseded by
a selection, metadata update, reset or newer read.
## Xtream
Xtream category and search details are represented by canonical routes.
@@ -288,7 +304,7 @@ Implication:
Current code paths:
- `libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts` (favorites + recent, with shared UI from `libs/portal/shared/ui/src/lib/components/favorites-layout/`)
- `libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.ts` (favorites + recent)
- `libs/portal/xtream/feature/src/lib/search-results/search-results.component.ts`
- `libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.ts`
+53 -3
View File
@@ -73,6 +73,40 @@ The current self-hosted PWA uses these `apps/web-backend` routes:
- `GET /xtream?targetId=<id>&username=<u>&password=<p>&action=<action>`
- `GET /stalker?targetId=<id>&macAddress=<mac>&action=<action>`
Server storage adds versioned routes under `/v1`; the PWA does not call them
yet:
- `GET /v1/instance` (always public): `{ service, apiVersion, instanceId,
storage: 'server' | 'none', auth: 'none' | 'token' }`
- `GET /v1/instance/status`: playlist count, database size and path
- `GET /v1/playlists[?view=meta]`, `GET|PUT|DELETE /v1/playlists/:id`,
`DELETE /v1/playlists`: whole playlist documents; `GET` sends an `ETag`
and answers `304` to a matching `If-None-Match`, and
`PUT ...?keepPayload=1` keeps the stored `playlist`, `items` and
`header` fields (`409 payload_missing` without a stored row)
- `GET /v1/user-state`, `PUT|DELETE /v1/user-state/:namespace/:playlistId`:
Xtream favorites, recently viewed, collection snapshots and playback
positions, one JSON value per namespace and playlist
- `GET|PUT /v1/app-state/:key`
Server storage lives in `apps/web-backend/src/app/storage/`. With
`IPTVNATOR_STORAGE=server`, `main.ts` opens the shared database through
`initDatabase()` (path from `IPTVNATOR_DATA_DIR`), so the server has the same
schema and migrations as the desktop app and writes playlists with the
shared row codec. It adds two server-only tables: `server_user_state` for
Xtream user data, and `server_playlist_meta`, a copy of each document
without its M3U payload fields that metadata reads use instead of walking
the payload blobs (filled once on open for rows written without it). ETags
come from an in-memory revision counter; the server is the only writer of
its database. Storage errors are logged by error name and code only, because
Drizzle messages can carry bound values such as portal credentials. With
`IPTVNATOR_ACCESS_TOKEN`, every route except `/`, `/health`, `/config.js`
and `/v1/instance` requires `Authorization: Bearer <token>`, the proxy
routes included. The PWA cannot send that token yet, so the variable must
stay unset until the browser side lands. Browser app state written through
`/v1/app-state` is stored under a `client:` key prefix, apart from
server-owned keys such as the instance id.
`pnpm nx run web-backend:serve` starts the backend by hand on port 3333 with
`TSX_TSCONFIG_PATH=tsconfig.base.json`, which `tsx` needs for the
`@iptvnator/*` path aliases. `apps/web-e2e/playwright.config.ts` does not use
@@ -135,9 +169,10 @@ these limitations explicit in UI, troubleshooting, and release notes:
programme-guide entry points, or EPG-fetching flows in browser/PWA mode, and
do not use EPG as the readiness signal for Docker.
- The PWA does not use the Electron SQLite database or DB worker. Playlist
metadata uses `PlaylistsService` with IndexedDB; Xtream favorites, recently
viewed items, playback positions, and cached collection snapshots use
`PwaXtreamDataSource` browser storage.
metadata uses `PlaylistsService` with IndexedDB, which
`PlaylistStorageBackend` selects when no SQLite bridge exists; Xtream
favorites, recently viewed items, playback positions, and cached collection
snapshots use `PwaXtreamDataSource` browser storage.
- Browser playlist deletion must go through `PlaylistsService`, not
`DatabaseService`. `PlaylistsService.deletePlaylist()` runs registered
cleanup hooks such as the PWA Xtream cleanup so localStorage sidecar data does
@@ -234,6 +269,11 @@ and sidecar state in localStorage:
- `xtream-playlists`
- `xtream-playback-positions`
`PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the
`USER_DATA_STORAGE` token from `@iptvnator/services` (default:
`localStorage`), never through `localStorage` directly, so a deployment can
substitute a server-synced storage.
Entries should include a content snapshot when the item is added. Global
collection routes and the dashboard can then restore titles, posters, content
type, and category IDs after navigation or a page reload without relying on the
@@ -268,6 +308,15 @@ Default runtime values:
- `PORT=3000`
- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=0`
- `NODE_EXTRA_CA_CERTS` unset
- `IPTVNATOR_STORAGE=server`, `IPTVNATOR_DATA_DIR=/data` (a `VOLUME`)
- `IPTVNATOR_ACCESS_TOKEN` unset, `IPTVNATOR_MAX_DOCUMENT_MB=64`
- `IPTVNATOR_BACKEND_START_TIMEOUT=300`: seconds the entrypoint waits for
`/health`; the backend migrates its database before it listens
better-sqlite3 is a native addon, so the Dockerfile builds it in a separate
`native` stage that is not pinned to `$BUILDPLATFORM`; multi-arch builds
produce it for each target platform. nginx raises `client_max_body_size` to
64 MB for `/api/` and gzips JSON responses.
When hosting behind another domain, set `CLIENT_URL` to the browser origin and
keep `BACKEND_URL=/api` unless the reverse proxy exposes the backend elsewhere.
@@ -282,6 +331,7 @@ Use the narrow validation ladder for self-hosted changes:
```bash
pnpm nx test web-backend
pnpm nx test web-backend --testPathPatterns=storage
pnpm nx test web --runTestsByPath apps/web/src/app/services/runtime-config.spec.ts
pnpm nx build web --configuration=pwa --skip-nx-cache
pnpm nx build web-backend
+5
View File
@@ -318,6 +318,11 @@ Implemented UI behavior:
- Numeric keypad (`0-9`, `DEL`, `CLR`, `OK`)
- Volume controls (`VOL-`, `MUTE/UNMUTE`, `VOL+`)
- Status card (portal, channel name/number, current program)
- Labels and errors come from the shared `REMOTE_CONTROL.*` translation keys.
The phone never sees the desktop language setting, so the app shell picks
the first browser language with a translation
(`resolveRemoteControlLanguage`, English otherwise) and sets `<html lang>`
to it.
- Polls `/status` every 2s
- Uses action wrapper to refresh status after command execution
+28 -1
View File
@@ -550,7 +550,9 @@ Xtream-only row shape:
as `Café` can still reach rows stored as either `Café` or `Cafe` before the
worker's accent-insensitive ranking step. Existing databases rebuild this
index once through the `migration:content-title-fts-trigram:v1` app-state
marker before legacy content cleanup/normalization migrations run; fresh
marker before legacy content cleanup/normalization migrations run (the
diacritics rebuild records that marker too, so it is never rebuilt twice;
see [VOD multi-source](vod-multi-source.md#short-titles-and-unicode)); fresh
inserts, deletes, and title updates stay synchronized through SQLite triggers.
2. `source_type = "m3u"` rows come from M3U playlist payloads stored in
`playlists.payload`. The worker uses SQL `payload LIKE` against channel
@@ -616,6 +618,17 @@ Xtream-only row shape:
12. `DB_GET_APP_STATE`
13. `DB_SET_APP_STATE`
`DB_UPSERT_APP_PLAYLIST(S)` write whole documents: the row built by the shared
codec (`buildPlaylistRow` in `libs/shared/database`) replaces the stored one.
`playlistConflictUpdate` sets every optional column the document leaves empty
(an empty string or a missing key) to NULL instead of skipping it, because
`parseAppPlaylist` prefers a column over the payload and would otherwise bring
a cleared User-Agent, Referrer, Origin, credential, EPG list or file path back.
Only `date_created` is kept from the first insert. Callers must send the full
document (`PlaylistsService` reads, patches and writes the whole row); a
column-only change goes through `DB_UPDATE_PLAYLIST`. The self-hosted web
backend's playlist repository uses the same conflict update.
`DB_GET_APP_PLAYLIST_METAS` is the preferred path for summary surfaces such as
the workspace sidebar and dashboard source rail. It selects playlist metadata
columns only and deliberately skips the large `payload` column, which can
@@ -661,6 +674,20 @@ is unavailable or the SQLite playlist migration has not completed.
4. `DB_GET_RECENT_PLAYBACK_POSITIONS`
5. `DB_GET_ALL_PLAYBACK_POSITIONS`
6. `DB_CLEAR_PLAYBACK_POSITION`
7. `DB_REPLACE_ALL_PLAYBACK_POSITIONS`
Backup restore uses `DB_REPLACE_ALL_PLAYBACK_POSITIONS` to replace only the
specified playlist's positions in one synchronous transaction. Every insert
and the delete use `.run()` so an insertion failure rolls the whole replacement
back. An empty replacement deliberately clears that playlist. Renderer backup
callers propagate read/write errors and retain pending restore state for retry;
normal playback persistence keeps its best-effort behavior. The renderer checks
atomic replacement support at the start of the immediate or deferred restore
pass, before updating existing categories, collections, positions, or pins.
Missing support fails restore with the pending snapshot retained; it does not
switch ordinary Xtream storage or backup export away from SQLite. Deferred
catalog import can still seed newly imported categories from pending visibility
state before that restore pass runs.
## SQLite Concurrency Rules
+67 -2
View File
@@ -393,12 +393,46 @@ Internal structure to preserve:
`isCategoryResourceFailed()` / `isPaginatedContentFailed()` for explicit
error handling.
Category arrays belong to `categoryPlaylistKey`, not just the content type.
Every category reader checks that owner; a portal switch clears all four
section caches before loading the destination. Aborted or foreign-portal
responses (including errors and radio fallbacks) cannot write into the active
cache. `resetCategories()` only clears: the route session calls it on a
portal switch before the destination is resolved and on teardown, where a
request would go to the portal being left. When a handoff had already put
the destination in the store, the owner does not change and the resource
would not reload by itself, so the session calls `reloadCategories()` once
it has installed that playlist. This contract applies to collection details as well as routed
catalogs, because both use the root Stalker store.
The store adds an every-item genre (`category_id: '*'`) in front of a
portal's list when the portal sends none. That entry holds no translated
name: its `category_name` is empty and its `labelKey` is
`PORTALS.ALL_CATEGORIES` (`PORTALS.ALL_RADIO` for radio). The category lists
render the key with the translate pipe. `getSelectedCategoryLabel()` returns
`{ name, labelKey }` for the selected genre. Text consumers (live header,
fullscreen panel title, catalog title, search scope) turn it into a string
with `stalkerCategoryLabelText()` inside a computed that reads
`injectTranslationTick()`. The root store outlives a runtime language switch, so a name
translated at load time would keep the old language until the next reload.
Failure-handling rule:
- Failed category or content requests must degrade into empty/error UI state,
not `undefined` collections or renderer exceptions. The workspace Stalker
context panel and live layout rely on this guarantee.
Detail metadata rule:
- `createStalkerInfo()` (`stalker-vod.utils.ts`) is the mapping both detail
templates read: the series view binds `info` directly and the shared VOD
view reads it through `normalizeStalkerVod()`. Each field takes the first
usable candidate, and portal stand-ins (`N/A`, `-`, an unrated `0` score)
become `''` there — see the placeholder rule in
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
TMDB enrichment, which keeps a non-empty provider rating and release date,
so a surviving stand-in would also block the TMDB value.
## Stalker Identity Policy
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
@@ -741,6 +775,31 @@ classifier accepts both shapes plus the legacy `{ js: '<body>' }` envelope.
`makeAuthenticatedRequest` retries once with fresh authentication and
otherwise throws `StalkerPortalError('auth-failed')` carrying the body.
### Cancelled requests and HTTP 401/403 over IPC
Electron prints `Error occurred in handler for 'STALKER_REQUEST'` — with a
stack trace — for every `ipcMain.handle` promise that rejects, so the main
process resolves two routine outcomes instead of throwing them: a request whose
abort signal fired (a source-health probe the renderer cancelled or let
expire) and an HTTP 401/403 (the endpoint answered and refused — an auth gate
in front of a discovery candidate, an expired account). Both come back as a
`{ portalRequestFailure }` envelope
(`libs/shared/interfaces/src/lib/portal-request-failure.util.ts`, classified
in `apps/electron-backend/src/app/events/portal-request-outcome.ts`).
`ElectronService.fetchStalkerData` is the only reader of the raw bridge result
and rethrows the envelope as the error the Stalker layers already classify: an
`AbortError` for the cancellation (debug log, no snackbar), or an
`HTTP Error <code>: <statusText>` Error that carries a numeric `status`, so
`getStalkerRequestErrorStatus` no longer has to parse it out of the message.
A cancelled request therefore never resolves into an empty answer. In the main
process the refusal is one `console.warn` through `formatPortalRequestError`
and `redactSensitiveData` (host and pathname, never the MAC, token or query
string); the cancellation is logged only under `IPTVNATOR_TRACE_IPC`. Every
other failure — 404, 5xx, network errors, the connectivity guard's fast-fail —
still rejects with the message contracts above and keeps its `console.error`.
`apps/electron-backend-e2e/src/portal-request-logging.e2e.ts` pins both
outcomes for both portals against the real main-process log.
### Error surfacing
`StalkerPortalError.portalText` holds the portal's own words. The import
@@ -1274,8 +1333,8 @@ The Stalker live route and radio route intentionally share
`create_link` with `type=radio`, skips EPG loading, and renders the same
`AudioPlayerComponent` layout instead of the Stalker VOD detail layout.
- Some Stalker portals do not expose radio categories. Radio category loading
falls back to a synthetic `PORTALS.ALL_RADIO` category with
`category_id: '*'` so the station list can still be loaded.
falls back to a synthetic every-item category (`category_id: '*'`,
`labelKey: 'PORTALS.ALL_RADIO'`) so the station list can still be loaded.
- A category click in the shell context panel only re-filters the channel
sidebar; the selected channel or station keeps playing (Xtream live #936 and
M3U group parity). `onStalkerCategoryClicked` therefore must NOT
@@ -1463,6 +1522,12 @@ records. Both accept the same closed set: boolean `true`, numeric `1`, or string
instead of showing the completed state. After a lazy load, quick start is
recomputed from the mapped episodes before playback so provider episode
ordering cannot start the wrong episode.
- Extras (season 0) follow the shared next-episode rule (`getSeriesNextUp`):
while the series has other seasons they never become the quick-start target,
and the lazy load before playback picks the first regular season the portal
has not answered for (an empty answer counts as loaded:
`isVodSeasonHydrationPending`), never an unloaded Specials season. Only when
every regular season came back empty do extras count as the run.
- For unloaded VOD-series seasons, the CTA target label is derived from season
metadata and rendered as `SxxE01` until episode details are loaded.
- Lazy VOD-series episodes use scoped tracking IDs derived from the parent
@@ -46,7 +46,9 @@ Direct signal properties currently exposed by `signalStore`:
- `getCategoryResource: StalkerCategoryItem[]`
- `isCategoryResourceLoading: boolean`
- `isCategoryResourceFailed: unknown`
- `getSelectedCategoryName: string`
- `getSelectedCategoryLabel: StalkerCategoryLabel` (`{ name, labelKey }`;
render with `stalkerCategoryLabelText()` inside a computed that reads
`injectTranslationTick()`)
## Exposed Resources/Props
@@ -74,6 +76,12 @@ Removed:
page size: the portal decides how large a page is. No facade alias is
provided, because a surviving `limit` would advertise a client-side window
that the append path does not honour.
- `getSelectedCategoryName` — replaced by `getSelectedCategoryLabel`. The
every-item genre the store adds (`category_id: '*'`) has no portal name: it
carries a `labelKey` that the caller translates, so the label follows a
runtime language switch. No alias is provided, because a name-only
selector is empty for that entry, and translating in the root store would
keep the language the list was loaded in.
During refactor:
@@ -93,6 +101,9 @@ During refactor:
- `clearSelectedItem(): void`
- `setCategories(type: 'vod' | 'series' | 'itv' | 'radio', categories: StalkerCategoryItem[]): void`
- `resetCategories(): void`
Clears only; it starts no request (called on portal switches and route teardown).
- `reloadCategories(): void`
Refetches the current portal's categories after a reset that keeps the owner.
- `setItvChannels(channels: StalkerItvChannel[]): void`
- `setRadioChannels(channels: StalkerItvChannel[]): void`
- `setSearchPhrase(phrase: string): void`
+27 -5
View File
@@ -208,7 +208,8 @@ component for detail→detail navigation.
`CrossPortalSimilarService` (`libs/services`) extends the rail across
portals: recommendations are matched against ALL imported Xtream
playlists with one batched `DB_MATCH_TITLES` request (Electron only,
playlists with one batched match (`DB_MATCH_TITLES`, sent in small
requests, see "Sending a batched match"; Electron only,
same two-tier + year rule). Stalker detail views — where the local
catalog is server-paginated and unmatchable — get their "Similar" rail
purely from these cross-portal matches (shared `VodDetailsComponent` for
@@ -353,7 +354,8 @@ Filmography has two scopes:
rest open the portal search prefilled with the title (`?q=`). Stalker
has no local catalog, so every title goes through the portal search.
- **All portals** (Electron only, toggle hidden in the PWA): one batched
`DB_MATCH_TITLES` worker request runs a trigram-FTS lookup per title
match (`DB_MATCH_TITLES` worker requests of five titles, see "Sending a
batched match") runs a trigram-FTS lookup per title
over ALL imported Xtream playlists
(`operations/title-match.operations.ts`), confirming candidates with
the same two-tier normalized-title matching the renderer uses
@@ -363,6 +365,24 @@ Filmography has two scopes:
This also works from Stalker actor pages — the one place the Stalker
catalog limitation is lifted.
### Sending a batched match
`CatalogTitleMatchService.matchTitles` deduplicates the titles, keeps the
first `TITLE_MATCH_TITLE_LIMIT` (200, the worker's own cap) and sends them
in `DB_MATCH_TITLES` requests of `TITLE_MATCH_REQUEST_SIZE` (5), one after
another, concatenating the answers in order. The DB worker answers one
request at a time, so a whole rail's batch in one request held every other
read behind it: on a 3.9M-title library one recommendations batch took
95 s cold and kept the dashboard's playback positions (and with them the
Continue Watching rail) waiting; now other reads wait for five titles at
most. The answer stays all-or-nothing: a failed
request, or a parental lock that starts withholding everything midway,
returns no matches, as one failed request did before. One request ran
under one parental-lock state, so a batch does too: when
`ParentalLockService.version()` changes between requests (a relock, an
unlock, a changed lock list) the batch starts over, and after
`TITLE_MATCH_LOCK_ATTEMPTS` (3) changing attempts it returns no matches.
### Resolving a batched match
Every `DB_MATCH_TITLES` consumer — the Trending rail, the "Because you
@@ -645,7 +665,8 @@ since shipped.)
toggle): `DashboardTrendingService`
(`libs/workspace/dashboard/data-access`) pulls the weekly TMDB trending
lists (cached one day per language) and runs ONE batched
`CatalogTitleMatchService.matchTitles` request against the imported
`CatalogTitleMatchService.matchTitles` call (worker requests of five
titles, see "Sending a batched match") against the imported
Xtream playlists — matched cards navigate straight to their detail view
and show the playlist name; unmatched cards open the global search
prefilled. Requires both the TMDB opt-in and the Electron DB worker
@@ -665,7 +686,8 @@ since shipped.)
interleaved round-robin, deduplicated by TMDB id, stripped of anything
already watched or favorited, and matched against
the imported libraries with ONE batched
`CatalogTitleMatchService.matchTitles` request. The watched/favorited
`CatalogTitleMatchService.matchTitles` call (worker requests of five
titles). The watched/favorited
exclusion index is built through the same lookup-attempt builder the
seeds use, so an activity row is indexed under more than its display
title: under the media type the detail view enriched with (a Stalker
@@ -719,7 +741,7 @@ since shipped.)
on its behalf would drop its recommendations for the session; a seed
that has no TMDB match never resolves either, so that user's rail
re-runs each visit, which is bounded work (cached enrichment misses
plus one batched worker call). Latched loads are keyed by the TMDB language
plus one batched title match). Latched loads are keyed by the TMDB language
(payloads are localized; the facade exposes `language()` for this), the
seed set, the watched/favorited exclusion set AND the imported-playlist
id set — watching something new re-seeds on the next dashboard visit,
+67 -14
View File
@@ -27,7 +27,7 @@ using its result as evidence. Docker validation can use
| ---------------------------------- | ----------------------------------- |
| Angular renderer entry points | `pnpm run typecheck:web` |
| Electron main process entry points | `pnpm run typecheck:backend` |
| Jest spec programs | `pnpm run typecheck:spec` |
| Spec programs (Jest, perf harness) | `pnpm run typecheck:spec` |
| Full unit suite (all projects) | `pnpm run test:unit:ci` |
| EPG data access | `pnpm nx test epg-data-access` |
| Workspace shell utilities | `pnpm nx test workspace-shell-util` |
@@ -38,20 +38,34 @@ using its result as evidence. Docker validation can use
over every `tsconfig.spec.json` under `apps/`, `libs/` and `tools/`, a few
programs at a time (`--concurrency=N` or `SPEC_TYPECHECK_CONCURRENCY`; a
positional argument filters by path), and fails on any diagnostic. ts-jest
transpiles with `isolatedModules`, so this is the only check that catches a
spec whose types drifted from the code it exercises. CI runs it in the
transpiles with `isolatedModules`, and `tsx` and Playwright strip types without
checking them, so this is the only check that catches a spec or harness file
whose types drifted from the code it exercises. CI runs it in the
`unit-and-typecheck` job after `typecheck:ci`. Conventions the gate relies on:
- Spec tsconfigs use `module: preserve` with `moduleResolution: bundler`, the
same as the library's own `tsconfig.json`; ts-jest forces CommonJS emit
outside ESM mode, so the setting only affects type-checking, and `node10`
resolution cannot see Angular's `exports`-only secondary entry points.
- Each spec program lists `global.d.ts` in `files` so `window.electron` and the
other ambient declarations resolve.
- Each spec program keeps `global.d.ts` in `files` so `window.electron` and the
other ambient declarations resolve. A config that sets `files` lists
`../../global.d.ts` again; one that does not inherits it from
`tsconfig.base.json`.
- Libraries tested through `tools/testing/run-web-esm-lib-tests.mjs` are
type-checked by `apps/web/tsconfig.spec.json`, the config
`jest.web-esm.workspace.ts` hands to ts-jest; add a new ESM-tested library's
spec globs there. `apps/web/src/jest-esm.d.ts` types `jest.unstable_mockModule`.
- `apps/electron-backend-e2e/tsconfig.spec.json` type-checks the performance
harness, which `test-performance-harness` (`tsx --test`) and the journeys'
Playwright runs execute without type checking. Besides `module` and
`moduleResolution` it sets `target: es2022` and `lib: ["es2022", "dom"]`,
because the project's `commonjs` module and the base `es2015` target reject
the harness's `import.meta`, BigInt literals and ES2022 library names. It
also sets `types: ["node"]`: the specs import `describe` and `it` from
`node:test`, so a Jest or Mocha global fails the check. Its `include` covers
the whole project (`src/**/*.ts` and the root Playwright configs): the
journey harness, the benchmark harness and its specs, and the Electron E2E
specs, with no baseline or ignore list.
- Type test doubles instead of casting to `any`: `jest.Mocked<T>`,
`InstanceType<typeof SomeStore>` for signal stores, and
`Object.defineProperty` or a writable mapped type for read-only capability
@@ -170,8 +184,8 @@ coverage do not decrease.
`apps/website` is an Astro marketing site. Its useful signal is a successful
static build plus targeted output checks, not a merged code coverage percentage.
Projects with a test target but no specs, such as `remote-control-web` and
`remote-control` today, should not be in Tier A until focused specs exist.
Projects with a test target but no specs, such as `remote-control-web`
today, should not be in Tier A until focused specs exist.
For local coverage inspection:
@@ -211,6 +225,14 @@ looping over `count()` with per-row `nth(index)` reads. Those reads auto-wait,
so a row removed mid-loop hangs the predicate until the poll times out instead
of letting it retry.
Chromium drops a scroll key pressed in the frame or two after the previous
smooth scroll lands but before Blink handles its finish, so the final
`scrollTop` is not a safe point for the next scroll input. Electron specs press
scroll keys through `pressScrollKey` in
`apps/electron-backend-e2e/src/keyboard-scroll.ts`, which waits for
`scrollend`; web specs call `waitForScrollIdle` from
`apps/web-e2e/src/e2e-helpers.ts` before the next key.
Playwright coverage is measured semantically by tags and critical journeys, not
by a source-line percentage. E2E reports should use tags such as `@critical`,
`@electron`, `@web`, `@xtream`, `@stalker`, `@m3u`, `@search`, `@epg`,
@@ -243,7 +265,9 @@ pnpm run coverage:e2e:v8:web
```bash
pnpm run i18n:validate # checker unit tests, then the check (CI)
pnpm run i18n:check # the check only
pnpm run i18n:check # the check only: locale drift, then key usage
pnpm run i18n:usage # key usage only
pnpm run i18n:duplicates # report: one English string, diverging translations
pnpm run i18n:baseline:update # deliberate: rewrite the English-identical baseline
```
@@ -266,8 +290,26 @@ that is legitimately identical in that language; review the diff, and never
run it in CI. A new locale starts with no baseline entries, so it has to
record its legitimate identical values the same way.
`node tools/i18n/check-drift.mjs --fail-on-identical` ignores the baseline for
a full translation audit. CI runs `pnpm run i18n:validate` in the unit test
job.
a full translation audit.
The usage check (`tools/i18n/check-usage.mjs`) fails on a key that renderer
code uses but `en.json` lacks, because ngx-translate shows such a key raw. It
reads production `.ts` and `.html` files under `apps/web/src`,
`apps/remote-control-web/src` and `libs`, without comments, and treats as a
use: a quoted key before `| translate`, or a ternary or fallback branch of a
parenthesised operand before it; the first argument of `instant`, `get`
or `stream` on a translate service, or of a function named like `translate*`,
`marker` or `t`; and a dotted upper-case literal in an `en.json` namespace,
which may also name a group of keys that code completes. The static prefix of
a template literal such as `` `EPG.DIALOG.${name}` `` must name a group. Keys
built entirely at runtime are not checked. CI runs `pnpm run i18n:validate` in
the unit test job.
`pnpm run i18n:duplicates` (`tools/i18n/check-duplicates.mjs`) is a report,
not a gate: it lists English strings defined under several keys that a locale
translates differently. Align drifted wording in the locale files; a group may
stay when context needs another grammatical form. `--max <n>` makes it fail
above n groups.
## Performance
@@ -276,7 +318,8 @@ pnpm nx build web
pnpm run perf:initial-bytes # breakdown only
pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline
pnpm nx test performance-tools
pnpm run perf:journeys # J1 launch + J2 open-source journeys, one dist/performance/journeys/<timestamp>/summary.json
pnpm run perf:journeys # J1 launch, J2 open-source and J3 playback journeys, one dist/performance/journeys/<timestamp>/summary.json
pnpm run perf:compositing # tile memory and composited layers per route, dist/performance/compositing/<timestamp>/summary.json
```
`perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the
@@ -285,7 +328,12 @@ bytes on the initial path (the J1 counter `renderer.initialBytes`).
`tools/performance/journey-baselines.json`; baselines only move down. CI runs
the same check in the `Initial bytes ratchet` job of `ci.yml` for PRs that
target `master` and for `master` pushes (dispatch it with
`gh workflow run ci.yml --ref <branch>` for a stacked branch). The weekly
`gh workflow run ci.yml --ref <branch>` for a stacked branch). The
`Performance journeys` job checks the journey counters listed with `--only`
in its `Check the journey counters against the baselines` step against the
same file (warn-only); a new journey baseline
must be added to that list too, which `pnpm nx test performance-tools`
checks. The weekly
`performance-ratchet.yml` workflow lowers baselines through a bot PR; validate
a change to it with `gh workflow run performance-ratchet.yml --ref <branch>`,
which measures but opens no PR off `master`. Dispatch needs the workflow file
@@ -295,13 +343,18 @@ journey spec against the Xtream mock: J1 launch, then J2 open-source (a
second set of launches, each followed by the click on the portal card), both
written to the same summary file; its probe specs run with
`pnpm nx run electron-backend-e2e:test-performance-harness`, which CI runs in
the `Unit Tests and Typechecks` job of `ci.yml` on every run. The
the `Unit Tests and Typechecks` job of `ci.yml` on every run. `tsx` runs them
without type checking; `pnpm run typecheck:spec electron-backend-e2e` checks
the whole project, those specs included (see Unit And Type Checks). The
`electron-backend-e2e` command targets call `tsx` and `playwright` directly,
not through `pnpm exec`: under `pnpm nx`, a nested `pnpm exec` can run from the
workspace root instead of the target `cwd` and miss cwd-relative specs, globs
and configs. The contract, what
counts and how to add a counter or a journey are in the
[performance journeys](performance-journeys.md) document.
[performance journeys](performance-journeys.md) document. `perf:compositing`
measures tile memory and composited layers on the artwork-heavy routes; the
deterministic guard is `compositing.e2e.ts` in the Electron E2E suite
([compositing budget](performance-journeys.md#compositing-budget)).
## Logging
+4 -1
View File
@@ -833,7 +833,10 @@ titles ("Amélie" → "amelie") against an index built from the raw one — with
folding, every accented title was invisible to the FTS path, and two identical
`Amélie` entries produced no candidates at all. The tokenizer is fixed at
CREATE time, so existing databases are recreated and rebuilt once behind
`migration:content-title-fts-remove-diacritics:v1`.
`migration:content-title-fts-remove-diacritics:v1`. That rebuild also records
`migration:content-title-fts-trigram:v1`, the plain rebuild it supersedes;
before it did, a new profile rebuilt the whole index a second time on its
second launch, on the main thread, right after the first catalog import.
`remove_diacritics` needs SQLite 3.45+. The migration probes support on a temp
table first and does nothing when the runtime rejects it, leaving the working
+129 -16
View File
@@ -70,13 +70,17 @@ Render rules:
1. Dashboard rails render independently as their data sources resolve. The
page no longer uses `dashboardReady()` as a page-wide skeleton gate.
Initial hero/recent/favorites loading states render scoped skeletons so one
slow rail does not hide already available content.
slow rail does not hide already available content. A rail skeleton is a
`lib-dashboard-rail-skeleton` with the layout of the rail it precedes,
sized from the rail's own geometry partial, so the rail replaces it at the
same height (UI guidelines, Loading States).
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
full-bleed. All rails and the hero are skipped.
3. The hero (`lib-dashboard-hero`) renders when it has at least one slide;
see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while
it has no slide and any of its sources (history, favorites, Xtream
recently added) is still on its first load, or a live candidate still
it has no slide and any of its sources (history and its playback
positions, favorites, Xtream recently added) is still on its first load,
or a live candidate still
waits for its first programme answer (portal or XMLTV, for at most
`DASHBOARD_HERO_LIVE_ANSWER_WAIT_MS`, 2 s, from the hero's creation).
Dropping it earlier removed the hero and inserted it again when a later
@@ -108,17 +112,31 @@ Render rules:
`clamp(320px, 42vh, 520px)` so the first rail starts above the fold, and uses
`--app-content-bg` as its scrim so it dissolves into the page in both themes.
Height: that clamp is a floor, not a fixed height. Every slide's content is
laid out in the same grid cell at the bottom of the banner, so it is as tall
as its tallest slide whichever one is shown, and an unusually full slide or a
long translation grows it instead of being cut. Only the active slide is
shown; the others are `inert` and `visibility: hidden`. When only the shown
slide was in flow, each automatic rotation between slides of different
heights resized the banner and moved every rail below it, every 8 s on an idle
dashboard. Late data (TMDB extras, the live slide's first EPG answer, the next
programme) can still grow the tallest slide, once, when it arrives.
Slides (`pickDashboardHeroSources`, at most four, stable order, each title
once):
1. the newest unfinished movie/series (`isPortalPlaybackWatched` rows skip);
1. the newest unfinished movie/series (`continueWatchingItems()`, the rail's
list: a finished movie skips, a series features the episode it continues
with);
2. a live channel with a programme on air — the first of
`selectDashboardHeroLiveCandidates` (up to three favourites, then two
recently watched channels) whose EPG answer has a title;
3. one favourite movie/series and one Xtream recently-added title;
4. remaining places round-robin over the next items of those lists;
5. only when nothing qualifies, the newest history row of any kind (a
detail action: it can be a finished title).
detail action: it can be a finished title). It waits for
`continueWatchingSettled()`: before the positions load, that row may be
the title the resume slide is about to feature.
While live candidates exist but none has answered yet, one place stays
reserved for the live slide, so its late arrival never evicts a slide the
@@ -161,9 +179,11 @@ 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
slides 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).
and a title's reads "N% watched". The dots are 26 × 24px targets (WCAG 2.5.8)
of one fixed width; the active pill is the same 18px bar with its
`clip-path` opened, so a slide change moves no dot.
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
@@ -176,10 +196,14 @@ region: ←/→ switch slides and Enter follows the primary action. The active s
by id, so a late live slide never moves the user off the current one. Test
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
`dashboard-hero-dot`, `dashboard-hero-pause`,
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`.
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`. The
slide hooks mark the shown slide only; the inert slides carry none.
`dashboard-hero-rotation.e2e.ts` drives the real fill animation (with a
shortened `--hero-rotation-ms`) to prove its `animationend` still advances
and that pause holds the slide.
and that pause holds the slide. `dashboard-hero-legibility.e2e.ts` sums the
layout shifts of an unattended rotation, at the wide width and again at the
narrow one once every slide carries a rating and an overview: under 0.001 in
all, and none inside the hero.
## Rail Contract
@@ -218,10 +242,88 @@ and that pause holds the slide.
2. It derives the dashboard surface via `computed()`:
1. The hero slides — built by `DashboardHeroSlidesPresenter`, see
[Cinematic Hero](#cinematic-hero).
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
cover cards. Portal playback positions are bulk-loaded per playlist so
hero and cards can show progress, remaining time, and series season/
episode badges. Whether an item is looked up as a movie (one `vod`
2. `continueWatchingCards` — maps `continueWatchingItems()` to movie/series
cover cards. The list and its loading gate are built by
`createDashboardContinueWatching` (`dashboard-continue-watching.ts`)
from the history and the playback positions `DashboardDataService`
holds, which exposes them as `continueWatchingItems()` and
`continueWatchingSettled()`. A movie stays until its position reaches
`PORTAL_WATCHED_PROGRESS_PERCENT` (`isPortalPlaybackWatched`). The
threshold is 90%, the Plex/Jellyfin/Emby/Kodi default, so stopping
during the end credits finishes a title, as does "Mark watched". A
series stays while it has something to continue: once its newest
episode is watched, or is an extra (season 0),
`DashboardSeriesEpisodesService` fetches its episode list
(`get_series_info`, two lookups at a time) and
`resolveDashboardSeriesContinuation` applies
`getSeriesNextUp`, the rule the series page's quick start follows
too. Like Plex's On Deck and Jellyfin's Next Up it goes on from the
episode watched last, not from the first gap in the list: the first
episode after it that is not watched, so a series started at season
2 or with an episode skipped (or left unfinished) goes on after the
episode actually watched. A started next episode resumes where it was
left. Extras (season 0) stand apart when the series has other
seasons: they never come next, never keep the series on the rail and
never take its card, not even one left unfinished; a series filed
under season 0 alone has them as its run. The card then shows and
resumes that episode; a next episode not started yet is a
synthesized row at 0:00 without duration, so it carries the S·E
badge but no progress, remaining time or "Mark watched". The series
leaves once no unwatched episode follows the one watched last, even
if an earlier one was skipped or extras are left. Until its list is
in (loading, failed, empty, or not holding the episode played last:
a list cached before that episode is fetched again in the next
round), for
series that cannot be looked up (Stalker), and for series never
looked up, a series keeps its newest episode rather than vanish on a
guess. `planDashboardSeriesLookups` picks the series to look up:
newest first until `CONTINUE_WATCHING_VISIBLE_ITEMS` (20, the rail's
`RAIL_ITEM_LIMIT`) titles are known to stay listed, counting movies
and series that need no lookup; a finished series frees its slot, so
an older one is looked up next, up to
`CONTINUE_WATCHING_SERIES_LOOKUP_LIMIT` (40) lookups. Lists are kept
per session and per source (server and username: an edited playlist
is looked up again, and a late answer for the old source is
dropped). Lookups are background requests (`suppressErrorLog`: a
failure is logged, never a toast). Each `reloadPlaybackPositions()`
that lands (dashboard entry, a changed history, "Mark watched") is a
lookup round: a failed lookup is tried again once
`DASHBOARD_SERIES_EPISODES_RETRY_DELAY_MS` (5 min) has passed (at
once when the playlist's password is corrected), and a list older
than `DASHBOARD_SERIES_EPISODES_MAX_AGE_MS` (1 h) is fetched again so
newly added episodes show up, the old list standing until the new
one arrives (a failed refresh keeps it, and waits for the same delay).
A source that failed two lookups in a row is not asked for its other
series until the delay has passed either: a portal that is down or
expired costs two requests per delay, not one per series per dashboard
visit. Within one round, more
series to look past never repeat a request, and the request list
compares by value, so playlist store churn elsewhere in the app does
not trigger lookups. Finished titles
stay in `globalRecentVodItems()` and on the Global Recent page ("See
all");
the rail's count badge counts only the listed ones. The list stays
empty until `continueWatchingSettled()`: the history has loaded (on
the PWA that includes its Xtream data-source read, not only the
playlist inventory), `reloadPlaybackPositions()` has covered every
playlist in it once, and the episode lists the first open asked for
have answered or `CONTINUE_WATCHING_SERIES_LOOKUP_WAIT_MS` (2 s) has
passed, so on first open the rail inserts once instead of listing
finished titles and dropping them a moment later. A playlist whose
positions failed to load counts as covered. The gate stays open
afterwards: like the history, a dashboard opened again first renders
the positions it already holds and refreshes them on entry, so a title
finished since the last visit leaves the rail, or a series moves on to
its next episode, when that refresh (and, for a series not looked up
yet this session, its episode list) lands.
`reloadPlaybackPositions()` applies only its latest call's result, so
an older read finishing last cannot bring finished titles back.
Portal playback positions are bulk-loaded per playlist
(`loadDashboardPlaybackPositions`), every playlist at once (one `Promise.all`, not one await per playlist: the
single-threaded DB worker then answers the set in one pass, and a
long request it is busy with delays the set once, not once per
playlist), so hero and cards can show progress, remaining time, and
series season/episode badges. Whether an item is looked up as a movie (one `vod`
row) or a series (episode rows under the parent id) is its WATCH
kind, `resolvePortalActivityWatchKind`, not its routing `type`. The
shape that needs the distinction is a Stalker embedded-VOD row: its
@@ -231,7 +333,11 @@ and that pause holds the slide.
rows. The mappers give both it and a lazy Ministra `is_series` row
(already typed `series`) `watch_kind: 'series'`. Series lookup uses
keyed maps for both direct episode ids and parent series ids; card
renders must not scan the full playback-position map. The badge uses saved `seasonNumber` /
renders must not scan the full playback-position map. A series'
newest row is the latest `updatedAt`; rows saved in the same second
(SQLite keeps whole seconds, and "Mark season watched" stamps a
season at once) go to the later episode, as `getSeriesNextUp` breaks
the tie. The badge uses saved `seasonNumber` /
`episodeNumber` metadata and does not infer it from provider payloads;
legacy rows without that metadata remain badge-less until replay.
Dashboard-originated Xtream and Stalker series clicks also carry that
@@ -340,8 +446,15 @@ and that pause holds the slide.
5. `recommendationCards` / `trendingCards` — the two TMDB rails. Both
need the TMDB opt-in AND the Electron DB worker that answers
`DB_MATCH_TITLES` (each is hidden in the PWA), and both load after
`globalFavoritesLoaded()` so the batched title match never competes
for the worker at startup. `recommendationCards` is seeded from
`globalFavoritesLoaded()` AND `continueWatchingSettled()`
(`connectDashboardTmdbRails` in `rails/dashboard-tmdb-rails.ts`) so
the batched title match never competes
for the worker at startup: one match runs a ranked FTS query per
title and a common single word holds the worker for seconds on a
large catalog; issued before the playback positions, it queued them
behind it and kept the Continue Watching and hero skeletons up for
as long as it ran (measured 14 s warm and 97 s cold on a 3.9M-title
library). `recommendationCards` is seeded from
recently watched movies/series and only shows titles present in an
imported library, hiding itself below five matched cards; its rail
label names the seed ("Because you watched X") when exactly one
+162 -29
View File
@@ -65,19 +65,19 @@ Compatibility redirect:
Provider route integration:
1. `apps/web/src/app/app.routes.ts` marks the `/workspace` root route with
`data.layout = 'workspace'`.
2. `isWorkspaceLayoutRoute(...)` treats that layout marker as inherited route
state for all descendants.
3. Xtream and Stalker parent routes attach route-scoped session providers that
1. Every routed view renders inside the workspace shell (the only top-level
paths outside `/workspace` are redirects), so views keep no standalone
layout branches: the shell header owns search, Back and the playlist
switcher.
2. Xtream and Stalker parent routes attach route-scoped session providers that
bootstrap the active playlist, sync provider section state, and clean up
provider-local state when the route is destroyed.
4. Xtream route bootstrap is DB-first for already imported Electron playlists:
3. Xtream route bootstrap is DB-first for already imported Electron playlists:
if the requested section has persisted categories and content, the route
hydrates from SQLite even when the portal status probe reports unavailable,
expired, or inactive. Fresh/no-cache Xtream routes still use the status probe
to block remote imports before the loading overlay starts.
5. Workspace routes no longer rely on nested provider shell components for
4. Workspace routes no longer rely on nested provider shell components for
hidden local chrome.
## Shell Structure
@@ -107,6 +107,77 @@ The shell is intentionally split into four persistent regions:
4. Optional footer:
1. External playback session bar when a docked session is visible.
### Content surface corner
`main.workspace-content` (the scroller) sits in `.workspace-content-frame`.
The frame carries the surface's 16px top-left radius and its shadow and never
clips; the scroller clips to a rectangle and is a stacking context; a 16px
corner piece on the frame paints the notch in the body colour. The radius used
to sit on the scroller, which made Blink clip every composited effect inside it
(the rail chevrons' and hero controls' `backdrop-filter`, running transform
animations, isolated groups) through a mask layer synthesized per effect: its
shader path for rounded clips needs four equal radii on macOS and a
translation-only transform between the clip and the effect, and the fallback
mask is the size of the whole clipped area, about 24 MB of tile memory at 2x
on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB
tile budget (`tile memory limits exceeded`, blank tiles while scrolling).
`apps/electron-backend-e2e/src/compositing.e2e.ts` guards it: no
drawing layer without an owner node and without a compositing reason (a
synthesized mask, unlike any content layer) may span half the content area,
and the backdrop-filtered controls must really be composited, so the check
cannot pass on a page with nothing to clip.
### Layout slides
When a route change adds or removes the context panel, the content frame
moves by the panel's width (323 px on desktop), and when Back or the phone
drawer toggle appears or leaves, the header controls after it move by the
slot's width (44 px). Both happen in the frame that swaps the page, so they
used to be one jump: a layout shift of 0.233 in the open-source journey,
0.222 of it the content frame (see
[performance journeys](./performance-journeys.md#j2-open-source-open-a-source-to-a-browsable-list)).
`LayoutSlideDirective` (`workspace-shell/layout-slide.directive.ts`) turns
these jumps into a 180 ms slide, the duration of the context panel's own
width transition. The new layout applies at once; each target starts
translated back to the position it had and animates its transform to none
(FLIP), so nothing waits for the animation and Chromium records no layout
shift for a transform. The shell's `.workspace-body` slides
`.workspace-content-frame` when `showContextPanel` changes; the header slides
the drawer toggle, switcher, search and action groups when its leading
navigation (Back, drawer toggle) changes.
1. **Previous positions.** `offsetLeft` values, which ignore transforms, are
recorded after every render in which the slide key or the record key
changed (the header's record key covers the other inputs that move its
controls: settings route, no playlists, parental lock, shortcuts) and
when the host or a target resizes (window resize, a context panel folding
or being dragged wider). A slide therefore starts where the target was
last laid out, and a target that has no recorded position or did not move
is left alone.
2. **The uncovered strip.** The frame is narrower beside the panel than
without it, so the side it moves away from would show the body colour
during the slide. While a slide runs the target carries
`layout-slide-active` and the uncovered widths as `--layout-slide-start`
and `--layout-slide-end`; the frame's stylesheet extends the content
surface over that strip with an `::after` below the scroller.
3. **Back fades in** over the same 180 ms, so the switcher still sliding away
from its slot does not draw over the arrow.
4. **Reduced motion.** Under `prefers-reduced-motion` there is no animation:
the jump (and its layout shift) remains.
5. **Compositing.** While it runs, each slid target is its own composited
layer (`ActiveTransformAnimation`): the frame at its width plus the
covered strip (1220×664 CSS pixels at 1280×720), the header controls a
few small ones. No synthesized clip mask appears, because no ancestor of
the frame clips with a radius ([content surface
corner](#content-surface-corner)), and the layers go away when the slide
ends: `perf:compositing` reads the same layer and mask counts on every
route as before the slides (#1895).
6. **Not covered.** A slide does not change what the route renders. Going
back from a portal to the dashboard still shows one frame with the new
dashboard below the old page before the old page leaves; that jump is a
routing issue, not a layout slide.
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
services:
@@ -119,11 +190,18 @@ services:
3. `WorkspaceShellSearchSyncService` owns the search query signals, debounced
application, provider-store synchronization, and query-param sync.
4. `WorkspaceShellHeaderService` owns playlist title/subtitle, account/info
actions, refresh action state, and recent-items bulk cleanup.
actions, and refresh action state. It has no clear action: clearing
recently viewed or favorites belongs to the collection page.
5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle
and recent-command recording.
6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay
state and labels.
state and labels. Remote import phases and refresh preparation of the
shown playlist (of any playlist on the dashboard) show the overlay at
once. A session that has only read the saved catalog (`loading-cached`,
`isImporting` still false) shows it only after
`XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS` (300 ms), so a warm open goes from
the skeleton to the page without flashing the overlay, while a slow or
stuck cache read still blocks and stays cancellable.
When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
@@ -146,9 +224,9 @@ label (else the translated "Back"), whether Escape on the page runs it, and
| 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 |
| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's history Back; parent: the catalog section Discover lists (`vod` for movies, `series` for TV), the portal's default section for actor | (no drawer) |
| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | history Back; parent: the portal's default section | (no drawer) |
| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | history Back; parent: the first workspace view (`WorkspaceStartupPreferencesService.resolveDashboardPath()`: the dashboard, or sources when it is hidden) | beside the drawer toggle |
Detail-page semantics (Escape, browse and watch) are in
[Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus).
@@ -167,11 +245,37 @@ 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.
**Parent fallback.** "Parent" in the table above: a registered page whose
Back is history Back calls `WorkspaceBackNavigationService.back(resolveParent)`
instead of `Location.back()`. It runs `Location.back()` while the previous
entry is an in-app one, by the same Navigation API test as the history
fallback. Without the API (older Safari and Firefox) the router's history
depth decides (`trackRouterHistoryDepth`): the document's first navigation is
depth 0, a push adds one, a replacement keeps it and a traversal restores the
depth recorded for its entry. The lazy workspace shell creates the service
after the first navigation began, so the tracker adopts the router's current
or last navigation: a first one is depth 0, a later one leaves the depth
unknown. A traversal to an entry from before a reload
leaves the depth unknown and keeps `Location.back()`, which then has a
previous entry. Otherwise the page opened
the session (a deep link, a reload or a restored view), where
`Location.back()` does nothing in Electron and leaves the app in a browser.
The service then navigates to the page's parent with `replaceUrl`, so history
Back cannot return to the page just left; with nothing in-app before it, the
parent shows no history fallback. The resolver returns a URL or router commands, may be asynchronous, and
returns null when the page knows no parent, which keeps `Location.back()`.
Portal pages build their parent with `workspacePortalCommands()`
(`@iptvnator/portal/shared/util`) from the route's `:id`; without a section,
the portal route's `redirectTo` picks the default section within the same
navigation, so the replacement still applies. Detail pages keep their own
return logic (`backClicked`).
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.
TV), where a disabled arrow would stay for the whole session. When Back first
appears or leaves, which happens only at the start of the history and together
with a route change, the switcher and search slide by the slot's width instead
of jumping (see [Layout slides](#layout-slides)).
There is no Forward button: Stalker inline details are store state, not
history entries, so Forward would skip them.
@@ -217,7 +321,10 @@ Xtream and Stalker category panels preserve provider/server category order by
default. The panel header exposes a sort menu next to category search with
`Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active,
synthetic "all categories" entries stay pinned before sorted provider
categories.
categories. Search and sort render disabled while the categories load (beside
the disabled Manage categories button), so the header row has its final width
under the skeleton instead of growing by two buttons when the list arrives;
they are hidden only once a load ended with no categories.
On live sections the category panel can be folded away independently of the
channels list; the folded panel is reachable as a popover from the channels
@@ -390,15 +497,32 @@ 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. 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.
`trafficLightPosition` from `MACOS_TRAFFIC_LIGHTS_POSITION` in
`@iptvnator/shared/interfaces`); the renderer draws no window buttons.
The lights sit in the header band (`--workspace-header-band`, 56 px) over
the rail and the header's leading padding. The macOS rail
(`.app-rail.is-macos`) starts its first link below the band: level with
the content area and the dashboard hero, with its hover surface clear of
the lights. The header's content starts 84 window pixels from the
window's left edge (60 px rail plus 24 px padding). macOS 26 ends the
lights at 76 (earlier releases at 68), which is where Back's left edge
sits at 100 % because of its 8 px pull-in.
App zoom (see "Zoom level") scales CSS pixels but not the lights. On
macOS, `TrafficLightsClearanceDirective` on `.workspace-shell` reads the
page zoom factor (`outerWidth / innerWidth`, refreshed on `resize`) and
publishes the clearance in CSS pixels as `--traffic-lights-clear-x` (84
window pixels) and `--traffic-lights-clear-y` (48: the lights' bottom
plus a gap). Zoomed out, the band grows to the vertical clearance, so the
lights never overlap the content area. The header's leading padding grows
to the horizontal clearance, less the rail column
(`--workspace-header-lights-inset`). At 100 % both match the default
layout. Off macOS nothing is published and the defaults apply. The phone
layout, which puts the rail in a row above the header, ignores the
inset. `window-controls.e2e.ts` ("macOS traffic lights") checks the rail
alignment, the first header control (the switcher on the first page,
which has no history fallback yet, then a detail page's Back) and the
content top at default and minimum zoom.
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
@@ -510,15 +634,24 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455):
3. `fullscreen` is the `BrowserWindow` constructor option: on Windows/Linux
the window is created hidden and enters fullscreen before its first
paint. macOS ignores the option while the window is hidden (an NSWindow
only toggles fullscreen once it is on screen), so `ready-to-show` repeats
only toggles fullscreen once it is on screen), so the first show repeats
the request with `setFullScreen(true)` right after `show()` wherever
`isFullScreen()` is still false — never unconditionally, or the
platforms that honoured the option would animate a second toggle. The
saved bounds stay spread into the options — they are the normal bounds
the window returns to, and the close handler keeps persisting
`getNormalBounds()`. `maximized` calls `maximize()` inside
`ready-to-show` right before `show()`, never earlier: `maximize()` on a
hidden window shows it, and a blank window would flash.
`getNormalBounds()`. `maximized` calls `maximize()` right before the
first `show()`, never earlier: `maximize()` on a hidden window shows it,
and a blank window would flash. That first show happens at
`ready-to-show` or the main frame's `did-finish-load`, whichever comes
first (`services/main-window-first-show.ts`): on Linux a hidden window
whose startup scripts ran before its first frame gets the next one about
a second later, so `ready-to-show` alone left the window off screen and
the splash's animation frame waiting. At `did-finish-load` the inline
splash is parsed, and the window's `backgroundColor` is the splash colour
(`MAIN_WINDOW_BACKGROUND_COLOR`, keep it in sync with `#initial-splash`
in `apps/web/src/index.html`), so showing before the first paint does
not flash.
4. `iptvnator --fullscreen` (read via `app.commandLine.hasSwitch`, so it can
sit anywhere in argv; the playlist-path extractor already skips every
`-`-prefixed argument) forces `fullscreen` for that launch only and is
+19
View File
@@ -401,9 +401,11 @@ sometimes only respond to that misspelled action.
| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active |
| `minimal:minimal` | 3003 | 2 each | 5 | active |
| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `epglong:epglong` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
| `streamdrop:streamdrop` | 8181 | series:1 | 1 | active |
| `expired:expired` | 4004 | 4 each | 10 | Expired |
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
| `<any other>` | hash | 6 each | 30 | active |
@@ -421,6 +423,23 @@ That deliberate mismatch lets Electron tests verify the renderer uses timestamp
fields for sorting, current-program selection, progress bars, and local clock
labels instead of trusting provider-local strings.
Timeshift URLs of `epg` and `epglong` play the local MPEG-TS clip
(`src/fixtures/live.mpegts`). `epglong:epglong` is the same fixture with
`LONG_PROGRAMME_TITLE`, a 120-character title, on the past programme, for
the catch-up seek bar's label bounds; `epg:epg` keeps its short titles,
which other tests assert verbatim.
### `streamdrop:streamdrop` fixture details
Its three episodes play the local 60 s Matroska clip
(`src/fixtures/episode.mkv`, generation command in the fixtures README) with
byte ranges, but `dropping-episode.ts` cuts the first response that reaches
35 % of the file and answers 403 for the next 4 s, so ffmpeg's own HTTP
reconnect fails and mpv reports EOF mid-episode. `POST /reset` re-arms the
drop. The Electron E2E `embedded-mpv-series-stream-drop.e2e.ts` uses it to
check that Embedded MPV resumes the episode instead of autoplaying the next
one (issue #1902).
### `marketing:marketing` fixture details
This scenario is reserved for release screenshots and marketing materials:
@@ -37,6 +37,58 @@ See [Nx Workspace Boundaries](./nx-workspace-boundaries.md),
[Portal Detail Navigation](./portal-detail-navigation.md), and
[VOD Multi-Source](./vod-multi-source.md).
## PWA Collection Persistence
Collection operations carry `{ id, type }`: `id` remains the SQLite row ID
on Electron and the provider ID on PWA. Electron unwraps the row ID at its
existing database boundary. Typed lookups, favorite toggles, recent updates,
removals, backdrops and backup/restore retain the content type. A favorite toggle
reads persisted status for its exact typed target instead of borrowing the last
detail's favorite state. Live history uses the same confirmed-playback gate as
VOD and series: inline playback confirms its playlist-scoped session key, while
external playback confirms its URL. The initiating playlist and typed identity
remain captured across delayed confirmation. Selection without playback or a
resolved URL does not update history. See the
[playback-history contract](./embedded-inline-playback.md#recently-viewed-confirmation).
PWA keeps the existing localStorage keys. `xtream-favorites` contains ordered
`type:id` keys (for example `movie:42`); `xtream-recent-items` uses the same
keys in each entry's `id`, retaining `viewedAt` and backdrop metadata.
`xtream-collection-items` stores snapshots under those typed keys while each
snapshot's `id` and `xtream_id` remain numeric provider IDs. Hydration fetches
only the required content types; snapshots support collection reads offline.
Typed add/remove/status operations use synchronous local migration and never
wait for unrelated catalog hydration. `PwaCollectionStorage` owns that local
migration and snapshot persistence; the data source owns provider hydration for
collection loading and legacy numeric callers that still need identity evidence.
Legacy numeric and numeric-string references migrate lazily. A valid legacy
snapshot supplies its saved content type; otherwise all three catalogs must
be loaded successfully and exactly one type must match (repeated rows within
that same type are one identity). A partial catalog,
failed hydration, missing item or colliding ID never chooses a type. Such
references remain stored but hidden until identity can be resolved or the
user explicitly adds a typed item. Legacy numeric snapshot entries remain as
migration evidence for the other collection. Already-overwritten snapshots
cannot recover information lost by an older version. Ambiguous references
cannot be included in typed backup exports; the original browser storage is
retained. Migration merges into current storage after hydration, preserving
concurrent additions/removals, and duplicate recent references keep the newest
timestamp.
Migration writes and snapshot-cache writes are best effort when browser storage
is full. Reads still resolve the retained legacy evidence in memory. Typed
status and mutations match original numeric references through that evidence;
removal filters the original stored keys without first expanding the remaining
keys. Typed favorite-status checks never write storage. Additions save the
requested reference before attempting optional migration or snapshot copies, so
those copies cannot consume space needed by the requested save. Recent aliases
are deduplicated in memory before the 50-item limit, keeping the newest timestamp
and its original saved key until optional migration. Favorite/recent
writes requested by the user still propagate storage failures. A new reference
that fits can be saved without its optional snapshot and hydrated on a later
collection load.
## Connection Input
Xtream server URLs are normalized through
@@ -100,6 +152,22 @@ connection failures, never reported as provider HTTP statuses or used to
authorize HTTP. Older backends without the envelope cannot authorize HTTP discovery.
Provider JSON remains nested in `payload` and cannot provide this evidence.
Electron resolves two more outcomes instead of rejecting them, because Electron
logs every rejected `ipcMain.handle` promise as a handler error with a stack
trace: a cancelled request (`XTREAM_CANCEL_SESSION`, a cancelled or expired
source-health probe) and an HTTP 401/403. Both return a
`{ portalRequestFailure }` envelope
(`libs/shared/interfaces/src/lib/portal-request-failure.util.ts`, classified in
`apps/electron-backend/src/app/events/portal-request-outcome.ts`) that
`ElectronService.forwardXtreamRequest` turns back into what its callers already
handle: health probes get an `AbortError` or an `HTTP Error <code>` Error
carrying `status`; catalog calls get the usual `{ type: ERROR, status, message }`
result — status 499 and no snackbar for a cancellation, the real 401/403
(previously lost as `[object Object]`) for a refusal. The main process logs a
refusal as one credential-free `console.warn` (unless `suppressErrorLog`), a
cancellation only under `IPTVNATOR_TRACE_IPC`, and everything else as before
at error level with a rejection.
The saved base drives catalog refresh, provider EPG, live/VOD/series/catch-up URL
construction and fresh Favorites/Recent resolution. The routed Xtream session
observes metadata connection changes and bootstraps the new connection. Separate
@@ -276,6 +344,12 @@ resolves. An unresolved source exposes no actions. Playback and download
descriptors also fall back to the catalog title and poster after `info` and
`movie_data`.
Provider stand-ins in `get_vod_info` / `get_series_info` (`N/A`, `-`, an
unrated `0` score) are emptied before the response becomes the store
selection (`stores/xtream-detail-metadata.ts`), since both detail routes bind
`info` fields directly. A `get_vod_info` response made only of stand-ins
therefore counts as sparse and gets the fallback page above.
## Catch-Up Playback URLs
Xtream-compatible portals differ on archive playback URL shape. IPTVnator
+139 -72
View File
@@ -50,38 +50,43 @@ must be ticked here.
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.
4. [x] `provideZonelessChangeDetection()` behind a build-time
`fileReplacements` flag, off by default; the three implemented journeys
(J1-J3; J4 is still planned) and the Electron E2E suite run with it on
(see [Zoneless flag](#zoneless-flag)).
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
65 production files, 66 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`.
files whose component metadata still sets
`changeDetection: ChangeDetectionStrategy.Eager` (comments do not count).
The settings sections read form values in their templates and the parent
patches the form outside their events (Discard, backup import, the EPG file
picker), so each marks itself on the form's `events` through
`markSectionForCheckOnFormEvents` (`apps/web/src/app/settings`).
### 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`
- [x] `apps/web/src/app/app.component.ts` (idle audit root)
- [x] `apps/web/src/app/app-update-notification-panel.component.ts` (idle audit root)
- [x] `apps/web/src/app/settings/app-update-release-notes-dialog.component.ts`
- [x] `apps/web/src/app/settings/settings.component.ts`
- [x] `apps/web/src/app/settings/settings-about-section.component.ts`
- [x] `apps/web/src/app/settings/settings-backup-section.component.ts`
- [x] `apps/web/src/app/settings/settings-dashboard-section.component.ts`
- [x] `apps/web/src/app/settings/settings-delete-all-playlists-dialog.component.ts`
- [x] `apps/web/src/app/settings/settings-epg-section.component.ts`
- [x] `apps/web/src/app/settings/settings-general-section.component.ts`
- [x] `apps/web/src/app/settings/settings-playback-section.component.ts`
- [x] `apps/web/src/app/settings/settings-remote-control-section.component.ts`
- [x] `apps/web/src/app/settings/settings-tmdb-section.component.ts`
- [x] `apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts`
### libs/ui (20 files, 21 components)
@@ -97,14 +102,14 @@ files that still contain `ChangeDetectionStrategy.Eager`.
- [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`
- [x] `libs/ui/playback/src/lib/art-player/art-player.component.ts`
- [x] `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
- [x] `libs/ui/playback/src/lib/external-player-info-dialog/external-player-info-dialog.component.ts`
- [x] `libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts`
- [x] `libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts`
- [x] `libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts`
- [x] `libs/ui/playback/src/lib/vod-details/vod-details.component.ts`
- [x] `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.
@@ -114,47 +119,48 @@ files that still contain `ChangeDetectionStrategy.Eager`.
### 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`
- [x] `libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-collection-context-panel.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts`
- [x] `libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts` (idle audit root)
- [x] `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`
- [x] `libs/playlist/import/feature/src/lib/add-playlist-dialog/add-playlist-dialog.component.ts`
- [x] `libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts`
- [x] `libs/playlist/import/feature/src/lib/file-upload/file-upload.component.ts`
- [x] `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts`
- [x] `libs/playlist/import/feature/src/lib/text-import/text-import.component.ts`
- [x] `libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts`
- [x] `libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts`
- [x] `libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts`
- [x] `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts`
- [x] `libs/playlist/shared/ui/src/lib/source-health/source-cleanup-dialog.component.ts`
- [x] `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 (8)
- [ ] `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`
- [x] `libs/portal/shared/ui/src/lib/components/playlist-error-view/playlist-error-view.component.ts`
- [x] `libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-favorites-button/stalker-favorites-button.component.ts`
- [x] `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- [x] `libs/portal/xtream/feature/src/lib/global-search-results/global-search-results.component.ts`
- [x] `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts`
Test stubs that set Eager (`*.spec.ts`, `*.spec-stubs.ts`, `*.spec-data.ts`,
`*.test-helpers.ts`) are not listed; they do not ship.
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
@@ -165,15 +171,16 @@ 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 |
| [x] | `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 |
| [x] | 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 |
| [x] | `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 |
| [x] | `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 |
| [x] | `libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts` (device-id derivation) | `form.patchValue` after `await`; no fix needed: while derivation is on `hasManualDeviceIds` is false whatever the IDs are, the controls write their own DOM values, and typing goes through value-accessor listeners that mark the view | playlist |
| [x] | `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 |
| [x] | `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 |
| [x] | same file, programme dialog `afterClosed` | deletes from `epgPrograms`/`currentProgramsProgress` after `await` without marking | portal |
| [x] | `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`; each section now marks itself on its form's `events` (`markSectionForCheckOnFormEvents`), which `settings.component.form.spec.ts` guards without a forced render | apps/web |
| [x] | `libs/ui/epg/src/lib/epg-guide/epg-guide.component.ts` (jump to now, keyboard focus) | `afterNextRender` registered from CDK/RxJS callbacks; zone.js followed them with a tick, zoneless schedules no render, so the guide opened at midnight. It now marks itself when it registers the hook. Found by `epg-guide.e2e.ts` on the zoneless build | flag |
| [ ] | `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
@@ -182,10 +189,10 @@ 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
- [x] `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`:
- [x] `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.
@@ -243,6 +250,66 @@ their events arrive over IPC.
`apps/web/src/setup-jest.ts` (no project, tsconfig or Jest config uses
them).
## Zoneless flag
`app.config.ts` takes its change-detection providers from
`apps/web/src/environments/change-detection.providers.ts`
(`provideZoneChangeDetection({ eventCoalescing: true })`). The
`electron-performance-zoneless` and `electron-e2e-zoneless` web
configurations are their base configuration plus one `fileReplacements`
swap to `change-detection.providers.zoneless.ts`
(`provideZonelessChangeDetection()`); a test in
`performance-build-config.spec.ts` pins that and refuses the swap in any
other configuration. zone.js stays in the polyfills, so these builds log
NG0914 in dev mode and nothing schedules through the zone. The Electron app
loads the renderer from `dist/apps/web`, so rebuilding only the web app
switches an existing Electron build:
```bash
pnpm nx run electron-backend:build-performance # or build-e2e
pnpm nx run web:build:electron-performance-zoneless # or electron-e2e-zoneless
cd apps/electron-backend-e2e
../../node_modules/.bin/playwright test --config=playwright.journeys.config.ts
../../node_modules/.bin/playwright test --grep-invert packaged
```
Do not run `pnpm run perf:journeys` or `pnpm nx run electron-backend-e2e:e2e`
afterwards: their build dependencies restore the zone.js renderer.
First measurement (macOS, 2026-10-04, the six OnPush PRs merged locally on
the flag branch; the same integration build measured with the flag off and
on, five iterations each):
| Counter | flag off | flag on |
| --- | --- | --- |
| `renderer.cdTicksToFirstCard` | 22, 20, 22, 21, 21 | 7, 7, 7, 7, 7 |
| `renderer.cdTicksIdle30s` | 4, 4, 4, 4, 4 | 3, 3, 3, 3, 3 |
| `renderer.cdTicksToFirstPage` | 22 (all) | 8 (all) |
| `renderer.cdTicksToPlaying` | 16, 15, 15, 17, 15 | 6, 8, 10, 10, 7 |
| DOM mutations J1 / J2 / J3 | 553 / 1,603 / 6,182 | 553 / 1,603 / 6,182 (one J3 iteration 6,199, as on master) |
| `spawnToFirstCardMs` p50 | 3,071 | 828 |
| `clickToFirstPageMs` p50 | 82.8 | 80.4 |
| `clickToLoadedMetadataMs` / `clickToPlayingMs` p50 | 94.8 / 268.7 | 158.3 / 409.3 |
J1 and J2 tick counts become deterministic without the zone.js one-tick
race, and the DOM mutations are unchanged, so nothing renders differently.
J3's tick count still varies with player events and its wall-clock
numbers rose locally; the machine was shared with other runs (load 26 to 58
during these two runs, master itself read 363.8 ms `clickToPlayingMs` p50
earlier the same day), so judge J3 on the CI runner before the flip.
Electron E2E suite on `electron-e2e-zoneless` (all specs except the
packaged frame-copy ones): 203 passed, 7 skipped, 3 failed. `epg-guide`
failed on every run and is fixed above; `playlist-auto-refresh` passed on
`--repeat-each=2`; `dash-clearkey` "reopens from recent and favorites" is the
known local flake (it fails as often on master). The IPC-driven paths
passed zoneless: external-player launch states and the MPV/VLC DASH
fallbacks in `dash-clearkey`, the MPV double-click gate in `settings`,
`remote-control`, `picture-in-picture` and `stream-info`. Their state reaches
the renderer over IPC into signals and never ran in the zone. Embedded MPV
playback itself is covered only by the packaged frame-copy E2E, which these
runs left out; run it on a packaged zoneless build before the flip.
## Measuring a PR
Build `electron-performance` and run the journeys as described in
+1 -1
View File
@@ -24,7 +24,7 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
```
- Narrower trace flags:
- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls
- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls and reports cancelled Stalker/Xtream portal requests, which are otherwise not logged (an HTTP 401/403 is always one `Refused` warning; other failures stay errors)
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
+1
View File
@@ -38,6 +38,7 @@ are not prerequisites for reading repository contracts.
| Web engines, controls, tracks, PiP, radio and display sleep; `libs/ui/playback` | [Player controls](../architecture/player-controls-contract.md), [inline playback/diagnostics/recovery](../architecture/embedded-inline-playback.md) | Read the contract directly |
| Embedded MPV, platform engines, addon, pinned runtime and packaging; Electron native services, `tools/embedded-mpv` | [Native MPV](../architecture/embedded-mpv-native.md), [runtime build and licensing](../../tools/embedded-mpv/README.md) | Read the contract directly |
| Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
| Rendering cost of UI: `backdrop-filter`, `filter`, `will-change`, `isolation`, transform/opacity animations, rounded or path-clipped scrollers; tile memory | [Compositing budget](../architecture/performance-journeys.md#compositing-budget), [workspace shell](../architecture/workspace-shell.md#content-surface-corner) | [Theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
| Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes |
| Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes |
| Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill |
@@ -0,0 +1,214 @@
# Server Storage for the Self-hosted PWA — Design
Date: 2026-10-08, revised 2026-10-11 against `master` at `7abf479de`.
Status: approved direction ("the server is the single source of truth in
self-hosted mode"). Implementation plans:
- `.plans/2026-10-11-server-storage.md` (overview and order)
- `.plans/2026-10-11-server-storage-phase-1-seams.md`
- `.plans/2026-10-11-server-storage-phase-2a-backend.md`
- `.plans/2026-10-11-server-storage-phase-2b-pwa.md`
Upstream demand: #305 (store playlists on the server for the Docker version,
open since 2023), #514 (closed as its duplicate), #1616 (sync watched state
and favorites between devices).
## 1. Summary
The Docker image runs the browser PWA. All user data lives in the browser:
playlists in IndexedDB, Xtream favorites, recently viewed, playback positions
and collection snapshots in localStorage. The bundled `web-backend` is a
stateless proxy, so every device that opens the NAS address starts empty.
This design gives `web-backend` an optional server storage: the same SQLite
database the Electron app uses (`libs/shared/database`, `initDatabase()`),
stored on a Docker volume, plus a small versioned HTTP API. When the instance
reports server storage, the PWA keeps playlists and playlist-scoped user data
on the server. Adding a playlist stays the ordinary "Add playlist" flow.
## 2. Goals
1. A playlist added on one device appears on every device that opens the same
self-hosted instance (M3U, Xtream, Stalker).
2. M3U and Stalker favorites and recently viewed (stored inside the playlist
document) and Xtream favorites, recently viewed, collection snapshots and
playback positions follow the playlist, not the browser.
3. Browser data moves to the server once, with consent, and is never deleted.
4. No behavior change for Electron and for PWA deployments without server
storage.
5. The HTTP contract mirrors the renderer's existing playlist document store,
so a later Electron "connect to server" phase reuses it.
## 3. Non-goals for this release
- Server-side EPG (phase 3) and Electron connecting to a server (phase 4).
- Syncing device settings (theme, language, player, EPG URLs), parental locks,
the global favorites order and UI preferences. They stay per device.
- Live push of changes (SSE). Devices read the server on every playlist read
and re-read Xtream user data when the tab becomes visible.
- User accounts. One optional shared access token.
- Credential encryption at rest. Documented limitation.
## 4. Findings that shaped the design
| Finding | Consequence |
| --- | --- |
| `PlaylistsService` persists whole playlist documents through a narrow bridge (`dbGetAppPlaylist(s)`, `dbGetAppPlaylistMetas`, `dbUpsertAppPlaylist(s)`, `dbDeletePlaylist`, `dbDeleteAllPlaylists`, `dbGet/SetAppState`). Every mutation re-reads the document, patches it and writes it back. | The server implements the same document contract over HTTP. The client change is one adapter plus a resolver; `PlaylistsService` keeps its API. |
| Electron stores the whole document as JSON in `playlists.payload`, with projected columns for fast metadata reads (`buildPlaylistRow` / `parseAppPlaylist`). | Move that codec into `libs/shared/database` and reuse it on the server. Same row format on desktop and server. |
| `initDatabase()` in `libs/shared/database` creates the full schema and runs every migration; the path comes from `path-utils`. | The server calls `initDatabase()`. A new `IPTVNATOR_DATA_DIR` variable in `path-utils` points it at the Docker volume. |
| better-sqlite3 13 is a Node-API addon. | One binary loads in Node and Electron. Only the Alpine Docker image needs its own native build stage. |
| Read-modify-write of a document carrying a large M3U payload would move megabytes per favorite click over Wi-Fi. | `GET` answers `304` on a matching ETag; an upsert whose payload object is the one the client received sends `keepPayload=1` without the payload. |
| Xtream PWA user data is four localStorage maps keyed by playlist id (`xtream-favorites`, `xtream-recent-items`, `xtream-collection-items`, `xtream-playback-positions`); `xtream-playlists` is a derived cache. | Server table `server_user_state(namespace, playlist_id, value)`. The client keeps a write-through cache under its own key prefix and sends only changed playlist entries. |
| nginx proxies `/api/` with the default 1 MB body limit. | Raise `client_max_body_size` with the server body limit. |
| The hosted PWA build points at an external parser API. | The client probes for server storage only when the deployment writes a runtime `BACKEND_URL` (the Docker entrypoint does). |
| Settings sections are grouped (App, Library, Devices, Data) and gated by capability requirements. | New "Server" section in the Data group before Backup, requirement `server-storage`. |
| New i18n keys must be translated in all 19 locales (`pnpm run i18n:check`). | Translation is a planned task. |
| Production files: target < 300 lines, hard 400; `web-backend-app.ts` (673) and `playlists.service.ts` (1580) are baselined. | New code lives in new files; baselined files only gain mounting lines. |
## 5. Product decisions
1. In server mode the server is the authority for playlists and the synced
user data. There is no per-playlist "local or server" choice.
2. Server storage is opt-in on the backend: `IPTVNATOR_STORAGE=server`. The
bare `web-backend` defaults to `none`; the Docker image sets `server`.
3. Optional `IPTVNATOR_ACCESS_TOKEN`. When set, every route except
`/`, `/health`, `/config.js` and `GET /v1/instance` requires
`Authorization: Bearer <token>`, including the provider proxy routes.
4. Migration:
- First device (server has no playlists, browser has some, no marker for
this instance): a dialog offers "Move to server". Playlists are copied
with their ids, then the four Xtream user-data maps for those ids.
- Any later device (server already has playlists): Settings → Server
offers "Import this browser's playlists", which runs the existing backup
export (browser) and import (server) with fingerprint merging, so the
same provider added on two devices is not duplicated.
- Browser data is never deleted. The server-mode cache uses its own
localStorage key prefix, so legacy keys stay intact.
5. Offline: reads and writes fail with a visible state. The rail shows an
offline server icon, a banner explains that changes cannot be saved, and
failed writes show a snackbar. No offline write queue.
6. "Delete all playlists" in server mode warns that it clears the server for
every device.
## 6. Architecture
```
Browser (PWA) Docker container
┌─────────────────────────────────────────┐ ┌───────────────────────────────────────┐
│ main.ts: probeServerInstance() │ /api │ nginx (client_max_body_size 64m) │
│ → provideServerStorage(instance) │────────►│ └─► web-backend (Express) │
│ PlaylistsService (API unchanged) │ │ ├ proxy routes (unchanged) │
│ └ PlaylistStorageBackend │ │ ├ access guard (optional) │
│ ├ Electron bridge (desktop) │ │ └ /v1 storage router │
│ ├ ServerPlaylistDocumentStore │ HTTP │ ├ instance, status │
│ └ IndexedDB (plain PWA) │◄───────►│ ├ playlists (documents) │
│ PwaXtreamDataSource │ │ ├ app-state │
│ └ USER_DATA_STORAGE │ │ └ user-state │
│ ├ localStorage │ │ ServerDatabase: initDatabase() │
│ └ ServerUserDataStorage (cache) │ │ + server_user_state │
│ ServerStorageStatusService (signals) │ │ /data/databases/iptvnator.db (WAL) │
└─────────────────────────────────────────┘ └───────────────────────────────────────┘
```
### 6.1 HTTP API (`/v1`, mounted under nginx `/api`)
All JSON. Errors: `{ message, status, code? }`.
| Route | Purpose |
| --- | --- |
| `GET /v1/instance` | Public. `{ service: 'iptvnator', apiVersion: 1, instanceId, storage: 'server' \| 'none', auth: 'none' \| 'token' }`. The frontend and backend ship in one image, so the PWA shows its own version. |
| `GET /v1/instance/status` | `{ playlists, databaseBytes, databasePath, startedAt }`. Also the token check. |
| `GET /v1/playlists?view=meta` | Metadata documents (no payload). |
| `GET /v1/playlists` | Full documents (backup export, global favorites). |
| `GET /v1/playlists/:id` | Full document, `ETag`; `If-None-Match` → `304`. |
| `PUT /v1/playlists/:id[?keepPayload=1]` | Upsert. With `keepPayload`, the stored `playlist`, `items` and `header` fields are kept; `409 payload_missing` when there is no stored row. Responds with the new `ETag`. |
| `DELETE /v1/playlists/:id` | Delete the row and its user-state entries. |
| `DELETE /v1/playlists` | Delete all playlists and all user state. |
| `GET/PUT /v1/app-state/:key` | `{ value }`. |
| `GET /v1/user-state` | `{ entries: { [namespace]: { [playlistId]: value } } }`. |
| `PUT/DELETE /v1/user-state/:namespace/:playlistId` | One entry. Namespaces are an allow-list. |
ETags are `"<bootId>-<revision>"` from an in-memory per-playlist revision
counter; the server is the only writer of its database.
### 6.2 Server
`apps/web-backend/src/app/storage/`: config, database bootstrap
(`initDatabase()` + `server_user_state` + instance id in `app_state`),
playlist repository on the shared codec, user-state repository, revision
tracker, access guard, routers. `main.ts` opens storage when enabled and
closes it on `SIGTERM`. Storage errors are logged by name and route only.
### 6.3 Client
- `PlaylistStorageBackend` (libs/services) resolves the document store:
server store in server mode, Electron bridge when `supportsSqlite`, else
IndexedDB. It can switch to browser storage temporarily for the migration
export.
- `@iptvnator/services/server-storage` (sub-entry, kept out of the eager
barrel): API client, document store, user-data storage, session, migration.
- `ServerStorageStatusService` (main barrel, tiny): connection state for the
rail, banner and Settings.
- `apps/web/src/app/server-storage/`: probe, providers (dynamically imported
from `main.ts` only in server mode), sign-in route, migration dialog,
Settings section.
## 7. UX
Mockup (2026-10-08, settings navigation has since gained groups):
https://claude.ai/artifact/3WheJAgeNL5ag6kmtPrJcD
1. Settings → Data → Server: connection pill and address,
storage figures, "Export backup", "Import this browser's playlists" (when
local playlists exist), access mode with "Sign out" when a token is used.
2. Migration dialog over the workspace on first boot (rule 5.4).
3. Rail: server icon above Settings with connected, offline and locked states.
Offline banner under the header with "Retry".
4. Sign-in page `/server-sign-in` when the instance requires a token.
## 8. Error handling
| Situation | Behavior |
| --- | --- |
| Probe fails or times out (2.5 s) | Browser mode, no server UI. |
| Request fails after boot | Status `offline`, banner, write failures show a snackbar; retry on `visibilitychange` and every 30 s. |
| `401` | Status `unauthorized`, stored token cleared, redirect to sign-in. |
| `413` | Snackbar names the limit. |
| `409 payload_missing` on `keepPayload` | Client retries once with the full document. |
| Data directory not writable | Backend exits on start with a clear log line; the container restarts. |
## 9. Testing
- Unit: codec round trip, path variable, resolver, document store (ETag,
keepPayload), user-data storage (diff, debounce, hydrate), probe, session,
migration decision, settings section, rail indicator.
- Backend: repositories on real SQLite in a temp directory; routes through a
real server and `fetch`; access guard.
- E2E: `@server-storage` spec against a storage-enabled backend on its own
port and temp directory: a playlist added in one browser context appears in
another; the migration dialog moves a seeded browser playlist. The token
page and guards are covered by unit tests (an E2E run would need a third
backend).
- Docker: `docker compose config`, image build, start with a volume.
## 10. Phases
| Phase | Content | Closes |
| --- | --- | --- |
| 1 | Seams, no behavior change: shared codec, `IPTVNATOR_DATA_DIR`, `PlaylistStorageBackend`, `USER_DATA_STORAGE`. | — |
| 2a | Server storage in `web-backend`, Docker volume, nginx limits, docs. | — |
| 2b | PWA server mode, migration, Settings → Server, rail/banner, sign-in, i18n, E2E, release note. | #305, #514 |
| 3 | Server-side EPG for the PWA. | PWA EPG gap |
| 4 | Electron connects to a server. | #1616 for NAS owners |
## 11. Risks
- **Concurrent edits on two devices**: last write wins per document or per
Xtream map entry. Every playlist mutation re-reads first, so the window is
one round trip.
- **Large payloads**: M3U global favorites and backup export read all full
documents, as IndexedDB does today.
- **Open instances**: storing credentials raises the cost of an unprotected
public instance. Docs and the Settings page state it; use the token or
reverse-proxy auth.
- **Native module in multi-arch images**: built in a target-platform stage.