Switching the unified Favorites/Recent page between "This playlist" and "All playlists" kept the old items on screen with no feedback until the new query resolved. The skeleton cannot be reused there: it unmounts a playing channel and drops focus from the toggle.
Add a separate reload state that keeps content mounted: after a 180 ms grace period an indeterminate progress bar overlays the header separator, the content region carries aria-busy, and the grid or the live tab's channel rail dims — the player never dims. Fast IndexedDB/SQLite answers show nothing. Only the latest request settles the state.
Also bind Clear and drag reorder to the request that loaded the rows still on screen (`loadedRequest`) instead of `effectiveScope()`, which moves ahead the moment the toggle is clicked.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Bump astro 7.2.4 → 7.2.10 (critical, website build) and retarget the pinned
pnpm overrides for the transitive alerts: js-yaml → 4.3.2 (the one runtime
path, via electron-updater), smol-toml → 1.7.1 (new key for nx's exact 1.6.1
pin), svgo → 4.1.0 (new key for astro's 4.0.2 resolution) and hono → 4.13.5.
Every target stays inside its parent's declared range except nx's exact
smol-toml pin, which is now recorded as the deliberate exception in
docs/architecture/dependency-security-overrides.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): keep a live-channel handoff alive until its playlist catalog is loaded
Arriving at /workspace/xtreams/:id/live from another route with
openXtreamLiveItemId in history state silently did nothing: the Xtream
shell mounts the live layout after its session bootstrap, i.e. after the
arrival's NavigationEnd, so the layout's NavigationEnd subscription never
saw it. When the layout was reused instead (playlist switch), the shared
store still held the previous playlist's catalog at NavigationEnd and the
"not in liveStreams" verdict dropped the pending id.
Read the state once at mount as well, carry openXtreamLivePlaylistId in
the navigation state, and treat a miss as final only once currentPlaylist
is the requested playlist and isContentInitialized is true.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(collections): open a favorite or recent live channel inside its playlist
Live channels watched from Favorites / Recently viewed had no visible
playlist and no way to jump there, unlike movies and series with "View in
portal". Add the live counterpart:
- getLiveCollectionPlaylistNavigation() resolves the channel inside its
playlist (Xtream via the live layout's auto-open state, M3U via
openM3uChannelUrl on the player's all view); Stalker resolves to null
until its ITV layout gets an open-on-arrival contract, so nothing is
shown there instead of landing on the section root.
- app-open-in-playlist-chip, projected into the EPG timeline / list-view
toolbar through a new [epgToolbarAction] slot beside the channel name,
visible in the collapsed state too.
- "Open in <playlist>" entry in the channel row context menu, which also
covers radio rows and rows that are not playing.
Both label with playlistDisplayLabel and reuse PORTALS.VIEW_IN_PORTAL_TOOLTIP.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): only match an auto-open channel against the requested playlist's catalog
Review finding (Greptile/Codex P1): the playlist check ran only on a miss,
so a colliding provider-local xtream_id in the previous playlist's catalog
was accepted, played the wrong channel and consumed the handoff. Check the
playlist before consulting the catalog at all.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* 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>
* fix(portals): apply the IPv6->IPv4 fallback budget in the Electron process
The 2500 ms happy-eyeballs attempt timeout from #1404 only ever ran in the
web backend. The Electron main process and its playlist-refresh and EPG
workers kept Node's 250 ms default, so a dual-stack panel hostname behind a
VPN or a slow link failed every connection attempt in a row and tripped the
host connectivity guard. The module now lives in `@iptvnator/shared/host-health`
and every Node isolate that opens connections applies it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): stop treating a slow panel as a dead one in the host guard
axios raises the same ECONNABORTED whether the SYN went unanswered or the
panel accepted the connection and then thought for longer than the request
budget. Two such timeouts opened the breaker and every request to the panel
was refused for 30 s with "portal is not responding" — the shape behind the
"connection keeps dropping" reports on 0.23 and nightly.
Both transports now report whether the TCP connection was established
(`onConnect`: Electron through a per-request observed agent instead of the
shared keep-alive globalAgent, the web backend through the transport that owns
the ClientRequest), and `classifyHostRequestFailure(error, { connected })`
downgrades a host-level code observed after the handshake to inconclusive.
Redirect attribution keeps precedence. A host that never accepts the
connection trips the guard exactly as before.
The Xtream mock gains a `silent:silent` scenario whose detail actions accept
and never answer, plus a real-socket regression spec for the guard.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): let an accepted connection clear the host-failure streak
Review finding: an unanswered SYN, then an accepted-but-slow timeout, then
another unanswered SYN still reached the two-failure threshold, because the
middle request was merely not counted. An accepted TCP connection is the
reachability the guard measures, so it now reads as `responded` and clears
the streak like an HTTP response would. Regression coverage for the mixed
sequence on one flapping loopback origin (Electron) and through the proxy
route (web backend).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): credit an accepted connection when it happens, not when the request settles
Review findings. A request that connected and then hung for 30 s cleared,
on its eventual timeout, the failures later requests had recorded while it
waited — reopening a host that had just died on evidence older than theirs.
The connect hook now reports the connection the moment it fires through a
new `HostConnectivityGuard.reportConnected`, which clears the failure streak
but closes no open or half-open breaker (the trial keeps its slot until it
settles), and the settled timeout is inconclusive.
Electron also skips the socket observer while an environment proxy
(`http_proxy` / `https_proxy` / `all_proxy`) applies to the request: through
a proxy the socket connects to the proxy, whose handshake proves nothing
about the portal, so those requests keep the pre-observer behaviour.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): decide the proxy exemption with axios' own resolution
Review findings. The hand-rolled environment check ignored `no_proxy`, so a
LAN portal exempted from the proxy lost its connect observer and slow
requests to it still tripped the breaker; it also read the variables with
`??`, letting an empty lowercase one mask a populated uppercase one that
axios would honour. The decision now calls `proxy-from-env`'s
`getProxyForUrl`, the same pinned package axios' http adapter uses,
declared as a direct dependency so the packaged app carries it.
The validated-axios spec clears and restores every proxy variable around each
case, so a runner that exports a proxy cannot change what the cases prove.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(settings): make the update-channel check honest and keep release notes fresh
Settings → About mixed two commit models: the channel select applied on
Save while "Check again" ran immediately against the still-saved channel,
so picking Nightly and checking reported "latest version" for Stable under
a select reading Nightly. The status now carries a badge naming the channel
the verdict describes; while the select shows an unsaved other channel the
verdict is dimmed, a hint names both channels, and the check button becomes
"Save and check for <channel> updates", which submits the form — the main
process already re-checks when the saved channel changes. A download in
flight or finished belongs to the previous channel and keeps the plain
check.
"What's new" for a nightly published after the app started failed with a
raw IPC error: each release catalog is a process-lifetime snapshot and a
fully paged list never re-read GitHub. findIndex now reloads the catalog
once when a version is missing, and a newly found update drops every
catalog. The dialog recognises the not-found rejection through the shared
marker text, explains it with the version, and links to the channel's
release list; other failures keep their reason under a localized headline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(updater): serialize readers of one release catalog
findIndex may rebuild the shared release array while another reader of
the same catalog still holds an index into the old one and dereferences
it after paging further. Every reader now runs through the catalog's
runExclusive queue (getReleaseNotes and the manual-update check), so a
reload can no longer pull the list out from under a navigation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(settings): attribute a kept download to the channel it was found on
setChannel keeps a download that is running or finished when the saved
channel changes, but status.channel already names the new channel, so the
About badge attributed a Stable download to Nightly and the pending hint
vanished. Every check now stamps status.verdictChannel with the channel it
ran on and setChannel leaves it alone; the badge names that channel, and
while it differs from the saved one a hint says the shown update came from
the other channel and the saved one has not been checked yet.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(updater): move release-notes reads out of AppUpdateService
AppUpdateReleaseCatalogs (app-update-release-notes.ts) now owns the
per-channel catalogs and both reads the updater performs on them: release
notes with previous/next paging and the newest release for the
manual-install fallback. The service only delegates, shrinking from 610
to 508 lines instead of growing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(updater): name verdictChannel as the badge's source
The About paragraph still said the badge reads status.channel while the
paragraph below it and the code use status.verdictChannel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(settings): show the release list, not the earlier release, after a paging failure
A failed Previous/Next keeps the earlier notes for navigation while the
body shows the error, so the dialog's action offered the earlier release
instead of the channel release list. The error is now checked first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(updater): do not reload the catalog before falling back to latest
A read that falls back to the newest release on a miss (the installed
version's notes, e.g. an unpublished local build) paged the whole list
twice: findIndex reloaded on the miss before index 0 was selected. The
reload is now opt-in per call and off on that path.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
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>
`apps/electron-backend/build-embedded-mpv.js` resolves the compiler with
`require.resolve('node-gyp/bin/node-gyp.js')` and its comment claimed the
package was "a declared devDependency" — it never was. node-gyp reached the
tree only as a transitive of `@electron/rebuild`, in pnpm's hidden hoist
(`node_modules/.pnpm/node_modules`). pnpm's `.bin` shims export that
directory on NODE_PATH, which is why `pnpm nx …`, `pnpm run build:backend`
and CI kept building the addon, while a plain
`node apps/electron-backend/build-embedded-mpv.js` on a clean install failed
with "Unable to resolve node-gyp".
Declare node-gyp 12.4.0 (the version already in the lockfile store) as a root
devDependency so the resolution no longer depends on a shim implementation
detail, correct the stale comment, and record the contract in the
embedded-MPV architecture doc plus the Agent Bootstrap notes.
No packaged-build change: the no-runtime skip and its
`embedded-mpv-unavailable.txt` marker are untouched.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(shell): zoom shortcuts on Windows and Linux (#1109)
Cmd/Ctrl and +/−/0 (numpad included) now zoom the app on every platform.
Windows/Linux run without a menu (`setMenu(null)`), so the shortcuts are a
renderer key binding in `WorkspaceKeyboardShortcutsService` calling a new
synchronous, preload-local bridge method `adjustZoomLevel`, which steps the
frame-bound temporary level through `webFrame.setZoomLevel` — never a
main-process `webContents.setZoomLevel`, whose per-URL entry the app's
`file://` path routing resets. Step and limits live in
`libs/shared/interfaces` (`stepZoomLevel`: 0.5 per press like Electron's
zoomIn/zoomOut roles, clamped to levels −4…6). On macOS the renderer sees the
key before the application menu, and `preventDefault()` keeps the menu role
from stepping a second time (Electron only performs the menu key equivalent
in its unhandled-keyboard-event hook).
Persistence is unchanged: the main process still reads the live level back
on close, quit and reload. The zoom E2E now drives the real shortcuts (in,
out, numpad, reset). Help dialog entries added and translated for all
locales; contract updated in docs/architecture/workspace-shell.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(shell): never step a stored out-of-range zoom level against the request
A level persisted before the shortcuts existed (the macOS menu roles never
clamped, and the store restores any finite level) was clamped BEFORE the
step, so the first zoom-in from level 7 rendered smaller. Step from the raw
level instead: a press further out leaves an out-of-range level where it is,
a press back in lands on the limit.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(shell): state the zoom bridge's return contract precisely
`adjustZoomLevel` steps by `stepZoomLevel`'s rules; a stored out-of-range
level is never moved against the request, so the returned level is not
itself guaranteed to be within `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(release): add a release note for the zoom shortcuts
The Release note gate requires an added `.changes/*.md` for runtime changes;
the shortcuts are a user-visible feature of their own, so they get their own
note and the persistence note stays about persistence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The title search sent the folded comparison key (NFD + strip marks + lowercase) as the TMDB query, so every Russian title with й/ё ("Фейк (10 серий)" → "феик") and every Arabic title with hamza missed and was cached as missing for 7 days. Search candidates now carry a provider-spelled wire query beside the folded comparison key; variants are deduplicated and cached per attempted variant by the lowercased query; the search lookup key moves to |v3 and startup deletes the retired |v2 rows under their own app_state marker. Verified on 1.99M live catalog titles (folded key byte-identical). Real-SQLite cleanup coverage runs inside Electron across skipped, previous, pre-person, fresh and repeated startups.
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>
The packaged renderer is index.html over file:// with path routing, so
after in-app navigation the document URL names a path with no file behind
it. A main-process reload (macOS View > Reload, DevTools) failed with
ERR_FILE_NOT_FOUND and stranded the window on Chromium's error page; a
renderer-initiated reload (the settings unsaved-changes guard's confirmed
location.reload()) was cancelled by the will-navigate trust check and
silently did nothing.
Both legs now re-load the packaged index with the route in a restoreRoute
query parameter, which main.ts restores with history.replaceState before
Angular bootstraps. The did-fail-load recovery is deferred to the error
page's dom-ready: a load issued from inside the failure event yields a
document that never receives animation frames and never paints.
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>
* fix(playback): make the fullscreen channel panel discoverable and calmer to close
The left-edge hot zone was an invisible 28px strip with nothing telling the
user where the list lived, so "hovering the left side" rarely reached it while
`C` always worked. Mouse movement over the stage now reveals a slim edge hint
tab (CSS chevron, fades after 2.5 s idle, lit while the pointer rests in the
zone), the zone grows to 40px (48px coarse), and a click or tap on the edge
opens at once without the dwell.
Closing follows the pointer more honestly: the mouse-leave grace grows from
420 ms to 1 s, and it applies only once the pointer has engaged with the
panel, so a `C`-opened list no longer closes while the mouse merely roams over
the video under the user's typing. Clicks inside the panel never close it.
Found on the way: closing with Escape while the mouse still rests on the edge
reopened the panel 160 ms later, because Chromium synthesizes a `pointerenter`
on the hot zone the aside slid away from. An explicit close now re-arms the
zone only on the next real pointer move.
Verified live over CDP on HTML5, Video.js, ArtPlayer and Embedded MPV
frame-copy; the hover mechanism itself was sound on every engine.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): open the panel only on a primary click begun on the edge
Review follow-up. The hot zone opened on every pointerup, so a drag released
over the edge, a right or middle click and a pen barrel button all opened the
panel; now pointerdown records the primary pointer and pointerup must match
it (a leave or cancel forgets the press). The synthetic pointerenter that
follows an explicit close no longer arms the hint either: the edge stays
clear until the next real pointer move, which arms it and starts the dwell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>