66 Commits
Author SHA1 Message Date
4grayandClaude Opus 5.5 43358e10f2 fix(ui): welcome screens and drop overlay follow the app theme (#1886)
* fix(ui): welcome screens and drop overlay follow the app theme

The welcome dashboard, the empty Sources page and the playlist drop
overlay switched on `prefers-color-scheme`, so with the app set to dark on
a light OS (or light on a dark OS) they painted the other theme's colours.
They now read `--app-*` tokens, which follow the `.dark-theme` class set
from Settings, and the hard-coded blues are derived from the selection
colour (a local "strong" accent mixed toward the heading ink keeps chips
and filled labels at 4.5:1 in both themes).

White rgba() fills that vanished in the light theme (season empty panel,
catalog refinement chips and menu divider, Xtream archive banner and
disabled paginator icons, shell download-activity track, search field)
now use the widget surface, on-surface mixes or the search tokens.

Reads of custom properties that nothing declares are fixed: the release
notes error, the search-layout empty state and the collection reload dim
now use declared tokens; unset hooks are replaced by their fallback value.

New guard `pnpm run styles:theme-references:validate` (CI) rejects
undeclared var() reads and prefers-color-scheme outside the settings
resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under
each explicit app theme and checks colours, contrast and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): retry the rejected-drop comparison when the card dismisses mid-read

The rejection hides itself after 1.8s, and the OS-scheme comparison reads
the overlay twice with an emulateMedia call between. A slow runner could
lose the card between the reads; the comparison now drops again until both
reads see it. A colour that follows the OS still fails every attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): measure welcome text contrast from rendered pixels

The feature and source cards paint gradients, which a backgroundColor
walk ignores, so card titles, descriptions and chips could pass while
falling short on the actual card. Every text on the three surfaces is now
measured against the pixels under it, as the filled button labels were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): scan only runtime sources in the theme-references guard

The guard read every tracked file under apps/ and libs/ except specs, so
an E2E, mock-server or test-helper declaration could satisfy a runtime
var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts
sets --cover-rail-width), and a test-only read could fail the check. It
now skips spec/test/e2e files, test helpers and stubs, testing projects,
the E2E and mock-server apps and the marketing website.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(xtream): set live paginator tokens through mat.paginator-overrides

The live layout hand-declared --mat-paginator-* tokens, which the UI
guidelines route through the overrides mixin so a mistyped name fails the
build instead of silently doing nothing. Same values, same output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): skip every test-only naming convention in the theme guard

The runtime scan still read .stub, .harness, .fixtures, .spec-stubs,
.spec-helpers, .spec-fixtures, test-setup and test-double sources, and
test-stubs/ or *fixtures/ directories, so a declaration in one could mask
an undeclared runtime read. A file is now test-only when a dot segment
after its name marks it (spec, stub, fixture, harness, mock, spec-*,
test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test
directory. Runtime names such as xtream-connection-test.service.ts stay
in the scan; no runtime source imports an excluded file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep welcome card text legible while hovered

At the 16%/10% hover tint the dark theme's body text on a feature card
measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in
dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E
now measures feature and source card text while hovered, in both themes;
with the old tint it fails at 4.45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:22:23 +02:00
4grayandClaude Opus 5.5 7abf479de9 fix(ui): one focus ring colour everywhere via the shared mixin (#1882)
* fix(ui): one focus ring colour everywhere via the shared mixin

#1866 gave the app a keyboard focus ring token (`--app-focus-ring`, at
least 3:1 on every surface), but 59 component rings still drew their own
colour: the selection blue under several names (`--app-selection-color`,
`--mat-sys-primary`, `--app-selection-border`, the EPG's `$accent-blue`,
`--embedded-mpv-accent`, `--apd-accent`, the hero's `--accent-color`),
the heading colour, or the overlay's literal text colour. The selection
blue falls to 2.6:1 on the stronger selection tint.

- Every one now includes `focus-ring.focus-ring-declarations`, keeping its
  offset; the projects that newly import `libs/ui/styles` declare
  `ui-styles`.
- The mixin reads the token alone: it is declared on `html` in both
  themes, and the fallback chain cost bytes in every ring.
- `tools/nx/check-focus-ring-colour.mjs` joins `styles:focus-visible:
  validate`: an outline in a focus rule must use the token or, over
  video, the player's `--pc-*` palette. Three deliberate exceptions are
  listed with their reasons, and a stale one is reported. On master it
  reports these 59 rings.
- The keyboard-focus E2E asserts each ring's colour equals the token
  where it is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): search fields and shadow rings take the focus colour too; guard reads them

Local review (Greptile P2): the ring-colour guard read only `outline`, so a
focus indicator drawn as a shadow or a border kept any colour. Search
fields showed focus as a `--mat-sys-primary`/selection border with a 12-16%
halo, the EPG guide's keyboard cell as an inset `$accent-blue` shadow, and
the downloads cards tinted their artwork border with the M3 primary.

- Every one now uses `--app-focus-ring` (the halos and tints keep their
  strength through `color-mix()`); over video the panel's search border
  mixes the overlay's own ring colour.
- The guard reads a focus rule's outline, its unblurred ring or line
  shadows and its border colours. Neutral boundaries (separator and
  widget-border tokens, transparent, currentColor) and blurred lift
  shadows are not indicators. On the previous commit it reports these 21
  declarations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): check every focus colour on its own; count exceptions only where they excuse a ring

Greptile on #1882 (2×P2):

- The guard joined a declaration's colours and accepted the lot when the
  token appeared anywhere, so `border-color: var(--app-focus-ring) red`
  or a `color-mix()` of the token and red passed. It now splits each
  declaration into plain colours, every `color-mix()` argument included,
  and checks each one: the token, the player palette or a neutral
  boundary. An outline or shadow ring without a colour is drawn in the
  text colour, which only a border may keep.
- An exception counted as used when its value appeared in any
  declaration (the diagnostic's amber is also a text colour), so a stale
  one was never reported. It now counts only where it excuses an
  off-token focus indicator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): the row a search result reveals takes the app focus ring

Codex on #1882 (P2): choosing a settings search result with Enter focuses
its row by script, and the keypress keeps `:focus-visible`, so the row's
60%-transparent selection outline was a real keyboard focus ring under
3:1, not the passive marker its guard exception described. The row now
includes the shared mixin (keeping its 12px radius), the exception is
gone, and the keyboard-focus E2E reveals a row with Enter and asserts the
app ring on it; with the old rule it fails on the 60% colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): accept focus colour tokens by exact name

Greptile (local, P2): the guard matched `--app-focus-ring` and the neutral
boundary tokens by prefix, so `var(--app-focus-ring-other, red)` or
`var(--app-separator-strong)` passed. It now reads the property a `var()`
names and accepts exactly `--app-focus-ring`, a `--pc-*` palette token or
one of the four neutral boundary tokens; their fallbacks are never drawn,
since the tokens are always declared, so they are not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): check the body of a focus mixin, the shared ring included

Greptile (local, P2): a mixin body has no selector and its includes are
not expanded, so the shared `focus-ring-declarations` itself escaped the
colour check; turning it red would change every ring and pass. The
walker now names the mixin a declaration sits in, and the colour guard
treats the body of a mixin whose name mentions focus as a focus rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read logical border sides in the focus colour guard

