Commit Graph
2831 Commits
Author SHA1 Message Date
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
4grayandClaude Fable 5.1 98da686cea feat(website): add the phone remote control guide
New guide at /blog/remote-control-guide/: enabling the remote in Settings,
opening it on a phone from the QR code, what each control does and which
list it navigates, a checklist for a page that does not load, and why the
remote must stay on the local network. Eight FAQ entries. The remote-control
feature page now links to it instead of the M3U guide.

Both screenshots are mock-backed. The phone view is the first "browser" shot:
a manifest entry names a loopback URL and a mobile viewport, and the capture
frames it in a separate Chromium page behind the same network and content
guards, with the manifest validator accepting loopback origins only. The
setup saves the remote-control setting so the app's own server answers, then
selects a live channel; the Xtream mock's marketing scenario now serves live
stream URLs from local bytes so that selection never leaves the machine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:06:23 +02:00
4grayandClaude Fable 5.1 c7f1784dbd feat(website): add the alternative sources guide
New guide at /blog/alternative-sources-guide/: where the Sources chip comes
from (a local lookup across the reader's own Xtream playlists, never a search
outside them), how to read fact tags versus ~guesses, check availability,
switch playlists mid-film without losing the timecode, pin a preferred copy
per movie, and what the opt-in auto-switch does and on which players. Eight
FAQ entries, opening with the general ContentDisclaimer.

The two screenshots are mock-backed: the Xtream mock gains a marketing2
scenario that serves the identical marketing catalog under a second
credential pair (with a spec proving the catalogs match), the capture seeds it
as a "Fictional Xtream Backup" source only for shots that walk into it, opens
its category so the movies reach the local content cache that discovery reads,
and two new setup actions open the chip and the checked popover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 20:59:49 +02:00
4grayandClaude Fable 5.1 0d03140661 feat(website): add the offline downloads guide with a reusable content disclaimer
New guide at /blog/offline-downloads-guide/: what the desktop download manager
can save, choosing the folder, downloading a movie, episodes and seasons,
following the queue (pause, resume, automatic reconnects), the offline
library, and the Needs attention states, with a nine-question FAQ. The prose
frames the feature as offline viewing of content the reader already streams
and defers legality to the provider's terms and local law.

ContentDisclaimer.astro carries that notice in a general and an offline
variant so later guides reuse it instead of rewording it.

The three screenshots are mock-backed captures. The Xtream mock's marketing
scenario now serves movie and episode stream URLs from generated local bytes
(downloadStreamFixture: 'local-media'), because the capture's network gate
rejects the public HLS stub every other scenario redirects to; the capture
stubs Electron's folder dialog so "Change Folder" authorizes a folder inside
the isolated data dir rather than the real OS Downloads folder; two new setup
actions queue a movie and two episodes and open the manager and the offline
detail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:41:10 +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 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): add programme guide implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add window-scoped guide programme queries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): render the guide predicate in tests and document its scope

Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): expose guide programme and coverage reads over the bridge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): separate coverage chunk size in the guide plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide source contract, day layout maths and preferences

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): key guide IPC answers by trimmed, present keys only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): guide search hits carry a row id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide geometry DST-safe and tighten the contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): cache guide programmes per day with batched loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide keyboard navigation controller

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide programme cache robust to first-run effects and coverage failures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add the programme guide grid components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add a Guide button to the timeline toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): adapt the playlist channel list to the guide contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): guard the guide's initial group scope and track language changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): open the programme guide in place with a docked player

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): remove the multi-EPG overlay and the channel-range IPC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): document the programme guide and its release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* i18n(epg): translate the programme guide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): let the guide own the keyboard and gate its entry points

While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): cover the programme guide flow

Imports a two-channel playlist with XMLTV, opens the guide from the
timeline toolbar and asserts the row list, the "Only with EPG" filter, a
channel switch that keeps the guide open, the hidden-but-mounted sidebar,
and that the player element survives both the mode and channel switches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(epg): tidy guide docs, palette gating and the unbound output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): match guide favorites by channel URL and skip re-activating the playing row

Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(epg): let the guide window predicate use the programme time index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): make guide row ids collision-proof and gate the G shortcut

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): describe guide row ids as scope-local

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers

