- The Stalker explicit-launch guard tracks every in-flight movie, so a
repeat of a pending launch is rejected even after another movie's
launch was started in between.
- The Xtream movie reset only touches the route's state while it still
shows the movie the reset started on.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A launch still resolving for the movie the user left no longer blocks
the explicit launch of the movie now on screen; only a repeat for the
same movie is ignored.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A second "Open in external player" while the first create_link is still
resolving is ignored, and the menu row is disabled for that window, so
two resolutions cannot each start a player.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The collection's selected movie id is reported only while the shown item
is a movie, so a series with the same provider number cannot let a stale
external launch start over it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The Favorites/Recent Stalker detail wires the menu's external launch
and progress reset to collection-owned actions.
- The Stalker search host passes its pending-start state to the detail,
so the reset row is disabled while a launch resolves.
- A rejected forced Stalker episode launch is logged and reported.
- The spec mocks of the external playback handle are signals, as the
contract types them (CI spec typecheck).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The launch is awaited inside the start, so the pending state covers the
close of the previous player and the launch itself instead of clearing
as soon as the launch was kicked off.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The external player rows of every details menu follow the managed
external player capability instead of the downloads availability.
- An explicit Stalker movie launch is a pending start for the selected
movie while its stream resolves, so the reset row is disabled meanwhile.
- The resume clock formatter moves into the presentation helpers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A start still resolving its stream for a series the view has since left
no longer keeps the new series' reset row disabled.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The shared movie menu offers the external player in provider-only
mode too; that mode hides local/download controls, not the provider's
stream in MPV/VLC.
- The movie and Stalker series reset-progress rows are also disabled
while a start is still resolving its stream, so a slow create_link
cannot recreate a just-cleared row.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- A forced Stalker episode launch compares against a generation captured
after the inline close instead of the one that close retired, so the
menu's MPV/VLC action starts again while stale launches still drop.
- The Stalker movie guard reports the selected id only while a movie is
on screen; a series with the same provider number never passes it.
- The series reset-progress row is disabled while an episode plays or
launches, independent of the bulk watched action's state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The series page's section heading is "Episodes" with its own test id
since the cinematic redesign; the spec from master still measured
".section-title" and the old "Seasons and Episodes" translation.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Stalker movie and series menu launches in MPV/VLC close the title's
own running external session first through the new shared
replaceOwnedExternalSession(), cancelling when teardown is not
confirmed or the selection moved on.
- The reset-progress row is disabled while an external player owns the
position, on the shared and the Xtream movie menus.
- Every ISO comma-bearing country name stays whole in provider lists.
- The dashboard hero spec competes a favorite movie for a slot so the
movie-title case is actually exercised.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closing the previous external session takes time; a launch whose series
page is gone or shows another series by then no longer starts. The
service clears its bindings on destroy and exposes the page's
playlist:series owner for that check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- A forced MPV/VLC episode launch closes an episode of the same series
that is still running externally before starting, as the movie flow
does; a failed close keeps the running player.
- A Stalker external launch resolved after the user moved to another
movie is dropped instead of launching the old stream or closing the
new item's inline playback.
- The trailer's sound toggle is rendered by the hero above its content
layer, so it can actually be clicked.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The movie menu's MPV/VLC launch is a regular route-owned start with
the player forced: a running external session is replaced through the
same guard instead of a second player starting beside it.
- The trailer-backdrop setting is shown for every player; it is a
details-page feature, not a web-player control.
- The series menu matches the recent list by content type as well, so a
movie sharing the provider id does not stand in for the series row,
and the hide action follows the refreshed list instead of a permanent
hidden marker.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(dashboard): scroll a focused rail card fully into view
Chromium skips its focus scroll when 32px or more of the element already
shows, so Tab onto the last source card of a rail that overflows by less
than a card left it half-hidden under the edge fade. The rail track now
handles focusin and scrolls to the first card-start snap position that
reveals the whole card; a plain "nearest" scroll is not enough because
mandatory snapping can round it back (seen on the live channel rail).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): keep mouse clicks on partly hidden rail cards
A mouse press focuses the card link on mousedown. Revealing the card at
that moment could slide it from under the pointer when the target snap
position overshoots (the live channel rail moves 316px for a 306px
card), so the click landed elsewhere. The rail now reveals a card only
for keyboard and programmatic focus, using the CDK FocusMonitor origin.
Adds an Electron E2E that checks the final layout after snapping: Tab and
focus() leave the last source card fully visible, and a mouse press keeps
the rail still and still opens the source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): tell pointer focus apart without touching the DOM
FocusMonitor toggles cdk-*-focused classes on the monitored track, so a
mouse press on a source card mutated the DOM before the click. The J2
"open a source" performance journey rejects iterations with DOM activity
between its settle snapshot and the click. Read the input modality from
the CDK InputModalityDetector in a focusin handler instead: it only
listens, so the rail stays untouched until the click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): reveal script-focused rail cards after a mouse click
The input modality stays "mouse" after any click, so a later focus() on
a partly hidden card left it clipped. The rail now skips the reveal only
for focus caused by a press inside the track: the focus has to arrive
within 100ms of that pointerdown (650ms for touch, whose focus comes
with the tap's compatibility mouse events, as in the CDK FocusMonitor).
Only event timestamps are compared, so the DOM still stays untouched
before the click.
The E2E now clicks elsewhere before the script focus, and unit tests
cover a tap and focus() after an earlier mouse press.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): keep an over-wide focused rail card in view
In a window narrower than a card (or under zoom), a focused card could
never fit, so its own snap offset fell short of the needed scroll and
the rail jumped to the next card's snap point, moving the focused card
offscreen. Such a card is now aligned at its own start instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(dashboard): select rail internals through stable test ids
The dashboard contract makes data-test-id hooks the supported Electron
E2E selector surface. The rail now exposes -viewport, -track and
-card-link hooks next to its existing ones, and the focus E2E selects
those (and the rail heading by role) instead of internal class names.
The dashboard doc lists the new hooks and records the focus-reveal
contract.
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 series menu trigger renders data-testid, so the Electron specs use
an attribute locator instead of getByTestId (data-test-id there). The
back-lane sweep targets the "Episodes" heading, the primary action is
read by its test id (its name now carries the runtime line), the M3U
inset assertion accepts the hero's own 32/20px inset, and the surface
contrast checks measure the flat episode card's artwork, which gets a
light hairline so its edge survives the light theme. The menu-closing
helper checks the panel rather than a backdrop locator that can match
a second overlay.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- The "…" menu's MPV/VLC launch goes through the regular episode and
movie starts, so recent history and the launch position are recorded
as for the Play button.
- Resetting a Stalker movie's progress only clears the shown position
while that movie is still selected, and retires a position read in
flight so it cannot restore the reset row.
- The sources panel takes keyboard focus when it replaces the menu rows.
- The trailer backdrop drops its frame and waits again (muted) when the
trailer changes, and stops while the document is hidden.
- The dashboard hero strips the season marker from series only.
- Provider country lists keep "Korea, Republic of" whole.
- The web season-watched E2E reacquires the menu row after every close.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(ui): destructive confirmations, verb labels and provider icons
Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".
The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.
Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.
The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): one provider icon per playlist row, imperative Korean remove label
A restored Stalker or Xtream playlist can also carry a URL, and the row's
independent checks then showed the M3U URL icon next to the provider icon.
The row now switches on resolvePlaylistSourceIconKey(), the precedence every
other surface uses, so each source shows exactly one icon.
HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete
tooltip; in Korean it read "the playlist has been removed". It now says
"remove playlist", like every other locale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): keep the auto-refresh badge on playlist rows with one provider icon
Showing one provider icon per row moved the auto-refresh badge into the M3U
branches only, so a restored Stalker playlist with a URL and auto-refresh
lost it although the URL is still re-fetched. The row now renders one icon
container: the provider icon from the shared precedence, then the badge for
any row with a URL or a local M3U, exactly the rows that showed it before.
The Xtream portal-status dot, used without source health, keeps that corner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): let the playlist row's cancel action render in the error color
The row's action buttons set `color: inherit`, and the selected row does so
again with more specific selectors. Both beat Material's token-driven icon
color, so the .app-destructive-button cancel action kept the row color
(selection blue on the active row). Pin the cancel button to
--mat-sys-error in both row states.
The large-deletion Electron E2E now checks the cancel color in both themes;
without this rule it reads rgb(47, 123, 255) instead of the error red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): give the dialog service spec the now-required confirm labels
ConfirmDialogData.confirmLabel became required, and the spec still built
confirmations without one. Jest only transpiles, so the suite stayed green,
but the "Typecheck Jest spec programs" CI step rejected it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The stage for a 16:9 backdrop is min(480px, 60vh) instead of 560/72vh,
with the content's top padding at 48px, so the episodes or cast row gains
~80px in a 1200×800 window. A title with only a poster (the common case
on real Xtream and Stalker playlists) gets a compact hero sized by its
content over the blurred poster. The layout is decided once per title: a
backdrop that TMDB enrichment adds a moment later fills the compact hero
instead of growing it under the cursor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Settings → Playback → "Play trailers in details background" (off by
default) hands the trailer embed URL to the detail shell; the new
app-hero-trailer-backdrop plays it muted and looping under the scrim
after three idle seconds with a corner mute toggle. It never starts under
prefers-reduced-motion or with saveData, and stops while the hero is off
screen or the window is unfocused. Xtream and Stalker movies and series
are wired; M3U has no trailer source.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The hero's primary is the same light Continue/Play as the details pages
and its chips are the shared meta chip. A title without a 16:9 backdrop
gets the blurred poster alone as the stage (the sharp key-art copy is
gone; a live channel's logo still sits on the right). Series titles drop
their season marker. The hero is a focusable region: ←/→ switch slides,
Enter follows the primary action, and rotation pauses while the document
is hidden.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Series pages take the same hero as movies: the provider's season marker
moves out of the title into a "Season N" chip, the status chip is localized,
the primary reads Continue / "S02E03 · 18m left" or Play / the next episode,
Trailer opens the modal, and favorites plus a "…" menu complete the row.
The menu carries mark season/series watched, reset progress, open in
external player, copy the next episode's URL, download season, show in
category and hide from Continue Watching — rows a provider cannot serve
are left out. The season container's header is "Episodes" with the season
pills and count; the season actions it used to show live in the menu;
episode cards are flat with a watched bar on the artwork, the plot and
"42 min · 18m left". Cast & crew and Similar follow the episodes. The
Electron and web E2E flows and the season container specs drive the new
menu and presenters; legacy detail styles are removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Rebuilds the movie hero in the order kind label → title → chips →
description → resume bar → actions → credits. The backdrop fills the hero
behind a two-layer scrim and falls back to the blurred poster when the
provider has none or sends the poster twice. The action row is a single
line: the light primary with a two-line label (Continue · time left / Play ·
duration), a Trailer button that opens the modal, 44px icon buttons for
favorites, watched and download, and a "…" menu with other sources, open in
external player, copy stream URL, start over, reset progress and show in
category. Cast & crew and Similar (title + year) move into rows under the
hero; the bottom trailer section is gone. The multi-source E2E flow and the
release screenshot driver follow the menu.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the building blocks of the redesigned VOD details and dashboard hero:
a pill meta chip, the light primary button with a two-line label, a 44px
ghost icon button with tooltip, the right-aligned "…" dropdown menu that
flips upward and hosts the alternative-sources panel, hero credits plus a
Cast & crew row, a horizontal rail with prev/next arrows, a Similar rail
and the trailer modal. Shared detail utilities (season suffix, remaining
time, duration, short country names) move to portal/shared/util, with the
new CTA and rating theme tokens and the i18n keys for all locales.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(player): keyboard focus, contrast and ARIA for controls and settings
Dock and settings-panel icon buttons draw a 2px --pc-text ring on
:focus-visible, and Material's theme-coloured focus layer is off, so
keyboard focus shows on video in the light theme too. A focused selected
subtitle swatch now differs from one that is only selected.
Settings headings read --pc-text-secondary on denser glass
(--pc-glass-bg-dense, 0.86): 4.5:1 or more over mid-grey and white
frames. They wrap (overflow-wrap: anywhere, hyphens: auto), so long
German and Russian headings stay inside the sheet's heading column.
The settings panel is now radio groups only (SettingsRadioGroupDirective
over a CDK FocusKeyManager): one Tab stop per group on the checked option,
arrows, Home and End move focus without applying, and Space/Enter checks.
The dialog and its groups are named by real h2/h3/h4 headings, the
load-file action sits outside the subtitle radio group, the subtitle and
speed chips carry their value in their name ("Subtitles: English"), and
tune has aria-haspopup="dialog".
Adds a web E2E for the keyboard path in both themes with an axe check on
the open panel, and de/ru sheet heading wrapping; axe-core is a new dev
dependency for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): arrows check settings radios; subtitle chip reads On
Review follow-up:
- Arrow keys, Home and End now check the settings radio they reach, as a
native radio group does (the directive clicks it, so the template's
handler applies the choice); an option the engine already reports as
checked is not applied again.
- With subtitles on but no track marked selected yet (the engine can
report the switch before the track list), the subtitle chip reads and
announces "On" (new SUBTITLES_ON key, 19 locales) instead of "Off".
- The swatch row has 4px padding on every side, so the outer focus ring
is not clipped at the scroll edge of the panel body.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(player): re-apply a settings radio while a switch is pending
The arrow-key check skipped any option the engine still reported as
checked. Arrowing from audio track A to B and back to A before the engine
confirmed B therefore sent no command for A, and playback ended on B with
focus on A. An arrow move always lands on an option other than the last
one applied, so it now applies unconditionally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(portals): treat an undetermined audio language as unknown
ICU 78, which Electron 43 and Node 26 ship, canonicalizes the ffprobe
marker `und` to the subtag `und` instead of an empty one. The source
metadata then recorded it as a stated language, so a switch between a
copy tagged `und` and one tagged English raised the "dub may differ"
warning. CI's older ICU hid this: the existing spec only fails on the
newer runtime.
Decline `und` explicitly, and cover `und-US` plus the dub comparison.
* test(portals): cover undetermined language across runtimes
---------
Co-authored-by: 4gray <serega05@gmail.com>
* fix(playback): show the Up next card near the real end of an episode
The card appeared a fixed 8 minutes before the end: most of a short
episode, and far into the story of a long one, with no way to hide it.
- Adaptive lead: 4% of the episode, clamped to 40 s … 3 min.
- A closing-credits chapter in the last third, when timeline segments
carry one, brings the card forward to its start (capped at 5 min).
- Close button and Escape dismiss the card for the current next episode.
- After 10 s (not while hovered) the card collapses into a one-line pill.
- Seconds countdown in the last minute.
- New playback setting "Up next card" (default on) turns it off.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): address Up next card review feedback
- Offer the Up next card setting for Embedded MPV only under the
frame-copy engine; native view never mounts the shared controls.
- Hovering pauses the collapse delay instead of restarting it.
- Document that the card stays visible when the controls auto-hide.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): restart the Up next collapse delay for a new episode
- A card that stays mounted while its next episode changes gives the new
item the full delay instead of the previous item's leftover.
- The setting description no longer promises credit-based timing: no
current series path supplies chapters yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): keep dialog action rows on one line
Settings "Unsaved changes" dialog:
- Cancel / Discard / Save replace the phrase labels in all 19 locales, and
the dismiss now comes first; the shared CANCEL key replaces the unused
UNSAVED_DIALOG_STAY.
- At the 640px phone breakpoint the actions stack one per row, full width,
in DOM order.
EPG programme dialog:
- mat-dialog-title gives the dialog an accessible name.
- The footer Close is the only dismiss; it comes first and the primary
action last.
- The archive copy/download tools move under their notice, so the footer
stays on one row.
- Channel rows now open it through EpgProgrammeDialogService, which owns
the 540px config and a panel class scoping the surface overrides.
Adds a web-e2e layout spec (en, de, ru, fr, hu, ar on one row; de and ru
stacked on a phone), extends the Electron EPG spec to all three openers,
and documents the dialog contract in the UI guidelines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(epg): stack programme dialog actions on phones
Below the 640px phone breakpoint the viewport caps the programme dialog,
and a long translated primary label ("Regarder depuis le début") no
longer fit beside Close. The footer had no wrap, and the dialog hides
overflow, so the label was clipped.
- At the phone breakpoint, the archive tools and the footer now stack one
full-width button per row, in DOM order.
- Buttons grow to fit their label, so a long label wraps inside its
button instead of being clipped.
- On desktop the footer can wrap again as a last resort.
The new Electron test opens a past programme in French at a 360px
viewport and measures both rows. It fails against the previous
stylesheet (the footer buttons are 44px narrower than the row).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(playback): give the fullscreen side panel the settings panel's glass look
The fullscreen channel/episode panel was a full-height graphite strip while
the controls' settings panel is an inset, rounded glass card. The side panel
now wears the same surface: 16px inset (8px under 560px), 20px corners, the
controls' glass fill, hairline border, blur and shadow, the same open motion
and a 32px square close button. The edge hint uses the same glass, and the
episode list marks the playing row with the settings panel's selected cyan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): dismiss the side panel from the exposed edge gutter
The inset card leaves the hot zone's left strip visible beside it, above the
scrim, so a click there re-ran show() instead of dismissing. A completed
primary press in the zone now closes an open panel; hovering there still
counts as inside, since a hover-opened panel leaves the pointer resting in
that strip. The playing episode row also keeps its cyan tint on hover/focus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): decide an edge press from the panel state at pointerdown
A press held on the edge past the hover dwell opened the panel before the
release, which then read the open state and dismissed it. The press now
remembers whether it began on an open panel.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(dashboard): fill the hero rotation dot on the compositor
The active hero dot animated `width` 0 → 18px for every 8 s rotation, so
an idle dashboard with two or more slides ran style, layout and paint on
every frame. The fill is now a full-width bar that slides in with
`transform` under the pill's rounded clip. The `animationend` advance,
the pause and reduced-motion behaviour are unchanged.
Measured on the E2E build (visible, four slides, 120 s): layouts
10,405-10,677 -> 366-369, renderer process CPU 14.2-16.4 s -> 3.7-4.1 s,
GPU process CPU 14.8-18.8 s -> 14.0-14.8 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): drive the real hero rotation animation
The unit specs dispatch `animationend` on the dot span by hand, so a fill
whose real event no longer reached the handler would still pass. The new
Electron spec shortens `--hero-rotation-ms` and checks that the running
`::before` fill advances the slide, that pause holds it and that Play
resumes it.
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>
* perf(electron): let hidden and minimized windows report themselves hidden
The main window was created with backgroundThrottling: false (since #1123,
without a stated reason). Electron then keeps document.visibilityState at
"visible" for a hidden, minimized or fully covered window and never lets
Chromium throttle it, so every renderer timer, rAF and CSS transition ran at
full rate in the background, and the playback keep-awake gate, which
releases the display for a minimized window, could never see one.
Use Chromium's default. Audible media and picture-in-picture are exempt
from background throttling in Chromium, and a local check confirmed HLS
playback continues unchanged through more than six minutes minimized,
audible and muted.
Playwright's focus emulation pins every page it attaches to as visible, so
the new window-visibility E2E launches the app without Playwright and
drives it over raw CDP (electron-unautomated-launch.ts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): harden the unautomated Electron launch
Review follow-ups for the window-visibility E2E:
- Resolve `electron` in the main process through a require created from
the `node:module` builtin instead of `process.mainModule`, which only
exists when the app entry is CommonJS.
- Bound teardown like closeElectronApplicationAndConfirmExit: SIGTERM,
then SIGKILL, 5 s each, then fail instead of waiting forever.
- Surface CDP protocol errors from Runtime.evaluate instead of returning
undefined.
- Wait until the window is actually shown before hiding it. The app shows
its window on ready-to-show, and a hide() that lands earlier is undone
by that show(); this was the first-attempt failure on the macOS shard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): check the playback display lock is released while hidden
Review follow-ups for #1724:
- Add an E2E that plays the webm fixture in the unautomated launch,
records the main process's prevent-display-sleep blockers, and asserts
the keep-awake lock is taken while visible, released when the window is
hidden, and taken again when it is shown. The visibility tests alone
would still pass if the renderer gate or the bridge stopped updating
powerSaveBlocker.
- Validate CDP replies before dispatch (CodeQL
js/unvalidated-dynamic-method-call): only a numeric id with a pending
settle function is called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): skip killing an Electron that already exited
stopElectron now checks the recorded exit state before each signal and
tolerates a kill that races the exit: on Windows taskkill throws for a PID
that no longer exists. Startup cleanup can no longer replace the startup
error that explains the failure; a cleanup failure there is logged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(stalker): keep the watchdog cadence while the window is hidden
With background throttling on, Chromium wakes a hidden, silent page's
timers at most once per minute after five minutes, so a portal that asks
for get_events every 30 s would see pings at half its cadence while the
window is minimized. Tick the watchdog from a dedicated worker
(createBackgroundInterval, an inline blob worker allowed by the renderer
CSP), whose timers are not subject to page throttling. It falls back to a
page setInterval where no worker is available or the worker fails to load.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(stalker): keep a stopped watchdog interval stopped
A worker error that arrived after stop() started the page fallback
interval, which nothing cleared, so pings continued for an inactive
playlist. stop() now marks the interval stopped, detaches the worker
handlers, and the fallback refuses to start afterwards.
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>
* perf(dashboard): slide live EPG progress fills with a compositor transform
The live-programme bars on channel rail cards and the hero animated their
width over 0.4 s whenever the 30 s live-EPG tick moved them. Width is a
layout property, so each tick re-laid out the whole document for about 24
frames, also while the window was minimized. Slide a full-width fill with
translateX driven by a --live-progress custom property instead; Chromium
runs that transition on the compositor. Reduced motion drops it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(dashboard): format the live progress spec
* perf(dashboard): stop animating the live EPG progress fills
A live-EPG tick moves the bar by under one percent, so the 0.4 s transform
transition was invisible but still produced a burst of compositor frames
on every tick. Place the fill without a transition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(dashboard): pin the hero progress fill to its custom property
The hero moved into DashboardHeroComponent (#1738); its progress bar gets
the same transform fill as the rail cards.
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>
* perf(workspace): share the startup inventory read and defer non-first-card IPC (J1)
Journey J1 / renderer.ipcCallsToFirstCard: 12 -> 7.
- PlaylistsService.getAllPlaylists() shares one in-flight SQLite read
between concurrent callers (the playlist effect and the XMLTV source
reconciliation at startup). Settled reads are never reused and every
SQLite write detaches the pending read.
- getM3uFavoriteChannels() stops re-reading the write-once IndexedDB ->
SQLite migration receipt once it has been seen.
- StartupDeferralService holds the download list, app update status and
the dashboard's recent items and favorites until one task after the
render that reveals the routed content (5 s safety timeout).
IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1 is the kill switch.
- reconcileEpgSources and the two distinct migration-flag reads stay on
the critical path: the first is the #1548 revision fence, the second
are different keys, not duplicates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(services): trust a migration receipt written in this session; correct J1 note
- Mark the IndexedDB -> SQLite receipt as confirmed after an empty-store
receipt write or a committed dbMigrateAppPlaylists, not only when it was
already present, so M3U favorites skip the per-playlist re-read on the
first launch after an upgrade too.
- The release note no longer claims a wall-clock speedup the J1 benchmark
did not show.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(services): keep only the shared startup inventory read (J1)
Drop the startup deferral gate, its kill switch and the deferred loads:
they lowered renderer.ipcCallsToFirstCard but moved neither
spawnToFirstCardMs nor load->card beyond drift, and they grew
renderer.initialBytes. Keep the shared in-flight inventory read, inline
it in PlaylistsService with short property names, and copy the joiner's
result with structuredClone (the Electron renderer has it; the services
test setup polyfills it for jsdom).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(performance): describe the shared inventory read and the J1 validation result
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(services): drop the migration receipt memo for M3U favorites
Skipping the receipt read let the dashboard ask the database for M3U
favorite channels before a just-toggled favorite was written: the write
waits in the per-playlist queue and the cross-context lock, and the
dashboard does not reload when the store's favorites are unchanged.
The extra round trip had been masking that race, and the Windows E2E
run hit it (epg.e2e.ts "dashboard live rails find a programme that only
another playlist's XMLTV carries"). The memo was off the first card's
path, so it bought nothing measurable for J1; restore the per-call read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(services): share only the startup inventory read
A pending read was shared with any concurrent caller, but not every
playlist write goes through PlaylistsService: the settings reset deletes
all playlists through DatabaseService, so a caller could join a read
taken before that deletion (Codex review). Share only the first read,
which the startup pair (playlist effect and XMLTV reconciliation) needs
while the startup screen still hides every writing action; sharing ends
when it settles or a PlaylistsService write starts.
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(settings): hide cached title matches the parental lock withholds
Cross-playlist title matches are cached by their consumers (Actor and
Discover routes, the dashboard trending and recommendation rails and
the four "similar in your portals" rails), so matches found while
unlocked kept advertising locked titles and their playlist names after
a relock. Each consumer now filters on read through a reactive
predicate on the match's provider category, so a relock hides them at
once and an unlock shows them again, without a re-query.
Closes#1723
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(settings): fall back to unlocked copies of a withheld title match
The dashboard trending and recommendation rails and the "similar in your
portals" rails picked one match per title at load, so hiding a withheld
match lost a copy of the same title in an unlocked portal. They now keep
every row the lookup returned and pick the match on read from the rows
the parental lock does not withhold. Adds the release note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(settings): derive the recommendations seed heading from the visible cards
The rail kept only the seeds that contributed a card to the original
selection, so a seed whose recommendations filled in after a relock (or
an offline prune) was missing from the "Because you watched" heading.
Every seed of the load is kept in order and the heading lists those that
contribute a card now.
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>
* perf(dashboard): hold rail skeletons back so empty rails stop shifting the page
On every launch with sources the dashboard shifted by about 0.23 (the
"good" CLS threshold is 0.1). The rails render as soon as their own data
arrives, and each loading rail showed a 328 px skeleton immediately. On a
normal profile the live-favorites and recent-content sources resolve empty
15-20 ms later, so their skeletons flashed and collapsed and every rail
below jumped up by about 360 px. J1 never saw it: its layout-shift window
ends at the first card, which is painted just before the collapse.
Rail skeletons now wait out a 300 ms grace period (createRailSkeletonGrace)
and appear only for a rail still loading after it; the hero keeps its
immediate skeleton because it reserves the top of the page. Recorded over
three renderer reloads of a seeded profile, the dashboard's layout shift
drops from 0.219-0.234 to 0.0004, with the real rails painted at the same
time as before. Plan thread C5, journey J1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): gate rail skeletons per rail, never above visible rails
Addresses the Codex and Greptile reviews on #1738. The component-wide grace
timer started when the dashboard was created, so a rail that begins loading
later (Xtream recently added, TMDB) showed its skeleton at once and could
still flash and collapse; and after the grace period a slow rail's skeleton
could appear above rails that already showed cards, pushing them down and,
if it resolved empty, back up.
createRailSkeletonGates now keeps one gate per rail, in template order: the
grace period counts from that rail's own loading start, a skeleton is never
inserted above a rail that already has cards (the real rail inserts at most
once instead), and a shown skeleton stays until its own rail finishes so the
first arriving rail does not collapse the others in a cascade. Nine specs
cover the fast path, per-rail start, the no-content-below rule, latching,
reloading, destroy and a zero grace period. The seeded-profile timeline is
unchanged at 0.0004 across three renderer reloads.
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>
* 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>