Merge origin/master into the programme guide branch

Adopts the compact icon-only timeline toolbar (#1557): the Guide button is
now an icon-only `epg-timeline__iconbtn` with its label in the tooltip and
aria-label, so the unused EPG.TIMELINE.GUIDE key is dropped from all locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:59:04 +02:00
commit 49fdd40c9d
123 files changed
+5976 -474

No files matched your search

@@ -494,7 +494,7 @@ Video.js HTTP error is `network-error` and shows its status. Because an HTTP
status is server/network evidence rather than decoding evidence, external
decoding is not presented as a likely fix.
Video.js `8.23.9`, the default web player, runs HTTP streaming through bundled
Video.js `8.24.0`, the default web player, runs HTTP streaming through bundled
VHS `3.17.5`. Its terminal `Player#error` crosses a separate allowlisted
boundary built only from the public Video.js `MediaError` code/status,
`metadata.errorType`, and the documented `player.tech().vhs` runtime property.
@@ -841,7 +841,12 @@ original headers or credentials required to reconstruct a safe launch request.
No recommendation mutates `Settings.player` or another persisted setting.
Recovery recommendations never auto-switch a player or source and do not
replace the separate source-owner auto-failover feature. Attempts, overrides,
replace the separate source-owner auto-failover feature. The narrow Xtream
live Auto format contract is separate: a source owner may supply one advertised
TS transport to the same web player on an initial terminal HTTP failure. It
never chooses another engine, and its pending callback waits for the old
transport's render teardown. See [Initial Auto HLS failure](./xtream-portal-compatibility.md#initial-auto-hls-failure)
for eligibility, session ownership and the external/Embedded MPV/VHS limits. Attempts, overrides,
resume handoff, and diagnostics are session-local: there is no persistent
history, cross-session learning, correlation, or telemetry.
+37 -11
View File
@@ -267,8 +267,8 @@ remain local when the meaning is explicit.
### Collapsible Live Sidebar
- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle
that hides the channels rail to give the player and EPG full width.
- M3U, Xtream, and Stalker live layouts share a sidebar collapse toggle that
hides the channels rail to give the player and EPG full width.
- Xtream Live TV's root view (`/live` with no selected category) follows the
same paginated `All Items` shell as VOD and Series: a widget header with the
total channel count, page-size controls, and page navigation above the shared
@@ -285,15 +285,41 @@ remain local when the meaning is explicit.
is `live` (Xtream) or `itv`/`radio` (Stalker); movies, series, favorites,
and recent routes leave it untouched.
- Collapsed state is owned by `LiveLayoutSidebarStateService`
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util`. Every surface that
participates injects the service and reads `isCollapsed`; any toggle calls
`service.toggle()`. Persistence delegates to the existing
`live-sidebar-state` helpers, so the localStorage key stays unchanged and
missing/invalid values restore to expanded.
- A `mat-icon-button` with `chevron_left` lives in the sidebar header and
toggles state. While collapsed, a floating `chevron_right` mini-fab appears
at the left edge of `.content-container` to restore the rail (and the
categories rail, in Xtream/Stalker live).
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util` and kept **per
surface** (`LiveSidebarSurface`): `m3u` (the M3U player), `portal` (Xtream
and Stalker live layouts plus the shell categories rail) and `collection`
(the unified favorites/recent live tab). Every participant injects the
service, holds `isCollapsedFor(surface)` (a stable signal) and calls
`toggle(surface)`; nothing reads localStorage directly. Persistence lives
under `live-sidebar-state:<surface>`. Hiding the list is a per-context
choice: it must not follow the user from a portal to an M3U playlist, nor
from the desktop rail to the phone bottom drawer of another surface. The
pre-split shared key `live-sidebar-state` is forgotten on service
construction and never read — a stored `collapsed` there hid every channel
list in the app behind a 32px chevron and survived restart, "Remove all
playlists" and re-import (issue #1458).
- The control never moves. Inside the rail a `mat-icon-button` with
`chevron_left` hides it; while collapsed a floating `chevron_right` mini-fab
sits at the left edge of `.content-container`. Because both of those live
in the thing they hide, the workspace header additionally renders
`view_sidebar` (`headerSidebarToggle`, `WorkspaceShellHeaderService`) on
every route that renders its own rail — M3U `all`/`groups`, Xtream `live`,
Stalker `itv`/`radio` (`resolveRouteLiveSidebarSurface`). It stays in place
in both states, uses `aria-pressed` (pressed = rail visible) and tints
primary only while the rail is hidden, since the hidden state is the
exception that deserves the cue. Collection pages are deliberately excluded:
only the page knows whether its live tab, and therefore the rail, is on
screen, so its own header toggle beside the content switch stays the owner.
At the phone breakpoint (≤640px) the header toggle is hidden: the rail is a
bottom drawer there with its own toggle and the header has no spare width.
- While the rail is collapsed and nothing is playing, every live host renders
`app-channel-list-hidden-state` (`@iptvnator/portal/shared/ui`) instead of
the "select a channel" empty state: a title that says the list is hidden, a
one-line hint naming the shortcut, and a full-size "Show channels list"
stroked button wired to the same toggle. The generic
`app-portal-empty-state` grew optional `hint`, `actionLabel`, `actionIcon`
inputs and an `action` output for this; the action keeps full opacity while
icon and copy stay muted, because it is the way out of the state.
- Keyboard shortcut: `Cmd/Ctrl+B`. The handler ignores events that originate
inside `<input>`, `<textarea>`, `<select>`, or content-editable elements via
the shared `isTypingInInput` helper.
+13 -4
View File
@@ -836,12 +836,22 @@ activation, and the details dialog behave identically to the timeline.
(**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus
popover** revealing the full title + time + description for any non-`wide`
block (it flips above the block when the panel is near the screen bottom);
(D) a px-per-minute **zoom** slider (tick density adapts via
`timelineTickStepForScale`); and (E) **grouping** of ≥4 consecutive short
(D) a px-per-minute **zoom** (tick density adapts via
`timelineTickStepForScale`): the toolbar's icon-only zoom button cycles
three presets — day overview (`1`) → by hour (`1.75`, the default) →
detailed (`3.4`, the scale a group chip expands to) — snapping a
wheel-tuned scale to its band's successor (`TIMELINE_ZOOM_LEVELS`,
`nextTimelineZoomScale`), while Ctrl/⌘ + wheel (and trackpad pinch) over
the ribbon zooms continuously around the cursor within
`TIMELINE_ZOOM_MIN..MAX` and `preventDefault`s so Chromium never page-zooms
the same gesture; the current level lives in the tooltip/`aria-label` and
a `data-zoom-level` attribute; and (E) **grouping** of ≥4 consecutive short
(<10 min) programmes into one dashed "N short" chip when zoomed out
(`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon
canvas lives in the child `app-epg-timeline-track`; the parent owns the
scroller, toolbar (incl. the zoom slider) and state.
scroller, toolbar (icon-only "Now" + zoom buttons, both labelled through
tooltip + `aria-label`, so the channel/programme heading takes every pixel
the fixed-width controls leave) and state.
- **Panel height & titles.** Block titles wrap onto as many lines as the card
height allows and are clipped (not single-line ellipsis); the foot ("ON NOW"
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
@@ -1518,7 +1528,6 @@ resolve another retained source sharing that channel ID. Legacy programmes with
unknown (`NULL`) ownership are conservatively left alone; the existing database
initialization backfill handles rows whose channel still identifies their owner.
Renderer reconciliation fences lookups before its first asynchronous step.
Imports wait for serialized reconciliation (including playlist migration), then
filter against its committed owner set. Completion increments the data revision again
+34 -4
View File
@@ -126,6 +126,36 @@ publishes it when the active channel is cleared IN PLACE (e.g. quitting an
external MPV/VLC session dispatches `resetActiveChannel` while the route
stays mounted).
## Live channel return and playback order
Xtream and Stalker live views keep a component-owned playback queue through
`LiveChannelPlaybackQueue` in `portal-shared-data-access`. Explicit selection
captures the actual displayed order, including search/sort and a fullscreen
panel's own filter. Remote up/down, numeric selection and the published channel
number use that queue while category or search browsing remains independent.
Xtream history handoffs from global search or Recently Added capture the
eligible destination category in the selected channel sort order; an unrelated
previous category/query does not define that queue. Explicit All Items clicks
still capture their displayed list. Same-channel replay and remote selection preserve it. Source/type changes and
view destruction discard it; ITV and radio never share an owner.
Stalker captures before asynchronous URL resolution and commits only the winning
successful request. Paged lists extend the queue only as more rows arrive for
the original category and search scope. They do not fetch a global catalog for
remote navigation. Xtream excludes removed streams and hidden or removed
categories from eligible queue entries.
A conditional **Show playing channel** icon in the channel header appears when
the playing channel is absent from the browsed results and its category remains
accessible. It clears `q` and the store query, returns to that category, expands
the sidebar, then scrolls and focuses the playing row. It never starts playback
or changes the playback/session/catchup identity. A collapsed sidebar first uses
its existing restore action. Removed categories are not recreated or unhidden.
Stalker reuses the already-resolved playing item as a temporary normal row when
it lies beyond loaded provider pages. This row is deduplicated once it arrives
in provider results and discarded on browsing or playback changes; returning
never crawls the catalog. Raw provider rows alone extend the playback queue.
## Shared helpers
- File: `libs/portal/shared/util/src/lib/remote-channel-navigation.ts`
@@ -184,10 +214,10 @@ Implemented behavior:
- `onRemoteControlCommand` for number select
- Up/down:
- Uses selected live item `selectedItem().xtream_id`
- Navigates inside `selectItemsFromSelectedCategory()`
- Navigates inside the captured eligible playback queue
- Calls `playLive(nextItem, true)` so remote actions explicitly start playback
- Number select:
- Maps number to item in current category list
- Maps number to item in the same captured eligible queue
- Calls `playLive(channel, true)` so remote actions explicitly start playback
- Publishes status via effect:
- `portal: 'xtream'`
@@ -207,10 +237,10 @@ Implemented behavior:
- `onRemoteControlCommand` for number select
- Up/down:
- Uses `selectedItem().id`
- Navigates inside `itvChannels()`
- Navigates inside the captured ITV/radio playback queue
- Calls `playChannel(nextItem, true)` so remote actions explicitly start playback
- Number select:
- Maps number into `itvChannels()`
- Maps number into the same captured playback queue
- Calls `playChannel(channel, true)` so remote actions explicitly start playback
- Publishes status via effect:
- `portal: 'stalker'`
+4 -3
View File
@@ -1274,9 +1274,10 @@ The Stalker live route and radio route intentionally share
in flight belong to the selection and survive the switch. Only a section
change (`itv` ↔ `radio`, where the route session clears the selection)
invalidates that request and drops the fallback. A playing channel outside
the newly selected category simply has no highlighted row, and remote
channel up/down finds no neighbour until a channel from the visible list is
played.
the newly selected category can be revealed with **Show playing channel**
in the channel header. Remote up/down, numeric selection and status retain
the captured playback order while browsing. See the
[queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order).
## Full ITV Channel List Cache
+5
View File
@@ -154,6 +154,11 @@ is watching keeps playing while the sidebar re-filters (Xtream: #936; Stalker:
series category clicks do drop the open detail (`setSelectedItem(null)` /
`clearSelectedItem()`) because they navigate to a list route.
The channel header offers **Show playing channel** when browsing excludes the
active channel. It returns to that category and focuses the row without
restarting playback; remote commands retain captured playback order. See the
[queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order).
## Search And Navigation Rules
Search is shell-owned and route-aware:
@@ -129,6 +129,58 @@ Manual `ts` and `m3u8` settings remain supported; when a manual setting is not
allowed by the portal, URL construction falls back to the first
provider-allowed format.
### Initial Auto HLS failure
The routed Xtream Live TV host attaches `ResolvedPortalPlayback.liveAutoTsUrl`
only when the current user preference is Auto and the current account explicitly
advertises both HLS and TS. `XtreamUrlService.constructAutoLiveTsUrl` uses the
same credential encoding and provider-subpath URL builder as the initial URL;
no URL string replacement, manifest preflight, stream download, or API probe is
introduced. Unknown/empty formats and manual HLS/TS have no alternative.
`WebPlayerLiveAutoFormat` may consume that alternative once per mounted logical
live session, in the same selected web player, after an owned terminal network
diagnostic with HTTP 4xx/5xx, before the native video element reports `playing`.
A successful manifest is not playback. HLS key/media/unknown-stage failures,
DRM payloads, content-info VOD/episodes, and catch-up are excluded. Generic
network failures, status zero, cancellation, timeouts without an HTTP response,
and decode errors keep the normal explicit recovery surface.
The old application is first removed from the render tree. The next render
callback starts TS only after the web engine's synchronous loader teardown and
only while source, logical session and user intent still match. The TS payload
keeps the original headers, title and playback metadata; Electron applies them
through its existing scoped header handoff. HTTP-media requests retain the
existing transport/trust rules and do not feed new evidence or admission into
the separate portal-API host-connectivity guard. Recovery recommendations still
require a user action and never auto-switch engines. No settings or playlist
cache is changed. A TS failure displays normal recovery actions; Retry repeats
TS without rearming the automatic attempt. Changing the actual logical channel
or playlist, or mounting a new host, creates a new session; same-session metadata
or provider-source refresh does not rearm it. Same-channel replay and metadata
refresh preserve the chosen TS while the candidate URLs, headers and mode stay
the same; an actual transport/provider change discards that selection. This deliberately avoids a sticky
playlist preference and reevaluates advertised formats on the next channel.
| Player/path | Initial Auto failure support |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HTML5 / ArtPlayer, hls.js | One advertised TS attempt after terminal HLS manifest/level/segment HTTP failure; waits for the engine's own bounded retries |
| Video.js / VHS | Only an observable terminal HTTP diagnostic can trigger an attempt. A sole HLS rendition's failing segment can remain in VHS retry/exclusion cycles without such a diagnostic; use manual TS for that case |
| External MPV / VLC | Manual TS only. Process launch/exit and best-effort playing telemetry do not reliably identify the initial HLS media HTTP failure |
| Embedded MPV | Manual TS only; no equivalent structured HTTP diagnostic |
| Unified Favorites/Recent live resolution | Manual TS; it does not own fresh session account-format evidence |
For manual recovery, stop the current player, choose **Settings > Playback >
Stream Format > ts**, save, and reopen the channel. This keeps the chosen player
and avoids a second uncorrelated external process. The settings description
explains this route. This is partial playback coverage of #1513, not a claim
that all external-player and VHS scenarios automatically recover.
Synthetic regression media lives in the mock's `live-fallback:live-fallback`
account: HLS manifest 200 plus segment 403, manifest 403, a playable local
H.264/AAC TS alternative, a failing TS alternative, and a delayed HLS segment.
Tests assert actual video progress and the number of concrete TS requests.
If stored Xtream playback credentials contain an invalid server URL or blank
username/password, stream URL construction returns an empty URL instead of
throwing during playback.
@@ -0,0 +1,62 @@
# Live channel return implementation plan
> **For agentic workers:** Use subagent-driven-development for the independently owned Xtream integration, with spec and code review. Execute the shared queue, Stalker integration and validation in this task. Do not merge the resulting PR.
**Goal:** Keep live remote navigation stable during category/search browsing and provide one conditional action to reveal the playing channel, without duplicating its title or restarting playback (#1520).
**Architecture:** Each live host owns a playback queue scoped by source and content type. Explicit channel activation captures the displayed ordered list; remote commands preserve it. Stalker extends the captured queue only when more rows arrive for the same browsing scope, never by falling back to a global ITV cache. Revealing restores the channel's available category and clears search without invoking playback. Provider-neutral queue state belongs in portal/shared/data-access; portal routing, visibility and scrolling remain in their feature libraries.
**Tech Stack:** Angular signals, Nx/Jest, Playwright, Electron IPC, existing Material icon buttons and translations.
## Contract
- Capture the list before asynchronous playback resolution; commit the queue only for the winning successful request. Same active-channel replays/catch-up return keep the queue.
- Source/type changes invalidate old queue ownership. Numeric, adjacent and status all read the same queue; remote commands never recapture the browsed category.
- A captured paged Stalker queue contains loaded rows. Newly loaded rows extend it only while source/type/category/search scope still matches. No background all-portal crawling; unavailable pages are not advertised as loaded channels.
- Xtream uses the actual sidebar sort/search order and filters hidden/removed categories and channels from navigation eligibility. Revealing never unhides categories.
- An out-of-filter channel gets a localized `CHANNELS.SHOW_PLAYING_CHANNEL` action in the existing list header. No now-playing title block or EPG changes. Reuse the existing sidebar restore action while collapsed.
- Reveal clears interfering query state, selects the active channel's accessible category, waits for rows/rendering and scrolls/focuses the scroll owner. Stale navigation/loading must not reselect a previous channel or restart the player. For paged Stalker search, reuse the already-resolved channel as a scoped normal row until provider results include it, rather than crawling the catalog.
- Fullscreen selection captures its own displayed filtered list; existing fullscreen controls and playback/session ownership remain intact. No redesign of the fullscreen panel.
- Stalker #1543 remains independent: queue capture consumes the actual list and makes no new global-search policy.
## Tasks
- [x] Add `LiveChannelPlaybackQueue<T>` and focused tests under `libs/portal/shared/data-access/src/lib/`; export via the public barrel. Test capture/fallback, source/type ownership, preserved order, same-scope extension, stale-scope rejection, unchanged-snapshot identity and reset. Run the focused Jest target red, then green.
- [x] Extend Xtream live layout and channel-list integration with regression coverage: category/search/sort drift, numeric/status/adjacent parity, hidden category exclusion, reveal without playback calls, route category/query handling, fullscreen list capture. Keep new logic in focused feature files where needed for max-lines. Run `pnpm nx test portal-xtream-feature --runInBand` and lint.
- [x] Extend Stalker live layout with the same queue policy and reveal action. Cover ITV/radio ID collisions, asynchronous successful/failed/stale resolution, paged queue extension, cache render windows, category/search drift, and reveal without session reset. Run `pnpm nx test portal-stalker-feature --runInBand` and lint.
- [x] Add `CHANNELS.SHOW_PLAYING_CHANNEL` to all shipped locale dictionaries. Keep icon button tooltip and accessible name identical.
- [x] Extend closest web and Electron E2E flows. Verify real remote HTTP commands preserve the original queue after browsing changes, and reveal retains the same video/session. Check both portal UIs in light/dark with synthetic sources only.
- [x] Update `docs/architecture/remote-control.md`, `stalker-portal.md`, `workspace-shell.md`, and mirrored AGENTS.md/CLAUDE.md guidance. Add one `.changes/portals-live-channel-return.md` note and validate it.
- [x] Run affected unit targets, E2E, lint, Electron E2E build, release-note validator and `git diff --check`. Complete independent spec review, then code-quality review and resolve findings.
- [ ] Commit, create a PR and complete CI/review checks to ready-to-merge without merging, as authorized in this task's original scope.
## Validation commands
```sh
pnpm nx test portal-shared-data-access --runInBand
pnpm nx test portal-xtream-feature --runInBand
pnpm nx test portal-stalker-feature --runInBand
pnpm nx run-many -t lint -p portal-shared-data-access,portal-xtream-feature,portal-stalker-feature
pnpm nx run electron-backend:build-e2e --parallel=1
pnpm run release:notes:validate
git diff --check
```
Choose atomized existing web/Electron E2E targets after inspecting project discovery and their runner configuration. Record any environment limitations rather than treating a build or unit pass as full runtime validation.
## Validation record
- Stalker data-access after the category-search merge: 546 tests — passed.
- Shared data-access: 169 unit tests; Xtream: 445; Stalker: 355 — passed.
- Electron build and all three affected library lint targets — passed (existing warnings only).
- Electron remote-control suite: 5/5, including both portals and Stalker radio;
Chromium category-switch/reveal regression: 1/1 — passed.
- New regressions exposed the original queue drift, same-URL reveal cancellation,
and virtual viewport attachment race before their fixes.
- Independent spec and quality reviews passed after resolving provider-search
ownership, beyond-page reveal and delayed-playback page reconciliation.
- Release notes and diff whitespace validated. Tested UI in light/dark using
synthetic mock portals. Platform-specific Windows/Linux packaged runs and
Firefox/WebKit were not needed for the shared renderer/IPC-navigation change.
- Synced with category-scoped Stalker search (#1552); added auto-open queue regressions for global search/Recently Added after Codex review.