The settle sample read J1's capture (pending calls) and J2's capture
(call count) in two evaluate calls, so a call starting in between was
counted with a stale zero in flight and its completion went unseen. Both
states are now read in one synchronous pass, where no ipcMain event can
be handled in between.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
J2 used J1's capture to see pending launch bridge calls while settling,
but its ipcMain listener stayed attached and ran for every bridge call of
the measured click. The capture can now be detached; J2 detaches J1's
right after settling, before the click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
waitForJourneyQuiet accepted a sample that returned unchanged after a
stall longer than the timeout as the end of a quiet period, before the
deadline check ran. The deadline is now checked first, so a stalled
sample fails the wait instead of letting the click go ahead unobserved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
httpRequestsAfterSettledByRoute read the ledger right after the accepted
quiet sample, so late requests had no chance to appear in it. The ledger
is now read after another quiet interval; the counter stays bounded by
the quiet sample's mark and late traffic shows up in the evidence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The post-terminal window read the ledger after waitForMockQuiet returned,
so a request arriving in between was counted although its completion was
never waited for. waitForMockQuiet now returns the ledger position its
accepted sample read; later requests are kept as evidence
(httpRequestsAfterSettledByRoute) instead of the counter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The late-activity check compared requests against a fresh ledger mark
taken after waitForQuiet returned. A request that arrived while the final
quiet sample was still reading the IPC capture advanced that mark and
escaped the check. The boundary is now the ledger position read by the
accepted sample itself, like its DOM and IPC counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A long task's startTime precedes the click event's timestamp when the
listener runs inside it, so the start-time filter dropped the task that
performs the interaction. Long tasks now count when their range overlaps
the window: on one main thread only the dispatching task can overlap the
click. Layout shifts keep the start-time filter. J1 is unchanged (its
window starts at -Infinity).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The open-source record compares the settle snapshot with what the probe
and the IPC capture counted up to the click event, and with the mock
requests between the snapshot and the click stamp. Any change means
background work began during Playwright's actionability checks and
could land in J2, so the iteration is rejected.
- The SQL opt-in guard also checks who passes mainCounters: true: only
measureLaunchJourney may, and J2 must pass false.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- runLaunchJourney takes the launch instrumentation; only J1 turns on the
main-process counters and IPTVNATOR_PERF_COUNT_SQL, so J2's click is not
measured under the hook that wraps every SQLite statement. The flags are
built in journey-launch-environment.ts, which the SQL opt-in guard now
expects, and a launch record without main counters is rejected.
- The mock ledger stamps arrivals with performance.timeOrigin +
performance.now(), the same sub-millisecond epoch as the renderer's
click, so a request later in the click's millisecond is not counted
before it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After rebasing on #1715, J1 measures main.sqlStatementsBeforeReadyToShow,
so J2's reason for listing main.sqlStatementsToFirstPage as unavailable
(no countable channel) was stale. State the actual limit: the running
total is read from the test process and cannot be bounded at the click
or the first-page batch. The performance-journeys CI job comment now
names both journeys.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both waits in the open-source journey (settling before the click, closing
the mock window after the terminal) now use one waitForJourneyQuiet
helper that compares whole samples, in-flight counts included. The poll
that first sees a request or bridge call complete restarts the quiet
period, so the window is never measured from a poll at which work was
still pending. A fake-clock spec covers the in-flight to zero case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The journey IPC capture pairs every traced start with its success or
error and exposes the calls still in flight. J2 settles only when J1's
capture, installed before the document loaded, has none pending, so a
slow startup call cannot resolve after the click and count as J2.
- The mock HTTP window starts at the renderer's click stamp instead of
the test-side mark taken before Playwright's actionability checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sentinels use cancelSourceProbe: the preload traces the call before
forwarding it and SOURCE_HEALTH_CANCEL is an in-memory map lookup, so a
marker no longer runs a SQLite query on the worker ahead of the measured
work (Codex P1). A spec pins that handler contract.
- A run is started only in the Playwright runner, replacing inherited
values, and carries a random harness.runId; summaries from another
invocation are never merged (Greptile P1, Codex P2).
- The mock ledger tracks in-flight requests; settling and the HTTP window
require none in flight (Codex P2).
- clickToFirstPagePaintMs reports click to the committed paint next to
the terminal-batch clickToFirstPageMs (Codex P1).
- The Playwright attachment carries the whole summary (Greptile P2).
- jsdom probe specs wait for the post-paint cutoff instead of a fixed
40 ms, which flaked when the harness runs all files in parallel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CodeQL flagged the fake upstream as reflected XSS. It now records what it
received server-side and answers with a fixed text/plain body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measure the click on the Xtream portal card until the category list and
the first page of the opened section are painted, in the same fresh
process as J1 after its counters are final and the app has settled.
- journey-renderer-probe: optional click start (capture-phase listener on
window, start sentinel before the app sees the click, entries before the
click dropped), companion selectors, recent-input layout shifts tallied
- journey-main-ipc-capture: optional start sentinel; counts calls between
the two sentinels
- journey-mock-request-ledger: loopback proxy that counts every request
the app sends to the mock without storing credentials
- open-source-journey-record: J2 counters and evidence
- journey-run / journey-summary: every journey spec of one perf:journeys
run adds its entry to the same summary.json
- docs: J2 contract in performance-journeys.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(playback): record recently viewed only after the stream plays
A channel, movie or episode used to enter Recently Viewed (and the
dashboard's Continue Watching hero) the moment it was selected or its
link was resolved, so streams that failed straight away cluttered the
history.
Writers now defer the write to a root PlaybackHistoryGate, keyed by the
stream URL and/or the playback session key. The inline players confirm
those keys once the owned engine's position has advanced by two seconds
(seeks, stalls, pauses and a previous stream's progress do not count),
the radio player does the same, and a launched MPV/VLC session confirms
on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on
selection. Covers M3U (live, radio, movie detail), Stalker (live, radio,
VOD, series), Xtream VOD and series, and the global live collection.
The M3U host's embeddedPlayback is now compared by value: the history
write updates the playlist meta mid-playback, and a new but identical
playback object remounted the engine and restarted the stream.
E2E flows that relied on recording-on-click now play local fixtures
(HLS/TS/WebM routed in place of unreachable or public streams) and wait
for confirmed playback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): tighten recently viewed confirmation per review
- Correlate by session key first: when both the deferred write and the
confirmation carry a playbackSessionKey, only that is compared, so the
same stream URL played in another playlist no longer records a failed
attempt. URLs remain the fallback (portal writes, MPV/VLC sessions).
The M3U radio player now receives the host's session key.
- Count only playing progress: engines report `playing` (not paused, not
seeking) with each time update, so short seeks of paused media no
longer confirm a view.
- Xtream: a write confirmed after a playlist switch still saves to its own
playlist but no longer replaces the current playlist's recent list.
- Global live tab: a row confirmed after another row was selected still
moves to the top of an open Recently Viewed list (only a disposed tab
skips the notification).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): confirm session-keyed history only by its own session
A write deferred with a playback session key (M3U) is now confirmed only
by that key. The app-wide MPV/VLC session confirmation carries just the
URL, so opening the same stream externally from another playlist could
still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is
instead confirmed by the WebPlayerViewComponent that requested it, under
its own session key, once the launch has opened.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(playback): keep the history gate off the initial bundle
The `@iptvnator/services` barrel ships in the initial bundle, so adding
PlaybackHistoryGate there (and subscribing to it from the app-wide
ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes.
- Move the gate to a new lazy-only `playback-data-access` project
(`@iptvnator/playback/data-access`; scope:shared, domain:playback,
type:data-access) and register it in the coverage policy.
- The gate subscribes to MPV/VLC session updates itself; it is created by
the first deferred write, which precedes the launch it waits for.
ExternalPlaybackService is back to master.
- The Xtream "playlist switched before confirmation" check moves to the
lazy helper; the initial-path store only takes a `skipListRefresh` flag.
Net effect on this branch: +27 bytes over master (master itself is
108 bytes over the ratchet baseline already).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(playback): drop late Xtream confirmations off the initial path
The Xtream store ships in the initial bundle, so even the small
`skipListRefresh` flag cost 27 bytes there. A confirmation can only
arrive after a switch to another playlist from a slow MPV/VLC launch
(the inline player goes with the page), so the lazy helper now drops it
instead: recording it would misfile the item or replace the other
playlist's recent list. with-recent-items is back to master.
This branch is now 3 bytes below master on renderer.initialBytes; the
ratchet still reports master's pre-existing overage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(playback): pass the spec type-check gate from master
- playback-data-access: align tsconfig.spec.json with the epg-data-access
config #1705 updated (bundler resolution, global.d.ts for window.electron).
- M3U recent-history spec: type the selectSignal override.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): keep late Xtream confirmations in their own playlist history
A confirmation that arrives after a switch to another playlist (a slow
MPV/VLC launch) is no longer dropped: the lazy helper saves it to the
captured playlist through the data source, without reloading the store's
recent list, which belongs to the other playlist by then. The store and its
barrel ship in the initial bundle, so the save path stays in the feature
helper; renderer.initialBytes stays under the baseline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): correlate global live-tab history by its session key
The unified Favorites/Recent live tab deferred its history write by stream
URL only, so the same URL played from another playlist could confirm a
failed selection, and a switch to catch-up before confirmation could never
match. It now defers with the tab's playlist-scoped playbackSessionKey (the
key its players confirm with), and the tab's radio player receives it too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): keep MPV/VLC rows of the live tab confirmable by URL
The live tab's session key can only be confirmed by its own inline
players; MPV/VLC confirm the launched URL alone. A row that goes to an
external player (also later, after a double-click) now defers by URL, and
only rows played inline carry the session key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): per-channel M3U history attempts, capability-based Xtream fallback
- M3U: the recently-viewed dedupe key now includes the channel id, so a
second row of the same URL defers its own write (its session key) and
is recorded when it plays after the first row failed.
- Xtream late write: key uncached content by Xtream id per
supportsXtreamSqliteDataSource (the data-source factory's contract), not
by a generic Electron bridge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(e2e): stop mock servers from outliving Playwright runs
Playwright stops a webServer with a SIGKILL to the process group it
spawned, but `nx run-commands` starts its command in a detached process
group of its own. Launching the Xtream/Stalker mocks through
`pnpm nx run *-mock-server:serve` therefore left the tsx server running
(reparented to PID 1) and holding its port after every run, so the next
run failed with "…/health is already used" or silently reused a stale
server.
Every Playwright config now starts the mocks as a single
`node --import tsx apps/<mock>/src/main.ts` process with
TSX_TSCONFIG_PATH=tsconfig.base.json, which stays in Playwright's group.
A project-config spec guards all playwright*.config.ts files against
regressing to the Nx launch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(e2e): read sidebar categories atomically; tighten mock launch guard
- category-management: readVisibleSidebarCategoryNames read items one by
one; when Save removed an item between isVisible() and textContent(),
textContent() auto-waited for the gone label through the whole 15 s
poll, so expect.poll never retried (ubuntu shard 1 failed 3/3 while the
UI already showed "No categories available"). Take one snapshot with
filter({ visible: true }).evaluateAll() instead.
- project-config.spec: pin which Playwright configs start which mock,
reject any Nx form that mentions a mock server, and fail when a new
config starts a mock without being listed (the old count check passed
vacuously on zero matches).
- docs: state which configs start which mock instead of "every config
starts both".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* revert(e2e): leave the sidebar category read race to #1728#1728 fixes the same readVisibleSidebarCategoryNames race with a shared
helper; keeping a second copy here would only conflict. This PR stays
about mock-server lifecycle.
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>
* test(performance): count startup phases and SQL statements for the J1 launch journey
Implements plan item A2. With IPTVNATOR_PERF_CAPTURE=1 the main process
keeps named counters and registers a main-only performance:read-counters
IPC handler; without the flag nothing is counted and the handler does not
exist.
- debug-trace.ts owns the registry; traceStartupPhase replaces the
trace('startup', ...) sites and counts main.startupPhases.
- The database worker counts executed statements through better-sqlite3's
Statement prototype (the verbose callback expands every statement and
made bulk inserts 2-4x slower) and posts the count over its message
port, flushed before every other worker message. The main-thread shared
connection is counted through a new connection observer in the shared
database library.
- The first main window freezes main.modulesRegisteredBeforeWindow at
creation and main.sqlStatementsBeforeReadyToShow at ready-to-show.
- The journey gate drops the ready-to-show that Electron emits for the
about:blank detour, so the app sees the real document's first paint,
and taps the counters handler; the J1 record reads both counters after
the renderer probe completes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(database): require one SQL statement per exec during initialization
The performance capture counts one exec call as one statement, because
SQL cannot be split reliably in the counter (trigger bodies contain
semicolons). The historical-upgrade driver now wraps exec on every
connection initDatabase opens and fails on a batch, so that counting
assumption holds for the fresh profile and all historical schemas.
Documents the definition in the counter and the architecture docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(performance): count SQL statements only for the launch journey
Codex review: the M3U import, refresh-cancellation and Xtream benchmarks
also run with IPTVNATOR_PERF_CAPTURE=1, so the statement hook wrapped
every row of their bulk inserts and changed what they measure.
SQL counting now also needs IPTVNATOR_PERF_COUNT_SQL=1, which only the
launch journey sets; a harness test fails if another source sets it.
Startup phases, the window snapshot and the read handler stay on the
capture flag. Without SQL counting no ready-to-show listener is attached,
so a zero is never reported for statements nobody counted.
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>
The "allows restoring live categories after every category is hidden"
Electron test failed intermittently on shard 1 across unrelated PRs and
master. The app hid every category correctly (the failure screenshot
shows "No categories available"), but readVisibleSidebarCategoryNames
looped over count() with per-row nth(index) reads. When the sidebar
removed a row between isVisible() and textContent(), textContent()
auto-waited for the missing element, so the expect.poll predicate never
returned and the poll timed out with "waiting on the predicate" instead
of retrying.
Read the visible rows with a single evaluateAll() snapshot in a shared
sidebar-categories.e2e-support.ts helper, use it for the category
picker in category-management and backup-roundtrip too, and record the
rule in the validation map.
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Registering IPC handlers costs 0.4 ms; evaluating the modules behind them (axios, drizzle-orm, better-sqlite3, electron-updater, fix-path) before the window could load was the real cost. main.ts now keeps only the pre-paint wiring and loads the rest as the deferred-events.js chunk inside the main window's did-start-loading listener, where the import and its registrations complete before any renderer invoke can arrive.
Interleaved A/B on the performance build: app.whenReady 323 -> 265 ms, did-finish-load 499 -> 447 ms; J1 journey spawnToDidFinishLoad ~405 -> ~365 ms with identical counters. Packaging ships the chunk explicitly, verify:package-layout requires it, and the benchmark build identity hashes it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main.js is now a small entry that enables Node's on-disk V8 compile cache under userData/v8-compile-cache and then requires the application bundle main.app.js. Warm launches reach app.whenReady about 13 ms sooner at the median; IPTVNATOR_DISABLE_COMPILE_CACHE=1 turns it off and IPTVNATOR_COMPILE_CACHE_DIR relocates it.
Packaging now ships main.app.js explicitly and verify:package-layout requires the main-process entry files in app.asar, because nx-electron copies the backend through an allowlist. The Xtream benchmark build identity hashes both the launcher and the bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): add the J1 launch-to-usable journey benchmark
Implements plan items A1, A3 (J1 only) and the minimal A4 from
.plans/2026-09-25-performance-journeys-ratchet.md.
- journey-renderer-probe.ts: init-script probe counting DOM mutations,
layout shifts and long tasks until the first source card is visible on
/workspace with the splash removed; unit-tested with jsdom fixtures.
- journey-main-ipc-capture.ts: counts bridge invocations from the preload's
renderer-API trace channel up to a sentinel call the probe fires, so the
IPC counter is exact without touching production code.
- launch.journey.ts + playwright.journeys.config.ts: seeded profile (one
M3U source, one Xtream portal on the loopback mock), one warm-up and five
measured iterations, fresh process and data directory each, writing
dist/performance/journeys/<timestamp>/summary.json with exact counters
and P50/P90 wall-clock.
- Nx target electron-backend-e2e:journeys and root script perf:journeys.
- docs/architecture/performance-journeys.md, README, context and
validation map entries.
renderer.cdTicksToFirstCard and main.sqlStatementsBeforeReadyToShow are
reported as unavailable with the reason instead of being faked.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): gate the renderer load so the probes never race startup
Review follow-up for #1698.
- journey-renderer-gate.cjs: a main-process hook loaded with `-r` (the
mechanism Playwright uses for its own loader) makes the first
loadFile/loadURL navigate to about:blank and holds the real load until
the test releases it. Playwright reports no page before a navigation
commits, so this is what lets the renderer probe be registered on the
page before the real document exists; the IPC capture is installed
before the release too. A safety timeout releases the gate on its own
and marks the iteration invalid. Unit-tested with a fake BrowserWindow.
- launch-journey-app.ts: registers the probe on the parked page, releases
the gate, waits for the real document to commit, fails fast when the
probe is missing, and refuses an iteration whose gate timed out, saw a
second load, or released before the probe was in place.
- journey-renderer-probe.ts: entries delivered live after the terminal
batch are buffered and filtered by the same cutoff as queued ones, and
the cutoff is sampled in a timer queued from the first rAF, i.e. after
the card's frame is painted, so the render task's long task and layout
shift are consistently included.
- launch-journey-record.ts: layoutShiftScore rounded to three decimals; a
0.0001 shift flipped in and out of the cutoff between iterations.
- playwright.journeys.config.ts: reuse a mock server left on the journeys
port locally (its fixtures are deterministic); CI still starts its own.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): validate the journey gate after the probe completes
Codex follow-up on #1698: the gate state returned by release() cannot see a
reload or recovery navigation that happens before the first card. Re-read the
live state once the renderer probe has finished and validate that instead,
so an iteration spanning an extra navigation is rejected.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(performance): fail an iteration whose performance observers were unavailable
Codex follow-up on #1698: a renderer that cannot observe layout-shift or
longtask entries used to pass the probe with zero counters, which a ratchet
could not tell apart from a genuine zero. The probe assertion now rejects
such an iteration and names the missing observer.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* test(electron): reap Windows process trees and retain smoke diagnostics
* test(electron): require confirmed process exit before relaunch
* test(electron): retain process handle after application exit
* test(electron): bind captured processes to application instances
Xtream and Stalker live cards on the dashboard carried no XMLTV key, so the
rails never asked anything for them and showed only the LIVE chip. Their
programme now comes from the portal, one card at a time and only once the card
is on screen.
- `lib-dashboard-rail` reports the cards inside its viewport through an
IntersectionObserver rooted at the track; `DashboardLiveEpgPresenter` unions
them with the pinned hero row and hands the set to
`DashboardPortalLiveEpgPresenter`.
- `DashboardPortalLiveEpgService` runs the bounded queue — two requests in
flight, 200 ms apart, one card each — through
`StreamResolverService.loadEpgForItems`, publishing every answer the moment
it lands, so the page never waits and a slow portal delays no other card. A
card scrolled past before its turn is never requested.
- A programme is trusted for 60 s, an empty answer for 30 s (the resolver
reports a dead portal and a guide-less channel identically), and a
completion captures both the display offset and the EPG source revision,
requeueing itself when either moved.
- The presenter hands its wanted set back on destroy. Desktop only: the shared
collection resolver is gated on the local XMLTV bridge.
- A shimmer placeholder shows only before a card's first portal answer; M3U
cards keep the batched XMLTV lookup.
* 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>
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>
* fix(shell): persist and restore the app zoom level (#1109)
The app-wide zoom (Cmd/Ctrl and +/-) was never saved, so it reset to the
default on every restart. Save the webContents zoom level next to the window
bounds on window close and before quit, and reapply it once the renderer
finishes loading. webContents zoom is per-host, so the restored level then
holds across in-app section navigation (SPA route changes never reload).
* fix(shell): restore the zoom level as frame-bound temporary zoom (#1109)
Under file:// Chromium keys zoom by the full URL, and the packaged
renderer routes with pushState, so a level applied through
webContents.setZoomLevel belongs to index.html only: the first resize
after a section change snapped the renderer back to the default, and
the close handler read the current route's entry (usually 0) over the
user's choice. The preload now applies the persisted level with
webFrame.setZoomLevel, a temporary zoom bound to the frame that survives
in-page navigation and resizes; the main process hands it over through
the synchronous WINDOW:GET_ZOOM_LEVEL IPC and writes the live level back
on close, before-quit and before every cross-document navigation, since
a reload drops the temporary level.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(shell): apply the restored zoom level at DOMContentLoaded (#1109)
A webFrame.setZoomLevel at preload start left a hidden window without a
first frame on Linux and Windows: ready-to-show never fired, the window
never showed, and the renderer got no animation frames, so the startup
splash removed in a requestAnimationFrame stayed. macOS was unaffected
and CDP-driven tests force frames, which is why only the packaged
legacy-migration E2E asserting the splash is gone caught it. Applying
once the document is parsed is harmless and still lands before the
first Angular paint; ownership of the level now follows the preload's
WINDOW:ZOOM_LEVEL_APPLIED acknowledgement instead of the request. The
release note no longer advertises Ctrl shortcuts Windows and Linux do
not have.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2
* fix(deps): complete Angular migrations after rebasing on master
* fix(ci): use the Node pin for Windows runtime refresh
* docs(deps): synchronize the workspace-shell Node requirements
* feat(epg): accept local XMLTV files as EPG sources
Settings → EPG and the playlist dialog accepted `file://` in their form
pattern, but the main process rejected everything except http(s), so a local
XMLTV entry saved fine and then failed on import. Both surfaces now take a
remote link, a `file:` URL, an absolute POSIX path or a Windows drive/UNC
path (`classifyEpgSourceReference` in shared/interfaces), and the settings
section spells out the accepted formats with examples.
The EPG worker opens every source through `openEpgSourceStream`: remote
links keep the validated-redirect client and trust policy, local files are
read from disk behind the signature-sniffing optional gunzip stage, so
.xml, .xml.gz and extension-less gzip all parse. Only hand-typed sources
may be local: `extractM3uEpgUrls` harvests http(s) links only from M3U
headers, since the local branch bypasses `validateRemoteUrl`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(epg): pick local XMLTV files with a native file dialog
A folder button beside each EPG source row (Settings → EPG and the playlist
dialog) opens the native open-file dialog and writes the chosen absolute
path into the row. New `EPG_OPEN_FILE_DIALOG` IPC behind
`ElectronBridgeApi.openEpgFileDialog`, gated in the renderer by
`RuntimeCapabilitiesService.supportsEpgFilePicker`.
The row's refresh/remove buttons carry `data-test-id`s now, and the EPG
e2e suites address them by id instead of index, since the folder button
became the first button in a row.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(epg): authorize local XMLTV files in the main process
Review follow-up (Greptile P1, Codex P1). The renderer hands source strings
to FETCH_EPG/EPG_FORCE_FETCH unchanged, so the form validator alone could
not enforce the provenance rule: a compromised renderer, or a legacy
`file://` entry an older version stored from an M3U header, could name any
file on disk.
`EpgWorkerService.startFetch` now asks a main-process
`EpgLocalSourceAuthorizer` before a local path reaches the worker: a path
the native picker returned is trusted at once, a hand-typed path is
confirmed once in a native message box the renderer cannot fake, and a
refusal is reported in the progress panel. Allowed paths persist under
TRUSTED_LOCAL_EPG_SOURCES in the main-process config. The worker opens its
local branch only when main set `allowLocalFile`; the service defaults to
deny-all until epg.events installs the persisted authorizer.
`resolvePlaylistEpgSourceState` and `filterPlaylistEpgUrlsForFetch` drop a
stored non-remote entry unless it is also in `manualEpgUrls`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(epg): fail a refused local EPG fetch instead of resolving it
Review follow-up (Codex P2). A denied native confirmation now rejects the
fetch after reporting the error row, so handleFetchEpg and the renderer's
fetch result cannot claim the file was read. Also restores the unrelated
CLAUDE.md paragraph an earlier formatter pass had reflowed into a list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(epg): cancel a local source retired during its authorization prompt
Review follow-up (Codex P2). startFetch keeps the request generation
captured before awaiting the native confirmation and rechecks it
afterwards: a source retired meanwhile ends as cancelled instead of
starting an import that a pending clear would then have to await.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(epg): leave the local XMLTV e2e with a pristine settings form
The local-file test ended with the EPG source field still dirty, which
arms the main-process close guard: the app then waited for the unsaved
changes dialog instead of closing, the close timeout killed it, and on
Windows the killed process kept iptvnator.db busy (EBUSY on the data-dir
cleanup) and hung the Playwright worker teardown. Discarding the form
before the app closes takes the test from 15 s to 4 s locally.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>