Greptile on #1882 (P2): only `border`, `border-color` and the physical
sides were read, so `border-inline-start: 2px solid red` in a focus rule
passed. The guard now reads every colour-carrying border property: the
shorthand and the physical and logical sides, with or without `-color`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): an exception excuses its own colour, not the declaration

Greptile (local, P2): an exception matched the whole declaration, so in
the player stylesheet `box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red`, or
the white mixed with red, passed. Exceptions now apply to each plain
colour, by exact value, like every other check; an exception counts as
used only where it excuses one of a focus indicator's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): tell a var() or calc() width from the ring colour

Greptile (local, P2): `outline: var(--ring-width) solid var(--app-focus-
ring)` failed the guard, since any non-length token counted as a colour.
Math functions now count as lengths, and a shorthand with one colour slot
takes its literal colour, else an accepted token (the other `var()`s are
widths), else reports every candidate it cannot prove; `border-color`
still checks a colour per side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read a shadow ring whose spread is computed

Codex (local, P2): with `box-shadow: 0 0 0 calc(1px + 1px) red` or a
`var()` spread, only three literal zeros were left as lengths, so the
shadow looked flat and was skipped. A shadow is now skipped only when it
is provably not a ring: a literal non-zero blur in the third place, or
every length a literal zero with no `var()` that could be a spread. The
test with a variable-width token ring now asserts it is read, not skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read shadow lengths by type, with the colour first or no blur

Greptile (local, P2): the guard took a shadow's first three tokens as its
lengths, so `0 2px` (a line in the text colour, no blur) was skipped as
blurred and a colour-first lift shadow was read as a ring. Lengths are
now read by type: a colour sits before or after them, never between, and
a missing blur is zero. A `var()` counts as a possible length, so a
shadow is skipped only when the first three possible lengths prove a
blur, or every length and the first four possible ones are zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): a negated focus condition does not make a focus rule

Greptile (local, P2): the colour guard read `:focus-visible` inside
`:not()` as a focus rule, so a hover style such as
`.button:hover:not(:focus-visible) { border-color: red; }` failed. The
selector is now tested without its `:not()` arguments; a focus condition
elsewhere, `:has()` included, still makes a focus rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read a width in any CSS length unit

Greptile (local, P2): only px, em and rem were lengths, so `outline: 1pt
solid var(--app-focus-ring)` took `1pt` for the colour and failed. Every
CSS length unit (absolute, font-relative, viewport and container) now
counts as a length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): the open-in-playlist chip rings in the app focus colour

Codex on #1882 (P2): the chip's inline component styles drew its focus
ring in `--app-selection-color`, out of the colour guard's reach (it reads
stylesheets). It was the only inline-style focus ring in the repository;
it now uses `--app-focus-ring` like every other ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): move the open-in-playlist chip styles into a stylesheet

Codex on #1882 (P2) suggested this over scanning TS inline styles: the
chip was the only component with a focus ring in inline `styles`, which
the colour guard does not read. Its styles now live in
`open-in-playlist-chip.component.scss` unchanged, the ring through the
shared `focus-ring-declarations` mixin, so the guard covers it (a drifted
colour there is reported).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:40:08 +02:00
4grayandClaude Opus 5.5 4281194cb4 fix(a11y): accessible names for icon-only buttons and a guard (#1880)
* fix(a11y): accessible names for icon-only buttons and a guard

Icon-only buttons named only by a matTooltip (or by nothing) had no
accessible name: the icon ligature is hidden from assistive technology
and a tooltip only adds a description. 22 buttons on master, in .html
and inline templates, now carry a translated aria-label bound to their
tooltip key. The channel row's favorite star keeps one label
("Favorite") and reports its state through aria-pressed.

New guard `pnpm run a11y:icon-buttons:validate` (CI step) fails on a
<button> whose only content is mat-icons (through control flow,
ng-container and spinners) without aria-label, an aria-label binding
or aria-labelledby. The inline-template lookup moves to a shared
tools/nx/inline-templates.mjs used by the icon-ligature guard too.

web-e2e icon-button-names.e2e.ts runs axe's button-name rule on a
channel list, the channel details dialog, Sources, the playlist info
dialog and an Xtream search. Five new keys are translated in all 18
locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(a11y): text hidden from assistive technology never names an icon button

Greptile: a static aria-hidden="true" child's text counted as the
button's name, so <button><mat-icon/><span aria-hidden="true">Close</span>
passed the guard. Hidden subtrees now add only their icons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:34:04 +02:00
4grayandClaude Opus 5.5 e1d4f00d93 fix(i18n): follow runtime language switches in stored and memoized labels (#1872)
* fix(i18n): follow runtime language switches in stored and memoized labels

A label translated once and kept kept its language after Settings ->
Language until the data reloaded or the app restarted. The Stalker store
baked `translate.instant('PORTALS.ALL_CATEGORIES' | 'PORTALS.ALL_RADIO')`
into its category list, so the every-item genre stayed English in the
rail, the live header, the fullscreen panel title, the catalog title and
the search scope.

Stalker: the every-item genre now carries `labelKey` and an empty
`category_name`; the category views render the key through the translate
pipe. `getSelectedCategoryName` becomes `getSelectedCategoryLabel`
({ name, labelKey }) and every text consumer goes through
`stalkerCategoryLabelText()` inside a computed that reads a language
signal, so no consumer can show the empty name or a stale translation.

Same class elsewhere (computed or stored `instant` text without a language
signal): the Xtream import overlay title and progress (shell-provided),
the Settings About version note (stored in a signal on the page where the
language changes), the remove-all-playlists progress, the context panel's
status/error text and category search, and the movie/series heroes
(`createVodDetailsHeroState`, `createSeriesHeroState`, the Xtream movie
presenter), cast & crew "Director", the M3U sidebar count, the M3U movie
hero and the collection panel title. These read
`toSignal(onLangChange.pipe(startWith(null)))` and also recover when the
translation file lands after the first render.

Documents the rule in the UI guidelines and the Stalker store contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(stalker): record getSelectedCategoryLabel in the store API baseline

getSelectedCategoryName is gone without an alias: a name-only selector is
empty for the every-item genre, whose label is a translation key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): one translation tick that also follows late dictionaries

Greptile asked for coverage of a dictionary that lands after the first
render. That exposed a gap: every hand-written tick listened to
`onLangChange` only, but a start-up without a saved language never calls
`use()` - the default dictionary landing fires `onDefaultLangChange` alone,
so computeds that ran before it kept raw keys. Dictionary updates
(`onTranslationChange`) were missed the same way.

`injectTranslationTick()` in `@iptvnator/pipes` merges the three events the
translate pipe re-renders on (the embedded MPV player already did). All 37
ticks use it now, including the hero factories' `language` dependency. New
specs cover the delayed default-language load, a late `use()` dictionary
and a dictionary update; the hero specs add the start-up case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(i18n): stub the Xtream selection the merged search scope reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): series view reads the shared translation tick

#1871 added an onLangChange-only tick for the synthetic episode titles; a
start-up dictionary that lands without use() would leave raw keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(i18n): move injectTranslationTick to @iptvnator/services

