* feat(settings): search settings from the header and the command palette
The header search on the Settings page was shown but disabled. It now
searches a shared index of all 56 settings rows by translated title,
description and English synonyms, replaces the section page with ranked
results, and opens a result by scrolling to, focusing and briefly
highlighting its row. Enter opens the best match, and the section
navigation shows per-section match counts.
The command palette gains a "Settings" group that lists the best six
matches for a non-empty query, so any setting is one Ctrl/Cmd+K away.
Rows hidden by the current form state fall back to the control that
reveals them; rows the runtime cannot render are never returned.
The index ships through a new @iptvnator/workspace/shell/util/settings-search
sub-entrypoint so it stays out of the eager bundle, and a registry spec
keeps it in step with the section templates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(settings): let search reveals win over pending input and gate embedded MPV rows
- A reveal (result click, command palette, Enter) now cancels a search
keystroke still waiting for its debounce, so its q navigation can no
longer supersede the reveal and leave the results open.
- Embedded MPV extra options and auto-reconnect require a lazily probed
embedded MPV capability; frame copy also needs frameCopyAvailable, so
search never offers a row the settings page cannot render.
- Keyboard users keep a focus-visible ring on the revealed row after the
highlight fades.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(workspace): wait for palette probes without Promise.allSettled
The web tsconfig lib predates Promise.allSettled; use Promise.all over
rejection-safe probes instead.
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>
Second step of the performance-journeys ratchet, stacked on #1692 (merge that first; this PR retargets to `master` automatically).
- `tools/performance/journey-baselines.json`: J1 `launch` / `renderer.initialBytes` = **2,739,510 bytes**, the ubuntu runner's production build of `apps/web` at this content (after #1692 stopped bundling `package.json` into `main.js`). A local macOS build of this pre-#1695 code is 2 bytes smaller in `main.js` (the eager locale imports); once #1695 removes them the two are byte-identical. Correction to an earlier version of this description: the "556-byte macOS vs Linux difference" was almost entirely `package.json` text embedded in `main.js`, which moved with every script edit in this stack, plus this 2-byte residue. The runner is the canonical measurer; the CI run on the stacked #1694 branch (this content plus the job) is where the number is confirmed.
- `tools/performance/check-journey-ratchet.mjs` compares a journey summary with the baselines: a counter above its value fails (exact, no slack), wall-clock entries fail above `value × toleranceRatio`, a baseline without a measurement fails so dropping a measurement cannot disable the ratchet, values below baseline print a "tighten" hint, and measured counters without a baseline are noted only. After review: checking nothing (empty file, or `--only` naming a missing entry) fails; a counter is read only from `counters` and a wall-clock entry only from `wallClock`; the repeatable `--only <journey>/<counter>` flag scopes a check.
- Root scripts: `perf:initial-bytes:check` (measures into its own `dist/performance/initial-bytes.summary.json`, then checks `--only launch/renderer.initialBytes`) and `perf:ratchet:check` (full check); `perf:tools:test` runs both test files, as does `pnpm nx test performance-tools`.
- `docs/architecture/performance-journeys.md` gains the Ratchet section (file format, rules, "baselines only move down"); the validation map lists the check.
The CI job that runs the check on every PR is #1694; C1 (lazy Angular date locales, #1695) then lowers the baseline with the measured output as evidence.
Note: `ci.yml` only triggers on pull requests targeting `master`, so this stacked PR shows no Actions runs until #1692 merges. The evidence runs above were dispatched with `gh workflow run ci.yml --ref <branch>`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Live channels in Favorites and Recently viewed now offer "Open in <playlist>"
for Stalker portals, the counterpart of the VOD "View in portal" action. The
chip in the programme panel and the channel's context menu jump to the channel
inside its portal's Live TV, with its genre selected and the channel playing.
Radio stays hidden: it is a separate legacy-paged section with no
open-on-arrival contract.
The handoff refuses to guess. The store records which genre the rendered ITV
list belongs to, so the deferred play waits for a list that can actually serve
the channel instead of inferring it from array identity. An id claimed by one
channel as its `id` and another as its `stream_id` is ambiguous, so neither is
played and the user still lands in the right genre. The handoff is abandoned
when the user changes genre, search, portal or section first.
Two pre-existing defects surfaced during review and are fixed here:
- A blank provider id shadowed a valid one. `a ?? b` keeps an empty string, so
a channel with a blank `stream_id` was stored with no identity at all and
could not be selected, played or found again. Six writers had that shape and
three private copies of the skip-the-blank rule; all now go through one
`firstNonBlankStalkerId` helper.
- Route-session readiness could publish before the store held the portal's
row. The constructor starts one sync and the first navigation starts another,
and the second skipped the bootstrap because the playlist id was claimed
before the awaits that install it. Arrivals are now serialized, the id is
claimed only after the store write resolves, and only the newest sync
publishes readiness.
Both fixes carry regression tests that fail on the old behavior.
* feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups
Follow-ups to the season posters shipped in #1628:
- The >6-seasons dropdown (`SeasonTabsComponent`) now carries a 28×42
season thumbnail at the start of each menu row that has a poster and in
the closed trigger for the selected season, fed by a new `seasonPosters`
input from the season container and the fullscreen episode panel. Rows
without a poster get no placeholder, a failed image is dropped, and the
pill row stays text-only as the design review decided.
- The fullscreen season strip's episode count uses its own
`PORTALS.EPISODE_COUNT_ONE/OTHER` keys instead of borrowing the download
manager's; all 18 locales filled through the i18n merger from their
existing `DOWNLOADS.EPISODE_COUNT_*` translations.
- The Stalker mock's serve targets no longer pin `PORT` (an nx:run-commands
`env` entry overrides the shell), and `main.ts` resolves `PORT`, then the
Playwright-side `MOCK_PORT` alias, then 3210 — so `MOCK_PORT=3310` now
relocates the whole E2E run. The Xtream mock honours `XTREAM_MOCK_PORT`
the same way.
- `resolveAutoSelectedSeason` gets a direct spec covering every branch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream-mock): mint marketing asset URLs on the port the server bound
Greptile P1 on #1633: the listener honoured `XTREAM_MOCK_PORT`, but
`marketingAssetOrigin()` still read `PORT` alone, so a run relocated only
through the alias sent every poster/backdrop/logo/episode URL to 3211.
One resolver (`resolveXtreamMockPortString` in `mock-port.ts`: `PORT`,
then `XTREAM_MOCK_PORT`, then 3211) now feeds the environment parser, the
marketing asset origin and the demo-guide origin fallback. A spec pins the
precedence and that `marketingAssetUrl` follows the bound port.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The "ClearKey reopens from recent and favorites collections" spec does
five cold `page.goto` loads of the dev-served app. On the CI runner each
one costs ~6 s, so the default 30 s test timeout expired on the last
route: every attempt in the affected runs ended as `timedOut`, and the
retry trace's final screencast frame shows `/workspace/global-favorites`
still on the startup screen at 29.7 s. The two reported "shapes" were
just where the clock ran out.
Size the test like the other multi-load specs (`test.setTimeout(90_000)`)
and assert the "All playlists" radio is checked before waiting for a row
only that scope can show, so a lost click fails on the toggle instead of
surfacing as a missing row.
Closes#1630
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Series detail pages now render the selected season's poster as a season
cover next to the season tabs and description, and the fullscreen episode
panel shows the same poster as a season strip above its tabs.
Resolution is TMDB-first, like the show artwork merge: the lazy season
enrichment stores `/tv/{id}/season/{n}` `poster_path` as a w342 URL in
`tmdb_season_posters` (Xtream) or `StalkerSeriesTmdbSeasonsService.posters()`
(Stalker), under the same write-only-if-changed convergence guard as the
season overview. Xtream falls back to the provider's `seasons[].cover_big`/
`cover` when it is an http(s) URL other than the show poster, because panels
repeat the show poster on every season. Stalker is TMDB-only.
The cover column is not rendered for one-season items, seasons without a
poster, or a failed image, so every fallback is today's markup. It is sized
by a new `--season-cover-width` token (96/120/144px per Settings.coverSize).
The hero poster never follows the season.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The two "channel scrolling keeps focus after selection" cases (and the
Xtream "channel focus and separate scrollbar" cases, which press Tab the
same way) failed deterministically on Playwright WebKit while passing on
Chromium and Firefox. Playwright's WebKit emulates Safari's default
keyboard preference, under which plain Tab visits only text fields and
links: from the focused channel pane the key landed on the sidebar search
field instead of the first row button. Option+Tab reaches the button and
then the favorite action in the same DOM order Chromium's Tab follows, so
the app's focus contract (ChannelScrollFocusDirective) is intact and this
is Safari's Tab semantics, not an app bug.
Add a `pressTab` E2E helper that presses Alt+Tab only on webkit and keeps
the literal Tab / Shift+Tab on chromium and firefox, use it at the four
Tab presses toward buttons, and note Safari's behaviour in the keyboard
scrolling contract.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Movie and series detail pages showed two arrow_back controls while the
inline player was open: the shell's sticky arrow (added in #1576 so Back
survives scrolling) meant "Close player" in watch state, while the
now-playing bar carried a second arrow that meant route-level Back — the
same icon with two meanings, next to a "Close player" button that
duplicated the first.
The shell's sticky arrow is now route-level Back in browse and watch
alike, and the bar carries no arrow of its own. Closing the player is
the bar's "Close player" button and Escape, which still unwinds one
level (close, then back). Hosts without a browse Back target (M3U,
downloads) render no arrow in either state.
Unit specs for the shell and the inline player cover the new contract;
the Electron and web E2E helpers that pressed Back from watch are
updated, and the M3U flow closes the player through the bar's button.
Docs, the mirrored CLAUDE.md/AGENTS.md paragraph and a release note
follow the change.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Series playing in fullscreen get the same slide-in side panel the live channel list has, with season tabs and the episode list: rest the mouse on the left edge, click it, or press C; pick an episode and it plays inline without leaving fullscreen.
- Panel contract: `FullscreenChannelPanelHost` gains optional `panelSearchEnabled` and `panelKind`; the template context gains `open`. Pointer/keyboard rules and the four live providers are unchanged.
- Series host: `PortalInlinePlayerComponent` provides the token through `createEpisodePanelHost()` and stamps `app-fullscreen-episode-panel` (SeasonTabsComponent over rows with TMDB still or numeral tile, label, runtime, clamped overview, progress, watched check, now-playing marker; playing row centred on open). Episode clicks reuse the Up Next rail's inline path; season tab clicks reach the hosts' `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD load with a Retry row after a failed request).
- Gates: `Settings.fullscreenChannelPanel` (label now covers both lists in all locales), episode content only, native-view Embedded MPV withheld by the view, external players excluded.
- Inline-series e2e moved to `xtream-series-playback.e2e.ts` with shared Xtream helpers in a fixture; adds a fullscreen episode switch through the panel.
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
* feat(portals): posters-only cover wall for movie and series grids
Add `Settings.showCoverTitles` (Settings > General, default on). Turning it
off drops the title row under VOD/series covers in catalog, favorites and
recent grids and reveals the title as a bottom-gradient overlay on hover and
keyboard focus, pinned open for items whose cover is missing or failed.
`CoverTitlesService` is the single resolver: the opt-out AND a hover-capable
pointer, so touch-only devices keep their titles. Live channel grids, search
results, "recently added" rails and dashboard rails always keep labels.
Catalog and collection cards become keyboard buttons (role, tabindex,
aria-label, Enter/Space, focus ring) and poster alt text is the title. The
default-on boolean coercion moves into `settings-opt-out.util.ts` because
the settings store reached the max-lines limit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): keep nested Remove key presses from activating the card
Enter/Space on the content card's nested Remove button bubbled into the
card's own key handlers: Enter opened the item before removing it and
Space opened it while cancelling the removal. Only keys pressed on the
card element itself now activate it. Regression spec added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): keep cover titles while an in-section search filters the grid
The posters-only wall exempts search results because they are identified
by the name the user typed; the category grid's own in-section filter is
the same case, so `app-grid-list` now keeps the title row while its
`searchTerm` is non-blank. Contract docs updated, regression spec added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): keep cover titles while the collection tab search is active
The unified favorites/recent tab filters by its own search term, so its
matches are identified by name like every other search result. The tab
now opts its cards out of the posters-only wall while the term is
non-blank. Contract docs updated, regression spec added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): move the card Remove control out of the button surface
An interactive control nested inside a role="button" is an invalid
accessibility structure. The content card's activation surface is now its
own inner element and the Remove button a sibling positioned over the
poster corner, labelled by its tooltip text. Spec asserts the control is
never a descendant of the button.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): draw the collection card focus ring where it is not clipped
The card's overflow: hidden clipped an outline drawn on the inner
activation surface on every edge, so keyboard users saw no focus
indication. The ring now sits on the outer card via
:has(> .content-card__activation:focus-visible).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): detect any hover-capable pointer for the posters-only wall
`hover` describes only the primary pointer, so a touch-first tablet with
a mouse or hover-capable stylus attached lost the wall. The resolver now
reads `(any-hover: hover)`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Locks are per category (Xtream category ids, Stalker genre ids, M3U group
titles) and kept in one renderer lock store persisted to app_state /
localStorage; `categories.locked` is the SQLite index re-stamped from it.
While the lock is active the DB worker filters every content read, the PWA
data source, the Stalker store and the M3U channel list filter in memory,
and the enforcement service reloads the stores and steps off withheld
selections. Settings → Parental lock sets the PIN (PBKDF2, never in
Settings), the relock timeout and Lock now; lock toggles live in the
Xtream/M3U management dialogs and a new Stalker lock dialog, all behind
the PIN. Backups carry the locks per playlist entry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
## Summary
Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together:
1. **Categories + channels + player** (browse, unchanged).
2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back.
3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1.
Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session.
## Design notes
- Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex).
- The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area.
- `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider.
- M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched.
## Merged with #1555 (per-surface rail state)
#1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree.
## Also fixed along the way
- The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now.
- A collapsed context panel left a 22px padding strip beside the channels rail.
- The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales).
Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface
The second report in #1458 ("all channels disappear after clearing the
playback history, reset does not bring them back") was not data loss: the
history write never touches playlist items. The reporter's screenshot shows
a collapsed channel rail, a state persisted under one localStorage key
shared by the M3U player, the Xtream/Stalker live layouts and the
favorites/recent live tab. It survived restart, "Remove all playlists" and
re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B.
- LiveLayoutSidebarStateService keeps the state per surface (m3u / portal /
collection) under live-sidebar-state:<surface>; the M3U player now goes
through the service instead of its own signal. The legacy shared key is
forgotten on startup and never read, so the update itself restores the
list for everyone who got stuck.
- The workspace header renders a view_sidebar toggle on every route that
renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so
the control exists in both states instead of disappearing with the rail.
Collection pages keep their own toggle beside the content switch.
- While the rail is collapsed and nothing plays, every live host shows
app-channel-list-hidden-state (title, shortcut hint, full-size "Show
channels list" button) instead of asking to pick from a list that is not
on screen. app-portal-empty-state gained optional hint/action inputs.
- New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales.
Tests: service, empty-state, hidden-state and header component specs, a
separate video-player-sidebar spec (the main M3U spec sits at the test
line budget), and an Electron E2E covering history clearing, restore via
button/header/shortcut across restart and re-import, per-surface scoping
against an Xtream portal, and legacy-key cleanup.
Refs #1458
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(live-tv): mirror the EPG offset setting in the sidebar spec mock
Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the
new sidebar spec was cloned from the movie-gate harness before that field
landed, so its playing-channel case threw inside the EPG effect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(web-e2e): scope the Stalker radio rail toggles to the rail
The workspace header now carries a second "Hide/Show channels list" toggle,
so the role+name locators matched more than one button and tripped
Playwright's strict mode. Target the rail's own chevron and the floating
restore button, and assert the header toggle mirrors the state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones
Codex review follow-ups on #1555:
- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
collection page had no handler; only the routed M3U/Xtream/Stalker live
layouts did. The page now toggles the collection surface while its live
tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
switcher, search and Add; the live rail is a bottom drawer with its own
toggle there, so the header rail toggle is hidden below 640px.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API
master (#1554) added `XtreamLiveChannelNavigationService` and
`stalker-live-navigation.ts`, which expand the rail through
`sidebar.setState('expanded')` on the pre-split signature. Point them at the
`portal` surface and update their specs; drop the now-unused hidden-state
stub from the Xtream layout spec, which master pushed to the max-lines
budget.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): try advertised TS after initial web HLS HTTP failure
* refactor(playback): extract fullscreen channel panel state
* test(xtream): keep synthetic media within the mock project
* fix(portals): preserve live channel navigation while browsing
* test(portals): await media source assertion in remote E2E
* fix(xtream): capture destination queue for live auto-open
* fix(stalker): keep live search within the selected category
* test(stalker): assert retained video ownership without source timing
* test(stalker): distinguish paged All Items from the initial cache grid
* fix(stalker): reveal remote selections in uncached search results
* test(stalker): wait for category rows and retain settled playback
Follow-up to #1516 for the vendor-chrome path (shared controls opted out). With
Video.js's own controls, Chromium leaves a clicked control-bar button focused,
and a focused Video.js component captures the keyboard entirely, so after
clicking fullscreen Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until the user clicked the video. ArtPlayer
and the native HTML5 controls were verified unaffected.
The legacy Video.js chrome now releases the focus a pointer interaction leaves
on a control (vjs-pointer-focus-release.ts). The release is scoped to the
.vjs-control-bar and pointer-attributed, and runs on both focusin (focus
landing on a control, e.g. a menu handing focus to its button) and click (a
control clicked while already focused, which fires no focusin); keyboard Tab
focus and modal-dialog focus traps are preserved. The eligibility helper is
shared with ControlsSurface via pointer-focus-release.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
The E2E added in #1517 proved the ITV case only. Radio shares the live
layout and the same context-panel handler, and its inline audio player is
gated on the store selection just like the ITV player, so a regression in
`onStalkerCategoryClicked` would silence a station the user never switched
away from. The new scenario mirrors the ITV one: play a station, pick
another category, wait for the sidebar title to change, and assert the
audio player is still mounted.
Closes#1521
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Switching the Live TV or radio category in the shell context panel tore
down the Stalker player: `onStalkerCategoryClicked` cleared the selected
item for every section, and the live layout gates its player on
`selectedItem`. Xtream live (#936) and M3U groups already keep the channel
playing across a category/group switch.
- Context panel: return before `clearSelectedItem()` for `itv`/`radio`;
VOD/series clicks still drop the open detail before navigating.
- Live layout: the category-change reset effect no longer wipes the
playing channel's short-EPG fallback or cancels a fallback load in
flight; only a section change (itv <-> radio) does that now.
- Regression coverage in the context panel spec, the live layout spec and
a new web E2E scenario; docs and a `.changes/` note added.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Chromium focuses a clicked <button>, and a focused control captures the
keyboard: Space and Enter activate it again, and ControlsShortcuts yields
to any interactive element in the key's path. After a click on the
fullscreen button, Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until a click on the video took focus
away. Follow-up to #1512, which stopped that focus from pinning the bar
but left it on the button.
A completed pointer click now releases the focus it left on the control
(onBarClick -> ControlsSurface.releasePointerFocus). The click is
attributed by its pointerType (empty for Enter/Space activation and
element.click()), with the legacy MouseEvent fallback answered once per
recorded press, so keyboard activation keeps focus where Tab put it.
Only buttons and range sliders are released. Chromium keeps its
sequential-focus starting point at the blurred control, so a later Tab
continues from it. The release dispatches a focusout while the pointer
still rests on the control, so the volume anchor ignores it instead of
closing the popover under the hovering mouse.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WebPlayerViewComponent remounts the engine component for every playback
application, and the DOM Fullscreen API exits the moment its element leaves
the document. The fullscreen element was the engine shell, so every next-
episode click, autoplay hand-off, channel zap and alternative-source switch
dropped the viewer back to the page.
app-player-controls gains a `fullscreenTarget` input; HTML5, Video.js,
ArtPlayer and Embedded MPV forward it, and WebPlayerViewComponent passes its
own host element, which spans all applications of one mount. Keeping
fullscreen exposed a latent bug: the Electron header handoff set plain
fields under OnPush hosts and was only rendered thanks to the fullscreen
exit's stage resize; `channel`/`vjsOptions` are signals now.
Covered by unit regressions (fullscreen target, WebPlayerView remount, OnPush
handoff), a web-e2e run through a manual and an automatic episode switch, and
a manual Electron check. Docs and release note updated.
Closes#1498
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(dashboard): detail-first continue watching cards with quick actions (#1441)
Continue Watching cards now open the detail page on click like movie
cards; resuming the saved episode, marking it watched, and removing the
entry from history move into a per-card ⋮ menu. Series details land on
the earliest season with unwatched episodes (or the latest once all are
watched) instead of always season 1.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): address review findings and season auto-select regressions
- A session's own watched toggles no longer re-resolve the selected
season when the positions map first fills — marking season 1 watched
used to jump the view to season 2 (CI regression in the web and
Electron season-watched-toggle E2Es).
- The all-watched season fallback skips loaded-but-empty seasons and
picks the latest season that has episodes (Greptile P1).
- Mark as Watched uses the strict failure-propagating save boundary
(Codex P2), and both card mutations surface persistence failures via
a snackbar with the new WORKSPACE.DASHBOARD.ACTION_FAILED key in all
19 languages (Greptile P2).
- Season E2Es now assert the intended post-reload behavior: the fresh
mount lands on the earliest unwatched season while season 1 keeps its
watched state behind its tab.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a
provider sent — links, Xtream credentials, a MAC address with device identity
— and a deterministic parser recognizes the source(s) and prefills the
matching import form.
- detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC
addresses and labeled fields, classifies each finding as Xtream, Stalker or
an M3U link/body, and returns ranked candidates. Pure and synchronous.
- Built against a corpus of 19 real reseller handouts kept verbatim in the
spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex
serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a
guard so a parental PIN is never read as the account password.
- Detection only proposes: the target form's own validation and behavioral
probes remain the sole path into the store, and no pasted text leaves the
app. Passwords are masked on candidate cards, including query and HTTP
Basic userinfo forms.
- Covered by parser, component and dialog unit tests plus two web E2E specs
for the paste → pick → prefilled form workflow; i18n for all 19 languages.
* feat(portals): mark a whole series as watched in one click
Adds a series-level watched toggle to the season header's new overflow
menu on both Xtream and Stalker series detail pages (issue #1442 v2,
building on the season-level toggle from #1447).
- Shared: buildSeriesWatchToggleRequest flattens every loaded season
with the season builder's mark/unmark semantics; the direction is
always the one the label advertised, never re-inferred at persist
time. Watch-toggle state math for both scopes moves into the new
component-provided SeasonWatchPresenter (the container component sat
at the max-lines cap).
- Xtream: the series request reuses SerialDetailsSeasonWatchService
through a scope-parameterized handle(), the same stillCurrent
ownership guard, and the XtreamStore.loadAllPositions badge refresh.
- Stalker: the season handler's core is extracted into
runWatchToggleBatch (feedback keys per scope). Lazy Ministra VOD
hydrates unloaded seasons sequentially first (zero writes on a failed
fetch, silent abort on navigation), re-runs the position reconcile
synchronously so newly hydrated episodes' legacy rows are cleaned,
then rebuilds the request keeping the clicked direction; an
all-watched outcome reports an honest count-0 snackbar.
- Container: new hasUnloadedSeasons input blocks the fully-watched
verdict and the count label while lazy seasons are unloaded, and the
empty mark request contract lets the host hydrate-then-rebuild.
Six new XTREAM i18n keys, synced to all 18 locales via the i18n-fill
workflow. No new IPC: the existing playback-position batch channels are
season-agnostic.
Refs #1442
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): treat an empty Stalker season answer as loaded, not pending
A lazy season the portal ANSWERS for with zero episodes was still
counted as unloaded (episodes.length === 0 heuristic): the series label
stayed countless forever and every series toggle re-fetched the empty
season, while a glitch-empty answer could silently skip a season and
still report success as if nothing remained.
VodSeriesSeasonVm gains an episodesLoaded flag set by every successful
episode fetch — including an empty one — and the series toggle's
pending predicate, hydration re-check, and hasUnloadedVodSeasons now
key on it. A loaded-and-empty season unblocks the count label and the
fully-watched verdict instead of re-fetching; a fresh detail mount
still re-fetches, so a one-off glitch self-corrects next session.
Addresses the Greptile P1 on PR #1451.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): harden lazy season hydration against malformed and racing loads
Two review findings on the series watched toggle:
- fetchVodSeriesEpisodes now trusts an empty answer only when the
envelope actually carried a well-formed array; a malformed envelope or
an answer whose rows contain no recognizable episode rejects, so the
load fails instead of the season being recorded loaded-and-empty and
silently skipped by the series batch.
- loadEpisodesForSeason is single-flight per season: a tab click, the
spillover prefetch, the quick-start recursion, and the series-toggle
hydration join one in-flight request instead of duplicating portal
traffic — previously a second request's failure could abort a series
toggle whose original request succeeded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(portals): mark a full season as watched in one click
Series detail pages on both Xtream and Stalker portals get a season-level
watched toggle next to "Download season": marking writes full-progress
rows for the unwatched episodes only (real durations survive), a fully
watched season flips the action to unwatch-all.
Persistence goes through new batch IPC channels
(DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH, one SQLite transaction with
onConflictDoUpdate().run(); the PWA data source rewrites its
localStorage blob once). Stalker deliberately bypasses the batch IPC
and loops the existing position-mutation queue so legacy-row
reconciliation still runs and the queue coalesces to a single reload;
partial failures surface a dedicated snackbar.
Also removes the dead toggleEpisodeWatched store method, splits
season-container/serial-details-playback under the max-lines cap
(season-watch-toggle.util.ts, SerialDetailsSeasonWatchService), and
classifies *.spec-data.ts fixtures under the test max-lines ceiling
(baseline shrinks by main.preload.spec-data.ts).
Closes#1442
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): guard stale season batches and split partial-unwatch feedback
Review follow-up (Codex on #1447):
- A season batch completing after the user navigated to another series
or playlist no longer writes the old series' rows into the freshly
reset position state (episode ids can collide across playlists); the
Xtream host captures the playlist/series identity before awaiting and
skips the rendered-state mutation when it changed. The DB write is
unaffected — it carries its own playlistId.
- A partially failed "mark season as unwatched" on Stalker now reports
a dedicated SEASON_MARKED_UNWATCHED_PARTIAL message instead of the
watch-direction "marked" text; translated into all 18 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): exclude the playing episode from season marking and count partial saves
Second review round (Codex on #1447):
- The episode currently playing (inline or in an external session, or
with a launch in flight) is excluded from a season's mark-watched
batch: the player persists its live position every ~15 s and would
immediately overwrite the just-written full-progress row. The button
count reflects the exclusion and the action disables when nothing is
markable. Unmarking still clears such an episode — the recreated
in-progress row reflects live playback truthfully.
- A Stalker StalkerSeriesPositionPartialSaveError (scoped watched row
saved and published, only legacy cleanup failed) now counts as a
watched success instead of feeding false total-failure feedback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): gate stale season-batch snackbars on the originating page
Third review round (Codex on #1447): a batch resolving after the user
navigated away no longer shows its contextless success/error snackbar
on the newly opened detail page — the same ownership check that guards
the state mutation now guards the feedback too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): sync catalog progress badges after toggles and gate Stalker feedback
Fourth review round (Codex on #1447):
- Any Xtream watched toggle (single episode or season batch) now
refreshes XtreamStore.loadAllPositions after persisting — the catalog
reads series-progress badges from the store, which otherwise loads
positions once per playlist, so returning from the detail kept stale
badges. Skipped when the playlist changed mid-flight (the store then
belongs to the other playlist; its own init reloads positions).
- Stalker's season snackbars are gated on the captured playlist/series
identity, matching the Xtream ownership guard — a batch draining after
navigation no longer reports on the newly opened page.
- Stalker season-toggle specs moved to stalker-series-view.season-watch
.spec.ts with their own harness; both prior spec files sat at the
1200-line test ceiling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: describe the season watched toggle in CLAUDE.md
Fifth review round (Codex on #1447): the canonical Seasons entry in the
VOD/Series detail section now covers the bulk toggle, its playing-episode
exclusion, both persistence paths, catalog badge sync, and the
stale-completion contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): let only the latest positions load patch the Xtream store
Sixth review round (Codex on #1447): loadAllPositions is now
latest-load-wins — a fetch superseded while in flight (playlist switch
before getAllPlaybackPositions resolves) no longer patches the singleton
store with the previous playlist's position maps, which could leave the
new catalog showing the old playlist's progress badges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: reflect the spec-data max-lines classification in CLAUDE.md and AGENTS.md
Seventh review round (Codex on #1447): both canonical max-lines
descriptions now list **/*.spec-data.ts among the test-ceiling globs so
future agents neither treat these fixtures as production files nor
remove the exemption unknowingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): parse "N min" durations when marking episodes watched
Eighth review round (Codex on #1447): Stalker VOD episodes report
durations like "45 min", which parseDuration could not read — bulk (and
single) mark-watched then persisted 1/1-second rows. The minute format
now parses to seconds, matching what the removed legacy store method
already handled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): parse compound hour durations and cover the toggle end-to-end
Ninth review round (Codex on #1447):
- parseDuration now reads the compound "1h 30min" form the Xtream
fixtures emit (hour group optional, so "45 min" keeps working) —
bulk-marked episodes no longer persist a minutes-only duration.
- New Playwright coverage exercises the season toggle through the real
UI on both portals: Xtream (category → series detail → mark →
reload-persistence → unmark) and Stalker (embedded-series flow,
mark → unmark with the item's actual episode count).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): refresh Stalker catalog progress badges after watched toggles
Tenth review round (Codex on #1447): the Stalker mirror of the Xtream
catalog sync — StalkerCatalogFacadeService loads its position maps once
per playlist and the runtime bridge only pushes external-player updates,
so renderer-initiated toggles left grid badges stale. The series view
now calls the facade's new ownership-checked refreshPositions after the
season batch (including partial successes) and after single toggles;
the reload is latest-load-wins like the Xtream store fix. Optional
injection keeps collection-detail mounts outside the catalog working.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(portals): cover the season toggle batch IPC end-to-end in Electron
Eleventh review round (Codex on #1447): the new Electron E2E marks a
season through the real UI, asserts the eight SQLite rows written by
DB_SAVE_PLAYBACK_POSITIONS_BATCH directly through the preload bridge,
proves persistence with a full app relaunch (renderer and main process
die, so state can only come from the database file), and clears again
through DB_CLEAR_PLAYBACK_POSITIONS_BATCH back to zero rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): keep watched rows out of the series resume target
Twelfth review round (Codex on #1447): a watched position row — a
natural finish or a manual/bulk "mark watched" marker — is a completion
record, not resumable progress. Continue Watching no longer auto-plays
such an episode at its end; the handoff stays detail-only and the series
page's quick-start picks the first unwatched episode instead. Card
progress bars and SxxEyy badges keep their current source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): fail closed on refresh reads and gate batch APIs by capability
Thirteenth review round (Codex on #1447):
- Position-cache refreshes now use a failure-propagating read
(getAllPlaybackPositionsOrThrow through the Electron data source): a
transient IPC failure rejects instead of masquerading as an empty
list, so a populated store/facade cache stays stale-but-populated
rather than being wiped. All load/refresh call sites handle the new
rejection (init loads may retry on the next activation; post-toggle
refreshes log and keep the snackbar flow).
- The season-batch bridge methods joined playbackPositionStorageMethods,
so a bridge lacking them degrades to the in-memory path wholesale
instead of throwing mid-action.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): apply saved player changes to mounted web players
WebPlayerViewComponent resolved the saved engine from a one-shot
StorageMap snapshot taken at mount, so a player switch from the command
palette or settings page confirmed via snackbar and persisted the
setting while an already-mounted Xtream/Stalker player silently kept
the previous engine. The same snapshot also made first play mount the
default Video.js engine and swap to the saved one once the async read
landed.
Resolve the player (and recording folder) from the live SettingsStore
signal instead and drop the snapshot entirely. Precedence is unchanged:
temporary recovery override -> host playerOverride -> saved player ->
Video.js. Hosts passing no override (Xtream/Stalker live layouts, the
portal inline detail player) now track saved changes in place; first
mount reads the already-loaded store, so the default engine no longer
flashes.
Regression coverage (all verified to fail with the fix reverted):
three unit tests on the component and two Xtream live-route e2e tests —
a palette switch reaching the mounted player without a layout remount,
and a MutationObserver engines-ever-seen assertion that the saved
engine mounts first time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): retain mounted engine when saved player becomes MPV/VLC
With the saved player now live-tracked, a mid-session palette switch to
managed MPV/VLC cleared the inline binding on hosts without a
playerOverride and left a blank viewport — the web player view can
neither render nor launch external players. resolveRenderableWebPlayer
keeps the mounted engine in that case; the external choice applies when
the host starts the next playback. Renderable players, including
Embedded MPV, still apply live. Raised by Codex review (P2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
M3U entries recognized as movie files now open in the portals' two-state VOD
detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first:
activation still plays immediately, with plot, cast, rating and artwork below
the player; Escape reveals the Browse hero.
Recognition is a synchronous URL-shape heuristic (movie container extension or
an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker
names keep today's live layout), gated on TMDB enrichment plus the new
default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA.
Review follow-ups included: the playback payload no longer carries TMDB fields
(its identity is the player's source-application key), the persisted volume
reaches the player and survives Browse → Play, the enrichment guard keys on
the full lookup identity, and the saved engine mounts first time instead of
briefly falling back to Video.js.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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(settings): split settings into per-section pages with an unsaved-changes bar
Replace the single scrolling settings page with routed section pages
(/workspace/settings/:section): the context-panel rail links each section,
only the active section renders, and unknown or capability-gated sections
redirect to General. The shared form lives on the parent component, so
staged edits survive section switches; a floating unsaved-changes bar
(Save/Discard) replaces the always-visible footer Save button. Rail links
navigate with replaceUrl so Back still leaves settings in one step.
Along the way:
- delete the unreachable settings dialog mode and the dead
AppPortalNavigationActionsService with both of its never-injected DI
tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS)
- delete the scroll-spy directive and pendingScrollTarget plumbing
- revive the EPG panel's "Open EPG settings" empty-state button as a deep
link to /workspace/settings/epg; the M3U player now reports
m3u-needs-setup only when the channel has no programmes and no EPG
source exists in settings or on the playlist itself
- load TMDB cache stats when the Metadata page opens (the section
component now only exists while its page is open)
- add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(settings): confirm before leaving with unsaved changes
Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with
a three-action dialog: save and leave, leave without saving, keep editing.
The guard only intercepts leaving the settings AREA — section switches
share the one settings form and pass unconditionally, so the dialog can
never nag while moving between pages. A failed save cancels the navigation
instead of silently dropping the edits it promised to keep; leaving
without saving also reverts the live theme preview. Save-and-leave is
disabled while the form is invalid, with a hint explaining why.
New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): stage cover size and EPG view mode; adapt e2e to section pages
Cover size and EPG view mode were the only two controls that persisted
eagerly on click, which made Discard (and leave-without-saving) unable to
revert them: hydrateFromStore() faithfully reloaded the just-persisted
edit. They now stage in the form like every other setting and reach the
store on Save. Review finding by Greptile (P1) and Codex.
E2E suites that walk through settings are updated for one-section-page
rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the
staged cover size (downloads asserts the dataset after Save); the EPG icon
fallback test saves before leaving settings so the new unsaved-changes
dialog does not block its navigation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.
Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).
CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".
Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.
The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.
Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.
Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.
Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.
get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".
Closes the identity-fields cluster: #927, #860.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>