* 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>
* 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>
* 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>
* 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>
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>
* 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>
* 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
* 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>
* 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>
* 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
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>
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%.
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>
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>
* 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>
* 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>
* 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>
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.
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.
* 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
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>
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>
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>
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
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
Move the always-visible themed scrollbar from epg-list into a global
.app-scrollbar utility class in styles.scss. Apply it to the All
channels and Groups view virtual-scroll viewports so the channel list
gets the same persistent gutter and themed thumb as the EPG list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c470abf78379
Switch to overflow-y: scroll with scrollbar-gutter: stable so the gutter
is reserved (no layout shift between scrollable and non-scrollable
days). Locally style the thumb with --app-muted-color so it's clearly
visible across themes and platforms, overriding the global 6px
near-transparent style which was being hidden by the macOS overlay
auto-fade.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 918ee7ade2f3
Separation: replace 4px gap with hairline dividers via ::before, hidden
around the active and current-program rows so they read as a continuous
emphasized block. Title line-height 1.4 → 1.3 + letter-spacing -0.01em
to match channel-name. Time uses en-dash, 0.78rem (parity with summary
bar), token-driven --app-muted-color. Description clamped to one line —
full text remains one click away via the info icon.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: b0389ce441a1
Drop the gradient + bevel + drop-shadow chrome from EPG rows so the panel
speaks the same flat-by-default visual language as the channel list. Add
--app-live-color token, tokenise the LIVE badge, tighten typography
(weight 600, text-wrap balance/pretty), and bump the info icon to 32px
for Fitts-compliant hit area.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4cc5371f549e
- Replaced moment.js usage with Intl.DateTimeFormat for date formatting in various components.
- Introduced utility functions `normalizeDateLocale` and `formatWithIntl` for locale normalization and date formatting.
- Updated PlaylistSwitcher, RecentPlaylists, ContentCard, EPG components, and MultiEpgContainer to utilize the new date formatting approach.
- Removed moment-date pipe and related tests, simplifying date handling logic.
- Ensured all date displays respect the current locale settings.
Entire-Checkpoint: 271bd0162027