Live channels in Favorites and Recently viewed now offer "Open in <playlist>"
for Stalker portals, the counterpart of the VOD "View in portal" action. The
chip in the programme panel and the channel's context menu jump to the channel
inside its portal's Live TV, with its genre selected and the channel playing.
Radio stays hidden: it is a separate legacy-paged section with no
open-on-arrival contract.
The handoff refuses to guess. The store records which genre the rendered ITV
list belongs to, so the deferred play waits for a list that can actually serve
the channel instead of inferring it from array identity. An id claimed by one
channel as its `id` and another as its `stream_id` is ambiguous, so neither is
played and the user still lands in the right genre. The handoff is abandoned
when the user changes genre, search, portal or section first.
Two pre-existing defects surfaced during review and are fixed here:
- A blank provider id shadowed a valid one. `a ?? b` keeps an empty string, so
a channel with a blank `stream_id` was stored with no identity at all and
could not be selected, played or found again. Six writers had that shape and
three private copies of the skip-the-blank rule; all now go through one
`firstNonBlankStalkerId` helper.
- Route-session readiness could publish before the store held the portal's
row. The constructor starts one sync and the first navigation starts another,
and the second skipped the bootstrap because the playlist id was claimed
before the awaits that install it. Arrivals are now serialized, the id is
claimed only after the store write resolves, and only the newest sync
publishes readiness.
Both fixes carry regression tests that fail on the old behavior.
A whole-file `prettier --write` on CLAUDE.md reflowed passages unrelated to
the change in #1628 (a nested list item lost its indentation, a `+ player`
continuation line became a `- player` bullet) and Greptile flagged the diff
as corrupted guidance. Record the rule in the Agent Bootstrap section of
both mirrored files, with the merge-base restore as the recovery path.
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>
Movie and series detail pages showed two arrow_back controls while the
inline player was open: the shell's sticky arrow (added in #1576 so Back
survives scrolling) meant "Close player" in watch state, while the
now-playing bar carried a second arrow that meant route-level Back — the
same icon with two meanings, next to a "Close player" button that
duplicated the first.
The shell's sticky arrow is now route-level Back in browse and watch
alike, and the bar carries no arrow of its own. Closing the player is
the bar's "Close player" button and Escape, which still unwinds one
level (close, then back). Hosts without a browse Back target (M3U,
downloads) render no arrow in either state.
Unit specs for the shell and the inline player cover the new contract;
the Electron and web E2E helpers that pressed Back from watch are
updated, and the M3U flow closes the player through the bar's button.
Docs, the mirrored CLAUDE.md/AGENTS.md paragraph and a release note
follow the change.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Series playing in fullscreen get the same slide-in side panel the live channel list has, with season tabs and the episode list: rest the mouse on the left edge, click it, or press C; pick an episode and it plays inline without leaving fullscreen.
- Panel contract: `FullscreenChannelPanelHost` gains optional `panelSearchEnabled` and `panelKind`; the template context gains `open`. Pointer/keyboard rules and the four live providers are unchanged.
- Series host: `PortalInlinePlayerComponent` provides the token through `createEpisodePanelHost()` and stamps `app-fullscreen-episode-panel` (SeasonTabsComponent over rows with TMDB still or numeral tile, label, runtime, clamped overview, progress, watched check, now-playing marker; playing row centred on open). Episode clicks reuse the Up Next rail's inline path; season tab clicks reach the hosts' `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD load with a Retry row after a failed request).
- Gates: `Settings.fullscreenChannelPanel` (label now covers both lists in all locales), episode content only, native-view Embedded MPV withheld by the view, external players excluded.
- Inline-series e2e moved to `xtream-series-playback.e2e.ts` with shared Xtream helpers in a fixture; adds a fullscreen episode switch through the panel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* 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>
* 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
* docs(database): require upgrades across skipped releases
* test(database): cover direct upgrades from 0.19 through 0.23
* test(database): verify upgraded schemas against current contract
* fix(xtream): render catch-up start times in the panel timezone
The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).
- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
from the `time_now` / `timestamp_now` clock pair, so spellings such as
`UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
when unchanged) and project it back from the payload in
`DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
+03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
Global favorites, and the clock-pair derivation at a UTC-3 viewer.
Closes#1562
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates
Review follow-ups (Greptile):
- A source switch while `get_account_info` is in flight no longer hands
playlist A's status or clock to playlist B: the store is patched only
while the asking playlist is still selected, the timezone is persisted
under the asking playlist's id regardless, and a late failure cannot mark
the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
by every account-info check and only ever used for non-standard servers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop a panel clock that no longer belongs to the source
Review follow-ups (Codex + Greptile):
- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
server drops the persisted `serverTimezone` (payload-only) until the next
account-info check, so Favorites / Recent cannot keep rendering the OLD
panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
at the panel it came from — an edit that moved the source during the
request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
timezone into the store playlist, so a later response without a usable
clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop the stale panel clock inside the UPDATE statement
Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(xtream): split the server-clock primitives out of the timezone util
Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): offer the learned panel clock to storage on every check
Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): apply an account-info answer only to the panel it came from
Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): never report another panel's status for the selected playlist
Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): persist the panel clock with one conditional UPDATE
Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.
Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:
- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
that `json_set`s the payload only while the row still points at the
request's connection and does not already carry the value; a malformed
payload is never rewritten (CASE, not AND, so json_extract cannot run
before json_valid). Wired through the worker types, main handler,
preload, bridge interface, both IPC contract tables and
`DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
readwrite cursor transaction, plus the localStorage copy.
The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): keep the stored panel clock across clockless full upserts
Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(release): split capture-navigation under the max-lines cap
`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:
- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
alternative sources (the two identical live-category flows share one
helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
the re-exported API the seeding driver and the capture script import
Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(electron): move the panel-clock SQL into its own operations module
Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
## Summary
Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together:
1. **Categories + channels + player** (browse, unchanged).
2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back.
3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1.
Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session.
## Design notes
- Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex).
- The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area.
- `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider.
- M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched.
## Merged with #1555 (per-surface rail state)
#1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree.
## Also fixed along the way
- The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now.
- A collapsed context panel left a 22px padding strip beside the channels rail.
- The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales).
Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): try advertised TS after initial web HLS HTTP failure
* refactor(playback): extract fullscreen channel panel state
* test(xtream): keep synthetic media within the mock project
* fix(portals): preserve live channel navigation while browsing
* test(portals): await media source assertion in remote E2E
* fix(xtream): capture destination queue for live auto-open
* fix(stalker): keep live search within the selected category
* test(stalker): assert retained video ownership without source timing
* test(stalker): distinguish paged All Items from the initial cache grid
* fix(stalker): reveal remote selections in uncached search results
* test(stalker): wait for category rows and retain settled playback
* fix(migration): recover legacy desktop sources without replacing current data
* test(migration): cover legacy recovery IPC contracts
* test(migration): use static legacy Electron bootstrap
* fix(playback): apply themes to player and EPG panels
* test(playback): verify active recording icon theme
* fix(epg): keep loading shimmer visible in both themes
* fix(playback): close legacy picture-in-picture on video replacement
* test(playback): wait for the selected video before PiP setup
* test(playback): await changed settings before PiP navigation
* fix(playback): release legacy WebKit picture-in-picture
* fix(playback): seek Embedded MPV steps relative to mpv's own position
Arrow keys and the ±10 s buttons in the Embedded MPV player advanced only
about a second per press when pressed repeatedly or held. The shortcuts
already asked for 5 s steps, but `EmbeddedMpvCommandRunner.seekBy` turned
each step into an absolute `seek` computed from `session.positionSeconds`,
which is floored to whole seconds, polled every 500 ms (helper snapshots at
most every 250 ms) and not refreshed by the seek reply. Every press inside
that window therefore landed on the same target.
Steps now go through a new `EMBEDDED_MPV_SEEK_BY` IPC / `seekEmbeddedMpvBy`
bridge method that every backend forwards as mpv `seek <delta>
relative+exact`: `seekBy` exports in the macOS addon and the Windows/Linux
`wid` addon (Linux over its JSON IPC socket), and a `seek-by` stdin command
in the frame-copy helper. mpv resolves the delta against its own position
and merges queued relative seeks, so presses accumulate as in mpv itself.
The absolute form survives only as a fallback for a preload without the
method or an addon binary without `seekBy`; the timeline scrub still
commits an absolute target.
Validated with a real mpv 0.39 IPC probe: three relative seeks in a burst
advance +15 s, three absolute seeks from one stale base advance +5 s.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): drop speculative position update from relative Embedded MPV seeks
Review follow-up for the relative seek path.
The macOS and Windows/Linux `seekBy` exports advanced `snapshot.positionSeconds`
by the delta after dispatching the mpv command. That is not idempotent the way
the absolute seek's optimistic write is: the observer (mpv event thread, or
the Linux IPC poll) can already have stored the post-seek `time-pos` under the
same mutex, so adding the delta on top counted the step twice, and while paused
nothing corrected it. On Linux it also advertised a position that a failed
socket delivery never reached. Relative steps now leave the snapshot alone;
only the observed `time-pos` updates the position.
The packaged Linux frame-copy smoke now drives `seekEmbeddedMpvBy` through the
built app: a burst of three +2 s steps issued without waiting for snapshots has
to land on 6 s, and a -60 s step has to clamp at 0. The generated Y4M fixture
grows from 2 s to 12 s (about 415 KB) so the burst and the playing section that
follows stay inside the clip. Replayed against a local mpv 0.39 with the same
fixture and media server: burst -> 6.0, -60 -> 0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(agents): mirror the Embedded MPV relative-seek contract into AGENTS.md
Review follow-up: the Shared Player Controls section documents the frame-copy
commands and shortcuts, so the relative seekEmbeddedMpvBy invariant lives
there too, next to the CLAUDE.md note.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): reject a Linux relative seek the mpv IPC socket did not accept
Review follow-up: the Linux branch of SeekBy discarded the socket transaction
result and returned normally, so a step that never reached mpv looked like a
seek still awaiting observation. It now throws like a failed mpv_command_async
on the in-process engines; the renderer swallows the rejection and resyncs
from the next snapshot, and the main process logs it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
`Build on macos arm64` started failing on master with
security set-key-partition-list -S apple-tool:,apple: -s -k *** <tmp>.keychain
SecKeychainUnlock: The user name or passphrase you entered is not correct.
Every failure ran the `macos-26-arm64` runner image 20260831.0337; the same
job on image 20260728.0273 kept passing, as did `macos-15-intel`. The image
exposed an electron-builder bug: `app-builder-lib` hands the certificate's
`.p12` import password to `set-key-partition-list -k`, which authenticates
against the temporary keychain and therefore needs the keychain's own
generated password. Older macOS builds accepted the wrong password once the
keychain was unlocked; the new one verifies it.
Upstream fixed this in electron-userland/electron-builder#10101 (master,
v27 alpha) and backported it in #10172 to release/v26 on 2026-09-03, but no
26.x containing it is published (26.16.0 predates the backport, #10167).
Apply the backport to the installed 26.15.7 as a pnpm patch, guarded by
`tools/dependencies/app-builder-lib-keychain-password.test.mjs`: it checks
that the patched version is the installed one, that the compiled source
passes `keychainPassword`, and — with `security` recorded — that
`createKeychain` unlocks the partition list with the password it gave
`create-keychain`, not the import password. The test fails 2/3 on the
unpatched code. CI runs it next to the Vite patch guard; CLAUDE.md and
AGENTS.md document when to retire the patch.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #1516 for the vendor-chrome path (shared controls opted out). With
Video.js's own controls, Chromium leaves a clicked control-bar button focused,
and a focused Video.js component captures the keyboard entirely, so after
clicking fullscreen Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until the user clicked the video. ArtPlayer
and the native HTML5 controls were verified unaffected.
The legacy Video.js chrome now releases the focus a pointer interaction leaves
on a control (vjs-pointer-focus-release.ts). The release is scoped to the
.vjs-control-bar and pointer-attributed, and runs on both focusin (focus
landing on a control, e.g. a menu handing focus to its button) and click (a
control clicked while already focused, which fires no focusin); keyboard Tab
focus and modal-dialog focus traps are preserved. The eligibility helper is
shared with ControlsSurface via pointer-focus-release.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Chromium focuses a clicked <button>, and a focused control captures the
keyboard: Space and Enter activate it again, and ControlsShortcuts yields
to any interactive element in the key's path. After a click on the
fullscreen button, Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until a click on the video took focus
away. Follow-up to #1512, which stopped that focus from pinning the bar
but left it on the button.
A completed pointer click now releases the focus it left on the control
(onBarClick -> ControlsSurface.releasePointerFocus). The click is
attributed by its pointerType (empty for Enter/Space activation and
element.click()), with the legacy MouseEvent fallback answered once per
recorded press, so keyboard activation keeps focus where Tab put it.
Only buttons and range sliders are released. Chromium keeps its
sequential-focus starting point at the blurred control, so a later Tab
continues from it. The release dispatches a focusout while the pointer
still rests on the control, so the volume anchor ignores it instead of
closing the popover under the hovering mouse.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Chromium focuses a clicked <button>, so the shared controls bar treated every
mouse click on a control (fullscreen, mute, ...) as keyboard navigation and
pinned itself open until a click on the viewport took focus away — a click
that also paused playback. Most visible on Embedded MPV frame-copy after
entering fullscreen; reproduces on HTML5, Video.js and ArtPlayer too.
Only keyboard-originated focus pins the bar now: pointer-attributed focus
reveals without a pin, the press record is discarded on the first bar focus
event or any keydown, a pointerdown inside the bar releases a keyboard pin,
and a keydown bubbling out of a bar control re-pins it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
WebPlayerViewComponent remounts the engine component for every playback
application, and the DOM Fullscreen API exits the moment its element leaves
the document. The fullscreen element was the engine shell, so every next-
episode click, autoplay hand-off, channel zap and alternative-source switch
dropped the viewer back to the page.
app-player-controls gains a `fullscreenTarget` input; HTML5, Video.js,
ArtPlayer and Embedded MPV forward it, and WebPlayerViewComponent passes its
own host element, which spans all applications of one mount. Keeping
fullscreen exposed a latent bug: the Electron header handoff set plain
fields under OnPush hosts and was only rendered thanks to the fullscreen
exit's stage resize; `channel`/`vjsOptions` are signals now.
Covered by unit regressions (fullscreen target, WebPlayerView remount, OnPush
handoff), a web-e2e run through a manual and an automatic episode switch, and
a manual Electron check. Docs and release note updated.
Closes#1498
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A node_modules tree installed at an older commit keeps serving the old
dependency versions after git pull/reset/rebase moves the checkout,
because git rewrites pnpm-lock.yaml but never re-links node_modules.
Document the trigger and the cmp-based staleness check in the Agent
Bootstrap section of both CLAUDE.md and the mirrored AGENTS.md.
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>