diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index d03a8e67e..dca9795c3 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 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 Xtream and Stalker series detail views share the quick-start decision helper in diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index ccc755405..8d0520070 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -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/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 Current implementation is shared via Stalker-specific helpers: diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index ebd6e1cd2..11c13119b 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -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 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 `info.video` / `info.audio` come back in two shapes: the declared string array diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 6545321e0..bf3fb37da 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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 spinner. These catalog/search surfaces use incremental loading instead of 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`. diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts index 1b7b16a75..699069e5b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-external-launch.ts @@ -198,10 +198,11 @@ function closeOwnedEpisodeSession( * still running externally is closed first: with instance reuse off a * 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 - * launches. A repeat of the same episode before its launch settled - * (Electron publishes the session only afterwards) is ignored; another - * episode of the series waits for that launch to settle and then replaces - * it like any later start. + * launches. A repeat of the same episode before its launch settled is + * ignored (the session cannot stand in for this guard: Electron publishes + * it only once the launch IPC arrives, as `launching` until that resolves); + * another episode of the series waits for that launch to settle and then + * replaces it like any later start. */ export async function openEpisodeExternally( host: SeriesExternalLaunchHost,