The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +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
4grayandClaude Fable 5.1 0dcfba7045 fix(live-tv): keep a hidden channel list discoverable and scoped per surface (#1555)
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface

The second report in #1458 ("all channels disappear after clearing the
playback history, reset does not bring them back") was not data loss: the
history write never touches playlist items. The reporter's screenshot shows
a collapsed channel rail, a state persisted under one localStorage key
shared by the M3U player, the Xtream/Stalker live layouts and the
favorites/recent live tab. It survived restart, "Remove all playlists" and
re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B.

- LiveLayoutSidebarStateService keeps the state per surface (m3u / portal /
  collection) under live-sidebar-state:<surface>; the M3U player now goes
  through the service instead of its own signal. The legacy shared key is
  forgotten on startup and never read, so the update itself restores the
  list for everyone who got stuck.
- The workspace header renders a view_sidebar toggle on every route that
  renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so
  the control exists in both states instead of disappearing with the rail.
  Collection pages keep their own toggle beside the content switch.
- While the rail is collapsed and nothing plays, every live host shows
  app-channel-list-hidden-state (title, shortcut hint, full-size "Show
  channels list" button) instead of asking to pick from a list that is not
  on screen. app-portal-empty-state gained optional hint/action inputs.
- New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales.

Tests: service, empty-state, hidden-state and header component specs, a
separate video-player-sidebar spec (the main M3U spec sits at the test
line budget), and an Electron E2E covering history clearing, restore via
button/header/shortcut across restart and re-import, per-surface scoping
against an Xtream portal, and legacy-key cleanup.

Refs #1458

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(live-tv): mirror the EPG offset setting in the sidebar spec mock

Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the
new sidebar spec was cloned from the movie-gate harness before that field
landed, so its playing-channel case threw inside the EPG effect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(web-e2e): scope the Stalker radio rail toggles to the rail

The workspace header now carries a second "Hide/Show channels list" toggle,
so the role+name locators matched more than one button and tripped
Playwright's strict mode. Target the rail's own chevron and the floating
restore button, and assert the header toggle mirrors the state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones

Codex review follow-ups on #1555:

- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
  collection page had no handler; only the routed M3U/Xtream/Stalker live
  layouts did. The page now toggles the collection surface while its live
  tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
  switcher, search and Add; the live rail is a bottom drawer with its own
  toggle there, so the header rail toggle is hidden below 640px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API

master (#1554) added `XtreamLiveChannelNavigationService` and
`stalker-live-navigation.ts`, which expand the rail through
`sidebar.setState('expanded')` on the pre-split signature. Point them at the
`portal` surface and update their specs; drop the now-unused hidden-state
stub from the Xtream layout spec, which master pushed to the max-lines
budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:48:58 +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
4grayandClaude Fable 5.1 ef2418a2c5 feat(epg): compact timeline toolbar with icon-only Now and cycling zoom (#1557)
* feat(epg): compact timeline toolbar with icon-only Now and cycling zoom

Replace the 96px zoom range slider with a single icon button that cycles
day overview → by hour → detailed, make the Now button icon-only (label in
tooltip + aria-label), and add Ctrl/⌘ + wheel zoom around the cursor on the
ribbon (preventDefault keeps Chromium from page-zooming the same gesture).
Zoom math moves into TimelineZoomController so the component stays under
the max-lines limit. The channel/programme heading no longer carries a 60%
width cap and takes the space the controls free up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): coalesce wheel-zoom anchoring and make the e2e zoom reset deterministic

- TimelineZoomController keeps a logical pending scrollLeft across a burst
  of wheel/pinch events and applies it in one animation frame, so every
  event anchors on the same minute instead of the stale DOM position
- the e2e settles on the detail preset from whichever band the wheel
  landed in (contrast checks need the block's time line, hidden at
  narrower tiers)
- zoom tooltip advertises ⌘/Ctrl per the repo's shortcut convention

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 10:34:28 +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
4grayandClaude Fable 5.1 baba0529ef feat(website): rebuild the hero, features, download and support sections (#1551)
* feat(website): rebuild the hero, features, download and support sections

Third landing redesign PR: the home page now speaks the app's own
language instead of a template's.

- Hero: left-aligned headline, a primary button that follows the
  visitor's OS (server-rendered fallback goes to /download/), an "All
  platforms" button and a text link for self-hosting. Project numbers
  (stars, downloads, languages, license) are set in the page's own
  typography from the GitHub API at build time (`lib/github-stats.ts`);
  anything the build cannot resolve is left out rather than faked. A
  small "Now playing" card with fictional demo content replaces the
  mascot, badges and social chips; the screenshot is lit by a blurred
  copy of itself, like the player's ambient mode.
- Features: a bento of interface fragments (EPG rows with progress,
  posters with a resume bar, a download queue with a REC dot, a remote
  D-pad) instead of numbered cards with icon watermarks and a marquee.
  The marquee CSS is gone.
- Download: one row per platform with the release's file names and a
  "Detected" badge for the visitor's OS (`lib/detect-platform.ts`),
  package-manager commands in the same panel.
- Support becomes a single row; the disclaimer moves into the footer,
  which also gets the mascot and a link list.
- Home sections drop the decorative eyebrows and the divider rules; the
  unused broadcast-wave, support-signal and watermark illustrations are
  removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(website): cover the rebuilt home sections and the OS-aware download CTA

`website-home-sections.test.mjs` (in the website test target) reads the
built home page — generic hero CTA to /download/, project facts, no
badge images or dashed borders, every feature page linked from the
bento, one download row per platform with release file names and the
Detected badge hidden, the disclaimer and mascot in the footer — and
then drives it in Chromium under Windows, macOS, Linux and Android user
agents: the primary button and the Detected row follow the platform,
the phone keeps the generic markup, and the copy button writes the
command to the clipboard.

`detectPlatform()` now consults the user-agent string before the
client-hints platform and the deprecated `navigator.platform`, so the
source a visitor's tools actually change decides first; silent sources
fall through instead of vetoing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): keep iPads on the generic download path

iPadOS asks for desktop sites with a Macintosh user agent and a
`MacIntel` platform, so the OS guess sent iPad visitors to the macOS
installers. A "mac" verdict is now trusted only on a device without
touch points: no Mac has a touch screen, every iPad reports several.
The browser test adds an iPad-in-desktop-mode row to the generic-device
matrix next to the Android phone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:51:46 +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 002faafbd7 fix(xtream): improve sync overlay contrast in both themes (#1546) 2026-09-06 00:44:08 +02:00
4gray e40f31db97 fix(host-health): retain trial ownership until requests settle (#1547) 2026-09-06 00:43:54 +02:00
4gray 0140146716 fix(ui): restore detail surface boundaries in light theme (#1549)
* fix(ui): restore detail surface boundaries in light theme

* test(ui): measure detail action edges over rendered artwork
2026-09-06 00:41:53 +02:00
4grayandClaude Fable 5.1 eb96b4c9f2 feat(website): turn the screenshot showcase into a channel switcher (#1540)
* feat(website): turn the screenshot showcase into a channel switcher

Second landing redesign PR. "See it in action" was a tab strip in a
dashed frame showing one screenshot inside a drawn window chrome that
duplicated the chrome already in the shot. It is now a channel list:
number, screen name and one line about what the screen is for on the
left, a single frame on the right, and a caption linking to the matching
feature page.

- Channels advance on their own with a progress hairline under the
  active row; hovering, focusing or scrolling the block out of view
  pauses it and `prefers-reduced-motion` disables autoplay entirely.
- A brief on-screen "CH 03" badge confirms every switch.
- Arrow keys, Home and End move between channels; the list is a proper
  vertical tablist with roving tabindex, panels carry `aria-hidden`.
- Six screens: Dashboard, Live TV, Program guide, Movies & series,
  Downloads, Settings. Same files from `public/screenshots`; the
  add-playlist shot is replaced by the movie detail and the download
  manager.
- On phones the screen comes first and the list follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(website): cover the channel switcher, keep focus pausing after mouseleave

Hover and focus now pause autoplay independently: clicking a channel
focused it, but moving the pointer away resumed the timer and seven
seconds later the selection moved under a still-focused tab.

New `website-screenshot-showcase.test.mjs` (in the website test target):
a structural half over the built HTML (vertical tablist, roving
tabindex, one aria-hidden panel per channel) and a browser half that
serves the build and drives it in Chromium — autoplay advances, hover
pauses, click + mouseleave keeps the pause while focused, arrow/Home/End
keys move selection, focus, panel, caption and badge together, blur
resumes, and `prefers-reduced-motion` disables autoplay. The browser
half falls back to the system Chrome channel and skips when no Chromium
exists, so the structural checks run everywhere.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(website): lazy-load every showcase screenshot

The block sits below the hero and the feature grid, so an eager first
frame only competed with above-the-fold assets for visitors who never
scroll to it. Native look-ahead loading brings it in well before the
switcher is on screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(website): share the browser harness, fail in CI without Chromium

`website-browser-support.mjs` owns the loopback static server and the
Chromium launcher for the website browser tests. The server resolves
every request against the build root and answers 404 for anything that
escapes it (CodeQL js/path-injection on the previous inline copy). The
launcher tries the Playwright download, then the system Chrome and
Chromium channels; when none launches it returns null locally so the
structural half still runs, and throws under `CI` so the interaction
assertions can never turn into a silent skip on the runner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(website): defer inactive showcase frames, document the browser tests

The six frames are stacked with opacity, so native lazy loading treated
all of them as near-viewport and fetched every screenshot at once. Only
the first frame now ships with a `src`; the switcher assigns it from
`data-src` when a channel is shown and preloads the one after it, so at
most two frames are ever in flight. The showcase test asserts the
markup and the runtime behaviour.

The website README now describes the browser-dependent suites, the
shared harness, and the skip-locally / fail-in-CI rule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(website): describe only the suite this PR adds

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): resume the switcher from where it paused, keep DOM order on phones

The dwell clock now stops while the switcher is hovered or focused and
the pause time is added back on resume, so the progress hairline
continues from its frozen position instead of snapping to zero and
granting a fresh seven seconds. The test asserts the resume.

On phones the list stays before the screen in the DOM and on screen
(tabs before their panels, focus order equals reading order); it hides
the per-channel descriptions and the keyboard hint there so the screen
stays close instead of being reordered with `order-first`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): never crop a showcase frame

The screen box stretches to the channel list's row height, and with
`object-cover` a wide screenshot lost its right side just above the
`lg` breakpoint. Frames are now contained on a dark stage (a letterbox,
as on a TV), and the per-channel descriptions are hidden between `lg`
and `xl` so the row stays close to the frame's own height.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): pause the switcher dwell while offscreen instead of resetting it

Leaving the viewport is now a pause like hover and focus: the frame
loop stops, the progress hairline keeps its width, and the clock
resumes from the same mark when the block scrolls back in. Only a
channel change resets the dwell. The interaction test scrolls away and
back to assert it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(website): no next-frame prefetch when reduced motion disables autoplay

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(website): persistent pause control for the channel switcher

Hover and focus only pause while they last, which is no use to touch or
screen-reader visitors, so the list footer now carries a Pause/Resume
toggle (`aria-pressed`, full `aria-label`) that keeps autoplay stopped
until pressed again (WCAG 2.2.2). It is removed under reduced motion,
where nothing advances.

The interaction test now waits for the seven-second rollover and checks
that tab, panel, caption, badge and the preloaded frame all move to
channel 02, and that the toggle holds through mouseleave and blur.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): keep the tablist to its tabs, no successor prefetch when autoplay is off

The channel list's header and the Pause/Resume control sat inside the
`role="tablist"` container; the tablist now wraps only the six tabs so
assistive technology reads the toggle as an ordinary button beside the
list. `show()` preloads the next frame only while autoplay can reach it
(neither reduced motion nor the toggle has stopped it). Tests assert
both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): preload the successor when auto-advance resumes

A channel picked while the switcher is paused deliberately skips its
successor; resuming now fetches that frame so the next automatic switch
does not land on a blank screen. The interaction test covers
pause → manual selection → resume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): Resume restarts autoplay at once; same-channel progress in the test

The transient hover/focus pauses now watch only the channel list and the
screen. The footer with the Pause/Resume control is not one of those
regions, so after pressing Resume — with the pointer and the focus still
on the button — autoplay visibly restarts instead of waiting for the
visitor to leave the whole block.

The interaction test compares progress within one channel (a paused
Movies before and after Resume) rather than across channels, which
could fail on a slow runner, and asserts that autoplay runs while the
toggle keeps focus and hover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(website): one scheduler for the switcher, no frames while paused

Every pause reason (hover, focus, the toggle, leaving the viewport) now
goes through a single `sync()`: the animation-frame loop runs only while
the block is visible and nothing pauses it, and is cancelled otherwise,
so a switcher left paused schedules no frames at all. The dwell clock
still resumes from where it froze, and the successor frame is fetched
only when the loop actually starts — so scrolling away and back under a
persistent pause loads nothing. Tests count scheduled frames while
paused and cover pause → manual selection → offscreen → return.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): a channel picked while paused gets its full dwell after Resume

`show()` reset the pause mark, so the time a visitor spent paused after
picking a channel counted toward that channel's dwell and Resume could
advance immediately. When the loop is not running the new channel now
starts out paused at that moment. The interaction test asserts the
progress is still near zero right after Resume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): follow reduced-motion changes and hidden tabs in the switcher