The tick injects TranslateService and subscribes, so it is injectable
runtime state; nx-workspace-boundaries.md reserves type:util for pure
helpers and contracts. @iptvnator/services is the shared type:data-access
project every consumer domain may depend on, and it imports none of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:31:38 +02:00
4grayandClaude Opus 5.5 17a2b1b3b0 fix(ui): visible keyboard focus everywhere via a global focus-visible fallback (#1866)
* fix(ui): visible keyboard focus everywhere via a global focus-visible fallback

The global stylesheet removed the outline from every input, button,
textarea and `:focus`, so Tab users saw no focus on about 110 raw buttons:
detail actions, season tabs, chips, title results, list rows.

- Remove the outline only under `:focus:not(:focus-visible)` and draw a
  fallback ring on every other `:focus-visible` element. Both rules sit
  in `:where()`, so any component that styles its own focus still wins.
- One recipe: `focus-ring-declarations` moves to
  `libs/ui/styles/_focus-ring.scss` and reads `--app-focus-ring`, declared
  per theme (light #1d63e0, dark #8cbaff) with at least 3:1 on every app
  surface and selection tint; `m3-theme.spec.ts` measures it. The card
  ring, the detail actions, the portal sidebar and both context panels
  use the mixin (the panels' selection-blue ring fell to 2.97:1 on the
  active item).
- Material Tab stops (buttons, switches, button toggles, checkboxes) take
  the ring over their 12% focus state layer; menu items and options keep
  Material's highlight.
- Surfaces over video (player controls, vendor chrome, fullscreen panels,
  Up Next rail) point the ring at the player's text colour.
- The selected season tab drew its border with `outline`, which outranks
  the fallback; it is a spread shadow now.
- Guard: `pnpm run styles:focus-visible:validate` (CI) rejects a global
  `outline: none` on an unscoped selector and a missing fallback.
- Electron E2E `keyboard-focus-ring.e2e.ts` tabs through the detail
  actions and season tabs, a catalog grid with its refinement chips, the
  Sources list and Settings in both themes: one ring per stop, 3:1 where
  measurable, none after a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the player ring in the fullscreen panel; tighten the focus guard and E2E

Local review round 1 (Codex P2, Greptile 3×P2):

- The fullscreen channel panel carries `dark-theme`, whose context declares
  the theme ring again, so its own controls ringed in #8cbaff. Point the
  token back at the player's text colour on `.fullscreen-channel-panel
  .dark-theme`; the web series-playback E2E checks the close button's ring.
- The guard took a container-scoped rule (`.panel :focus-visible`), a mixin
  body or a media query as the global fallback. The fallback is now the
  whole selector, outside any at-rule.
- The keyboard-focus E2E now fails a ring that an `overflow: hidden`
  ancestor (up to the scroll container) cuts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): show focus where a component removes its outline; catch invisible rings in the guard

GitHub review round on #1866 (Codex P2, Greptile P2):

- A component rule that sets `outline: none` outranks the zero-specificity
  fallback. Re-audited every one: two hid a Tab stop with nothing else to
  show. The EPG list row (`role="button"`) now draws an inset ring, and the
  command palette underlines its search row while the field has focus.
  The others already show focus on the field's wrapper, on another
  element, or as a highlight inside an arrow-key composite (the "…" menu,
  the guide's search listbox); the guidelines now state the rule.
- The guard read only a bare zero or `none` as a removal. It now reads a
  zero width, a `none`/`hidden` style or a transparent colour anywhere in
  the shorthand or longhands, so `outline: 0 solid transparent` is
  reported and `outline: 2px solid transparent` is no fallback.
- The keyboard-focus E2E walks the live EPG list and the command palette
  too, and reads an inset or offset shadow line as a ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): keep --pc-text a literal palette colour

Master's palette spec (#1854) reads `--pc-text` from the controls host as
a literal; this branch had made it `#{palette.$text}`, failing "sets the
timeline label on the dense glass" on the merge ref. The host declares the
literal again, and a spec keeps the palette's `$text` (the focus ring
token for surfaces beside the host) equal to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(epg): declare the ui-styles dependency of the list row's focus ring

The EPG list row now `@use`s `libs/ui/styles/_focus-ring.scss`; Nx cannot
infer a Sass import, so `ui-epg` declares `ui-styles` like the other
consumers of the shared partials.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): a styleless outline shorthand hides focus too

`outline: 2px` or `outline: red` resets the style to `none`, yet the guard
counted either as a visible fallback. A shorthand without a drawn style
(or a `var()` that may carry one) now counts as removing the outline, as
do `initial` and `unset`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:10:24 +02:00
4grayandClaude Opus 5.5 2b400cb81d fix(i18n): translate the remaining hard-coded labels (#1871)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(i18n): translate the remaining hard-coded labels

Follow-up to the UI-26 pass. The remaining English UI literals now come
from translation keys, translated in all 18 locales:

- Windows/Linux window controls, playlist switcher title and actions
  menu, the portal status tooltip, dashboard carousel roles and rail
  scroll buttons, the search placeholder, the card remove tooltip, the
  EPG offset unit, the REC chip, the Stalker EPG source label and the
  export file type.
- Embedded MPV failures raised in the renderer and the release-notes
  dialog without a bridge. Settings never showed its English reasons,
  so they are dropped.
- Unnamed Stalker episodes: data access leaves the title empty and the
  series view labels it after the TMDB overlay. Synthetic season names
  stay, because they key persisted episode progress.
- The phone remote-control page, which follows the first browser
  language with a translation and sets <html lang>.

Removes the dead getStatusMessage(), the unused favorites layout and the
translateWithFallback() helpers whose keys now exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(remote-control): keep the remote build off shared-interfaces

The language resolver imported the Language enum, which made
remote-control-web depend on shared-interfaces. Its build-performance
chain then hit Nx's recursive-invocation guard through the existing
shared-interfaces/shared-logging build loop, failing the Electron E2E
and performance journey builds. The resolver now keeps its own list of
translation codes, and a spec checks it against the locale files the page
loads.

Also drops the deleted favorites layout from the zoneless checklist,
whose guard spec requires ticked entries to exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(journeys): raise the launch DOM mutation baseline for translated attributes

Launch now sets 569 DOM mutations before the first dashboard card instead
of 557 (identical in all three CI iterations). The extra twelve come from
attributes this PR moved from static English to translated bindings on
the launch path: the window-control labels and tooltips on Linux, the
hero carousel and slide roles, and the rail scroll-button labels. A
translated attribute is a binding set after the element is attached, so
each costs a mutation. Accepted as a deliberate trade-off; it needs the
perf-baseline-increase label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:22:31 +02:00
6c8f5028c7 perf(epg): render only the timeline programmes near the visible range (J3) (#1817)
* perf(epg): render only the timeline programmes near the visible range (J3)

Selecting a live channel rendered every programme block of its schedule
(about 240 for the Xtream mock) while the stream was starting: about 6,000
of J3's 6,199 renderer.domMutationsToPlaying. The ribbon now renders the
blocks, ticks and day dividers within half a viewport of the visible range;
the track keeps the full schedule's width, so positions, the scrollbar and
scroll-to-now are unchanged.

A resize reported before the scroll-to-now must not re-centre the window
(it once jumped to the schedule's start and back); resizes only widen it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): sweep the EPG ribbon to see every programme

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): estimate the ribbon window once per channel or mount

The live estimate followed the centred day and the 30 s now tick while the
ribbon was not yet scrolled: a small scroll across midnight re-centred the
window on the next day's noon and left the visible range empty, and the
host width was re-read (a forced layout) on every tick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): keyboard Tab reaches programmes beyond the rendered EPG range

Greptile flagged that windowing the ribbon could strand keyboard users at
the last rendered block. Focusing a block scrolls it into view, which
re-windows before the next key press; the new test walks ten unrendered
programmes past the range with Tab and with Shift+Tab, and fails if focus
ever leaves the ribbon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): re-centre the ribbon window in the same pass as a zoom

