Merge remote-tracking branch 'origin/master' into perf/zoneless-flip

# Conflicts:
#	tools/performance/journey-baselines.json
This commit is contained in:
4gray committed 2026-10-14 19:58:21 +02:00
commit 8e807242db
191 files changed
+15429 -6865

No files matched your search

+123 -19
View File
@@ -38,6 +38,12 @@ Use it when changing existing views or introducing new list-based UI in the work
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss`
- Shared list selection style:
`libs/ui/styles/_nav-list.scss`
- Source row (Sources page and playlist switcher):
`libs/playlist/shared/ui/src/lib/source-row/source-row.component.html`
- UI-kit buttons, menus and segmented control:
`libs/ui/styles/_app-controls.scss`
- Side lists (title, group labels, single-choice rows):
`libs/ui/styles/_side-list.scss`
- Theme tokens:
`apps/web/src/m3-theme.scss`
- Settings surfaces:
@@ -67,6 +73,15 @@ in `apps/web/src/m3-theme.scss`):
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
- `--app-success-color` — done/watched status (episode watched toggle)
- `--app-list-selected-bg` / `--app-list-selected-fg` /
`--app-list-selected-outline` — the chosen row of a side list and the
active source's row on Sources and in the switcher (see Selection Pattern)
- `--app-line-strong` — popover and menu borders, a step above
`--app-widget-header-border`
- `--app-danger-text` / `--app-danger-surface` — a failing source's tag and
destructive menu items; `--app-neutral-tag-surface` for a neutral tag
- `--app-font-mono` — URLs and keyboard hints (JetBrains Mono, 400/500)
- `--app-popover-shadow` / `--app-popover-backdrop` — the switcher popover
Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the
@@ -121,7 +136,10 @@ Everything else is lazy by these rules:
and the dark colours. Add an entry with a new `@angular/material/<entry>`
import and remove it with the last one;
`pnpm run styles:material-theme-scope:validate` (CI) checks both directions
(`core` always stays, `button` brings `icon-button` and `fab`).
(`core` always stays, `button` brings `icon-button`; `fab` is listed only
while a runtime template renders `mat-fab` or `mat-mini-fab`, since the
button import carries the directives either way and the fab tokens alone
are about 6 KB).
- **No animations module.** Nothing declares an Angular animation trigger and
Material 22 animates with CSS, deciding from `prefers-reduced-motion` itself,
so `app.config.ts` does not call `provideAnimations()`; the Electron
@@ -216,6 +234,13 @@ Use this pattern for:
Do not add extra badges, left rails, or second selection systems unless there is a strong reason.
Side lists styled with `_side-list.scss` (the Sources filters) and the
active source's row (Sources page and playlist switcher) use the Settings
navigation's variant
instead, through `--app-list-selected-*`: a soft tint with no lift or glow,
outlined in light theme. Its text is the heading colour in light theme (the
selection blue on its own tint is 3.3:1) and the selection colour in dark.
## Single-Choice Menus And Status Chips
A `mat-menu` that picks one value, such as a sort order or a rating
@@ -241,6 +266,15 @@ threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
- 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.
- Keep a menu short enough to open whole below its trigger in a 600px-tall
window. Material 22 sets no max-height on a menu panel: a taller menu is
pushed up over its own trigger, then clipped. Move a secondary group into
a submenu whose row shows the active value, as the refine menu does with
its rating thresholds.
- Material offsets a submenu by the `offsetTop` of the parent menu's first
direct `mat-menu-item`. When the earlier rows sit inside a `role="group"`,
that first direct item is the submenu trigger itself, and the submenu
opens off screen. Wrap such a trigger in `<div role="none">`.
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
@@ -1360,27 +1394,97 @@ A visual change is not done until:
### 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.
A Sources row (`app-source-row`, regular size) opens on a click anywhere on
it, and it also holds a drag handle and its 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 `.source-row__body`
(`role="button"`, `tabindex="0"`, named by the source's display name,
`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. `R` on a focused row refreshes it, as its menu item shows.
`sources-keyboard.e2e.ts` (Electron) tabs to a row, checks its ring, opens
the source with Enter, and deletes and undoes a source keyboard-only.
### Sources page and playlist switcher
One `app-source-row` serves both lists: `size="regular"` on the Sources page
(56px, 36px tile, hover actions Refresh, Edit and ⋯; a failing row keeps
Retry in view; touch screens always show the actions) and `size="compact"`
in the switcher (52px, 32px tile, ⋯ only). In both the active source's row
is tinted with the selected-row token; the switcher adds a check. The page
header (title, Clean up, Sort) stays; only the list below it scrolls. The meta line is always type · channels · added · updated · URL, with
empty parts left out; only M3U playlists store a channel count. The helpers
in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`,
`source-list`) own that text, the filters and the counts.
- **No credentials on screen.** Names come from `sourceDisplayName`: a URL, a
URL-derived name such as `get.php?username=…&password=…` or any name holding
the source's password, MAC or URL secrets becomes the host. URLs come from
`sourceDisplayUrl`, built on `stripUrlCredentials` (beside it in
`source-display`, out of the eager logging barrel): no
userinfo, sensitive query or fragment parameters, or Xtream path
credentials; Xtream shows `scheme://host:port · username`. Copy URL copies
the full address on request.
- **Delete with Undo.** A single delete has no confirmation: the row leaves
every list at once (`SourceRemovalService.pendingIds`) and the delete runs
when the four-second Undo window ends. The window is the service's own
timer, not the toast's: another message replacing the toast (a refresh
finishing elsewhere) does not shorten it. The toast's close button commits
at once, and quitting within the four seconds keeps the source. Deleting the active source
switches to the next row; Undo switches back only if no other source was
chosen since. A desktop Xtream delete brings its row back as "Removing…"
with progress and Cancel on the Sources page; the switcher leaves the
source out until the delete ends. When the delete took the row and with
it the focus, the toast's Undo takes the focus. A delete from the
switcher closes the popover first, since its focus trap would keep the
keyboard from Undo: Undo takes the focus and returns it to the trigger
(`SourceRemovalOptions.returnFocusTo`). A source cannot be removed while
a refresh clears and refills it: every refresh (a Sources row, the
switcher, the Xtream flow, the startup auto-refresh), every Xtream import
and every delete holds the source in the root `SourceActivityService`,
which both lists, the removal service and a second refresh consult. The
Xtream route session takes its hold on the refresh's `NavigationEnd`, so
the catalog is never idle between the delete and the re-import. The Sources page shows
a switcher refresh as busy. Bulk cleanup keeps its confirmation dialog.
- **Side list.** Type and, on desktop, Status (Any / Responding / Not
responding) combine and persist (`SourcesViewStateService`); each group
counts within the other's choice. The page checks every source, not only
the rows a filter shows (`keepSourcesChecked`): a saved status filter
would otherwise hide the unchecked sources it is waiting for.
- **Switcher.** The trigger is a 40px secondary button; ⌘P / Ctrl+P opens it
(⌘K stays the command palette). The popover is a CDK connected overlay, so
row menus open above it, not inside its scrolling list. Focus stays in the
search field, which drives a `role="listbox"` through
`aria-activedescendant`: ↑/↓ move and wrap, Enter opens, Shift+F10 opens
the highlighted row's menu, Escape closes and returns focus to the
trigger. The compact row's ⋯ is pointer-only (`aria-hidden`, not
focusable), since an option may not contain a control. "Recent" lists the
three most recently opened sources once there are six or more. The search
field is the shared 36px search field (`--app-search-*`). The popover is a
height-capped column in which only the list shrinks and scrolls: every
other part is `flex: none`. Otherwise a long list (hundreds of sources)
squeezes them in proportion to its size, and the type filter collapses.
### Network source indicators (Electron)
The switcher and source rows use `SourceHealthIndicatorComponent`: green means
available, orange expired, red disabled/access refused, and neutral means
checking or unverified. Accessible tooltip text distinguishes refusal from a
confirmed disabled account and includes the check time. M3U indicators describe
the source URL only. Check again is a separate action, not a playlist refresh.
The status dot on a source tile reads `SourceStatusService`: green answers,
red does not, orange is an expired account, grey is not checked yet; local
files have no dot. On desktop the live `SourceHealthService` result wins; the
last conclusive result per source is kept across restarts
(`SourceHealthHistoryService`, no URL or credential stored, only a 32-bit
fingerprint of the connection), so a source that failed yesterday shows as
failing before it is checked again, with "Not responding · 3 days". An
edited address or account is a new connection: its kept result no longer
counts, and its history starts over. The dot's tooltip and accessible name give the
check's cause (`SourceStatus.reason`: access denied, account disabled,
timeout…, kept with the history) and check time; M3U checks say they
cover the playlist URL, not its channels. Retry and Check
again rerun the check; they are not a playlist refresh. The browser build
keeps the Xtream portal status only.
### Inactive-source cleanup dialog
+9
View File
@@ -1856,6 +1856,15 @@ shared data access. Electron checks Xtream account info, Stalker account/profile
facts through the existing session, and the first 64 KiB of M3U URL responses.
An M3U success describes the playlist URL, not every channel. Local files and
text imports have no network indicator. PWA retains its existing Xtream path.
Lists read one status per source through `SourceStatusService`, which falls
back to the last conclusive result kept by `SourceHealthHistoryService`
(localStorage, by playlist ID: state, check time, the start of a failing
run, the check's cause and a 32-bit fingerprint of the checked connection;
no URL or credential). A result kept for an edited address or account is
ignored, and the next check starts the history over. Deleting a source or
resetting the library clears its entry. The Sources page keeps every
library source checked while it is open (`keepSourcesChecked`), so a status
filter never hides the sources it is waiting for. See the [UI guidelines](iptvnator-ui-guidelines.md#network-source-indicators-electron).
Checks are demand-driven, never a startup readiness barrier: four at a time,
two per origin, shared between surfaces. Known results remain visible during
+38 -3
View File
@@ -174,9 +174,10 @@ navigation (Back, drawer toggle) changes.
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.
back from a portal to the dashboard used to lay the new dashboard out
below the old page before the old page left, a shift a slide cannot
hide. That was the route swap, fixed at its cause (see
[Route swaps](#route-swaps)).
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
kept as a thin template-facing API and delegates ownership to component-scoped
@@ -207,6 +208,40 @@ When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
for the template unless the template contract itself intentionally changes.
### Route swaps
The router's activation runs in one task, outside change detection:
deactivation destroys the old routed view, then activation creates the new
one and inserts its host element into `main.workspace-content` at once.
`WorkspaceShellRouteStateService.currentUrl` is set on `NavigationEnd`, which
the router emits synchronously after activation, so the shell's state does
not lag the outlet. The shell re-renders (the context panel leaves, the
content frame slides) in the next change detection. With event coalescing
that runs in the next `requestAnimationFrame`, before that frame paints.
The app does not provide `provideAnimations()`. Nothing uses
`@angular/animations` triggers (Material animates with CSS), and that
package's renderer queues every DOM removal until the end of the next change
detection, while insertions apply at once. Leaving a portal section for the
dashboard therefore inserted the dashboard below the still-present portal
page, and the old page left only in the coalesced tick. In between, Chromium
laid out the document with both pages, the dashboard at y=720 in a 720 px
window, and scored the swap as one layout shift: 0.337 at 1280 px, 0.394 at
900 px, 0.723 at 420 px (measured with a `layout-shift` observer in the
`electron-performance` build). With the default DOM renderer the old page is
removed in the same microtask, before the new one is inserted, and the swap
records no shift. `app.config.spec.ts` keeps the animations renderer out, and
`workspace-route-swap.e2e.ts` asserts that no mutation checkpoint holds both
pages and that neither the dashboard nor the content pane shifts.
Destroy hooks therefore run with the view's DOM already detached; the
[UI guidelines](./iptvnator-ui-guidelines.md) ("No animations module") say
how hooks record geometry and focus instead.
Do not add `provideAnimations()` or `provideAnimationsAsync()` back; animate
with CSS. A leave animation (`animate.leave` included) delays its element's
removal in the same way, so a routed page must not carry one.
## Header Back
The header's leading slot is the workspace's one page-level Back. Pages do not
+3 -5
View File
@@ -61,7 +61,7 @@ must be ticked here.
## Eager components
65 production files, 66 components (`epg-progress-panel.component.ts` holds
two). Tick an entry by deleting `changeDetection: ChangeDetectionStrategy.Eager`
two); two have since been deleted (the old Sources row and its health dot). 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 whose component metadata still sets
@@ -127,7 +127,7 @@ picker), so each marks itself on the form's `events` through
- [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 (12)
- [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`
@@ -140,9 +140,7 @@ picker), so each marks itself on the form's `events` through
- [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.
@@ -173,7 +171,7 @@ the field a signal (or a `computed`), or writes it through one.
| --- | --- | --- | --- |
| [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/portal/shared/data-access/src/lib/source-status.service.ts` `checkPortal` (replaced the old Sources row's `checkPortalStatus`) | the PWA Xtream portal status lands after `await`; it is written to a signal | 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 |