feat(collections): open a Stalker live favorite inside its portal (#1639)

Live channels in Favorites and Recently viewed now offer "Open in <playlist>"
for Stalker portals, the counterpart of the VOD "View in portal" action. The
chip in the programme panel and the channel's context menu jump to the channel
inside its portal's Live TV, with its genre selected and the channel playing.
Radio stays hidden: it is a separate legacy-paged section with no
open-on-arrival contract.

The handoff refuses to guess. The store records which genre the rendered ITV
list belongs to, so the deferred play waits for a list that can actually serve
the channel instead of inferring it from array identity. An id claimed by one
channel as its `id` and another as its `stream_id` is ambiguous, so neither is
played and the user still lands in the right genre. The handoff is abandoned
when the user changes genre, search, portal or section first.

Two pre-existing defects surfaced during review and are fixed here:

- A blank provider id shadowed a valid one. `a ?? b` keeps an empty string, so
  a channel with a blank `stream_id` was stored with no identity at all and
  could not be selected, played or found again. Six writers had that shape and
  three private copies of the skip-the-blank rule; all now go through one
  `firstNonBlankStalkerId` helper.
- Route-session readiness could publish before the store held the portal's
  row. The constructor starts one sync and the first navigation starts another,
  and the second skipped the bootstrap because the playlist id was claimed
  before the awaits that install it. Arrivals are now serialized, the id is
  claimed only after the store write resolves, and only the newest sync
  publishes readiness.

Both fixes carry regression tests that fail on the old behavior.
This commit is contained in:
4gray authored and GitHub committed 2026-09-20 22:55:29 +02:00
1 parent 6578e4073c
commit f80a21beef
36 files changed
+2211 -121

No files matched your search

+57 -3
View File
@@ -176,9 +176,63 @@ with a return handler keep Back available.
effect re-runs as the store switches and loads;
M3U navigates to `/workspace/playlists/:id/all` with `openM3uChannelUrl`
(`OPEN_M3U_CHANNEL_URL_STATE_KEY`), the same key global search writes and
the M3U player selects by URL. Stalker resolves to `null` — its ITV layout
has no open-on-arrival contract yet, and a jump that only reached `/itv`
would not be the affordance promised — so the action is hidden there.
the M3U player selects by URL; Stalker navigates to `/workspace/stalker/:id/itv`
with `openStalkerLiveItemId` + `openStalkerLivePlaylistId` (+ the row's
genre as `openStalkerLiveCategoryId` when known) from
`buildStalkerLiveNavigationTarget`, which `StalkerLiveAutoOpen`
(`stalker-live-stream-layout/stalker-live-auto-open.ts`, the Stalker
counterpart of the Xtream service + effect) consumes: it reads the state at
construction and on every `NavigationEnd`, waits until `currentPlaylist` is
the requested portal (channel ids are provider-local, so a colliding id in
the previous portal's list must never match), then locates the channel in
the full ITV channel list cache — `get_ordered_list` is server-paged, so
the row may sit on any page of its genre — selects that genre (`'*'` for a
channel without one), expands the rail and plays it. While the list loads it
waits (the cache turning ready re-runs the effect); a portal that cannot
serve a full list (`itvFullListUnsupported`, the cache's reactive
unsupported set), a load that fails transiently (the cache only arms a
retry cooldown and changes no signal, so the flow awaits the preload
promise and treats "settled, neither ready nor unsupported" as the same
outcome) or a channel missing from the list (censored genres are excluded
from `get_all_channels`) falls back to selecting the remembered genre, so
the user still lands in the right list, and the handoff is consumed either
way. That remembered genre is the stored row's `tv_genre_id` (an opaque
portal id, numeric on most panels but not all); the row's `categoryId`
counts only when it is not a section marker, because app-written
favorites/recent rows carry `'itv'` there. Playback is deferred whenever
selecting the genre changes the list scope (another genre, the All Items
grid — `null`, a different row source from the `'*'` All list — or an
active search): `playChannel` → `navigation.prepare` captures the
displayed rows as the remote/numeric channel order, and the store serves a
category a tick after `setSelectedCategory` — even from the full-list
cache — so playing right away would capture the previous scope's queue.
The store answers "whose channels are on screen?" with
`itvChannelsCategory` (set wherever `itvChannels` is served, cleared by
`setItvChannels`), which is what the deferred play waits for. Array
identity cannot answer it: filtering by `'*'` hands back the cache by
reference, and clearing a search replaces the rendered list without the
source moving. Clearing the search IS synchronous, so a genre already on
screen plays at once. A pending play is dropped when a newer handoff
arrives, when the user switches portal, genre or section or starts a
search, and when the layout is destroyed. Two things settle before any of
that: `StalkerWorkspaceRouteSession.isReady` (its sync resets the selected
category and item for the arrival, and the store keeps the previous
portal's playlist and cache across a revisit, so the playlist check alone
passes too early and the selection would be wiped a tick later), and the
user — the handoff is abandoned when the genre or search changes away from
what it captured once it became actionable, which is measured after the
session's own resets so they never read as a user action. Readiness is
published by the NEWEST sync only, and never before the store holds that
portal's row: the session applies arrivals one at a time and claims the
playlist id only after `setCurrentPlaylist()` resolves. The constructor
starts a sync before the first `NavigationEnd` starts another, so two run
at once — the second used to find the id already claimed, skip the
bootstrap and report ready while the first was still awaiting that write,
which let a revisited same-id portal whose endpoint or credentials had
changed play against the PREVIOUS row. A failed bootstrap leaves readiness
false rather than handing the arrival a stale row. Stalker radio stations resolve to `null`: they live in the separate
`radio` section, whose station list is legacy-paged with no
open-on-arrival contract, so the action stays hidden for them.
Two surfaces render the one verdict: `app-open-in-playlist-chip`
(`libs/portal/shared/ui`), projected into the EPG timeline / list-view
toolbar through the panels' `[epgToolbarAction]` content slot beside the
+6
View File
@@ -1278,6 +1278,12 @@ The Stalker live route and radio route intentionally share
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).
- Arrival with `openStalkerLiveItemId` in history state (a collection's
"open in playlist") is served by `StalkerLiveAutoOpen`
(`stalker-live-auto-open.ts`): once the store serves the requested portal
and the full ITV list (below) is ready, the channel is located there, its
genre selected and playback started; see
[Portal Detail Navigation](./portal-detail-navigation.md).
## Full ITV Channel List Cache