Review follow-up (Codex): a zoom (button, Ctrl/⌘ wheel, coalesced wheel
burst) or a group expansion changed the scale before the anchored
scrollLeft landed on a later frame, so the window was the previous
centre at the new scale until the next scroll event re-measured it. The
ribbon could flash empty or show the wrong section. The zoom controller
now hands the window the minute its anchored scroll will centre, and a
group expansion the group's centre, together with the new scale
(TimelineWindowController.centreOnMinute).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): window the scroll position a zoom can actually reach

Review follow-up (Greptile, Codex): a zoom-out anchored right of centre
at the ribbon's start computed a negative scroll position. The window
centred on it, the browser kept scrollLeft at 0, and with the position
unchanged no scroll event re-windowed, so the visible right-hand part
stayed empty. The centre now uses the position clamped at 0, and once
the frame applies scrollLeft the window re-centres on what the browser
kept, which also covers the clamp at the end of the track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): close the programme popover when the window drops its block

Review follow-up (Codex): the ribbon window can remove a focused narrow
block on scroll, and a removed node fires no focusout, so the fixed
tooltip kept showing a programme no longer on screen. The popover is now
a linkedSignal over the rendered items that keeps its state only while
its block (by key) is still rendered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): re-measure the ribbon window when the axis origin moves

Review follow-up (Codex): a time offset that carries the first programme
across midnight moves the axis origin and every track position while
scrollLeft and the ribbon stay put, so no scroll event fires and the
window kept its epoch-time centre at a different pixel position. The
window identity now includes the axis start; when only that changes,
the viewport is measured from the ribbon instead of re-estimated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:07:25 +02:00
22a9c1f1e5 perf(web): add an opt-in zoneless change-detection build flag (#1824)
* perf(web): add an opt-in zoneless change-detection build flag

Plan item C6 step 4. app.config.ts takes its change-detection providers
from environments/change-detection.providers.ts, which keeps
provideZoneChangeDetection({ eventCoalescing: true }) for every existing
build. The new electron-performance-zoneless and electron-e2e-zoneless
web configurations are their base configuration plus one fileReplacements
swap to provideZonelessChangeDetection(), so the journeys and the Electron
E2E suite can run zoneless while nothing ships it. zone.js stays in the
polyfills until the flip.

A build-config test pins each *-zoneless configuration to its base plus
the swap and refuses the swap anywhere else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): schedule the guide's post-render scroll without zone.js

The programme guide jumps to now once the virtual list first renders rows,
and focuses cells after keyboard scrolls, from afterNextRender hooks
registered in CDK and RxJS callbacks. zone.js followed those callbacks
with a tick; under zoneless change detection a render hook schedules no
render, so the guide opened at midnight (epg-guide.e2e.ts on the zoneless
build). The guide now marks itself when it registers one, which is
harmless with zone.js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(performance): record the zoneless flag measurements and E2E run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(performance): say the zoneless flag ran with the three implemented journeys

