Files
iptvnator/docs/architecture/embedded-inline-playback.md
T
4gray fd96b85c19 feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
2026-08-08 01:04:39 +02:00

803 lines
46 KiB
Markdown

# Embedded Inline Playback
This document records the current contract for embedded playback in portal detail views.
## Summary
- Embedded web players are `videojs`, `html5`, and `artplayer`.
- `embedded-mpv` exists as a hidden desktop experimental harness backed by a native MPV addon. macOS and Windows use in-process `libmpv`; Linux uses an X11/Xwayland child window with an out-of-process `mpv --wid` backend.
- Controlled external players are `mpv` and `vlc`.
- macOS `.app` bundle paths are resolved only for real MPV/VLC apps. IINA may
launch through the MPV path field when the user supplies an executable path
such as `/Applications/IINA.app/Contents/MacOS/iina-cli`, but IPTVnator
controls, position polling, and instance reuse are not guaranteed for IINA.
- Flatpak launches external players on the host via `flatpak-spawn --host`.
- Live playback stays inline in dedicated live layouts.
- VOD and series detail playback stays inline on canonical detail, collection,
favorites, recent, and search surfaces.
- Xtream and Stalker series detail heroes expose a quick-start CTA driven by
saved episode playback positions.
- Embedded playback UI is always hosted by the current view. `PlayerService`
launches MPV/VLC only and does not open an embedded-player dialog.
- Browser-player failures are diagnosed client-side and produce ranked,
user-triggered recovery actions without changing the saved player setting.
## Scope
Inline embedded playback is required for these VOD/series entry points:
- Xtream VOD detail route
- Xtream series detail route
- Stalker VOD detail view
- Stalker series detail view
- unified favorites collection details
- unified recently viewed collection details
- Stalker advanced search result details
Collection/search VOD surfaces that expose embedded playback must host
`ResolvedPortalPlayback` inline state locally. They must not call
`PlayerService.openPlayer(...)` or `PlayerService.openResolvedPlayback(...)` to
create embedded UI.
## Logical Playback Identity
Every inline playback host owns a required, URL-independent
`playbackSessionKey`. Live hosts derive it from the playlist/source and the
current channel identity; an M3U session uses `Channel.id` rather than a mutable
stream or catch-up URL. VOD and series hosts use the route or catalog
content identity, with series episode coordinates. Stalker episode identity
also includes the explicit series mode, normalized parent, exact season key,
season number, and episode number; synthesized episode hashes are not identity.
Mapped episodes retain the original provider command or episode ID for playback
resolution. Recovery ownership never serializes that value into the session
key or retained recovery state; its recovery-ownership use is limited to a
short-lived exact pending-request snapshot/guard that locates the selected
episode and rejects stale or out-of-order completion. This keeps token refreshes
in one logical session while ensuring colliding tracking hashes cannot select a
sibling episode.
After a Stalker episode mounts, the host retains only a frozen structural
identity (source, normalized parent, series mode, exact season key, season and
episode coordinates, and the credential-free session key). Each metadata,
navigation, or autoplay read resolves those coordinates against the current
`mappedSeasons()` value. Same-owner provider or TMDB refreshes therefore update
the mounted episode without changing its session key; if the episode disappears,
or if multiple episodes claim the same structural coordinates, the mounted
commands fail closed. The command/ID-bearing identity never outlives the pending
playback request.
Shared wrappers (`VodDetailsComponent` and `PortalInlinePlayerComponent`) pass
the key unchanged to `WebPlayerViewComponent`.
Hosts invalidate both committed playback and pending resolution when the
canonical owner changes (playlist/source, content, and mode where applicable).
Refreshing data for the same canonical owner preserves the mounted player.
Collection UIDs remain a separate persistence concern; legacy M3U collection
UIDs continue to use stream URLs so saved favorites ordering remains compatible.
Temporary portal URLs, catch-up URLs, headers, DRM data, and alternative source
payloads are transport details and must not change this logical identity. A
content or episode change must produce a different key. The serialized key is
created with `createPlaybackSessionKey()` from `@iptvnator/playback/util` so
delimiter-bearing provider IDs remain unambiguous.
The key is host-owned and stable only for the logical selection represented by
that mounted host. In particular, M3U identity uses the current playlist/source
identity and `Channel.id`; it does not claim durability across a refresh that
replaces either identity. Recovery state contains this credential-free key,
target IDs, generation counters, and a finite VOD resume position. It never
uses a playback URL, headers, DRM configuration, or credentials as identity.
Inline hosts capture this identity before asynchronous playback resolution. A
completion may mount only while the same owner is current; stale completions
and embedded starts without a complete canonical identity are ignored without
replacing an already committed session.
## Embedded MPV Harness
The repository now contains a first-pass native embedded MPV harness for Electron:
- shared setting id: `embedded-mpv`
- native addon owner: `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`
- IPC bridge: `/Users/4gray/Code/iptvnator/apps/electron-backend/src/app/events/embedded-mpv.events.ts`
- renderer host: `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts`
- native architecture and release-readiness details: `/Users/4gray/Code/iptvnator/docs/architecture/embedded-mpv-native.md`
Current contract:
- desktop only: macOS, Windows x64, and Linux x64 under X11/Xwayland
- experimental opt-in
- enabled in local development only when `IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1`
- Linux Wayland sessions must start Electron through Xwayland, for example with `pnpm nx run electron-backend:serve-electron --args=--ozone-platform=x11` during local development or `iptvnator --ozone-platform=x11` for a packaged app
- enabled in packaged desktop builds only when the bundled native addon/runtime prerequisites are present; Linux additionally requires an `mpv` executable on `PATH`
- uses IPTVnator-owned controls and `ResolvedPortalPlayback` payloads
- uses the libmpv render API on macOS and renders through an IPTVnator-owned native `NSView`
- uses mpv `wid` embedding on Windows and Linux through IPTVnator-owned native child windows
- Linux starts `mpv --wid=<x11-window>` out of process so MPV does not share Electron's FFmpeg or graphics symbols
- Linux controls that out-of-process MPV instance through a private JSON IPC socket so duration, position, pause, volume, and seek state come from MPV instead of renderer guesses
- Linux starts that MPV process with `WAYLAND_DISPLAY` removed, `XDG_SESSION_TYPE=x11`, and X11 video output options; otherwise MPV can pick Wayland inside a Wayland desktop session, ignore `--wid`, and open a separate window instead of embedding
- macOS/Windows default to libmpv's OpenGL render backend with `hwdec=auto-safe`
- keeps the previous software renderer as a debug fallback via `IPTVNATOR_EMBEDDED_MPV_RENDERER=sw`
- emits lightweight native diagnostics when `IPTVNATOR_TRACE_EMBEDDED_MPV=1` is set; Linux also writes MPV's own trace log to `/tmp/iptvnator-embedded-mpv.log`
- exposes an IPTVnator-owned fullscreen button that uses the renderer fullscreen API and resyncs the native MPV view bounds after fullscreen transitions
- auto-hides IPTVnator-owned controls while playback is active and restores them on pointer/focus interaction
- exposes audio-track metadata from MPV and switches tracks through the `aid` property without reloading the stream
- passes VOD/episode resume offsets to MPV through the `loadfile` options map; live catchup URLs are treated as already-positioned streams
- applies the initial volume during session creation and uses async libmpv control calls after startup where the in-process libmpv backend is active
- VLC remains external-only
Current limitation:
- the current feasibility harness is still experimental and platform-specific
- the original macOS `wid` embedding path produced audio with a black video surface inside Electron, so the harness now avoids foreign-window embedding on macOS
- Windows and Linux use the mpv `wid` path and still need OS-native packaged smoke coverage before public exposure
- Linux native Wayland is not supported in this implementation; the Electron process must have `DISPLAY` through X11/Xwayland and a real X11 window handle
- the OpenGL render path avoids the old per-frame `CGImage` copy path, but it still needs broader interaction, resize, and packaging coverage
- startup deadlocks seen during early macOS playback bring-up are mitigated, but the feature is still kept behind the explicit experiment flag until more interaction and packaging coverage is proven
- because of that, the setting is auto-sanitized back to the default inline player unless support detection reports that the experimental runtime is available
- this follows the rollout gate: keep the native work in-tree, but do not leave it user-facing until playback, resize, focus, and packaging are stable
## Two-State Detail Layout (Browse ↔ Watch)
Portal VOD/series detail pages are hosted by the shared
`PortalDetailShellComponent`
(`libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`),
which owns the page scroll container and two layout states:
- **Browse** (`playbackActive=false`): the hero (`ContentHeroComponent`,
now a natural-height, non-scrolling block) renders poster, title, chips,
description, credits, and actions. Episodes render below.
- **Watch** (`playbackActive=true`, bound by hosts to
`inlinePlayback() !== null`): the hero collapses (~300ms CSS morph,
disabled under `prefers-reduced-motion`), the host-projected
`[detail-player]` slot renders the inline player at full content width,
and the shell renders an About block (`ContentAboutComponent`) below the
`[detail-episodes]` slot so the hero metadata stays reachable.
Contracts:
- Hosts provide hero chips/meta/actions as `*appDetailTags` / `*appDetailMeta` /
`*appDetailActions` templates; the shell stamps them into the hero and again
into the About block. Degradation stays "missing → not rendered".
- The shell never wraps the `[detail-player]` slot in a conditional; the
host's `@if (inlinePlayback())` is the only creator/destroyer of the
player subtree, so shell state changes cannot recreate the `<video>`.
- **External MPV/VLC sessions do not flip the layout to watch** — browse
layout stays, and the primary CTA keeps its "Stop <player>" behavior.
- Escape closes inline playback: the shell emits `closePlayerRequested`
when playback is active, the event was not `defaultPrevented`, and no
element is in browser fullscreen; hosts wire it to `closeInlinePlayer()`.
- The now-playing bar separates two exits: the back arrow emits
`backClicked`, which hosts wire to their route-level `goBack()` (straight
back to the list — everything browse offers is also visible in watch, so
a two-step unwind would be ceremony); the "Close player" button and
Escape emit `closed`/`closePlayerRequested` and return to browse without
navigating.
- Entering watch scrolls the shell to the top; leaving keeps the scroll
position.
### Inline player stage (theater + ambient fill)
`PortalInlinePlayerComponent`
(`libs/ui/playback/src/lib/portal-inline-player/`) wraps the projected
`WebPlayerViewComponent` in a `.player-shell__viewport` "theater stage".
The stage spans the full content width and is capped at
`min(70vh, 720px)`; on wide-short windows it becomes wider than 16:9. The
player is sized as the largest 16:9 box that fits the stage height and is
centered, so the leftover is always the stage's own black background — never
a stray strip of app surface. This is the YouTube-style letterbox and is the
default behavior for every inline engine.
The optional `playerAmbientMode` setting (Settings → Playback, default off,
shown only for the built-in web players) renders a blurred, dimmed copy of the
poster (`ResolvedPortalPlayback.thumbnail`) behind the player via the
`--ambient-image` custom property, turning the letterbox margins into
atmosphere (YouTube "Ambient mode" / Netflix backdrops). The component also
enforces the web-player scope at runtime (HTML5, Video.js, ArtPlayer): with
Embedded MPV selected, a persisted `playerAmbientMode=true` never renders the
layer, keeping extra DOM out of the native-video compositing path. Live
channels are excluded (their `thumbnail` is a logo), and only
`http(s):`/`data:` poster URLs are accepted to avoid CSS `url()` breakout.
### Up Next side rail (series)
For inline **series** playback the stage can trade its centered letterbox for
a Netflix/Plex-style layout: the player docks left and the leftover column
becomes an "Up Next" episode rail (`app-up-next-rail`,
`libs/ui/playback/src/lib/portal-inline-player/up-next-rail.component.ts`).
The rail lists the currently playing episode (highlighted, click-inert)
followed by the rest of its season and a spillover into the following
seasons, with per-episode watch-progress bars from playback positions.
- Data flow: the hosts (Xtream `SerialDetailsComponent`, Stalker
`StalkerSeriesViewComponent`) build the entries with
`buildUpNextRailItems()` (`up-next-rail.util.ts`) from their
season→episodes map, the inline episode state, and the playback-position
map, and pass them into `PortalInlinePlayerComponent` via the
`upNextEpisodes` input. Selection comes back through
`upNextEpisodeSelected`, carrying the host's raw episode object, and is
routed into the host's existing episode-play flow — the same path the
season container uses.
- Width gating: a ResizeObserver on `.player-shell__viewport` feeds the
component's `stageSize` with the stage's **border-box** size, and the gate
computes the width the rail would actually receive — stage minus the docked
layout's padding, minus the 16:9 player sized to the remaining height, minus
the flex gap (`RAIL_STAGE_PADDING` / `RAIL_STAGE_GAP`, kept in sync with the
stylesheet). The rail docks in only when that is ≥ 320px
(`UP_NEXT_RAIL_MIN_WIDTH`). Measuring the border box matters: the docked
modifier adds padding to the same element being observed, so a content-box
measurement would change the input the moment the rail appears and could
oscillate around the threshold. On near-16:9 or taller windows the rail
auto-hides and the centered theater/ambient behavior above remains.
- Lazy Stalker seasons: Ministra VOD-series seasons carry no episodes until
their tab is opened, so `StalkerSeriesViewComponent` prefetches the season
after the playing one while inline playback is active — otherwise the rail's
next-season spillover would silently stop at the current season's end. The
prefetch is claimed synchronously and answered seasons (including genuinely
empty ones) are never re-requested: a failed request leaves `episodes` empty
with `isLoading` back to false, which would otherwise re-run the effect that
issued it and loop. A _failed_ request releases the claim but is pinned to
the episode that triggered it, so a transient portal error retries on the
next playback change instead of either looping or giving up permanently.
- Gating mirrors ambient mode: the `playerUpNextRail` setting (Settings →
Playback, **default on**, shown only for the built-in web players) plus a
runtime web-engine check; the rail also requires
`contentInfo.contentType === 'episode'` and non-live playback, so movies
and live channels always keep the centered stage.
- Layering: the rail is an opaque panel rendered on top of the stage, so the
ambient fill stays behind it and shows in the flexible gap between the
docked player and the rail on very wide stages.
Season navigation inside `SeasonContainerComponent` uses season tabs
(`SeasonTabsComponent`; a dropdown beyond 6 seasons) instead of the old
seasons-grid + "Back to seasons" level. A season is auto-selected
(inline-playing episode's season → most recently updated in-progress
episode's season → first) and the auto-selection emits `seasonSelected`,
so host lazy-load/enrichment hooks (Stalker VOD-series episode fetch,
TMDB season fetch, Xtream `enrichSelectedSerialSeason`) fire on open
without a click. Switching tabs never stops playback; a "back to playing
episode" chip appears when the playing episode is outside the opened
season. Season descriptions come from `get_series_info` seasons (Xtream)
or `TmdbEnrichmentService.getSeason` (Stalker).
## Components
Shared detail layout shell:
- `/Users/4gray/Code/iptvnator/libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.ts`
Shared inline player shell:
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts`
Xtream detail hosts:
- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts`
Stalker detail hosts:
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts`
- `/Users/4gray/Code/iptvnator/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts`
Embedded playback does not have a fallback dialog path.
`PlayerService.openResolvedPlayback(...)` remains the MPV/VLC external launch
entry point; for embedded players it returns without creating UI.
Diagnostics, recovery policy, and recovery UI:
- `/Users/4gray/Code/iptvnator/libs/playback/util/src/index.ts`
- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts`
## Playback Decision Rule
When a detail view starts playback:
1. Resolve or construct a typed playback payload.
2. Check the active player setting.
3. If the player is embedded, render the inline player inside the current detail view.
4. If the player is external, hand the same payload to `PlayerService` for MPV/VLC playback.
The detail or collection/search host owns inline state. `PlayerService` is not
an owner of embedded UI playback state.
After a successful external episode launch, the detail host immediately
persists that episode as the latest playback-position entry, preserving an
existing resume offset or using zero for a newly opened episode. MPV/VLC
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.
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
`libs/portal/shared/util/src/lib/series-quick-start.ts`.
The helper flattens the loaded season/episode map, sorts seasons and episodes in
natural order, and returns the hero CTA state.
Current contract:
- the CTA shows the action label plus a compact episode target such as
`S01E02 · Episode title`
- if an episode is in progress, resume the latest updated in-progress episode
with its saved offset
- if the newest episode entry is a successful external-player launch marker
with no meaningful progress yet, target it with `Play episode N` instead of
falling back to the first episode
- if no episode is in progress, play the first unwatched episode in season order
- if watched episodes end at a season boundary, play the first episode of the
next loaded season
- if every loaded episode is watched, render a disabled completed state
The click path must continue through each detail host's normal episode playback
method so recent-item updates, inline/external player selection, resume offsets,
and playback-position saving keep the same behavior as manual episode clicks.
## Series Inline Episode Continuation
Xtream and Stalker series detail views own current-episode state and pass a
`SeriesPlaybackNavigation` payload through `PortalInlinePlayerComponent` and
`WebPlayerViewComponent` to the active embedded player.
Current contract:
- Video.js, HTML5, ArtPlayer, and embedded MPV emit `playbackEnded` only for
real media EOF/`ended` events.
- Teardown, replacement, manual close, player reload, idle state, and playback
errors must not emit `playbackEnded`.
- Series previous/next controls render only when the series navigation payload is
present. Movies, live streams, radio streams, and non-series VOD must not show
those buttons.
- The shared navigation payload contains `canPrevious`, `canNext`, and
`autoplayEnabled`. Player controls disable previous on the first episode and
next on the last episode of the current season.
- Autoplay is enabled by default for series playback. On `playbackEnded`, the
portal detail host starts the next episode only when `canNext` is true for the
current season.
- Autoplay and Next never cross season boundaries or lazy-load another season.
Quick start remains the flow that may choose an episode from another loaded or
newly loaded season before playback begins.
- Previous switches directly to the previous episode in the current season. It
does not implement a restart-threshold behavior.
## Codec And Container Diagnostics
The shared `WebPlayerViewComponent` is the central browser-player viewport for
M3U, Xtream, and Stalker inline playback, including live streams opened from
favorites and recently viewed collections. Video.js, HTML5, and ArtPlayer
report native media errors, hls.js errors, Video.js/VHS errors, Shaka errors,
mpegts.js errors, and HLS manifest codec metadata into the DOM-free classifiers
exported by `@iptvnator/playback/util`.
The canonical recovery flow is:
```text
engine public error
-> sanitized PlaybackDiagnostic (@iptvnator/playback/util)
-> recommendPlaybackRecovery(context)
-> ranked maximum-three action model
-> WebPlayerView session-local user action
```
Engine adapters own public-event collection and sanitation. The playback
utility owns diagnostic contracts, evidence classification, source/engine
mapping, capability contracts, and the pure recommendation policy.
`WebPlayerViewComponent` owns only the current session state and execution of a
user-selected action. Diagnostic producers do not decide which player to show,
and the recommendation policy does not inspect Angular, the DOM, settings,
storage, or Electron globals.
The diagnostics remain client-only:
- no ffprobe or server-side probing
- no extra manifest fetch beyond the active player
- no automatic failover to an external player
- no embedded MPV diagnostics
Supported diagnostic codes are:
- `unsupported-container`
- `unsupported-codec`
- `media-decode-error`
- `network-error`
- `browser-access-error`
- `drm-or-encryption`
- `unknown-playback-error`
Native `MediaError` code 4 alone is not codec evidence. A source with a known
browser-incompatible container remains `unsupported-container`; otherwise, a
code 4 error without stronger evidence is `unknown-playback-error`. An explicit
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
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.
The engine type must exactly match an installed `videojs.Error` value;
unrecognized values remain unknown. Exact network identifiers, HTTP 4xx/5xx
status, and standard network code 2 produce `network-error`; exact
`streamingfailedtodecryptsegment` or standard encrypted code 5 produce
`drm-or-encryption`. A generic VHS code 3 remains
`unknown-playback-error` because VHS also assigns code 3 to terminal internal
objects and strings that do not establish a decode cause. Non-VHS native code
3 keeps its standard media-decode meaning.
VHS stage evidence is derived only where the public engine identifier names
the operation: HLS playlist parsing is `playlist`, DASH manifest parsing is
`manifest`, and select/decrypt/transmux/append segment errors are `segment`;
everything else is `unknown`. In particular, IPTVnator does not read internal
`requestType` values to guess manifest, playlist, segment, or key stages. VHS
handles retries, rendition exclusions/re-inclusions, segment timeout recovery,
and request aborts before a final player error; IPTVnator observes only the
public terminal event and does not subscribe to undocumented recovery events
or private loaders.
Video.js/VHS error messages, request or response URLs, headers, xhr objects,
response text/bodies, credentials, request types, and arbitrary metadata are
neither retained nor rendered. Technical details contain only the sanitized
stage, exact/unknown engine type, standard/unknown media error code, terminal
disposition, and validated HTTP status. The active playback URL remains
available only through the pre-existing playback metadata used by Retry, Copy
URL, and explicit external-player actions; it is never copied from VHS error
metadata.
HLS.js errors cross one shared sanitizer boundary before HTML5 or ArtPlayer can
emit a diagnostic. The boundary retains only allowlisted engine `type` and
`details` identifiers, the final fatal/recoverable disposition, a stage derived
from exact details (`manifest`, `level`, `segment`, `key`, `media`, or
`unknown`), a structured failure kind, and a validated `response.code`.
Recoverable events return no terminal diagnostic. Exact codec, decrypt/key
system, network/timeout/HTTP, and media/mux evidence select the corresponding
diagnostic; insufficient evidence stays `unknown-playback-error`.
HLS.js does not expose a reliable structured CORS, mixed-content, CSP, or
private-network-access cause. Status zero and generic fetch failures therefore
remain network/unknown evidence instead of becoming browser-access guesses.
Error URLs, request context and headers, loader/network objects, response
URL/text/body, error messages, reasons, and arbitrary provider payloads are
neither retained nor rendered. Technical details show only the sanitized stage,
failure, engine type/details, disposition, and HTTP status. The active playback
URL remains available to the pre-existing retry, copy, and explicit
external-player workflows; it is not copied from the HLS error payload into
the evidence or technical details. HLS startup development logs are event-only:
they do not include provider-supplied channel names or source URLs.
Shaka Player `5.2.2` errors cross a separate structured boundary before the
HTML5 or ArtPlayer DASH session emits a diagnostic. Version-locked tests assert
the installed Shaka version plus the public `Severity`, `Category`, and selected
online-playback `Code` values used by the boundary. Evidence retains only
validated severity/category/code, the lifecycle disposition, an exact
code-derived stage and failure kind, and a validated HTTP status. A direct
`Network.BAD_HTTP_STATUS` may expose `data[1]` as the status; the same status is
accepted from the documented nested networking error for
`Drm.LICENSE_REQUEST_FAILED` and
`Drm.SERVER_CERTIFICATE_REQUEST_FAILED`. No other `error.data` value is read.
A recoverable Shaka `error` event does not become a terminal playback
diagnostic because the engine continues its retry/recovery lifecycle. A
critical event is terminal. A rejected `Player.load()` is also terminal even
when its last networking error still carries recoverable severity, because the
load lifecycle has ended; the structured evidence preserves both facts as
`severity=recoverable` and `disposition=terminal`. Unknown event severity does
not prove terminal failure and is ignored. Exact public code/category pairs may
classify network, DRM/encryption, manifest/parsing, or media/decode failures.
Ambiguous evidence stays `unknown-playback-error`: in particular, the Manifest
category alone is not container incompatibility, and Shaka messages never infer
CORS, codec, DRM, container, or stage. The public critical
`STREAMING_ENGINE_STARTUP_INVALID_STATE` code remains exact evidence while its
stage and failure stay unknown because the code does not identify a user-facing
media cause. Public DASH text-parser codes are also retained exactly; their
`TEXT` category proves the parser subsystem, but not a safe manifest, segment,
or media cause, so stage and failure remain unknown.
A failed public `Player.isBrowserSupported()` preflight is not a Shaka error
and therefore retains fully unknown technical evidence instead of being
mislabelled as an unsupported container. The app adds only the exact,
enumerated `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker to that
otherwise unknown diagnostic. This Shaka/DASH runtime-preflight marker is the
sole unknown-code exception that can rank configured MPV/VLC actions, and only
for clear, externally transferable DASH in a managed-external runtime. Generic
unknown diagnostics remain Retry/alternative-source only. PWA capability and
KODIPROP DRM suppress the external actions because those players are absent or
the launch contract cannot transfer the key configuration.
Shaka messages, URLs, headers, request/response bodies, credentials,
license/key payloads, and arbitrary `error.data` objects are neither retained
nor rendered. Technical details show only sanitized stage, failure, severity,
category, code, disposition, and optional HTTP status. Unsupported playlist
DRM uses a fixed safe description rather than echoing provider license
configuration.
`network-error` is reserved for provider/network loading failures. Engines that expose concrete browser security evidence, such as CORS, mixed content, Content Security Policy, or private-network-access blocks, use `browser-access-error` so the UI can explain that the browser player was blocked before playback reached decoding.
mpegts.js `1.8.0` errors cross one shared structured boundary before the HTML5,
Video.js, or ArtPlayer owner emits a diagnostic. Version-locked tests compare
the installed public `ErrorTypes` and `ErrorDetails` exports with the accepted
contract. Evidence retains only an exact type/detail pair, terminal
disposition, a pair-derived stage and failure, and a validated HTTP 4xx/5xx
status from the top-level `info.code` slot of
`NetworkError + HttpStatusCodeInvalid`.
Exact public pairs classify HTTP/timeout/exception as network failures,
`FormatUnsupported` as an unsupported container, `CodecUnsupported` as an
unsupported codec, and `FormatError`/`MediaMSEError` as media failures.
`UnrecoverableEarlyEof` remains a `media-decode-error` that may rank a distinct
engine or external player:
mpegts.js has already exhausted its internal finite-source early-EOF recovery,
and another demuxer may tolerate the truncated transport stream. Mismatched or
unknown pairs fail closed to `unknown-playback-error`.
Arbitrary `info`, messages, URLs, headers, bodies, credentials, and provider
objects are neither retained nor rendered. A generic `Exception` does not
prove CORS, mixed content, CSP, or private-network access, so mpegts.js no
longer creates `browser-access-error` from message text. HTTP and other network
failures do not claim an external decoder will fix the provider response;
container, codec, truncated-stream, format, and MediaSource failures retain
evidence that can rank an explicit MPV/VLC action when the payload and runtime
permit it.
The diagnostic surface covers the inline player viewport when playback fails,
with a compact warning badge, one primary recommendation, at most two secondary
recommendations, and always-available Copy URL and Technical details utilities.
An alternative-source recommendation consumes one of those three slots even
when its bounded source list renders several rows. Technical details contain
the diagnostic code, reporting player/source, detected container/MIME,
video/audio codecs, native browser error fields, and sanitized structured
Video.js/VHS, HLS, Shaka, and mpegts.js evidence. HLS manifest codec metadata
also drives a concise browser-support hint for codecs that Chromium/Electron
commonly cannot decode inline, such as HEVC, AC-3, E-AC-3, DTS, and MPEG-2
video.
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
MKV sources are attempted through Chromium's native Matroska path. Video.js
receives `video/matroska` for `.mkv` URLs and explicit query metadata such as
`extension=mkv` or `container=mkv`; ArtPlayer and HTML5 continue to use their
native video paths. This is container support rather than a universal codec
guarantee: native source or decode failures still produce a diagnostic whose
ranked actions may include MPV/VLC.
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
## Recovery Recommendation Policy
Recommendations change playback paths only when structured evidence supports
that conclusion. A different skin over the same engine family is not a distinct
recovery target.
| Source path | Active engine family | Distinct built-in recommendation |
| ---------------------------------------- | ------------------------------- | -------------------------------- |
| HLS in Video.js | Video.js/VHS | HTML5 through hls.js |
| HLS in HTML5 or ArtPlayer | hls.js | Video.js/VHS |
| MPEG-TS in any web player | shared mpegts.js | None |
| DASH in HTML5 or ArtPlayer | Shaka | None |
| DASH in Video.js | unsupported recommendation path | None |
| Native media/container in any web player | browser native-media | None |
HTML5 is the canonical hls.js alternative to Video.js/VHS; ArtPlayer is not a
second independent hls.js choice. MPEG-TS, DASH/Shaka, and native media never
offer a same-family built-in alternative. Unknown source or engine-family facts
also suppress built-in recommendations.
The pure policy builds the following order, filters the current, unavailable,
incompatible, and already attempted targets, and then returns at most three
actions. It also projects attempted inline target IDs through the validated
canonical source/target capability matrix and filters every engine family that
has already been attempted. HTML5 and ArtPlayer therefore cannot be offered as
separate hls.js recovery attempts. The first surviving action is primary and
later actions are secondary.
| Sanitized evidence | Candidate order |
| ----------------------------------------- | ------------------------------------------------------------ |
| HTTP, timeout, or generic network failure | Retry -> Alternative source |
| Generic unknown playback error | Retry -> Alternative source |
| Exact Shaka browser-unsupported preflight | MPV -> VLC -> Alternative source |
| Browser access/CORS/CSP-class failure | MPV -> VLC -> Alternative source |
| Unsupported codec or container | MPV -> VLC -> Alternative source |
| Media/decode/engine processing failure | Distinct built-in family -> MPV -> VLC -> Alternative source |
| DRM/encryption failure | Compatible built-in path -> Alternative source -> MPV -> VLC |
Network and generic unknown evidence fail closed: they never claim that
changing a decoder will repair the provider response. The exact app-owned
Shaka browser-unsupported runtime-preflight marker above is the only
unknown-code exception. Contradictory or incomplete capability facts fail
closed to Retry and an available alternative source.
External targets require both managed external-player support and a transferable
payload. PWA builds therefore never rank MPV/VLC. Any ClearKey/KODIPROP DRM
payload is non-transferable and suppresses both external targets; the policy
does not infer transferability from a message or URL. Eligible Electron portal
fallback requests keep using the original `ResolvedPortalPlayback`, so the
existing host path can forward its required headers and playback metadata.
## Recovery Session Lifecycle And Privacy
Each mounted `WebPlayerViewComponent` owns one in-memory recovery session for
its required `playbackSessionKey`. Retry and an alternative source for the same
logical content keep the key and attempted-target set. A different channel,
movie, or exact episode changes the key and synchronously clears the diagnostic,
attempts, temporary player override, and handoff position. Destroying the
component also ends the session; the same key in a later component is a new
session. Applying a different source under the same key, including another
catch-up programme, clears only the VOD handoff position; attempts and the
temporary player override remain available for the recovery session.
On a terminal failure, the current binding is accepted only when both its
generation and inline target still match. The current target becomes attempted,
the sanitized diagnostic is stored, and recommendations are reranked. The
`PlaybackBinding` contains exactly `{ generation, target }`. Changes to the
playback URL, headers, DRM, live/VOD mode, target, or reload generation are
instead correlated with a fieldless opaque `Symbol` application token. Source
applications also advance a second fieldless source-revision `Symbol` that
resets the VOD handoff position; target-only switches and Retry leave that
revision stable. None of these ownership objects embed source material, and
recovery ownership state never stores URLs, headers, DRM keys, error payloads,
or credentials. Each rendered web or Embedded MPV application captures the
nullable binding, both opaque tokens, and its live/VOD flag. A time update can
change the resume position only while that exact capture still owns the current
application, so a replaced source cannot repopulate cleared handoff state.
A separate fieldless intent token invalidates on each new source, target, or
reload intent. The application effect synchronizes the content session before
tracking that intent, so clearing a temporary player override is incorporated
into one application instead of scheduling a duplicate Electron header handoff.
Each application start clears both the diagnostic owner and backing signal,
making the prior diagnostic neither retained nor actionable before asynchronous
header setup completes. False or rejected current handoffs leave it clear, and
a stale success, false result, or rejection cannot erase a newer owned
diagnostic or mutate the newer application state.
Selecting a built-in recommendation records the target, immediately detaches
the diagnostic, and installs a temporary local override ahead of the host
override and saved player setting. The new engine receives the latest finite
VOD position as a best-effort resume point; live playback starts at the live
edge. Retry reloads the active target without clearing attempts. Selecting MPV
or VLC records the external target before emitting the existing fallback
request. The system does not infer whether the external process ultimately
played the stream.
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,
resume handoff, and diagnostics are session-local: there is no persistent
history, cross-session learning, correlation, or telemetry.
Only allowlisted public engine evidence crosses the structured sanitizers.
Provider/engine messages, request and response objects, arbitrary `error.data`
or `info`, bodies, URLs copied from error payloads, headers, DRM material, and
credentials do not enter recommendation evidence or recovery ownership state.
The active playback URL remains available only through the pre-existing
playback metadata needed by Retry, Copy URL, and eligible explicit
external-player actions.
`PortalPlayer.openExternalPlayback(playback, player)` remains the forced
external launch API. It sends the resolved playback payload to MPV or VLC
regardless of the current saved player setting, so a recommendation never
mutates preferences.
## External Player Arguments
Electron settings expose optional MPV and VLC command-line argument fields only
when the corresponding external player is selected. The executable path remains a
path-only setting; extra flags are stored separately as `mpvPlayerArguments` and
`vlcPlayerArguments`.
Argument fields are line-oriented: one non-empty trimmed line becomes one argv
entry. IPTVnator prepends those custom entries before its stream-specific runtime
arguments, then keeps the stream URL last. This avoids shell parsing, keeps paths
with spaces safe, and preserves existing settings for users who never configured
extra arguments.
The arguments apply only when IPTVnator spawns a new external player process. If
MPV or VLC instance reuse is active and an existing process is reused, subsequent
streams are loaded through MPV IPC or VLC RC commands and new process arguments
are not re-applied until a fresh process starts.
## Electron External Player Ownership
External MPV/VLC integration is split across focused main-process modules:
- `apps/electron-backend/src/app/events/player.events.ts` registers IPC handlers
and settings updates only.
- `apps/electron-backend/src/app/events/external-player-launch-context.ts`
resolves Flatpak spawning, default executable paths, macOS `.app` bundles,
custom argv merging, spawn specs, and reuse decisions.
- `apps/electron-backend/src/app/events/external-player-playback-request.ts`
builds the effective playback request, including Stalker direct-stream fallback
metadata and external-player request headers.
- `apps/electron-backend/src/app/events/external-player-runtime.ts` owns shared
session tracking, trace logging, renderer notifications, playback-position
forwarding, and user-facing start errors.
- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns MPV process,
socket, reuse, cleanup, and progress polling lifecycle.
- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns VLC process,
RC interface, reuse, cleanup, command parsing, and progress polling lifecycle.
Keep player-specific process state in the MPV/VLC session modules. Shared spawn,
request-header, session-registry, and notification helpers belong in the
`external-player-*` modules so IPC registration stays small and reviewable.
## Flatpak External Players
Flatpak cannot execute host-installed `mpv` or `vlc` binaries directly from the sandbox.
Current contract:
- Flatpak launches external players through `flatpak-spawn --host`.
- AppImage, deb/rpm, snap, macOS, and Windows keep the existing direct process spawn flow.
- VLC keeps the current external-session flow in Flatpak, including the RC port used for progress polling.
- MPV is intentionally reduced in Flatpak: the app does not reuse an existing MPV instance there and does not open the Unix socket bridge used for non-Flatpak progress polling.
- VLC instance reuse is also gated off in Flatpak. Outside Flatpak the user can opt in via the "Reuse VLC instance" setting; the app then keeps a single tracked VLC process and drives subsequent stream loads through its RC interface (`clear` + `add <url> :http-*`) instead of spawning a new window per click.
This keeps non-Flatpak behavior unchanged while allowing Flatpak builds to open host-installed external players.
## Typed Playback Payload
Shared playback payloads live in:
- `/Users/4gray/Code/iptvnator/libs/shared/interfaces/src/lib/portal-playback.interface.ts`
Types introduced:
- `PlayerContentInfo`
- `ResolvedPortalPlayback`
These provide a single shape for:
- `streamUrl`
- `title`
- optional thumbnail and resume start time
- playback-position metadata
- optional external-player headers and request metadata
## Xtream Behavior
Xtream detail views already own canonical routes, so they construct playback locally and decide inline vs external locally.
Behavior to preserve:
- resume/playback position continues saving from `timeUpdate`
- back navigation clears inline playback with the route
- favorites, recent, and search still route into canonical Xtream detail screens before playback
## Stalker Behavior
Stalker previously resolved playback and opened UI in the same method.
Current contract:
- `resolveVodPlayback(...)` returns a `ResolvedPortalPlayback`
- `createLinkToPlayVod(...)` remains as a compatibility API but collection,
search, and canonical detail views use the resolver directly
- Stalker detail, collection, and search views decide inline vs external locally
This keeps:
- inline/store-state detail navigation intact
- series and VOD-as-series support intact
- external MPV/VLC launches unchanged
## Playback Position Saving
The old dialog path saved playback positions from inside the removed Xtream
player dialog.
The new contract is:
- inline detail hosts listen to `timeUpdate`
- each host throttles saves
- each host persists via existing playback-position infrastructure
This avoids coupling inline UI state to a global dialog.
## Future Migration Rule
If a non-detail surface needs embedded playback:
- give that surface a canonical inline host
- switch it to `ResolvedPortalPlayback`
- do not move portal-specific navigation into `PlayerService`
The preferred direction is view-owned inline playback, not a larger dialog manager.