* feat(stalker): append portal pages on scroll and drop pagination everywhere
Second and final PR of the pagination removal (plan:
.plans/2026-08-09-infinite-scroll-catalog.md). Stalker VOD/series grids now
feed the shared infinite-scroll contract from server-paged appends: portal
pages (server-side size, typically 14) accumulate into one deduplicated
paginatedContent list, page 1 replaces it for the skeleton, hasMoreContent
derives from accumulated length vs total_items (portals that ignore
requested page sizes still terminate), and a failed page > 1 keeps the
accumulated pages on screen with a tail retry (retryContentPage reloads the
same page; loadMore refuses to skip past an unresolved append error). The
facade splits the resource's loading flag by page — skeleton for page one,
tail spinner for appends — and keeps per-identity scroll offsets for
Stalker's INLINE detail round trips; the shared view re-arms its one-shot
restore when a detail opens in the same component instance.
The transitional supportsInfiniteScroll flag and every paged member are
deleted from PortalCatalogFacade; the shared catalog view loses the
mat-paginator, the ?page= round-trip, and the paged query-param branch. The
ITV all-channels grid becomes a client-side render window over the cached
full list (the app's last paginator), and Stalker search pages past its
first capped request via the layout's nearEnd, with a progress guard for
portals that report no usable total.
Validation: 1600 unit tests across 7 projects green (new: vod/series
append + failed-append retry, facade loading split/loadMore guards/scroll
snapshots, ITV window model, compat selector update); catalog-sorting e2e
5/5 (Stalker spec rewritten to scroll model with p>=2 network asserts and
an inline-detail spot-restore round trip; one unrelated nav-timeout flake
reproduced only under parallel machine load), search e2e 16/16, web
stalker e2e green (all-channels grid asserts the windowed count instead of
a paginator range label); lint clean; release note added and validated;
stalker-portal.md, CLAUDE.md, and ui-guidelines updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): reset paging on content-type switch and never skip failed search pages
Round-1 review findings on #1395:
1. Codex P1: switching /vod -> /series with the same category id ('*' on
both section roots) left page > 1 in place — setSelectedContentType did
not touch paging and setSelectedCategory('*') no-ops on an unchanged id
— so the new type's FIRST response was treated as an append onto the
old type's accumulated list. The type setter now resets the page (and
no-ops entirely when the type repeats, keeping detail round-trip
restores intact).
2. Greptile P1 + Codex P2: a failed search append left searchHasMore true,
so the next near-end advanced to page N+1 and permanently omitted the
failed page. The search now tracks searchAppendError: a failed append
keeps the accumulated pages and the next near-end RETRIES the same
page; a failed fresh search (page 1) clears the previous query's cards
instead of rendering them under the new term (Codex P2).
The page-merge/failure logic moved into applySearchPageSuccess/Failure
methods: Angular resource() never re-fires on params changes in this
repo's template-less jest harnesses (store-hosted resources do), so the
extracted methods carry the unit coverage — accumulation + dedupe,
no-total progress guard, retry-not-skip, fresh-failure clear — plus a
selection spec for the type-switch page reset. portal-stalker-feature
260, portal-stalker-data-access 464, lint clean; catalog-sorting e2e 5/5
and web stalker e2e green. search.e2e shows machine-load nav-timeout
flakes on unrelated M3U/live specs (a runaway third-party process pegs
the host CPU); CI provides the clean independent run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): include the portal in the search paging identity
Round-2 Codex P1 on #1395: Angular reuses the search route across
/stalker/A/search -> /stalker/B/search, and the paging identity covered
only term + filter — the page number and accumulator survived the portal
change, so the next near-end fetched portal B at the OLD page number and
appended it onto portal A's results while skipping B's first page.
The active playlist id now joins the page-reset identity, the resource
params, the stale-response guard, and the layout's near-end reset key.
Regression spec: switching the active playlist on a reused route resets
the page to 1 and rotates the scroll reset key.
portal-stalker-feature 261, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): end paging on no-progress appends even with a reported total
Round-3 Codex P2s on #1395 (same defect in both accumulators): the
no-progress guard only applied when the portal reported no usable
total_items. After a mid-list portal mutation, deduplication can leave
the unique list permanently shorter than the claimed total — hasMore then
stayed true forever and every scroll crossing kept requesting pages past
the end of the data.
An append that adds no unique items now ends paging in both places: the
catalog clamps totalCount to the accumulated length (hasMoreContent turns
false and the count badge reflects what is actually reachable), and the
search requires append progress in the total-backed branch exactly like
the no-total branch. Regression specs cover a duplicate page under a
larger claimed total for both. portal-stalker-data-access 465,
portal-stalker-feature 262, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): explicit search retry control and per-portal scroll identities
Round-4 findings on #1395:
1. Greptile P1: with the results pane parked at the bottom, repeated
append failures exhausted the scroll auto-fill budget while the
near-end latch stayed armed — the retry path was reachable only
through another nearEnd event that could never fire. The search page
now renders an explicit retry control under the results whenever an
append has failed (same wording as the catalog grid tail), wired to
the existing retry-same-page path, so recovery never depends on
producing another scroll event.
2. Codex P2: the facade's saved-scroll map survives a same-config portal
switch (the vod/series route provider is reused across /stalker/A ->
/stalker/B), and its identity lacked the playlist — portal A's offset
could restore onto portal B's unrelated catalog. The playlist id now
leads the scroll identity; regression spec covers the cross-portal
non-restore and the return restore.
portal-stalker-feature 263, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): restore the search results scroll after an inline detail
Round-5 Codex P2 on #1395: the search layout destroys the results
container while an inline detail is shown (showDetails) and recreates it
at offset zero — with the new multi-page accumulation a user could load
several pages, open a result far down the list, and land back at the top
on close even though the accumulated results survived.
SearchLayoutComponent now exposes a scroll handoff for hosts whose
details replace the results (getResultsScrollTop /
restoreResultsScrollTop on the container it owns), and the Stalker search
captures the offset when a detail opens and restores it one-shot after
the container is recreated on close. Regression specs cover the layout
handoff methods and the capture/restore round trip.
portal-shared-ui 90, portal-stalker-feature 264, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): clear accumulated search results for unsearchable portals
Round-6 Codex P2 on #1395: the loader's early returns (deleted or
malformed playlist on a reused route) predate the accumulator and
returned [] without touching it — the previous portal's cards kept
rendering under the new context once loading settled.
Every no-portal early return now goes through resetSearchAccumulator(),
which empties the accumulated list and both paging flags; the short-term
path uses it too (and now also clears a stale append error). Regression
spec covers the full reset. portal-stalker-feature 265, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(xtream): replace catalog pagination with infinite scroll
Xtream movie/series/live catalogs now load continuously while scrolling
instead of paging. The selection store keeps a growing visibleCount render
window over the in-memory catalog (initial 50, +50 per load) plus a saved
scroll state, so opening a title and going back restores the exact spot. A
shared InfiniteScrollDirective (portal/shared/ui) fires loadMore near the
bottom (edge-triggered, mirroring search-layout) and auto-fills viewports
taller than the initial window by measuring container overflow — capped at
10 self-initiated loads per list identity, with a ResizeObserver re-check.
The shared CategoryContentViewComponent branches on the transitional
PortalCatalogFacade.supportsInfiniteScroll flag: Xtream scrolls, Stalker
keeps its server-driven paginator and ?page= round-trip untouched until its
append lands (PR 2), after which the paged facade members and the flag are
deleted. grid-list loses its dead built-in paginator and gains tail states
(append spinner, retry-on-error) plus content-visibility on cards. The
in-portal search results reuse the search layout's nearEnd hook to window
their full result set instead of rendering it unbounded.
Validation: portal-xtream-data-access (234), portal-xtream-feature (357),
portal-catalog-feature (22), portal-shared-ui (77, incl. new directive
spec), portal-stalker-* (253) unit tests green; catalog-sorting e2e 5/5
(new scroll-growth + spot-restore test against the large 200-item mock
scenario, Stalker paged spec unchanged); search e2e 16/16; lint green;
release note added and validated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(xtream): auto-fill search results and refresh the near-end latch
Review findings from #1392: the in-portal search window could stall at its
first 60-item chunk when the rendered cards did not overflow the container
— nearEnd only fired on real scroll events (Greptile P1), the search
layout's edge latch survived a result-set replacement (Codex), and the
shared directive's latch went stale after appended content moved the
bottom out of the threshold (Codex).
The search layout now drives its results container through the shared
InfiniteScrollDirective instead of a bespoke scroll handler: the measured
auto-fill reveals further chunks on tall viewports without any scroll, the
reset key (search term) and item-count changes refresh the latch, and new
nearEndHasMore/nearEndAppending inputs let consumers gate emissions.
Xtream search wires them for both modes — this also fixes the same latent
tall-viewport stall in the global search's 100-item pages — and the
Stalker search page (single capped request until PR 2) sets hasMore=false.
The directive's fill check now refreshes the latch from the measured
state, so an End-key jump straight to the new bottom is a genuine crossing
again.
New coverage: directive stale-latch regression, search-layout auto-fill +
hasMore gating, in-portal window reveal/reset in search-results. Reruns:
portal-shared-ui 80, portal-xtream-feature 357, portal-stalker-feature
green; search e2e 16/16 (fresh Playwright report verified); lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(xtream): re-measure search auto-fill on the rendered window, not the total
Round-2 review finding on #1392 (Greptile P1 + Codex P2, same defect): the
search layout bound the constant result-set total to the infinite-scroll
directive's item count, so once the in-portal window grew 60 -> 120 no
tracked input changed, no further overflow check was scheduled, and
results beyond 120 stayed unreachable on tall viewports.
The layout now takes an explicit nearEndRenderedCount (falling back to
resultsCount for consumers that render everything they report) and feeds
THAT to the directive. Xtream search passes the windowed slice length for
in-portal mode and the loaded-set length for global mode. Regression
specs: layout re-measures when the rendered window grows while the total
stays constant; the component exposes the rendered count following the
window. portal-shared-ui 81, portal-xtream-feature 357, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(xtream): include filter state in the search reset identity
Round-3 Codex P2 on #1392: the near-end latch and auto-fill budget were
keyed on the search term alone, so a filter-only transition (type filters
or the hidden-categories toggle) replaced the result set without resetting
them — a jump straight back into the threshold could be swallowed. The
search layout now accepts an explicit nearEndResetKey (defaulting to the
term); Xtream search supplies term + type filters + excludeHidden.
Regression specs: layout latch resets on an identity change without a new
term; the component identity changes on filter-only and hidden-toggle
transitions. portal-shared-ui 82, portal-xtream-feature 358, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(xtream): refuse global-search appends while an edited query debounces
Round-4 Codex P2 on #1392: after the reset-identity change, the layout's
auto-fill can request more results inside the 300ms search debounce. The
append then ran with the freshly edited term but the old result count as
offset, interleaving a page of the new query into the old query's visible
results until the offset-zero search landed.
An append now only continues the LAST EXECUTED search: the append guard
additionally requires the effective term to equal lastGlobalSearchTerm,
so pagination stays suppressed from the first keystroke until the fresh
search replaces the result set. Regression spec covers the mid-debounce
refusal; the two existing append specs state their precondition
explicitly. portal-xtream-feature 359, lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(xtream): per-selection scroll snapshots and progress-based auto-fill stop
Round-5 Codex P2s on #1392:
1. The single saved-scroll slot lost the first tab's position on a
VOD -> Series -> VOD round trip — the series view's destroy hook
overwrote it with series coordinates. Snapshots are now kept per
selection identity (bounded to the 8 most recent), so a detour's save
can never destroy another list's spot. Store API is unchanged.
2. The fixed 10-load auto-fill budget could strand items on a viewport
large enough that ten chunks still do not overflow — with no
scrollbar, no real scroll event can ever fire. The auto-fill now
terminates on lack of progress instead: loads continue while they
grow scrollHeight (until genuine overflow hands off to scroll
events) and stop after three consecutive loads without growth, which
only a source that reports more but renders nothing can produce.
Regression specs: VOD/Series round trip keeps both snapshots; growth
keeps filling past the old cap and stops at overflow; no-growth stalls
stop at three; reset key clears the stall guard. portal-shared-ui 83,
portal-xtream-data-access 235, catalog-sorting e2e 5/5 re-run, lint
clean. CLAUDE.md wording updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(xtream): show a catch-up badge on live channels that have archive
Live channels whose provider declares playable catch-up (tv_archive=1
with a positive tv_archive_duration) now show a small history badge in
the channel sidebar next to the name and on the all-channels grid cards,
with the archive window (days) in the tooltip.
Closes#1128
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(xtream): expose the catch-up badge status to assistive technology
The mat-icon is aria-hidden and the tooltip is pointer-only, so the
badge status was invisible to keyboard and screen-reader users. Both
badge surfaces now also render the translated status as visually-hidden
text (Codex review, P2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(xtream): show the catch-up badge in favorites and recent lists
Carries tvArchive/tvArchiveDuration through UnifiedFavoriteChannel so
the shared favorites list (portal favorites/recent tabs and global
favorites) renders the same catch-up badge as the live sidebar.
Requested in PR feedback by the issue author.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): show the programme-info button in portal sidebars, stacked vertically
Adds the (i) programme-info button to the Xtream and Stalker live
sidebars and reworks the row action column: buttons stack vertically
(favorite on top, info below), so the second button costs no horizontal
space — the column is actually narrower than the previous single-button
row. The info slot is reserved (inert, visibility:hidden) while the row
has no programme, so the star never shifts when EPG data arrives.
Requested by the issue author in PR feedback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(ui): move the archive passthrough spec out of the budget-capped file
CI lints the merge with master, where unified-live-tab.component.spec.ts
grew (#1374) to one line under the 1200 max-lines test budget — the
archive passthrough test added here tipped the merged result over. The
test moves to a focused template-less spec (plus a null-normalisation
case), leaving the main spec at master's size.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): constrain Material touch targets to the stacked button bounds
mat-icon-button keeps a 48px touch target; stacked 28px buttons
overlapped by 20px and the later sibling (programme info) stole clicks
from the lower third of the favorite star. Verified via
document.elementFromPoint before/after: the star's visual bounds now hit
the star, and clicks left of the column reach the row again instead of
the button's oversized target (Codex review).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(build): include shared UI stylesheets in Nx cache inputs
`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.
Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.
Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.
`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.
Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(build): spawn git without a shell in the stylesheet check
`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.
Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.
Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.
Reported by Codex review on #1360.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(styles): move nav-list partial into ui-styles (#1361)
* fix(build): count every target of a comma-separated Sass @import
`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.
Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.
The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(playback): forward portal Cookie/Authorization to built-in players
The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever
receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal
session cookie or Bearer token played exclusively in external MPV/VLC — the
long-running "only VLC works" cluster (#849, #910, #732).
- request-header-overrides.service: the scoped override now carries Cookie
and Authorization, attached only to requests on the exact stream origin,
in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls
drop credentials fail-closed; control characters in header values are
rejected. Chosen over session.cookies.set(): jar cookies attach only to
credentialed requests, which would force withCredentials into every engine
and break against the Access-Control-Allow-Origin:* IPTV panels send, and
jar scoping is port-blind.
- WebPlayerViewComponent is now the single owner of the scoped override for
every built-in player: it extracts the full header set from the resolved
playback, configures the override BEFORE handing the source over (players
render only once the source exists), and clears the scoped layer on
destroy. HtmlVideoPlayerComponent's own three-header call is removed — it
would overwrite the credentialed override.
- Stalker VOD, series episodes and radio now build the same portal header
set ITV already had (they previously carried no portal headers at all);
same-origin playback sends the real User-Agent alongside X-User-Agent.
- Stream classification is host-based via one shared predicate
(isStalkerStreamCredentialSafe): same-host port changes and scheme
upgrades keep the portal profile (the #1158 class), a foreign host or
https->http downgrade keeps the credential-free KSPlayer profile. The
main-process fallback context uses the same predicate so
isStalkerDirectStreamProfile can no longer discard renderer headers.
- setUserAgent bridge gains an optional credentials parameter; preload,
ipcMain handler and ElectronBridgeApi updated together.
- stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose
create_link returns a local /stream/gated/video.mp4 that 403s without the
mac cookie + current Bearer token; new Electron e2e proves a built-in
player actually plays it (and that the gate refuses bare requests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): apply header override to Stalker radio, redact mock cookie log
Address Codex review feedback on #1335:
- The radio branch of the Stalker live layout renders the dedicated audio
player, never WebPlayerViewComponent, so the resolved portal headers were
built but never applied — an auth-gated radio stream still 403'd. The
override sync is extracted into ElectronStreamHeadersService (single owner
of the scoped override slot, with clear-only-while-owning semantics so a
destroyed consumer cannot wipe a newer consumer's override), applied by
WebPlayerViewComponent for video players and by the radio branch before
the audio element gets its URL. The service feature-detects the bridge
method so partial bridges behave like the PWA instead of throwing.
- The gated-stream mock no longer logs the raw Cookie header on 403 —
presence only, matching the Authorization logging.
- The gated scenario now serves an audio fixture for radio create_link and
the Electron e2e covers the radio path end-to-end (bare request 403s,
built-in audio player advances past the gate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): claim radio header ownership before awaiting the IPC
Codex round-2 P2: leaving the radio route while the header IPC was still in
flight left the portal cookie/token installed — ngOnDestroy saw a null scope
URL (it was recorded only after the await) and could not clear the override.
Ownership is now claimed synchronously before awaiting, destroy invalidates
the pending playback continuation, and the apply's stillCurrent verdict is
honored. Regression test covers destroy-during-pending-IPC.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): carry portal headers into collection playback
Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed
resolved through StreamResolverService.resolveStalker(), which returned no
portal headers — the video path handed the header owner an empty set and
collection radio bypassed it entirely, so auth-gated streams still 403'd
from collections.
- resolveStalker() now builds the same profile as the live layout via the
shared classifier: portal-owned streams get mac cookie/Bearer token/MAG
UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer
profile (both create_link results and direct radio URLs).
- UnifiedLiveTabComponent applies the scoped override for radio before the
audio element gets its URL (ownership claimed before awaiting the IPC,
round-2 lesson), and clears it on close and destroy.
- Regression tests: resolver header profiles for portal-host and foreign
streams; unified tab radio apply-then-clear.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): release the radio override when a new selection mounts no player
Codex round-4 P2: after radio installed its credentials, selecting an item
that never mounts a player surface (external video playback, failed
resolution) left the old Cookie/Authorization installed — no
WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both
radio hosts (unified collection tab and the Stalker live layout, which has
the identical hole) now release the previously owned radio scope at the
start of every new selection; the slot-ownership semantics keep this a
no-op when another playback already owns the override. Regression test in
the live-layout spec pins the failed-selection path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(playback): state the exact override release points
Codex round-5 P2 flagged that the media 'ended' event does not clear the
scoped override while the player stays mounted. That is deliberate, not a
gap: a mounted player still owns the session — replay or a seek into an
unbuffered range must keep working against a gated stream, and clearing on
'ended' would 403 exactly the streams this PR fixes. The credentials only
ever travel to the exact origin that issued them, and every dismount path
(channel/source change, player close/destroy, radio close, playerless
selection) releases them. The security doc and the release note now say
precisely that instead of the ambiguous "cleared when playback ends".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(playback): fit the release note back under the 400-character cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): make the workspace usable on phone-sized screens
The shell was half-adapted below 640px: the rail flipped to a horizontal
bar but the link lists inside it kept stacking downwards, so the navigation
was drawn outside the bar and over the header (#1100).
Three resizable rails — the shell context panel, the live-layout channel
sidebar and the M3U channel drawer — kept their persisted desktop width,
which left the content around 50px on a 375px screen. They now span the
full width and stack above the content. The inline width written by
ResizableDirective is why these rules need `!important`.
Found while walking the rest of the UI at 375px and 768px:
- The detail hero kept poster and details side by side, squeezing the
action row below its own labels until "Play" was clipped to its icon.
- The settings section list did not scroll and painted over the footer,
which also affected short desktop windows.
- Hiding the M3U channel list on a phone was one-way: the restore handle
was hidden and only Cmd/Ctrl+B could bring it back.
- The live header drew the channel count and the paginator on top of each
other up to tablet width, because the paginator does not shrink and the
meta collapsed to zero width and overflowed its box.
- The search scope checkbox was pushed off the right edge.
Live TV states a floor for the player instead of a ceiling for the lists,
so the video keeps a usable share of the screen under the categories panel
and the channel list.
Closes#1100
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — keep the palette reachable and the video visible
Two findings from the Codex review on #1326.
Hiding the command-palette trigger on phones removed the only pointer-driven
way to open it: the rail renders route links plus Settings and emits nothing,
so `commandPaletteRequested` had exactly one source. The button stays and its
keyboard-shortcut label is swapped for an icon instead. Doing that exposed a
latent flex trap in the same row — an <input> keeps an intrinsic min-width
from its `size`, and `min-width: auto` honours it, so the field refused to
shrink and pushed the trigger out onto the buttons beside it.
The M3U drawer released the shared player floor, which on a short landscape
phone (600-640px wide) left the content container at half the shell body.
The inline guide inside it is `flex: 0 0 <basis>` and took its full 180px out
of a container that no longer had it, so the video could reach zero height.
The floor is restored and now yields on short viewports, the video states its
own minimum, and the guide is what gives way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the channel list keep its height on a landscape phone
Follow-up to the review: the player floor added in the previous commit was
measured against the viewport, not against what the shell had left. On a
640x360 landscape phone the stacked categories panel already takes 30vh, so
claiming another 50vh here drove the channel sidebar to zero height while it
was still marked expanded — no way to pick another channel — and pushed the
layout past the viewport.
The floor now applies only where the screen can afford it (`min-height:
600px`), the sidebar states a floor of its own so it cannot be squeezed out,
and the collapsed rule clears that floor so hiding the list still works.
Below that height the two panes simply share what is left.
Portrait is unchanged: categories 244px, channel list 220px, player 240px on
a 375x812 screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — settings nav on landscape, poster dead space
Two more findings from the Codex review.
The stacked settings context panel capped itself at 30vh, which on a 360px
screen is 108px — less than the panel's own title and footer, so the seven
section rows collapsed to nothing behind an overlapping footer. On short
screens the caption gives way (the rail already labels the page), the footer
sheds its tall-screen padding, and the settings variant gets a slightly
larger cap: unlike the live routes there is no player below competing for
height, only a scrollable form.
The poster kept a 330px minimum from the skeleton fallback at the bottom of
the file — sized for the 220px desktop poster — while the stacked phone hero
renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail
page carried ~120px of empty space between the poster and the title. The
override sits after that rule because it wins on source order, not
specificity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the playlist switcher yield to the search field on narrow phones
Codex review of d6133da9: on a 320px header a route that contributes its
shortcut button left the search field less than its own chrome needs (~74px
of icon, palette trigger, gaps and padding), so the field's contents spilled
onto the buttons beside it.
The switcher is the one header region whose content can ellipsize, so it is
what shrinks — down to an 88px floor — while the field states its chrome as
a minimum. The field's basis moves from auto to zero so the input's intrinsic
size stops counting as content: with basis auto the field claimed its
intrinsic width even when room was ample and squeezed the switcher to ~115px
on a 375px screen that could fit all 140.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): fit the switcher's own chrome inside its phone floor
Follow-up to the Codex note that the trigger's fixed chrome (type icon,
refresh, chevron, gaps, padding) exceeds the 88px floor the shell now allows
the switcher to shrink to. The flagged scenario itself cannot occur — the
Multi-EPG shortcut needs Electron bridge methods the PWA lacks, and Electron
enforces a 900px minimum window width so it never sees the phone breakpoint —
but the floor should hold on its own terms rather than by accident of which
buttons happen to render. Dropping the decorative type icon on phones brings
the fixed chrome under the floor, and the name gets the space instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views.
Remove redundant LIVE, VOD, and SERIES badges from homogeneous Xtream and Stalker catalog grids while preserving mixed-content badges and type-driven grid behavior.
* feat(epg): manual EPG mapping for Stalker portals
Extends the manual EPG-to-channel mapping (PR #1165) to Stalker:
- shared key helper buildStalkerEpgMappingKey — playlist-scoped
stalker:{playlistId}:{channelId} keys, mirroring the Xtream scheme
- ITV sidebar: right-click context menu with "Map EPG channel"; after
the dialog closes with a change, the bulk EPG cache is rebuilt so the
panel and row previews reflect the new mapping immediately
- withStalkerEpg().applyMappedItvEpg(): batch-resolves mappings for
rendered channels (one getEpgMappingsBatch IPC per new id set) and
overlays uploaded-XMLTV programs onto bulkItvEpgByChannel; overrides
survive ensureBulkItvEpg reloads
- stream-resolver: mapping check in loadStalkerEpgItems (detail) and
batched prefetch in loadStalkerEpgBatch (previews);
mappingCandidateKeys/prefetchEpgMappings generalized beyond Xtream
- global favorites: stalker branch for the Map-EPG menu entry (item id
extracted from the stalker::{playlistId}::{id} uid)
- docs: manual-mapping section in docs/architecture/stalker-epg.md and
a CLAUDE.md EPG bullet covering the whole mapping feature
- tests: applyMappedItvEpg suite, stalker preview mapping in the
stream-resolver spec, key-builder specs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): harden Stalker mapping edges found in adversarial review
- stream-resolver: stalker items resolve only the playlist-scoped
mapping key — bare tvgId/name candidates (tvgId mirrors the raw
provider id) could only produce false matches against unrelated M3U
mappings, and previews would disagree with the detail path
- applyMappedItvEpg: staleness guard after every await so an in-flight
call cannot write portal A's mapped EPG into portal B's state after
a playlist switch (the store is a root singleton)
- applyMappedItvEpg: ids are marked checked only after a successful
lookup — a transient IPC failure no longer suppresses the mapping
for the rest of the session
- live layout: post-dialog refresh re-applies overrides for the
unfiltered channel list, so an active search cannot drop the playing
channel's mapping
- global favorites: new epgMappingChanged output emitted when the
dialog actually changed a mapping; unified live tab reloads its EPG
previews in response
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(epg): manual EPG-to-channel mapping with mapping fallback in all EPG paths
* fix: epg mapping in live tv list
* fix(epg): harden manual EPG mapping — upgrade safety, perf, playlist-scoped keys
Follow-up fixes on top of the manual EPG-to-channel mapping feature:
- epg-database: dedupe existing epg_programs rows before creating the
unique (channel_id, start, title) index — a plain CREATE UNIQUE INDEX
crashed the EPG worker on upgrade when historical duplicates exist;
replace INSERT OR REPLACE with ON CONFLICT DO UPDATE so the
epg_programs_fts delete trigger is not bypassed (REPLACE skips delete
triggers unless recursive_triggers is on), with a plain-INSERT
fallback when the index cannot be created
- db: add idx_content_epg_channel — the mapping fallback scanned the
whole content table on every single-channel EPG lookup
- keys: scope Xtream mapping keys per playlist via shared
buildXtreamEpgMappingKey (xtream:{playlistId}:{id}) — bare stream ids
collide across portals; the backend fallback now joins categories to
resolve the playlist id
- pwa: hide "Map EPG channel" entries behind the supportsEpgMapping
capability — the menu item was a dead end in the PWA
- parser: parse the XMLTV offset sign from the string — Math.sign(0)
dropped the minutes of ±00:xx offsets
- cleanup: typed window.electron access instead of ad-hoc casts, drop
unused resolveChannelId and dialog data field, shared
EpgMappingDialogComponent.open() for all seven call sites
- dialog UX: minimum-characters search hint, save/remove snackbars,
current mapping shows the EPG channel display name,
takeUntilDestroyed on the search stream
- i18n: fill the new keys in all 17 locales
- tests: cover mapping CRUD/search escaping, the dedup-index guard,
offset parsing and playlist-scoped keys; update stale stream-resolver
specs for the new 50-item limit and 10s timeout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): escape backslashes in EPG channel search LIKE pattern
CodeQL js/incomplete-sanitization: a lone trailing backslash in the
search term paired with the closing wildcard under the ESCAPE clause
and corrupted the pattern.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(epg): batch mapping lookups in the viewport preview queue
Expose the existing getEpgMappingsBatch operation over a new
EPG_MAPPING_GET_BATCH IPC channel and use it in resolveManualMappings —
the per-entry lookup issued one IPC round-trip per visible channel on
every scroll event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(epg): batch mapping prefetch in the collection preview loader
Resolve all candidate mapping keys for an Xtream preview batch with a
single getEpgMappingsBatch IPC call instead of per-channel lookups.
Also fix a worker early-exit: a channel without tvgId/name returned out
of the shared-iterator loop and silently killed one of the three
concurrent preview workers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
Dashboard Continue Watching now carries the exact saved season/episode into
Xtream series details and starts it at the persisted offset. Successful
external MPV/VLC launches persist the launched episode and retarget the
series CTA to "Play episode N". Recent-history rows keyed by an episode id
resolve their parent series before navigation.
Includes maintainer follow-ups: no zero-offset resume on failed position
loads, seriesXtreamId-gated resume targets for legacy rows, and patch
coverage raised from 76.7% to 93.9%.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat: add strip country prefix setting
* feat: scope country-prefix stripping to live content and cover missing surfaces
- narrow the heuristic: pipes always strip, dash/colon separators only
when the prefix is a short uppercase tag ("UK - BBC One" strips,
"Sky - Sports F1" and "Mission: Impossible - Fallout" stay intact)
- fall back to the original name when stripping would leave nothing
- scope stripping to live content only: grid type (live/itv/radio),
playback isLive, external sessions without contentInfo, dashboard
cards with contentType 'live'
- cover previously missed surfaces: M3U player EPG timeline header,
M3U inline player title, radio player, dashboard live rails
- replace hardcoded settings strings with translate keys and add
SETTINGS.STRIP_COUNTRY_PREFIX(_DESCRIPTION) to all 18 locales
- add unit specs for the utility plus regression specs for
channel-list-item and external-playback-dock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: cover strip-country-prefix call sites for codecov
- dashboard-rail: new spec for cardTitle live/movie/series scoping
- grid-list: strip enabled/disabled, VOD passthrough, 'No name' fallback
- portal-inline-player: live strip vs VOD passthrough
- unified-live-tab: timeline channel name strip + M3U name precedence
- video-player: timeline/radio/inline titles with the setting on and off
- settings-store: default false + persisted true round-trip
- settings-form.utils: new spec for form default and ?? false fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(portals): back button on the in-portal search page
Opening a "grey" (unmatched) film from an actor page navigates to the
portal search prefilled with ?q=, but the search page had no way back —
users had to re-navigate through the sidebar to reach the actor's
filmography again.
SearchLayoutComponent gains a showBackButton input + backClick output;
both the Xtream in-portal search (SearchResultsComponent) and the Stalker
search wire it to Location.back(). Shown only on the nested in-portal
search — the top-level global search (sidebar destination) and the dialog
mode (own close button) are excluded. location.back() unwinds the full
history, so search → actor → movie detail → … all chain correctly.
Tests: search-layout spec covers the conditional button + backClick.
Docs: CLAUDE.md actor-pages note updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): address search back-button review (dialog guard, RTL-safe spacing)
- showBackButton now also checks !dialogRef, so a hypothetical dialog
opened with isGlobalSearch:false from a workspace route can't show a
back arrow that navigates the page behind the dialog (greptile).
- Replaced the fragile negative margin with a header-title-group flex
wrapper (gap: 4px) that keeps the arrow and title together regardless
of the outer gap or Material padding, and is RTL-safe (greptile).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): wire catch-up Watch button in unified live tab, surface resolve failures
The favorites/recent live views (UnifiedLiveTabComponent) passed
archivePlaybackAvailable/archiveDays to the EPG timeline and list view, so
the "Watch" button and the dialog's "Watch from start" action rendered —
but neither (programActivated) nor (returnToLive) was bound. Clicking
Watch emitted an event nobody listened to: no error, no feedback, no
playback change.
- Handle programActivated in the unified live tab: resolve the catch-up
URL via resolveM3uCatchupUrl, swap the inline player's playback target
(or hand it to the configured external player), show the
"Archive playback" summary label, "From archive" block tag and
"Return to live"; clear the override on channel switch/close/return.
- Never fail silently: both the unified tab and the m3u-state effect now
show an EPG.TIMELINE.CATCHUP_FAILED snackbar when the replay URL can't
be resolved. New i18n key translated into all 17 locales via the
i18n-fill workflow.
- Extract the effect's resolve branch into a pure
resolveActiveEpgProgramAction util and the unified tab's EPG summary
helpers into unified-live-epg-summary.util.ts (component shrinks
598 → 572 lines despite the new feature).
- Regression coverage: 6 component tests for the catch-up flow,
3 unit tests for the resolver action; docs updated in
m3u-playlist-module.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(epg): address PR review — untrack progress tick in archive summary, open live external without prior timeshift
- liveEpgPanelSummary no longer tracks the 30s progressTick while an
archived programme is shown (Greptile P2): the archive summary is
frozen, so re-running the computed each tick only produced needless
re-renders.
- returnToLivePlayback no longer early-returns when no timeshift is
active (Codex P2): with an external player configured and
openStreamOnDoubleClick enabled, the programme dialog's "Watch live"
action now launches the external player instead of being dropped.
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.
- merge origin/master into PR #964 and keep embedded MPV test on the isolated playback sub-entrypoint
- centralize EPG capability through DataService.supportsEpg and update PWA web-e2e expectations
- split BrowserAccessError copy between Electron and PWA diagnostics
Two small focused changes from an Angular perf audit.
content-card lazy loading
ContentCard is the building block for catalog grids and rails — Xtream
recently-added (3 rails × 20 = 60 cards on first paint), Xtream/Stalker
search results, unified favorites/recent grids. The poster <img> had
no loading attribute, so every off-screen card eagerly fetched its
poster from the Xtream/Stalker server on initial render. Same fix
already in place on the dashboard rail's separate <img>; mirror it on
the shared ContentCard so every consumer benefits.
Add loading="lazy" decoding="async" to both <img> tags (real poster
and the default-poster fallback). Browser defers off-screen requests;
no JS changes.
OnPush on workspace-sources-filters-panel
The sources filters panel renders one row per filter option and
displays {{ getTypeCount(option.id) }} per row — that method is a
plain Map lookup but fires every CD cycle under default change
detection. The component is signal-only (computed + selectSignal, no
subscribes) so the OnPush conversion is purely additive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
The loading placeholders for live channel rows were inconsistent and
visibly heavier than the real channel-list-item: each module rendered
its own skeleton with a colored card surface, larger padding/radius,
mismatched logo border-radius, and only a single tiny right-side dot
instead of the real 34×34 action buttons. Stalker had no initial-load
skeleton at all.
Extract a shared app-channel-list-item-skeleton that mirrors
channel-list-item 1:1 (transparent surface, 8/10/8/12 padding, 12px
radius, 44×44 logo with 10px radius, optional EPG progress placeholder,
0–3 button placeholders) plus a thin app-channel-list-skeleton wrapper
that renders N rows with deterministic width variance, and route every
live-tv loading state through it (m3u channel-list-loading-state,
xtream portal-channels-list, stalker live-stream-layout via a derived
isInitialChannelsLoading signal, and the unified favorites/recent
collection page). Drops the bespoke channel-skeleton-shimmer keyframes
in favor of the shared skeleton-shimmer animation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 58a2d50756dd
Three placeholders were rendering at once when a search filter cleared
both the groups rail and the channel list, and they each used a
different visual treatment:
1. Groups rail (.groups-nav-empty) was bare 0.82rem muted text with no
icon and no hint, and routed through CHANNELS.NO_SEARCH_RESULTS
("No channels found") which is wrong copy for the *groups* column.
2. Channel list (.groups-content-empty-state) was the canonical L4
icon + title + hint pattern (correct).
3. Player area (<app-portal-empty-state>) was a 72px icon at 0.2
opacity, which read as ghosted on dark theme.
Bring the groups rail into the L4 family scaled down for the narrow
column: 28px search_off / folder_off icon at 0.5 opacity, 0.85rem
title, 0.78rem hint at 0.65 opacity, centered. Add a CHANNELS.NO_GROUPS_FOUND
i18n key so the copy reflects the column ("No groups found").
Bump portal-empty-state's icon opacity from 0.2 -> 0.35 and title
opacity from 0.5 -> 0.6 so the player-area placeholder feels present
rather than abandoned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 6719280e397b
Audit found 10 list-rendering components (each >150 lines, all using
signal-based state) on default change detection. Default CD re-checks
every binding on every parent CD cycle (mouse moves, EPG progress
ticks, etc.); under OnPush these only re-check when their own signals
change or when explicitly markedForCheck.
All 10 use signals exclusively for state (no manual subscribe-and-mutate
patterns), so the conversion is straightforward and safe.
Components:
- recent-playlists (740L) — homepage playlist list with drag-drop
- vod-details-route (552L) — Xtream VOD detail page
- playlist-switcher (509L) — workspace shell playlist switcher
- season-container (444L) — Stalker/Xtream series episodes (DoCheck preserved)
- stalker-search (362L) — Stalker search results
- search-results (350L) — Xtream search results
- category-management-dialog (199L) — manage Xtream categories
- recently-added (162L) — Xtream recently-added rail
- category-content-view (Catalog) — category browse page
- grid-list (shared) — generic grid renderer used across portals
Tests: 124 passed across all touched lib projects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
The previous clamp(120, 18vw, 180) made the search_off icon visually
compete with the SVG illustrations used for ERROR / EMPTY_CATEGORY,
which is wrong — a no-search-results state is a transient filter view
and should recede compared to a true page-level error.
Drop to clamp(72, 11vw, 112) at 0.45 opacity so the icon is clearly
present but smaller and lighter than the SVG illustrations above it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
When a search filter inside a category produces zero results, the grid
@empty fallback used to render the EMPTY_CATEGORY view ("No content in
this category" with the empty-tv illustration). That message was
misleading — the category itself wasn't empty, only the search filter
excluded everything.
grid-list now takes a searchTerm input and branches the @empty fallback:
when a search is active it renders the NO_SEARCH_RESULTS view with the
parameterised "No results found for {term}" title, otherwise it falls
back to EMPTY_CATEGORY as before. category-content-view derives the
searchTerm signal from the ?q= query param and passes it through.
Also: bump the NO_SEARCH_RESULTS icon from a 64px mat-icon to a clamp()
sized icon (120-180px) at 0.5 opacity so it visually balances with the
SVG illustrations used by the other viewTypes, swap the icon to
search_off (struck-through magnifier) so it reads as "no results"
rather than "search now", and fix the EN i18n typo
("change you search request" -> "change your search request").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 2e1a36e2f0f6
Clearing all recents of a given content type fired N concurrent IPC calls,
each opening its own implicit transaction in the recently_viewed table.
For users with hundreds of recently-watched VOD/series rows this added
real overhead even though the UI updates optimistically.
Add a new DB_REMOVE_RECENT_ITEMS_BATCH path end-to-end:
- removeRecentItemsBatch() Drizzle op: one transaction, one prepared
statement reused per row
- Wire through worker → IPC handler → preload → window.electron typings
→ DatabaseService
- UnifiedRecentDataService.removeRecentItemsBatch() groups items by
source. Xtream items go through the new batch IPC. M3U/Stalker items
still go per-playlist because they update a JSON column on the
playlist row, not the recently_viewed table — but they now run in
parallel with the Xtream batch via a single Promise.all.
- Single call site updated: unified-collection-page "Clear all of type"
confirmation handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4bfb62f988b7
The "channel name + close" bar above the right-pane player was redundant
with the active row in the left list (which already shows the title and
the selection chrome) and was inconsistent with the rest of the app's
live-tv views, which never had it. Closing playback was already
achievable by selecting another channel; the X added cognitive load
without earning its space (ux-similarity-consistency,
ux-cognitive-load-reduce).
Also swap the collection-header border-bottom from
--mat-sys-outline-variant to --app-separator so the divider under
"Favorites" / "Recently viewed" actually carries enough contrast to
read in dark mode (matching the live-epg-panel summary-bar pattern).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: c2cea9c530e6
Two issues kept the scrollbar invisible on the global Favorites and
Recently viewed live-tv lists:
1. The wrong files. The visible Favorites/Recent pages render through
<app-global-favorites-list> from portal/shared/ui (and the
workspace-shell variant), not the channel-list-container's tabs.
Apply .app-scrollbar to both of those components.
2. Specificity. Component CSS gets an Angular [_ngcontent-xxx]
attribute selector, so `.nav-list { overflow-y: auto }` resolves to
(0,2,0) — same as the doubled `.app-scrollbar.app-scrollbar` and
wins on source order (component CSS loads after globals). Mark
overflow-y: scroll !important on the utility so the contract holds:
applying .app-scrollbar always reserves the gutter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 8ae011808369