Review follow-up (Greptile): J4 search is still planned, so the flag was
validated with J1-J3 and the Electron E2E suite, not all four journeys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:52:47 +02:00
26ca8e2cb0 perf(ui): make the libs/ui Eager components OnPush (#1818)
* docs(performance): inventory the zoneless change-detection migration

Plan item C6 step 2. docs/architecture/zoneless-migration.md lists the 66
production files (67 components) that still set
ChangeDetectionStrategy.Eager, the ten places where a template-read plain
field is written outside an Angular event, the NgZone and
ChangeDetectorRef calls to remove at the flip, and the IPC, player,
observer, timer and dialog paths checked as signal-safe.

On Angular 22 an unset changeDetection already means OnPush, so only the
explicit Eager components re-render on every tick.

zoneless-migration.spec.ts in the performance harness compares the
checklist with the code: a new Eager component, or a converted one left
unticked, fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(ui): make the libs/ui Eager components OnPush

Plan item C6 step 3 for libs/ui/components and libs/ui/epg: eleven
components (twelve with the EPG trust dialog) set
ChangeDetectionStrategy.Eager and were checked on every tick, among them
the always-mounted EPG progress panel the idle audit found re-rendering
on every idle tick. Their template state is already signals, signal
inputs, immutable dialog data or fields written from template events, so
they switch to OnPush without other changes.

The epg-item-description spec mutated dialog data after creation and
marked only the fixture's host view; it now marks the component's own
view, which OnPush requires. The libs/ui playback and remote-control
components stay Eager for their own PRs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 16:44:40 +02:00
4gray 1653ffe9fb fix(epg): scroll the programme guide to now on open and on Now/N (#1781) 2026-10-01 21:40:06 +02:00
4gray 572034f3be fix(ui): declare Material system tokens and migrate dead --mdc overrides (#1775) 2026-10-01 18:02:50 +02:00
4grayandClaude Opus 5.5 cb317bad4c fix(ui): keep dialog action rows on one line (#1762)
* fix(ui): keep dialog action rows on one line

Settings "Unsaved changes" dialog:
- Cancel / Discard / Save replace the phrase labels in all 19 locales, and
  the dismiss now comes first; the shared CANCEL key replaces the unused
  UNSAVED_DIALOG_STAY.
- At the 640px phone breakpoint the actions stack one per row, full width,
  in DOM order.

EPG programme dialog:
- mat-dialog-title gives the dialog an accessible name.
- The footer Close is the only dismiss; it comes first and the primary
  action last.
- The archive copy/download tools move under their notice, so the footer
  stays on one row.
- Channel rows now open it through EpgProgrammeDialogService, which owns
  the 540px config and a panel class scoping the surface overrides.

Adds a web-e2e layout spec (en, de, ru, fr, hu, ar on one row; de and ru
stacked on a phone), extends the Electron EPG spec to all three openers,
and documents the dialog contract in the UI guidelines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(epg): stack programme dialog actions on phones

Below the 640px phone breakpoint the viewport caps the programme dialog,
and a long translated primary label ("Regarder depuis le début") no
longer fit beside Close. The footer had no wrap, and the dialog hides
overflow, so the label was clipped.

- At the phone breakpoint, the archive tools and the footer now stack one
  full-width button per row, in DOM order.
- Buttons grow to fit their label, so a long label wraps inside its
  button instead of being clipped.
- On desktop the footer can wrap again as a last resort.

The new Electron test opens a past programme in French at a 360px
viewport and measures both rows. It fails against the previous
stylesheet (the footer buttons are 44px narrower than the row).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:54:09 +02:00
650da4a1d3 ci(test): type-check Jest spec programs and gate it in CI (#1705)
* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:54:27 +02:00
f0e51d2806 perf(web): keep lazy-only services and SafePipe out of main.js (#1729)
* perf(web): keep lazy-only services and SafePipe out of main.js

The eager shell imported barrels that re-export Angular injectables and a
pipe it never uses, and their static definitions keep those modules in
main.js: PlaylistFileImportService came with PlaylistContextFacade,
normalizeDateLocale with SafePipe, and the workspace-shell-util barrel with
SettingsContextService, which #1714 grew with match counts. That growth put
master 108 bytes over the renderer.initialBytes baseline #1712 had measured
on a branch without #1714.

Add file-level entries for the three modules and use them from the eager
and settings code: renderer.initialBytes 1,626,127 -> 1,619,993 bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(performance): lower the initial-bytes baseline to 1,619,993 bytes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:21:40 +02:00
b30c783e85 fix(search): locale-invariant Turkish case folding in every search path (#1640)
Turkish upper and lower case queries now return the same results everywhere a
title can be searched. Lower-casing the dotted capital "İ" (U+0130) leaves a
combining dot behind, so "İnş" and "inş" reached different search arms and
different results.

- Case folding is locale-invariant: `toLowerCase()`, never `toLocaleLowerCase()`,
  which under a Turkish or Azeri OS locale maps ASCII "I" to the dotless "ı".
- The Electron content search composes to NFC and drops the leftover combining
  marks before tokenizing, and its LIKE/GLOB pattern builders additionally spell
  the `'tr'`-locale İ forms, since SQLite LIKE folds only ASCII.
- A shared `foldSearchText` covers every in-memory filter: channel lists, the
  Xtream and Stalker catalogs, category filters, collections, the EPG guide, the
  command palette, sources, the playlist switcher and the download lists.
- Composing before the strip keeps canonically equivalent spellings equal while
  the fold stays accent-sensitive; the Turkish I/ı pair is deliberately left
  alone, as the FTS index does not fold it either.

Covered by a SQLite-backed spec over the real trigram index plus regression
cases in the affected renderer specs.

Closes #609.

Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:14:02 +02:00
4grayandClaude Fable 5.1 033a08cad9 feat(collections): open a favorite or recent live channel inside its playlist (#1634)
* fix(xtream): keep a live-channel handoff alive until its playlist catalog is loaded

Arriving at /workspace/xtreams/:id/live from another route with
openXtreamLiveItemId in history state silently did nothing: the Xtream
shell mounts the live layout after its session bootstrap, i.e. after the
arrival's NavigationEnd, so the layout's NavigationEnd subscription never
saw it. When the layout was reused instead (playlist switch), the shared
store still held the previous playlist's catalog at NavigationEnd and the
"not in liveStreams" verdict dropped the pending id.

Read the state once at mount as well, carry openXtreamLivePlaylistId in
the navigation state, and treat a miss as final only once currentPlaylist
is the requested playlist and isContentInitialized is true.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(collections): open a favorite or recent live channel inside its playlist

Live channels watched from Favorites / Recently viewed had no visible
playlist and no way to jump there, unlike movies and series with "View in
portal". Add the live counterpart:

- getLiveCollectionPlaylistNavigation() resolves the channel inside its
  playlist (Xtream via the live layout's auto-open state, M3U via
  openM3uChannelUrl on the player's all view); Stalker resolves to null
  until its ITV layout gets an open-on-arrival contract, so nothing is
  shown there instead of landing on the section root.
- app-open-in-playlist-chip, projected into the EPG timeline / list-view
  toolbar through a new [epgToolbarAction] slot beside the channel name,
  visible in the collapsed state too.
- "Open in <playlist>" entry in the channel row context menu, which also
  covers radio rows and rows that are not playing.

Both label with playlistDisplayLabel and reuse PORTALS.VIEW_IN_PORTAL_TOOLTIP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(xtream): only match an auto-open channel against the requested playlist's catalog

Review finding (Greptile/Codex P1): the playlist check ran only on a miss,
so a colliding provider-local xtream_id in the previous playlist's catalog
was accepted, played the wrong channel and consumed the handoff. Check the
playlist before consulting the catalog at all.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 18:01:17 +02:00
4gray e9eca1c386 chore(deps): upgrade Angular to 22.1 and Nx to 23.2 (#1603)
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2

* fix(deps): complete Angular migrations after rebasing on master

* fix(ci): use the Node pin for Windows runtime refresh

* docs(deps): synchronize the workspace-shell Node requirements
2026-09-14 19:02:40 +02:00
4gray d9c8ba4d0b fix(epg): keep the import panel free of horizontal scrolling (#1599) 2026-09-13 17:25:53 +02:00
4gray bad8a0991e feat(downloads): download completed Xtream catch-up programmes as TS (#1572)
* feat(epg): copy catch-up programme URLs without changing playback

* feat(downloads): save completed Xtream archive programmes as TS

* fix(epg): let newer archive copy requests supersede pending work

* fix(downloads): protect archive partials and independent submissions

* fix(downloads): verify archive identity through finalization

* fix(downloads): bound archive storage and capture cleanup entries

* fix(downloads): preserve archive ownership across failure paths

* fix(downloads): recover explicitly verified archive completions

* fix(downloads): journal archive promotion before publishing files

* fix(downloads): reset archive proof before an explicit restart

* fix(downloads): preserve archive recovery ownership and interruption

* fix(downloads): verify durable archive identity at resume open

* fix(downloads): fence archive commands during completion commit

* fix(downloads): persist archive ownership throughout its lifecycle

* fix(downloads): protect archive removal and missing-file recovery

* fix(downloads): journal private cleanup captures for recovery

* fix(downloads): journal active archive cleanup before removal

* fix(downloads): clean settled archives before deleting stale rows

* fix(downloads): preserve archive ownership on removal and resubmission

* fix(downloads): recover proven archive completions before retry

* fix(downloads): recover local archives before remote transfer checks

* test(downloads): resolve archive fixture from workspace root

* fix(downloads): distinguish reused archive inodes by creation time

* fix(downloads): bind fresh archive reservations to owned files

* fix(downloads): clean reservations when ownership writes fail

* fix(downloads): commit archive reservation and ownership atomically

* fix(downloads): retain captures until replacement restoration succeeds

* fix(downloads): require durable ownership before cleanup relocation

* fix(downloads): preserve 64-bit archive file identities on Windows

* refactor(release): keep capture fixture constants in their shared module

* fix(downloads): preserve the last link of captured foreign files

* fix(downloads): expose retained archive recovery files

* fix(downloads): keep recovery instructions open while copying
2026-09-08 20:33:05 +02:00
4gray 7f06690e72 feat(epg): copy catch-up programme URLs (#1569)
* feat(epg): copy catch-up programme URLs without changing playback

* fix(epg): let newer archive copy requests supersede pending work
2026-09-08 07:29:11 +02:00
4grayandClaude Fable 5.1 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): add programme guide implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add window-scoped guide programme queries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): render the guide predicate in tests and document its scope

Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): expose guide programme and coverage reads over the bridge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): separate coverage chunk size in the guide plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide source contract, day layout maths and preferences

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): key guide IPC answers by trimmed, present keys only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): guide search hits carry a row id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide geometry DST-safe and tighten the contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): cache guide programmes per day with batched loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide keyboard navigation controller

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide programme cache robust to first-run effects and coverage failures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add the programme guide grid components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add a Guide button to the timeline toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): adapt the playlist channel list to the guide contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): guard the guide's initial group scope and track language changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): open the programme guide in place with a docked player

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): remove the multi-EPG overlay and the channel-range IPC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): document the programme guide and its release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* i18n(epg): translate the programme guide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): let the guide own the keyboard and gate its entry points

While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): cover the programme guide flow

