Commit Graph
96 Commits
Author SHA1 Message Date
4gray e9eca1c386 chore(deps): upgrade Angular to 22.1 and Nx to 23.2 (#1603)
* 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
2026-09-14 19:02:40 +02:00
4gray 17b8aa309d fix(m3u): restore DASH playback from favorites and recently viewed (#1597) 2026-09-13 11:17:55 +02:00
4gray bd848aaad6 feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) 2026-09-13 10:39:14 +02:00
4gray 62655a8b5d feat(playlist): show desktop health indicators for network sources (#1592) 2026-09-12 23:05:17 +02:00
4gray a417826b01 fix(m3u): determine VOD playback independently of TMDB (#1594) 2026-09-12 22:47:54 +02:00
4gray 6e3f0d258f fix: prevent fullscreen hover and playlist refresh races (#1591) 2026-09-12 21:11:11 +02:00
4gray 7d1265d566 fix(xtream): detect HTTP portals during explicit connection tests (#1588) 2026-09-12 15:30:22 +02:00
4gray 0700ba966e fix(epg): decode gzip files wrapped in HTTP gzip (#1589) 2026-09-12 14:23:59 +02:00
4gray fadcbb4673 test(database): guard upgrades across skipped releases (#1582)
* 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
2026-09-11 07:08:45 +02:00
4gray fff022afe4 fix(ui): keep detail back navigation available while scrolling (#1576) 2026-09-10 21:34:10 +02:00
Lars Emig e76447975b feat(playback): stream-info popover in the player overlay (#1578) 2026-09-10 21:33:41 +02:00
4gray bad8a0991e feat(downloads): download completed Xtream catch-up programmes as TS (#1572)
* feat(epg): copy catch-up programme URLs without changing playback

* feat(downloads): save completed Xtream archive programmes as TS

* fix(epg): let newer archive copy requests supersede pending work

* fix(downloads): protect archive partials and independent submissions

* fix(downloads): verify archive identity through finalization

* fix(downloads): bound archive storage and capture cleanup entries

* fix(downloads): preserve archive ownership across failure paths

* fix(downloads): recover explicitly verified archive completions

* fix(downloads): journal archive promotion before publishing files

* fix(downloads): reset archive proof before an explicit restart

* fix(downloads): preserve archive recovery ownership and interruption

* fix(downloads): verify durable archive identity at resume open

* fix(downloads): fence archive commands during completion commit

* fix(downloads): persist archive ownership throughout its lifecycle

* fix(downloads): protect archive removal and missing-file recovery

* fix(downloads): journal private cleanup captures for recovery

* fix(downloads): journal active archive cleanup before removal

* fix(downloads): clean settled archives before deleting stale rows

* fix(downloads): preserve archive ownership on removal and resubmission

* fix(downloads): recover proven archive completions before retry

* fix(downloads): recover local archives before remote transfer checks

* test(downloads): resolve archive fixture from workspace root

* fix(downloads): distinguish reused archive inodes by creation time

* fix(downloads): bind fresh archive reservations to owned files

* fix(downloads): clean reservations when ownership writes fail

* fix(downloads): commit archive reservation and ownership atomically

* fix(downloads): retain captures until replacement restoration succeeds

* fix(downloads): require durable ownership before cleanup relocation

* fix(downloads): preserve 64-bit archive file identities on Windows

* refactor(release): keep capture fixture constants in their shared module

* fix(downloads): preserve the last link of captured foreign files

* fix(downloads): expose retained archive recovery files

* fix(downloads): keep recovery instructions open while copying
2026-09-08 20:33:05 +02:00
4gray 93e759e1da fix(m3u): accept standard Base64 ClearKey values (#1575)
* fix(m3u): accept standard Base64 ClearKey values

* refactor(release): move M3U fixture generation out of capture driver
2026-09-08 08:59:00 +02:00
4gray c952b55da1 feat(playback): enrich failure diagnostics and add safe support reports (#1574) 2026-09-08 08:26:26 +02:00
4gray 7f06690e72 feat(epg): copy catch-up programme URLs (#1569)
* feat(epg): copy catch-up programme URLs without changing playback

* fix(epg): let newer archive copy requests supersede pending work
2026-09-08 07:29:11 +02:00
4gray 9f950a1530 fix(migration): show startup preparation and recover source read failures (#1568)
* fix(migration): show startup preparation and recover source read failures

* fix(migration): keep inventory reload failures recoverable

* test(migration): type the deferred recovery hook

* fix(migration): reconcile failed EPG cleanup before recovery

* fix(migration): await settings before inventory readiness

* style(migration): add a subtle theme-aware startup watermark
2026-09-07 21:24:34 +02:00
4grayandClaude Fable 5.1 97b0264dee fix(xtream): render catch-up start times in the panel timezone (#1563)
* 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>
2026-09-07 19:40:47 +02:00
4gray 15c2ac1f39 feat(packaging): add AppManager discovery metadata to AppImages (#1559)
* feat(packaging): add AppManager discovery metadata to AppImages

* test(xtream): restore live queue URL service mock

* test(xtream): extract live layout component stubs
2026-09-06 18:47:38 +02:00
4grayandClaude Fable 5.1 0a2373f192 feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)
## 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>
2026-09-06 14:42:59 +02:00
4gray 436825bdec fix(xtream): try advertised TS after initial web HLS HTTP failure (#1558)
* 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
2026-09-06 11:00:09 +02:00
4gray 61b06b9f31 fix(portals): preserve live channel navigation while browsing (#1554)
* 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
2026-09-06 10:22:07 +02:00
4gray 5a8c5ca4a4 fix(stalker): keep live search within the selected category (#1552)
* 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
2026-09-06 09:05:21 +02:00
4gray 5febe28eba fix(web-backend): validate and pin provider redirect hops (#1553)
* fix(web-backend): validate and pin every provider redirect hop

* fix(web-backend): separate provider metadata from connection authority
2026-09-06 08:59:28 +02:00
4gray 9de480826c fix(epg): remove cached XMLTV data after source deletion (#1548)
* fix(epg): remove cached XMLTV data after source deletion

* fix(epg): close source reconciliation review races

* fix(epg): serialize cleanup with replacement imports

* fix(epg): report retired worker exits as cancellations

* fix(epg): preserve source metadata through cache cleanup

* refactor(epg): separate worker runtime and import lifecycle

* fix(epg): skip cleanup for unchanged source settings

* fix(epg): cancel retired error rows and pending retries

* fix(epg): redact diagnostics and mirror committed settings after cleanup errors

* fix(epg): preserve metadata writer order independently of timestamps
2026-09-06 07:32:30 +02:00
4gray 9bcdbc0efb fix(migration): preserve and recover legacy desktop sources (#1550)
* fix(migration): recover legacy desktop sources without replacing current data

* test(migration): cover legacy recovery IPC contracts

* test(migration): use static legacy Electron bootstrap
2026-09-06 00:45:35 +02:00
4gray e40f31db97 fix(host-health): retain trial ownership until requests settle (#1547) 2026-09-06 00:43:54 +02:00
4gray d9d6f49757 feat(playback): slide-in channel list for fullscreen playback (#1519) 2026-09-05 17:00:46 +02:00
4gray 8eda5370eb fix(playback): apply themes to player and EPG panels (#1541)
* 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
2026-09-05 15:32:22 +02:00
4gray eb602db5fc fix(ui): restore channel and detail keyboard scrolling (#1542)
* fix(ui): restore channel and detail keyboard scrolling

* test(ui): drag below the Windows scrollbar arrow
2026-09-05 15:02:57 +02:00
4gray 0ba5107561 fix(m3u): use custom User-Agent for URL import and refresh (#1535) 2026-09-05 14:49:23 +02:00
4gray 79f3f6c897 fix(playback): close legacy picture-in-picture on video replacement (#1538)
* 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
2026-09-05 14:02:55 +02:00
4gray eba68d687e fix(xtream): scope category bulk actions to search results (#1534) 2026-09-05 12:39:57 +02:00
4gray 0245d73d78 feat(portals): make connection cooldown configurable in desktop settings (#1536) 2026-09-05 11:18:05 +02:00
4grayandClaude Fable 5.1 b2ca85172c fix(playback): seek Embedded MPV steps relative to mpv's own position (#1518)
* 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>
2026-09-04 17:36:54 +02:00
4grayandClaude Fable 5.1 52b33fe5a3 fix(release): pass the keychain password to set-key-partition-list on macOS
`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>
2026-09-04 17:07:27 +02:00
4grayandClaude Opus 4.8 fe3c86394c fix(playback): keep Video.js vendor-chrome shortcuts after a mouse click on a control (#1523)
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)
2026-09-04 16:36:29 +02:00
4grayandClaude Fable 5.1 308ed9cb41 fix(playback): keep playback shortcuts after a mouse click on a bar button (#1516)
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>
2026-09-03 21:42:08 +02:00
4grayandClaude Fable 5.1 4f723b63a0 fix(playback): auto-hide shared controls after a mouse click on a bar button (#1512)
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>
2026-09-03 13:06:46 +02:00
4grayandClaude Fable 5.1 0a2f6121f8 fix(playback): keep fullscreen across episode, channel and source switches (#1509)
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>
2026-09-03 08:21:37 +02:00
4gray 740b784268 feat(playback): make the shared player controls the default (#1408) (#1485) 2026-08-29 21:24:44 +02:00
4gray f04f67728e ci(embedded-mpv): keep Windows runtime pin available (#1495) 2026-08-29 21:24:06 +02:00
4gray 29ca94aa43 feat(release): announcement formats, highlight cards, and draft verification (#1480) 2026-08-29 10:16:07 +02:00
4gray 069b8b3cc9 feat(playback): advanced subtitle support in shared player controls (#1471) 2026-08-23 13:57:46 +02:00
9494643a8d docs(agents): re-run pnpm install after the checkout moves (#1473)
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>
2026-08-23 08:56:49 +02:00
50871e581f feat(playback): add quality selection to shared player controls (#1470)
* 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>
2026-08-23 04:39:39 +02:00
4grayandClaude Fable 5 7fc9380bff feat(portals): mark a full season as watched in one click (#1447)
* 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>
2026-08-15 21:03:14 +02:00
4grayandClaude Fable 5 dded17010d fix(playback): keep the display awake while built-in players play video (#1405)
* 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>
2026-08-12 00:23:03 +02:00
4gray de77c6d467 fix(playback): update mpegts.js to 1.8.1 (#1412) 2026-08-11 03:15:08 +02:00
4gray 77842b9d04 fix(playback): update Shaka Player to 5.2.4 (#1411) 2026-08-11 03:14:03 +02:00
4gray 728df1a68c fix(packaging): restore Snap desktop runtime (#1406)
* fix(packaging): restore Snap desktop runtime

* docs(packaging): publish Snap launch repair note

* fix(packaging): declare Node 22.12 floor

* docs(architecture): update SQLite pin rationale

* fix(tooling): align Node engine floor

* fix(tooling): constrain supported Node releases

* docs(architecture): correct node-abi consumer
2026-08-11 02:08:30 +02:00