docs(portals): document forced MPV/VLC launch and pending-start contracts (#1809)

* docs(portals): document forced MPV/VLC launch and pending-start contracts

The rules #1792 settled for forced external launches and playback-start
bookkeeping lived only in code comments and specs. Record them as
contracts in the owning documents:

- embedded-inline-playback.md: the shared detail-host rules (one external
  player per title, unconfirmed teardown cancels, ownership rechecked
  after every await, owner-scoped pending state).
- xtream-portal-compatibility.md: the series launch chain, page-token
  duplicate guard and queued choice.
- vod-multi-source.md: the movie menu launch and reset follow the copy
  the primary button acts on; pending resets are a list of targets.
- stalker-portal.md: the per-series launch queue, the batch-held choice
  and the movie launch/reset pending start.

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

* docs(portals): correct launch-contract claims against the code

- A launching session is published before the launch IPC resolves; what
  it lacks until then is an exact closer.
- The series watched/reset batch and the Xtream movie launch gate are
  page-wide, not owner-scoped.
- Only the Stalker movie hosts retire a pending start, and that does not
  clear the repeat guard of a launch still in flight.
- Name only the specs that exercise the Xtream series rules.

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

* docs(portals): tighten closer timing and page-wide gate wording

A launching session may already have its closer before the launch IPC
resolves, the Xtream movie launch gate does not cover the inline
player's diagnostic fallback, and the hero-state spec covers only the
external-player and reset rows.

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

* docs(portals): fix stale session-timing comment and search guard wording

- serial-details-external-launch.ts: Electron publishes a `launching`
  session as soon as the launch IPC arrives, not only after the launch
  settles. Comment only; no behaviour change.
- stalker-portal.md: the search host's selection check does not include
  the content type; a switch to a series supersedes the launch through
  the playback owner key instead.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-04 17:19:59 +02:00
1 parent 2dfdd65f53
commit 2738bc28a1
5 files changed
+183 -4

No files matched your search

@@ -471,6 +471,58 @@ position telemetry overwrites this launch marker when available. This keeps the
last-watched season and episode correct even when an external player's progress last-watched season and episode correct even when an external player's progress
interface is unavailable; exact external timestamps remain best-effort. interface is unavailable; exact external timestamps remain best-effort.
## Forced External Launches From Detail Pages
The detail "…" menu's "Open in external player" sends the title to MPV/VLC
through `PortalPlayer.openExternalPlayback(playback, player)` whatever the
configured player is. The launch IPC cannot be cancelled, and until it
resolves the session is at most `launching` and may not have a closer yet.
Every detail host therefore keeps these rules:
- **One external player per owner.** Before launching, the host closes the
external session the page owns: the session of the same title on the
Stalker pages and the Xtream series page; the session it launched, else the
one matching its movie, on the Xtream movie page. Sessions the page does not
own are left alone. With instance reuse off, a second detached player would
otherwise start beside the first. Stalker hosts use
`replaceOwnedExternalSession` from
`@iptvnator/portal/shared/util`; the Xtream pages use
`closeRunningExternalSession` with the same outcome rules.
- **Unconfirmed teardown cancels the launch.** A live session without a
closer, or a close that rejects, leaves the running player in place and
nothing new launches.
- **Ownership is rechecked after every await.** Stream resolution, the close
and the launch IPC can each outlive the page or be superseded by a newer
start. A stale step stops without reporting, and a launch that resolves
stale closes the session it just opened.
- **No second player while a launch settles.** A repeat of the same launch is
ignored, or its control stays disabled. Movie pages refuse or disable every
other start of that title until the launch settles. Series pages hold the
latest episode choice and, once the launch settled, replace the player it
opened, only while that series is still on screen.
- **Pending starts are owner-scoped.** A start still resolving holds the
actions of its own title only: another title shown by the reused page is
not blocked by it. Movie hosts track starts with
`createPendingPlaybackStart` (`@iptvnator/portal/shared/util`): only the
latest start may clear the flag, and `isPendingFor(owner)` answers for one
owner. The Stalker movie hosts, whose starts wait on a portal round trip,
also `retire(owner)` when the selection leaves it, so a start that never
settles does not keep the flag set on a return to the same title. A movie's
"Reset progress" is scoped the same way.
- **Two gates are page-wide.** The Xtream movie page refuses Play, Start
over, source switches and the menu launch while an external launch it made
has not settled. A series page runs one watched or reset batch at a time,
whichever series is shown; the Stalker page also holds episode starts until
that batch settles.
Owner keys and queueing are provider contracts:
| Host | Contract |
| --- | --- |
| Xtream series | [Forced external launches from detail pages](./xtream-portal-compatibility.md#forced-external-launches-from-detail-pages) |
| Xtream movie | [Menu launch and reset follow the primary button](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button) |
| Stalker series and movies | [Forced External Launches](./stalker-portal.md#forced-external-launches) |
## Series Quick Start CTA ## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in Xtream and Stalker series detail views share the quick-start decision helper in
+63
View File
@@ -1559,6 +1559,69 @@ Core decision logic and normalization are centralized in:
- `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts` - `libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts`
- `libs/portal/stalker/data-access/src/lib/models/*.ts` - `libs/portal/stalker/data-access/src/lib/models/*.ts`
## Forced External Launches
"Open in external player" needs a `create_link` round trip before it reaches
MPV/VLC. The shared rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages);
the Stalker keys and queues are:
Series (`StalkerSeriesViewComponent`, `stalker-series-launch-queue.ts`):
- Pending starts and the launch queue's held choices are keyed by
`playlist:series` (`currentSeriesKey`). The view is reused across series
and provider ids collide across playlists, so one series settling never
drops what another holds.
- A start is pending for its series from the click until it settles. A forced
launch stays pending through the close of the previous player, the launch
and the release of a held choice. The pending flag disables the hero button
and the menu's external-player and watched rows.
- Before launching, an episode of the same series still running externally is
closed (`replaceOwnedExternalSession`). The request is rechecked after that
close and after the launch IPC; a superseded launch closes the session it
opened.
- An episode chosen while a forced launch of its series is mid-flight is held
(`StalkerSeriesLaunchQueue.hold`); the latest choice per series wins. On
release it is dropped when the series is no longer shown. Otherwise
`replacePlayer` closes what the launch opened before the choice starts, and
an unconfirmed close drops the choice.
- An episode chosen while a watched or reset batch runs is held in one slot
tagged with its series; the last choice wins. When the batch settles it
goes through the usual gates only if that series is still shown: episode
identities overlap across series.
Movies (`createStalkerVodDetailActions`, used by the catalog detail, the
collection detail and search):
- A repeat for the same `playlist:movie` while its launch is in flight is
ignored, also after leaving the movie and returning to it. Launches of
other movies are not held back.
- The launch joins the host's starts (`beginPendingStart`): it supersedes an
earlier start, is dropped once a later one begins, and keeps Play, Start
over, the watched toggle and the menu rows disabled until it settles.
- The resolved stream is discarded when the movie is no longer selected or a
newer start took over. Movie and series ids collide, so the catalog and
collection details include the content type in the selection check; in
search, a switch to a series changes the playback owner instead, which
supersedes the launch. Otherwise the movie's own external
session is replaced, the host's `beforeExternalLaunch` hook runs (the
catalog and collection details close their inline player there), and the
launch is sent. A launch that resolves after either condition changed
closes the session it opened; one that fails by then is not reported.
- "Reset progress" counts as a pending start of the movie until the write
lands, so a start made meanwhile cannot resume from the row being cleared.
- The pending start is owner-scoped (`createPendingPlaybackStart`). Each host
retires it when the selection leaves the owner; that clears the pending
flag, not the repeat guard of a launch still in flight.
Regression coverage: `stalker-series-launch-queue.spec.ts`,
`stalker-series-view.component.spec.ts`,
`stalker-series-view.season-watch.spec.ts`,
`stalker-vod-detail-actions.spec.ts`,
`stalker-vod-playback-controller.spec.ts` and, in
`libs/portal/shared/util/src/lib/`, `pending-playback-start.spec.ts` and
`replace-owned-external-session.spec.ts`.
## Favorites and Recently Viewed ## Favorites and Recently Viewed
Current implementation is shared via Stalker-specific helpers: Current implementation is shared via Stalker-specific helpers:
+26
View File
@@ -757,6 +757,32 @@ lookup comes back empty, because "never watched" is an answer: the button must
read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the
route's own row changes nothing; the loaded position already IS that copy's. route's own row changes nothing; the loaded position already IS that copy's.
## Menu launch and reset follow the primary button
The "…" menu acts on the copy the primary button acts on. The shared launch
rules are in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
- "Open in external player" and "Start over" are host-owned
(`VodDetailsMenuBindings.openExternal` / `restart`); the menu service never
builds a playback itself. The route forces MPV/VLC for the pinned copy from
that copy's own resume point (`playPinnedSource` with `player` and
`replacePlaying`), also while that copy already plays: it is relaunched,
never swapped for the route copy. Only an `unavailable` pin falls through
to the route copy's Resume or Play.
- "Reset progress" clears the row of `primaryTarget`: the pinned copy's own
row, otherwise the route copy's.
- Resets in flight are a list of targets
(`VodDetailsPlaybackService.pendingResets`, `vod-details-reset-target.ts`),
not one flag: the reused page can show another movie and come back, and
resets of one copy can overlap, so each reset removes only its own entry.
- A start is refused (`startResolvedPlayback`) while a launch this page made
has not settled or the list holds the copy the page currently acts on
(`resetTarget`). `startBlocked` disables Play, Start over and the menu
launch on those conditions and while a matched session is still
`launching`; the menu rows are also held while a start is pending. A reset
still writing for another copy does not block it.
## Provider codec metadata ## Provider codec metadata
`info.video` / `info.audio` come back in two shapes: the declared string array `info.video` / `info.audio` come back in two shapes: the declared string array
@@ -405,3 +405,40 @@ deduplicated list, `hasMoreContent` derives from accumulated length vs
and the facade maps page 0 to the skeleton and later pages to the tail and the facade maps page 0 to the skeleton and later pages to the tail
spinner. These catalog/search surfaces use incremental loading instead of spinner. These catalog/search surfaces use incremental loading instead of
page buttons. page buttons.
## Forced external launches from detail pages
The "…" menu's MPV/VLC launch follows the shared rules in
[Forced External Launches From Detail Pages](./embedded-inline-playback.md#forced-external-launches-from-detail-pages).
The movie page's pin and reset rules are in
[VOD Multi-Source](./vod-multi-source.md#menu-launch-and-reset-follow-the-primary-button).
The series page keeps its launch state at module level in
`serial-details-external-launch.ts`, so it outlives a recreated page:
- The owner is `playlist:series` (`launchOwner()`). It changes when the page
shows another series and is null once the page is gone.
- Forced launches of one owner run on one chain. A later launch waits for the
earlier one to settle, closes the owner's running episode session and then
launches. Each step rechecks the owner, and a launch that resolves after the
page left the owner closes the session it opened.
- The duplicate guard is keyed by page token plus episode. The token
(`pageToken()`) is owner, page instance and visit, so a launch left behind
by an earlier visit of the same series does not swallow a launch from the
reopened page; that launch queues on the owner's chain.
- While a forced launch of the owner is pending (`forcedLaunchPending`), a
start that does not force a player is queued instead of started. One choice
is kept per owner, the latest wins, and it carries the host and `start` of
the page that made it. Once the chain settles, the player the launch opened
is closed first while the owner stays pending. The choice is dropped when
that page no longer shows the owner or the close was not confirmed.
- The pending flag also disables the menu's external-player row and the
season and series watched actions, and counts as active playback for "Reset
progress".
- The launch-position marker and a launch-failure message apply only while
the page token is unchanged.
Regression coverage: `serial-details-external-launch.spec.ts` (chain,
duplicate guard, queued choice), `serial-details-playback.service.spec.ts`
(page token) and, for the external-player and reset rows,
`libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts`.
@@ -198,10 +198,11 @@ function closeOwnedEpisodeSession(
* still running externally is closed first: with instance reuse off a * still running externally is closed first: with instance reuse off a
* second detached player would start beside it. When that close fails, or * second detached player would start beside it. When that close fails, or
* the user moved on while it ran, the running player stays and nothing new * the user moved on while it ran, the running player stays and nothing new
* launches. A repeat of the same episode before its launch settled * launches. A repeat of the same episode before its launch settled is
* (Electron publishes the session only afterwards) is ignored; another * ignored (the session cannot stand in for this guard: Electron publishes
* episode of the series waits for that launch to settle and then replaces * it only once the launch IPC arrives, as `launching` until that resolves);
* it like any later start. * another episode of the series waits for that launch to settle and then
* replaces it like any later start.
*/ */
export async function openEpisodeExternally( export async function openEpisodeExternally(
host: SeriesExternalLaunchHost, host: SeriesExternalLaunchHost,