Imports a two-channel playlist with XMLTV, opens the guide from the
timeline toolbar and asserts the row list, the "Only with EPG" filter, a
channel switch that keeps the guide open, the hidden-but-mounted sidebar,
and that the player element survives both the mode and channel switches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(epg): tidy guide docs, palette gating and the unbound output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): match guide favorites by channel URL and skip re-activating the playing row

Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(epg): let the guide window predicate use the programme time index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): make guide row ids collision-proof and gate the G shortcut

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): describe guide row ids as scope-local

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers

The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +02:00
4grayandClaude Fable 5.1 ef2418a2c5 feat(epg): compact timeline toolbar with icon-only Now and cycling zoom (#1557)
* feat(epg): compact timeline toolbar with icon-only Now and cycling zoom

Replace the 96px zoom range slider with a single icon button that cycles
day overview → by hour → detailed, make the Now button icon-only (label in
tooltip + aria-label), and add Ctrl/⌘ + wheel zoom around the cursor on the
ribbon (preventDefault keeps Chromium from page-zooming the same gesture).
Zoom math moves into TimelineZoomController so the component stays under
the max-lines limit. The channel/programme heading no longer carries a 60%
width cap and takes the space the controls free up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): coalesce wheel-zoom anchoring and make the e2e zoom reset deterministic

- TimelineZoomController keeps a logical pending scrollLeft across a burst
  of wheel/pinch events and applies it in one animation frame, so every
  event anchors on the same minute instead of the stale DOM position
- the e2e settles on the detail preset from whichever band the wheel
  landed in (contrast checks need the block's time line, hidden at
  narrower tiers)