The reduced-motion preference is read from a live MediaQueryList: when
it changes while the page is open the scheduler stops or restarts and
the Pause/Resume control is hidden or shown (it is hidden, not removed,
for that reason). A hidden document counts as a pause too — background
tabs throttle animation frames while the clock keeps running, so
without it the first frame back would skip a channel. The interaction
test flips both at runtime.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): plain action button for the switcher pause, no manual animations under reduced motion

`aria-pressed` on a button whose name changes between "Pause
auto-advance" and "Resume auto-advance" announced the wrong thing; the
control is now an ordinary action button whose name says what pressing
it does next. The OSD slide and the panel fade get
`motion-reduce:transition-none`, so a manual selection under reduced
motion moves nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:58:24 +02:00
4gray 249cd38a66 fix(stalker): align season markers and preserve episode loading (#1545) 2026-09-05 22:22:46 +02:00
4grayandClaude Fable 5.1 812ec69fd4 feat(website): turn blog tags into topic hubs
Tags were decorative: a plain span on every card and post header, with no
page, filter or search behind them, and a vocabulary of 32 slugs across 16
posts where 22 slugs appeared once. They now form a closed vocabulary of ten
topics (src/lib/blog-tags.ts) that the content collection schema enforces, so
an unknown slug fails the build instead of minting a new tag.

Every used tag gets a hub at /blog/tag/<tag>/ with CollectionPage and
BreadcrumbList structured data; the blog index and the hubs show a "Topics"
rail with post counts; and every chip links to its hub. The cards become
<article> elements with the title link stretched over the card, because chip
links cannot nest inside a card that is one big <a>.

Posts are retagged to the new vocabulary. A structural test covers the rail,
each hub, the chip targets, the sitemap and the absence of nested anchors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:51:26 +02:00
4grayandClaude Fable 5.1 b55a3cc322 feat(website): tidy blog card tags and date, show a third card on the homepage
The blog card kept the date and every tag in one non-wrapping flex row, so a
post with five or six tags overflowed the card frame and the date broke across
three lines inside the three-column grid. The date now sits on its own
non-wrapping line, tags wrap below it, and only the first three render as chips
while the rest collapse into a "+N" chip whose tooltip lists them. The featured
card still shows every tag.

The homepage rendered two cards under the featured post and left the third grid
column empty; it now shows three.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:14:45 +02:00
4grayandClaude Fable 5.1 e9326c9427 feat(website): add a Docker page for the self-hosted browser version
Publish /download/docker/ so "Self-host it with Docker" no longer sends
visitors off-site to the developer README. The page covers what the
image contains, a four-step quick start with the repository compose
command and an image-only compose snippet, the environment variables
and port that matter, the published tag patterns with the update
command, what the browser version leaves out, and a seven-question FAQ.
It links to docker/README.md for the full reference, to the desktop vs
browser comparison and to the setup guides; the README links back.

The download hub gains a fourth card, the homepage and hub links point
at the page, the platform switcher shows a Docker card on the OS pages,
and the comparison page links both the page and the README. The page
emits SoftwareApplication / FAQPage / BreadcrumbList JSON-LD and is
covered by website-download-pages.test.mjs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 20:53:45 +02:00
4gray eb3cf89f62 fix(stalker): let media transports control seek byte ranges (#1544) 2026-09-05 20:51:32 +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 1c56b3ea02 fix(portals): count concurrent connection failures once (#1537)
* fix(portals): count concurrent connection failures once

* docs(portals): clarify failure counting after guard eviction
2026-09-05 12:21:49 +02:00
4grayandClaude Fable 5.1 1cf17f7301 style(website): solid hairlines, Bricolage Grotesque display face, drop TV effects (#1539)
* style(website): solid hairlines, Bricolage Grotesque display face, drop TV effects

First of three landing redesign PRs. This one only touches tokens and
surface treatment so every page (home, blog, /features, /compare,
/download) benefits without any structural change:

- Replace every dashed border with a solid hairline; `.card-dotted`
  becomes `.card-panel` (bg step + hairline, lighter hairline on hover)
  and the section divider is solid `surface-800`.
- Swap the display face from Playfair Display to Bricolage Grotesque
  (DM Sans body and IBM Plex Mono unchanged). The italic accent word
  that every heading repeated is gone; only page-level h1s keep the
  accent, and as colour rather than italic.
- Remove the CRT/TV effects: the `.btn-tv` block and its nine keyframes,
  the scanline overlay on the hero screenshot, the CRT hover overlay and
  teal glow shadows on feature cards, the pulsing mascot, and the unused
  scan/tv-* Tailwind animations.
- The hero download button and the header Download link are now solid
  accent buttons; the self-host link is a plain text link.
- Blog tag chips use neutral surface borders so teal stays reserved for
  actions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* style(website): finish the dashed sweep in prose and package rows

The blanket `border-dashed ` removal ate the utility but not the
variant prefix in the blog prose classes, leaving
`prose-pre:prose-pre:...` and `prose-hr:prose-hr:...`; and the
Linux package rows still used `divide-dashed`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:11:56 +02:00
4gray 07ccf45f32 fix(playback): hide VLC RC console on Windows (#1533)
* fix(playback): hide VLC RC console on Windows

* fix(playback): preserve VLC retry arguments containing quiet flag
2026-09-05 11:33:05 +02:00
4gray 0245d73d78 feat(portals): make connection cooldown configurable in desktop settings (#1536) 2026-09-05 11:18:05 +02:00
dependabot[bot] de81e3b238 chore(deps): bump the npm-minor-patch group across 1 directory with 19 updates (#1528)
Bumps the npm-minor-patch group with 19 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.19.0` | `1.20.0` |
| [hls.js](https://github.com/video-dev/hls.js) | `1.7.0` | `1.7.1` |
| [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.11` |
| [@astrojs/sitemap](https://github.com/withastro/astro/tree/HEAD/packages/integrations/sitemap) | `3.7.3` | `3.7.4` |
| [@eslint/eslintrc](https://github.com/eslint/eslintrc) | `3.3.6` | `3.3.7` |
| [@faker-js/faker](https://github.com/faker-js/faker) | `10.5.0` | `10.6.0` |
| [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core) | `1.15.47` | `1.16.1` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.67.0` | `8.69.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.67.0` | `8.69.0` |
| [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) | `8.67.0` | `8.69.0` |
| [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest) | `30.4.2` | `30.5.1` |
| [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) | `30.4.1` | `30.5.1` |
| [jest-environment-node](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-node) | `30.4.1` | `30.5.1` |
| [jest-util](https://github.com/jestjs/jest/tree/HEAD/packages/jest-util) | `30.4.1` | `30.5.1` |
| [ng-mocks](https://github.com/help-me-mom/ng-mocks) | `14.17.1` | `14.17.3` |
| [sharp](https://github.com/lovell/sharp) | `0.35.3` | `0.35.4` |
| [tsx](https://github.com/privatenumber/tsx) | `4.23.12` | `4.23.13` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.67.0` | `8.69.0` |
| [zod](https://github.com/colinhacks/zod) | `4.3.6` | `4.5.4` |



Updates `axios` from 1.19.0 to 1.20.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.19.0...v1.20.0)

Updates `hls.js` from 1.7.0 to 1.7.1
- [Release notes](https://github.com/video-dev/hls.js/releases)
- [Changelog](https://github.com/video-dev/hls.js/blob/master/docs/release-process.md)
- [Commits](https://github.com/video-dev/hls.js/compare/v1.7.0...v1.7.1)

Updates `marked` from 18.0.9 to 18.0.11
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.11)

Updates `@astrojs/sitemap` from 3.7.3 to 3.7.4
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/integrations/sitemap/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/@astrojs/sitemap@3.7.4/packages/integrations/sitemap)

Updates `@eslint/eslintrc` from 3.3.6 to 3.3.7
- [Release notes](https://github.com/eslint/eslintrc/releases)
- [Changelog](https://github.com/eslint/eslintrc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslintrc/compare/eslintrc-v3.3.6...eslintrc-v3.3.7)

Updates `@faker-js/faker` from 10.5.0 to 10.6.0
- [Release notes](https://github.com/faker-js/faker/releases)
- [Changelog](https://github.com/faker-js/faker/blob/next/CHANGELOG.md)
- [Commits](https://github.com/faker-js/faker/compare/v10.5.0...v10.6.0)

Updates `@swc/core` from 1.15.47 to 1.16.1
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/commits/v1.16.1/packages/core)

Updates `@typescript-eslint/eslint-plugin` from 8.67.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.67.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/parser)

Updates `@typescript-eslint/utils` from 8.67.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/utils)

Updates `jest` from 30.4.2 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest)

Updates `jest-environment-jsdom` from 30.4.1 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest-environment-jsdom)

Updates `jest-environment-node` from 30.4.1 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest-environment-node)

Updates `jest-util` from 30.4.1 to 30.5.1
- [Release notes](https://github.com/jestjs/jest/releases)
- [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jestjs/jest/commits/v30.5.1/packages/jest-util)

Updates `ng-mocks` from 14.17.1 to 14.17.3
- [Release notes](https://github.com/help-me-mom/ng-mocks/releases)
- [Changelog](https://github.com/help-me-mom/ng-mocks/blob/main/CHANGELOG.md)
- [Commits](https://github.com/help-me-mom/ng-mocks/compare/v14.17.1...v14.17.3)

Updates `sharp` from 0.35.3 to 0.35.4
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.3...v0.35.4)

Updates `tsx` from 4.23.12 to 4.23.13
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.12...v4.23.13)

Updates `typescript-eslint` from 8.67.0 to 8.69.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.69.0/packages/typescript-eslint)

Updates `zod` from 4.3.6 to 4.5.4
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.5.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: hls.js
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: marked
  dependency-version: 18.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@astrojs/sitemap"
  dependency-version: 3.7.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@eslint/eslintrc"
  dependency-version: 3.3.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: "@faker-js/faker"
  dependency-version: 10.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@swc/core"
  dependency-version: 1.16.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: jest
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: jest-environment-jsdom
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: jest-environment-node
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: jest-util
  dependency-version: 30.5.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: ng-mocks
  dependency-version: 14.17.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: sharp
  dependency-version: 0.35.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: tsx
  dependency-version: 4.23.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-patch
- dependency-name: typescript-eslint
  dependency-version: 8.69.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
- dependency-name: zod
  dependency-version: 4.5.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 10:23:42 +02:00
4grayandClaude Fable 5.1 4d8559976c fix(dashboard): read cached EPG timestamps as unix seconds (#1532)
`epgTimestampMs` in the dashboard live-EPG helpers returned
`EpgProgram.startTimestamp`/`stopTimestamp` as-is, but those fields are
unix SECONDS everywhere else (`getProgramTimeMs` in `@iptvnator/ui/epg`
multiplies by 1000). The bug was latent: the backend
`GET_CURRENT_PROGRAMS_BATCH` rows never set the fields, so the ISO
fallback always ran.

Scale a positive finite timestamp by 1000 and treat zero/non-finite
values as absent, mirroring `getProgramTimeMs`. Add a dedicated spec
that feeds seconds-based timestamps and asserts the formatted range and
progress, and fix the rails component spec fixture, which stored
millisecond values in the seconds fields and so encoded the old bug.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:22:37 +02:00
4grayandClaude Opus 5 eb8e997c8f feat(website): add comparison pages for sources, players and editions
Publish /compare/ with three pages answering the choices the app asks
users to make: M3U vs Xtream Codes vs Stalker portal, the built-in web
players vs embedded MPV vs external MPV/VLC, and the desktop app vs the
self-hosted browser version. Each opens with a one-paragraph verdict,
carries a feature matrix whose cells are yes, no or a qualifier, and
emits WebPage / FAQPage / BreadcrumbList JSON-LD — not
SoftwareApplication, because guidance is not a product listing.

These compare IPTVnator's own options against each other rather than
naming other products, so every cell is checkable against this
repository. Pages that name competitors remain phase 3's open half; the
plan now records what has to be decided first.

A registry in src/lib/comparisons.ts drives the hub, the switcher and
the tests. The header gains a Compare entry and the features hub links
across. tools/testing/website-compare-pages.test.mjs checks canonical
URLs, the verdict block, the table, schema, cross-links and sitemap
entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 09:31:22 +02:00
4grayandClaude Fable 5.1 2f1ffe59f9 fix(embedded-mpv): disable the youtube-dl hook in the frame-copy helper (#1526)
The native-view addons and the external MPV launch path already run with
`ytdl=no`, but the frame-copy helper left mpv's default, so a refused HTTP
open fell through to ytdl_hook and yt-dlp before failing. That delayed the
`error` transition the auto-reconnect policy waits for and could leave the
session error reading "youtube-dl failed: unexpected error occurred"
instead of the load error.

Set `ytdl=no` in the helper's built-in block, before the stdin
`mpv-options` loop, so a user `ytdl=yes` session option still overrides
it. Document the helper's built-in block next to the other session-option
transports and add a release note.

Verified by rebuilding the helper against Homebrew libmpv and driving the
binary by hand against a connection-refused URL: the built-in default logs
only the ffmpeg/stream errors, while a `ytdl=yes` stdin line spawns yt-dlp.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:00:43 +02:00
4grayandClaude Opus 5 302afb237a chore(deps): close transitive CVE alerts via pnpm overrides (#1527)
Four open Dependabot alerts, all on transitive npm dependencies, so no
direct dependency changes:

- browserslist 4.28.1 -> 4.28.8 (GHSA-73wf-gq98-2v4g, high)
- @xmldom/xmldom 0.8.13 -> 0.8.15 (GHSA-6gmq-8vp8-gcm6)
- @humanfs/node 0.16.7 -> 0.16.8 (GHSA-p498-v437-472g)
- postcss-selector-parser 6.1.2 -> 6.1.4 (GHSA-w9m9-85wc-3x92)

@xmldom/xmldom already had an override, but its pinned target 0.8.13 had
itself fallen into the widened advisory range (<= 0.8.14), so that entry
is bumped rather than added.

browserslist is pinned to 4.28.8 rather than the advisory's 4.28.7 because
4.28.8 was already resolved elsewhere in the tree; collapsing onto it takes
browserslist from three copies to one and drops the duplicate
caniuse-lite/electron-to-chromium/update-browserslist-db trees with it, so
the lockfile is a net reduction. postcss-selector-parser 6.0.10 is left
alone: it sits below the advisory's >= 6.1.0 lower bound.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:59:51 +02:00
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.

Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:46:10 +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 ad81fbfc45 feat(website): add the features hub and five feature landing pages
Publish /features/ with one landing page per feature people search for:
M3U playlist player, Xtream Codes player, Stalker portal player, TV
guide (EPG) and phone remote control. Each page composes a feature hero
(download and setup-guide calls to action) with the download-page
sections, carries SoftwareApplication (featureList) / FAQPage /
BreadcrumbList structured data, links to the other feature pages and
the matching guides, and uses only mock-backed screenshots.

A registry in src/lib/features.ts drives the hub, the per-page
switcher, the homepage feature cards (now links) and the header
Features entry, so a new page is one registry entry and one .astro
file. tools/testing/website-feature-pages.test.mjs checks canonical
URLs, schema, cross-links, hub coverage and sitemap entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:09:03 +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 Fable 5.1 50b980af7a feat(website): add the M3U playlist and EPG setup guide
Publish "How to Load an M3U Playlist and Add an EPG in IPTVnator": the
three import methods plus drag-and-drop and OS file opening, the
playlist views, refresh and startup auto-update, attaching an XMLTV
guide through Settings or a url-tvg header, the tvg-id / tvg-name /
name matching order with manual mapping, catch-up attributes,
troubleshooting and a seven-question FAQ. The three guides now link to
each other, the download pages point at all three, and llms.txt lists
the new one.

Three guide shots join the manifest: the M3U URL dialog, the Groups
view (reusing open-m3u-groups under the guides group) and the EPG
settings section with a staged source row. A settings shot leaves the
form dirty, which arms the app's close guard and blocked app.close()
indefinitely; the capture now discards unsaved settings before every
action and before teardown, and bounds every locator wait.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:39:09 +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
ZoultandClaude Fable 5.1 5a11b82eaf feat(embedded-mpv): configurable extra libmpv options and network auto-reconnect (#1515)
Extra libmpv options (Settings > Playback) reach every embedded engine off the command line (createSession array on Windows/macOS, a 0600 --include file on Linux native-view, a stdin preamble for the frame-copy helper); the keys the embed depends on are refused, and keys libmpv rejects are reported once per session. Dropped streams reload automatically (error, or ended on live) with 2 s -> 30 s backoff, six attempts per outage and a 30 s stability reset, only for a load that already played; engine failures stay terminal, a running recording is filed as interrupted and restarted after the reload, and an external subtitle file is re-added. Settings.embeddedMpvAutoReconnect (default on) opts out; the player shows 'Reconnecting... attempt N of M'.

Started by Bpl5966 in #1515 and finished by the maintainers in the same PR.

Co-authored-by: Bpl5966 <amine.b1959@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:36:14 +02:00
4grayandClaude Fable 5.1 6cfdaf5900 feat(website): add the Stalker portal setup guide with mock-backed screenshots
Publish "How to Connect a Stalker or Ministra Portal to IPTVnator": the
portal URL shapes discovery accepts, MAC normalization, the optional
serial/device-ID/signature fields and the pinning rules behind the
"generate device IDs" toggle, what endpoint discovery does on Add, the
sections a portal source gets, Account info, and a troubleshooting list
built from the app's own refusal messages, plus a seven-question FAQ.
The guide is cross-linked from the download pages and llms.txt.

Guide screenshots come from the capture script. Shots that walk into a
Stalker portal start the stalker-mock-server and seed its marketing-demo
portal for that run only, so release shots never gain a third source
card. The frame guard allowlists exactly that scenario's MAC and keeps
rejecting every other MAC-shaped string.

To keep the live-TV frame free of third-party images, the fictional live
channel list and the channel-logo SVG renderer move into
@iptvnator/shared/marketing-fixtures; both mocks now serve
/assets/marketing/logo/<slug>.svg, the Stalker marketing-demo scenario
builds its ITV categories, channels and schedule from those fixtures
instead of faker names with picsum logos, and the mock resolves asset
URLs on get_all_channels too, which is the response the app renders
the channel list from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:20:56 +02:00
4grayandClaude Fable 5.1 9455e0db65 test(stalker): cover radio playback surviving a category switch in web E2E (#1522)
The E2E added in #1517 proved the ITV case only. Radio shares the live
layout and the same context-panel handler, and its inline audio player is
gated on the store selection just like the ITV player, so a regression in
`onStalkerCategoryClicked` would silence a station the user never switched
away from. The new scenario mirrors the ITV one: play a station, pick
another category, wait for the sidebar title to change, and assert the
audio player is still mounted.

Closes #1521

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:27:15 +02:00
4grayandClaude Fable 5.1 70fff19977 fix(website): inject the fallback version instead of importing package.json
`website:lint` failed on master since the per-OS download pages landed:
`src/lib/downloads.ts` imported the workspace root `package.json` through a
relative path, which `@nx/enforce-module-boundaries` rejects ("external
resources cannot be imported using a relative or absolute path").

The value is only needed as a build-time constant for the offline fallback,
so `astro.config.mjs` now reads the root manifest through the file system
and injects `__IPTVNATOR_VERSION__` via Vite `define`; `downloads.ts` uses
that constant and `src/env.d.ts` declares its type. Verified with
`WEBSITE_SKIP_RELEASE_FETCH=1`: the built download pages link the
`iptvnator-<version>` assets and no define literal leaks into the output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:47:01 +02:00