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