- zoom tooltip advertises ⌘/Ctrl per the repo's shortcut convention

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 10:34:28 +02:00
4gray 9de480826c fix(epg): remove cached XMLTV data after source deletion (#1548)
* fix(epg): remove cached XMLTV data after source deletion

* fix(epg): close source reconciliation review races

* fix(epg): serialize cleanup with replacement imports

* fix(epg): report retired worker exits as cancellations

* fix(epg): preserve source metadata through cache cleanup

* refactor(epg): separate worker runtime and import lifecycle

* fix(epg): skip cleanup for unchanged source settings

* fix(epg): cancel retired error rows and pending retries

* fix(epg): redact diagnostics and mirror committed settings after cleanup errors

* fix(epg): preserve metadata writer order independently of timestamps
2026-09-06 07:32:30 +02:00
4gray 8eda5370eb fix(playback): apply themes to player and EPG panels (#1541)
* fix(playback): apply themes to player and EPG panels

* test(playback): verify active recording icon theme

* fix(epg): keep loading shimmer visible in both themes
2026-09-05 15:32:22 +02:00
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.

Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:46:10 +02:00
4gray 08b868d6c1 test(electron): harden runtime boundary coverage (#1267)
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.

The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.

Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.

Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
2026-07-26 22:27:50 +02:00
StefanandClaude 2f8aee72df feat(xtream): add catch-up playback to favorites and recent tabs (#1166)
Enables Xtream catch-up/timeshift from the Favorites and Recent surfaces (per-playlist and global), not just Live TV, and adds start-over replay of the currently-airing programme. Carries tv_archive/tv_archive_duration through the favorites and recently-viewed DB projections and maps them onto UnifiedCollectionItem; tv_archive_duration is interpreted as days, matching live-stream-layout.controlledArchiveDays.

Closes #1138.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-19 06:38:19 +02:00
4grayandClaude Fable 5 a51c537bb2 fix(theme): make scrollbars follow the app theme instead of the OS color scheme (#1179)
On Windows with a light OS theme, scrollbars rendered light even when the
app was switched to dark. Two combined causes:

- The page never declared `color-scheme`, so Chromium colored native
  scrollbars from the OS preference. Declare `color-scheme: light` on html
  and flip it to `dark` via `html:has(> body.dark-theme)` plus the
  `.dark-theme` block itself.
- Scrollbar styling referenced `--mat-sys-*` tokens, which are never
  emitted by the current Material theme setup (mat.define-theme +
  all-component-themes does not produce system tokens). Those
  `scrollbar-color` declarations computed to `auto`, falling back to the
  native (light) scrollbar. Switch scrollbar styling to the `--app-muted-color`
  design token (defined for both themes), replace hardcoded white
  `rgba(255,255,255,.08)` thumbs, and add an explicit `scrollbar-color`
  where only `scrollbar-width: thin` was set.

Verified live in Electron via CDP in both themes: scrollbar-color resolves
and scrollbars render dark in dark theme regardless of the OS setting.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 17:24:49 +02:00
4grayandClaude Fable 5 57ba1977a1 test: close coverage gaps in persistence, portal stores, EPG, players, and E2E (#1124)
* test(db): cover playback positions, recently viewed, and connection migrations

Add specs for the previously untested persistence paths: playback-position
and recently-viewed operations (upsert/dedup/ordering/scoped deletes),
shared-database path-utils, createTables and the tolerant column/index
migrations incl. Xtream cache deduplication. Exposes createTables through
the existing __databaseConnectionTestHooks object.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(xtream): cover the Electron DB-first data source and favorites guards

Add specs for electron-xtream-data-source (DB-hit vs cold-cache paths,
concurrent request dedup, error propagation, full method delegation) and
extend the favorites feature spec with the invalid-input and
content-not-found guard paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(stalker): cover favorites and recent store features

Add specs for with-stalker-favorites and with-stalker-recent: payload
normalization and id/title fallbacks, series-mode category forcing, meta
sync dispatches, snackbar/callback side effects, and error paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(epg): cover archive/summary utils and the EPG worker service

Add specs for the pure catch-up window and summary-progress helpers shared
by the EPG panels, and for epg-worker.service: in-flight dedup by URL,
double-settle guard, progress-aware timeouts, worker lifecycle and error
broadcasting. Also settle an interrupted fetch in epg.events.spec that
caused "Cannot log after tests are done" in longer runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(player): cover the VLC session service

Mirror the MPV session spec patterns for VLC: enqueue-command building and
RC response parsing, launch argv construction, instance reuse over the RC
socket, exit-code handling, and the retry-without-RC fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): add downloads page and EPG timeline interaction coverage

Downloads: empty state without sources, and a full lifecycle - authorize a
folder via a stubbed native dialog, download from a local server, verify
the completed item and file on disk, remove it from the UI. Timeline: zoom
changes block widths and the on-air info affordance opens the programme
dialog with the correct title and watch-live action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: split oversized specs to meet the file-size guideline

Address review feedback: extract shared drizzle mocks into
operations.test-helpers.ts and split the Electron data-source delegation
spec into delegation + user-data files. Pure reorganization - test counts
and assertions unchanged (29/13/10), all files now under 300 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(e2e): wait for DB readiness before opening the downloads page

On slow CI runners (macOS) the renderer can query SQLite while the DB
worker is still creating tables, leaving the downloads page on its
skeleton state forever. Poll a playlist read until it succeeds before
navigating on a cold profile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(build): exclude *.test-helpers.ts from the electron-backend app tsconfig

The new operations.test-helpers.ts uses jest globals and broke the webpack
build and tsc typecheck, which compile every non-spec file in the app.
Exclude the test-helpers pattern alongside the existing spec exclusions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 15:45:06 +02:00
4grayandClaude Fable 5 1c710d69ef chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps (#1116)
* chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps

Removes verified-dead components (0 class/selector references outside
their own files): EpgListComponent (+ epg-list-item), EpgViewComponent,
LiveEpgPanelComponent, StalkerCollectionChannelsListComponent,
NavigationComponent, FilterSortMenuComponent, video-player
ToolbarComponent, PortalCollectionShellComponent and
LoadingOverlayComponent, together with their barrel exports.

Alive code extracted from the deleted trees:
- LiveEpgPanelSummary -> libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts
- EpgProgramActivationEvent -> libs/ui/epg/src/lib/epg-program-activation-event.ts
- epg-list.utils.ts trimmed to the three timeline-used helpers and moved
  to libs/ui/epg/src/lib/epg-program.utils.ts
- epg-item-description/ moved up out of the deleted epg-list/ folder

Also removes 18 i18n keys now unused (from all 18 locales), dead CSS
selectors targeting the deleted elements, and unused dependencies:
lodash (+ @types/lodash), semver, @ngrx/component-store and
@videojs/http-streaming (videojs-quality-selector-hls declares no peer
dependency on it; video.js 8 bundles VHS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(coverage): move shared-portals to Tier C, fix stale doc references

The Tier A gate failed in CI because deleting the dead epg-view and
live-epg-panel components removed the only specs in libs/ui/shared-portals.
The lib now contains a single type-only interface (LiveEpgPanelSummary),
so there is no runtime code to unit test; reclassify it to Tier C with a
documented reason, matching the gate's own guidance.

Also update remaining doc references to the deleted components in
docs/architecture/stalker-epg.md, iptvnator-ui-guidelines.md and
CLAUDE.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:09:50 +02:00
4grayandClaude Opus 4.8 0b526b119a feat(epg): add vertical list view for the live EPG panel (#1115)
* feat(epg): add vertical list view for the live EPG panel

Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).

- New EpgListViewComponent (app-epg-list-view) mirrors
  EpgTimelineComponent's input/output contract 1:1, so all four live
  hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
  with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
  hasProgramsForDateKey, epg-archive.util catch-up gating,
  epg-summary.util collapsed-summary maths, epg-date helpers,
  EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
  duplicated logic.
- Rows show time range, title, optional description, live progress on
  the on-air row, catch-up "Watch" on past rows when archive playback
  is available, and a details dialog; keyboard activation guards
  nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
  collapse/expand remounts, and shows a sticky in-flow "On now" strip
  (never overlaying rows) when the current programme is scrolled away;
  all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
  modifier (--epg-inline-height clamp); timeline and collapsed heights
  are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
  SettingsStore/StorageMap → segmented control in the EPG section);
  Electron-only UI, PWA stays on the timeline default. i18n keys added
  to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
  persistence spec, swap tests in all four host specs, and Electron
  E2E for the settings round-trip and the rendered list view. Docs
  updated (m3u-playlist-module.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(epg): address list-view review findings from Codex and Greptile

- Reset the list view to today when a new channel's programme set
  arrives while the user is parked on another day (timeline parity):
  the scroll controller now keys by the full programme-set identity
  (programsFocusKey) and commits today before focusing, instead of
  silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
  on SettingsStore; the four live hosts consume the derived signal
  instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
  registerEpgListViewEffects(), bringing the component back under the
  300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
  coverage: return-to-today on channel switch, day navigation left
  alone, no-takeover when today has no data, empty-set no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(epg): drop malformed programmes from the list-view day filter

Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:05:08 +02:00
4gray 401012eac4 feat(epg): rework live EPG panel into a horizontal timeline (#1102)
Replaces the vertical EPG list with a shared horizontal `app-epg-timeline`
ribbon across all live surfaces (M3U player, unified live tab, Xtream, Stalker):
zoom, day navigation, short-programme grouping, catch-up/timeshift, and
per-state empty views. Backend gains timezone-aware `datetime()` comparisons,
unscoped source fallback, non-ASCII candidate matching, and chunked candidate
queries.

Timeline split into reusable, view-agnostic modules (archive/summary/dialog
service/render util/scroll controller) for the future EPG list view.

Fixes landed during review:
- honor the controlled `selectedDate` input (seed via linkedSignal)
- restore ribbon position across collapse/expand
- keep the ribbon mounted when scrolling across a gap day
- don't trigger block playback on Enter from nested watch/info buttons
- don't reset timeshift playback on the 30s now-tick during EPG gaps

Greptile 5/5 (safe to merge); Codex clean; CI green.
2026-07-01 21:09:49 +02:00
4gray 3cb228d931 fix(m3u): highlight active archive epg program (#1097)
fix(m3u): highlight active archive epg program

* Store the active archive EPG program with the resolved playback URL so the clicked M3U catch-up row stays highlighted during archive playback.
* Show archive playback state and return-to-live controls in the inline player panel.
* Hide duplicate EPG program rows that share the same time slot while keeping the richer provider entry.
2026-06-28 14:34:51 +02:00
4gray a84090026b fix(epg): avoid duplicate track keys for archive rows (#1096)
* fix(epg): avoid duplicate track keys for archive rows

* refactor(epg): extract epg list helpers
2026-06-28 12:37:42 +02:00
4gray d43bc83531 feat(epg): improve catchup row playback handling 2026-06-27 12:02:00 +02:00
4grayand4gray e8aa7c3a34 [codex] Add scoped EPG security trust controls (#1054)
* Add scoped EPG security trust controls

* fix: address scoped trust review feedback

---------

Co-authored-by: 4gray <fourgray@proton.me>
2026-06-12 20:46:24 +02:00
Salem 2c032cd3c8 fix(security): complete Electron hardening and review follow-ups
* fix(security): harden Electron IPC against MITM, SSRF, path and injection risks

S1 TLS: validate certs by default on playlist/EPG fetches (opt-out via IPTVNATOR_ALLOW_INSECURE_TLS); new util/secure-https.ts.
S2: write-file IPC restricted to save-dialog-authorized paths.
S3: XTREAM_PROBE_URL guarded by assertRemoteUrlAllowed + maxRedirects:0; new events/url-safety.ts (+19 tests).
S4: EPG titles rendered via interpolation, not [innerHTML].
S5: downloads reveal/play limited to recorded download paths.
S6: Stalker cmd encoded (slash-preserving) to block query injection.
EPG-worker and Stalker fetches reject file://-style/credentialed URLs; LAN/self-hosted targets remain allowed.

* perf(player): lazy-load web video players via @defer

Wrap Video.js/HTML5/ArtPlayer in @defer (on immediate) so video.js, hls.js,
artplayer and mpegts.js split into a deferred chunk loaded on first playback
instead of eagerly on the player route. Embedded MPV (native) stays eager.
Spec uses DeferBlockBehavior.Playthrough.

* fix(player): remove leaked HTML video listeners on destroy

volumechange used a mismatched removeEventListener reference, while
loadedmetadata and timeupdate were never removed at all. Bind all three to
stable handler fields used for both add and remove, and add a teardown
regression test asserting each listener is detached on destroy.

* refactor(dashboard): extract pure navigation helpers from DashboardDataService

Move the 8 stateless link/navigation-state/type-kind helpers into a new
dashboard-navigation.util.ts so the routing logic is independently testable and
the 1260-line god-service shrinks. DashboardDataService keeps the public methods
as thin delegators (facade) so the public API and the single consumer
(workspace-dashboard-rails) are unchanged. First slice of the DashboardDataService
decomposition; verified by the existing service spec (33/33) and the app typecheck.

* fix(review): address PR feedback (IPv6 link-local, write-path cap, @defer placeholder)

- url-safety: broaden IPv6 link-local detection to the full fe80::/10 range
  (fe80:: through febf::), not just the fe80:: prefix (+ regression tests).
- playlist.events: cap authorizedWritePaths (evict oldest past 32) so a save
  dialog opened without a following write cannot accumulate entries until restart.
- web-player-view: add a @placeholder to each @defer (on immediate) player block
  to avoid the one-frame blank/layout-shift before the chunk resolves.

* fix(security): close Electron network and download gaps

* test(downloads): cover cancellation and restart cleanup

* fix(downloads): address Greptile review gaps

* test(security): reproduce remaining Greptile findings

* fix(security): close remaining Greptile findings

* test(downloads): reproduce early database queue stall

* fix(downloads): release queue after setup failures

* test(downloads): reproduce completion queue stall

* fix(downloads): release queue after completion failures
2026-06-12 15:24:29 +02:00
4gray 1badb9a057 refactor(electron): share typed preload bridge contract (#1018)
* refactor(electron): share typed preload bridge contract

* refactor(electron): tighten bridge review fixes

* refactor(electron): tighten playlist bridge returns

* refactor(electron): use bridge epg progress type

* refactor(epg): alias progress bridge types
2026-06-01 09:52:22 +02:00
4gray 3d4d2ca9bf fix(angular): remove template diagnostics warnings (#1011)
* fix(angular): remove template diagnostics warnings

* fix(angular): preserve template fallback behavior
2026-05-26 18:33:44 +02:00
4gray 697eab6e73 refactor(epg): route renderer calls through runtime bridge
Add typed EPG runtime bridge, split EPG runtime capabilities, migrate renderer EPG callers away from direct window.electron access, and address Greptile review feedback.
2026-05-24 13:45:51 +03:00
4gray ed8680116c fix(runtime): address consolidated review feedback 2026-05-22 14:02:56 +03:00
4gray 54d29f2239 refactor(epg): gate multi epg queries by runtime capability 2026-05-22 13:33:31 +03:00
4gray 030c6ef89d refactor(epg): gate source freshness by runtime capability 2026-05-22 13:33:31 +03:00
4gray 2ecb5c28f4 fix(dashboard): address PR review feedback 2026-05-22 00:59:17 +03:00
4grayandClaude Opus 4.7 6891a1ca30 i18n(epg): translate multi-EPG toolbar tooltips
The Zoom/Filter/Search Programs buttons in the Multi-EPG header
hard-coded English matTooltip strings, so they never localized — even
when the rest of the toolbar (Close, Previous/Next day) used keys. Adds
six new EPG.* keys (ZOOM_IN, ZOOM_OUT, FILTER_CHANNELS, CLOSE_FILTER,
SEARCH_PROGRAMS, CLOSE_PROGRAM_SEARCH), wires both [matTooltip] and
[attr.aria-label] through `| translate`, and fans the strings out to
all 17 non-English locales via the i18n-fill pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:15:35 +02:00
4grayandClaude Opus 4.7 7399fbfa9e refactor(multi-epg): match app theme, widen channels, label the now-line
Four UX-audit fixes for the Multi-channel EPG view:

1. Theme harmonization
   - $bg-deep / $bg-surface / $bg-elevated / $bg-hover now resolve through
     --app-content-bg / --app-widget-bg / --app-widget-header-bg /
     --app-card-hover-bg. Multi-EPG was the only screen that switched to
     near-black (#0a0a0f) instead of sitting on the app's dark surface, so
     the audit described it as "visually detaching from the rest of the
     app". It now inherits the same dark graphite chain.
   - Cleaned up 29 remaining hard-coded rgba(139,92,246,…) purples and
     rgba(0,212,170,…) teal accents that survived the earlier color
     unification commit. Purple shadows/glows are gone; the cyan glow
     used by the now-line is now driven by the unified $5cd6ff token.

2. Channel column widened 140px → 180px
   - Long names like "13th Street Universal HD" no longer truncate to 5
     characters. Logo size dropped to 24px so the name actually has room.
     The label allows up to two lines and wraps cleanly on long titles.
   - Responsive breakpoint mirrors the change: 100px → 140px at <768px.

3. Now-line time badge
   - New `currentTimeLabel` computed signal renders an "HH:MM" pill
     pinned to the top of the now-line. Recomputes on the same 60s tick
     as the line position so they always stay in lockstep.

4. "Airing now" program highlight
   - New `isProgramAiringNow(program)` returns true when the now-line's
     x-coordinate falls inside [startPosition, startPosition + width].
     Programs that match get a .is-now class → 1.5px cyan border. Users
     can now see what's on every channel at a glance without tracing the
     line down each row.

Toolbar harmonization (audit item 3 for this screen) intentionally
deferred — that's part of the app-wide toolbar unification work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:21:10 +02:00
4grayandClaude Opus 4.7 a27a29fcf2 refactor(ui): unify primary color to blue across all surfaces
Swap M3 primary palette from violet to azure so checkboxes, radio
buttons, raised CTAs, and active states read as the same blue used by
the rail selection token. Cascading template + SCSS updates align the
remaining hand-rolled surfaces (Add Playlist dialog, VOD play button,
radio player, multi-EPG, empty-state CTAs) with the unified system.

LIVE stays red (broadcast role), cyan stays on EPG "now" indicator,
green stays on completed-download — semantic colors keep their meaning;
only the indiscriminate accent uses get folded into the blue primary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:12:34 +02:00
4gray d24c77a143 chore(nx): enforce scoped workspace boundaries (#942) 2026-05-15 09:59:06 +02:00
4grayandClaude Opus 4.7 21fe51e73b fix(ui): redesign EPG no-data empty state to match app pattern
Replace the dashed-bordered "Ooops" placeholder in the EPG side panel
(rendered when the selected date has no programmes) with the icon +
title + hint pattern already used by groups view, season container,
and portal empty states.

The dashed border was a visual outlier — every other empty state in
the codebase is borderless, and a dashed stroke conventionally signals
a drop-zone or "add new" affordance, neither of which applies to a
passive informational state. The new layout uses a 48px event_busy
icon at 0.5 opacity, a 1.05rem title, and a 0.85rem hint at 0.65
opacity, centered in the panel via flex.

Soften the EN microcopy ("Ooops, EPG is not available for the selected
date" -> "No program guide", which also fixes the "Ooops" typo) and
add a NO_GUIDE_HINT key with localized strings across all 18 locales,
nudging the user toward the date arrows or playlist refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4bfb62f988b7
2026-05-01 22:55:17 +02:00
4grayandClaude Opus 4.7 0d79466c84 perf(ui): enable OnPush on portal channels list, fix EPG track keys
PortalChannelsListComponent renders a virtual-scrolled channel list (Xtream
live/VOD/series, can be 1000+ items). It was using default change detection,
so every CD cycle in the parent tree (EPG ticks, progress updates) re-checked
every binding. Switch to OnPush — the existing cdr.detectChanges() calls
already cover the async update paths, and signal-based state marks for check
automatically.

Replace track $index with track program.start in two EPG @for loops
(epg-list, multi-epg-container search results). $index causes Angular to
re-create DOM nodes whenever the list reorders (e.g. on EPG refresh or
timezone correction). program.start is the ISO datetime — unique per
program within a channel and stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3d6901072049
2026-05-01 21:18:22 +02:00