## 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>
* feat(playback): add quality selection to shared player controls
Adds a per-session video quality menu (Auto + "1080p"-style levels) to the
shared player-controls layer, mirroring the audio-track pattern:
- Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state,
setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR.
- hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with
list-index ids, smooth switching through nextLevel, selection read from
manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED,
LEVEL_SWITCHED.
- Shaka (DASH): variant tracks filtered to the active audio language, ABR
disabled before selectVariantTrack; manual state keyed to the exact player
instance so a session restart never shows a stale selection.
- Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual =
exactly one enabled level, auto = all enabled, derived statelessly).
- Embedded MPV and external players report the capability false.
The capability derives from the manifest (advertised only for >1 video
rendition), nothing persists to Settings, and the menu rides the default-off
webPlayerSharedControls rollout gate. Labels come from one shared helper so
all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to
all 19 i18n files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): pin DASH quality candidates to the active audio stream
Review findings on #1470:
- Shaka quality candidates now match the active variant's exact audioId
(language fallback only when Shaka reports none), so a DASH manifest with
same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no
longer switch the audio track or show duplicate levels when a quality is
picked. Regression test added.
- Mirror the quality-selection contract into AGENTS.md's Shared Player
Controls section, which must stay in sync with CLAUDE.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): track Video.js manual quality intent explicitly
Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off
itself when it temporarily excludes failing renditions, so inferring the
manual/auto mode from the enabled count could report a manual selection the
user never made once exclusions leave a single survivor.
VjsQualityLevels now records the picked level object as explicit manual
intent: error exclusions read as auto, a picked level that leaves the list
reverts to auto, and the bridge resets the intent on every new source.
Regression tests added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): re-enable surviving renditions when the picked level is removed
Codex follow-up on #1470: dropping manual intent when the picked
QualityLevel leaves the list reverted the UI to auto but left the surviving
renditions disabled by the earlier manual pick, pinning VHS with no
selectable rendition. Reverting to auto now re-enables every remaining
level, both on the removal event and lazily from the state read.
Regression tests added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(portals): mark a full season as watched in one click
Series detail pages on both Xtream and Stalker portals get a season-level
watched toggle next to "Download season": marking writes full-progress
rows for the unwatched episodes only (real durations survive), a fully
watched season flips the action to unwatch-all.
Persistence goes through new batch IPC channels
(DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH, one SQLite transaction with
onConflictDoUpdate().run(); the PWA data source rewrites its
localStorage blob once). Stalker deliberately bypasses the batch IPC
and loops the existing position-mutation queue so legacy-row
reconciliation still runs and the queue coalesces to a single reload;
partial failures surface a dedicated snackbar.
Also removes the dead toggleEpisodeWatched store method, splits
season-container/serial-details-playback under the max-lines cap
(season-watch-toggle.util.ts, SerialDetailsSeasonWatchService), and
classifies *.spec-data.ts fixtures under the test max-lines ceiling
(baseline shrinks by main.preload.spec-data.ts).
Closes#1442
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): guard stale season batches and split partial-unwatch feedback
Review follow-up (Codex on #1447):
- A season batch completing after the user navigated to another series
or playlist no longer writes the old series' rows into the freshly
reset position state (episode ids can collide across playlists); the
Xtream host captures the playlist/series identity before awaiting and
skips the rendered-state mutation when it changed. The DB write is
unaffected — it carries its own playlistId.
- A partially failed "mark season as unwatched" on Stalker now reports
a dedicated SEASON_MARKED_UNWATCHED_PARTIAL message instead of the
watch-direction "marked" text; translated into all 18 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): exclude the playing episode from season marking and count partial saves
Second review round (Codex on #1447):
- The episode currently playing (inline or in an external session, or
with a launch in flight) is excluded from a season's mark-watched
batch: the player persists its live position every ~15 s and would
immediately overwrite the just-written full-progress row. The button
count reflects the exclusion and the action disables when nothing is
markable. Unmarking still clears such an episode — the recreated
in-progress row reflects live playback truthfully.
- A Stalker StalkerSeriesPositionPartialSaveError (scoped watched row
saved and published, only legacy cleanup failed) now counts as a
watched success instead of feeding false total-failure feedback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): gate stale season-batch snackbars on the originating page
Third review round (Codex on #1447): a batch resolving after the user
navigated away no longer shows its contextless success/error snackbar
on the newly opened detail page — the same ownership check that guards
the state mutation now guards the feedback too.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): sync catalog progress badges after toggles and gate Stalker feedback
Fourth review round (Codex on #1447):
- Any Xtream watched toggle (single episode or season batch) now
refreshes XtreamStore.loadAllPositions after persisting — the catalog
reads series-progress badges from the store, which otherwise loads
positions once per playlist, so returning from the detail kept stale
badges. Skipped when the playlist changed mid-flight (the store then
belongs to the other playlist; its own init reloads positions).
- Stalker's season snackbars are gated on the captured playlist/series
identity, matching the Xtream ownership guard — a batch draining after
navigation no longer reports on the newly opened page.
- Stalker season-toggle specs moved to stalker-series-view.season-watch
.spec.ts with their own harness; both prior spec files sat at the
1200-line test ceiling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: describe the season watched toggle in CLAUDE.md
Fifth review round (Codex on #1447): the canonical Seasons entry in the
VOD/Series detail section now covers the bulk toggle, its playing-episode
exclusion, both persistence paths, catalog badge sync, and the
stale-completion contract.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): let only the latest positions load patch the Xtream store
Sixth review round (Codex on #1447): loadAllPositions is now
latest-load-wins — a fetch superseded while in flight (playlist switch
before getAllPlaybackPositions resolves) no longer patches the singleton
store with the previous playlist's position maps, which could leave the
new catalog showing the old playlist's progress badges.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: reflect the spec-data max-lines classification in CLAUDE.md and AGENTS.md
Seventh review round (Codex on #1447): both canonical max-lines
descriptions now list **/*.spec-data.ts among the test-ceiling globs so
future agents neither treat these fixtures as production files nor
remove the exemption unknowingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): parse "N min" durations when marking episodes watched
Eighth review round (Codex on #1447): Stalker VOD episodes report
durations like "45 min", which parseDuration could not read — bulk (and
single) mark-watched then persisted 1/1-second rows. The minute format
now parses to seconds, matching what the removed legacy store method
already handled.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): parse compound hour durations and cover the toggle end-to-end
Ninth review round (Codex on #1447):
- parseDuration now reads the compound "1h 30min" form the Xtream
fixtures emit (hour group optional, so "45 min" keeps working) —
bulk-marked episodes no longer persist a minutes-only duration.
- New Playwright coverage exercises the season toggle through the real
UI on both portals: Xtream (category → series detail → mark →
reload-persistence → unmark) and Stalker (embedded-series flow,
mark → unmark with the item's actual episode count).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): refresh Stalker catalog progress badges after watched toggles
Tenth review round (Codex on #1447): the Stalker mirror of the Xtream
catalog sync — StalkerCatalogFacadeService loads its position maps once
per playlist and the runtime bridge only pushes external-player updates,
so renderer-initiated toggles left grid badges stale. The series view
now calls the facade's new ownership-checked refreshPositions after the
season batch (including partial successes) and after single toggles;
the reload is latest-load-wins like the Xtream store fix. Optional
injection keeps collection-detail mounts outside the catalog working.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(portals): cover the season toggle batch IPC end-to-end in Electron
Eleventh review round (Codex on #1447): the new Electron E2E marks a
season through the real UI, asserts the eight SQLite rows written by
DB_SAVE_PLAYBACK_POSITIONS_BATCH directly through the preload bridge,
proves persistence with a full app relaunch (renderer and main process
die, so state can only come from the database file), and clears again
through DB_CLEAR_PLAYBACK_POSITIONS_BATCH back to zero rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): keep watched rows out of the series resume target
Twelfth review round (Codex on #1447): a watched position row — a
natural finish or a manual/bulk "mark watched" marker — is a completion
record, not resumable progress. Continue Watching no longer auto-plays
such an episode at its end; the handoff stays detail-only and the series
page's quick-start picks the first unwatched episode instead. Card
progress bars and SxxEyy badges keep their current source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): fail closed on refresh reads and gate batch APIs by capability
Thirteenth review round (Codex on #1447):
- Position-cache refreshes now use a failure-propagating read
(getAllPlaybackPositionsOrThrow through the Electron data source): a
transient IPC failure rejects instead of masquerading as an empty
list, so a populated store/facade cache stays stale-but-populated
rather than being wiped. All load/refresh call sites handle the new
rejection (init loads may retry on the next activation; post-toggle
refreshes log and keep the snackbar flow).
- The season-batch bridge methods joined playbackPositionStorageMethods,
so a bridge lacking them degrades to the in-memory path wholesale
instead of throwing mid-action.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): keep the display awake while built-in players play video
Closes#1095. The renderer tracks every playing <video> through
document-level capture listeners (element-level release listeners catch
the detached-element pause on component teardown) and, while any video
is playing and the document is visible, holds a display-sleep lock:
a main-process powerSaveBlocker over IPC in Electron — reliable on
Linux where Chromium's own video wake lock depends on DE D-Bus
inhibitors — and the Screen Wake Lock API in the PWA. The vote is
auto-cleared when the renderer reloads or dies. Radio's <audio>
deliberately never blocks display sleep; embedded MPV and external
MPV/VLC already manage their own inhibition.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): withdraw the keep-awake vote when the renderer crashes
A crash emits render-process-gone while the WebContents object stays
alive, so the destroyed listener alone missed it: without a follow-up
reload the display stayed pinned awake. Review finding by Codex.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): keep the display lock for picture-in-picture playback
Minimizing the window hides the document but leaves the PiP surface on
screen, so the visibility gate was releasing the lock mid-watch. A
tracked playing video that owns document.pictureInPictureElement now
counts as visible playback, and PiP enter/leave events resynchronize
the gate. Review finding by Codex.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): re-evaluate the wake lock after a rejection masked a state change
In the PWA path a hidden-visible round-trip (or pause/resume) while
wakeLock.request() was pending got swallowed by the in-flight guard; if
that request then rejected, only the flag was cleared and a continuously
playing visible video sat without a wake lock until the next unrelated
event. State changes arriving mid-flight now queue one re-evaluation on
rejection; permanent denials still don't loop because nothing queues a
retry without a fresh interleaved change. Review finding by Codex.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(playback): mirror the display-sleep contract into AGENTS.md
AGENTS.md carries its own playback sections (radio, shared controls,
PiP), so the keep-awake contract belongs there too. Review finding by
Codex.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): make playback keyboard shortcuts work without shared controls
With the default configuration (Video.js, webPlayerSharedControls off) the
playback shortcuts advertised in the in-app help and README — Space/K, F,
arrow seek/volume, M — silently did nothing: ControlsShortcuts only exists
inside app-player-controls, which never renders on the preference-off path.
Attach a LegacyPlayerShortcuts wrapper (same arbitration and ignore rules)
in the vendor-chrome HTML5, Video.js, and ArtPlayer players, forwarding the
commands to each engine's own API. Seek stays gated on authoritative VOD
metadata plus a finite positive duration, and a visible playback diagnostic
disables the keys. The legacy ArtPlayer chrome now passes hotkey:false —
its focus-scoped vendor hotkeys ignore defaultPrevented and would
double-handle every key — with its Escape-exits-web-fullscreen behavior
restored by the new wiring.
The playback entries in the in-app shortcut help and README drop their
embedded-MPV-only qualifier, since the keys now work in every runtime.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB
* fix(playback): restore audible volume when M unmutes at zero volume
Addresses the Codex review finding on #1398: after arrowing the volume
down to zero (which mutes), M flipped muted off while leaving the volume
at 0, so the player looked unmuted but stayed silent — in all three
legacy engine adapters.
Mirror the shared controls' ControlsVolume semantics with a per-adapter
LegacyMuteMemory: muting remembers the audible volume, and unmuting while
the volume sits at zero restores it, with the same 0.5 fallback when
nothing was remembered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(build): include shared UI stylesheets in Nx cache inputs
`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.
Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.
Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.
`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.
Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(build): spawn git without a shell in the stylesheet check
`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.
Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.
Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.
Reported by Codex review on #1360.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(styles): move nav-list partial into ui-styles (#1361)
* fix(build): count every target of a comma-separated Sass @import
`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.
Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.
The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* chore(lint): hold tests to their own max-lines ceiling
The flat 400-line cap treated a spec like a component. A spec is a flat
list of independent cases, so hitting the cap there produces arbitrary
`-2.spec.ts` splits and hides coverage instead of surfacing design debt —
65 of the 138 files over the limit were tests.
Production code keeps 400. Tests (`**/*.spec.ts`, `**/*.e2e.ts`, and
everything under `apps/*-e2e/**`) get 1200. Blank lines and comments no
longer count, so a docblock can't be the reason a file must be split.
Both limits now live in tools/eslint/max-lines-config.mjs, imported by
eslint.config.mjs and the baseline generator alike. The generator decides
who belongs on the list by running ESLint's own max-lines rule instead of
counting lines itself — a private reimplementation would disagree with the
rule the moment either side changed (a `//` inside a template literal is
enough) and yield a baseline that turns CI red while looking correct.
The baseline drops 126 -> 68 entries with nothing added, and six now-dead
`eslint-disable max-lines` directives are removed. A new eslint-tools test
asserts the committed baseline still matches what the generator produces,
so a stale entry or a forgotten regeneration fails CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(lint): classify eslint-tools in the coverage policy
A project with a `test` target must be assigned a coverage tier, so
adding eslint-tools broke `coverage:policy:check` before the unit suite
even ran. Tier B alongside packaging and release-tools: these are Node
tests over lint tooling, and a coverage percentage across a generated
list would not mean anything.
CI runs Tier B/C through its own `--run-non-tier-a` step, so the
baseline-consistency test executes there rather than being skipped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Opening an .m3u/.m3u8 file from the command line or a file association did
nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event
that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged
it as an unknown type and dropped it.
The path now belongs to the main process, which is where the OS actually
delivers it:
- argv is parsed on first launch (skipping the executable and Chromium
switches) and normalized to an absolute path;
- macOS gets an `open-file` listener registered before `whenReady`, since
Launch Services never puts the path in argv;
- the single-instance guard forwards a second launch's argv and working
directory instead of discarding them, so opening a playlist against a
running app works too.
Requests are queued in the main process until the renderer subscribes to the
`OPEN_FILE` push and drains the queue, which closes the startup race. The
import itself reuses the existing file path, so persistence, playlist-scoped
EPG and the navigation to the new playlist behave exactly like a dialog
import; a failed open now surfaces a snackbar instead of silence.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.
A second app instance sharing the same userData directory cannot take the
Chromium storage lock, so its renderer reads defaults and every write is
dropped. The app now holds a single-instance lock and focuses the running window
instead of starting a rival copy. The lock is requested after the userData
override so E2E runs with their own data dir keep independent locks, and after
Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local
CDP debugging.
updateSettings() patches in-memory state before persisting and the submit path
had no rejection handler, so a failed write produced an unhandled rejection and
no user-visible feedback. SettingsStore now records which half of the round trip
failed, and the settings page surfaces it through a dismissible error snackbar;
the dialog stays open on failure so the save can be retried.
Two follow-ups from review, both wider than the report:
- a second launch now re-creates the main window when the lock owner has none
left, so closing the last window on macOS no longer leaves a second launch
quitting silently with nothing on screen
- App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt
window, so the downloads broadcaster stops holding a destroyed window. This
also fixes the same bug on the pre-existing dock `activate` path.
Closes#1156Closes#102
* fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts
Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures
and 5 Windows-only lint errors in electron-backend while Linux CI was
green:
- embedded-mpv-native-source.spec: normalize CRLF after readFileSync so
multi-line source assertions match on autocrlf checkouts
- worker-runtime-paths.spec: build expected candidate paths with
path.join instead of hardcoded POSIX strings
- external-player-launch-context: join darwin-only paths (.app bundle
executables, Homebrew Caskroom) with path.posix.join so simulated
darwin platforms resolve correctly on win32 hosts (no-op on macOS)
- app.spec: build packaged-navigation fixtures with path.resolve +
pathToFileURL; file:///tmp/... is not a valid win32 file URL
- lint target: quote the eslint glob. The unquoted ** was expanded by
the POSIX shell on Linux (shallow match), so CI linted only a subset
of files while Windows passed the literal pattern to ESLint and
linted the full tree - the hosts checked different file sets
- fix the 5 errors full-tree linting surfaces: prefer-const in
epg-worker.service and database.worker-connection, no-unsafe-finally
in external-player-session-registry and embedded-mpv-native.service
(rewritten as catch-swallow with identical semantics, pinned by new
regression tests), intentional no-control-regex in the recording
filename sanitizer; drop stale unused disable directives
- document the quoted-glob convention in CLAUDE.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: mirror the quoted-lint-glob convention into AGENTS.md
AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from
CLAUDE.md, and it requires coding conventions to stay in sync between the two
files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping
from AGENTS.md could reintroduce a host-dependent lint target.
Also corrects the wording in both copies: the shallow expansion happens on
macOS as well as Linux — /bin/sh has no globstar on either — and the target
still exits 0 with a broken glob, which is why this went unnoticed. Adds the
file-count check that catches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>