From fd96b85c19e4bf21caaff818b607e4fa70d2bd9b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:04:39 +0200 Subject: [PATCH] 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 --- .changes/playback-recovery-recommendations.md | 10 + .changes/playback-runtime-guidance-fixes.md | 6 + .changes/playback-stable-session-ownership.md | 9 + .gitattributes | 1 + AGENTS.md | 63 +- CLAUDE.md | 65 +- .../src/dash-clearkey.e2e.ts | 359 ++- apps/web-e2e/eslint.config.mjs | 3 + apps/web-e2e/src/dash-clearkey.e2e.ts | 9 + apps/web-e2e/src/fixtures/playback/corrupt.ts | 1 + .../src/fixtures/playback/fatal-media.m3u8 | 7 + .../src/fixtures/playback/unsupported.mkv | 1 + .../src/playback-recommendations.e2e.ts | 141 ++ apps/web-e2e/tsconfig.json | 3 +- apps/web/src/assets/i18n/ar.json | 12 + apps/web/src/assets/i18n/ary.json | 12 + apps/web/src/assets/i18n/by.json | 12 + apps/web/src/assets/i18n/de.json | 12 + apps/web/src/assets/i18n/el.json | 12 + apps/web/src/assets/i18n/en.json | 12 + apps/web/src/assets/i18n/es.json | 12 + apps/web/src/assets/i18n/fr.json | 12 + apps/web/src/assets/i18n/hu.json | 12 + apps/web/src/assets/i18n/it.json | 12 + apps/web/src/assets/i18n/ja.json | 12 + apps/web/src/assets/i18n/ko.json | 12 + apps/web/src/assets/i18n/nl.json | 12 + apps/web/src/assets/i18n/pl.json | 12 + apps/web/src/assets/i18n/pt.json | 12 + apps/web/src/assets/i18n/ru.json | 12 + apps/web/src/assets/i18n/tr.json | 12 + apps/web/src/assets/i18n/zh.json | 12 + apps/web/src/assets/i18n/zhtw.json | 12 + docs/architecture/embedded-inline-playback.md | 239 +- docs/architecture/m3u-playlist-module.md | 13 +- docs/architecture/nx-workspace-boundaries.md | 20 + docs/architecture/player-controls-contract.md | 24 +- docs/architecture/stalker-portal.md | 14 +- .../2026-08-03-playback-recommendations.md | 2079 +++++++++++++++++ ...6-08-03-playback-recommendations-design.md | 490 ++++ libs/playback/util/project.json | 28 + libs/playback/util/src/index.ts | 9 + .../hls-playback-evidence.util.spec.ts | 2 +- .../hls-playback-evidence.util.ts | 0 .../mpegts-playback-evidence.model.ts | 0 .../mpegts-playback-evidence.util.spec.ts | 128 +- .../mpegts-playback-evidence.util.ts | 0 .../playback-diagnostics.model.ts | 9 +- .../playback-diagnostics.util.spec.ts | 55 +- .../diagnostics}/playback-diagnostics.util.ts | 39 +- .../playback-error-patterns.util.ts | 0 .../playback-media-source.util.ts | 0 .../shaka-error-classifier.spec.ts | 7 +- .../diagnostics}/shaka-error-classifier.ts | 25 +- .../lib/diagnostics}/shaka-error-contract.ts | 0 .../lib/diagnostics}/shaka-error-lifecycle.ts | 4 +- .../lib/diagnostics}/shaka-error-mapping.ts | 0 .../src/lib/diagnostics/shaka-error.types.ts | 6 + .../shaka-playback-evidence.util.spec.ts | 0 .../shaka-playback-evidence.util.ts | 4 +- .../vhs-playback-evidence.util.spec.ts | 0 .../vhs-playback-evidence.util.ts | 0 .../playback-recommendation-capabilities.ts | 144 ++ .../playback-recommendation-policy.spec.ts | 1046 +++++++++ .../src/lib/playback-recommendation-policy.ts | 302 +++ .../src/lib/playback-recommendation.model.ts | 95 + .../util/src/lib/playback-session-key.spec.ts | 196 ++ .../util/src/lib/playback-session-key.ts | 38 + .../lib/playback-target-capabilities.spec.ts | 303 +++ .../src/lib/playback-target-capabilities.ts | 133 ++ .../src/lib/playback-util-boundary.spec.ts | 347 +++ libs/playback/util/tsconfig.json | 25 + libs/playback/util/tsconfig.lib.json | 12 + libs/playback/util/tsconfig.spec.json | 12 + .../video-player/video-player.component.html | 9 +- .../video-player.component.spec.ts | 63 +- .../video-player/video-player.component.ts | 20 +- .../unified-favorites-data.service.spec.ts | 4 + .../unified-recent-data.service.spec.ts | 1 + .../unified-live-playback-session-key.spec.ts | 47 + .../unified-live-playback-session-key.ts | 23 + .../unified-live-tab.component.html | 9 +- .../unified-live-tab.component.spec.ts | 46 +- .../unified-live-tab.component.ts | 14 +- .../stalker-catalog-detail.component.html | 1 + .../stalker-catalog-detail.component.spec.ts | 147 +- .../stalker-catalog-detail.component.ts | 40 +- ...talker-collection-detail.component.spec.ts | 166 +- .../stalker-collection-detail.component.ts | 49 + .../stalker-inline-detail.component.html | 1 + .../stalker-inline-detail.component.spec.ts | 6 + .../stalker-inline-detail.component.ts | 1 + .../stalker-live-stream-layout.component.html | 13 +- ...alker-live-stream-layout.component.spec.ts | 90 +- .../stalker-live-stream-layout.component.ts | 106 +- ...stream-layout.playback-session-key.spec.ts | 294 +++ .../stalker-search.component.html | 1 + .../stalker-search.component.spec.ts | 273 +++ .../stalker-search.component.ts | 47 +- ...alker-episode-playback-session-key.spec.ts | 278 +++ .../stalker-episode-playback-session-key.ts | 237 ++ .../stalker-series-view.component.html | 1 + .../stalker-series-view.component.spec.ts | 14 +- .../stalker-series-view.component.ts | 421 ++-- ...r-series-view.playback-session-key.spec.ts | 753 ++++++ .../stalker-vod-playback-controller.spec.ts | 28 + .../lib/stalker-vod-playback-controller.ts | 25 +- .../live-stream-layout.component.html | 1 + .../live-stream-layout.component.spec.ts | 167 +- .../live-stream-layout.component.ts | 22 + .../serial-details-playback.service.ts | 17 +- .../serial-details.component.html | 1 + .../serial-details.component.spec.ts | 82 +- .../serial-details.component.ts | 8 + .../serial-playback-session-key.ts | 28 + .../vod-details-route.actions.spec.ts | 32 + .../vod-details-route.component.html | 1 + .../vod-details-route.component.ts | 8 + libs/ui/playback/src/index.ts | 2 +- .../src/lib/art-player/art-player-setup.ts | 2 +- .../art-player-source-session.dash.spec.ts | 2 +- ...art-player-source-session.spec-fixtures.ts | 2 +- .../art-player-source-session.spec.ts | 88 +- .../art-player/art-player-source-session.ts | 8 +- .../art-player/art-player-video-session.ts | 2 +- .../art-player/art-player.component.spec.ts | 3 - .../lib/art-player/art-player.component.ts | 2 +- .../html-video-element-session.ts | 2 +- .../html-video-player-diagnostics.ts | 8 +- ....component.shared-controls.sources.spec.ts | 1 - .../html-video-player.component.spec.ts | 96 +- .../html-video-player.component.ts | 2 +- .../playback-diagnostic-panel.component.html | 184 ++ .../playback-diagnostic-panel.component.scss | 530 +++++ ...layback-diagnostic-panel.component.spec.ts | 619 +++++ .../playback-diagnostic-panel.component.ts | 126 + .../playback-diagnostic-view.util.spec.ts | 139 ++ .../playback-diagnostic-view.util.ts} | 21 +- .../playback-recommendation-view.util.spec.ts | 162 ++ .../playback-recommendation-view.util.ts | 148 ++ .../portal-inline-player-sources.spec.ts | 12 +- .../portal-inline-player-up-next.spec.ts | 15 +- .../portal-inline-player.component.html | 1 + .../portal-inline-player.component.spec.ts | 77 +- .../portal-inline-player.component.ts | 17 +- .../lib/shaka-engine/shaka-module.types.ts | 7 +- .../shaka-engine/shaka-video-session.spec.ts | 28 +- .../lib/shaka-engine/shaka-video-session.ts | 75 +- .../lib/vjs-player/vjs-mpegts-session.spec.ts | 2 - .../src/lib/vjs-player/vjs-mpegts-session.ts | 2 +- .../vjs-player/vjs-player.component.spec.ts | 8 +- .../lib/vjs-player/vjs-player.component.ts | 2 +- .../src/lib/vjs-player/vjs-player.types.ts | 2 +- .../vod-details/vod-details.component.html | 1 + .../vod-details/vod-details.component.spec.ts | 23 + .../lib/vod-details/vod-details.component.ts | 3 +- .../playback-recovery-session.spec.ts | 500 ++++ .../playback-recovery-session.ts | 174 ++ .../web-player-application-handoff.ts | 116 + .../web-player-application-state.ts | 58 + .../web-player-playback-state.spec.ts | 175 ++ .../web-player-playback-state.ts | 119 + .../web-player-recovery-policy.ts | 82 + .../web-player-view.component.html | 366 +-- ...web-player-view.component.recovery.spec.ts | 1312 +++++++++++ .../web-player-view.component.scss | 459 ---- ...yer-view.component.shared-controls.spec.ts | 64 +- .../web-player-view.component.spec.ts | 192 +- .../web-player-view.component.ts | 562 ++--- .../web-player-view.spec-stubs.ts | 22 +- .../browser-media-type-support.spec.ts | 71 + .../browser-media-type-support.ts | 16 + .../web-video-shaka-controls.spec.ts | 2 +- .../global-favorites.service.spec.ts | 65 +- tools/coverage/coverage-policy.json | 7 + tsconfig.base.json | 1 + 176 files changed, 15228 insertions(+), 1908 deletions(-) create mode 100644 .changes/playback-recovery-recommendations.md create mode 100644 .changes/playback-runtime-guidance-fixes.md create mode 100644 .changes/playback-stable-session-ownership.md create mode 100644 .gitattributes create mode 100644 apps/web-e2e/src/fixtures/playback/corrupt.ts create mode 100644 apps/web-e2e/src/fixtures/playback/fatal-media.m3u8 create mode 100644 apps/web-e2e/src/fixtures/playback/unsupported.mkv create mode 100644 apps/web-e2e/src/playback-recommendations.e2e.ts create mode 100644 docs/superpowers/plans/2026-08-03-playback-recommendations.md create mode 100644 docs/superpowers/specs/2026-08-03-playback-recommendations-design.md create mode 100644 libs/playback/util/project.json create mode 100644 libs/playback/util/src/index.ts rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/hls-playback-evidence.util.spec.ts (99%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/hls-playback-evidence.util.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/mpegts-playback-evidence.model.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/mpegts-playback-evidence.util.spec.ts (68%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/mpegts-playback-evidence.util.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-diagnostics.model.ts (96%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-diagnostics.util.spec.ts (95%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-diagnostics.util.ts (89%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-error-patterns.util.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/playback-media-source.util.ts (100%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-classifier.spec.ts (95%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-classifier.ts (81%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-contract.ts (100%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-lifecycle.ts (82%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-error-mapping.ts (100%) create mode 100644 libs/playback/util/src/lib/diagnostics/shaka-error.types.ts rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-playback-evidence.util.spec.ts (100%) rename libs/{ui/playback/src/lib/shaka-engine => playback/util/src/lib/diagnostics}/shaka-playback-evidence.util.ts (98%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/vhs-playback-evidence.util.spec.ts (100%) rename libs/{ui/playback/src/lib/playback-diagnostics => playback/util/src/lib/diagnostics}/vhs-playback-evidence.util.ts (100%) create mode 100644 libs/playback/util/src/lib/playback-recommendation-capabilities.ts create mode 100644 libs/playback/util/src/lib/playback-recommendation-policy.spec.ts create mode 100644 libs/playback/util/src/lib/playback-recommendation-policy.ts create mode 100644 libs/playback/util/src/lib/playback-recommendation.model.ts create mode 100644 libs/playback/util/src/lib/playback-session-key.spec.ts create mode 100644 libs/playback/util/src/lib/playback-session-key.ts create mode 100644 libs/playback/util/src/lib/playback-target-capabilities.spec.ts create mode 100644 libs/playback/util/src/lib/playback-target-capabilities.ts create mode 100644 libs/playback/util/src/lib/playback-util-boundary.spec.ts create mode 100644 libs/playback/util/tsconfig.json create mode 100644 libs/playback/util/tsconfig.lib.json create mode 100644 libs/playback/util/tsconfig.spec.json create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts rename libs/ui/playback/src/lib/{web-player-view/web-player-view-diagnostics.utils.ts => playback-diagnostic-panel/playback-diagnostic-view.util.ts} (91%) create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts create mode 100644 libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts create mode 100644 libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts diff --git a/.changes/playback-recovery-recommendations.md b/.changes/playback-recovery-recommendations.md new file mode 100644 index 000000000..477529b25 --- /dev/null +++ b/.changes/playback-recovery-recommendations.md @@ -0,0 +1,10 @@ +--- +type: fix +area: playback +issues: [1159] +--- + +When playback fails, IPTVnator now ranks useful next steps from the reported +error, including another compatible built-in player, MPV/VLC, Retry, or another +source. Trying a built-in recommendation affects only the current item and +does not change the saved player setting. diff --git a/.changes/playback-runtime-guidance-fixes.md b/.changes/playback-runtime-guidance-fixes.md new file mode 100644 index 000000000..428250984 --- /dev/null +++ b/.changes/playback-runtime-guidance-fixes.md @@ -0,0 +1,6 @@ +--- +type: fix +area: playback +--- + +Playback recovery now removes old error actions as soon as a new source or player starts, even while Electron prepares stream headers. Stalker series playing inline now picks up refreshed episode names and navigation, and safely disables episode commands if the playing episode disappears. diff --git a/.changes/playback-stable-session-ownership.md b/.changes/playback-stable-session-ownership.md new file mode 100644 index 000000000..cc914c765 --- /dev/null +++ b/.changes/playback-stable-session-ownership.md @@ -0,0 +1,9 @@ +--- +type: fix +area: playback +issues: [1159] +--- + +Playback recovery no longer crosses into a different selected channel, movie, +or exact Stalker episode. Switching content while a stream resolves no longer +restores stale playback or shows an error from the previous selection. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..451f899d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +apps/web-e2e/src/fixtures/playback/unsupported.mkv binary diff --git a/AGENTS.md b/AGENTS.md index 0b8dc3676..9f3ea4a65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,20 +223,67 @@ Key files: engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and ArtPlayer components; ClearKey keys come from KODIPROP-derived `Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via - source kind `shaka`. The Shaka `5.2.2` diagnostic boundary version-locks - public severity/category/code evidence, ignores recoverable error events, + source kind `shaka`. The DOM-free Shaka `5.2.2` diagnostic boundary lives in + `libs/playback/util`; it version-locks public severity/category/code evidence, + ignores recoverable error events, treats rejected loads as terminal lifecycle outcomes, preserves exact public DASH text-parser category/code evidence with unknown stage/failure, and never retains or renders raw messages or `error.data`. A failed browser-support - preflight stays unknown but keeps external fallback for clear DASH; KODIPROP - DRM still suppresses it. See the CLAUDE.md "Video Players" feature entry and - `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback"). + preflight stays generic-unknown but carries the exact app-owned + `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed + external fallback only for clear transferable DASH; PWA capability and + KODIPROP DRM still suppress it. See the CLAUDE.md "Video Players" feature + entry and the "DASH + ClearKey Playback" section of + `docs/architecture/m3u-playlist-module.md`. - mpegts.js `1.8.0` errors from HTML5, Video.js, and ArtPlayer cross one - version-locked structured evidence boundary. Only exact public type/detail - pairs, pair-derived stage/failure, terminal disposition, and the validated - HTTP 4xx/5xx status slot are retained; raw messages and arbitrary `info` + version-locked structured evidence boundary in `libs/playback/util`. Only + exact public type/detail pairs, pair-derived stage/failure, terminal + disposition, and the validated HTTP 4xx/5xx status slot are retained; raw + messages and arbitrary `info` never reach diagnostics. This is a sibling of `PlayerController`, not part of the controls contract. +- Browser playback diagnostics and recovery policy live in + `libs/playback/util` and are exported by `@iptvnator/playback/util`. + Public engine errors cross allowlisted sanitizers into a + `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at + most three actions, and `WebPlayerViewComponent` executes only the action + the user selects. The policy is a sibling of `PlayerController`; shared + controls only gate interaction while the diagnostic panel is visible. + `WebPlayerViewComponent` owns a host-derived content-session key that is + stable for the mounted logical selection, attempted target IDs, the temporary + player override, and VOD handoff position. Its `PlaybackBinding` is exactly + `{ generation, target }`, while every source/target/reload application uses a + fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless + intent `Symbol`, and source applications advance a third fieldless revision + `Symbol` that clears only the VOD handoff position; target-only switches and + Retry leave that revision stable. None of these ownership primitives contains + URLs, headers, DRM material, or credentials. The application effect + synchronizes the content session before tracking intent, so clearing a + temporary player override cannot schedule a duplicate application or header + handoff. Every application start clears both the diagnostic owner and backing + signal before asynchronous header setup; a current false result or rejection + leaves them clear, and a stale completion cannot erase a newer owned + diagnostic. Each + rendered web or Embedded MPV application captures its nullable binding, the + application and source-revision tokens, and live/VOD flag; a time update + changes resume state only while that exact capture still owns the current + application. A recommended built-in + player temporarily + outranks the host override and saved player for that mounted content session, + never mutates `Settings.player`, and resumes finite VOD position on a + best-effort basis; live playback returns to the live edge. Retry and + alternative sources preserve attempts, while a different content-session key + or component teardown resets them. Recovery recommendations never + auto-switch, persist history, learn across sessions, or emit telemetry. + The policy projects attempted inline target IDs through the validated + canonical source/target capabilities and excludes every attempted engine + family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network + and generic unknown evidence fail closed to Retry/alternative source; the + exact Shaka browser-unsupported preflight marker is the sole unknown-code + exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP + DRM suppresses external targets because its payload is not transferable. Raw + engine messages, arbitrary data, and credentials never enter recommendation + evidence or ownership state. - The built-in HTML5/hls.js player is the second guarded consumer. `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared diff --git a/CLAUDE.md b/CLAUDE.md index fd09cb7d1..3f5db2213 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -758,13 +758,56 @@ app as a real argument, so it is not an option. - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer - mpegts.js `1.8.0` errors from all three built-in players cross one - version-locked structured evidence boundary. It retains only exact public - type/detail pairs, pair-derived stage/failure, terminal disposition, and a + version-locked structured evidence boundary in `libs/playback/util`. It + retains only exact public type/detail pairs, pair-derived stage/failure, + terminal disposition, and a validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach stored or rendered diagnostics. HTTP/network failures avoid false decoder recommendations, while exact format, codec, truncated-stream, and - MediaSource failures retain actionable fallback guidance. This diagnostic + MediaSource failures retain actionable recovery guidance. This diagnostic layer remains separate from the shared `PlayerController` controls contract. +- Browser playback diagnostics and recovery policy live in + `libs/playback/util` and are exported by `@iptvnator/playback/util`. + Public engine errors cross allowlisted sanitizers into a + `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at + most three actions, and `WebPlayerViewComponent` executes only the action + the user selects. The policy is a sibling of `PlayerController`; shared + controls only gate interaction while the diagnostic panel is visible. + `WebPlayerViewComponent` owns a host-derived content-session key that is + stable for the mounted logical selection, attempted target IDs, the temporary + player override, and VOD handoff position. Its `PlaybackBinding` is exactly + `{ generation, target }`, while every source/target/reload application uses a + fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless + intent `Symbol`, and source applications advance a third fieldless revision + `Symbol` that clears only the VOD handoff position; target-only switches and + Retry leave that revision stable. None of these ownership primitives contains + URLs, headers, DRM material, or credentials. The application effect + synchronizes the content session before tracking intent, so clearing a + temporary player override cannot schedule a duplicate application or header + handoff. Every application start clears both the diagnostic owner and backing + signal before asynchronous header setup; a current false result or rejection + leaves them clear, and a stale completion cannot erase a newer owned + diagnostic. Each + rendered web or Embedded MPV application captures its nullable binding, the + application and source-revision tokens, and live/VOD flag; a time update + changes resume state only while that exact capture still owns the current + application. A recommended built-in + player temporarily + outranks the host override and saved player for that mounted content session, + never mutates `Settings.player`, and resumes finite VOD position on a + best-effort basis; live playback returns to the live edge. Retry and + alternative sources preserve attempts, while a different content-session key + or component teardown resets them. Recovery recommendations never + auto-switch, persist history, learn across sessions, or emit telemetry. + The policy projects attempted inline target IDs through the validated + canonical source/target capabilities and excludes every attempted engine + family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network + and generic unknown evidence fail closed to Retry/alternative source; the + exact Shaka browser-unsupported preflight marker is the sole unknown-code + exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP + DRM suppresses external targets because its payload is not transferable. Raw + engine messages, arbitrary data, and credentials never enter recommendation + evidence or ownership state. - DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded Shaka Player source engine inside the HTML5 and ArtPlayer components (no new player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*` @@ -776,13 +819,17 @@ app as a real argument, so it is not an option. ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope, need the castLabs Electron fork) surface a DRM playback diagnostic instead of crashing. ClearKey EME works in stock Electron. Engine: - `libs/ui/playback/src/lib/shaka-engine/`. Its Shaka `5.2.2` diagnostic - boundary version-locks public severity/category/code evidence, ignores + `libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.2` + diagnostic boundary lives in `libs/playback/util`; it version-locks public + severity/category/code evidence, ignores recoverable error events, treats rejected loads as terminal lifecycle outcomes, preserves exact public DASH text-parser category/code evidence with unknown stage/failure, and never retains or renders raw messages or - `error.data`. A failed browser-support preflight stays unknown but keeps - external fallback for clear DASH; KODIPROP DRM still suppresses it. Details in + `error.data`. A failed browser-support preflight stays generic-unknown but + carries the exact app-owned + `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed + external fallback only for clear transferable DASH; PWA capability and + KODIPROP DRM still suppress it. Details in `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback"). - External players: MPV, VLC (via IPC to Electron backend) - Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. @@ -915,7 +962,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command @@ -1126,7 +1173,7 @@ engine` (restart required) or - `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. - Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text. - Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections. -- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. +- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The _effective_ cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. - Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting. - Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle"). diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index 553708db6..c3ba12807 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -1,6 +1,10 @@ import { createServer, Server } from 'http'; import { readFileSync } from 'fs'; import { basename, join } from 'path'; +import type { + ExternalPlayerName, + PlayerContentInfo, +} from '@iptvnator/shared/interfaces'; import { channelItemByTitle, closeElectronApp, @@ -19,7 +23,47 @@ import { * context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures. */ -const FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash'); +const DASH_FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash'); +const PLAYBACK_FIXTURE_DIR = join( + workspaceRoot, + 'apps/web-e2e/src/fixtures/playback' +); + +type PlaybackFixture = { + readonly contentType: string; + readonly path: string; +}; + +const FIXTURE_FILES: ReadonlyMap = new Map([ + [ + 'clearkey.mpd', + { + contentType: 'application/dash+xml', + path: join(DASH_FIXTURE_DIR, 'clearkey.mpd'), + }, + ], + [ + 'clearkey-video.mp4', + { + contentType: 'video/mp4', + path: join(DASH_FIXTURE_DIR, 'clearkey-video.mp4'), + }, + ], + [ + 'clearkey-audio.mp4', + { + contentType: 'video/mp4', + path: join(DASH_FIXTURE_DIR, 'clearkey-audio.mp4'), + }, + ], + [ + 'unsupported.mkv', + { + contentType: 'video/matroska', + path: join(PLAYBACK_FIXTURE_DIR, 'unsupported.mkv'), + }, + ], +]); const CLEARKEY_KID = '00112233445566778899aabbccddeeff'; const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100'; @@ -29,31 +73,33 @@ type DashFixtureServer = { origin: string; }; -/** Serves the DASH fixture directory with HTTP Range support (Shaka fetches - * init segments and the sidx via byte ranges). */ +/** Serves the explicit offline fixture allowlist with HTTP Range support + * (Shaka fetches init segments and the sidx via byte ranges). */ async function startDashFixtureServer(): Promise { const server: Server = createServer((request, response) => { const pathname = (request.url ?? '').split('?')[0]; const fileName = basename(pathname); + const fixture = FIXTURE_FILES.get(fileName); + if (!fixture) { + response.writeHead(404); + response.end('not found'); + return; + } let body: Buffer; try { - body = readFileSync(join(FIXTURE_DIR, fileName)); + body = readFileSync(fixture.path); } catch { response.writeHead(404); response.end('not found'); return; } - const contentType = fileName.endsWith('.mpd') - ? 'application/dash+xml' - : 'video/mp4'; - const range = /bytes=(\d+)-(\d+)?/.exec( - request.headers.range ?? '' - ); + const range = /bytes=(\d+)-(\d+)?/.exec(request.headers.range ?? ''); if (!range) { response.writeHead(200, { - 'Content-Type': contentType, + 'Content-Type': fixture.contentType, 'Accept-Ranges': 'bytes', + 'Access-Control-Allow-Origin': '*', 'Content-Length': body.length, }); response.end(body); @@ -64,8 +110,9 @@ async function startDashFixtureServer(): Promise { const end = range[2] ? Number(range[2]) : body.length - 1; const chunk = body.subarray(start, end + 1); response.writeHead(206, { - 'Content-Type': contentType, + 'Content-Type': fixture.contentType, 'Accept-Ranges': 'bytes', + 'Access-Control-Allow-Origin': '*', 'Content-Range': `bytes ${start}-${end}/${body.length}`, 'Content-Length': chunk.length, }); @@ -106,6 +153,8 @@ function buildDashPlaylist(origin: string): string { '#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha', '#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv', `${origin}/clearkey.mpd`, + '#EXTINF:-1 tvg-id="unsupported-mkv" group-title="DASH",Unsupported MKV', + `${origin}/unsupported.mkv`, ].join('\n'); } @@ -122,13 +171,179 @@ async function importDashPlaylistFromText( await waitForM3uCatalog(app.mainWindow); } -test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a diagnostic', async ({ +type ExternalPlayerIpcArguments = [ + url: string, + title: string, + thumbnail: string | undefined, + userAgent: string | undefined, + referer: string | undefined, + origin: string | undefined, + contentInfo: PlayerContentInfo | undefined, + startTime: number | undefined, + headers: Record | undefined, +]; + +type CapturedExternalPlayerLaunch = { + args: ExternalPlayerIpcArguments; + player: ExternalPlayerName; +}; + +type PlaybackRecommendationCaptureSnapshot = { + completed: number; + launches: CapturedExternalPlayerLaunch[]; + released: boolean; +}; + +type PlaybackRecommendationCaptureState = + PlaybackRecommendationCaptureSnapshot & { + releaseFirstResponse: (() => void) | undefined; + waitForFirstResponse: Promise; + }; + +async function installPlaybackRecommendationLaunchCapture( + app: LaunchedElectronApp +): Promise { + await app.electronApp.evaluate(({ ipcMain }) => { + let releaseFirstResponse: (() => void) | undefined; + const waitForFirstResponse = new Promise((resolve) => { + releaseFirstResponse = resolve; + }); + const state: PlaybackRecommendationCaptureState = { + completed: 0, + launches: [], + released: false, + releaseFirstResponse, + waitForFirstResponse, + }; + const globalRef = globalThis as typeof globalThis & { + __playbackRecommendationCapture?: PlaybackRecommendationCaptureState; + }; + globalRef.__playbackRecommendationCapture = state; + + const captureLaunch = + (player: ExternalPlayerName) => + async ( + _event: unknown, + url: string, + title: string, + thumbnail?: string, + userAgent?: string, + referer?: string, + origin?: string, + contentInfo?: PlayerContentInfo, + startTime?: number, + headers?: Record + ) => { + const invocation = state.launches.push({ + args: [ + url, + title, + thumbnail, + userAgent, + referer, + origin, + contentInfo, + startTime, + headers, + ], + player, + }); + + if (invocation === 1 && !state.released) { + await state.waitForFirstResponse; + } + + state.completed += 1; + const now = new Date().toISOString(); + return { + canClose: false, + id: `e2e-recommended-${player}-${invocation}`, + player, + startedAt: now, + status: 'opened', + streamUrl: url, + thumbnail: thumbnail ?? null, + title, + updatedAt: now, + }; + }; + + ipcMain.removeHandler('OPEN_MPV_PLAYER'); + ipcMain.removeHandler('OPEN_VLC_PLAYER'); + ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv')); + ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc')); + }); +} + +async function getPlaybackRecommendationCapture( + app: LaunchedElectronApp +): Promise { + return app.electronApp.evaluate(() => { + const globalRef = globalThis as typeof globalThis & { + __playbackRecommendationCapture?: PlaybackRecommendationCaptureState; + }; + const capture = globalRef.__playbackRecommendationCapture; + if (!capture) { + throw new Error( + 'Playback recommendation capture is not installed.' + ); + } + return { + completed: capture.completed, + launches: capture.launches, + released: capture.released, + }; + }); +} + +async function releasePlaybackRecommendationCapture( + app: LaunchedElectronApp +): Promise { + await app.electronApp.evaluate(() => { + const globalRef = globalThis as typeof globalThis & { + __playbackRecommendationCapture?: PlaybackRecommendationCaptureState; + }; + const capture = globalRef.__playbackRecommendationCapture; + if (!capture || capture.released) { + return; + } + capture.released = true; + capture.releaseFirstResponse?.(); + capture.releaseFirstResponse = undefined; + }); +} + +test('@electron @dash ClearKey DASH filters DRM fallback and eligible MKV opens in MPV', async ({ dataDir, }) => { const fixtureServer = await startDashFixtureServer(); const app = await launchElectronApp(dataDir); try { + const unsupportedFixtureResponse = await app.mainWindow.evaluate( + async (url) => { + const response = await fetch(url); + const body = await response.text(); + return { + body, + bodyBytes: new TextEncoder().encode(body).byteLength, + contentLength: response.headers.get('content-length'), + contentType: response.headers.get('content-type'), + rendererProtocol: window.location.protocol, + status: response.status, + }; + }, + `${fixtureServer.origin}/unsupported.mkv` + ); + expect(unsupportedFixtureResponse).toEqual({ + body: 'This fixture intentionally declares Matroska without playable media bytes.\n', + bodyBytes: 75, + contentLength: '75', + contentType: 'video/matroska', + rendererProtocol: 'file:', + status: 200, + }); + await importDashPlaylistFromText( app, buildDashPlaylist(fixtureServer.origin) @@ -160,12 +375,124 @@ test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a await channelItemByTitle(app.mainWindow, 'Widevine DASH') .first() .click(); - const banner = app.mainWindow.getByTestId( - 'playback-diagnostic-banner' - ); + const banner = app.mainWindow.getByTestId('playback-diagnostic-banner'); await expect(banner).toBeVisible({ timeout: 15_000 }); await expect(banner).toContainText(/encrypted or DRM-protected/i); + await expect( + banner.locator('[data-test-id="playback-fallback-mpv"]') + ).toHaveCount(0); + await expect( + banner.locator('[data-test-id="playback-fallback-vlc"]') + ).toHaveCount(0); + await expect( + banner.locator('[data-test-id^="playback-recommendation-"]') + ).toHaveCount(0); + const widevineDetails = banner.locator( + '[data-test-id="playback-diagnostic-details"]' + ); + await widevineDetails.locator('summary').click(); + await expect(widevineDetails).toContainText('drm-or-encryption'); + + await installPlaybackRecommendationLaunchCapture(app); + await channelItemByTitle(app.mainWindow, 'Unsupported MKV') + .first() + .click(); + await expect(banner).toContainText( + /container is likely unsupported by the browser player/i, + { timeout: 15_000 } + ); + const mkvDetails = banner.locator( + '[data-test-id="playback-diagnostic-details"]' + ); + await mkvDetails.locator('summary').click(); + await expect(mkvDetails).toContainText('unsupported-container'); + + const mpvFallback = banner.locator( + '[data-test-id="playback-fallback-mpv"]' + ); + const vlcFallback = banner.locator( + '[data-test-id="playback-fallback-vlc"]' + ); + await expect(mpvFallback).toBeVisible(); + await expect(vlcFallback).toBeVisible(); + await expect(mpvFallback).toHaveClass( + /web-player-diagnostic__player-card--primary/ + ); + await expect(vlcFallback).not.toHaveClass( + /web-player-diagnostic__player-card--primary/ + ); + + const expectedLaunches = [ + { + args: [ + `${fixtureServer.origin}/unsupported.mkv`, + 'Unsupported MKV', + '', + '', + '', + undefined, + undefined, + undefined, + undefined, + ], + player: 'mpv', + }, + ] satisfies CapturedExternalPlayerLaunch[]; + expect(await getPlaybackRecommendationCapture(app)).toEqual({ + completed: 0, + launches: [], + released: false, + }); + + await mpvFallback.click(); + await expect + .poll(() => getPlaybackRecommendationCapture(app), { + timeout: 10_000, + }) + .toEqual({ + completed: 0, + launches: expectedLaunches, + released: false, + }); + expect( + (await getPlaybackRecommendationCapture(app)).launches.filter( + ({ player }) => player === 'vlc' + ) + ).toHaveLength(0); + await expect(mpvFallback).toHaveCount(0); + await expect(vlcFallback).toHaveClass( + /web-player-diagnostic__player-card--primary/ + ); + + await releasePlaybackRecommendationCapture(app); + await expect + .poll(() => getPlaybackRecommendationCapture(app), { + timeout: 10_000, + }) + .toEqual({ + completed: 1, + launches: expectedLaunches, + released: true, + }); + await app.mainWindow.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()) + ); + }) + ); + expect(await getPlaybackRecommendationCapture(app)).toEqual({ + completed: 1, + launches: expectedLaunches, + released: true, + }); + await expect(mpvFallback).toHaveCount(0); + await expect(vlcFallback).toHaveClass( + /web-player-diagnostic__player-card--primary/ + ); } finally { + await releasePlaybackRecommendationCapture(app).catch(() => undefined); await closeElectronApp(app); await fixtureServer.close(); } diff --git a/apps/web-e2e/eslint.config.mjs b/apps/web-e2e/eslint.config.mjs index b2e9fac09..2cff1f885 100644 --- a/apps/web-e2e/eslint.config.mjs +++ b/apps/web-e2e/eslint.config.mjs @@ -2,6 +2,9 @@ import playwright from 'eslint-plugin-playwright'; import baseConfig from '../../eslint.config.mjs'; export default [ + { + ignores: ['**/fixtures/playback/corrupt.ts'], + }, playwright.configs['flat/recommended'], ...baseConfig, { diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts index 280b1631a..673e99aa5 100644 --- a/apps/web-e2e/src/dash-clearkey.e2e.ts +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -150,4 +150,13 @@ test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({ const banner = page.locator('[data-test-id="playback-diagnostic-banner"]'); await expect(banner).toBeVisible({ timeout: 15_000 }); await expect(banner).toContainText(/encrypted or DRM-protected/i); + await expect( + banner.locator('[data-test-id="playback-fallback-mpv"]') + ).toHaveCount(0); + await expect( + banner.locator('[data-test-id="playback-fallback-vlc"]') + ).toHaveCount(0); + await expect( + banner.locator('[data-test-id^="playback-recommendation-"]') + ).toHaveCount(0); }); diff --git a/apps/web-e2e/src/fixtures/playback/corrupt.ts b/apps/web-e2e/src/fixtures/playback/corrupt.ts new file mode 100644 index 000000000..5a480f848 --- /dev/null +++ b/apps/web-e2e/src/fixtures/playback/corrupt.ts @@ -0,0 +1 @@ +This is intentionally not an MPEG transport stream segment. diff --git a/apps/web-e2e/src/fixtures/playback/fatal-media.m3u8 b/apps/web-e2e/src/fixtures/playback/fatal-media.m3u8 new file mode 100644 index 000000000..697d64b1d --- /dev/null +++ b/apps/web-e2e/src/fixtures/playback/fatal-media.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:1, +corrupt.ts +#EXT-X-ENDLIST diff --git a/apps/web-e2e/src/fixtures/playback/unsupported.mkv b/apps/web-e2e/src/fixtures/playback/unsupported.mkv new file mode 100644 index 000000000..5121eb6e1 --- /dev/null +++ b/apps/web-e2e/src/fixtures/playback/unsupported.mkv @@ -0,0 +1 @@ +This fixture intentionally declares Matroska without playable media bytes. diff --git a/apps/web-e2e/src/playback-recommendations.e2e.ts b/apps/web-e2e/src/playback-recommendations.e2e.ts new file mode 100644 index 000000000..dcd73cbc1 --- /dev/null +++ b/apps/web-e2e/src/playback-recommendations.e2e.ts @@ -0,0 +1,141 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +const FIXTURE_DIR = join(__dirname, 'fixtures/playback'); +const FIXTURE_HOST = 'https://playback-fixture.local'; +const FATAL_HLS_PLAYLIST = [ + '#EXTM3U', + '#EXTINF:-1 tvg-id="fatal-hls" group-title="Playback",Fatal HLS', + `${FIXTURE_HOST}/fatal-media.m3u8`, +].join('\n'); + +const PLAYBACK_FIXTURES = new Map([ + [ + '/fatal-media.m3u8', + { + body: readFileSync(join(FIXTURE_DIR, 'fatal-media.m3u8')), + contentType: 'application/vnd.apple.mpegurl', + }, + ], + [ + '/corrupt.ts', + { + body: readFileSync(join(FIXTURE_DIR, 'corrupt.ts')), + contentType: 'video/mp2t', + }, + ], +]); + +test.use({ + launchOptions: { + args: ['--autoplay-policy=no-user-gesture-required'], + }, + serviceWorkers: 'block', +}); + +test.skip( + ({ browserName }) => browserName !== 'chromium', + 'HLS recovery coverage targets Chromium' +); + +async function servePlaybackFixtures(page: Page): Promise { + await page.route(`${FIXTURE_HOST}/**`, async (route) => { + const fixture = PLAYBACK_FIXTURES.get( + new URL(route.request().url()).pathname + ); + if (!fixture) { + await route.fulfill({ + status: 404, + contentType: 'text/plain', + body: 'not found', + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: fixture.contentType, + body: fixture.body, + }); + }); +} + +async function openSettings(page: Page): Promise { + await page.getByRole('link', { name: 'Open settings' }).click(); + await page.waitForURL(/\/workspace\/settings$/); + await expect( + page.getByRole('button', { name: 'Back', exact: true }) + ).toBeVisible(); +} + +async function selectAndSaveHtml5(page: Page): Promise { + const playerSelect = page.locator('[data-test-id="select-video-player"]'); + await playerSelect.click(); + await page + .getByRole('option', { name: 'HTML5 video player', exact: true }) + .click(); + + const saveButton = page.getByRole('button', { name: 'Save changes' }); + await saveButton.click(); + await expect(saveButton).toBeDisabled(); +} + +async function importFatalHlsPlaylist(page: Page): Promise { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog + .getByLabel('Insert m3u(8) playlist as text') + .fill(FATAL_HLS_PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('1 channel')).toBeVisible(); +} + +test('@web @m3u @playback temporarily switches to the recommended player', async ({ + page, +}) => { + await servePlaybackFixtures(page); + await page.goto('/'); + + await openSettings(page); + await selectAndSaveHtml5(page); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.waitForURL(/\/workspace\/dashboard$/); + + await importFatalHlsPlaylist(page); + await page.getByText('1. Fatal HLS', { exact: true }).click(); + + const banner = page.locator('[data-test-id="playback-diagnostic-banner"]'); + const videoJsRecommendation = page.locator( + '[data-test-id="playback-recommendation-videojs"]' + ); + await expect(banner).toBeVisible({ timeout: 15_000 }); + await expect( + banner.locator('[data-test-id="playback-diagnostic-details"]') + ).toContainText( + /HLS\.js[\s\S]*type=mediaError[\s\S]*details=fragParsingError[\s\S]*disposition=fatal/ + ); + await expect(videoJsRecommendation).toBeVisible(); + await expect(videoJsRecommendation).toHaveClass( + /web-player-diagnostic__player-card--primary/ + ); + + await videoJsRecommendation.click(); + await expect(page.locator('app-vjs-player')).toBeVisible(); + + await openSettings(page); + await page.reload(); + await expect(page).toHaveURL(/\/workspace\/settings$/); + const persistedPlayerSelect = page.locator( + '[data-test-id="select-video-player"]' + ); + await expect(persistedPlayerSelect).toBeVisible(); + await expect(persistedPlayerSelect).toBeEnabled(); + await expect(persistedPlayerSelect).toHaveText('HTML5 video player'); +}); diff --git a/apps/web-e2e/tsconfig.json b/apps/web-e2e/tsconfig.json index dcc6815b6..d35c2908f 100644 --- a/apps/web-e2e/tsconfig.json +++ b/apps/web-e2e/tsconfig.json @@ -17,5 +17,6 @@ "playwright.config.ts", "src/**/*.d.ts", "src/**/*.e2e.ts" - ] + ], + "exclude": ["src/fixtures/playback/corrupt.ts"] } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 01b47a609..0502c49da 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "إجراءات الاسترداد الموصى بها", + "ACTION_TRY_PLAYER": "جرّب {{player}}", + "ACTION_TRY_PLAYER_HINT": "مؤقت لهذا العنصر؛ سيبقى المشغل المحفوظ دون تغيير", + "REASON_RETRY_TRANSIENT_FAILURE": "أعد المحاولة بنفس المشغل بعد فشل مؤقت في التحميل", + "REASON_RETRY_UNKNOWN_FAILURE": "أعد المحاولة لأن الخطأ لم يحدد مشغلًا آخر متوافقًا", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "جرّب مصدرًا آخر متاحًا للمحتوى نفسه", + "REASON_DIFFERENT_ENGINE_FAMILY": "يستخدم هذا المشغل محرك تشغيل مختلفًا في المتصفح", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "تدعم المشغلات الأصلية مزيدًا من برامج الترميز والحاويات", + "REASON_EXTERNAL_BROWSER_ACCESS": "قد يتجاوز المشغل الأصلي قيود الوصول في المتصفح", + "REASON_COMPATIBLE_DRM_PATH": "هذا الهدف متوافق مع بيانات التشفير المتاحة", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0cca90eee..581fcfc72 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "إجراءات الاسترجاع المقترحة", + "ACTION_TRY_PLAYER": "جرّب {{player}}", + "ACTION_TRY_PLAYER_HINT": "مؤقت غير لهاد المحتوى؛ المشغل المحفوظ غيبقى بلا تغيير", + "REASON_RETRY_TRANSIENT_FAILURE": "عاود جرّب نفس المشغل من بعد مشكل مؤقت فالتحميل", + "REASON_RETRY_UNKNOWN_FAILURE": "عاود جرّب حيث الخطأ ما حدّد حتى مشغل آخر متوافق", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "جرّب مصدر آخر متوفر لنفس المحتوى", + "REASON_DIFFERENT_ENGINE_FAMILY": "هاد المشغل كيستعمل محرك تشغيل مختلف فالمتصفح", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "المشغلات الأصلية كتدعم كوديكات وحاويات أكثر", + "REASON_EXTERNAL_BROWSER_ACCESS": "يقدر مشغل أصلي يتجاوز قيود الولوج ديال المتصفح", + "REASON_COMPATIBLE_DRM_PATH": "هاد الهدف متوافق مع بيانات التشفير المتوفرة", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 3f46c4965..84d57fdd6 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Рэкамендаваныя дзеянні для аднаўлення", + "ACTION_TRY_PLAYER": "Паспрабаваць {{player}}", + "ACTION_TRY_PLAYER_HINT": "Часова толькі для гэтага элемента; захаваны плэер не зменіцца", + "REASON_RETRY_TRANSIENT_FAILURE": "Паўтарыць у тым жа плэеры пасля часовага збою загрузкі", + "REASON_RETRY_UNKNOWN_FAILURE": "Паўтарыць, бо памылка не вызначыла іншы сумяшчальны плэер", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Паспрабаваць іншую даступную крыніцу таго ж змесціва", + "REASON_DIFFERENT_ENGINE_FAMILY": "Гэты плэер выкарыстоўвае іншы рухавік прайгравання ў браўзеры", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Сістэмныя плэеры падтрымліваюць больш кодэкаў і кантэйнераў", + "REASON_EXTERNAL_BROWSER_ACCESS": "Сістэмны плэер можа абысці абмежаванні доступу браўзера", + "REASON_COMPATIBLE_DRM_PATH": "Гэтая мэта сумяшчальная з даступнымі данымі шыфравання", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 4eeeb4e66..ebf158524 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Empfohlene Wiederherstellungsaktionen", + "ACTION_TRY_PLAYER": "{{player}} ausprobieren", + "ACTION_TRY_PLAYER_HINT": "Nur vorübergehend für diesen Inhalt; der gespeicherte Player bleibt unverändert", + "REASON_RETRY_TRANSIENT_FAILURE": "Nach einem vorübergehenden Ladefehler denselben Player erneut versuchen", + "REASON_RETRY_UNKNOWN_FAILURE": "Erneut versuchen, da der Fehler keinen anderen kompatiblen Player erkennen ließ", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Eine andere verfügbare Quelle für denselben Inhalt ausprobieren", + "REASON_DIFFERENT_ENGINE_FAMILY": "Dieser Player verwendet eine andere Browser-Wiedergabe-Engine", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native Player unterstützen mehr Codecs und Container", + "REASON_EXTERNAL_BROWSER_ACCESS": "Ein nativer Player kann Zugriffsbeschränkungen des Browsers umgehen", + "REASON_COMPATIBLE_DRM_PATH": "Dieses Ziel ist mit den verfügbaren Verschlüsselungsdaten kompatibel", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index ad300b364..a7b2a8b99 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Προτεινόμενες ενέργειες ανάκτησης", + "ACTION_TRY_PLAYER": "Δοκιμή του {{player}}", + "ACTION_TRY_PLAYER_HINT": "Προσωρινά για αυτό το στοιχείο· το αποθηκευμένο πρόγραμμα αναπαραγωγής δεν αλλάζει", + "REASON_RETRY_TRANSIENT_FAILURE": "Δοκιμάστε ξανά το ίδιο πρόγραμμα αναπαραγωγής μετά από μια προσωρινή αποτυχία φόρτωσης", + "REASON_RETRY_UNKNOWN_FAILURE": "Δοκιμάστε ξανά, επειδή το σφάλμα δεν εντόπισε άλλο συμβατό πρόγραμμα αναπαραγωγής", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Δοκιμάστε μια άλλη διαθέσιμη πηγή για το ίδιο περιεχόμενο", + "REASON_DIFFERENT_ENGINE_FAMILY": "Αυτό το πρόγραμμα αναπαραγωγής χρησιμοποιεί διαφορετική μηχανή αναπαραγωγής του προγράμματος περιήγησης", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Τα εγγενή προγράμματα αναπαραγωγής υποστηρίζουν περισσότερους κωδικοποιητές και περιέκτες", + "REASON_EXTERNAL_BROWSER_ACCESS": "Ένα εγγενές πρόγραμμα αναπαραγωγής μπορεί να παρακάμψει περιορισμούς πρόσβασης του προγράμματος περιήγησης", + "REASON_COMPATIBLE_DRM_PATH": "Αυτός ο στόχος είναι συμβατός με τα διαθέσιμα δεδομένα κρυπτογράφησης", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index e2baf15a4..a5754aa74 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Recommended recovery actions", + "ACTION_TRY_PLAYER": "Try {{player}}", + "ACTION_TRY_PLAYER_HINT": "Temporary for this item; your saved player stays unchanged", + "REASON_RETRY_TRANSIENT_FAILURE": "Retry the same player after a temporary loading failure", + "REASON_RETRY_UNKNOWN_FAILURE": "Retry because the failure did not identify another compatible player", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Try another available source for the same content", + "REASON_DIFFERENT_ENGINE_FAMILY": "This player uses a different browser playback engine", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native players support more codecs and containers", + "REASON_EXTERNAL_BROWSER_ACCESS": "A native player may avoid browser access restrictions", + "REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index af6718930..baad2e909 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Acciones de recuperación recomendadas", + "ACTION_TRY_PLAYER": "Probar {{player}}", + "ACTION_TRY_PLAYER_HINT": "Temporal para este elemento; el reproductor guardado no cambiará", + "REASON_RETRY_TRANSIENT_FAILURE": "Reintentar con el mismo reproductor tras un fallo temporal de carga", + "REASON_RETRY_UNKNOWN_FAILURE": "Reintentar porque el error no identificó otro reproductor compatible", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Probar otra fuente disponible para el mismo contenido", + "REASON_DIFFERENT_ENGINE_FAMILY": "Este reproductor usa un motor de reproducción distinto del navegador", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Los reproductores nativos admiten más códecs y contenedores", + "REASON_EXTERNAL_BROWSER_ACCESS": "Un reproductor nativo puede evitar las restricciones de acceso del navegador", + "REASON_COMPATIBLE_DRM_PATH": "Este destino es compatible con los datos de cifrado disponibles", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 888e5e739..ecf59f1b3 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Actions de récupération recommandées", + "ACTION_TRY_PLAYER": "Essayer {{player}}", + "ACTION_TRY_PLAYER_HINT": "Temporaire pour cet élément ; votre lecteur enregistré reste inchangé", + "REASON_RETRY_TRANSIENT_FAILURE": "Réessayer avec le même lecteur après un échec temporaire de chargement", + "REASON_RETRY_UNKNOWN_FAILURE": "Réessayer, car l’erreur n’a identifié aucun autre lecteur compatible", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Essayer une autre source disponible pour le même contenu", + "REASON_DIFFERENT_ENGINE_FAMILY": "Ce lecteur utilise un autre moteur de lecture du navigateur", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Les lecteurs natifs prennent en charge davantage de codecs et de conteneurs", + "REASON_EXTERNAL_BROWSER_ACCESS": "Un lecteur natif peut contourner les restrictions d’accès du navigateur", + "REASON_COMPATIBLE_DRM_PATH": "Cette cible est compatible avec les données de chiffrement disponibles", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index e0b62c911..cadfbb300 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -551,6 +551,7 @@ "DESCRIPTION": "A kérést CORS-korlátozás, vegyes tartalom, illetve a szolgáltató fejléc- vagy proxykorlátozásai akadályozhatják. Az MPV vagy a VLC gyakran képes megkerülni a böngészőben működő lejátszó hozzáférési korlátait.", "PWA_DESCRIPTION": "A kérést CORS-korlátozás, vegyes tartalom, illetve a szolgáltató fejléc- vagy proxykorlátozásai akadályozhatják. A PWA nem tud közvetlenül külső lejátszót indítani; válassza az Adatfolyam URL-címének másolása műveletet, majd nyissa meg kézzel MPV-ben, VLC-ben, IINA-ban vagy más lejátszóban." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "Az adatfolyam titkosított vagy DRM-védelemmel ellátott lehet", "DESCRIPTION": "A böngészőben működő lejátszó a jelzett titkosítási vagy kulcshiba miatt nem tudja folytatni a lejátszást. Ha a szolgáltató DRM-védelmet alkalmaz, a külső lejátszás is sikertelen lehet." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Nyissa meg inkább külső lejátszóban", "INLINE_FAILURE_TITLE": "Másolja ki az URL-címet, vagy próbálja újra", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Ajánlott helyreállítási műveletek", + "ACTION_TRY_PLAYER": "A(z) {{player}} kipróbálása", + "ACTION_TRY_PLAYER_HINT": "Csak ennél az elemnél ideiglenes; a mentett lejátszó változatlan marad", + "REASON_RETRY_TRANSIENT_FAILURE": "Ugyanannak a lejátszónak az újrapróbálása ideiglenes betöltési hiba után", + "REASON_RETRY_UNKNOWN_FAILURE": "Újrapróbálás, mert a hiba nem azonosított más kompatibilis lejátszót", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Másik elérhető forrás kipróbálása ugyanahhoz a tartalomhoz", + "REASON_DIFFERENT_ENGINE_FAMILY": "Ez a lejátszó más böngészős lejátszómotort használ", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "A natív lejátszók több kodeket és konténert támogatnak", + "REASON_EXTERNAL_BROWSER_ACCESS": "Egy natív lejátszó megkerülheti a böngésző hozzáférési korlátozásait", + "REASON_COMPATIBLE_DRM_PATH": "Ez a cél kompatibilis az elérhető titkosítási adatokkal", "ACTION_OPEN_MPV": "Megnyitás MPV-ben", "ACTION_OPEN_VLC": "Megnyitás VLC-ben", "ACTION_OPEN_MPV_HINT": "beállított lejátszó", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 72ab26444..c3b25fc7b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Azioni di ripristino consigliate", + "ACTION_TRY_PLAYER": "Prova {{player}}", + "ACTION_TRY_PLAYER_HINT": "Temporaneo per questo elemento; il lettore salvato resta invariato", + "REASON_RETRY_TRANSIENT_FAILURE": "Riprova con lo stesso lettore dopo un errore temporaneo di caricamento", + "REASON_RETRY_UNKNOWN_FAILURE": "Riprova perché l’errore non ha identificato un altro lettore compatibile", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Prova un’altra fonte disponibile per lo stesso contenuto", + "REASON_DIFFERENT_ENGINE_FAMILY": "Questo lettore usa un motore di riproduzione del browser diverso", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "I lettori nativi supportano più codec e contenitori", + "REASON_EXTERNAL_BROWSER_ACCESS": "Un lettore nativo può evitare le restrizioni di accesso del browser", + "REASON_COMPATIBLE_DRM_PATH": "Questa destinazione è compatibile con i dati di crittografia disponibili", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e968b7e1b..3734536b3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "推奨される復旧操作", + "ACTION_TRY_PLAYER": "{{player}} を試す", + "ACTION_TRY_PLAYER_HINT": "この項目にのみ一時的に適用され、保存済みのプレーヤーは変更されません", + "REASON_RETRY_TRANSIENT_FAILURE": "一時的な読み込みエラーの後、同じプレーヤーで再試行します", + "REASON_RETRY_UNKNOWN_FAILURE": "エラーから別の互換プレーヤーを特定できなかったため、再試行します", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "同じコンテンツで利用可能な別のソースを試します", + "REASON_DIFFERENT_ENGINE_FAMILY": "このプレーヤーは別のブラウザー再生エンジンを使用します", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "ネイティブプレーヤーはより多くのコーデックとコンテナに対応しています", + "REASON_EXTERNAL_BROWSER_ACCESS": "ネイティブプレーヤーならブラウザーのアクセス制限を回避できる場合があります", + "REASON_COMPATIBLE_DRM_PATH": "この対象は利用可能な暗号化データに対応しています", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 6691d2f39..6653602ad 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "권장 복구 작업", + "ACTION_TRY_PLAYER": "{{player}} 사용해 보기", + "ACTION_TRY_PLAYER_HINT": "이 항목에만 임시로 적용되며 저장된 플레이어는 변경되지 않습니다", + "REASON_RETRY_TRANSIENT_FAILURE": "일시적인 로드 실패 후 같은 플레이어로 다시 시도합니다", + "REASON_RETRY_UNKNOWN_FAILURE": "오류에서 다른 호환 플레이어를 확인할 수 없어 다시 시도합니다", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "같은 콘텐츠에 사용 가능한 다른 소스를 시도합니다", + "REASON_DIFFERENT_ENGINE_FAMILY": "이 플레이어는 다른 브라우저 재생 엔진을 사용합니다", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "네이티브 플레이어는 더 많은 코덱과 컨테이너를 지원합니다", + "REASON_EXTERNAL_BROWSER_ACCESS": "네이티브 플레이어는 브라우저 접근 제한을 피할 수 있습니다", + "REASON_COMPATIBLE_DRM_PATH": "이 대상은 사용 가능한 암호화 데이터와 호환됩니다", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2093410be..4f0b05448 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Aanbevolen herstelacties", + "ACTION_TRY_PLAYER": "{{player}} proberen", + "ACTION_TRY_PLAYER_HINT": "Tijdelijk voor dit item; je opgeslagen speler blijft ongewijzigd", + "REASON_RETRY_TRANSIENT_FAILURE": "Dezelfde speler opnieuw proberen na een tijdelijke laadfout", + "REASON_RETRY_UNKNOWN_FAILURE": "Opnieuw proberen omdat de fout geen andere compatibele speler heeft vastgesteld", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Een andere beschikbare bron voor dezelfde inhoud proberen", + "REASON_DIFFERENT_ENGINE_FAMILY": "Deze speler gebruikt een andere afspeelengine in de browser", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Systeemeigen spelers ondersteunen meer codecs en containers", + "REASON_EXTERNAL_BROWSER_ACCESS": "Een systeemeigen speler kan toegangsbeperkingen van de browser omzeilen", + "REASON_COMPATIBLE_DRM_PATH": "Dit doel is compatibel met de beschikbare versleutelingsgegevens", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 266c512bf..a3165f818 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Zalecane działania odzyskiwania", + "ACTION_TRY_PLAYER": "Wypróbuj {{player}}", + "ACTION_TRY_PLAYER_HINT": "Tymczasowo dla tego elementu; zapisany odtwarzacz pozostanie bez zmian", + "REASON_RETRY_TRANSIENT_FAILURE": "Ponów próbę w tym samym odtwarzaczu po tymczasowym błędzie wczytywania", + "REASON_RETRY_UNKNOWN_FAILURE": "Ponów próbę, ponieważ błąd nie wskazał innego zgodnego odtwarzacza", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Wypróbuj inne dostępne źródło tej samej treści", + "REASON_DIFFERENT_ENGINE_FAMILY": "Ten odtwarzacz używa innego silnika odtwarzania w przeglądarce", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Natywne odtwarzacze obsługują więcej kodeków i kontenerów", + "REASON_EXTERNAL_BROWSER_ACCESS": "Natywny odtwarzacz może ominąć ograniczenia dostępu przeglądarki", + "REASON_COMPATIBLE_DRM_PATH": "Ten cel jest zgodny z dostępnymi danymi szyfrowania", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 2248deb26..34ce3e6e3 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Ações de recuperação recomendadas", + "ACTION_TRY_PLAYER": "Experimentar {{player}}", + "ACTION_TRY_PLAYER_HINT": "Temporário para este item; o reprodutor salvo permanece inalterado", + "REASON_RETRY_TRANSIENT_FAILURE": "Tentar novamente com o mesmo reprodutor após uma falha temporária de carregamento", + "REASON_RETRY_UNKNOWN_FAILURE": "Tentar novamente porque o erro não identificou outro reprodutor compatível", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Experimentar outra fonte disponível para o mesmo conteúdo", + "REASON_DIFFERENT_ENGINE_FAMILY": "Este reprodutor usa um mecanismo de reprodução diferente no navegador", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Os reprodutores nativos oferecem suporte a mais codecs e contêineres", + "REASON_EXTERNAL_BROWSER_ACCESS": "Um reprodutor nativo pode contornar as restrições de acesso do navegador", + "REASON_COMPATIBLE_DRM_PATH": "Este destino é compatível com os dados de criptografia disponíveis", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 18a52d805..f95a46827 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -551,6 +551,7 @@ "DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. MPV или VLC часто обходят такие ограничения браузерного плеера.", "PWA_DESCRIPTION": "Запрос может быть заблокирован CORS, mixed content или требованиями провайдера/прокси к заголовкам. PWA не может запускать внешние плееры напрямую: нажмите «Скопировать URL потока» и откройте его вручную в MPV, VLC, IINA или другом плеере." }, + "UNTRANSFERABLE_DESCRIPTION": "Для этого защищённого потока требуются настройки воспроизведения, которые нельзя передать вместе с URL-адресом потока.", "DRM_OR_ENCRYPTION": { "TITLE": "Поток может быть зашифрован или защищен DRM", "DESCRIPTION": "Браузерный плеер не может продолжить из-за ошибки ключа или шифрования. Внешний плеер тоже может не помочь, если требуется DRM провайдера." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Откройте поток во внешнем плеере", "INLINE_FAILURE_TITLE": "Скопируйте URL или повторите попытку", + "UNTRANSFERABLE_FAILURE_TITLE": "Не удалось продолжить защищённое воспроизведение", + "RECOMMENDATIONS_LABEL": "Рекомендуемые действия для восстановления", + "ACTION_TRY_PLAYER": "Попробовать {{player}}", + "ACTION_TRY_PLAYER_HINT": "Временно только для этого элемента; сохранённый плеер не изменится", + "REASON_RETRY_TRANSIENT_FAILURE": "Повторить в том же плеере после временной ошибки загрузки", + "REASON_RETRY_UNKNOWN_FAILURE": "Повторить, поскольку ошибка не указала другой совместимый плеер", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Попробовать другой доступный источник того же контента", + "REASON_DIFFERENT_ENGINE_FAMILY": "Этот плеер использует другой движок воспроизведения в браузере", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Нативные плееры поддерживают больше кодеков и контейнеров", + "REASON_EXTERNAL_BROWSER_ACCESS": "Нативный плеер может обойти ограничения доступа браузера", + "REASON_COMPATIBLE_DRM_PATH": "Эта цель совместима с доступными данными шифрования", "ACTION_OPEN_MPV": "Открыть в MPV", "ACTION_OPEN_VLC": "Открыть в VLC", "ACTION_OPEN_MPV_HINT": "настроенный плеер", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 2830c03ff..14df41536 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "Önerilen kurtarma işlemleri", + "ACTION_TRY_PLAYER": "{{player}} ile dene", + "ACTION_TRY_PLAYER_HINT": "Bu öğe için geçicidir; kayıtlı oynatıcınız değişmeden kalır", + "REASON_RETRY_TRANSIENT_FAILURE": "Geçici bir yükleme hatasından sonra aynı oynatıcıyla yeniden deneyin", + "REASON_RETRY_UNKNOWN_FAILURE": "Hata başka bir uyumlu oynatıcı belirlemediği için yeniden deneyin", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Aynı içerik için kullanılabilir başka bir kaynak deneyin", + "REASON_DIFFERENT_ENGINE_FAMILY": "Bu oynatıcı farklı bir tarayıcı oynatma motoru kullanır", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Yerel oynatıcılar daha fazla codec bileşenini ve kapsayıcıyı destekler", + "REASON_EXTERNAL_BROWSER_ACCESS": "Yerel bir oynatıcı tarayıcı erişim kısıtlamalarını aşabilir", + "REASON_COMPATIBLE_DRM_PATH": "Bu hedef kullanılabilir şifreleme verileriyle uyumludur", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 1eb615183..983395365 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "推荐的恢复操作", + "ACTION_TRY_PLAYER": "尝试 {{player}}", + "ACTION_TRY_PLAYER_HINT": "仅临时用于此项目;已保存的播放器不会更改", + "REASON_RETRY_TRANSIENT_FAILURE": "临时加载失败后使用同一播放器重试", + "REASON_RETRY_UNKNOWN_FAILURE": "重试,因为该错误未能确定其他兼容的播放器", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "为同一内容尝试其他可用源", + "REASON_DIFFERENT_ENGINE_FAMILY": "此播放器使用不同的浏览器播放引擎", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "原生播放器支持更多编解码器和容器", + "REASON_EXTERNAL_BROWSER_ACCESS": "原生播放器可以避开浏览器访问限制", + "REASON_COMPATIBLE_DRM_PATH": "此目标与可用的加密数据兼容", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index bd2e1033d..09cfd812f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -551,6 +551,7 @@ "DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. MPV or VLC can often bypass browser-player access limits.", "PWA_DESCRIPTION": "The request may be blocked by CORS, mixed content, or provider header/proxy restrictions. The PWA cannot launch external players directly; use the Copy stream URL action and open it manually in MPV, VLC, IINA, or another player." }, + "UNTRANSFERABLE_DESCRIPTION": "This protected stream requires playback configuration that cannot be transferred with the stream URL.", "DRM_OR_ENCRYPTION": { "TITLE": "This stream may be encrypted or DRM-protected", "DESCRIPTION": "The browser player cannot continue with the reported encryption/key error. External playback may still fail if provider DRM is required." @@ -561,6 +562,17 @@ }, "NATIVE_FALLBACK_TITLE": "Open it in a native player instead", "INLINE_FAILURE_TITLE": "Copy the URL or try again", + "UNTRANSFERABLE_FAILURE_TITLE": "Protected playback could not continue", + "RECOMMENDATIONS_LABEL": "建議的復原操作", + "ACTION_TRY_PLAYER": "嘗試 {{player}}", + "ACTION_TRY_PLAYER_HINT": "僅暫時套用於此項目;已儲存的播放器不會變更", + "REASON_RETRY_TRANSIENT_FAILURE": "暫時載入失敗後使用相同的播放器重試", + "REASON_RETRY_UNKNOWN_FAILURE": "重試,因為此錯誤未能識別其他相容的播放器", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "為相同內容嘗試其他可用來源", + "REASON_DIFFERENT_ENGINE_FAMILY": "此播放器使用不同的瀏覽器播放引擎", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "原生播放器支援更多編解碼器和容器", + "REASON_EXTERNAL_BROWSER_ACCESS": "原生播放器可以避開瀏覽器存取限制", + "REASON_COMPATIBLE_DRM_PATH": "此目標與可用的加密資料相容", "ACTION_OPEN_MPV": "Open in MPV", "ACTION_OPEN_VLC": "Open in VLC", "ACTION_OPEN_MPV_HINT": "configured player", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 426664fba..44bd446dd 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -19,7 +19,8 @@ This document records the current contract for embedded playback in portal detai 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 can offer explicit MPV/VLC fallback actions without changing the saved player setting. +- Browser-player failures are diagnosed client-side and produce ranked, + user-triggered recovery actions without changing the saved player setting. ## Scope @@ -38,6 +39,58 @@ Collection/search VOD surfaces that expose embedded playback must host `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: @@ -231,9 +284,9 @@ 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 and fallback UI: +Diagnostics, recovery policy, and recovery UI: -- `/Users/4gray/Code/iptvnator/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts` +- `/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 @@ -309,7 +362,30 @@ Current contract: ## 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, mpegts.js errors, and HLS manifest codec metadata into the shared diagnostics classifier. +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: @@ -418,10 +494,14 @@ 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. For clear DASH, the diagnostic still -offers configured MPV/VLC actions because the failure is specific to the web -engine. KODIPROP DRM sources keep external fallback disabled because external -players do not receive their key configuration. +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 @@ -443,7 +523,8 @@ status from the top-level `info.code` slot of 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 fallback-actionable `media-decode-error`: +`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`. @@ -454,17 +535,20 @@ 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 -the existing explicit MPV/VLC fallback behavior. +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, a native-player fallback headline, and -player-card actions for configured external players. It exposes technical -details on demand: diagnostic code, reporting player/source, detected -container/MIME, video/audio codecs, native browser error fields, 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. +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. @@ -472,14 +556,125 @@ 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 the existing -diagnostic and explicit MPV/VLC fallback. +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. -When a diagnostic is actionable in Electron, the diagnostic surface may offer `Open in MPV`, `Open in VLC`, `Copy URL`, technical details, and `Retry`. Web builds only expose copy/help text and retry. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact. Retry clears the current diagnostic and rebuilds the active inline player inputs; it does not change the saved player setting. +## Recovery Recommendation Policy -`PortalPlayer.openExternalPlayback(playback, player)` is the forced external launch API. It sends the playback payload to MPV or VLC regardless of the current saved player setting, so fallback buttons do not mutate preferences. +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 diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index cec501b91..76a6ec298 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -994,8 +994,9 @@ player in settings. - `ShakaVideoSession` (`libs/ui/playback/src/lib/shaka-engine/`) owns the engine: lazy `import('shaka-player')` on first use (the module is a separate lazy chunk, ~217 KB transfer), `drm.clearKeys` configuration, an operation - queue + generation guard against channel-switch races, and a Shaka `5.2.2` - public-error boundary. The boundary version-locks its allowlisted + queue + generation guard against channel-switch races. The DOM-free Shaka + `5.2.2` public-error boundary lives in `libs/playback/util`; it version-locks + its allowlisted severity/category/code values, emits only structured sanitized `PlaybackDiagnosticSource.Shaka` evidence, ignores recoverable error events, and treats a rejected load as terminal even if its final retry error retains @@ -1007,9 +1008,11 @@ player in settings. unknown stage/failure. Public DASH text-parser codes likewise retain their exact `TEXT` category/code but keep stage/failure unknown. A failed `Player.isBrowserSupported()` preflight also stays unknown rather than - claiming container incompatibility; clear DASH still offers configured - external-player actions, while KODIPROP DRM keeps them disabled because - those players never receive its keys. + claiming container incompatibility. It carries only the enumerated, + app-owned `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker — never a + producer recommendation hint or engine payload. Clear DASH still offers + configured external-player actions, while KODIPROP DRM keeps them disabled + because those players never receive its keys. Channels with `drm.supported === false` emit a `DrmOrEncryption` diagnostic with a fixed safe detail string and without starting an engine. - HTML5 player: `extension === 'mpd'` branch in `playChannel()`. ArtPlayer: diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 63faab2bc..8304d69e3 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -73,6 +73,26 @@ in `libs/portal/shared/util`, while reusable collection views stay in `libs/portal/shared/ui`. Existing injectable or stateful services in a `util` path are legacy debt, not precedent for new placement. +Playback follows the same split: browser and Angular player integration stays +in `libs/ui/playback`, while DOM-free diagnostic contracts and classifiers live +in `libs/playback/util` and receive browser capability checks as explicit +probes. + +`libs/playback/util` is the `playback-util` Nx project and is imported through +`@iptvnator/playback/util`. Its exact tags are `scope:shared`, +`domain:playback`, and `type:util`. It owns the public playback diagnostic, +structured engine-evidence, source/engine-family, target-capability, +content-session-key, and recovery-recommendation contracts and pure helpers. +Its public API is `libs/playback/util/src/index.ts`. + +`playback-util` has no Angular, DOM, settings, storage, UI, or Electron IPC +ownership. Browser/player adapters collect public engine events and supply +explicit capability facts; `playback-util` classifies and ranks them without +inspecting runtime globals. As a `type:util` project it may depend only on +other utility projects, including shared interface contracts, while +`ui-playback` and feature hosts may depend on it to render and execute +session-local recovery actions. + ## Project Tags Every Nx project keeps one tag from each family in `project.json`: diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 8fc5d161f..323af7f7b 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -7,8 +7,7 @@ Embedded MPV rendering and native-view bounds behavior remain documented in ## Current status -The shared-controls foundation from PR #1148 now supports four runtime -consumers and includes: +The shared-controls foundation supports four runtime consumers and includes: - the `PlayerController` contract, default state, and capability presets; - the standalone `app-player-controls` presentation component and its @@ -82,6 +81,25 @@ also includes a recording coordinator that correlates asynchronous snapshots with the active playback/session owner, serializes toggles, and cancels pending ownership when the session, playback, engine, or component changes. +## Diagnostics And Recovery Ownership Boundary + +`PlayerController` remains a sibling of playback diagnostics and recovery +recommendations. It owns engine-neutral playback state, capabilities, and +commands for the shared controls; it does not classify errors, call +`recommendPlaybackRecovery()`, rank actions, track recovery attempts, choose a +temporary player, or own content-session policy. + +Those pure contracts and policies live in `@iptvnator/playback/util` (Nx +project `playback-util`). `WebPlayerViewComponent` owns their in-memory, +session-local application. No diagnostic or recommendation state or command is +added to `PlayerController`. + +The only controls-layer participation is interaction gating. While the sibling +diagnostic panel is visible, a web-player host disables shared surface and +keyboard ownership and exits only its own DOM fullscreen so the recovery +actions remain reachable. Clearing the diagnostic restores those paths; it +does not make the controls contract an owner of the recovery lifecycle. + ## Why this exists Historically each playback engine owned both media integration and controls UI. @@ -306,7 +324,7 @@ playback diagnostic is visible: `WebPlayerViewComponent` passes components bind that value to `showControls` and `shortcutsEnabled`. If the active player shell owns DOM fullscreen, its host exits fullscreen before hiding the controls so the sibling diagnostic banner -and its retry/fallback actions remain visible; fullscreen owned by another +and its recovery actions remain visible; fullscreen owned by another element is left untouched. Retrying playback or clearing the diagnostic restores both interaction paths. diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 45d824421..ebe31290d 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1263,7 +1263,19 @@ Series inline playback behavior is shared across all three modes: blocks the action rather than binding regular, embedded, or lazy VOD content to another mode; an exact canonical episode id still wins. -- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and derives the currently playing episode from `inlinePlayback.contentInfo.contentXtreamId`. +- `StalkerSeriesViewComponent` maps every mode into `mappedSeasons()` and uses + two episode identities. A pending playback request keeps an exact, + request-local provider command or episode ID so command rotation and hash + collisions reject stale completion. Once mounted, the component freezes only + the credential-free structural identity and session key (source, normalized + parent, mode, season key, season number, and episode number). +- The mounted structural identity is re-resolved against the current + `mappedSeasons()` for metadata, Previous/Next, and autoplay. Same-owner + provider and TMDB refreshes therefore expose current episode objects and + commands without remounting the player; if the episode is missing or its + structural coordinates are ambiguous, those surfaces and commands fail + closed. Provider commands and IDs are never retained in mounted session + state. - The inline player header shows the current episode metadata below the title, for example `S01E03 - Episode title`. - Embedded players receive previous/next episode state for the current season only. - Inline series autoplay is enabled by default. On player EOF (`ended`), Stalker starts the next episode only when it already exists in the current season's mapped episode list. diff --git a/docs/superpowers/plans/2026-08-03-playback-recommendations.md b/docs/superpowers/plans/2026-08-03-playback-recommendations.md new file mode 100644 index 000000000..e99be311c --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-playback-recommendations.md @@ -0,0 +1,2079 @@ +# Playback Recovery Recommendations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rank evidence-based playback recovery actions and let users try a +recommended built-in web player for the current content without changing their +saved setting. + +**Architecture:** Extract the existing pure diagnostic contracts into a new +`@iptvnator/playback/util` Nx project, then add a total ranked-policy function +over structured evidence and explicit target capabilities. Keep playback-session +state in `WebPlayerViewComponent` through a focused helper, render recommendations +through a presentational diagnostic panel, and make every host provide stable +content identity so Retry/source changes preserve attempts while new content +resets them. + +**Tech Stack:** Angular 21 signals and standalone components, TypeScript 5.9, +Nx 22, Jest, Playwright for web and Electron, hls.js, Video.js/VHS, Shaka Player, +mpegts.js, ngx-translate, Angular Material/CDK. + +--- + +## File Map + +Create the pure project and keep each file focused: + +- `libs/playback/util/project.json` — Nx ownership, tags, test, and lint targets. +- `libs/playback/util/tsconfig.json` and `tsconfig.lib.json` — strict library + compilation. +- `libs/playback/util/src/index.ts` — the only public playback-util barrel. +- `libs/playback/util/src/lib/diagnostics/` — the moved native/HLS/VHS/ + mpegts.js/Shaka evidence and classification boundary. +- `libs/playback/util/src/lib/playback-recommendation.model.ts` — target, + capability, source-context, reason, priority, and recommendation contracts. +- `libs/playback/util/src/lib/playback-target-capabilities.ts` — deterministic + source-kind and engine-family mapping. +- `libs/playback/util/src/lib/playback-recommendation-policy.ts` — the total, + ordered, maximum-three recommendation function. +- `libs/playback/util/src/lib/playback-session-key.ts` — stable content identity + encoding shared by M3U and portal hosts. + +Keep UI ownership under `ui-playback`: + +- `libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts` — + memory-only attempts, temporary override, resume point, generation, and + single-flight switch state. +- `libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts` — pure + construction of resolved playback, channel, and Video.js options extracted + from the near-limit host component. +- `libs/ui/playback/src/lib/playback-diagnostic-panel/` — presentational ranked + recommendation overlay and its formatting helpers, template, styles, and + specs. +- `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` — + orchestration only: engine host, session state, policy input, and outputs. + +Host changes stay with their existing owners. E2E uses repository-owned media +under `apps/web-e2e/src/fixtures/playback/`. No production diagnostic-injection +hook is added. + +## Task 0: Refresh The Isolated Baseline + +**Files:** + +- Verify: `package.json` +- Verify: `pnpm-lock.yaml` +- Verify: `docs/superpowers/specs/2026-08-03-playback-recommendations-design.md` +- Verify: `docs/superpowers/plans/2026-08-03-playback-recommendations.md` + +- [ ] **Step 1: Rebase the local design/plan commits onto the latest master** + +Run: + +```bash +git status --short +git fetch origin master --prune +git rebase origin/master +test "$(git merge-base HEAD origin/master)" = "$(git rev-parse origin/master)" +``` + +Expected: the worktree is clean, the rebase succeeds, and the final command +exits 0. Do not start implementation from the older merged diagnostics branch. + +- [ ] **Step 2: Verify dependency and Nx discovery** + +Run: + +```bash +test -d node_modules || pnpm install --frozen-lockfile +pnpm nx show projects +pnpm nx show projects --withTarget test +pnpm nx show projects --withTarget e2e +``` + +Expected: exit 0 with `ui-playback`, `web-e2e`, and +`electron-backend-e2e` present. + +- [ ] **Step 3: Re-run the affected baseline** + +Run: + +```bash +pnpm nx test ui-playback +pnpm nx lint ui-playback +``` + +Expected: the pre-change baseline remains green (currently 91 suites and 915 +tests), and lint succeeds. A Jest worker-teardown warning is non-fatal only when +Nx still reports success. + +## Task 1: Extract The Pure Playback Diagnostic Boundary + +**Files:** + +- Create: `libs/playback/util/project.json` +- Create: `libs/playback/util/tsconfig.json` +- Create: `libs/playback/util/tsconfig.lib.json` +- Create: `libs/playback/util/src/index.ts` +- Create: `libs/playback/util/src/lib/playback-util-boundary.spec.ts` +- Move: `libs/ui/playback/src/lib/playback-diagnostics/*` +- Move: `libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts` +- Move: `libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts` +- Move: `libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts` +- Move: `libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts` +- Move: `libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts` +- Move: `libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts` +- Move: `libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts` +- Create: `libs/playback/util/src/lib/diagnostics/shaka-error.types.ts` +- Create: `libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts` +- Modify: `libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts` +- Modify: HTML5 and ArtPlayer HLS manifest-codec probe call sites +- Modify: `tsconfig.base.json` +- Modify: `libs/ui/playback/src/index.ts` +- Modify: imports under `libs/ui/playback/src/lib/**/*.ts` + +- [ ] **Step 1: Scaffold the pure Nx project** + +Create `project.json`: + +```json +{ + "name": "playback-util", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/playback/util/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:shared", "domain:playback", "type:util"], + "targets": { + "test": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "command": [ + "node", + "./tools/testing/run-web-esm-lib-tests.mjs", + "libs/playback/util/src" + ], + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + }, + "forwardAllArgs": true + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} +``` + +Create `tsconfig.json`: + +```json +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.lib.json" }] +} +``` + +Create `tsconfig.lib.json`: + +```json +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} +``` + +Add the scoped alias to `tsconfig.base.json`: + +```json +"@iptvnator/playback/util": ["libs/playback/util/src/index.ts"] +``` + +Run: + +```bash +pnpm nx show project playback-util +``` + +Expected: Nx reports `scope:shared`, `domain:playback`, `type:util`, plus test +and lint targets. + +- [ ] **Step 2: Move the pure diagnostic files without changing behavior** + +Run these repository moves: + +```bash +mkdir -p libs/playback/util/src/lib/diagnostics +git mv libs/ui/playback/src/lib/playback-diagnostics/* libs/playback/util/src/lib/diagnostics/ +git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts libs/playback/util/src/lib/diagnostics/ +git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts libs/playback/util/src/lib/diagnostics/ +git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts libs/playback/util/src/lib/diagnostics/ +git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts libs/playback/util/src/lib/diagnostics/ +git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts libs/playback/util/src/lib/diagnostics/ +git mv libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts libs/playback/util/src/lib/diagnostics/ +git mv libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts libs/playback/util/src/lib/diagnostics/ +``` + +Because all moved Shaka files now share the same directory as the diagnostic +model, replace their old `../playback-diagnostics/...` imports with local +`./playback-diagnostics...` imports. + +Do not move `shaka-module.types.ts`: it owns the lazy vendor loader and DOM +player surface. Instead, create the DOM-free structural seam in +`shaka-error.types.ts`: + +```typescript +export interface ShakaErrorLike { + readonly severity: number; + readonly category: number; + readonly code: number; + readonly data?: readonly unknown[]; +} +``` + +Import that type locally from the moved Shaka classifier/evidence/lifecycle +files. Remove its old declaration from `shaka-module.types.ts`, import it from +`@iptvnator/playback/util`, and re-export the type there so the UI session keeps +its current local module surface. Keep `shaka-video-session`, the lazy module +loader, `shaka-player-test-double`, and text-track suppression in +`ui-playback`. + +Remove the two remaining production DOM references from the moved diagnostic +boundary. Narrow `classifyNativePlaybackIssue` to the already-public +`NativePlaybackErrorInput | null | undefined` structural input; real +`MediaError` values remain assignable. Change the HLS codec preflight to accept +an explicit probe: + +```typescript +export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined; + +export function classifyUnsupportedHlsManifestCodecs( + metadata: PlaybackSourceMetadata, + isTypeSupported: MediaTypeSupportProbe +): PlaybackDiagnostic | null; +``` + +The classifier returns null when there are no codecs or the probe returns +`true`/`undefined`, and returns `unsupported-codec` only for exact `false`. +Create the UI-owned adapter: + +```typescript +export function isBrowserMediaTypeSupported( + mimeType: string +): boolean | undefined { + return typeof MediaSource === 'undefined' + ? undefined + : MediaSource.isTypeSupported(mimeType); +} +``` + +Pass this adapter from the existing HTML5 and ArtPlayer manifest-codec call +sites. Update the closest specs first to prove supported, unsupported, and +unavailable-probe behavior is unchanged. + +Add `playback-util-boundary.spec.ts` as a source-boundary regression test. It +must recursively parse every non-spec `.ts` file under the project with the +TypeScript compiler API and fail when an import starts with `@angular/`, +`@iptvnator/ui/`, `electron`, or `@ngx-pwa/`, or when a non-property identifier +references `window`, `document`, `localStorage`, `sessionStorage`, +`MediaSource`, `MediaError`, `HTMLMediaElement`, or `HTMLElement`. Ignoring +property names is required because app-owned evidence values such as +`MpegTsPlaybackFailure.MediaSource` are not DOM access. Assert the current file +set produces an empty violation array. + +- [ ] **Step 3: Publish the new barrel and compatibility re-export** + +Create `libs/playback/util/src/index.ts`: + +```typescript +export * from './lib/diagnostics/playback-diagnostics.util'; +export * from './lib/diagnostics/shaka-error-classifier'; +export * from './lib/diagnostics/shaka-error-contract'; +export * from './lib/diagnostics/shaka-error-lifecycle'; +export type * from './lib/diagnostics/shaka-error.types'; +``` + +In `libs/ui/playback/src/index.ts`, replace the deleted relative diagnostic +export with the compatibility export: + +```typescript +export * from '@iptvnator/playback/util'; +``` + +Change every internal source and spec import that names +`playback-diagnostics/` or a moved `shaka-error-*` module to the new scoped +alias. Do not add a legacy bare alias or a deep import. + +Run: + +```bash +rg -n "playback-diagnostics/|shaka-engine/shaka-error-(classifier|contract|lifecycle|mapping)" libs apps --glob '*.ts' +rg -n "typeof (window|document|MediaSource)|\b(window|document|MediaSource)\.|\b(MediaError|HTMLMediaElement|HTMLElement)\s*[|&>,)]" libs/playback/util/src --glob '*.ts' --glob '!*.spec.ts' +``` + +Expected: no import references a deleted path, and the production +`playback-util` scan prints no Angular, UI, Electron, storage, or DOM +dependency. Documentation may still mention the old path until Task 10. + +- [ ] **Step 4: Verify the behavior-preserving extraction** + +Run: + +```bash +pnpm nx test playback-util +pnpm nx lint playback-util +pnpm nx test ui-playback +pnpm nx lint ui-playback +``` + +Expected: all migrated diagnostic contract/redaction/version-lock specs and all +remaining UI playback suites pass. + +- [ ] **Step 5: Commit the extraction** + +```bash +git add tsconfig.base.json libs/playback/util libs/ui/playback +git commit -m "refactor(playback): extract diagnostic utilities" +``` + +## Task 2: Define Targets, Capabilities, Engine Families, And Session Keys + +**Files:** + +- Create: `libs/playback/util/src/lib/playback-recommendation.model.ts` +- Create: `libs/playback/util/src/lib/playback-target-capabilities.ts` +- Create: `libs/playback/util/src/lib/playback-target-capabilities.spec.ts` +- Create: `libs/playback/util/src/lib/playback-session-key.ts` +- Create: `libs/playback/util/src/lib/playback-session-key.spec.ts` +- Modify: `libs/playback/util/src/index.ts` + +- [ ] **Step 1: Write failing capability and engine-family tests** + +Cover the approved matrix with this table: + +```typescript +it.each([ + ['hls', 'videojs', 'vhs'], + ['hls', 'html5', 'hls.js'], + ['hls', 'artplayer', 'hls.js'], + ['mpegts', 'videojs', 'mpegts.js'], + ['mpegts', 'html5', 'mpegts.js'], + ['mpegts', 'artplayer', 'mpegts.js'], + ['dash', 'videojs', null], + ['dash', 'html5', 'shaka'], + ['dash', 'artplayer', 'shaka'], + ['native', 'videojs', 'native-media'], + ['native', 'html5', 'native-media'], + ['native', 'artplayer', 'native-media'], +] as const)('%s maps %s to %s', (sourceKind, target, expectedFamily) => { + expect(getInlinePlaybackEngineFamily(sourceKind, target)).toBe( + expectedFamily + ); +}); +``` + +Also assert that engine-specific `hls`, `mpegts`, `shaka`, and `native` +diagnostic sources are authoritative even when generic metadata disagrees. +For generic `source` and multi-format `vhs`, accept only normalized exact base +MIME/container evidence: `m3u`/`m3u8` and the established HLS MIME aliases, +or `mpd` and `application/dash+xml`. Cover parameters/casing, MIME-only DASH +and HLS, symmetric container/MIME contradictions, malformed MIME substrings, +and insufficient evidence. Contradictory or insufficient evidence stays +`unknown`. + +Run: + +```bash +pnpm nx test playback-util -- --runTestsByPath libs/playback/util/src/lib/playback-target-capabilities.spec.ts --runInBand +``` + +Expected: FAIL because the contracts and mapper do not exist. + +- [ ] **Step 2: Add the recommendation contracts** + +Create `playback-recommendation.model.ts` with these exact public shapes: + +```typescript +import type { ExternalPlayerName } from '@iptvnator/shared/interfaces'; +import type { + InlinePlaybackPlayer, + PlaybackDiagnostic, +} from './diagnostics/playback-diagnostics.model'; + +export const PlaybackRecommendationReason = { + RetryTransientFailure: 'retry-transient-failure', + RetryUnknownFailure: 'retry-unknown-failure', + AlternativeSourceAvailable: 'alternative-source-available', + DifferentEngineFamily: 'different-engine-family', + ExternalCodecOrContainerSupport: 'external-codec-or-container-support', + ExternalBrowserAccess: 'external-browser-access', + CompatibleDrmPath: 'compatible-drm-path', +} as const; + +export type PlaybackRecommendationReason = + (typeof PlaybackRecommendationReason)[keyof typeof PlaybackRecommendationReason]; + +export type PlaybackRecommendationPriority = 'primary' | 'secondary'; +export type PlaybackRecommendationTarget = + InlinePlaybackPlayer | ExternalPlayerName; + +export const PlaybackSourceKind = { + Hls: 'hls', + MpegTs: 'mpegts', + Dash: 'dash', + Native: 'native', + Unknown: 'unknown', +} as const; +export type PlaybackSourceKind = + (typeof PlaybackSourceKind)[keyof typeof PlaybackSourceKind]; + +export const PlaybackEngineFamily = { + Vhs: 'vhs', + HlsJs: 'hls.js', + MpegTsJs: 'mpegts.js', + Shaka: 'shaka', + NativeMedia: 'native-media', +} as const; +export type PlaybackEngineFamily = + (typeof PlaybackEngineFamily)[keyof typeof PlaybackEngineFamily]; + +export type PlaybackTargetCapability = + | { + readonly kind: 'inline'; + readonly target: InlinePlaybackPlayer; + readonly available: boolean; + readonly engineFamily: PlaybackEngineFamily | null; + } + | { + readonly kind: 'external'; + readonly target: ExternalPlayerName; + readonly available: boolean; + }; + +export interface PlaybackRecommendationSourceContext { + readonly kind: PlaybackSourceKind; + readonly isLive: boolean; + readonly drm: 'none' | 'untransferable'; + readonly externalTransferable: boolean; +} + +export interface PlaybackRecommendationContext { + readonly diagnostic: PlaybackDiagnostic; + readonly activeTarget: PlaybackRecommendationTarget; + readonly attemptedTargets: ReadonlySet; + readonly targetCapabilities: readonly PlaybackTargetCapability[]; + readonly source: PlaybackRecommendationSourceContext; + readonly alternativeSourceCount: number; +} + +export type PlaybackRecommendation = + | { + readonly action: 'retry'; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + } + | { + readonly action: 'alternative-source'; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + } + | { + readonly action: 'player'; + readonly target: PlaybackRecommendationTarget; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + }; +``` + +- [ ] **Step 3: Implement the exact capability mapper** + +In `playback-target-capabilities.ts`, export: + +```typescript +export function resolvePlaybackSourceKind( + diagnostic: PlaybackDiagnostic +): PlaybackSourceKind; + +export function getInlinePlaybackEngineFamily( + sourceKind: PlaybackSourceKind, + target: InlinePlaybackPlayer +): PlaybackEngineFamily | null; + +export function createPlaybackTargetCapabilities(options: { + readonly sourceKind: PlaybackSourceKind; + readonly managedExternalPlayersAvailable: boolean; +}): readonly PlaybackTargetCapability[]; +``` + +Use this fail-closed mapping. Engine-specific sources identify the diagnostic +boundary that emitted the failure and therefore outrank generic metadata: + +```typescript +switch (diagnostic.source) { + case PlaybackDiagnosticSource.Hls: + return PlaybackSourceKind.Hls; + case PlaybackDiagnosticSource.MpegTs: + return PlaybackSourceKind.MpegTs; + case PlaybackDiagnosticSource.Shaka: + return PlaybackSourceKind.Dash; + case PlaybackDiagnosticSource.Source: + case PlaybackDiagnosticSource.Vhs: + return resolveSourceOrVhsKind(diagnostic); + case PlaybackDiagnosticSource.Native: + return PlaybackSourceKind.Native; + default: + return PlaybackSourceKind.Unknown; +} +``` + +`resolveSourceOrVhsKind` normalizes MIME to its trimmed, lowercased base +value with parameters stripped. It recognizes exact +`application/vnd.apple.mpegurl`, `application/x-mpegurl`, and the repository's +established `audio/x-mpegurl` alias as HLS, and exact +`application/dash+xml` as DASH. Gather container and MIME evidence separately; +if both are recognized and disagree, return `Unknown`. Otherwise return the +single recognized kind, the shared kind when both agree, or `Unknown`. Never +use substring MIME matching. + +Return inline capabilities in canonical order `videojs`, `html5`, `artplayer` +and external capabilities in `mpv`, `vlc` order. Video.js is unavailable for +DASH recommendations; the other matrix rows use the engine families asserted +in Step 1. + +- [ ] **Step 4: Drive and implement collision-safe session keys** + +Write tests proving callers can reuse one host-owned canonical logical identity +across different provider copies and source URLs, while changing channel, +movie, or episode identity changes the key. Include `:` and `|` in identifiers +to prove parts cannot collide, and assert the module does not expose an adapter +that guesses identity from provider-scoped playback metadata. + +Create `playback-session-key.ts`: + +```typescript +/** + * Host-owned canonical logical content identity. Source and content IDs must + * not come from the currently selected provider copy or playback URL. + */ +export type PlaybackSessionIdentity = + | { + readonly kind: 'live'; + readonly sourceId: string; + readonly contentId: string | number; + } + | { + readonly kind: 'vod'; + readonly sourceId: string; + readonly contentId: string | number; + } + | { + readonly kind: 'episode'; + readonly sourceId: string; + readonly contentId: string | number; + readonly seriesId?: string | number; + readonly seasonNumber?: number; + readonly episodeNumber?: number; + }; + +export function createPlaybackSessionKey( + identity: PlaybackSessionIdentity +): string { + const parts = [ + identity.kind, + identity.sourceId, + String(identity.contentId), + identity.kind === 'episode' ? String(identity.seriesId ?? '') : '', + identity.kind === 'episode' ? String(identity.seasonNumber ?? '') : '', + identity.kind === 'episode' ? String(identity.episodeNumber ?? '') : '', + ]; + + return parts.map((part) => `${part.length}:${part}`).join('|'); +} +``` + +Do not add a `PlayerContentInfo` adapter: multi-source resolution replaces its +playlist/content IDs with the selected provider copy, so it cannot represent a +stable recovery session. + +Run: + +```bash +pnpm nx test playback-util -- --runTestsByPath libs/playback/util/src/lib/playback-target-capabilities.spec.ts libs/playback/util/src/lib/playback-session-key.spec.ts --runInBand +``` + +Expected: PASS. + +- [ ] **Step 5: Export and commit the contracts** + +Add the three new modules to `libs/playback/util/src/index.ts`, then run: + +```bash +pnpm nx test playback-util +pnpm nx lint playback-util +git add libs/playback/util +git commit -m "feat(playback): define recovery recommendation contracts" +``` + +## Task 3: Implement The Ranked Recovery Policy With TDD + +**Files:** + +- Create: `libs/playback/util/src/lib/playback-recommendation-policy.ts` +- Create: `libs/playback/util/src/lib/playback-recommendation-policy.spec.ts` +- Modify: `libs/playback/util/src/index.ts` + +- [ ] **Step 1: Write the failing table for the complete policy matrix** + +Use a diagnostic/capability factory and assert exact ordered outputs for: + +```typescript +const cases = [ + ['network-error', ['retry', 'alternative-source']], + ['unknown-playback-error', ['retry', 'alternative-source']], + ['browser-access-error', ['mpv', 'vlc', 'alternative-source']], + ['unsupported-codec', ['mpv', 'vlc', 'alternative-source']], + ['unsupported-container', ['mpv', 'vlc', 'alternative-source']], + ['media-decode-error:hls-vhs', ['html5', 'mpv', 'vlc']], + ['media-decode-error:hls-hlsjs', ['videojs', 'mpv', 'vlc']], + ['media-decode-error:mpegts', ['mpv', 'vlc', 'alternative-source']], + ['media-decode-error:dash', ['mpv', 'vlc', 'alternative-source']], + ['media-decode-error:native', ['mpv', 'vlc', 'alternative-source']], + ['drm-or-encryption:dash-clearkey', ['alternative-source']], +] as const; +``` + +Add separate tests proving: + +- the current and attempted targets are excluded; +- filtering promotes the first survivor to `primary`; +- output is capped at three and later entries are `secondary`; +- HLS returns one representative per distinct engine family; +- ClearKey/KODIPROP context excludes both external targets; +- unavailable managed players are excluded in the PWA; +- missing/contradictory active capability returns only Retry plus alternative + source; +- the function does not mutate the input Set or capability array and never + throws for `unknown` source kind. + +Run the focused spec. Expected: FAIL because +`recommendPlaybackRecovery` does not exist. + +- [ ] **Step 2: Implement ordered candidates and filtering** + +Create `playback-recommendation-policy.ts` around this complete flow: + +```typescript +type PlaybackRecommendationCandidate = + | Omit< + Extract, + 'priority' + > + | Omit< + Extract< + PlaybackRecommendation, + { readonly action: 'alternative-source' } + >, + 'priority' + > + | Omit< + Extract, + 'priority' + >; + +export function recommendPlaybackRecovery( + context: PlaybackRecommendationContext +): readonly PlaybackRecommendation[] { + const capabilityIndex = isPlayerOrientedDiagnostic(context.diagnostic.code) + ? createCapabilityIndex(context) + : null; + const candidates = buildCandidates(context, capabilityIndex).filter( + (candidate): candidate is PlaybackRecommendationCandidate => + candidate !== null + ); + const seenTargets = new Set(); + const filtered = candidates.filter((candidate) => { + if (candidate.action !== 'player') { + return true; + } + if ( + candidate.target === context.activeTarget || + context.attemptedTargets.has(candidate.target) || + seenTargets.has(candidate.target) + ) { + return false; + } + if (capabilityIndex?.get(candidate.target)?.available !== true) { + return false; + } + if ( + isExternalTarget(candidate.target) && + (context.source.drm === 'untransferable' || + !context.source.externalTransferable) + ) { + return false; + } + seenTargets.add(candidate.target); + return true; + }); + + return filtered + .slice(0, 3) + .map((candidate, index) => ({ + ...candidate, + priority: index === 0 ? 'primary' : 'secondary', + })); +} +``` + +`buildCandidates` must use these exact sequences before filtering: + +```typescript +switch (context.diagnostic.code) { + case PlaybackDiagnosticCode.NetworkError: + return [retryTransient(), alternative(context)]; + case PlaybackDiagnosticCode.UnknownPlaybackError: + return [retryUnknown(), alternative(context)]; + case PlaybackDiagnosticCode.BrowserAccessError: + return [ + external('mpv', PlaybackRecommendationReason.ExternalBrowserAccess), + external('vlc', PlaybackRecommendationReason.ExternalBrowserAccess), + alternative(context), + ]; + case PlaybackDiagnosticCode.UnsupportedCodec: + case PlaybackDiagnosticCode.UnsupportedContainer: + return [ + external( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + external( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(context), + ]; + case PlaybackDiagnosticCode.MediaDecodeError: + return [ + distinctInline( + context, + capabilityIndex, + PlaybackRecommendationReason.DifferentEngineFamily + ), + external( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + external( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(context), + ]; + case PlaybackDiagnosticCode.DrmOrEncryption: + return [ + distinctInline( + context, + capabilityIndex, + PlaybackRecommendationReason.CompatibleDrmPath + ), + alternative(context), + external('mpv', PlaybackRecommendationReason.CompatibleDrmPath), + external('vlc', PlaybackRecommendationReason.CompatibleDrmPath), + ]; + default: + return [retryUnknown(), alternative(context)]; +} +``` + +`buildCandidates` returns +`readonly (PlaybackRecommendationCandidate | null)[]`. +`alternative(context)` returns null unless `alternativeSourceCount` is a +positive safe integer. Before building candidates for a player-oriented +diagnostic, validate one complete, unique capability record for each canonical +target, including exact target/kind pairing and source-kind engine-family +mapping. Any malformed matrix returns Retry plus a valid alternative instead +of trusting player candidates. `distinctInline` then selects the fixed HLS +representative from that validated index independently of capability-array +order: HTML5 represents hls.js after a Video.js/VHS failure, while Video.js +represents VHS after an HTML5 or ArtPlayer hls.js failure. If HTML5 is current, +unavailable, or already attempted, filtering proceeds to external and +alternative candidates without substituting ArtPlayer. For MPEG-TS, DASH, +native, and unknown matrices this naturally returns null. + +If a player-oriented diagnostic has no coherent active inline capability, +return `retryUnknown()` plus `alternative(context)` instead of trusting any +player candidate. + +- [ ] **Step 3: Run the full policy and project suites** + +```bash +pnpm nx test playback-util -- --runTestsByPath libs/playback/util/src/lib/playback-recommendation-policy.spec.ts --runInBand +pnpm nx test playback-util +pnpm nx lint playback-util +``` + +Expected: every ordered case passes, with no mutation or thrown-error failures. + +- [ ] **Step 4: Export and commit the policy** + +```bash +git add libs/playback/util +git commit -m "feat(playback): rank recovery recommendations" +``` + +## Task 4: Add A Focused In-Memory Recovery Session + +**Files:** + +- Create: `libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts` +- Create: `libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts` + +- [ ] **Step 1: Write failing lifecycle and race tests** + +Cover: + +```typescript +session.syncSession('movie-a'); +const first = session.beginPlayback('videojs'); +expect(session.recordFailure(first)).toBe(true); +expect(session.attemptedTargets()).toEqual(new Set(['videojs'])); + +session.recordTimeUpdate({ currentTime: 42, duration: 120 }, false); +expect(session.beginPlayerSwitch('html5', false)).toBe(true); +const switched = session.beginPlayback('html5'); +expect(switched.target).toBe('html5'); +expect(session.temporaryPlayerOverride()).toBe('html5'); +expect(session.resumeStartTime(0, false)).toBe(42); + +session.syncSession('movie-b'); +expect(session.attemptedTargets().size).toBe(0); +expect(session.temporaryPlayerOverride()).toBeNull(); +expect(session.resumeStartTime(0, false)).toBe(0); +expect(session.recordFailure(first)).toBe(false); +``` + +Also test Retry preserves attempts, source changes with the same key preserve +attempts, live sessions never retain a resume point, external attempts are +recorded, an accepted failure or settle clears pending state, and concurrent +switch/retry attempts accept only the first operation. Explicitly cover the +pre-effect interval after an accepted switch and retry: old binding callbacks +must be rejected until `beginPlayback` installs the replacement binding. + +Run the focused spec. Expected: FAIL because the class does not exist. + +- [ ] **Step 2: Implement the signal-backed UI session** + +Create the class with this public contract: + +```typescript +export interface PlaybackBinding { + readonly generation: number; + readonly target: InlinePlaybackPlayer; +} + +export class PlaybackRecoverySession { + private readonly attemptedTargetsState = signal< + ReadonlySet + >(new Set()); + private readonly temporaryPlayerOverrideState = + signal(null); + private readonly switchPendingState = signal(false); + private readonly activeBindingState = signal(null); + + private readonly sessionKey = signal(null); + private readonly generation = signal(0); + private readonly resumePosition = signal(null); + + readonly attemptedTargets: Signal< + ReadonlySet + > = this.attemptedTargetsState.asReadonly(); + readonly temporaryPlayerOverride: Signal = + this.temporaryPlayerOverrideState.asReadonly(); + readonly switchPending: Signal = + this.switchPendingState.asReadonly(); + readonly activeBinding: Signal = + this.activeBindingState.asReadonly(); + + syncSession(key: string): boolean; + beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding; + clearPlaybackBinding(): void; + recordFailure(binding: PlaybackBinding): boolean; + recordInlineAttempt(target: InlinePlaybackPlayer): void; + recordExternalAttempt(target: ExternalPlayerName): void; + recordTimeUpdate( + event: { readonly currentTime: number; readonly duration: number }, + isLive: boolean + ): void; + beginPlayerSwitch(target: InlinePlaybackPlayer, isLive: boolean): boolean; + beginRetry(): boolean; + settle(binding: PlaybackBinding): void; + resumeStartTime(inputStartTime: number, isLive: boolean): number; + accepts(binding: PlaybackBinding): boolean; +} +``` + +Implementation rules: + +- `syncSession` is a no-op for the same key; a new key clears attempts, + override, pending state, resume position, and active binding, then increments + generation. +- Public state is exposed as read-only `Signal` values backed by private + writable signals. `beginPlayback` always advances the generation and + installs and returns the same frozen active-target snapshot. Call it for + every applied source, including a same-content alternative URL, so delayed + events from the replaced source become stale without clearing attempts. + `clearPlaybackBinding` delegates to the same private invalidation helper used + by recovery operations; the helper advances the generation and stores null + for Embedded MPV/non-diagnostic playback. +- `recordInlineAttempt`, `recordExternalAttempt`, and every failure update copy + the Set before adding. +- `recordFailure` and `settle` first call `accepts`; stale generations do + nothing. Both clear `switchPending` for an accepted replacement binding; a + replacement target that fails before emitting a success/clear event must not + leave every recovery action disabled. +- `beginPlayerSwitch` returns false without invalidating the binding while + pending. Otherwise it records the target, clears live resume, sets the + override and pending state, synchronously invalidates the current binding, + and returns true. Old callbacks in the interval before the component's + playback effect calls `beginPlayback` are therefore rejected. +- `beginRetry` returns false without invalidating the binding while pending or + without an active binding. Otherwise it keeps attempts/override, marks the + reload pending, synchronously invalidates the current binding, and returns + true. Incrementing the component reload token reruns the playback effect and + installs the next binding. +- `recordTimeUpdate` stores only finite non-negative VOD positions; live, + `NaN`, `Infinity`, and negative positions are ignored. +- `resumeStartTime` always returns 0 for live playback and otherwise prefers + the stored finite position over the host input. +- `accepts` compares both the generation and exact target against + `activeBinding`; a binding for the old target is stale even if a caller + accidentally reuses its generation. + +- [ ] **Step 3: Verify and commit the session helper** + +```bash +pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts --runInBand +pnpm nx lint ui-playback +git add libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts +git commit -m "feat(playback): track session recovery attempts" +``` + +## Task 5: Bind Stable Content Session Keys In Every Host + +**Files:** + +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` +- Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts` +- Modify: `libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts` +- Modify: `libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html` +- Modify: `libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts` +- Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts` +- Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html` +- Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts` +- Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts` +- Modify: `libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.ts` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.html` +- Modify: `libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html` +- Modify: `libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html` +- Modify: `libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts` + +- [ ] **Step 1: Add failing host identity tests** + +Prove these invariants in the closest existing specs: + +- M3U: playlist ID + `Channel.id`; catch-up URL changes keep the key, changing + channel changes it. +- Xtream live: current playlist ID + selected `xtream_id`. +- Stalker live: current playlist `_id` + normalized selected channel ID. +- Unified live: active item playlist ID + `uid`; timeshift URL changes keep the + key. +- Portal inline VOD/episode: the Xtream/Stalker route or series host derives a + canonical key from its original route/catalog identity and passes it through + unchanged. Replacing playback with an alternative provider copy changes its + URL and provider-scoped `contentInfo` but keeps the key; selecting a + different original movie or episode changes it. +- Transfer contract: M3U preserves the resolved request URL plus active-channel + User-Agent/Referer/Origin; Xtream, Stalker, unified live, and portal inline + pass the exact `ResolvedPortalPlayback` (including headers and content info) + to their existing external-launch owner. These assertions justify marking + current non-DRM Electron playback externally transferable in Task 7. + +Run: + +```bash +pnpm nx test playlist-m3u-feature-player -- --runTestsByPath libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts --runInBand +pnpm nx test portal-xtream-feature -- --runTestsByPath libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts --runInBand +pnpm nx test portal-stalker-feature -- --runTestsByPath libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts --runInBand +pnpm nx test portal-shared-ui -- --runTestsByPath libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts --runInBand +pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts --runInBand +pnpm nx test portal-xtream-feature -- --runTestsByPath libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts --runInBand +pnpm nx test portal-stalker-feature -- --runTestsByPath libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts --runInBand +``` + +Expected: FAIL because neither the required input nor the host bindings exist. + +- [ ] **Step 2: Make content identity required through the player chain** + +Add this input to `WebPlayerViewComponent`; Task 7 will consume it, while this +task first makes every current caller compile with a stable key. Add the same +required input to `PortalInlinePlayerComponent` and `VodDetailsComponent`, +which only thread the host-owned value to their nested player: + +```typescript +readonly playbackSessionKey = input.required(); +``` + +Update every stub for these three components with the same required input: + +```typescript +readonly playbackSessionKey = input.required(); +``` + +- [ ] **Step 3: Bind direct live hosts** + +Use `createPlaybackSessionKey` in each component. The M3U shape is: + +```typescript +readonly playbackSessionKey = computed(() => { + const playlistId = this.activePlaylistId(); + const channel = this.activeChannel(); + return playlistId && channel + ? createPlaybackSessionKey({ + kind: 'live', + sourceId: playlistId, + contentId: channel.id, + }) + : ''; +}); +``` + +Xtream and Stalker use their current playlist and selected item IDs in the same +shape. Unified live uses `activeItem().playlistId` and `activeItem().uid`. +Bind every direct view: + +```html + +``` + +Add that binding to each existing component tag without deleting or changing +any of its other input/output bindings. Do not include the current +stream/catch-up URL in these keys. + +- [ ] **Step 4: Derive VOD and episode keys in their owning hosts** + +Xtream VOD derives its key from the current route playlist and original +`selectedVodId`, not `inlinePlayback().contentInfo`. Xtream series derives an +episode key from the route playlist/series plus the host's active original +episode, season, and episode coordinates. The shapes are: + +```typescript +readonly vodPlaybackSessionKey = computed(() => + createPlaybackSessionKey({ + kind: 'vod', + sourceId: this.xtreamStore.currentPlaylist()?.id ?? '', + contentId: this.selectedVodId(), + }) +); + +readonly episodePlaybackSessionKey = computed(() => { + const originalEpisode = this.playback.inlineEpisodeState()?.episode; + return createPlaybackSessionKey({ + kind: 'episode', + sourceId: this.xtreamStore.currentPlaylist()?.id ?? '', + contentId: originalEpisode?.id ?? '', + seriesId: this.routeParams().serialId ?? '', + seasonNumber: originalEpisode?.season, + episodeNumber: originalEpisode?.episode_num, + }); +}); +``` + +Use the equivalent original catalog/route item and active episode identity in +Stalker VOD and series hosts. Thread the required key through any +`StalkerInlineDetailComponent`/`VodDetailsComponent` intermediary, bind it to +`PortalInlinePlayerComponent`, and have that component pass the exact input to +its nested `WebPlayerViewComponent`. + +Do not derive or fall back from `playback.contentInfo`, `streamUrl`, or title. +Alternative-source resolution intentionally rewrites `contentInfo.playlistId` +and `contentXtreamId` for the selected provider copy; those fields remain +playback/resume metadata and are not recovery-session identity. Host specs must +replace the full playback payload with such an alternative copy and prove the +required key is unchanged. + +- [ ] **Step 5: Verify every required binding and host project** + +Run: + +```bash +rg -l "(); + readonly recommendations = + input.required(); + readonly playback = input.required(); + readonly alternativeSources = input([]); + readonly pending = input(false); + + readonly retryRequested = output(); + readonly playerRequested = output(); + readonly alternativeSourceRequested = output(); + readonly sourceCheckRequested = output(); +} +``` + +Keep the existing `ERROR_SCREEN_ALTERNATIVES = 5`, `visibleAlternatives`, and the hidden +count inside this panel. Move the existing diagnostic title/meta/codec/detail +formatters into this folder unchanged; add small exhaustive functions mapping +recommendation reasons to translation keys, player names, icons, and the stable +test IDs asserted above. Derive `hasExternalPlayerRecommendation` from actual +ranked `mpv`/`vlc` entries. Use it for the existing native-vs-inline headline +choice and as the boolean passed to `getDiagnosticDescriptionKey`; this keeps +the browser-access PWA description accurate when policy or DRM filtering +removes managed external actions. + +- [ ] **Step 3: Render the ranked action list without redesigning the overlay** + +Copy the current badge, headline, description, metadata, codec hint, source-row, +Copy URL, and details markup verbatim. Replace the fixed action areas with this +ordered loop: + +```html +
+ @for (recommendation of recommendations(); track + getRecommendationKey(recommendation)) { @if (recommendation.action === + 'alternative-source') { +
+

+ {{ 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' | translate }} +

+ @for (source of visibleAlternatives(); track source.id) { + + } @if (hiddenAlternativeCount() > 0) { +

+ {{ 'PORTALS.MULTI_SOURCE.MORE_SOURCES' | translate : { count: + hiddenAlternativeCount() } }} +

+ } +
+ } @else { + + } } +
+``` + +`activate` emits Retry for `retry`, emits the target for `player`, and leaves +individual `VodSourceRow` buttons responsible for source selection. +`isTemporaryBuiltInRecommendation` returns true only when `action === 'player'` +and the target is one of `videojs`, `html5`, or `artplayer`; external targets +must not show the saved-player hint. +Do not add autofocus or a focus trap. + +- [ ] **Step 4: Copy and adapt the existing diagnostic styles** + +Copy `.web-player-diagnostic*` rules from +`web-player-view.component.scss` into the panel stylesheet. Add: + +```scss +:host { + display: contents; +} + +.web-player-diagnostic__recommendations { + display: grid; + grid-template-columns: repeat(2, minmax(168px, 192px)); + gap: 12px; + margin-top: 28px; +} + +.web-player-diagnostic__player-card { + min-width: 0; +} + +.web-player-diagnostic__alternatives { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +@container (max-width: 520px) { + .web-player-diagnostic__recommendations { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + margin-top: 16px; + } +} +``` + +Keep the current focus-visible outline and mobile detail layout. Do not change +the overlay colors or typography. + +- [ ] **Step 5: Add translation-key parity** + +Add these keys under `PLAYBACK_DIAGNOSTICS` in every locale file: + +```json +{ + "RECOMMENDATIONS_LABEL": "Recommended recovery actions", + "ACTION_TRY_PLAYER": "Try {{player}}", + "ACTION_TRY_PLAYER_HINT": "Temporary for this item; your saved player stays unchanged", + "REASON_RETRY_TRANSIENT_FAILURE": "Retry the same player after a temporary loading failure", + "REASON_RETRY_UNKNOWN_FAILURE": "Retry because the failure did not identify another compatible player", + "REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Try another available source for the same content", + "REASON_DIFFERENT_ENGINE_FAMILY": "This player uses a different browser playback engine", + "REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native players support more codecs and containers", + "REASON_EXTERNAL_BROWSER_ACCESS": "A native player may avoid browser access restrictions", + "REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data" +} +``` + +The block above is the canonical English copy. Add meaning-equivalent localized +values to all 18 other locale files; do not leave the new values in English in +non-English files. Reuse existing localized MPV, VLC, Retry, Copy URL, and +Technical details strings rather than adding duplicates. + +Run: + +```bash +pnpm run i18n:validate +``` + +Expected: every locale has identical keys and placeholder variables. + +- [ ] **Step 6: Verify and commit the unused presentational unit** + +The new component is intentionally not mounted until Task 7, so existing UI +behavior remains unchanged in this commit. + +```bash +pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts --runInBand +pnpm nx lint ui-playback +git add libs/ui/playback/src/lib/playback-diagnostic-panel apps/web/src/assets/i18n +git commit -m "feat(ui): add ranked playback diagnostic panel" +``` + +## Task 7: Integrate Policy And Temporary Player Switching + +**Files:** + +- Create: `libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts` +- Create: `libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts` +- Create: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts` +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts` +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.html` +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss` +- Modify: `libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts` +- Modify: diagnostic factories/specs under `libs/playback/util` and + `libs/ui/playback` + +- [ ] **Step 1: Extract existing playback construction before adding state** + +Drive `web-player-playback-state.spec.ts` from the current component assertions, +then extract these pure functions: + +```typescript +export function resolveWebPlayerPlayback(options: { + readonly playback: ResolvedPortalPlayback | null; + readonly streamUrl: string; + readonly title: string; + readonly startTime: number; +}): ResolvedPortalPlayback; + +export function createWebPlayerChannel( + playback: ResolvedPortalPlayback +): Channel; + +export function createVideoJsOptions(options: { + readonly streamUrl: string; + readonly isLive: boolean; + readonly reloadToken: number; +}): { + readonly isLive: boolean; + readonly reloadToken: number; + readonly sources: readonly { + readonly src: string; + readonly type: string; + }[]; +}; +``` + +Move the existing MIME selection and case-insensitive header lookup into these +functions without changing output. Replace `setChannel`, `setVjsOptions`, and +the resolved-playback construction in the component with these helpers. + +Run: + +```bash +pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts --runInBand +``` + +Expected: PASS before recommendation behavior is added and production +`web-player-view.component.ts` drops below 300 counted lines after the full +Task 7 edit. + +- [ ] **Step 2: Write failing WebPlayerView recovery integration tests** + +In the new recovery spec, use the existing player stubs and prove: + +1. a fatal Video.js HLS media diagnostic ranks HTML5, MPV, then VLC; +2. clicking HTML5 mounts the HTML5 stub while the settings storage still says + Video.js; +3. a second failure excludes Video.js and HTML5 from subsequent player actions; +4. Retry preserves attempts and reloads the active target; +5. a same-key playback URL change preserves attempts; +6. a new key clears attempts and the temporary override; +7. VOD switching passes the latest finite time as start time while live passes + zero; +8. a stale `{ generation, target }` issue cannot replace the current diagnostic; +9. playback with `drm` never ranks MPV/VLC; +10. PWA capabilities never render managed external-player actions; +11. a pending switch disables another action. + +Run: + +```bash +pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --runInBand +``` + +Expected: FAIL because policy wiring and temporary switching are absent. + +- [ ] **Step 3: Add session and recommendation signals to WebPlayerView** + +Add the required input and focused computed state: + +```typescript +readonly playbackSessionKey = input.required(); +private readonly recoverySession = new PlaybackRecoverySession(); +readonly recoveryPending = this.recoverySession.switchPending; +readonly activeBinding = this.recoverySession.activeBinding; + +readonly selectedPlayer = computed(() => { + const temporary = this.recoverySession.temporaryPlayerOverride(); + return temporary + ? toVideoPlayer(temporary) + : (this.playerOverride() ?? + this.settings()?.player ?? + VideoPlayer.VideoJs); +}); + +readonly effectiveStartTime = computed(() => + this.recoverySession.resumeStartTime( + this.startTime(), + this.resolvedIsLive() + ) +); +``` + +Add an exhaustive `toVideoPlayer` helper mapping `videojs` to +`VideoPlayer.VideoJs`, `html5` to `VideoPlayer.Html5Player`, and `artplayer` to +`VideoPlayer.ArtPlayer`; do not cast between the const-derived diagnostic type +and the settings enum. + +Use one playback effect that reads `playbackSessionKey` first, calls +`syncSession`, reads the resolved playback/selected player/reload token, then +calls `beginPlayback(inlineTarget)` immediately before `applyPlayback`. For +Embedded MPV call `clearPlaybackBinding`. Clear the visible diagnostic for +each newly applied source but do not clear attempts when `syncSession` reports +the same content key. Pass the newly returned binding into `applyPlayback` and +require both the header service's `stillCurrent` result and +`recoverySession.accepts(binding)` before its asynchronous callback hands a +source to a web player. This ordering makes key, source, Retry, and target +changes invalidate all older callbacks. + +At the start of `handlePlaybackIssue`, call a small `syncRecoverySession()` +helper that synchronizes the latest required input and clears a diagnostic if +the key changed. This closes the narrow interval between an Angular input +update and its effect flush, so an old child output cannot land in the new +content session. + +Build `recommendations` from the current diagnostic with: + +```typescript +readonly recommendations = computed(() => { + const issue = this.visiblePlaybackDiagnostic(); + const binding = this.activeBinding(); + if (!issue || !binding) { + return []; + } + + const sourceKind = resolvePlaybackSourceKind(issue); + return recommendPlaybackRecovery({ + diagnostic: issue, + activeTarget: binding.target, + attemptedTargets: this.recoverySession.attemptedTargets(), + targetCapabilities: createPlaybackTargetCapabilities({ + sourceKind, + managedExternalPlayersAvailable: + this.runtime.supportsManagedExternalPlayers, + }), + source: { + kind: sourceKind, + isLive: this.resolvedIsLive(), + drm: this.resolvedPlayback().drm + ? 'untransferable' + : 'none', + externalTransferable: !this.resolvedPlayback().drm, + }, + alternativeSourceCount: this.alternativeSources().length, + }); +}); +``` + +The non-DRM transferability fact is allowed only because Task 5 proves every +current WebPlayerView host forwards the full required payload through its +existing external-player path. If any host cannot satisfy that assertion, +add a required host capability input and pass false there instead of weakening +the policy or inferring safety from the URL. + +- [ ] **Step 4: Make engine events generation-safe** + +Change player outputs to pass the binding captured for that rendered branch: + +```html +@let binding = activeBinding(); @if (binding && selectedPlayer() === 'videojs') +{ + +} +``` + +Apply the same pattern to HTML5 and ArtPlayer. In `handlePlaybackIssue`: + +```typescript +handlePlaybackIssue( + issue: PlaybackDiagnostic | null, + binding: PlaybackBinding +): void { + if (!this.recoverySession.accepts(binding)) { + return; + } + if (!issue) { + this.recoverySession.settle(binding); + this.playbackDiagnostic.set(null); + return; + } + if (!this.recoverySession.recordFailure(binding)) { + return; + } + this.playbackDiagnostic.set(issue); + this.playbackFailed.emit(issue.code); +} +``` + +Capture the same generation before the asynchronous Electron header operation; +its `then` callback must call `recoverySession.accepts(binding)` before handing +the source to a player. + +`handleTimeUpdate` records the latest position, then forwards the unchanged +event to the host output. + +- [ ] **Step 5: Implement recommendation actions** + +Use one dispatcher: + +```typescript +requestRecommendedPlayer(target: PlaybackRecommendationTarget): void { + const diagnostic = this.visiblePlaybackDiagnostic(); + if (!diagnostic) { + return; + } + + if (target === 'mpv' || target === 'vlc') { + this.recoverySession.recordExternalAttempt(target); + this.externalFallbackRequested.emit({ + player: target, + playback: this.resolvedPlayback(), + diagnostic, + }); + return; + } + + const available = this.recommendations().some( + (item) => item.action === 'player' && item.target === target + ); + if (!available) { + this.recoverySession.recordInlineAttempt(target); + return; + } + + if ( + this.recoverySession.beginPlayerSwitch( + target, + this.resolvedIsLive() + ) + ) { + this.playbackDiagnostic.set(null); + } +} +``` + +The unavailable inline branch records the target through the +`recordInlineAttempt(target)` method defined in Task 4, leaving the diagnostic +visible so the computed list reranks immediately. Retry calls `beginRetry`, +and only when it returns true clears the diagnostic and increments +`reloadToken`; the playback effect rebuilds the same source with a new binding +without clearing attempts. Alternative source output does not reset session +state. + +- [ ] **Step 6: Mount the panel and remove the old overlay** + +Replace the entire inline diagnostic `
` in +`web-player-view.component.html` with: + +```html +@if (visiblePlaybackDiagnostic(); as issue) { + +} +``` + +Delete `.web-player-diagnostic*` rules from the WebPlayerView stylesheet now +that the panel owns them. Preserve the host/container and defer-placeholder +rules. + +- [ ] **Step 7: Remove diagnostic-owned fallback policy** + +Delete `externalFallbackRecommended` from `PlaybackDiagnostic`, the factory +option/default, classifier overrides, and every fixture assertion. Replace the +old boolean expectations with policy assertions in +`playback-recommendation-policy.spec.ts`. + +Run: + +```bash +rg -n "externalFallbackRecommended|canShowExternalFallbackActions" libs apps --glob '*.ts' --glob '*.html' +``` + +Expected: no matches. + +- [ ] **Step 8: Verify integration, file limits, and commit** + +```bash +pnpm nx test playback-util +pnpm nx test ui-playback +pnpm nx lint playback-util +pnpm nx lint ui-playback +node tools/eslint/generate-max-lines-baseline.mjs +git diff -- tools/eslint/max-lines-baseline.mjs +``` + +Expected: tests/lint pass; no new baseline entry appears; if the generator +removes a now-split UI file, retain that shrink. Every new production +TypeScript file and the edited `web-player-view.component.ts` remain below 300 +counted lines. + +```bash +git add libs/playback/util libs/ui/playback tools/eslint/max-lines-baseline.mjs +git commit -m "feat(playback): switch temporarily to recommended players" +``` + +## Task 8: Cover Temporary Built-In Switching In Web E2E + +**Files:** + +- Create: `apps/web-e2e/src/fixtures/playback/fatal-media.m3u8` +- Create: `apps/web-e2e/src/fixtures/playback/corrupt.ts` +- Create: `apps/web-e2e/src/playback-recommendations.e2e.ts` + +- [ ] **Step 1: Add the bounded offline HLS fixture** + +Create `fatal-media.m3u8`: + +```text +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:1, +corrupt.ts +#EXT-X-ENDLIST +``` + +Create `corrupt.ts` as the literal text: + +```text +This is intentionally not an MPEG transport stream segment. +``` + +The fixture contains no external URL or account data. + +- [ ] **Step 2: Write the Playwright flow** + +In the new E2E file: + +1. block service workers and run Chromium with autoplay allowed; +2. route `https://playback-fixture.local/**` to the two fixture files with + `application/vnd.apple.mpegurl` and `video/mp2t` content types; +3. open Settings, select HTML5, and save; +4. import a one-channel raw M3U pointing to + `https://playback-fixture.local/fatal-media.m3u8`; +5. select the channel and wait for the structured fatal HLS media diagnostic; +6. assert `playback-recommendation-videojs` is primary; +7. click it and assert `app-vjs-player` mounts; +8. return to Settings and assert the persisted selector still reads HTML5. + +Use these stable locators: + +```typescript +const banner = page.locator('[data-test-id="playback-diagnostic-banner"]'); +const videoJsRecommendation = page.locator( + '[data-test-id="playback-recommendation-videojs"]' +); +await expect(banner).toBeVisible({ timeout: 15_000 }); +await expect(videoJsRecommendation).toBeVisible(); +await videoJsRecommendation.click(); +await expect(page.locator('app-vjs-player')).toBeVisible(); +``` + +Do not add a test-only production API. If Chromium reports a different public +terminal hls.js media detail for the bounded corrupt segment, update only the +fixture to another deterministic public fatal media event; retain the +component-level exact policy test. + +- [ ] **Step 3: Run the atomized web E2E target and commit** + +After Nx discovers the new file, run: + +```bash +pnpm nx run web-e2e:e2e-ci--src/playback-recommendations.e2e.ts -- --project=chromium +``` + +Expected: the recommendation mounts Video.js and the Settings selector remains +HTML5. + +```bash +git add apps/web-e2e/src/fixtures/playback apps/web-e2e/src/playback-recommendations.e2e.ts +git commit -m "test(playback): cover temporary player recommendation" +``` + +## Task 9: Cover DRM Exclusion And External Fallback In Electron E2E + +**Files:** + +- Create: `apps/web-e2e/src/fixtures/playback/unsupported.mkv` +- Modify: `apps/electron-backend-e2e/src/dash-clearkey.e2e.ts` +- Modify: `apps/web-e2e/src/dash-clearkey.e2e.ts` + +- [ ] **Step 1: Extend ClearKey assertions before adding the eligible case** + +For the unsupported Widevine channel in both existing E2E files, assert: + +```typescript +await expect( + banner.locator('[data-test-id="playback-fallback-mpv"]') +).toHaveCount(0); +await expect( + banner.locator('[data-test-id="playback-fallback-vlc"]') +).toHaveCount(0); +``` + +In web E2E also assert no built-in recommendation test ID is rendered. The web +runtime already lacks managed external launch, while the Electron assertion is +the required proof that DRM transferability—not runtime capability—filters the +targets. + +- [ ] **Step 2: Add a deterministic unsupported-container fixture** + +Create `unsupported.mkv` with a small non-media payload: + +```text +This fixture intentionally declares Matroska without playable media bytes. +``` + +Extend the Electron fixture server to serve `.mkv` as `video/matroska`, and add +an `Unsupported MKV` channel to the imported M3U. Video.js/native media must +surface `unsupported-container` from the `.mkv` metadata rather than a network +diagnostic. + +- [ ] **Step 3: Capture and assert the existing MPV launch request** + +Install local IPC capture in the Electron test before selecting the MKV +channel: + +```typescript +await app.electronApp.evaluate(({ ipcMain }) => { + const launches: Array<{ player: string; url: string; title: string }> = []; + ( + globalThis as typeof globalThis & { + __playbackRecommendationLaunches?: typeof launches; + } + ).__playbackRecommendationLaunches = launches; + ipcMain.removeHandler('OPEN_MPV_PLAYER'); + ipcMain.handle('OPEN_MPV_PLAYER', async (_event, url, title) => { + launches.push({ player: 'mpv', url, title }); + const now = new Date().toISOString(); + return { + canClose: false, + id: 'e2e-recommended-mpv', + player: 'mpv', + startedAt: now, + status: 'opened', + streamUrl: url, + thumbnail: null, + title, + updatedAt: now, + }; + }); +}); +``` + +Select `Unsupported MKV`, assert both existing MPV/VLC test IDs are visible, +click MPV, and poll the main-process capture for exactly one launch with the +fixture URL and channel title. This verifies the existing +`PlaybackFallbackRequest` path remains intact. + +- [ ] **Step 4: Run both atomized DASH/Electron targets and commit** + +```bash +pnpm nx run web-e2e:e2e-ci--src/dash-clearkey.e2e.ts -- --project=chromium +pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts +``` + +Expected: ClearKey still plays, unsupported DRM shows no external actions, and +eligible Matroska failure launches the captured MPV request. + +```bash +git add apps/web-e2e/src/fixtures/playback/unsupported.mkv apps/web-e2e/src/dash-clearkey.e2e.ts apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +git commit -m "test(playback): verify recommendation capability guards" +``` + +## Task 10: Update Canonical Documentation And Release Note + +**Files:** + +- Modify: `docs/architecture/embedded-inline-playback.md` +- Modify: `docs/architecture/nx-workspace-boundaries.md` +- Modify: `docs/architecture/player-controls-contract.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` +- Create: `.changes/playback-recovery-recommendations.md` + +- [ ] **Step 1: Update the canonical architecture flow** + +In `embedded-inline-playback.md`, document this exact sequence: + +```text +engine public error + -> sanitized PlaybackDiagnostic (@iptvnator/playback/util) + -> recommendPlaybackRecovery(context) + -> ranked maximum-three action model + -> WebPlayerView session-local user action +``` + +Include the HLS/VHS, shared mpegts.js, Shaka/DASH, and native-media engine-family +matrix; the network/unknown fail-closed rule; ClearKey/KODIPROP external +suppression; temporary override lifecycle; and the no-history/no-auto-switch +boundary. + +- [ ] **Step 2: Update workspace and controls ownership docs** + +Add `playback-util` and `@iptvnator/playback/util` to +`nx-workspace-boundaries.md` with tags +`scope:shared/domain:playback/type:util`. State in +`player-controls-contract.md` that `PlayerController` remains a sibling and +does not own diagnostics or recovery recommendations. + +Update the Shared Player Controls/playback diagnostic sections in both +`AGENTS.md` and `CLAUDE.md` with the new path, policy ownership, content-session +key, and temporary-switch semantics. Keep overlapping process guidance in the +two root files synchronized. + +- [ ] **Step 3: Add the user-facing release note** + +Create: + +```markdown +--- +type: fix +area: playback +issues: [1159] +--- + +When playback fails, IPTVnator now ranks useful next steps from the reported +error, including another compatible built-in player, MPV/VLC, Retry, or another +source. Trying a built-in recommendation affects only the current item and +does not change the saved player setting. +``` + +The body is under 400 characters and describes the user outcome. + +- [ ] **Step 4: Validate docs and commit** + +```bash +pnpm exec prettier --check docs/architecture/embedded-inline-playback.md docs/architecture/nx-workspace-boundaries.md docs/architecture/player-controls-contract.md AGENTS.md CLAUDE.md .changes/playback-recovery-recommendations.md +pnpm run release:notes:validate +git diff --check +git add docs/architecture AGENTS.md CLAUDE.md .changes/playback-recovery-recommendations.md +git commit -m "docs(playback): document recovery recommendations" +``` + +## Task 11: Complete The Test-Impact Pass And Local Codex Review + +**Files:** + +- Verify: all changed files against `origin/master` +- Modify: only files required to fix review findings + +- [ ] **Step 1: Verify Nx discovery, boundaries, and focused projects** + +```bash +pnpm nx show project playback-util +pnpm nx show projects +pnpm nx sync:check +pnpm nx test playback-util +pnpm nx lint playback-util +pnpm nx test ui-playback +pnpm nx lint ui-playback +pnpm nx test playlist-m3u-feature-player +pnpm nx test portal-xtream-feature +pnpm nx test portal-stalker-feature +pnpm nx test portal-shared-ui +``` + +Expected: all commands pass and Nx reports the new project with all three tags. + +- [ ] **Step 2: Verify application builds and repository gates** + +```bash +pnpm nx build web +pnpm nx run electron-backend:build-e2e +pnpm run i18n:validate +pnpm run release:notes:validate +pnpm run skills:validate +git diff --check origin/master...HEAD +``` + +`skills:validate` is required because `AGENTS.md`/`CLAUDE.md` document literal +repository paths used by skills and agents. Expected: all commands pass. + +- [ ] **Step 3: Re-run the atomized playback E2E coverage** + +```bash +pnpm nx run web-e2e:e2e-ci--src/playback-recommendations.e2e.ts -- --project=chromium +pnpm nx run web-e2e:e2e-ci--src/dash-clearkey.e2e.ts -- --project=chromium +pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts +``` + +Expected: all three targets pass without real provider traffic. + +- [ ] **Step 4: Run the requested local Codex P1/P2 review** + +The shell-installed npm wrapper is currently missing its native binary; use the +bundled desktop binary directly: + +```bash +/Applications/Codex.app/Contents/Resources/codex review \ + --base origin/master \ + "Review this playback recovery PR. Report only actionable P1/P2 correctness, privacy, race, lifecycle, DRM-transfer, Nx-boundary, and regression issues. Verify temporary switching never mutates persisted settings and stale engine events cannot affect a new content session." +``` + +Expected: no actionable P1/P2 findings. For every finding, reproduce it with a +focused failing test, implement the smallest correction, rerun the affected +project/E2E target, and commit with a scoped `fix(playback): ...` message. Run +the same review once more after fixes. + +- [ ] **Step 5: Inspect the final branch without publishing it** + +```bash +git status --short --branch +git log --oneline origin/master..HEAD +git diff --stat origin/master...HEAD +git diff --check origin/master...HEAD +``` + +Expected: clean worktree, only intentional commits/files, and no whitespace +errors. Do not push or create the PR until the user explicitly authorizes those +GitHub mutations. diff --git a/docs/superpowers/specs/2026-08-03-playback-recommendations-design.md b/docs/superpowers/specs/2026-08-03-playback-recommendations-design.md new file mode 100644 index 000000000..71828452e --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-playback-recommendations-design.md @@ -0,0 +1,490 @@ +# Playback Recovery Recommendations + +## Context + +Issue #1159 started as a request for more accurate playback errors and useful +guidance when a stream fails in one player but may work in another. The browser +players now emit structured, fail-closed diagnostics for native media, +hls.js, Video.js/VHS, Shaka Player, and mpegts.js. Those boundaries preserve +public engine evidence without retaining provider messages, credentials, or +arbitrary error payloads. + +The remaining recovery policy is still embedded in the UI. Every +`PlaybackDiagnostic` carries an `externalFallbackRecommended` boolean, and +`WebPlayerViewComponent` converts that flag into a fixed MPV/VLC section while +always showing Retry and every available alternative-source row. This cannot +explain when a distinct built-in engine is the better next step, rank recovery +actions by evidence, or avoid recommending a target already tried during the +same playback session. + +This design introduces one player-neutral recommendation layer. It ranks a +small set of explicit user actions from structured diagnostic evidence and +runtime capabilities. It does not perform automatic failover. + +## Goals + +- Convert structured playback diagnostics into deterministic, evidence-based + recovery recommendations. +- Rank one primary recommendation and at most two secondary recommendations. +- Recommend a distinct built-in web engine only when it can plausibly change + the failing playback path. +- Keep MPV/VLC recommendations for source formats and browser restrictions + where an external player can receive the required playback data. +- Keep network, HTTP, and unknown failures fail-closed: prefer Retry or an + alternative source instead of guessing that another decoder will help. +- Let a user temporarily try a recommended built-in player for the current + content without changing the saved player setting. +- Remember attempted targets only for the current in-memory content session so + repeated failures produce a more useful next recommendation. +- Establish a pure playback-domain boundary that future structured native + diagnostics and target capabilities can extend. + +## Non-goals + +- Automatically switching players or sources. +- Changing the persisted player setting. +- Persistent diagnostic history, cross-session learning, telemetry, or + correlation. +- A retry scheduler, health monitor, or full failover orchestrator. +- Moving diagnostics or recommendation policy into `PlayerController`. +- Replacing the existing multi-source auto-failover behavior. +- Recommending Embedded MPV as an inline target before it emits an equivalent + structured diagnostic lifecycle. +- Detecting whether an external MPV/VLC process ultimately played the stream. +- AirPlay, Cast, Document Picture-in-Picture, or remote-device capabilities. +- Redesigning the diagnostic overlay or the player controls. + +## Approaches Considered + +### Pure ranked policy layer (selected) + +Create a pure playback-domain function that receives structured evidence, +source context, target capabilities, and session-local attempts, then returns +an ordered list of typed recommendations. `WebPlayerViewComponent` remains the +owner of UI state and user-triggered switching. + +This keeps classification, policy, and rendering independently testable. It +also makes future evidence sources additive without coupling playback engines +to Angular or turning the controls contract into an error orchestrator. + +### Extend diagnostic booleans + +Add fields such as `retryRecommended`, `builtInFallbackRecommended`, and +`alternativeSourceRecommended` to `PlaybackDiagnostic` and let the component +choose the buttons. This looks small but duplicates ranking logic, makes +diagnostic producers own runtime/UI policy, and cannot cleanly account for +attempted targets. It is rejected. + +### Full failover orchestrator + +Introduce a state machine that launches players, observes outcomes, mutates +preferences, and manages retries. This could eventually support automatic +recovery, but it is materially larger and would blur the existing ownership of +source selection, settings, and engine lifecycle. It is rejected for v1. + +## Architecture And Ownership + +### New playback utility project + +Create `libs/playback/util` as the pure playback-domain boundary: + +- Nx project name: `playback-util`; +- path alias: `@iptvnator/playback/util`; +- tags: `scope:shared`, `domain:playback`, `type:util`; +- public exports through `libs/playback/util/src/index.ts` only. + +The project contains contracts, evidence normalization, diagnostic +classification, source/engine-family mapping, target-capability contracts, and +the recommendation policy. It has no Angular, DOM, storage, settings-store, or +Electron IPC dependency. As a `type:util` project it depends only on other +utility projects, including the existing shared interfaces required for +external-player names and resolved playback metadata. + +Move the pure diagnostic model and classifiers from +`libs/ui/playback/src/lib/playback-diagnostics/` into this project. Move only +the pure Shaka evidence/classifier helpers from the Shaka area; the Shaka +engine and video session remain in `ui-playback`. Engine components import the +new alias instead of reaching back into UI-owned diagnostic paths. + +For one compatibility window, `@iptvnator/ui/playback` re-exports the public +diagnostic contracts from `@iptvnator/playback/util`. Existing consumers can +therefore migrate without deep imports or a flag day. New code imports the +new alias directly, and the compatibility export can be removed in a later +cleanup PR after all consumers have migrated. + +### UI owner + +`WebPlayerViewComponent` owns: + +- the current content-session key; +- the temporary built-in player override; +- the set of targets attempted in the current session; +- the most recent VOD position used for a best-effort engine handoff; +- the binding generation that rejects stale engine events; +- mapping ranked recommendations to translated, accessible UI; +- existing external-player and alternative-source outputs. + +UI-specific translation keys, formatting helpers, Material components, styles, +and diagnostic action cards remain in `libs/ui/playback`. + +### Controls contract + +`PlayerController` remains the engine-neutral state, command, and capability +contract used by shared controls. Diagnostics and recovery policy stay as a +sibling layer. No recommendation state or commands are added to +`PlayerController`. + +## Recommendation Contracts + +The utility layer exposes a pure +`recommendPlaybackRecovery(context)` function and a discriminated +recommendation union: + +```ts +type PlaybackRecommendation = + | { + readonly action: 'retry'; + readonly reason: PlaybackRecommendationReason; + readonly priority: 'primary' | 'secondary'; + } + | { + readonly action: 'alternative-source'; + readonly reason: PlaybackRecommendationReason; + readonly priority: 'primary' | 'secondary'; + } + | { + readonly action: 'player'; + readonly target: PlaybackRecommendationTarget; + readonly reason: PlaybackRecommendationReason; + readonly priority: 'primary' | 'secondary'; + }; +``` + +`PlaybackRecommendationTarget` is the union of the three built-in diagnostic +players (`videojs`, `html5`, `artplayer`) and the managed external targets +(`mpv`, `vlc`). Embedded MPV is deliberately absent in v1. + +Reasons are stable app-owned values, not user-visible prose: + +- `retry-transient-failure`; +- `retry-unknown-failure`; +- `alternative-source-available`; +- `different-engine-family`; +- `external-codec-or-container-support`; +- `external-browser-access`; +- `compatible-drm-path`. + +Angular maps those values to translations. The model has no numeric confidence +score: the evidence matrix and list order are the contract. + +The policy input contains: + +- the sanitized `PlaybackDiagnostic`; +- the active target; +- the session-local attempted-target set; +- available target capabilities and their engine family for this source; +- source kind, live/VOD state, and DRM/external-transfer context; +- the count of alternative sources. + +The policy output is deterministic, contains at most three entries, and has at +most one primary entry. If the list is non-empty, its first entry is primary +and every later entry is secondary. Current, unavailable, incompatible, and +already attempted targets are excluded before ranking. + +`PlaybackDiagnostic.externalFallbackRecommended` is removed. Diagnostic +producers report evidence and classification only; the recommendation policy +decides which actions are safe and useful in the current runtime. + +Copy URL and Technical details are utilities rather than recommendations and +are therefore not part of this union. + +## Capability And Source Context + +The caller supplies explicit capability facts instead of asking the policy to +inspect settings, the DOM, or Electron globals. Each player target records +whether it is available for the current runtime/source and its effective +engine family. + +The source context records whether the playback payload can be transferred to +an external player. ClearKey/KODIPROP playback is always non-transferable in +v1: MPV and VLC are excluded because the current external-player request does +not carry an equivalent DRM contract. Header-bearing portal playback is +transferable only when its existing host-specific external launch path forwards +the required resolved playback fields; otherwise the host capability marks it +non-transferable. The policy never infers transferability from an error message +or URL substring. + +## Engine-Family Matrix + +Recommendations change engines, not merely skins: + +| Source path | Built-in engine families | v1 built-in alternative | +| -------------------------------- | ----------------------------------- | ----------------------- | +| HLS in Video.js | Video.js/VHS | HTML5 using hls.js | +| HLS in HTML5 or ArtPlayer | hls.js | Video.js/VHS | +| MPEG-TS in any web player | mpegts.js | None | +| DASH in HTML5 or ArtPlayer | Shaka Player | None | +| DASH in Video.js | not a supported recommendation path | None | +| Native MP4/MKV in any web player | browser media element | None | + +Only one target represents a distinct engine family in the ranked list. For a +Video.js HLS failure, HTML5 is the canonical hls.js target; ArtPlayer is not a +second independent engine recommendation. If that target is unavailable or +already attempted, the policy proceeds to an external target rather than +presenting a duplicate engine-family guess. + +MPEG-TS does not recommend another built-in player because all three use the +same mpegts.js engine. DASH does not recommend Video.js in v1 because it is not +an equivalent supported Shaka path. Native media does not recommend another +web player because all three ultimately depend on the same browser decoder. + +## Policy Matrix + +The policy builds the following exact candidate order, then filters unavailable, +current, incompatible, and attempted targets and truncates the result to three +entries. “Alternative source” is omitted when its count is zero. + +| Evidence | Ordered candidates | Forbidden guess | +| ----------------------------------------- | --------------------------------------------------------- | -------------------------------- | +| HTTP, timeout, or generic network failure | Retry → Alternative source | Any player change | +| Unknown playback error | Retry → Alternative source | Any player change | +| Browser access/CORS/CSP-class evidence | MPV → VLC → Alternative source | Another browser player | +| Unsupported codec or container | MPV → VLC → Alternative source | Another browser player | +| Media/decode/engine processing failure | Distinct built-in family → MPV → VLC → Alternative source | Same engine family | +| DRM/encryption failure | Compatible built-in path → Alternative source → MPV → VLC | Non-transferable external target | + +Network and unknown cases never claim that another decoder is likely to fix +the failure. For DRM, every candidate still passes explicit target and payload +capability checks; ClearKey/KODIPROP therefore never reaches MPV or VLC. + +For external actions, MPV precedes VLC to preserve the existing primary +fallback. If MPV is unavailable or attempted, VLC may become primary. When no +ranked recommendation survives, the overlay still exposes Copy URL and +Technical details instead of fabricating a guess. + +## Playback Session Lifecycle + +### Stable content identity + +Every `WebPlayerViewComponent` host supplies a required +`playbackSessionKey`. The key identifies canonical logical content, not the +current URL or selected provider copy: + +- an M3U live channel key uses playlist/source identity plus channel identity; +- Xtream and Stalker live keys use provider/account plus content identity; +- movie keys use the owning route/catalog's original source plus movie + identity; +- episode keys use the owning series route's original source and series plus + season and episode identity. + +An alternative source's `playback.contentInfo` is provider-scoped playback and +resume metadata, not recovery-session identity. Source-owning route and series +hosts derive the key before passing it through the inline player, so replacing +that playback payload cannot replace the recovery session. + +Retry and alternative sources for the same channel, movie, or episode keep the +same key. Selecting a different channel, movie, or episode changes it. A key +change synchronously clears the attempted-target set, temporary override, +handoff position, and visible diagnostic, then advances the binding generation +so every callback from the previous content becomes stale. + +The state is component-local, so a same-content source change must retain the +`WebPlayerViewComponent` instance and update its inputs. Destroying that +component ends the recovery session even if a later instance receives the same +key. Host tests enforce retained identity for supported multi-source flows. + +### Failure and reranking + +When the active web engine emits a terminal diagnostic, the component: + +1. verifies that the event belongs to the current binding generation and + active target; +2. records the current target as attempted; +3. stores the diagnostic; +4. emits the existing `playbackFailed` output for source-owner behavior; +5. runs the pure recommendation policy with current capabilities and attempts. + +An event from a destroyed or replaced engine is ignored and cannot overwrite +the new session's UI. + +### Trying a built-in target + +When the user selects a built-in recommendation, the component records that +target as attempted, captures the latest VOD position, clears the diagnostic, +and applies a local override that outranks the host override and saved setting +for this content session. The player host is recreated for the chosen target. +No storage or settings-store mutation occurs. + +For VOD and episodes, the new engine receives the latest finite playback +position as a best-effort start time. Live playback restarts at the live edge. +If the target fails, the new diagnostic is reranked with both attempted +targets excluded. If the target proves unavailable before attachment, the +component keeps it attempted and immediately reranks without throwing. + +Retry keeps the same content session and attempts, clears the current +diagnostic, and reloads the active target. An alternative-source request also +keeps the same content session and attempts; the source-owning host changes the +resolved playback URL without resetting the recommendation history. + +### External targets + +MPV/VLC actions continue to emit the existing `PlaybackFallbackRequest` with +the full `ResolvedPortalPlayback` and diagnostic. The selected external target +is recorded as attempted before emission so returning to the overlay can +promote the next useful action. v1 does not infer launch or playback success +and does not persist the result. + +## User Interface + +Keep the existing diagnostic overlay and its badge, headline, description, +HTTP/container/codec metadata, codec hint, and `role="status"`. This feature +changes the action hierarchy, not the overall visual language. + +The overlay renders: + +1. one prominent primary recommendation card; +2. at most two compact secondary recommendation cards; +3. the always-available Copy URL and Technical details utilities. + +An alternative-source recommendation renders the existing +`VodSourceRow`-based block and occupies one recommendation slot regardless of +the number of visible source rows. Its existing bounded row count and “more +sources” affordance remain. + +Retry moves out of the unconditional utility row and appears only when the +policy ranks it. Preserve `playback-retry`, `playback-fallback-mpv`, and +`playback-fallback-vlc`. Built-in actions use +`playback-recommendation-videojs`, `playback-recommendation-html5`, and +`playback-recommendation-artplayer`. Their copy explicitly says that the +change is temporary and does not alter the saved setting. + +All actions are native buttons with visible keyboard focus. No autofocus or +focus trap is added to the status overlay. A pending switch disables repeated +activation until the current operation settles. At narrow widths cards stack +vertically, content uses `min-width: 0`, and long translated copy wraps without +forcing horizontal overflow. Existing light/dark application tokens remain +the styling source. + +## Error Handling And Safety + +- The recommendation function is total and does not throw for missing, + unknown, or future diagnostic evidence. +- Incomplete or contradictory context fails closed to Retry and an available + alternative source; it never upgrades uncertainty into a player claim. +- Unknown engine families cannot produce built-in recommendations. +- Unavailable or non-transferable targets are filtered before rendering. +- A binding generation plus exact target identity rejects stale diagnostic, + async header, and delayed player events after a switch. +- Switching is single-flight from the UI perspective; double clicks cannot + mount two targets or corrupt the attempt set. +- Session state remains memory-only and contains stable target IDs and a + numeric playback position, never raw error payloads or credentials. +- Existing structured-evidence redaction guarantees remain unchanged after + moving the pure files into `playback-util`. + +## Testing + +Use test-driven development. + +### Pure policy and boundary tests + +- Add exhaustive table-driven tests in `playback-util` for every policy row, + source engine family, priority transition, availability combination, attempt + exclusion, DRM transfer constraint, and three-result limit. +- Prove network and unknown diagnostics never recommend a player. +- Prove MPEG-TS, native media, and Shaka/DASH never offer a same-engine browser + alternative. +- Prove HLS offers only one distinct built-in engine family. +- Prove ClearKey/KODIPROP excludes MPV/VLC. +- Preserve and migrate all diagnostic/evidence contract tests, including + package-version locks and redaction assertions. +- Add a module-boundary assertion that the new utility project has no Angular, + DOM, Electron, storage, or UI dependency. + +### Component and host tests + +Extend `WebPlayerViewComponent` tests to cover: + +- deterministic primary/secondary rendering and stable test IDs; +- a temporary built-in switch without settings mutation; +- attempted-target exclusion and reranking after another failure; +- Retry and alternative-source preservation of session state; +- complete reset on `playbackSessionKey` change; +- VOD position handoff and live-edge behavior; +- stale generation/target event rejection; +- single-flight action handling and unavailable-target reranking; +- DRM and runtime-capability filtering; +- keyboard focus, disabled state, and narrow-layout structure. + +Update the closest M3U, Xtream, Stalker, unified live, and portal inline-player +specs to prove that each host supplies a stable content key and preserves it +while switching sources for the same content. + +### E2E coverage + +- Add a deterministic web Playwright case backed by repository-owned media + fixtures: trigger an engine-specific HLS failure, assert the distinct + built-in recommendation, activate it, verify the new player host mounts, and + verify the persisted player setting remains unchanged. +- Extend the existing web and Electron DASH/ClearKey E2E cases to prove that + non-transferable DRM never exposes MPV/VLC recommendations. +- Preserve or add an Electron case where an eligible browser/container failure + still exposes the existing managed MPV/VLC actions and emits the expected + external fallback request. + +Do not add a production-only diagnostic injection hook for E2E. If a browser +cannot deterministically expose the chosen HLS engine event from a bounded +fixture, the implementation plan must select another public, deterministic +engine event and retain component-level coverage of the exact policy branch. + +### Validation ladder + +Run at minimum: + +- `pnpm nx test playback-util`; +- `pnpm nx lint playback-util`; +- `pnpm nx test ui-playback`; +- `pnpm nx lint ui-playback`; +- focused tests for every host whose session-key binding changes; +- the targeted web and Electron E2E atomized targets discovered from Nx; +- affected application typecheck/build targets; +- `pnpm run i18n:validate`; +- `pnpm run release:notes:validate`; +- Nx module-boundary and project-discovery checks. + +The implementation plan must use the exact target names reported by the fresh +workspace rather than inventing commands. + +## Documentation And Release Note + +Update: + +- `docs/architecture/embedded-inline-playback.md` with the canonical + diagnostic-to-recommendation flow and engine-family matrix; +- `docs/architecture/nx-workspace-boundaries.md` with the new + `playback-util` project and alias; +- `AGENTS.md` and `CLAUDE.md` with the new shared playback recommendation + ownership and session behavior. + +Update `docs/architecture/player-controls-contract.md` with a short boundary +clarification that recovery recommendations remain outside the controls +contract; the controls API itself does not change. + +Add a `fix(playback)` note under `.changes/` because users gain new recovery +actions and temporary built-in player switching. The note describes the user +outcome, not the internal policy extraction. + +## Future Extensions + +The boundary is intentionally extensible where additional evidence changes a +decision: + +- structured Embedded MPV/native-view diagnostics and capabilities; +- explicit external-launch failure results; +- richer per-target codec, container, DRM, and header-transfer capabilities; +- session-local outcome adaptation when a target starts successfully; +- source health facts supplied by an existing source owner. + +Persistent history, telemetry-driven ranking, and automatic mutation of player +settings remain low-value or high-risk until a concrete user problem justifies +them. diff --git a/libs/playback/util/project.json b/libs/playback/util/project.json new file mode 100644 index 000000000..7cea6e54e --- /dev/null +++ b/libs/playback/util/project.json @@ -0,0 +1,28 @@ +{ + "name": "playback-util", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/playback/util/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:shared", "domain:playback", "type:util"], + "targets": { + "test": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "command": [ + "node", + "./tools/testing/run-web-esm-lib-tests.mjs", + "libs/playback/util/src" + ], + "env": { + "NODE_OPTIONS": "--experimental-vm-modules" + }, + "forwardAllArgs": true + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/playback/util/src/index.ts b/libs/playback/util/src/index.ts new file mode 100644 index 000000000..14e312989 --- /dev/null +++ b/libs/playback/util/src/index.ts @@ -0,0 +1,9 @@ +export * from './lib/diagnostics/playback-diagnostics.util'; +export * from './lib/diagnostics/shaka-error-classifier'; +export * from './lib/diagnostics/shaka-error-contract'; +export * from './lib/diagnostics/shaka-error-lifecycle'; +export type * from './lib/diagnostics/shaka-error.types'; +export * from './lib/playback-recommendation.model'; +export * from './lib/playback-recommendation-policy'; +export * from './lib/playback-session-key'; +export * from './lib/playback-target-capabilities'; diff --git a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts similarity index 99% rename from libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts index b53899c1f..4ac813d5b 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.spec.ts @@ -154,7 +154,7 @@ describe('HLS playback evidence', () => { url: `https://provider.example/context?token=${secret}`, responseType: 'text', headers: { Authorization: `Bearer ${secret}` }, - } as ErrorData['context'], + } as unknown as ErrorData['context'], networkDetails: { responseURL: `https://provider.example/xhr?token=${secret}`, responseText: secret, diff --git a/libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/hls-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/hls-playback-evidence.util.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.model.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.model.ts rename to libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.model.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts similarity index 68% rename from libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts index 583f4cd34..4de6b5da2 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.spec.ts @@ -29,8 +29,7 @@ describe('mpegts.js playback evidence', () => { OTHER_ERROR: MpegTsPlaybackEngineType.Other, }); expect(mpegts.ErrorDetails).toEqual({ - NETWORK_EXCEPTION: - MpegTsPlaybackEngineDetails.NetworkException, + NETWORK_EXCEPTION: MpegTsPlaybackEngineDetails.NetworkException, NETWORK_STATUS_CODE_INVALID: MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid, NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout, @@ -46,47 +45,25 @@ describe('mpegts.js playback evidence', () => { }); it.each([ - [ - 'NetworkError', - 'HttpStatusCodeInvalid', - 'loader', - 'http', - ], + ['NetworkError', 'HttpStatusCodeInvalid', 'loader', 'http'], ['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'], ['NetworkError', 'Exception', 'loader', 'network'], - [ - 'NetworkError', - 'UnrecoverableEarlyEof', - 'loader', - 'truncated-stream', - ], + ['NetworkError', 'UnrecoverableEarlyEof', 'loader', 'truncated-stream'], ['MediaError', 'FormatError', 'demux', 'format'], ['MediaError', 'FormatUnsupported', 'demux', 'format'], ['MediaError', 'CodecUnsupported', 'demux', 'codec'], - [ - 'MediaError', - 'MediaMSEError', - 'media-source', - 'media-source', - ], - ])( - 'maps %s + %s to %s/%s', - (engineType, engineDetails, stage, failure) => { - expect( - createMpegTsPlaybackEvidence( - engineType, - engineDetails, - undefined - ) - ).toEqual({ - engineType, - engineDetails, - disposition: 'terminal', - stage, - failure, - }); - } - ); + ['MediaError', 'MediaMSEError', 'media-source', 'media-source'], + ])('maps %s + %s to %s/%s', (engineType, engineDetails, stage, failure) => { + expect( + createMpegTsPlaybackEvidence(engineType, engineDetails, undefined) + ).toEqual({ + engineType, + engineDetails, + disposition: 'terminal', + stage, + failure, + }); + }); it.each([ ['OtherError', 'CodecUnsupported'], @@ -96,15 +73,18 @@ describe('mpegts.js playback evidence', () => { ['NetworkError', 'httpstatuscodeinvalid'], [{ type: 'NetworkError' }, 'Exception'], ['MediaError', { details: 'FormatError' }], - ])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => { - const evidence = createMpegTsPlaybackEvidence(type, details, { - code: 503, - }); + ])( + 'keeps inconsistent or malformed %p + %p evidence unknown', + (type, details) => { + const evidence = createMpegTsPlaybackEvidence(type, details, { + code: 503, + }); - expect(evidence.stage).toBe('unknown'); - expect(evidence.failure).toBe('unknown'); - expect(evidence.httpStatus).toBeUndefined(); - }); + expect(evidence.stage).toBe('unknown'); + expect(evidence.failure).toBe('unknown'); + expect(evidence.httpStatus).toBeUndefined(); + } + ); it.each([399, 600, 404.5, '404', null, undefined])( 'rejects invalid HTTP status %p', @@ -163,45 +143,24 @@ describe('mpegts.js playback evidence', () => { }); it.each([ - ['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false], - ['NetworkError', 'ConnectingTimeout', 'network-error', false], - ['NetworkError', 'Exception', 'network-error', false], - [ - 'NetworkError', - 'UnrecoverableEarlyEof', - 'media-decode-error', - true, - ], - ['MediaError', 'FormatError', 'media-decode-error', true], - [ - 'MediaError', - 'FormatUnsupported', - 'unsupported-container', - true, - ], - [ - 'MediaError', - 'CodecUnsupported', - 'unsupported-codec', - true, - ], - ['MediaError', 'MediaMSEError', 'media-decode-error', true], - ['OtherError', 'Exception', 'unknown-playback-error', false], - ])( - 'classifies %s + %s as %s with fallback=%s', - (type, details, code, externalFallbackRecommended) => { - const issue = classifyMpegTsPlaybackIssue( - createMpegTsPlaybackEvidence(type, details, undefined), - METADATA - ); + ['NetworkError', 'HttpStatusCodeInvalid', 'network-error'], + ['NetworkError', 'ConnectingTimeout', 'network-error'], + ['NetworkError', 'Exception', 'network-error'], + ['NetworkError', 'UnrecoverableEarlyEof', 'media-decode-error'], + ['MediaError', 'FormatError', 'media-decode-error'], + ['MediaError', 'FormatUnsupported', 'unsupported-container'], + ['MediaError', 'CodecUnsupported', 'unsupported-codec'], + ['MediaError', 'MediaMSEError', 'media-decode-error'], + ['OtherError', 'Exception', 'unknown-playback-error'], + ])('classifies %s + %s as %s', (type, details, code) => { + const issue = classifyMpegTsPlaybackIssue( + createMpegTsPlaybackEvidence(type, details, undefined), + METADATA + ); - expect(issue.code).toBe(code); - expect(issue.externalFallbackRecommended).toBe( - externalFallbackRecommended - ); - expect(issue.details).toBeUndefined(); - } - ); + expect(issue.code).toBe(code); + expect(issue.details).toBeUndefined(); + }); it('preserves an exact HTTP failure without retaining provider details', () => { const secret = 'mpegts-http-secret'; @@ -230,7 +189,6 @@ describe('mpegts.js playback evidence', () => { failure: 'http', httpStatus: 404, }), - externalFallbackRecommended: false, }) ); expect(issue.details).toBeUndefined(); diff --git a/libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/mpegts-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/mpegts-playback-evidence.util.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts similarity index 96% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts rename to libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts index 31c3a94df..413e89105 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.model.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.model.ts @@ -32,6 +32,13 @@ export const PlaybackDiagnosticSource = { export type PlaybackDiagnosticSource = (typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource]; +export const PlaybackRuntimeSupport = { + ShakaBrowserUnsupported: 'shaka-browser-unsupported', +} as const; + +export type PlaybackRuntimeSupport = + (typeof PlaybackRuntimeSupport)[keyof typeof PlaybackRuntimeSupport]; + export const InlinePlaybackPlayer = { VideoJs: 'videojs', Html5: 'html5', @@ -255,6 +262,7 @@ export interface PlaybackDiagnostic { readonly player?: InlinePlaybackPlayer; readonly audioCodecs: readonly string[]; readonly videoCodecs: readonly string[]; + readonly runtimeSupport?: PlaybackRuntimeSupport; readonly details?: string; readonly nativeErrorCode?: number; readonly nativeErrorMessage?: string; @@ -264,7 +272,6 @@ export interface PlaybackDiagnostic { readonly hls?: HlsPlaybackEvidence; readonly mpegTs?: MpegTsPlaybackEvidence; readonly shaka?: ShakaPlaybackEvidence; - readonly externalFallbackRecommended: boolean; } export interface PlaybackFallbackRequest { diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts similarity index 95% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts index bda83e268..060ee97e9 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.spec.ts @@ -10,6 +10,7 @@ import { classifyHlsPlaybackIssue, classifyMpegTsPlaybackIssue, classifyNativePlaybackIssue, + classifyUnsupportedHlsManifestCodecs, createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, getLikelyBrowserUnsupportedCodecLabels, @@ -38,6 +39,38 @@ type VhsClassifier = ( ) => PlaybackDiagnostic; describe('playback diagnostics', () => { + it('classifies manifest codecs only when the explicit support probe rejects them', () => { + const metadata = createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'html5', + audioCodecs: ['ac-3'], + videoCodecs: ['avc1.64001f'], + }); + + const issue = classifyUnsupportedHlsManifestCodecs( + metadata, + () => false + ); + + expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); + }); + + it.each([true, undefined])( + 'does not classify manifest codecs when the support probe returns %s', + (supportResult) => { + const issue = classifyUnsupportedHlsManifestCodecs( + createPlaybackSourceMetadata({ + url: 'https://example.com/live/index.m3u8', + player: 'html5', + videoCodecs: ['avc1.64001f'], + }), + () => supportResult + ); + + expect(issue).toBeNull(); + } + ); + it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => { const issue = classifyStructuredHlsPlaybackIssue( { @@ -57,7 +90,6 @@ describe('playback diagnostics', () => { ); expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); - expect(issue?.externalFallbackRecommended).toBe(true); expect(issue?.audioCodecs).toEqual(['ac-3']); expect(issue?.videoCodecs).toEqual(['avc1.64001f']); }); @@ -78,7 +110,6 @@ describe('playback diagnostics', () => { ); expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); - expect(issue?.externalFallbackRecommended).toBe(true); }); it('classifies native decode and unsupported source errors without using network wording', () => { @@ -100,11 +131,9 @@ describe('playback diagnostics', () => { ); expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); - expect(decodeIssue.externalFallbackRecommended).toBe(true); expect(unsupportedSourceIssue.code).toBe( PlaybackDiagnosticCode.UnsupportedContainer ); - expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true); }); it('classifies MIME-only unsupported containers as container diagnostics', () => { @@ -133,7 +162,6 @@ describe('playback diagnostics', () => { expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); expect(issue.container).toBe('mp2t'); - expect(issue.externalFallbackRecommended).toBe(false); }); it('classifies native HTTP source failures from a safe status and error type', () => { @@ -153,7 +181,6 @@ describe('playback diagnostics', () => { expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); expect(issue.httpStatus).toBe(404); expect(issue.nativeErrorType).toBe('networkrequestfailed'); - expect(issue.externalFallbackRecommended).toBe(false); }); it('classifies exact VHS network evidence without using provider text', () => { @@ -177,7 +204,6 @@ describe('playback diagnostics', () => { source: 'vhs', httpStatus: undefined, nativeErrorMessage: undefined, - externalFallbackRecommended: false, vhs: { engineType: 'networkrequestfailed', mediaErrorCode: 4, @@ -205,7 +231,6 @@ describe('playback diagnostics', () => { expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); expect(issue.source).toBe('vhs'); - expect(issue.externalFallbackRecommended).toBe(true); expect(issue.nativeErrorMessage).toBeUndefined(); expect(issue.vhs).toEqual({ engineType: 'streamingfailedtodecryptsegment', @@ -247,7 +272,6 @@ describe('playback diagnostics', () => { ); expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); - expect(vhsIssue.externalFallbackRecommended).toBe(false); expect(vhsIssue.nativeErrorMessage).toBeUndefined(); expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); }); @@ -310,7 +334,6 @@ describe('playback diagnostics', () => { ); expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); - expect(issue.externalFallbackRecommended).toBe(false); expect(issue.httpStatus).toBeUndefined(); }); @@ -331,7 +354,6 @@ describe('playback diagnostics', () => { expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); expect(issue.httpStatus).toBeUndefined(); expect(issue.nativeErrorType).toBeUndefined(); - expect(issue.externalFallbackRecommended).toBe(false); }); it.each([ @@ -400,7 +422,6 @@ describe('playback diagnostics', () => { ); expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError); - expect(issue?.externalFallbackRecommended).toBe(false); }); it('retains structured HLS HTTP and stage evidence', () => { @@ -431,7 +452,6 @@ describe('playback diagnostics', () => { failure: 'http', httpStatus: 404, }), - externalFallbackRecommended: false, }) ); }); @@ -471,7 +491,6 @@ describe('playback diagnostics', () => { expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError); expect(issue?.httpStatus).toBeUndefined(); - expect(issue?.externalFallbackRecommended).toBe(false); }); it('classifies exact HLS decrypt evidence as DRM or encryption', () => { @@ -490,7 +509,6 @@ describe('playback diagnostics', () => { ); expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); - expect(issue?.externalFallbackRecommended).toBe(true); }); it('classifies exact HLS key load failures as network errors', () => { @@ -509,7 +527,6 @@ describe('playback diagnostics', () => { ); expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError); - expect(issue?.externalFallbackRecommended).toBe(false); }); it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])( @@ -549,7 +566,6 @@ describe('playback diagnostics', () => { ); expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError); - expect(issue?.externalFallbackRecommended).toBe(false); }); it('classifies native CORS failures as browser access errors', () => { @@ -566,7 +582,6 @@ describe('playback diagnostics', () => { ); expect(issue.code).toBe('browser-access-error'); - expect(issue.externalFallbackRecommended).toBe(true); }); it('keeps opaque native network failures generic when the browser hides access details', () => { @@ -582,7 +597,6 @@ describe('playback diagnostics', () => { ); expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); - expect(issue.externalFallbackRecommended).toBe(false); }); it('does not guess mpegts browser access from exception messages', () => { @@ -599,7 +613,6 @@ describe('playback diagnostics', () => { ); expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError); - expect(issue.externalFallbackRecommended).toBe(false); expect(JSON.stringify(issue)).not.toContain(secret); }); @@ -618,7 +631,6 @@ describe('playback diagnostics', () => { ); expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError); - expect(issue.externalFallbackRecommended).toBe(true); expect(issue.mpegTs?.failure).toBe('truncated-stream'); expect(issue.details).toBeUndefined(); }); @@ -638,7 +650,6 @@ describe('playback diagnostics', () => { ); expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec); - expect(issue.externalFallbackRecommended).toBe(true); }); it('prefers stream extension query metadata over web script path extensions', () => { diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts similarity index 89% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts rename to libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts index c6c61aa53..99d272b11 100644 --- a/libs/ui/playback/src/lib/playback-diagnostics/playback-diagnostics.util.ts +++ b/libs/playback/util/src/lib/diagnostics/playback-diagnostics.util.ts @@ -5,6 +5,7 @@ import type { PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, + PlaybackRuntimeSupport, PlaybackSourceMetadata, ShakaPlaybackEvidence, VhsPlaybackEngineType as VhsPlaybackEngineTypeValue, @@ -50,17 +51,13 @@ const VHS_NETWORK_ERROR_TYPES: ReadonlySet = ]); export function classifyNativePlaybackIssue( - error: NativePlaybackErrorInput | MediaError | null | undefined, + error: NativePlaybackErrorInput | null | undefined, metadata: PlaybackSourceMetadata ): PlaybackDiagnostic { const nativeErrorCode = error?.code; const nativeErrorMessage = error?.message || undefined; - const nativeErrorInput = error as - NativePlaybackErrorInput | null | undefined; - const httpStatus = getNativeHttpStatus(nativeErrorInput?.status); - const nativeErrorType = getNativeErrorType( - nativeErrorInput?.metadata?.errorType - ); + const httpStatus = getNativeHttpStatus(error?.status); + const nativeErrorType = getNativeErrorType(error?.metadata?.errorType); const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? ''; if (httpStatus !== undefined) { @@ -175,17 +172,20 @@ export function classifyMpegTsPlaybackIssue( }); } +export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined; + export function classifyUnsupportedHlsManifestCodecs( - metadata: PlaybackSourceMetadata + metadata: PlaybackSourceMetadata, + isTypeSupported: MediaTypeSupportProbe ): PlaybackDiagnostic | null { - if (!hasCodecs(metadata) || typeof MediaSource === 'undefined') { + if (!hasCodecs(metadata)) { return null; } const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs]; const mimeType = `video/mp4; codecs="${codecList.join(',')}"`; - if (MediaSource.isTypeSupported(mimeType)) { + if (isTypeSupported(mimeType) !== false) { return null; } @@ -201,6 +201,7 @@ export function createPlaybackDiagnostic(options: { readonly code: PlaybackDiagnosticCode; readonly source: PlaybackDiagnosticSource; readonly metadata: PlaybackSourceMetadata; + readonly runtimeSupport?: PlaybackRuntimeSupport; readonly details?: string; readonly nativeErrorCode?: number; readonly nativeErrorMessage?: string; @@ -210,14 +211,12 @@ export function createPlaybackDiagnostic(options: { readonly hls?: HlsPlaybackEvidence; readonly mpegTs?: MpegTsPlaybackEvidence; readonly shaka?: ShakaPlaybackEvidence; - /** Overrides the code-derived recommendation, e.g. when external players - * are known to be unable to handle the stream either. */ - readonly externalFallbackRecommended?: boolean; }): PlaybackDiagnostic { const { code, source, metadata, + runtimeSupport, details, nativeErrorCode, nativeErrorMessage, @@ -238,6 +237,7 @@ export function createPlaybackDiagnostic(options: { player: metadata.player, audioCodecs: metadata.audioCodecs, videoCodecs: metadata.videoCodecs, + runtimeSupport, details: details || undefined, nativeErrorCode, nativeErrorMessage, @@ -247,9 +247,6 @@ export function createPlaybackDiagnostic(options: { hls, mpegTs, shaka, - externalFallbackRecommended: - options.externalFallbackRecommended ?? - isExternalFallbackRecommended(code), }; } @@ -323,16 +320,6 @@ function getNativeErrorType(errorType: unknown): string | undefined { : undefined; } -function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean { - return ( - code === DiagnosticCode.UnsupportedContainer || - code === DiagnosticCode.UnsupportedCodec || - code === DiagnosticCode.MediaDecodeError || - code === DiagnosticCode.BrowserAccessError || - code === DiagnosticCode.DrmOrEncryption - ); -} - function hasCodecs(metadata: PlaybackSourceMetadata): boolean { return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0; } diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts b/libs/playback/util/src/lib/diagnostics/playback-error-patterns.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-error-patterns.util.ts rename to libs/playback/util/src/lib/diagnostics/playback-error-patterns.util.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts b/libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/playback-media-source.util.ts rename to libs/playback/util/src/lib/diagnostics/playback-media-source.util.ts diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts similarity index 95% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts index 529dbc9d4..394b48144 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.spec.ts @@ -2,8 +2,8 @@ import { InlinePlaybackPlayer, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.model'; -import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util'; +} from './playback-diagnostics.model'; +import { createPlaybackSourceMetadata } from './playback-diagnostics.util'; import { classifyShakaPlaybackIssue, createUnsupportedDrmDiagnostic, @@ -216,8 +216,5 @@ describe('createUnsupportedDrmDiagnostic', () => { expect(issue.details).toBe('Unsupported DRM license configuration'); expect(JSON.stringify(issue)).not.toContain(secret); expect(JSON.stringify(issue)).not.toContain('provider.example'); - // MPV/VLC cannot receive KODIPROP license config, so the diagnostic - // must not offer them as a fallback. - expect(issue.externalFallbackRecommended).toBe(false); }); }); diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts similarity index 81% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts index 9bc96d8ef..7f914497b 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-classifier.ts @@ -4,18 +4,19 @@ import type { PlaybackSourceMetadata, ShakaPlaybackDisposition, ShakaPlaybackEvidence, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from './playback-diagnostics.model'; import { PlaybackDiagnosticCode as DiagnosticCode, PlaybackDiagnosticSource as DiagnosticSource, ShakaPlaybackCategory, ShakaPlaybackDisposition as ShakaDisposition, ShakaPlaybackFailure, -} from '../playback-diagnostics/playback-diagnostics.model'; -import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; + PlaybackRuntimeSupport, +} from './playback-diagnostics.model'; +import { createPlaybackDiagnostic } from './playback-diagnostics.util'; import { SHAKA_ERROR_CODE } from './shaka-error-contract'; import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util'; -import type { ShakaErrorLike } from './shaka-module.types'; +import type { ShakaErrorLike } from './shaka-error.types'; export { SHAKA_DIAGNOSTIC_VERSION, @@ -88,9 +89,19 @@ export function createUnsupportedDrmDiagnostic( source: DiagnosticSource.Shaka, metadata, details: 'Unsupported DRM license configuration', - // External MPV/VLC cannot receive the KODIPROP license config either, - // so offering them as a fallback would just fail differently. - externalFallbackRecommended: false, + }); +} + +/** Exact app-owned evidence for Shaka's browser-support preflight. */ +export function createShakaBrowserUnsupportedDiagnostic( + metadata: PlaybackSourceMetadata +): PlaybackDiagnostic { + return createPlaybackDiagnostic({ + code: DiagnosticCode.UnknownPlaybackError, + source: DiagnosticSource.Shaka, + metadata, + runtimeSupport: PlaybackRuntimeSupport.ShakaBrowserUnsupported, + shaka: createShakaPlaybackEvidence(null, ShakaDisposition.Terminal), }); } diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts similarity index 100% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-contract.ts diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-lifecycle.ts similarity index 82% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-lifecycle.ts index b58c0a87c..c29dc807a 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-error-lifecycle.ts @@ -1,10 +1,10 @@ -import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model'; +import { ShakaPlaybackDisposition as ShakaDisposition } from './playback-diagnostics.model'; import { SHAKA_ERROR_CATEGORY, SHAKA_ERROR_CODE, SHAKA_ERROR_SEVERITY, } from './shaka-error-contract'; -import type { ShakaErrorLike } from './shaka-module.types'; +import type { ShakaErrorLike } from './shaka-error.types'; export function getShakaErrorEventDisposition( error: Partial | null diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts b/libs/playback/util/src/lib/diagnostics/shaka-error-mapping.ts similarity index 100% rename from libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts rename to libs/playback/util/src/lib/diagnostics/shaka-error-mapping.ts diff --git a/libs/playback/util/src/lib/diagnostics/shaka-error.types.ts b/libs/playback/util/src/lib/diagnostics/shaka-error.types.ts new file mode 100644 index 000000000..41ce89de0 --- /dev/null +++ b/libs/playback/util/src/lib/diagnostics/shaka-error.types.ts @@ -0,0 +1,6 @@ +export interface ShakaErrorLike { + readonly severity: number; + readonly category: number; + readonly code: number; + readonly data?: readonly unknown[]; +} diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts similarity index 100% rename from libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.spec.ts diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts similarity index 98% rename from libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts index 479788259..00ae4c116 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts +++ b/libs/playback/util/src/lib/diagnostics/shaka-playback-evidence.util.ts @@ -9,7 +9,7 @@ import { ShakaPlaybackSeverity, ShakaPlaybackStage, ShakaPlaybackUnknownCode, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from './playback-diagnostics.model'; import { SHAKA_ERROR_CATEGORY, SHAKA_ERROR_CODE, @@ -29,7 +29,7 @@ import { SHAKA_PUBLIC_CODES, SHAKA_SEGMENT_STAGE_CODES, } from './shaka-error-mapping'; -import type { ShakaErrorLike } from './shaka-module.types'; +import type { ShakaErrorLike } from './shaka-error.types'; export function createShakaPlaybackEvidence( error: Partial | null | undefined, diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts b/libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.spec.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.spec.ts rename to libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.spec.ts diff --git a/libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts b/libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.ts similarity index 100% rename from libs/ui/playback/src/lib/playback-diagnostics/vhs-playback-evidence.util.ts rename to libs/playback/util/src/lib/diagnostics/vhs-playback-evidence.util.ts diff --git a/libs/playback/util/src/lib/playback-recommendation-capabilities.ts b/libs/playback/util/src/lib/playback-recommendation-capabilities.ts new file mode 100644 index 000000000..c9ee6c3ce --- /dev/null +++ b/libs/playback/util/src/lib/playback-recommendation-capabilities.ts @@ -0,0 +1,144 @@ +import { InlinePlaybackPlayer } from './diagnostics/playback-diagnostics.model'; +import { + PlaybackEngineFamily, + type PlaybackRecommendationContext, + type PlaybackRecommendationTarget, + type PlaybackTargetCapability, +} from './playback-recommendation.model'; +import { getInlinePlaybackEngineFamily } from './playback-target-capabilities'; + +interface RuntimeCapabilityRecord { + readonly target: unknown; + readonly kind: unknown; + readonly available: unknown; + readonly engineFamily?: unknown; +} + +export type PlaybackCapabilityIndex = ReadonlyMap< + PlaybackRecommendationTarget, + PlaybackTargetCapability +>; + +const CANONICAL_TARGETS: readonly PlaybackRecommendationTarget[] = [ + InlinePlaybackPlayer.VideoJs, + InlinePlaybackPlayer.Html5, + InlinePlaybackPlayer.ArtPlayer, + 'mpv', + 'vlc', +]; + +export function createPlaybackCapabilityIndex( + context: PlaybackRecommendationContext +): PlaybackCapabilityIndex | null { + const index = new Map< + PlaybackRecommendationTarget, + PlaybackTargetCapability + >(); + for (const capability of context.targetCapabilities) { + if (!isValidCapability(context, capability, index)) { + return null; + } + index.set(capability.target, capability); + } + if (index.size !== CANONICAL_TARGETS.length) { + return null; + } + const active = index.get(context.activeTarget); + return active?.kind === 'inline' && + active.available && + active.engineFamily !== null + ? index + : null; +} + +export function hasAvailableCapability( + capabilityIndex: PlaybackCapabilityIndex | null, + target: PlaybackRecommendationTarget +): boolean { + return capabilityIndex?.get(target)?.available === true; +} + +export function getAttemptedInlineFamilies( + context: PlaybackRecommendationContext, + capabilityIndex: PlaybackCapabilityIndex | null +): ReadonlySet { + const families = new Set(); + if (capabilityIndex === null) { + return families; + } + for (const target of context.attemptedTargets) { + const capability = capabilityIndex.get(target); + if ( + capability?.kind === 'inline' && + capability.available && + capability.engineFamily !== null + ) { + families.add(capability.engineFamily); + } + } + return families; +} + +export function isInlineFamilyAttempted( + target: PlaybackRecommendationTarget, + capabilityIndex: PlaybackCapabilityIndex | null, + attemptedFamilies: ReadonlySet +): boolean { + const capability = capabilityIndex?.get(target); + return ( + capability?.kind === 'inline' && + capability.engineFamily !== null && + attemptedFamilies.has(capability.engineFamily) + ); +} + +function isValidCapability( + context: PlaybackRecommendationContext, + capability: unknown, + index: PlaybackCapabilityIndex +): capability is PlaybackTargetCapability { + if ( + !hasRequiredCapabilityFields(capability) || + !isKnownTarget(capability.target) || + index.has(capability.target) || + typeof capability.available !== 'boolean' + ) { + return false; + } + if (!isInlineTarget(capability.target)) { + return capability.kind === 'external'; + } + if (capability.kind !== 'inline' || !('engineFamily' in capability)) { + return false; + } + const expectedFamily = getInlinePlaybackEngineFamily( + context.source.kind, + capability.target + ); + return ( + capability.engineFamily === expectedFamily && + !(expectedFamily === null && capability.available) + ); +} + +function hasRequiredCapabilityFields( + value: unknown +): value is RuntimeCapabilityRecord { + return ( + typeof value === 'object' && + value !== null && + 'target' in value && + 'kind' in value && + 'available' in value + ); +} + +function isKnownTarget(value: unknown): value is PlaybackRecommendationTarget { + return CANONICAL_TARGETS.includes(value as PlaybackRecommendationTarget); +} + +function isInlineTarget( + value: PlaybackRecommendationTarget +): value is InlinePlaybackPlayer { + return value !== 'mpv' && value !== 'vlc'; +} diff --git a/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts new file mode 100644 index 000000000..b73a425de --- /dev/null +++ b/libs/playback/util/src/lib/playback-recommendation-policy.spec.ts @@ -0,0 +1,1046 @@ +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from './diagnostics/playback-diagnostics.model'; +import { + PlaybackEngineFamily, + PlaybackRecommendationReason, + PlaybackSourceKind, + type PlaybackRecommendation, + type PlaybackRecommendationContext, + type PlaybackRecommendationPriority, + type PlaybackRecommendationTarget, + type PlaybackTargetCapability, +} from './playback-recommendation.model'; +import { recommendPlaybackRecovery } from './playback-recommendation-policy'; +import { createPlaybackTargetCapabilities } from './playback-target-capabilities'; + +interface ContextOptions { + readonly diagnostic?: PlaybackDiagnostic; + readonly code?: PlaybackDiagnosticCode; + readonly sourceKind?: PlaybackSourceKind; + readonly activeTarget?: PlaybackRecommendationTarget; + readonly attemptedTargets?: ReadonlySet; + readonly targetCapabilities?: readonly PlaybackTargetCapability[]; + readonly managedExternalPlayersAvailable?: boolean; + readonly drm?: 'none' | 'untransferable'; + readonly externalTransferable?: boolean; + readonly alternativeSourceCount?: number; +} + +function diagnostic(code: PlaybackDiagnosticCode): PlaybackDiagnostic { + return { + code, + source: PlaybackDiagnosticSource.Source, + sourceUrl: 'https://example.com/stream', + container: '', + audioCodecs: [], + videoCodecs: [], + }; +} + +function context(options: ContextOptions = {}): PlaybackRecommendationContext { + const sourceKind = options.sourceKind ?? PlaybackSourceKind.Hls; + return { + diagnostic: + options.diagnostic ?? + diagnostic( + options.code ?? PlaybackDiagnosticCode.UnknownPlaybackError + ), + activeTarget: options.activeTarget ?? InlinePlaybackPlayer.VideoJs, + attemptedTargets: options.attemptedTargets ?? new Set(), + targetCapabilities: + options.targetCapabilities ?? + createPlaybackTargetCapabilities({ + sourceKind, + managedExternalPlayersAvailable: + options.managedExternalPlayersAvailable ?? true, + }), + source: { + kind: sourceKind, + isLive: false, + drm: options.drm ?? 'none', + externalTransferable: options.externalTransferable ?? true, + }, + alternativeSourceCount: options.alternativeSourceCount ?? 1, + }; +} + +function unsupportedShakaBrowserDiagnostic(): PlaybackDiagnostic { + return { + ...diagnostic(PlaybackDiagnosticCode.UnknownPlaybackError), + source: PlaybackDiagnosticSource.Shaka, + container: 'mpd', + mimeType: 'application/dash+xml', + runtimeSupport: 'shaka-browser-unsupported', + }; +} + +function retry( + reason: PlaybackRecommendationReason, + priority: PlaybackRecommendationPriority = 'primary' +): PlaybackRecommendation { + return { action: 'retry', reason, priority }; +} + +function alternative( + priority: PlaybackRecommendationPriority = 'secondary' +): PlaybackRecommendation { + return { + action: 'alternative-source', + reason: PlaybackRecommendationReason.AlternativeSourceAvailable, + priority, + }; +} + +function player( + target: PlaybackRecommendationTarget, + reason: PlaybackRecommendationReason, + priority: PlaybackRecommendationPriority = 'secondary' +): PlaybackRecommendation { + return { action: 'player', target, reason, priority }; +} + +function untrustedCapabilityMatrix( + entry: unknown +): readonly PlaybackTargetCapability[] { + return Object.freeze([ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + entry, + ]) as readonly PlaybackTargetCapability[]; +} + +function sparseCapabilityMatrix(): readonly PlaybackTargetCapability[] { + const capabilities: unknown[] = [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + ]; + capabilities.length += 1; + return Object.freeze(capabilities) as readonly PlaybackTargetCapability[]; +} + +describe('recommendPlaybackRecovery', () => { + it.each([ + { + name: 'network error', + input: context({ code: PlaybackDiagnosticCode.NetworkError }), + expected: [ + retry(PlaybackRecommendationReason.RetryTransientFailure), + alternative(), + ], + }, + { + name: 'unknown playback error', + input: context({ + code: PlaybackDiagnosticCode.UnknownPlaybackError, + }), + expected: [ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ], + }, + { + name: 'browser access error', + input: context({ code: PlaybackDiagnosticCode.BrowserAccessError }), + expected: [ + player( + 'mpv', + PlaybackRecommendationReason.ExternalBrowserAccess, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalBrowserAccess + ), + alternative(), + ], + }, + { + name: 'unsupported codec', + input: context({ code: PlaybackDiagnosticCode.UnsupportedCodec }), + expected: [ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ], + }, + { + name: 'unsupported container', + input: context({ + code: PlaybackDiagnosticCode.UnsupportedContainer, + }), + expected: [ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ], + }, + { + name: 'HLS VHS decode error', + input: context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + }), + expected: [ + player( + InlinePlaybackPlayer.Html5, + PlaybackRecommendationReason.DifferentEngineFamily, + 'primary' + ), + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + ], + }, + { + name: 'HLS hls.js decode error on HTML5', + input: context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.Html5, + }), + expected: [ + player( + InlinePlaybackPlayer.VideoJs, + PlaybackRecommendationReason.DifferentEngineFamily, + 'primary' + ), + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + ], + }, + { + name: 'HLS hls.js decode error on ArtPlayer', + input: context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.ArtPlayer, + }), + expected: [ + player( + InlinePlaybackPlayer.VideoJs, + PlaybackRecommendationReason.DifferentEngineFamily, + 'primary' + ), + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + ], + }, + ...[PlaybackSourceKind.MpegTs, PlaybackSourceKind.Native].map( + (sourceKind) => ({ + name: `${sourceKind} decode error`, + input: context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind, + }), + expected: [ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ], + }) + ), + { + name: 'DASH decode error', + input: context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind: PlaybackSourceKind.Dash, + activeTarget: InlinePlaybackPlayer.Html5, + }), + expected: [ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ], + }, + { + name: 'untransferable DASH DRM error', + input: context({ + code: PlaybackDiagnosticCode.DrmOrEncryption, + sourceKind: PlaybackSourceKind.Dash, + activeTarget: InlinePlaybackPlayer.Html5, + drm: 'untransferable', + }), + expected: [alternative('primary')], + }, + ])( + 'returns exact ordered recommendations for $name', + ({ input, expected }) => { + expect(recommendPlaybackRecovery(input)).toEqual(expected); + } + ); + + it('excludes the active target and every attempted player target', () => { + const recommendations = recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + activeTarget: InlinePlaybackPlayer.VideoJs, + attemptedTargets: new Set([ + InlinePlaybackPlayer.Html5, + 'mpv', + 'vlc', + ]), + }) + ); + + expect(recommendations).toEqual([alternative('primary')]); + expect( + recommendations.some( + (recommendation) => + recommendation.action === 'player' && + recommendation.target === InlinePlaybackPlayer.VideoJs + ) + ).toBe(false); + }); + + it('promotes the first surviving result and marks later results secondary', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.UnsupportedCodec, + attemptedTargets: new Set(['mpv']), + }) + ) + ).toEqual([ + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + alternative(), + ]); + }); + + it('caps results at three after filtering', () => { + const recommendations = recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + activeTarget: InlinePlaybackPlayer.VideoJs, + }) + ); + + expect(recommendations).toHaveLength(3); + expect(recommendations).toEqual([ + player( + InlinePlaybackPlayer.Html5, + PlaybackRecommendationReason.DifferentEngineFamily, + 'primary' + ), + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + ]); + }); + + it('uses one canonical representative per distinct HLS engine family', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + activeTarget: InlinePlaybackPlayer.VideoJs, + managedExternalPlayersAvailable: false, + }) + ) + ).toEqual([ + player( + InlinePlaybackPlayer.Html5, + PlaybackRecommendationReason.DifferentEngineFamily, + 'primary' + ), + alternative(), + ]); + }); + + it('does not substitute ArtPlayer when canonical HTML5 is unavailable', () => { + const targetCapabilities = createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).map((capability) => + capability.kind === 'inline' && + capability.target === InlinePlaybackPlayer.Html5 + ? { ...capability, available: false } + : capability + ); + + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + targetCapabilities, + }) + ) + ).toEqual([ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ]); + }); + + it('does not substitute ArtPlayer when canonical HTML5 was attempted', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + attemptedTargets: new Set([InlinePlaybackPlayer.Html5]), + }) + ) + ).toEqual([ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ]); + }); + + it('excludes an inline family already attempted through a sibling target', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + activeTarget: InlinePlaybackPlayer.VideoJs, + attemptedTargets: new Set([InlinePlaybackPlayer.ArtPlayer]), + }) + ) + ).toEqual([ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ]); + }); + + it('keeps managed external recovery for exact clear DASH browser-support evidence', () => { + expect( + recommendPlaybackRecovery( + context({ + diagnostic: unsupportedShakaBrowserDiagnostic(), + sourceKind: PlaybackSourceKind.Dash, + activeTarget: InlinePlaybackPlayer.Html5, + }) + ) + ).toEqual([ + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'primary' + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + alternative(), + ]); + }); + + it.each([ + { + name: 'PWA runtime', + options: { managedExternalPlayersAvailable: false }, + }, + { + name: 'untransferable ClearKey DRM', + options: { drm: 'untransferable' as const }, + }, + ])('suppresses DASH browser-support fallback in $name', ({ options }) => { + expect( + recommendPlaybackRecovery( + context({ + diagnostic: unsupportedShakaBrowserDiagnostic(), + sourceKind: PlaybackSourceKind.Dash, + activeTarget: InlinePlaybackPlayer.Html5, + ...options, + }) + ) + ).toEqual([alternative('primary')]); + }); + + it('ignores capability array order when choosing canonical HTML5', () => { + const targetCapabilities = [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + ].reverse(); + + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + targetCapabilities, + }) + ) + ).toEqual([ + player( + InlinePlaybackPlayer.Html5, + PlaybackRecommendationReason.DifferentEngineFamily, + 'primary' + ), + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + ]); + }); + + it('uses canonical Video.js for an ArtPlayer hls.js failure', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + activeTarget: InlinePlaybackPlayer.ArtPlayer, + }) + ) + ).toEqual([ + player( + InlinePlaybackPlayer.VideoJs, + PlaybackRecommendationReason.DifferentEngineFamily, + 'primary' + ), + player( + 'mpv', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + player( + 'vlc', + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ), + ]); + }); + + it('excludes external players for untransferable DRM', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.DrmOrEncryption, + drm: 'untransferable', + }) + ) + ).toEqual([ + player( + InlinePlaybackPlayer.Html5, + PlaybackRecommendationReason.CompatibleDrmPath, + 'primary' + ), + alternative(), + ]); + }); + + it('excludes external players when the source is not transferable', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.UnsupportedContainer, + externalTransferable: false, + }) + ) + ).toEqual([alternative('primary')]); + }); + + it('excludes unavailable managed external capabilities', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.BrowserAccessError, + managedExternalPlayersAvailable: false, + }) + ) + ).toEqual([alternative('primary')]); + }); + + it.each([ + { + name: 'missing', + capabilities: createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).filter( + (capability) => + capability.target !== InlinePlaybackPlayer.VideoJs + ), + }, + { + name: 'duplicate', + capabilities: [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + { + kind: 'inline' as const, + target: InlinePlaybackPlayer.VideoJs, + available: true, + engineFamily: null, + }, + ], + }, + { + name: 'unavailable', + capabilities: createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Unknown, + managedExternalPlayersAvailable: true, + }), + }, + { + name: 'null-family', + capabilities: [ + { + kind: 'inline' as const, + target: InlinePlaybackPlayer.VideoJs, + available: true, + engineFamily: null, + }, + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).slice(1), + ], + }, + ])( + 'fails closed for a $name active inline capability', + ({ capabilities }) => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + targetCapabilities: capabilities, + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ]); + } + ); + + it.each([ + { + name: 'duplicate MPV availability records', + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + capabilities: [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + { + kind: 'external' as const, + target: 'mpv' as const, + available: false, + }, + ], + }, + { + name: 'duplicate inline records with contradictory family and availability', + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + capabilities: [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + { + kind: 'inline' as const, + target: InlinePlaybackPlayer.Html5, + available: false, + engineFamily: PlaybackEngineFamily.Vhs, + }, + ], + }, + { + name: 'wrong target and kind pairing', + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + capabilities: [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).filter((capability) => capability.target !== 'mpv'), + { + kind: 'inline', + target: 'mpv', + available: true, + engineFamily: PlaybackEngineFamily.HlsJs, + } as unknown as PlaybackTargetCapability, + ], + }, + { + name: 'missing canonical target record', + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + capabilities: createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).filter( + (capability) => + capability.target !== InlinePlaybackPlayer.ArtPlayer + ), + }, + { + name: 'future target record', + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + capabilities: [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + { + kind: 'external', + target: 'future-player', + available: true, + } as unknown as PlaybackTargetCapability, + ], + }, + { + name: 'future engine family', + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + capabilities: createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).map((capability) => + capability.kind === 'inline' && + capability.target === InlinePlaybackPlayer.ArtPlayer + ? ({ + ...capability, + engineFamily: 'future-family', + } as unknown as PlaybackTargetCapability) + : capability + ), + }, + { + name: 'source kind and engine family mismatch', + sourceKind: PlaybackSourceKind.Hls, + activeTarget: InlinePlaybackPlayer.VideoJs, + capabilities: createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).map((capability) => + capability.kind === 'inline' && + capability.target === InlinePlaybackPlayer.VideoJs + ? { + ...capability, + engineFamily: PlaybackEngineFamily.HlsJs, + } + : capability + ), + }, + { + name: 'available null-family path', + sourceKind: PlaybackSourceKind.Dash, + activeTarget: InlinePlaybackPlayer.Html5, + capabilities: createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Dash, + managedExternalPlayersAvailable: true, + }).map((capability) => + capability.kind === 'inline' && + capability.target === InlinePlaybackPlayer.VideoJs + ? { ...capability, available: true } + : capability + ), + }, + ])( + 'fails closed for $name in the complete capability context', + ({ sourceKind, activeTarget, capabilities }) => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind, + activeTarget, + targetCapabilities: capabilities, + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ]); + } + ); + + it('omits an unavailable alternative when capability validation fails', () => { + const targetCapabilities = [ + ...createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }), + { + kind: 'external' as const, + target: 'mpv' as const, + available: false, + }, + ]; + + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.UnsupportedCodec, + targetCapabilities, + alternativeSourceCount: 0, + }) + ) + ).toEqual([retry(PlaybackRecommendationReason.RetryUnknownFailure)]); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['number', 42], + ['string', 'invalid-capability'], + ['boolean', true], + ])('fails closed for a %s capability entry', (_name, entry) => { + const targetCapabilities = untrustedCapabilityMatrix(entry); + + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + targetCapabilities, + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ]); + }); + + it('fails closed for a sparse capability array without mutating it', () => { + const targetCapabilities = sparseCapabilityMatrix(); + const keysBefore = Object.keys(targetCapabilities); + const lengthBefore = targetCapabilities.length; + + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + targetCapabilities, + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ]); + expect(Object.keys(targetCapabilities)).toEqual(keysBefore); + expect(targetCapabilities).toHaveLength(lengthBefore); + }); + + it.each([ + PlaybackDiagnosticCode.NetworkError, + PlaybackDiagnosticCode.UnknownPlaybackError, + ])('keeps %s total with malformed capabilities', (code) => { + expect( + recommendPlaybackRecovery( + context({ + code, + targetCapabilities: untrustedCapabilityMatrix(null), + }) + ) + ).toEqual([ + retry( + code === PlaybackDiagnosticCode.NetworkError + ? PlaybackRecommendationReason.RetryTransientFailure + : PlaybackRecommendationReason.RetryUnknownFailure + ), + alternative(), + ]); + }); + + it('fails closed when the active capability is external', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.UnsupportedCodec, + activeTarget: 'mpv', + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ]); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, 0.5, 0, -1])( + 'omits alternative source when its count is invalid or non-positive: %s', + (alternativeSourceCount) => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.NetworkError, + alternativeSourceCount, + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryTransientFailure), + ]); + } + ); + + it('includes alternative source for a positive safe integer count', () => { + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.NetworkError, + alternativeSourceCount: Number.MAX_SAFE_INTEGER, + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryTransientFailure), + alternative(), + ]); + }); + + it('does not mutate attempted targets or capability inputs', () => { + const attemptedTargets = new Set(['mpv']); + const targetCapabilities = Object.freeze( + createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }).map((capability) => Object.freeze({ ...capability })) + ); + const attemptedBefore = [...attemptedTargets]; + const capabilitiesBefore = targetCapabilities.map((capability) => ({ + ...capability, + })); + + const first = recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + attemptedTargets, + targetCapabilities, + }) + ); + const second = recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + attemptedTargets, + targetCapabilities, + }) + ); + + expect([...attemptedTargets]).toEqual(attemptedBefore); + expect(targetCapabilities).toEqual(capabilitiesBefore); + expect(second).toEqual(first); + expect(second).not.toBe(first); + expect(second[0]).not.toBe(first[0]); + }); + + it('handles unknown source kinds without throwing', () => { + expect(() => + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind: PlaybackSourceKind.Unknown, + }) + ) + ).not.toThrow(); + expect( + recommendPlaybackRecovery( + context({ + code: PlaybackDiagnosticCode.MediaDecodeError, + sourceKind: PlaybackSourceKind.Unknown, + }) + ) + ).toEqual([ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ]); + }); + + it('handles future diagnostic codes with the safe default', () => { + const unknownDiagnostic = { + ...diagnostic(PlaybackDiagnosticCode.UnknownPlaybackError), + code: 'future-playback-error', + } as unknown as PlaybackDiagnostic; + const input = { + ...context(), + diagnostic: unknownDiagnostic, + }; + + expect(() => recommendPlaybackRecovery(input)).not.toThrow(); + expect(recommendPlaybackRecovery(input)).toEqual([ + retry(PlaybackRecommendationReason.RetryUnknownFailure), + alternative(), + ]); + }); +}); diff --git a/libs/playback/util/src/lib/playback-recommendation-policy.ts b/libs/playback/util/src/lib/playback-recommendation-policy.ts new file mode 100644 index 000000000..dcec6a38a --- /dev/null +++ b/libs/playback/util/src/lib/playback-recommendation-policy.ts @@ -0,0 +1,302 @@ +import type { ExternalPlayerName } from '@iptvnator/shared/interfaces'; +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + PlaybackRuntimeSupport, +} from './diagnostics/playback-diagnostics.model'; +import { + PlaybackEngineFamily, + PlaybackRecommendationReason, + PlaybackSourceKind, + type PlaybackRecommendation, + type PlaybackRecommendationContext, + type PlaybackRecommendationTarget, + type PlaybackTargetCapability, +} from './playback-recommendation.model'; +import { + createPlaybackCapabilityIndex, + getAttemptedInlineFamilies, + hasAvailableCapability, + isInlineFamilyAttempted, + type PlaybackCapabilityIndex, +} from './playback-recommendation-capabilities'; + +type WithoutPriority = T extends unknown ? Omit : never; + +type PlaybackRecommendationCandidate = WithoutPriority; + +type InlineCapability = Extract< + PlaybackTargetCapability, + { readonly kind: 'inline' } +>; + +type AvailableInlineCapability = InlineCapability & { + readonly available: true; + readonly engineFamily: PlaybackEngineFamily; +}; + +export function recommendPlaybackRecovery( + context: PlaybackRecommendationContext +): readonly PlaybackRecommendation[] { + const capabilityIndex = hasPlayerRecoveryEvidence(context) + ? createPlaybackCapabilityIndex(context) + : null; + const attemptedInlineFamilies = getAttemptedInlineFamilies( + context, + capabilityIndex + ); + const seenTargets = new Set(); + const candidates = buildCandidates(context, capabilityIndex).filter( + isCandidate + ); + const filtered = candidates.filter((candidate) => { + if (candidate.action !== 'player') { + return true; + } + if ( + candidate.target === context.activeTarget || + context.attemptedTargets.has(candidate.target) || + seenTargets.has(candidate.target) || + !hasAvailableCapability(capabilityIndex, candidate.target) + ) { + return false; + } + if ( + isInlineFamilyAttempted( + candidate.target, + capabilityIndex, + attemptedInlineFamilies + ) + ) { + return false; + } + if ( + isExternalTarget(candidate.target) && + !canTransferExternally(context) + ) { + return false; + } + seenTargets.add(candidate.target); + return true; + }); + + return filtered.slice(0, 3).map((candidate, index) => ({ + ...candidate, + priority: index === 0 ? 'primary' : 'secondary', + })); +} + +function buildCandidates( + context: PlaybackRecommendationContext, + capabilityIndex: PlaybackCapabilityIndex | null +): readonly (PlaybackRecommendationCandidate | null)[] { + if (hasPlayerRecoveryEvidence(context) && capabilityIndex === null) { + return [retryUnknown(), alternative(context)]; + } + + if (isShakaBrowserUnsupported(context)) { + return [ + externalCodecSupport('mpv'), + externalCodecSupport('vlc'), + alternative(context), + ]; + } + + switch (context.diagnostic.code) { + case PlaybackDiagnosticCode.NetworkError: + return [retryTransient(), alternative(context)]; + case PlaybackDiagnosticCode.BrowserAccessError: + return [ + external( + 'mpv', + PlaybackRecommendationReason.ExternalBrowserAccess + ), + external( + 'vlc', + PlaybackRecommendationReason.ExternalBrowserAccess + ), + alternative(context), + ]; + case PlaybackDiagnosticCode.UnsupportedCodec: + case PlaybackDiagnosticCode.UnsupportedContainer: + return [ + externalCodecSupport('mpv'), + externalCodecSupport('vlc'), + alternative(context), + ]; + case PlaybackDiagnosticCode.MediaDecodeError: + return [ + distinctInline( + context, + capabilityIndex, + PlaybackRecommendationReason.DifferentEngineFamily + ), + externalCodecSupport('mpv'), + externalCodecSupport('vlc'), + alternative(context), + ]; + case PlaybackDiagnosticCode.DrmOrEncryption: + return [ + distinctInline( + context, + capabilityIndex, + PlaybackRecommendationReason.CompatibleDrmPath + ), + alternative(context), + external('mpv', PlaybackRecommendationReason.CompatibleDrmPath), + external('vlc', PlaybackRecommendationReason.CompatibleDrmPath), + ]; + case PlaybackDiagnosticCode.UnknownPlaybackError: + default: + return [retryUnknown(), alternative(context)]; + } +} + +function retryTransient(): PlaybackRecommendationCandidate { + return { + action: 'retry', + reason: PlaybackRecommendationReason.RetryTransientFailure, + }; +} + +function retryUnknown(): PlaybackRecommendationCandidate { + return { + action: 'retry', + reason: PlaybackRecommendationReason.RetryUnknownFailure, + }; +} + +function alternative( + context: PlaybackRecommendationContext +): PlaybackRecommendationCandidate | null { + return !Number.isSafeInteger(context.alternativeSourceCount) || + context.alternativeSourceCount <= 0 + ? null + : { + action: 'alternative-source', + reason: PlaybackRecommendationReason.AlternativeSourceAvailable, + }; +} + +function distinctInline( + context: PlaybackRecommendationContext, + capabilityIndex: PlaybackCapabilityIndex | null, + reason: PlaybackRecommendationCandidate['reason'] +): PlaybackRecommendationCandidate | null { + const activeCapability = getActiveInlineCapability( + context, + capabilityIndex + ); + if (activeCapability === null) { + return null; + } + + const canonicalTarget = getCanonicalDistinctInlineTarget( + activeCapability.engineFamily + ); + if (canonicalTarget === null) { + return null; + } + const capability = capabilityIndex?.get(canonicalTarget); + return capability?.kind !== 'inline' || + !capability.available || + capability.engineFamily === null || + capability.engineFamily === activeCapability.engineFamily + ? null + : { action: 'player', target: capability.target, reason }; +} + +function getCanonicalDistinctInlineTarget( + activeFamily: PlaybackEngineFamily +): InlinePlaybackPlayer | null { + if (activeFamily === PlaybackEngineFamily.Vhs) { + return InlinePlaybackPlayer.Html5; + } + return activeFamily === PlaybackEngineFamily.HlsJs + ? InlinePlaybackPlayer.VideoJs + : null; +} + +function getActiveInlineCapability( + context: PlaybackRecommendationContext, + capabilityIndex: PlaybackCapabilityIndex | null +): AvailableInlineCapability | null { + const capability = capabilityIndex?.get(context.activeTarget); + return capability?.kind === 'inline' && + capability.available && + capability.engineFamily !== null + ? (capability as AvailableInlineCapability) + : null; +} + +function externalCodecSupport( + target: ExternalPlayerName +): PlaybackRecommendationCandidate { + return external( + target, + PlaybackRecommendationReason.ExternalCodecOrContainerSupport + ); +} + +function external( + target: ExternalPlayerName, + reason: PlaybackRecommendationCandidate['reason'] +): PlaybackRecommendationCandidate { + return { action: 'player', target, reason }; +} + +function isPlayerOrientedDiagnostic( + code: PlaybackRecommendationContext['diagnostic']['code'] +): boolean { + return ( + code === PlaybackDiagnosticCode.BrowserAccessError || + code === PlaybackDiagnosticCode.UnsupportedCodec || + code === PlaybackDiagnosticCode.UnsupportedContainer || + code === PlaybackDiagnosticCode.MediaDecodeError || + code === PlaybackDiagnosticCode.DrmOrEncryption + ); +} + +function hasPlayerRecoveryEvidence( + context: PlaybackRecommendationContext +): boolean { + return ( + isPlayerOrientedDiagnostic(context.diagnostic.code) || + isShakaBrowserUnsupported(context) + ); +} + +function isShakaBrowserUnsupported( + context: PlaybackRecommendationContext +): boolean { + return ( + context.diagnostic.code === + PlaybackDiagnosticCode.UnknownPlaybackError && + context.diagnostic.source === PlaybackDiagnosticSource.Shaka && + context.diagnostic.runtimeSupport === + PlaybackRuntimeSupport.ShakaBrowserUnsupported && + context.source.kind === PlaybackSourceKind.Dash + ); +} + +function isExternalTarget( + target: PlaybackRecommendationTarget +): target is ExternalPlayerName { + return target === 'mpv' || target === 'vlc'; +} + +function canTransferExternally( + context: PlaybackRecommendationContext +): boolean { + return ( + context.source.drm !== 'untransferable' && + context.source.externalTransferable + ); +} + +function isCandidate( + candidate: PlaybackRecommendationCandidate | null +): candidate is PlaybackRecommendationCandidate { + return candidate !== null; +} diff --git a/libs/playback/util/src/lib/playback-recommendation.model.ts b/libs/playback/util/src/lib/playback-recommendation.model.ts new file mode 100644 index 000000000..0c9ce3f61 --- /dev/null +++ b/libs/playback/util/src/lib/playback-recommendation.model.ts @@ -0,0 +1,95 @@ +import type { + ExternalPlayerName, +} from '@iptvnator/shared/interfaces'; +import type { + InlinePlaybackPlayer, + PlaybackDiagnostic, +} from './diagnostics/playback-diagnostics.model'; + +export const PlaybackRecommendationReason = { + RetryTransientFailure: 'retry-transient-failure', + RetryUnknownFailure: 'retry-unknown-failure', + AlternativeSourceAvailable: 'alternative-source-available', + DifferentEngineFamily: 'different-engine-family', + ExternalCodecOrContainerSupport: 'external-codec-or-container-support', + ExternalBrowserAccess: 'external-browser-access', + CompatibleDrmPath: 'compatible-drm-path', +} as const; + +export type PlaybackRecommendationReason = + (typeof PlaybackRecommendationReason)[keyof typeof PlaybackRecommendationReason]; + +export type PlaybackRecommendationPriority = 'primary' | 'secondary'; + +export type PlaybackRecommendationTarget = + | InlinePlaybackPlayer + | ExternalPlayerName; + +export const PlaybackSourceKind = { + Hls: 'hls', + MpegTs: 'mpegts', + Dash: 'dash', + Native: 'native', + Unknown: 'unknown', +} as const; + +export type PlaybackSourceKind = + (typeof PlaybackSourceKind)[keyof typeof PlaybackSourceKind]; + +export const PlaybackEngineFamily = { + Vhs: 'vhs', + HlsJs: 'hls.js', + MpegTsJs: 'mpegts.js', + Shaka: 'shaka', + NativeMedia: 'native-media', +} as const; + +export type PlaybackEngineFamily = + (typeof PlaybackEngineFamily)[keyof typeof PlaybackEngineFamily]; + +export type PlaybackTargetCapability = + | { + readonly kind: 'inline'; + readonly target: InlinePlaybackPlayer; + readonly available: boolean; + readonly engineFamily: PlaybackEngineFamily | null; + } + | { + readonly kind: 'external'; + readonly target: ExternalPlayerName; + readonly available: boolean; + }; + +export interface PlaybackRecommendationSourceContext { + readonly kind: PlaybackSourceKind; + readonly isLive: boolean; + readonly drm: 'none' | 'untransferable'; + readonly externalTransferable: boolean; +} + +export interface PlaybackRecommendationContext { + readonly diagnostic: PlaybackDiagnostic; + readonly activeTarget: PlaybackRecommendationTarget; + readonly attemptedTargets: ReadonlySet; + readonly targetCapabilities: readonly PlaybackTargetCapability[]; + readonly source: PlaybackRecommendationSourceContext; + readonly alternativeSourceCount: number; +} + +export type PlaybackRecommendation = + | { + readonly action: 'retry'; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + } + | { + readonly action: 'alternative-source'; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + } + | { + readonly action: 'player'; + readonly target: PlaybackRecommendationTarget; + readonly reason: PlaybackRecommendationReason; + readonly priority: PlaybackRecommendationPriority; + }; diff --git a/libs/playback/util/src/lib/playback-session-key.spec.ts b/libs/playback/util/src/lib/playback-session-key.spec.ts new file mode 100644 index 000000000..61426380c --- /dev/null +++ b/libs/playback/util/src/lib/playback-session-key.spec.ts @@ -0,0 +1,196 @@ +import * as playbackSessionKey from './playback-session-key'; +import type { PlaybackSessionIdentity } from './playback-session-key'; + +const { createPlaybackSessionKey } = playbackSessionKey; + +describe('playback session key', () => { + it('length-prefixes every identity part', () => { + expect( + createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'list:west|hd', + contentId: 'ep:4|finale', + seriesId: 'series|2:remaster', + seasonNumber: 3, + episodeNumber: 4, + }) + ).toBe( + '7:episode|12:list:west|hd|11:ep:4|finale|17:series|2:remaster|1:3|1:4' + ); + }); + + it.each([ + [ + { kind: 'live', sourceId: 'a', contentId: 'b|c' }, + { kind: 'live', sourceId: 'a|b', contentId: 'c' }, + ], + [ + { kind: 'vod', sourceId: 'a:1', contentId: '2' }, + { kind: 'vod', sourceId: 'a', contentId: '1:2' }, + ], + [ + { + kind: 'episode', + sourceId: 'source', + contentId: 'content', + seriesId: 'series:1|2', + seasonNumber: 3, + episodeNumber: 4, + }, + { + kind: 'episode', + sourceId: 'source', + contentId: 'content', + seriesId: 'series', + seasonNumber: 1, + episodeNumber: 2, + }, + ], + ] as const)( + 'does not collide delimiter-bearing identities %#', + (left, right) => { + expect(createPlaybackSessionKey(left)).not.toBe( + createPlaybackSessionKey(right) + ); + } + ); + + it('reuses canonical movie identity across provider copies and source URLs', () => { + const logicalIdentity: PlaybackSessionIdentity = { + kind: 'vod', + sourceId: 'catalog:featured', + contentId: 'movie:42', + }; + const originalCopy = { + providerId: 'provider:one', + sourceUrl: 'https://one.example/movie/42', + logicalIdentity, + }; + const alternativeCopy = { + providerId: 'provider|two', + sourceUrl: 'https://two.example/alternate/9001', + logicalIdentity, + }; + + expect(createPlaybackSessionKey(originalCopy.logicalIdentity)).toBe( + createPlaybackSessionKey(alternativeCopy.logicalIdentity) + ); + }); + + it.each([ + [{ kind: 'live', sourceId: 'playlist:2', contentId: 42 }], + [{ kind: 'live', sourceId: 'playlist:1', contentId: 43 }], + [{ kind: 'vod', sourceId: 'playlist:1', contentId: 42 }], + ] as const)( + 'changes when a live channel identity changes: %o', + (changedIdentity) => { + const original = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist:1', + contentId: 42, + }); + + expect(createPlaybackSessionKey(changedIdentity)).not.toBe( + original + ); + } + ); + + it.each([ + [{ kind: 'vod', sourceId: 'playlist:2', contentId: 42 }], + [{ kind: 'vod', sourceId: 'playlist:1', contentId: 43 }], + ] as const)( + 'changes when a logical movie identity changes: %o', + (changedIdentity) => { + const original = createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist:1', + contentId: 42, + }); + + expect(createPlaybackSessionKey(changedIdentity)).not.toBe( + original + ); + } + ); + + it.each([ + [ + 'source', + { + kind: 'episode', + sourceId: 'playlist:2', + contentId: 42, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 4, + }, + ], + [ + 'content', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 43, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 4, + }, + ], + [ + 'series', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 8, + seasonNumber: 3, + episodeNumber: 4, + }, + ], + [ + 'season', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 7, + seasonNumber: 4, + episodeNumber: 4, + }, + ], + [ + 'episode', + { + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 5, + }, + ], + ] as const)( + 'changes when the logical episode %s identity changes', + (_field, changedIdentity) => { + const original = createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'playlist:1', + contentId: 42, + seriesId: 7, + seasonNumber: 3, + episodeNumber: 4, + }); + + expect(createPlaybackSessionKey(changedIdentity)).not.toBe( + original + ); + } + ); + + it('does not expose a provider-scoped content-info adapter', () => { + expect(playbackSessionKey).not.toHaveProperty( + 'createPlaybackSessionKeyFromContentInfo' + ); + }); +}); diff --git a/libs/playback/util/src/lib/playback-session-key.ts b/libs/playback/util/src/lib/playback-session-key.ts new file mode 100644 index 000000000..0bd7d4f50 --- /dev/null +++ b/libs/playback/util/src/lib/playback-session-key.ts @@ -0,0 +1,38 @@ +/** + * Host-owned canonical logical content identity. Source and content IDs must + * not come from the currently selected provider copy or playback URL. + */ +export type PlaybackSessionIdentity = + | { + readonly kind: 'live'; + readonly sourceId: string; + readonly contentId: string | number; + } + | { + readonly kind: 'vod'; + readonly sourceId: string; + readonly contentId: string | number; + } + | { + readonly kind: 'episode'; + readonly sourceId: string; + readonly contentId: string | number; + readonly seriesId?: string | number; + readonly seasonNumber?: number; + readonly episodeNumber?: number; + }; + +export function createPlaybackSessionKey( + identity: PlaybackSessionIdentity +): string { + const parts = [ + identity.kind, + identity.sourceId, + String(identity.contentId), + identity.kind === 'episode' ? String(identity.seriesId ?? '') : '', + identity.kind === 'episode' ? String(identity.seasonNumber ?? '') : '', + identity.kind === 'episode' ? String(identity.episodeNumber ?? '') : '', + ]; + + return parts.map((part) => `${part.length}:${part}`).join('|'); +} diff --git a/libs/playback/util/src/lib/playback-target-capabilities.spec.ts b/libs/playback/util/src/lib/playback-target-capabilities.spec.ts new file mode 100644 index 000000000..b91379383 --- /dev/null +++ b/libs/playback/util/src/lib/playback-target-capabilities.spec.ts @@ -0,0 +1,303 @@ +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from './diagnostics/playback-diagnostics.model'; +import { + PlaybackEngineFamily, + PlaybackSourceKind, +} from './playback-recommendation.model'; +import { + createPlaybackTargetCapabilities, + getInlinePlaybackEngineFamily, + resolvePlaybackSourceKind, +} from './playback-target-capabilities'; + +function diagnostic( + overrides: Partial +): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.UnknownPlaybackError, + source: PlaybackDiagnosticSource.Source, + sourceUrl: 'https://example.com/stream', + container: '', + audioCodecs: [], + videoCodecs: [], + ...overrides, + }; +} + +describe('playback target capabilities', () => { + it.each([ + [ + PlaybackSourceKind.Hls, + InlinePlaybackPlayer.VideoJs, + PlaybackEngineFamily.Vhs, + ], + [ + PlaybackSourceKind.Hls, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.HlsJs, + ], + [ + PlaybackSourceKind.Hls, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.HlsJs, + ], + [ + PlaybackSourceKind.MpegTs, + InlinePlaybackPlayer.VideoJs, + PlaybackEngineFamily.MpegTsJs, + ], + [ + PlaybackSourceKind.MpegTs, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.MpegTsJs, + ], + [ + PlaybackSourceKind.MpegTs, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.MpegTsJs, + ], + [PlaybackSourceKind.Dash, InlinePlaybackPlayer.VideoJs, null], + [ + PlaybackSourceKind.Dash, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.Shaka, + ], + [ + PlaybackSourceKind.Dash, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.Shaka, + ], + [ + PlaybackSourceKind.Native, + InlinePlaybackPlayer.VideoJs, + PlaybackEngineFamily.NativeMedia, + ], + [ + PlaybackSourceKind.Native, + InlinePlaybackPlayer.Html5, + PlaybackEngineFamily.NativeMedia, + ], + [ + PlaybackSourceKind.Native, + InlinePlaybackPlayer.ArtPlayer, + PlaybackEngineFamily.NativeMedia, + ], + ] as const)('maps %s on %s to %s', (sourceKind, target, expectedFamily) => { + expect(getInlinePlaybackEngineFamily(sourceKind, target)).toBe( + expectedFamily + ); + }); + + it.each([ + [PlaybackDiagnosticSource.Hls, {}, PlaybackSourceKind.Hls], + [ + PlaybackDiagnosticSource.Vhs, + { container: 'm3u8' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Source, + { container: 'm3u' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Vhs, + { + mimeType: ' Application/Vnd.Apple.MPEGURL; charset=UTF-8 ', + }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Source, + { mimeType: 'application/x-mpegurl' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.Vhs, + { mimeType: 'audio/x-mpegurl' }, + PlaybackSourceKind.Hls, + ], + [PlaybackDiagnosticSource.Shaka, {}, PlaybackSourceKind.Dash], + [ + PlaybackDiagnosticSource.Source, + { container: 'mpd' }, + PlaybackSourceKind.Dash, + ], + [ + PlaybackDiagnosticSource.Vhs, + { container: 'mpd' }, + PlaybackSourceKind.Dash, + ], + [ + PlaybackDiagnosticSource.Source, + { mimeType: ' Application/Dash+XML; profile=live ' }, + PlaybackSourceKind.Dash, + ], + [PlaybackDiagnosticSource.MpegTs, {}, PlaybackSourceKind.MpegTs], + [PlaybackDiagnosticSource.Native, {}, PlaybackSourceKind.Native], + ] as const)( + 'resolves %s diagnostics with %o as %s', + (source, metadata, expectedKind) => { + expect( + resolvePlaybackSourceKind(diagnostic({ source, ...metadata })) + ).toBe(expectedKind); + } + ); + + it.each([ + diagnostic({ source: PlaybackDiagnosticSource.Source }), + diagnostic({ + source: PlaybackDiagnosticSource.Source, + container: 'mp4', + mimeType: 'video/mp4', + }), + diagnostic({ + source: PlaybackDiagnosticSource.Vhs, + container: 'mpd', + mimeType: 'application/vnd.apple.mpegurl', + }), + diagnostic({ + source: PlaybackDiagnosticSource.Source, + container: 'm3u8', + mimeType: 'application/dash+xml', + }), + diagnostic({ + source: PlaybackDiagnosticSource.Vhs, + mimeType: 'application/not-mpegurl', + }), + ])('keeps insufficient or contradictory evidence unknown', (issue) => { + expect(resolvePlaybackSourceKind(issue)).toBe( + PlaybackSourceKind.Unknown + ); + }); + + it.each([ + [ + PlaybackDiagnosticSource.Hls, + { container: 'mpd', mimeType: 'application/dash+xml' }, + PlaybackSourceKind.Hls, + ], + [ + PlaybackDiagnosticSource.MpegTs, + { container: 'mpd', mimeType: 'application/dash+xml' }, + PlaybackSourceKind.MpegTs, + ], + [ + PlaybackDiagnosticSource.Shaka, + { + container: 'm3u8', + mimeType: 'application/vnd.apple.mpegurl', + }, + PlaybackSourceKind.Dash, + ], + [ + PlaybackDiagnosticSource.Native, + { + container: 'm3u8', + mimeType: 'application/vnd.apple.mpegurl', + }, + PlaybackSourceKind.Native, + ], + ] as const)( + 'treats engine-specific %s source evidence as authoritative', + (source, metadata, expectedKind) => { + expect( + resolvePlaybackSourceKind(diagnostic({ source, ...metadata })) + ).toBe(expectedKind); + } + ); + + it('creates capabilities in canonical order', () => { + expect( + createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Hls, + managedExternalPlayersAvailable: true, + }) + ).toEqual([ + { + kind: 'inline', + target: InlinePlaybackPlayer.VideoJs, + available: true, + engineFamily: PlaybackEngineFamily.Vhs, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.Html5, + available: true, + engineFamily: PlaybackEngineFamily.HlsJs, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.ArtPlayer, + available: true, + engineFamily: PlaybackEngineFamily.HlsJs, + }, + { kind: 'external', target: 'mpv', available: true }, + { kind: 'external', target: 'vlc', available: true }, + ]); + }); + + it('marks Video.js unavailable for DASH and mirrors external availability', () => { + expect( + createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Dash, + managedExternalPlayersAvailable: false, + }) + ).toEqual([ + { + kind: 'inline', + target: InlinePlaybackPlayer.VideoJs, + available: false, + engineFamily: null, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.Html5, + available: true, + engineFamily: PlaybackEngineFamily.Shaka, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.ArtPlayer, + available: true, + engineFamily: PlaybackEngineFamily.Shaka, + }, + { kind: 'external', target: 'mpv', available: false }, + { kind: 'external', target: 'vlc', available: false }, + ]); + }); + + it('fails closed for unknown inline source capabilities', () => { + expect( + createPlaybackTargetCapabilities({ + sourceKind: PlaybackSourceKind.Unknown, + managedExternalPlayersAvailable: true, + }) + ).toEqual([ + { + kind: 'inline', + target: InlinePlaybackPlayer.VideoJs, + available: false, + engineFamily: null, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.Html5, + available: false, + engineFamily: null, + }, + { + kind: 'inline', + target: InlinePlaybackPlayer.ArtPlayer, + available: false, + engineFamily: null, + }, + { kind: 'external', target: 'mpv', available: true }, + { kind: 'external', target: 'vlc', available: true }, + ]); + }); +}); diff --git a/libs/playback/util/src/lib/playback-target-capabilities.ts b/libs/playback/util/src/lib/playback-target-capabilities.ts new file mode 100644 index 000000000..9ed48d006 --- /dev/null +++ b/libs/playback/util/src/lib/playback-target-capabilities.ts @@ -0,0 +1,133 @@ +import { + InlinePlaybackPlayer, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from './diagnostics/playback-diagnostics.model'; +import { + PlaybackEngineFamily, + PlaybackSourceKind, + type PlaybackTargetCapability, +} from './playback-recommendation.model'; + +const HLS_MIME_TYPES = new Set([ + 'application/vnd.apple.mpegurl', + 'application/x-mpegurl', + 'audio/x-mpegurl', +]); +const DASH_MIME_TYPE = 'application/dash+xml'; + +export function resolvePlaybackSourceKind( + diagnostic: PlaybackDiagnostic +): PlaybackSourceKind { + switch (diagnostic.source) { + // Engine-specific sources identify the boundary that emitted the + // failure, so generic container/MIME metadata cannot override them. + case PlaybackDiagnosticSource.Hls: + return PlaybackSourceKind.Hls; + case PlaybackDiagnosticSource.MpegTs: + return PlaybackSourceKind.MpegTs; + case PlaybackDiagnosticSource.Shaka: + return PlaybackSourceKind.Dash; + case PlaybackDiagnosticSource.Source: + case PlaybackDiagnosticSource.Vhs: + return resolveSourceOrVhsKind(diagnostic); + case PlaybackDiagnosticSource.Native: + return PlaybackSourceKind.Native; + default: + return PlaybackSourceKind.Unknown; + } +} + +export function getInlinePlaybackEngineFamily( + sourceKind: PlaybackSourceKind, + target: InlinePlaybackPlayer +): PlaybackEngineFamily | null { + switch (sourceKind) { + case PlaybackSourceKind.Hls: + return target === InlinePlaybackPlayer.VideoJs + ? PlaybackEngineFamily.Vhs + : PlaybackEngineFamily.HlsJs; + case PlaybackSourceKind.MpegTs: + return PlaybackEngineFamily.MpegTsJs; + case PlaybackSourceKind.Dash: + return target === InlinePlaybackPlayer.VideoJs + ? null + : PlaybackEngineFamily.Shaka; + case PlaybackSourceKind.Native: + return PlaybackEngineFamily.NativeMedia; + case PlaybackSourceKind.Unknown: + default: + return null; + } +} + +export function createPlaybackTargetCapabilities(options: { + readonly sourceKind: PlaybackSourceKind; + readonly managedExternalPlayersAvailable: boolean; +}): readonly PlaybackTargetCapability[] { + const inlineTargets = [ + InlinePlaybackPlayer.VideoJs, + InlinePlaybackPlayer.Html5, + InlinePlaybackPlayer.ArtPlayer, + ] as const; + const inlineCapabilities = inlineTargets.map((target) => { + const engineFamily = getInlinePlaybackEngineFamily( + options.sourceKind, + target + ); + return { + kind: 'inline' as const, + target, + available: engineFamily !== null, + engineFamily, + }; + }); + + return [ + ...inlineCapabilities, + { + kind: 'external', + target: 'mpv', + available: options.managedExternalPlayersAvailable, + }, + { + kind: 'external', + target: 'vlc', + available: options.managedExternalPlayersAvailable, + }, + ]; +} + +function resolveSourceOrVhsKind( + diagnostic: PlaybackDiagnostic +): PlaybackSourceKind { + const containerKind = resolveContainerKind(diagnostic.container); + const mimeTypeKind = resolveMimeTypeKind(diagnostic.mimeType); + + if ( + containerKind !== null && + mimeTypeKind !== null && + containerKind !== mimeTypeKind + ) { + return PlaybackSourceKind.Unknown; + } + + return containerKind ?? mimeTypeKind ?? PlaybackSourceKind.Unknown; +} + +function resolveContainerKind(container: string): PlaybackSourceKind | null { + if (container === 'm3u' || container === 'm3u8') { + return PlaybackSourceKind.Hls; + } + return container === 'mpd' ? PlaybackSourceKind.Dash : null; +} + +function resolveMimeTypeKind( + mimeType: string | undefined +): PlaybackSourceKind | null { + const baseMimeType = mimeType?.split(';', 1)[0]?.trim().toLowerCase() ?? ''; + if (HLS_MIME_TYPES.has(baseMimeType)) { + return PlaybackSourceKind.Hls; + } + return baseMimeType === DASH_MIME_TYPE ? PlaybackSourceKind.Dash : null; +} diff --git a/libs/playback/util/src/lib/playback-util-boundary.spec.ts b/libs/playback/util/src/lib/playback-util-boundary.spec.ts new file mode 100644 index 000000000..ba5e757b4 --- /dev/null +++ b/libs/playback/util/src/lib/playback-util-boundary.spec.ts @@ -0,0 +1,347 @@ +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import ts from 'typescript'; + +const PROJECT_SOURCE_ROOT = join(process.cwd(), 'libs/playback/util/src'); +const FORBIDDEN_IMPORT_PREFIXES = [ + '@angular/', + '@iptvnator/ui/', + 'electron', + '@ngx-pwa/', +] as const; +const FORBIDDEN_GLOBALS = new Set([ + 'window', + 'document', + 'localStorage', + 'sessionStorage', + 'MediaSource', + 'MediaError', + 'HTMLMediaElement', + 'HTMLElement', + 'navigator', + 'indexedDB', +]); + +describe('playback util boundary', () => { + it('keeps production sources independent of UI and browser globals', () => { + const violations = collectProductionSources( + PROJECT_SOURCE_ROOT + ).flatMap(findBoundaryViolations); + + expect(violations).toEqual([]); + }); + + it.each([ + ['import declaration', "import '@angular/core';", '@angular/core'], + [ + 're-export declaration', + "export * from '@iptvnator/ui/components';", + '@iptvnator/ui/components', + ], + [ + 'import type node', + "type AngularType = import('@angular/core').Type;", + '@angular/core', + ], + [ + 'dynamic import', + "void import('@ngx-pwa/service-worker');", + '@ngx-pwa/service-worker', + ], + [ + 'import-equals declaration', + "import Electron = require('electron'); void Electron;", + 'electron', + ], + ])('rejects forbidden sources in a %s', (_label, source, moduleName) => { + const violations = findFixtureViolations(source); + + expect(violations).toEqual([expect.stringContaining(moduleName)]); + }); + + it('allows non-forbidden sources in every supported import form', () => { + const violations = findFixtureViolations(` + import './imported'; + export * from './exported'; + type LocalType = import('./typed').Type; + void import('./dynamic'); + import Local = require('./required'); + void Local; + `); + + expect(violations).toEqual([]); + }); + + it.each([ + ['direct identifier', 'void navigator;', 'navigator'], + ['direct storage identifier', 'void indexedDB;', 'indexedDB'], + [ + 'globalThis property access', + 'void globalThis.MediaSource;', + 'MediaSource', + ], + ['self property access', 'void self.indexedDB;', 'indexedDB'], + [ + 'globalThis element access', + "void globalThis['localStorage'];", + 'localStorage', + ], + [ + 'self element access', + "void self['sessionStorage'];", + 'sessionStorage', + ], + [ + 'globalThis destructuring', + 'const { navigator } = globalThis;', + 'navigator', + ], + [ + 'self destructuring', + 'const { indexedDB: database } = self; void database;', + 'indexedDB', + ], + ])( + 'rejects forbidden browser globals through %s', + (_label, source, name) => { + const violations = findFixtureViolations(source); + + expect(violations).toEqual([expect.stringContaining(name)]); + } + ); + + it.each([ + ['property access', 'void source.navigator;'], + ['element access', "void source['indexedDB'];"], + ['destructuring', 'const { navigator } = source;'], + [ + 'property declaration', + 'const config = { MediaSource: source }; void config;', + ], + ['shadowed self', 'const self = source; void self.MediaSource;'], + [ + 'local forbidden-name binding', + 'const navigator = source.navigator; void navigator;', + ], + ])('allows ordinary local %s names', (_label, source) => { + const violations = findFixtureViolations(source); + + expect(violations).toEqual([]); + }); +}); + +function findFixtureViolations(source: string): string[] { + const directory = mkdtempSync(join(tmpdir(), 'playback-util-boundary-')); + const filePath = join(directory, 'fixture.ts'); + try { + writeFileSync(filePath, `${source}\nexport {};`); + return findBoundaryViolations(filePath); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +} + +function collectProductionSources(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + return collectProductionSources(path); + } + return entry.isFile() && + entry.name.endsWith('.ts') && + !entry.name.endsWith('.spec.ts') && + !entry.name.endsWith('.test.ts') + ? [path] + : []; + }); +} + +function findBoundaryViolations(filePath: string): string[] { + const program = ts.createProgram({ + rootNames: [filePath], + options: { + lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'], + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + noResolve: true, + skipLibCheck: true, + target: ts.ScriptTarget.ESNext, + }, + }); + const sourceFile = program.getSourceFile(filePath); + if (!sourceFile) { + throw new Error(`Unable to parse ${filePath}`); + } + const checker = program.getTypeChecker(); + const violations: string[] = []; + + sourceFile.forEachChild(function visit(node): void { + const moduleSpecifier = getExternalModuleSpecifier(node); + if (moduleSpecifier && isForbiddenModuleSpecifier(moduleSpecifier)) { + violations.push(formatViolation(sourceFile, node, moduleSpecifier)); + } + for (const globalName of getForbiddenGlobalPropertyAccesses( + node, + checker, + sourceFile + )) { + violations.push(formatViolation(sourceFile, node, globalName)); + } + if ( + ts.isIdentifier(node) && + FORBIDDEN_GLOBALS.has(node.text) && + isAmbientIdentifierReference(node, checker, sourceFile) + ) { + violations.push(formatViolation(sourceFile, node, node.text)); + } + node.forEachChild(visit); + }); + + return violations; +} + +function getExternalModuleSpecifier(node: ts.Node): string | undefined { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier + ) { + return getStaticString(node.moduleSpecifier); + } + if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { + return getStaticString(node.argument.literal); + } + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword + ) { + return getStaticString(node.arguments[0]); + } + if ( + ts.isImportEqualsDeclaration(node) && + ts.isExternalModuleReference(node.moduleReference) + ) { + return getStaticString(node.moduleReference.expression); + } + return undefined; +} + +function getStaticString(node?: ts.Node): string | undefined { + return node && + (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) + ? node.text + : undefined; +} + +function isForbiddenModuleSpecifier(moduleSpecifier: string): boolean { + return FORBIDDEN_IMPORT_PREFIXES.some((prefix) => + moduleSpecifier.startsWith(prefix) + ); +} + +function getForbiddenGlobalPropertyAccesses( + node: ts.Node, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): string[] { + if ( + ts.isPropertyAccessExpression(node) && + isAmbientGlobalRoot(node.expression, checker, sourceFile) && + FORBIDDEN_GLOBALS.has(node.name.text) + ) { + return [node.name.text]; + } + if ( + ts.isElementAccessExpression(node) && + isAmbientGlobalRoot(node.expression, checker, sourceFile) + ) { + const propertyName = getStaticString(node.argumentExpression); + return propertyName && FORBIDDEN_GLOBALS.has(propertyName) + ? [propertyName] + : []; + } + if ( + ts.isVariableDeclaration(node) && + ts.isObjectBindingPattern(node.name) && + node.initializer && + isAmbientGlobalRoot(node.initializer, checker, sourceFile) + ) { + return node.name.elements.flatMap((element) => { + const propertyName = getBindingPropertyName(element); + return propertyName && FORBIDDEN_GLOBALS.has(propertyName) + ? [propertyName] + : []; + }); + } + return []; +} + +function getBindingPropertyName( + element: ts.BindingElement +): string | undefined { + const property = element.propertyName ?? element.name; + return ts.isIdentifier(property) || ts.isStringLiteral(property) + ? property.text + : undefined; +} + +function isAmbientGlobalRoot( + expression: ts.Expression, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): boolean { + return ( + ts.isIdentifier(expression) && + (expression.text === 'globalThis' || expression.text === 'self') && + isAmbientIdentifier(expression, checker, sourceFile) + ); +} + +function isAmbientIdentifierReference( + identifier: ts.Identifier, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): boolean { + return ( + !isPropertyName(identifier) && + isAmbientIdentifier(identifier, checker, sourceFile) + ); +} + +function isAmbientIdentifier( + identifier: ts.Identifier, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): boolean { + const symbol = checker.getSymbolAtLocation(identifier); + return !symbol?.declarations?.some( + (declaration) => declaration.getSourceFile() === sourceFile + ); +} + +function isPropertyName(identifier: ts.Identifier): boolean { + const { parent } = identifier; + return ( + (ts.isPropertyAccessExpression(parent) && parent.name === identifier) || + (ts.isBindingElement(parent) && + (parent.name === identifier || + parent.propertyName === identifier)) || + ((ts.isPropertyAssignment(parent) || + ts.isPropertyDeclaration(parent) || + ts.isPropertySignature(parent) || + ts.isMethodDeclaration(parent) || + ts.isMethodSignature(parent) || + ts.isEnumMember(parent)) && + parent.name === identifier) + ); +} + +function formatViolation( + sourceFile: ts.SourceFile, + node: ts.Node, + detail: string +): string { + const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart()); + return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}: ${detail}`; +} diff --git a/libs/playback/util/tsconfig.json b/libs/playback/util/tsconfig.json new file mode 100644 index 000000000..41d58cc4b --- /dev/null +++ b/libs/playback/util/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/playback/util/tsconfig.lib.json b/libs/playback/util/tsconfig.lib.json new file mode 100644 index 000000000..03dbd1884 --- /dev/null +++ b/libs/playback/util/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/playback/util/tsconfig.spec.json b/libs/playback/util/tsconfig.spec.json new file mode 100644 index 000000000..01174f48f --- /dev/null +++ b/libs/playback/util/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "ESNext", + "target": "ESNext", + "esModuleInterop": true, + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index f4e33afdb..3e50589e0 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -79,6 +79,7 @@ /> } @else { ({ @@ -113,6 +115,7 @@ class StubAudioPlayerComponent { template: '', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); @@ -311,6 +314,7 @@ describe('VideoPlayerComponent', () => { } as typeof window.electron; syncStoreState(null); + playlistId.set('playlist-1'); localStorage.removeItem('m3u-sidebar-width'); localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY); player.set(VideoPlayer.VideoJs); @@ -535,7 +539,9 @@ describe('VideoPlayerComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); expect(headerContext.action()).toBeNull(); }); @@ -564,12 +570,19 @@ describe('VideoPlayerComponent', () => { }, } as Channel); + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://archive.example.com/live.m3u8?utc=1', + title: 'Sample TV', + isLive: true, + headers: { + 'user-agent': 'IPTVnator Test', + Referer: 'https://referrer.example.com', + Origin: 'https://origin.example.com', + }, + }; component.handleExternalFallbackRequest({ player: 'mpv', - playback: { - streamUrl: 'https://archive.example.com/live.m3u8?utc=1', - title: 'Archive Sample', - }, + playback, diagnostic: { code: 'unsupported-codec', source: 'hls', @@ -577,19 +590,18 @@ describe('VideoPlayerComponent', () => { container: 'm3u8', audioCodecs: ['ac-3'], videoCodecs: ['avc1.64001f'], - externalFallbackRecommended: true, }, } satisfies PlaybackFallbackRequest); expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith( 'OPEN_MPV_PLAYER', - expect.objectContaining({ + { url: 'https://archive.example.com/live.m3u8?utc=1', title: 'Sample TV', 'user-agent': 'IPTVnator Test', referer: 'https://referrer.example.com', origin: 'https://origin.example.com', - }) + } ); }); @@ -640,12 +652,36 @@ describe('VideoPlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ); expect(playerView).not.toBeNull(); - const stub = - playerView.componentInstance as StubWebPlayerViewComponent; + const stub = playerView.componentInstance as StubWebPlayerViewComponent; expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled(); }); + it('owns a collision-safe live session key that ignores resolved URL changes', () => { + playlistId.set('playlist|one'); + syncStoreState({ ...sampleChannel, id: 'channel|one' } as Channel); + activePlaybackUrl.set('https://archive.example/first.m3u8'); + fixture.detectChanges(); + + const playerView = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + const expected = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist|one', + contentId: 'channel|one', + }); + expect(playerView.playbackSessionKey()).toBe(expected); + + activePlaybackUrl.set('https://archive.example/second.m3u8'); + fixture.detectChanges(); + expect(playerView.playbackSessionKey()).toBe(expected); + + syncStoreState({ ...sampleChannel, id: 'channel|two' } as Channel); + fixture.detectChanges(); + expect(playerView.playbackSessionKey()).not.toBe(expected); + }); + it('routes catch-up playback that resolves to a DASH URL inline as well', () => { syncStoreState(sampleChannel); activePlaybackUrl.set('http://localhost/archive/replay.mpd'); @@ -657,8 +693,7 @@ describe('VideoPlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ); expect(playerView).not.toBeNull(); - const stub = - playerView.componentInstance as StubWebPlayerViewComponent; + const stub = playerView.componentInstance as StubWebPlayerViewComponent; expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); }); @@ -675,9 +710,7 @@ describe('VideoPlayerComponent', () => { // The external-player guard declines DASH channels, so the inline // player must stay — otherwise the session has no player at all. expect( - fixture.debugElement.query( - By.directive(StubWebPlayerViewComponent) - ) + fixture.debugElement.query(By.directive(StubWebPlayerViewComponent)) ).not.toBeNull(); }); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 1a39c26fc..72714b01c 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -90,6 +90,7 @@ import { WebPlayerViewComponent, } from '@iptvnator/ui/playback'; import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components'; import { DataService, @@ -166,13 +167,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly activePlaybackUrl = this.store.selectSignal( selectActivePlaybackUrl ); - readonly activeEpgProgram = this.store.selectSignal( - selectActiveEpgProgram - ); + readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram); readonly activeEpgProgramOrNull = computed( () => this.activeEpgProgram() ?? null ); readonly activePlaylistId = this.playlistContext.resolvedPlaylistId; + readonly playbackSessionKey = computed(() => { + const sourceId = this.activePlaylistId(); + const contentId = this.activeChannel()?.id; + return sourceId && contentId !== undefined + ? createPlaybackSessionKey({ kind: 'live', sourceId, contentId }) + : ''; + }); readonly channels = this.store.selectSignal(selectChannels); readonly channelsLoading = this.store.selectSignal(selectChannelsLoading); readonly archivePlaybackAvailable = computed(() => @@ -235,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly epgChannelLogo = toSignal( toObservable(this.activeChannel).pipe( switchMap((channel) => { - const key = channel - ? resolveChannelEpgLookupKey(channel) - : ''; + const key = channel ? resolveChannelEpgLookupKey(channel) : ''; if (!key) { return of(''); } @@ -587,9 +591,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } const currentEpgProgram = epgProgram as - | EpgProgram - | null - | undefined; + EpgProgram | null | undefined; const currentIndex = channels.findIndex( (channel) => channel.url === activeChannel.url ); diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index ede3bcbd5..da7afe700 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -383,6 +383,10 @@ describe('UnifiedFavoritesDataService', () => { 'https://example.com/2.m3u8', 'https://example.com/1.m3u8', ]); + expect(items.map((item) => item.uid)).toEqual([ + 'm3u::m3u-1::https://example.com/2.m3u8', + 'm3u::m3u-1::https://example.com/1.m3u8', + ]); expect(items[1].channelId).toBe('channel-1'); expect(items[0].radio).toBe('true'); expect(items[0].m3uChannel).toBe(m3uChannels[1]); diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 6cf29a142..98573a237 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -195,6 +195,7 @@ describe('UnifiedRecentDataService', () => { expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ + uid: 'm3u::m3u-1::https://example.com/2.m3u8', sourceType: 'm3u', streamUrl: 'https://example.com/2.m3u8', channelId: 'channel-2', diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts new file mode 100644 index 000000000..6306dfb29 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.spec.ts @@ -0,0 +1,47 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; + +describe('createUnifiedLivePlaybackSessionKey', () => { + it('keeps an M3U key across transport changes and separates channels', () => { + const first = m3uItem({ + uid: 'm3u::pl-1::https://old.example/live.m3u8', + streamUrl: 'https://old.example/live.m3u8', + channelId: 'provider-channel-7', + }); + const expected = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'pl-1', + contentId: 'provider-channel-7', + }); + + expect(createUnifiedLivePlaybackSessionKey(first)).toBe(expected); + expect( + createUnifiedLivePlaybackSessionKey({ + ...first, + uid: 'm3u::pl-1::https://new.example/timeshift.m3u8', + streamUrl: 'https://new.example/timeshift.m3u8', + }) + ).toBe(expected); + expect( + createUnifiedLivePlaybackSessionKey({ + ...first, + channelId: 'provider-channel-8', + }) + ).not.toBe(expected); + }); +}); + +function m3uItem( + overrides: Partial +): UnifiedCollectionItem { + return { + uid: 'm3u::pl-1::channel', + name: 'Channel', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'pl-1', + playlistName: 'Playlist', + ...overrides, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts new file mode 100644 index 000000000..22f93442e --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-playback-session-key.ts @@ -0,0 +1,23 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; + +export function createUnifiedLivePlaybackSessionKey( + item: UnifiedCollectionItem | null +): string { + const sourceId = item?.playlistId.trim() ?? ''; + const contentId = item ? getUnifiedLiveContentId(item) : ''; + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'live', sourceId, contentId }) + : ''; +} + +function getUnifiedLiveContentId(item: UnifiedCollectionItem): string { + switch (item.sourceType) { + case 'm3u': + return item.channelId?.trim() || item.m3uChannel?.id?.trim() || ''; + case 'xtream': + return item.xtreamId == null ? '' : String(item.xtreamId); + case 'stalker': + return item.stalkerId == null ? '' : String(item.stalkerId).trim(); + } +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index df9ce8167..94e3de217 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -59,6 +59,7 @@ /> } @else { (); readonly streamUrl = input.required(); readonly title = input(''); readonly playback = input(null); @@ -586,6 +585,13 @@ describe('UnifiedLiveTabComponent', () => { expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8'); expect(webPlayer.title()).toBe('Xtream Live'); + const sourceId = item.playlistId; + const contentId = String(item.xtreamId); + const key = sessionKey({ kind: 'live', sourceId, contentId }); + expect(webPlayer.playbackSessionKey()).toBe(key); + component.activeItem.set({ ...item, playlistId: 'p2', uid: 'c2' }); + expect(component.playbackSessionKey()).not.toBe(key); + component.activeItem.set(item); const playback = webPlayer.playback(); expect(playback).toEqual( expect.objectContaining({ @@ -597,15 +603,10 @@ describe('UnifiedLiveTabComponent', () => { throw new Error('Expected wrapper playback to be set'); } expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs); - expect( - fixture.nativeElement.querySelector('app-vjs-player') - ).toBeNull(); - expect( - fixture.nativeElement.querySelector('app-html-video-player') - ).toBeNull(); - expect( - fixture.nativeElement.querySelector('app-art-player') - ).toBeNull(); + const host = fixture.nativeElement; + expect(host.querySelector('app-vjs-player')).toBeNull(); + expect(host.querySelector('app-html-video-player')).toBeNull(); + expect(host.querySelector('app-art-player')).toBeNull(); webPlayer.externalFallbackRequested.emit({ player: 'mpv', @@ -618,16 +619,13 @@ describe('UnifiedLiveTabComponent', () => { mimeType: '', videoCodecs: [], audioCodecs: [], - externalFallbackRecommended: false, }, }); - expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith( - expect.objectContaining({ - streamUrl: 'https://example.com/xtream.m3u8', - }), - 'mpv' - ); + const [forwardedPlayback, forwardedPlayer] = + portalPlayer.openExternalPlayback.mock.calls[0]; + expect(forwardedPlayback).toBe(playback); + expect(forwardedPlayer).toBe('mpv'); }); it('does not wait for M3U program lookup before opening playback', async () => { @@ -749,9 +747,9 @@ describe('UnifiedLiveTabComponent', () => { // auth-gated streams) before the audio element gets the URL, and // clears it again on close. const setUserAgent = jest.fn().mockResolvedValue(true); - ( - window.electron as unknown as Record - )['setUserAgent'] = setUserAgent; + (window.electron as unknown as Record)[ + 'setUserAgent' + ] = setUserAgent; const item = { ...buildLiveItem('stalker'), name: 'Jazz Radio', @@ -1177,6 +1175,7 @@ describe('UnifiedLiveTabComponent', () => { 'https://example.com/timeshift.m3u8' ); const item = await selectXtreamArchiveChannel(); + const key = component.playbackSessionKey(); component.onTimelineProgramActivated(timeshiftEvent()); await fixture.whenStable(); @@ -1192,6 +1191,7 @@ describe('UnifiedLiveTabComponent', () => { 'https://example.com/timeshift.m3u8' ); expect(component.inlinePlayback()?.isLive).toBe(false); + expect(component.playbackSessionKey()).toBe(key); expect(component.activeTimeshiftProgram()?.title).toBe( 'Xtream Show' ); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 677c90f73..e95223f81 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -62,6 +62,7 @@ import { import { ResizableDirective } from '@iptvnator/ui/components'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { EpgProgram } from '@iptvnator/shared/interfaces'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; @Component({ selector: 'app-unified-live-tab', @@ -123,6 +124,9 @@ export class UnifiedLiveTabComponent { * portal archive fields (tvArchive/tvArchiveDuration) and to * supply credentials for Xtream catch-up URL resolution. */ readonly activeItem = signal(null); + readonly playbackSessionKey = computed(() => + createUnifiedLivePlaybackSessionKey(this.activeItem()) + ); readonly isSelecting = signal(false); readonly epgMap = signal>(new Map()); readonly progressTick = signal(0); @@ -266,9 +270,7 @@ export class UnifiedLiveTabComponent { return getLiveEpgPanelSummary(this.activeDetail()); }); readonly liveEpgPanelSummaryLabelKey = computed(() => - this.activeTimeshift() - ? 'EPG.ARCHIVE_PLAYBACK' - : 'EPG.CURRENT_PROGRAM' + this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM' ); readonly channelsForList = computed((): UnifiedFavoriteChannel[] => @@ -480,10 +482,7 @@ export class UnifiedLiveTabComponent { program.startTimestamp, program.start ); - const stopEpoch = toEpochSeconds( - program.stopTimestamp, - program.stop - ); + const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop); if (startEpoch == null || stopEpoch == null) { this.snackBar.open( this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'), @@ -709,5 +708,4 @@ export class UnifiedLiveTabComponent { }; }); } - } diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html index 74eeca1f1..39c85e94a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html @@ -8,6 +8,7 @@ } @else if (vodDetailsItem()) { () { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} @Component({ selector: 'app-vod-details', template: '' }) class StubVodDetailsComponent { + readonly playbackSessionKey = input.required(); readonly item = input.required(); readonly providerOnly = input(false); readonly isFavorite = input(false); @@ -47,7 +59,15 @@ class StubStalkerSeriesViewComponent { describe('StalkerCatalogDetailComponent provider presentation', () => { let fixture: ComponentFixture; + const resolveVodPlayback = jest.fn(); + const portalPlayer = { + isEmbeddedPlayer: jest.fn(() => true), + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }; const contentType = signal<'vod' | 'series'>('vod'); + const catalogPlaylist = signal({ id: 'stalker-1' }); + const snackBar = { open: jest.fn() }; const selectedItem = signal({ id: '42', cmd: '/media/42', @@ -61,6 +81,10 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { cmd: '/media/42', info: { name: 'Portal movie' }, }); + resolveVodPlayback.mockReset(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + catalogPlaylist.set({ id: 'stalker-1' }); + snackBar.open.mockReset(); await TestBed.configureTestingModule({ imports: [StalkerCatalogDetailComponent], @@ -70,8 +94,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { useValue: { contentType, selectedItem, - playlist: signal({ id: 'stalker-1' }), + playlist: catalogPlaylist, clearSelectedItem: jest.fn(), + resolveVodPlayback, }, }, { @@ -83,7 +108,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { }, { provide: PORTAL_PLAYER, - useValue: { isEmbeddedPlayer: jest.fn() }, + useValue: portalPlayer, }, { provide: PORTAL_EXTERNAL_PLAYBACK, @@ -99,7 +124,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { }, { provide: DownloadsService, useValue: {} }, { provide: Router, useValue: { navigateByUrl: jest.fn() } }, - { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: MatSnackBar, useValue: snackBar }, { provide: TranslateService, useValue: { @@ -139,6 +164,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { ).componentInstance as StubVodDetailsComponent; expect(fixture.componentInstance.providerOnly()).toBe(true); expect(child.providerOnly()).toBe(true); + expect(child.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '42', + }) + ); }); it('keeps provider-only presentation disabled for a regular VOD open', async () => { @@ -154,6 +186,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { ).componentInstance as StubVodDetailsComponent; expect(fixture.componentInstance.providerOnly()).toBe(false); expect(child.providerOnly()).toBe(false); + expect(child.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '99', + }) + ); }); it.each([ @@ -178,4 +217,106 @@ describe('StalkerCatalogDetailComponent provider presentation', () => { expect(child.providerOnly()).toBe(true); } ); + + it('does not mount a VOD resolution after the catalog owner changes', async () => { + let resolve!: (value: { streamUrl: string }) => void; + resolveVodPlayback.mockReturnValueOnce( + new Promise((resolvePromise) => { + resolve = resolvePromise; + }) + ); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay({ + type: 'stalker', + cmd: '/media/42', + data: selectedItem(), + } as never); + + selectedItem.set({ + id: '99', + cmd: '/media/99', + info: { name: 'Replacement movie' }, + }); + fixture.detectChanges(); + resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + }); + + it('clears committed VOD playback when the playlist changes with the same provider id', async () => { + const playback = { streamUrl: 'https://a.example/movie.mpg' }; + resolveVodPlayback.mockResolvedValue(playback); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + + catalogPlaylist.set({ id: 'stalker-2' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-2', + contentId: '42', + }) + ); + }); + + it('does not mount a pending VOD resolution after only the playlist owner changes', async () => { + const pending = deferred<{ streamUrl: string }>(); + resolveVodPlayback.mockReturnValue(pending.promise); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + + catalogPlaylist.set({ id: 'stalker-2' }); + fixture.detectChanges(); + pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + }); + + it('suppresses a pending VOD error after the playlist owner changes', async () => { + const pending = deferred<{ streamUrl: string }>(); + resolveVodPlayback.mockReturnValue(pending.promise); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + + catalogPlaylist.set({ id: 'stalker-2' }); + fixture.detectChanges(); + pending.reject(new Error('stale playlist failure')); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('preserves committed playback across a same-owner item refresh', async () => { + const playback = { streamUrl: 'https://a.example/movie.mpg' }; + resolveVodPlayback.mockResolvedValue(playback); + fixture.detectChanges(); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + + selectedItem.set({ + ...(selectedItem() as Record), + screenshot_uri: 'refreshed.jpg', + }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 80c0f77a3..804e3d111 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -23,6 +23,7 @@ import { createRefreshTrigger, isStalkerSeriesFlag, isSelectedStalkerVodFavorite, + normalizeStalkerEntityId, StalkerSelectedVodItem, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; @@ -46,6 +47,7 @@ import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service'; import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component'; import { startStalkerVodDownload } from './stalker-vod-download'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @Component({ selector: 'app-stalker-catalog-detail', @@ -76,6 +78,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy { private readonly downloadsService = inject(DownloadsService); private readonly logger = createLogger('StalkerCatalogDetail'); private readonly favoritesRefresh = createRefreshTrigger(); + private playbackRequestId = 0; + private currentPlaybackOwnerKey = ''; readonly contentType = this.catalog.contentType; readonly selectedItem = computed( @@ -85,6 +89,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy { ); readonly inlinePlayback = signal(null); readonly providerOnly = input(false); + readonly playbackSessionKey = computed(() => { + const sourceId = this.catalog.playlist()?.id; + const contentId = normalizeStalkerEntityId(this.selectedItem()?.id); + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) + : ''; + }); + private readonly playbackOwnerKey = computed(() => + JSON.stringify([this.playbackSessionKey(), this.contentType()]) + ); private readonly selectedVodPosition = signal( null ); @@ -147,8 +161,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy { }); effect(() => { - const selectedItemId = this.selectedItem()?.id; - void selectedItemId; + const ownerKey = this.playbackOwnerKey(); + if (ownerKey === this.currentPlaybackOwnerKey) return; + this.currentPlaybackOwnerKey = ownerKey; this.closeInlinePlayer(); }); @@ -236,6 +251,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy { } closeInlinePlayer(): void { + this.playbackRequestId += 1; this.inlinePlayback.set(null); this.positionWriter.reset(); } @@ -305,6 +321,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy { thumbnail?: string, startTime?: number ): Promise { + const requestId = ++this.playbackRequestId; + const sessionKey = this.playbackSessionKey(); + const ownerKey = this.playbackOwnerKey(); + const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer(); + if (usesEmbeddedPlayer && !sessionKey) return; + try { const playback = await this.catalog.resolveVodPlayback( cmd, @@ -312,9 +334,15 @@ export class StalkerCatalogDetailComponent implements OnDestroy { thumbnail, startTime ); + if ( + requestId !== this.playbackRequestId || + this.playbackOwnerKey() !== ownerKey + ) { + return; + } this.positionWriter.reset(); - if (this.portalPlayer.isEmbeddedPlayer()) { + if (usesEmbeddedPlayer) { this.inlinePlayback.set(playback); return; } @@ -322,6 +350,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy { this.closeInlinePlayer(); void this.portalPlayer.openResolvedPlayback(playback, true); } catch (error) { + if ( + requestId !== this.playbackRequestId || + this.playbackOwnerKey() !== ownerKey + ) { + return; + } this.logger.error('Failed to start inline VOD playback', error); const errorMessage = error instanceof Error && error.message === 'nothing_to_play' diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts index ec7278c47..8a450154d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.spec.ts @@ -1,5 +1,6 @@ import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; import { TranslateService } from '@ngx-translate/core'; import { ContentHeroComponent } from '@iptvnator/ui/components'; @@ -20,6 +21,7 @@ import { import { of } from 'rxjs'; import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @Component({ selector: 'app-content-hero', @@ -36,6 +38,7 @@ class StubContentHeroComponent { template: '', }) class StubStalkerInlineDetailComponent { + readonly playbackSessionKey = input.required(); readonly categoryId = input<'vod' | 'series' | null>(null); readonly seriesItem = input(null); readonly isSeries = input(false); @@ -89,6 +92,8 @@ describe('StalkerCollectionDetailComponent', () => { getAllPlaybackPositions: jest.Mock; clearPlaybackPosition: jest.Mock; }; + let snackBar: { open: jest.Mock }; + let playlistsService: { getPlaylistById: jest.Mock }; const playlist = { _id: 'stalker-1', @@ -140,6 +145,12 @@ describe('StalkerCollectionDetailComponent', () => { getAllPlaybackPositions: jest.fn(), clearPlaybackPosition: jest.fn(), }; + snackBar = { open: jest.fn() }; + playlistsService = { + getPlaylistById: jest.fn((playlistId: string) => + of({ ...playlist, _id: playlistId }) + ), + }; await TestBed.configureTestingModule({ imports: [StalkerCollectionDetailComponent], @@ -167,9 +178,7 @@ describe('StalkerCollectionDetailComponent', () => { }, { provide: MatSnackBar, - useValue: { - open: jest.fn(), - }, + useValue: snackBar, }, { provide: TranslateService, @@ -185,7 +194,7 @@ describe('StalkerCollectionDetailComponent', () => { { provide: PlaylistsService, useValue: { - getPlaylistById: jest.fn(() => of(playlist)), + getPlaylistById: playlistsService.getPlaylistById, getPortalFavorites: jest.fn(() => of([])), }, }, @@ -315,9 +324,7 @@ describe('StalkerCollectionDetailComponent', () => { ).toEqual([1, 2]); // The snapshot renders immediately, but a background portal // re-fetch must be triggered so new episodes can appear. - expect( - stalkerStore.refreshEmbeddedSeriesSelection - ).toHaveBeenCalled(); + expect(stalkerStore.refreshEmbeddedSeriesSelection).toHaveBeenCalled(); }); it('shows newly released episodes when the background snapshot refresh patches the selection', async () => { @@ -393,6 +400,131 @@ describe('StalkerCollectionDetailComponent', () => { expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled(); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); expect(fixture.componentInstance.inlinePlayback()).toEqual(playback); + const detail = fixture.debugElement.query( + By.directive(StubStalkerInlineDetailComponent) + ).componentInstance as StubStalkerInlineDetailComponent; + expect(detail.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '1701', + }) + ); + }); + + it('ignores a pending payload after the collection detail owner changes and lets the new owner commit', async () => { + const pendingA = deferred(); + const pendingB = deferred(); + stalkerStore.resolveVodPlayback.mockImplementation((cmd: string) => + cmd.includes('1701') ? pendingA.promise : pendingB.promise + ); + const itemA = buildCollectionItem({ + stalkerId: '1701', + stalkerItem: buildVodSource('1701', 'Movie A'), + }); + const itemB = buildCollectionItem({ + stalkerId: '1702', + stalkerItem: buildVodSource('1702', 'Movie B'), + }); + + fixture.componentRef.setInput('item', itemA); + await settleDetail(fixture); + fixture.componentInstance.onVodPlay( + createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId) + ); + await Promise.resolve(); + + fixture.componentRef.setInput('item', itemB); + await settleDetail(fixture); + pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' }); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-1', + contentId: '1702', + }) + ); + + fixture.componentInstance.onVodPlay( + createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId) + ); + pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' }); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toEqual({ + streamUrl: 'https://streams.test/b.mp4', + }); + }); + + it('suppresses a pending error after the collection playlist owner changes', async () => { + const pending = deferred(); + stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise); + const source = buildVodSource('1701', 'Movie A'); + const itemA = buildCollectionItem({ + playlistId: 'stalker-1', + stalkerId: '1701', + stalkerItem: source, + }); + const itemB = buildCollectionItem({ + playlistId: 'stalker-2', + stalkerId: '1701', + stalkerItem: { ...source }, + }); + + fixture.componentRef.setInput('item', itemA); + await settleDetail(fixture); + fixture.componentInstance.onVodPlay( + createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId) + ); + await Promise.resolve(); + + fixture.componentRef.setInput('item', itemB); + await settleDetail(fixture); + pending.reject(new Error('stale A failure')); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'stalker-2', + contentId: '1701', + }) + ); + }); + + it('keeps a pending request when the same canonical owner is refreshed with a new object', async () => { + const pending = deferred(); + stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise); + const item = buildCollectionItem({ + stalkerId: '1701', + stalkerItem: buildVodSource('1701', 'Movie A'), + }); + + fixture.componentRef.setInput('item', item); + await settleDetail(fixture); + fixture.componentInstance.onVodPlay( + createStalkerVodItem(item.stalkerItem as never, item.playlistId) + ); + await Promise.resolve(); + + fixture.componentRef.setInput('item', { + ...item, + stalkerItem: { ...(item.stalkerItem as object) }, + }); + await settleDetail(fixture); + pending.resolve({ streamUrl: 'https://streams.test/a.mp4' }); + await settleDetail(fixture); + + expect(fixture.componentInstance.inlinePlayback()).toEqual({ + streamUrl: 'https://streams.test/a.mp4', + }); + expect(snackBar.open).not.toHaveBeenCalled(); }); it('does not load VOD playback position when the playlist id is missing', async () => { @@ -444,6 +576,26 @@ function buildCollectionItem( }; } +function buildVodSource(id: string, title: string) { + return { + id, + title, + category_id: 'vod', + cmd: `/media/file_${id}.mpg`, + info: { name: title }, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + async function settleDetail( fixture: ComponentFixture ): Promise { diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 370867993..a8e575952 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -48,6 +48,7 @@ import { import { firstValueFrom } from 'rxjs'; import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component'; import { StalkerVodPlaybackController } from './stalker-vod-playback-controller'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; interface StalkerCollectionStateSnapshot { currentPlaylist: Playlist | undefined; @@ -65,12 +66,19 @@ interface StalkerCollectionDetailMode { needsSeriesFetch: boolean; } +interface StalkerCollectionPlaybackOwner { + readonly sourceId: string; + readonly contentId: string; + readonly sessionKey: string; +} + @Component({ selector: 'app-stalker-collection-detail', imports: [PortalDetailShellComponent, StalkerInlineDetailComponent], template: ` @if (inlineDetail().categoryId) { (null); readonly vodDetailsItem = signal(null); readonly inlinePlayback = signal(null); + private readonly playbackOwner = computed(() => + captureStalkerCollectionPlaybackOwner(this.item()) + ); + readonly playbackSessionKey = computed( + () => this.playbackOwner()?.sessionKey ?? '' + ); private readonly selectedVodPosition = signal( null ); @@ -149,6 +163,7 @@ export class StalkerCollectionDetailComponent { ); private initRequestId = 0; + private currentPlaybackOwnerKey = ''; private readonly vodPlayback = new StalkerVodPlaybackController({ inlinePlayback: this.inlinePlayback, selectedVodPosition: this.selectedVodPosition, @@ -158,6 +173,7 @@ export class StalkerCollectionDetailComponent { translateService: this.translateService, logger: this.logger, playbackErrorLogMessage: 'Failed to start collection VOD playback', + playbackOwnerKey: () => this.playbackSessionKey(), }); constructor() { @@ -168,7 +184,12 @@ export class StalkerCollectionDetailComponent { effect(() => { const item = this.item(); + const playbackOwnerKey = this.playbackSessionKey(); untracked(() => { + if (playbackOwnerKey !== this.currentPlaybackOwnerKey) { + this.currentPlaybackOwnerKey = playbackOwnerKey; + this.closeInlinePlayer(); + } void this.prepareDetail(item); }); }); @@ -490,3 +511,31 @@ export class StalkerCollectionDetailComponent { await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId); } } + +function captureStalkerCollectionPlaybackOwner( + item: UnifiedCollectionItem | null +): StalkerCollectionPlaybackOwner | null { + if (!item) return null; + + const sourceId = item.playlistId.trim(); + const providerItem = item.stalkerItem as + { id?: unknown; stream_id?: unknown } | undefined; + const uidParts = item.uid.split('::'); + const contentId = normalizeStalkerEntityId( + providerItem?.id ?? + providerItem?.stream_id ?? + item.stalkerId ?? + uidParts[uidParts.length - 1] + ); + if (!sourceId || !contentId) return null; + + return Object.freeze({ + sourceId, + contentId, + sessionKey: createPlaybackSessionKey({ + kind: 'vod', + sourceId, + contentId, + }), + }); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html index b97e8e102..00997ac87 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html @@ -15,6 +15,7 @@ } @else if (vodDetailsItem(); as item) { (); readonly item = input.required(); readonly providerOnly = input(false); readonly isFavorite = input(false); @@ -57,6 +58,10 @@ describe('StalkerInlineDetailComponent provider presentation', () => { .compileComponents(); fixture = TestBed.createComponent(StalkerInlineDetailComponent); fixture.componentRef.setInput('providerOnly', true); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'collection-owned-vod-key' + ); }); afterEach(() => fixture.destroy()); @@ -70,6 +75,7 @@ describe('StalkerInlineDetailComponent provider presentation', () => { By.directive(StubVodDetailsComponent) ).componentInstance as StubVodDetailsComponent; expect(child.providerOnly()).toBe(true); + expect(child.playbackSessionKey()).toBe('collection-owned-vod-key'); }); it.each([ diff --git a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts index 37cfaf1ef..10fe38211 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts @@ -32,6 +32,7 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie ], }) export class StalkerInlineDetailComponent { + readonly playbackSessionKey = input.required(); readonly categoryId = input<'vod' | 'series' | null>(null); readonly seriesItem = input(null); readonly isSeries = input(false); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 7e53dea5b..b8bc99d2d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -32,7 +32,9 @@ mat-icon-button type="button" [disabled]="isFullListLoading()" - [attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate" + [attr.aria-label]=" + 'PORTALS.REFRESH_CHANNEL_LIST' | translate + " [matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate" (click)="refreshChannels()" > @@ -83,7 +85,9 @@
} @else { @@ -120,7 +124,9 @@ (clicked)="playChannel(item)" (activated)="playChannel(item, true)" (favoriteToggled)="toggleFavorite(item)" - (contextMenuRequested)="onChannelContextMenu(item, $event)" + (contextMenuRequested)=" + onChannelContextMenu(item, $event) + " /> } } @@ -180,6 +186,7 @@ /> } @else { (); readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); - readonly externalFallbackRequested = output(); + readonly externalFallbackRequested = output(); } @Component({ @@ -202,9 +206,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { loaded: number; total: number; } | null>(null); - const itvFullChannelList = signal< - ReturnType - >([]); + const itvFullChannelList = signal>( + [] + ); const itvSelectedCategoryFromCache = signal(false); const isPaginatedContentLoading = signal(false); @@ -266,6 +270,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { const portalPlayer = { isEmbeddedPlayer: jest.fn(() => true), openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), }; const settingsStore = { openStreamOnDoubleClick: signal(false), @@ -277,6 +282,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { // The store mock is module-scoped: reset so a failed test can't leak // 'list' into siblings. settingsStore.resolvedEpgViewMode.set('timeline'); + playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' }); window.electron = { platform: 'darwin', setUserAgent: jest.fn().mockResolvedValue(true), @@ -327,6 +333,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { portalPlayer.isEmbeddedPlayer.mockReset(); portalPlayer.isEmbeddedPlayer.mockReturnValue(true); portalPlayer.openResolvedPlayback.mockClear(); + portalPlayer.openExternalPlayback.mockClear(); fetchChannelEpg.mockReset(); fetchChannelEpg.mockResolvedValue([]); ensureBulkItvEpg.mockReset(); @@ -407,9 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => { useValue: { supportsEpgMapping: false, getEpgMapping: jest.fn().mockResolvedValue(null), - getEpgMappingsBatch: jest - .fn() - .mockResolvedValue(null), + getEpgMappingsBatch: jest.fn().mockResolvedValue(null), }, }, ], @@ -503,7 +508,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { fixture.nativeElement.querySelector('app-web-player-view') ).not.toBeNull(); expect(fixture.nativeElement.querySelector('.epg')).toBeNull(); - expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); const channelRows = fixture.debugElement.queryAll( By.directive(StubChannelListItemComponent) ); @@ -526,6 +533,43 @@ describe('StalkerLiveStreamLayoutComponent', () => { ).toBe(true); }); + it('forwards the exact resolved live playback to external fallback', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.com/stalker-fallback.m3u8', + title: 'Alpha TV', + isLive: true, + headers: { Authorization: 'Bearer token' }, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 10001, + contentType: 'live', + }, + }; + const request: PlaybackFallbackRequest = { + player: 'vlc', + playback, + diagnostic: { + code: 'network-error', + player: 'html5', + source: 'hls', + container: '', + mimeType: '', + videoCodecs: [], + audioCodecs: [], + }, + }; + + component.handleExternalFallbackRequest(request); + + expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith( + playback, + 'vlc' + ); + expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe( + playback + ); + }); + it('renders radio channel rows with compact no-EPG density', () => { stalkerStore.selectedContentType.set('radio'); @@ -1115,7 +1159,9 @@ describe('StalkerLiveStreamLayoutComponent', () => { expect(ensureBulkItvEpg).not.toHaveBeenCalled(); expect(fetchChannelEpg).not.toHaveBeenCalled(); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); - expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull(); + expect( + fixture.nativeElement.querySelector('app-epg-timeline') + ).toBeNull(); expect( fixture.nativeElement.querySelector('app-audio-player') ).not.toBeNull(); @@ -1185,14 +1231,14 @@ describe('StalkerLiveStreamLayoutComponent', () => { ); // Once: only the apply call gets the pending promise — the destroy // clear below also calls the bridge and must not steal the resolver. - ( - window.electron?.setUserAgent as jest.Mock - ).mockImplementationOnce(() => { - signalApplyIssued(); - return new Promise((resolve) => { - resolveApply = () => resolve(true); - }); - }); + (window.electron?.setUserAgent as jest.Mock).mockImplementationOnce( + () => { + signalApplyIssued(); + return new Promise((resolve) => { + resolveApply = () => resolve(true); + }); + } + ); resolveRadioPlayback.mockResolvedValue({ streamUrl: 'http://portal.example/radio_2.mpg', title: 'Portal FM', diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts index e67469273..3b795996c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts @@ -76,12 +76,24 @@ import { normalizeStalkerEntityId, } from '@iptvnator/portal/stalker/data-access'; import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; type StalkerPlayableChannel = StalkerPortalItem & { cmd?: string; has_files?: unknown; }; +interface StalkerActiveLivePlaybackIdentity { + readonly sourceId: string; + readonly contentId: string; +} + +interface StalkerPlaybackResolutionOwner { + readonly sourceId: string; + readonly contentType: string; + readonly channelId: string; +} + /** Channels rendered per "page" when the full list is served from the cache. */ const FULL_LIST_RENDER_CHUNK = 100; @@ -270,6 +282,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.portalPlayer.isEmbeddedPlayer() ); readonly activePlayback = signal(null); + private readonly activePlaybackIdentity = + signal(null); + readonly playbackSessionKey = computed(() => { + const identity = this.activePlaybackIdentity(); + return identity + ? createPlaybackSessionKey({ kind: 'live', ...identity }) + : ''; + }); readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? ''); readonly activePlaybackTitle = computed( () => @@ -366,7 +386,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { /** Stream URL of the radio playback whose header override this layout configured. */ private radioHeaderScopeUrl: string | null = null; private playbackResolution: { - channelId: string; + ownerKey: string; promise: Promise; } | null = null; private lastPlaylistId: string | null | undefined = undefined; @@ -414,9 +434,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { untracked(() => { if (contentType === 'radio') { this.stalkerStore.setRadioChannels([]); - } else if ( - !this.stalkerStore.itvSelectedCategoryFromCache() - ) { + } else if (!this.stalkerStore.itvSelectedCategoryFromCache()) { this.stalkerStore.setItvChannels([]); } this.stalkerStore.setPage(0); @@ -449,8 +467,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // store dedupes per channel id, so this is cheap on rerenders. if (this.supportsEpgMapping && channels.length > 0) { const channelIds = channels.map((channel) => channel.id); - untracked(() => - void this.stalkerStore.applyMappedItvEpg(channelIds) + untracked( + () => void this.stalkerStore.applyMappedItvEpg(channelIds) ); } }); @@ -567,6 +585,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { // override slot. this.playbackRequestId += 1; this.streamHeaders.clear(this.radioHeaderScopeUrl); + this.clearActivePlayback(); } isSelectedChannel(item: StalkerItvChannel): boolean { @@ -581,6 +600,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { ) { const requestId = ++this.playbackRequestId; const channelId = normalizeStalkerEntityId(item.id); + const sourceId = normalizeStalkerEntityId( + this.stalkerStore.currentPlaylist()?._id + ); + const contentType = this.stalkerStore.selectedContentType(); + const isRadioMode = this.isRadioMode(); this.stalkerStore.setSelectedItem(item); this.ensureChannelWithinRenderWindow(channelId); // A previously owned radio override must not survive into a @@ -591,15 +615,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { this.radioHeaderScopeUrl = null; try { - const isRadioMode = this.isRadioMode(); const playback = await this.resolvePlaybackForChannel( item, - channelId + { sourceId, contentType, channelId }, + isRadioMode ); - if ( - requestId !== this.playbackRequestId || - this.selectedChannelId() !== channelId - ) { + const owner = { sourceId, contentType, channelId }; + if (!this.isPlaybackRequestCurrent(requestId, owner)) { return; } @@ -618,12 +640,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { const stillCurrent = headerSync ? await headerSync : true; if ( !stillCurrent || - requestId !== this.playbackRequestId || - this.selectedChannelId() !== channelId + !this.isPlaybackRequestCurrent(requestId, owner) ) { return; } - this.activePlayback.set(playback); + this.setActivePlayback(playback, null); return; } @@ -632,12 +653,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { } if (this.usesEmbeddedPlayer()) { - this.activePlayback.set(playback); + if (!sourceId || !channelId) return; + this.setActivePlayback(playback, { + sourceId, + contentId: channelId, + }); } else if (startPlayback) { void this.portalPlayer.openResolvedPlayback(playback, true); } } catch (error) { - if (requestId !== this.playbackRequestId) { + if ( + !this.isPlaybackRequestCurrent(requestId, { + sourceId, + contentType, + channelId, + }) + ) { return; } @@ -652,21 +683,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { private resolvePlaybackForChannel( item: StalkerItvChannel, - channelId: string + owner: StalkerPlaybackResolutionOwner, + isRadioMode: boolean ): Promise { - const playbackChannelId = [ - this.stalkerStore.selectedContentType(), - channelId, - ].join(':'); - if (this.playbackResolution?.channelId === playbackChannelId) { + const ownerKey = JSON.stringify(owner); + if (this.playbackResolution?.ownerKey === ownerKey) { return this.playbackResolution.promise; } const playableItem = this.toPlayableChannel(item); - const promise = this.isRadioMode() + const promise = isRadioMode ? this.stalkerStore.resolveRadioPlayback(playableItem) : this.stalkerStore.resolveItvPlayback(playableItem); - this.playbackResolution = { channelId: playbackChannelId, promise }; + this.playbackResolution = { ownerKey, promise }; const cleanup = () => { if (this.playbackResolution?.promise === promise) { @@ -679,6 +708,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy { return promise; } + private isPlaybackRequestCurrent( + requestId: number, + owner: StalkerPlaybackResolutionOwner + ): boolean { + return ( + requestId === this.playbackRequestId && + this.selectedChannelId() === owner.channelId && + normalizeStalkerEntityId( + this.stalkerStore.currentPlaylist()?._id + ) === owner.sourceId && + this.stalkerStore.selectedContentType() === owner.contentType + ); + } + + private setActivePlayback( + playback: ResolvedPortalPlayback, + identity: StalkerActiveLivePlaybackIdentity | null + ): void { + this.activePlaybackIdentity.set(identity); + this.activePlayback.set(playback); + } + + private clearActivePlayback(): void { + this.activePlaybackIdentity.set(null); + this.activePlayback.set(null); + } + toggleFavorite(item: StalkerItvChannel) { const itemId = normalizeStalkerEntityId(item.id); if (this.favorites.has(itemId)) { diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts new file mode 100644 index 000000000..75a22b730 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.playback-session-key.spec.ts @@ -0,0 +1,294 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { + LiveLayoutSidebarStateService, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; +import { + PlaylistsService, + RuntimeCapabilitiesService, + SettingsStore, +} from '@iptvnator/services'; +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; +import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +describe('StalkerLiveStreamLayoutComponent playback session ownership', () => { + let fixture: ComponentFixture; + let component: StalkerLiveStreamLayoutComponent; + const playlist = signal({ _id: 'playlist-one', title: 'Portal One' }); + const channels = [ + { + id: 'channel-one', + cmd: 'ffrt4://itv/channel-one', + name: 'One', + o_name: 'One', + logo: 'one.png', + }, + { + id: 'channel-two', + cmd: 'ffrt4://itv/channel-two', + name: 'Two', + o_name: 'Two', + logo: 'two.png', + }, + ]; + const itvChannels = signal(channels); + const selectedItvId = signal(channels[0].id); + const selectedItem = signal<(typeof channels)[number] | null>(channels[0]); + const selectedContentType = signal<'itv' | 'radio'>('itv'); + const resolveItvPlayback = jest.fn(); + const snackBar = { open: jest.fn() }; + const store = { + getSelectedCategoryName: signal('All'), + currentPlaylist: playlist, + selectedContentType, + selectedCategoryId: signal('all'), + selectedItvId, + selectedItem, + itvChannels, + radioChannels: signal([]), + searchPhrase: signal(''), + hasMoreChannels: signal(false), + itvFullListActive: signal(false), + itvSelectedCategoryFromCache: signal(false), + itvFullListLoading: signal(false), + itvFullListProgress: signal(null), + itvFullChannelList: signal([]), + isPaginatedContentLoading: signal(false), + selectedItvEpgPrograms: signal([]), + bulkItvEpgByChannel: signal({}), + isLoadingBulkItvEpg: signal(false), + setItvChannels: jest.fn(), + setRadioChannels: jest.fn(), + setPage: jest.fn(), + preloadItvChannels: jest.fn(), + applyMappedItvEpg: jest.fn(), + clearBulkItvEpgCache: jest.fn(), + ensureBulkItvEpg: jest.fn(), + fetchChannelEpg: jest.fn(), + resolveItvPlayback, + resolveRadioPlayback: jest.fn(), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + setSelectedItem: jest.fn((item: (typeof channels)[number]) => { + selectedItem.set(item); + selectedItvId.set(String(item.id)); + }), + }; + + beforeEach(async () => { + playlist.set({ _id: 'playlist-one', title: 'Portal One' }); + selectedContentType.set('itv'); + selectedItvId.set(channels[0].id); + selectedItem.set(channels[0]); + resolveItvPlayback.mockReset(); + snackBar.open.mockReset(); + store.setSelectedItem.mockClear(); + await TestBed.configureTestingModule({ + imports: [StalkerLiveStreamLayoutComponent], + providers: [ + { provide: StalkerStore, useValue: store }, + { + provide: RuntimeCapabilitiesService, + useValue: { + supportsEpg: false, + isElectron: false, + supportsEpgMapping: false, + }, + }, + { + provide: PlaylistsService, + useValue: { getPortalFavorites: () => of([]) }, + }, + { + provide: SettingsStore, + useValue: { openStreamOnDoubleClick: signal(false) }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + }, + }, + { + provide: ElectronStreamHeadersService, + useValue: { apply: jest.fn(), clear: jest.fn() }, + }, + { + provide: LiveLayoutSidebarStateService, + useValue: { isCollapsed: signal(false), toggle: jest.fn() }, + }, + { provide: EpgRuntimeBridgeService, useValue: {} }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }) + .overrideComponent(StalkerLiveStreamLayoutComponent, { + set: { template: '' }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => fixture.destroy()); + + it('changes identity only when the current embedded playback commits', async () => { + const firstPlayback = { streamUrl: 'https://one.example/live.m3u8' }; + resolveItvPlayback.mockResolvedValueOnce(firstPlayback); + await component.playChannel(channels[0]); + const firstKey = component.playbackSessionKey(); + const session = () => [ + component.activePlayback(), + component.playbackSessionKey(), + ]; + expect(session()).toEqual([firstPlayback, firstKey]); + + const failed = deferred(); + resolveItvPlayback.mockReturnValueOnce(failed.promise); + const failedSelection = component.playChannel(channels[1]); + failed.reject(new Error('resolution failed')); + await failedSelection; + expect(session()).toEqual([firstPlayback, firstKey]); + + const stale = deferred(); + const current = deferred(); + resolveItvPlayback + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(current.promise); + const staleSelection = component.playChannel(channels[0]); + const currentSelection = component.playChannel(channels[1]); + const currentPlayback = { + streamUrl: 'https://two.example/live.m3u8', + }; + current.resolve(currentPlayback); + stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' }); + await Promise.all([staleSelection, currentSelection]); + expect(session()).toEqual([ + currentPlayback, + createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist-one', + contentId: 'channel-two', + }), + ]); + }); + + it('does not dedupe the same channel id across playlist owners', async () => { + const sourceA = deferred(); + const sourceB = deferred(); + resolveItvPlayback + .mockReturnValueOnce(sourceA.promise) + .mockReturnValueOnce(sourceB.promise); + const shared = { ...channels[0], id: 'shared-channel' }; + playlist.set({ _id: 'playlist-a', title: 'Portal A' }); + const requestA = component.playChannel(shared); + playlist.set({ _id: 'playlist-b', title: 'Portal B' }); + const requestB = component.playChannel(shared); + const playbackB = { streamUrl: 'https://b.example/shared.m3u8' }; + + sourceB.resolve(playbackB); + sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' }); + await Promise.all([requestA, requestB]); + + expect(resolveItvPlayback).toHaveBeenCalledTimes(2); + expect(component.activePlayback()).toBe(playbackB); + expect(component.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist-b', + contentId: 'shared-channel', + }) + ); + }); + + it('preserves the committed pair when source or channel identity is absent', async () => { + const valid = { streamUrl: 'https://valid.example/live.m3u8' }; + resolveItvPlayback.mockResolvedValueOnce(valid); + await component.playChannel(channels[0]); + const key = component.playbackSessionKey(); + + playlist.set({ _id: '', title: 'Missing source' }); + resolveItvPlayback.mockResolvedValueOnce({ + streamUrl: 'https://invalid.example/source.m3u8', + }); + await component.playChannel(channels[1]); + expect([ + component.activePlayback(), + component.playbackSessionKey(), + ]).toEqual([valid, key]); + + playlist.set({ _id: 'playlist-one', title: 'Portal One' }); + resolveItvPlayback.mockResolvedValueOnce({ + streamUrl: 'https://invalid.example/channel.m3u8', + }); + await component.playChannel({ ...channels[1], id: '' }); + expect([ + component.activePlayback(), + component.playbackSessionKey(), + ]).toEqual([valid, key]); + }); + + it('does not commit a pending ITV request after the mode changes to radio with the same channel id', async () => { + const pending = deferred(); + resolveItvPlayback.mockReturnValue(pending.promise); + const request = component.playChannel(channels[0]); + + selectedContentType.set('radio'); + pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' }); + await request; + + expect(component.activePlayback()).toBeNull(); + expect(component.playbackSessionKey()).toBe(''); + }); + + it('suppresses a stale rejection after playlist, mode, and channel ownership change', async () => { + const pending = deferred(); + resolveItvPlayback.mockReturnValue(pending.promise); + const request = component.playChannel(channels[0]); + + playlist.set({ _id: 'playlist-two', title: 'Portal Two' }); + selectedContentType.set('radio'); + selectedItvId.set(channels[1].id); + pending.reject(new Error('stale owner failure')); + await request; + + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('still reports an error for the current full owner', async () => { + resolveItvPlayback.mockRejectedValueOnce(new Error('current failure')); + + await component.playChannel(channels[0]); + + expect(snackBar.open).toHaveBeenCalledWith( + 'PORTALS.PLAYBACK_ERROR', + undefined, + { duration: 3000 } + ); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html index d2faca0ac..0bdf141a6 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.html @@ -12,6 +12,7 @@ @if (showingDetails) { () { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +@Component({ + selector: 'app-stalker-inline-detail', + standalone: true, + template: '', +}) +class StubStalkerInlineDetailComponent { + readonly playbackSessionKey = input.required(); + readonly inlinePlayback = input(null); + readonly inlinePlaybackClosed = output(); +} + +describe('StalkerSearchComponent playback session key', () => { + let fixture: ComponentFixture; + const playlist = signal({ + _id: 'playlist|one', + title: 'Search portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }); + const selectedItem = signal(null); + const resolveVodPlayback = jest.fn(); + const snackBar = { open: jest.fn() }; + + beforeEach(async () => { + playlist.set({ + _id: 'playlist|one', + title: 'Search portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }); + selectedItem.set(null); + resolveVodPlayback.mockReset(); + snackBar.open.mockReset(); + await TestBed.configureTestingModule({ + imports: [StalkerSearchComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParamMap: of(convertToParamMap({})), + snapshot: { + data: {}, + queryParamMap: convertToParamMap({}), + routeConfig: { path: 'search' }, + }, + }, + }, + { provide: Location, useValue: { back: jest.fn() } }, + { provide: DataService, useValue: {} }, + { + provide: PlaylistContextFacade, + useValue: { activePlaylist: playlist }, + }, + { + provide: PlaylistsService, + useValue: { getPortalFavorites: () => of([]) }, + }, + { + provide: StalkerStore, + useValue: { + selectedItem, + setSelectedContentType: jest.fn(), + setSelectedItem: jest.fn((item) => + selectedItem.set(item) + ), + addToFavorites: jest.fn(), + removeFromFavorites: jest.fn(), + resolveVodPlayback, + }, + }, + { provide: StalkerSessionService, useValue: {} }, + { provide: StalkerPortalRepairService, useValue: {} }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition: jest.fn().mockResolvedValue(null), + savePlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + openExternalPlayback: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }) + .overrideComponent(StalkerSearchComponent, { + set: { + imports: [StubStalkerInlineDetailComponent], + template: ` + @if (showingDetails) { + + } + `, + }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerSearchComponent); + }); + + afterEach(() => fixture?.destroy()); + + it('owns the original item identity and threads it unchanged through payload replacement', () => { + const first = { + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }; + fixture.componentInstance.selectItem(first); + fixture.detectChanges(); + const expected = createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist|one', + contentId: 'movie|one', + }); + const detail = fixture.debugElement.query( + By.directive(StubStalkerInlineDetailComponent) + ).componentInstance as StubStalkerInlineDetailComponent; + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + expect(detail.playbackSessionKey()).toBe(expected); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://alternative.example/replaced.mkv', + title: 'Alternative movie', + contentInfo: { + playlistId: 'alternative-playlist', + contentXtreamId: 999, + contentType: 'vod', + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + + fixture.componentInstance.selectItem({ + ...first, + id: 'movie|two', + }); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe( + expected + ); + const itemChangedKey = fixture.componentInstance.playbackSessionKey(); + playlist.set({ ...playlist(), _id: 'playlist|two' }); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe( + itemChangedKey + ); + }); + + it('clears committed playback when the route playlist owner changes for the same provider id', async () => { + const item = { + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }; + resolveVodPlayback.mockResolvedValue({ + streamUrl: 'https://a.example/movie.mpg', + }); + fixture.componentInstance.selectItem(item); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + expect(fixture.componentInstance.inlinePlayback()).not.toBeNull(); + + playlist.set({ ...playlist(), _id: 'playlist|two' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist|two', + contentId: 'movie|one', + }) + ); + }); + + it('suppresses a pending old-playlist error for the same provider id', async () => { + const pending = deferred(); + resolveVodPlayback.mockReturnValue(pending.promise); + fixture.componentInstance.selectItem({ + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await Promise.resolve(); + + playlist.set({ ...playlist(), _id: 'playlist|two' }); + fixture.detectChanges(); + pending.reject(new Error('stale playlist failure')); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + }); + + it('preserves committed playback when the same canonical owner object refreshes', async () => { + const item = { + id: 'movie|one', + cmd: 'ffrt4://movie/one', + name: 'Movie One', + info: { name: 'Movie One' }, + }; + const playback = { streamUrl: 'https://a.example/movie.mpg' }; + resolveVodPlayback.mockResolvedValue(playback); + fixture.componentInstance.selectItem(item); + fixture.componentInstance.onVodPlay( + fixture.componentInstance.vodDetailsItem()! + ); + await fixture.whenStable(); + + selectedItem.set({ ...item, screenshot_uri: 'refreshed.jpg' }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index 3a3b03f4f..e2bb6f0b1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -6,6 +6,7 @@ import { inject, resource, signal, + untracked, } from '@angular/core'; import { Location } from '@angular/common'; import { FormsModule } from '@angular/forms'; @@ -57,6 +58,7 @@ import { toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; interface StalkerFilter { key: StalkerSearchContentType; @@ -103,6 +105,7 @@ export class StalkerSearchComponent { private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly logger = createLogger('StalkerSearch'); + private currentPlaybackOwnerKey = ''; readonly filters = signal>({ series: false, @@ -141,6 +144,16 @@ export class StalkerSearchComponent { readonly itemDetails = signal(null); readonly vodDetailsItem = signal(null); readonly inlinePlayback = signal(null); + readonly playbackSessionKey = computed(() => { + const sourceId = this.currentPlaylist()?._id; + const contentId = normalizeStalkerEntityId(this.itemDetails()?.id); + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) + : ''; + }); + private readonly playbackOwnerKey = computed(() => + JSON.stringify([this.playbackSessionKey(), this.selectedFilterType()]) + ); readonly selectedVodPosition = signal(null); readonly selectedVodPlaybackPosition = computed( () => this.selectedVodPosition()?.positionSeconds ?? null @@ -154,6 +167,7 @@ export class StalkerSearchComponent { translateService: this.translateService, logger: this.logger, playbackErrorLogMessage: 'Failed to start search VOD playback', + playbackOwnerKey: () => this.playbackOwnerKey(), }); readonly portalFavorites = createPortalFavoritesResource( @@ -197,16 +211,15 @@ export class StalkerSearchComponent { // executeStalkerRequest owns the portal-mode decision (shared // predicate with URL fallback for legacy rows) and the lazy // portal repair, so search cannot drift from the catalog paths. - const response = - await executeStalkerRequest( - { - dataService: this.dataService, - stalkerSession: this.stalkerSession, - portalRepair: this.portalRepair, - }, - playlist, - requestParams - ); + const response = await executeStalkerRequest( + { + dataService: this.dataService, + stalkerSession: this.stalkerSession, + portalRepair: this.portalRepair, + }, + playlist, + requestParams + ); const items = response.js?.data || []; return items.map((item: StalkerVodSource) => this.processItemUrls(item, portalUrl) @@ -217,6 +230,12 @@ export class StalkerSearchComponent { readonly isSelectedVodFavorite = signal(false); constructor() { + this.currentPlaybackOwnerKey = this.playbackOwnerKey(); + effect(() => { + const ownerKey = this.playbackOwnerKey(); + untracked(() => this.syncPlaybackOwner(ownerKey)); + }); + effect(() => { const routeTerm = this.routeSearchTerm(); if (routeTerm !== this.searchTerm()) { @@ -293,7 +312,6 @@ export class StalkerSearchComponent { } selectItem(item: StalkerVodSource) { - this.closeInlinePlayer(); const filterType = this.selectedFilterType(); const hasEmbeddedSeries = (item.series?.length ?? 0) > 0; const needsSeriesFetch = @@ -338,6 +356,7 @@ export class StalkerSearchComponent { default: break; } + this.syncPlaybackOwner(this.playbackOwnerKey()); } onVodPlay(item: VodDetailsItem): void { @@ -430,6 +449,12 @@ export class StalkerSearchComponent { ); } + private syncPlaybackOwner(ownerKey: string): void { + if (ownerKey === this.currentPlaybackOwnerKey) return; + this.currentPlaybackOwnerKey = ownerKey; + this.closeInlinePlayer(); + } + inlineDetail() { return createStalkerInlineDetailState( this.itemDetails(), diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts new file mode 100644 index 000000000..0048ddde1 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.spec.ts @@ -0,0 +1,278 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access'; +import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter'; +import { + captureStalkerEpisodePlaybackSessionIdentity, + createStalkerEpisodePlaybackSessionKey, + resolveStalkerEpisodeStateByIdentity, + resolveStalkerEpisodeStateByStructuralIdentity, + toStalkerEpisodePlaybackStructuralIdentity, +} from './stalker-episode-playback-session-key'; + +const episodeState = ( + episode: Partial, + seasonKey = 'season|1' +) => ({ + seasonKey, + seasonNumber: 1, + episodeNumber: 2, + episode: { + id: 'synthesized-32-bit-id', + title: 'Episode 2', + episode_num: 2, + season: 1, + ...episode, + } as StalkerMappedEpisode, + previous: null, + next: null, +}); + +describe('createStalkerEpisodePlaybackSessionKey', () => { + it.each([ + STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod, + ])( + 'keeps %s provider credentials out of the structural key', + (seriesMode) => { + const originalCmdA = + 'https://user:password@stream.example/episode.mpg?access_token=secret-a'; + const originalCmdB = + 'https://user:password@stream.example/episode.mpg?access_token=secret-b'; + const stateA = episodeState({ originalCmd: originalCmdA }); + const stateB = episodeState({ originalCmd: originalCmdB }); + const composite = JSON.stringify([ + seriesMode, + 'parent|series', + stateA.seasonKey, + stateA.seasonNumber, + stateA.episodeNumber, + ]); + + const keyA = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'playlist|source', + parentSeriesId: 'parent|series', + seriesMode, + episodeState: stateA, + }); + const keyB = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'playlist|source', + parentSeriesId: 'parent|series', + seriesMode, + episodeState: stateB, + }); + + expect(keyA).toBe( + createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'playlist|source', + contentId: composite, + seriesId: 'parent|series', + seasonNumber: 1, + episodeNumber: 2, + }) + ); + expect(keyB).toBe(keyA); + expect(keyA).not.toContain(originalCmdA); + expect(keyA).not.toContain('password'); + expect(keyA).not.toContain('secret-a'); + } + ); + + it('retains the exact pending guard without a synthesized tracking hash', () => { + const originalCmd = + 'https://stream.example.invalid/episode.mpg?access_token=example-token'; + const state = episodeState({ + id: 'command-derived-tracking-hash', + originalCmd, + }); + const identity = captureStalkerEpisodePlaybackSessionIdentity({ + sourceId: 'playlist', + parentSeriesId: 'series', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: state, + }); + if (!identity) throw new Error('Expected a captured request identity'); + + expect(identity).toEqual({ + sourceId: 'playlist', + parentSeriesId: 'series', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + originalEpisodeIdentity: originalCmd, + seasonKey: state.seasonKey, + seasonNumber: state.seasonNumber, + episodeNumber: state.episodeNumber, + sessionKey: identity.sessionKey, + }); + }); + + it('freezes only credential-free structural fields for a mounted session', () => { + const originalCmd = + 'https://user:password@stream.example/episode.mpg?access_token=secret'; + const identity = captureStalkerEpisodePlaybackSessionIdentity({ + sourceId: 'playlist', + parentSeriesId: 'series', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: episodeState({ originalCmd }), + }); + if (!identity) throw new Error('Expected a captured request identity'); + + const retained = toStalkerEpisodePlaybackStructuralIdentity(identity); + + expect(Object.isFrozen(retained)).toBe(true); + expect(retained).toEqual({ + sourceId: identity.sourceId, + parentSeriesId: identity.parentSeriesId, + seriesMode: identity.seriesMode, + seasonKey: identity.seasonKey, + seasonNumber: identity.seasonNumber, + episodeNumber: identity.episodeNumber, + sessionKey: identity.sessionKey, + }); + expect(JSON.stringify(retained)).not.toContain(originalCmd); + expect(retained).not.toHaveProperty('originalEpisodeIdentity'); + }); + + it('fails closed when structural coordinates match multiple episodes', () => { + const state = episodeState({ originalCmd: 'command-a' }); + const requestIdentity = captureStalkerEpisodePlaybackSessionIdentity({ + sourceId: 'playlist', + parentSeriesId: 'series', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: state, + }); + if (!requestIdentity) { + throw new Error('Expected a captured request identity'); + } + const identity = + toStalkerEpisodePlaybackStructuralIdentity(requestIdentity); + + expect( + resolveStalkerEpisodeStateByStructuralIdentity({ + episodesBySeason: { + [state.seasonKey]: [ + state.episode, + { + ...state.episode, + id: 'another-tracking-id', + originalCmd: 'command-b', + }, + ], + }, + identity, + }) + ).toBeNull(); + }); + + it('keeps lazy provider ids transient while rejecting an id refresh for a pending request', () => { + const stateA = episodeState({ originalId: 'provider|episode:token-a' }); + const stateB = episodeState({ originalId: 'provider|episode:token-b' }); + const options = { + sourceId: 'playlist', + parentSeriesId: '50001', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + } as const; + const identityA = captureStalkerEpisodePlaybackSessionIdentity({ + ...options, + episodeState: stateA, + }); + const keyB = createStalkerEpisodePlaybackSessionKey({ + ...options, + episodeState: stateB, + }); + if (!identityA) throw new Error('Expected a captured request identity'); + + expect(identityA.originalEpisodeIdentity).toBe( + 'provider|episode:token-a' + ); + expect(keyB).toBe(identityA.sessionKey); + expect(keyB).not.toContain('provider|episode:token-a'); + expect(keyB).not.toContain('provider|episode:token-b'); + expect(keyB).not.toContain('synthesized-32-bit-id'); + expect( + resolveStalkerEpisodeStateByIdentity({ + episodesBySeason: { [stateB.seasonKey]: [stateB.episode] }, + identity: identityA, + }) + ).toBeNull(); + }); + + it('separates every structural coordinate without delimiter collisions', () => { + const base = { + sourceId: 'playlist|a', + parentSeriesId: 'parent|a', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: episodeState({ originalCmd: 'cmd|a' }), + } as const; + const variants = [ + { ...base, sourceId: 'playlist|b' }, + { ...base, parentSeriesId: 'parent|b' }, + { + ...base, + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod, + }, + { + ...base, + episodeState: episodeState( + { originalCmd: 'cmd|a' }, + 'season|2' + ), + }, + { + ...base, + episodeState: { + ...base.episodeState, + seasonNumber: 2, + }, + }, + { + ...base, + episodeState: { + ...base.episodeState, + episodeNumber: 3, + }, + }, + ]; + const baseKey = createStalkerEpisodePlaybackSessionKey(base); + const delimiterVariantKey = createStalkerEpisodePlaybackSessionKey({ + ...base, + sourceId: 'playlist', + parentSeriesId: 'a|parent|a', + }); + + expect( + variants.map(createStalkerEpisodePlaybackSessionKey) + ).not.toContain(baseKey); + expect(delimiterVariantKey).not.toBe(baseKey); + }); + + it.each([ + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + state: episodeState({}), + }, + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + state: episodeState({ originalCmd: '' }), + }, + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + state: episodeState({}), + }, + { + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + state: episodeState({ originalId: '' }), + }, + ])( + 'fails closed when original episode identity is absent', + ({ seriesMode, state }) => { + expect( + createStalkerEpisodePlaybackSessionKey({ + sourceId: 'playlist', + parentSeriesId: 'series', + seriesMode, + episodeState: state, + }) + ).toBe(''); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts new file mode 100644 index 000000000..8cee082c0 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-episode-playback-session-key.ts @@ -0,0 +1,237 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { + normalizeStalkerEntityId, + type StalkerMappedEpisode, +} from '@iptvnator/portal/stalker/data-access'; +import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback'; +import { + STALKER_SERIES_DOWNLOAD_MODES, + type StalkerSeriesDownloadMode, +} from './stalker-series-download.adapter'; + +export interface StalkerEpisodePlaybackSessionKeyOptions { + readonly sourceId: string | null | undefined; + readonly parentSeriesId: unknown; + readonly seriesMode: StalkerSeriesDownloadMode; + readonly episodeState: SeriesPlaybackEpisodeState | null; +} + +export interface StalkerEpisodePlaybackSessionIdentity { + readonly sourceId: string; + readonly parentSeriesId: string; + readonly seriesMode: StalkerSeriesDownloadMode; + readonly originalEpisodeIdentity: string | number; + readonly seasonKey: string; + readonly seasonNumber: number; + readonly episodeNumber: number; + readonly sessionKey: string; +} + +export interface StalkerEpisodePlaybackStructuralIdentity { + readonly sourceId: string; + readonly parentSeriesId: string; + readonly seriesMode: StalkerSeriesDownloadMode; + readonly seasonKey: string; + readonly seasonNumber: number; + readonly episodeNumber: number; + readonly sessionKey: string; +} + +interface StalkerEpisodeStateLookupOptions { + readonly episodesBySeason: Record; +} + +function present(value: unknown): value is string | number { + return ( + (typeof value === 'string' && value.trim().length > 0) || + (typeof value === 'number' && Number.isFinite(value)) + ); +} + +export function captureStalkerEpisodePlaybackSessionIdentity({ + sourceId, + parentSeriesId, + seriesMode, + episodeState, +}: StalkerEpisodePlaybackSessionKeyOptions): StalkerEpisodePlaybackSessionIdentity | null { + const normalizedSourceId = sourceId?.trim() ?? ''; + const seriesId = normalizeStalkerEntityId(parentSeriesId); + if (!normalizedSourceId || !seriesId || !episodeState) return null; + + const { episodeNumber, seasonKey, seasonNumber } = episodeState; + const episode = episodeState.episode as StalkerMappedEpisode; + const originalIdentity = + seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod + ? episode.originalId + : episode.originalCmd; + if ( + !present(originalIdentity) || + !seasonKey.trim() || + !Number.isSafeInteger(seasonNumber) || + seasonNumber < 0 || + !Number.isSafeInteger(episodeNumber) || + episodeNumber < 1 + ) { + return null; + } + + // Recovery ownership uses provider commands and IDs only for the transient + // request guard below. They may contain credentials and must never enter + // the session key or retained recovery state. + const structuralContentId = JSON.stringify([ + seriesMode, + seriesId, + seasonKey, + seasonNumber, + episodeNumber, + ]); + const sessionKey = createPlaybackSessionKey({ + kind: 'episode', + sourceId: normalizedSourceId, + contentId: structuralContentId, + seriesId, + seasonNumber, + episodeNumber, + }); + return Object.freeze({ + sourceId: normalizedSourceId, + parentSeriesId: seriesId, + seriesMode, + originalEpisodeIdentity: originalIdentity, + seasonKey, + seasonNumber, + episodeNumber, + sessionKey, + }); +} + +export function createStalkerEpisodePlaybackSessionKey( + options: StalkerEpisodePlaybackSessionKeyOptions +): string { + return ( + captureStalkerEpisodePlaybackSessionIdentity(options)?.sessionKey ?? '' + ); +} + +export function toStalkerEpisodePlaybackStructuralIdentity( + identity: StalkerEpisodePlaybackSessionIdentity +): StalkerEpisodePlaybackStructuralIdentity { + const { + sourceId, + parentSeriesId, + seriesMode, + seasonKey, + seasonNumber, + episodeNumber, + sessionKey, + } = identity; + return Object.freeze({ + sourceId, + parentSeriesId, + seriesMode, + seasonKey, + seasonNumber, + episodeNumber, + sessionKey, + }); +} + +export function resolveSelectedStalkerEpisodeState({ + episodesBySeason, + episode, +}: StalkerEpisodeStateLookupOptions & { + readonly episode: XtreamSerieEpisode; +}): SeriesPlaybackEpisodeState | null { + const exactState = findStalkerEpisodeState( + episodesBySeason, + (candidate) => candidate === episode + ); + if (exactState) return exactState; + + const selected = episode as StalkerMappedEpisode; + const originalIdentity = selected.originalId ?? selected.originalCmd; + const selectedSeasonNumber = Number(episode.season) || 0; + if (!present(originalIdentity)) return null; + + return findStalkerEpisodeState(episodesBySeason, (candidate, state) => { + const mapped = candidate as StalkerMappedEpisode; + return ( + (mapped.originalId ?? mapped.originalCmd) === originalIdentity && + (!selectedSeasonNumber || + state.seasonNumber === selectedSeasonNumber) && + state.episodeNumber === Number(episode.episode_num) + ); + }); +} + +export function resolveStalkerEpisodeStateByIdentity({ + episodesBySeason, + identity, +}: StalkerEpisodeStateLookupOptions & { + readonly identity: StalkerEpisodePlaybackSessionIdentity; +}): SeriesPlaybackEpisodeState | null { + return findStalkerEpisodeState( + { [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] }, + (candidate, state) => { + const mapped = candidate as StalkerMappedEpisode; + const originalIdentity = + identity.seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod + ? mapped.originalId + : mapped.originalCmd; + return ( + originalIdentity === identity.originalEpisodeIdentity && + state.seasonNumber === identity.seasonNumber && + state.episodeNumber === identity.episodeNumber + ); + } + ); +} + +export function resolveStalkerEpisodeStateByStructuralIdentity({ + episodesBySeason, + identity, +}: StalkerEpisodeStateLookupOptions & { + readonly identity: StalkerEpisodePlaybackStructuralIdentity; +}): SeriesPlaybackEpisodeState | null { + return findStalkerEpisodeState( + { [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] }, + (_candidate, state) => + state.seasonNumber === identity.seasonNumber && + state.episodeNumber === identity.episodeNumber, + true + ); +} + +function findStalkerEpisodeState( + episodesBySeason: Record, + matches: ( + episode: XtreamSerieEpisode, + state: SeriesPlaybackEpisodeState + ) => boolean, + requireUnique = false +): SeriesPlaybackEpisodeState | null { + let match: SeriesPlaybackEpisodeState | null = null; + for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) { + for ( + let episodeIndex = 0; + episodeIndex < episodes.length; + episodeIndex++ + ) { + const episode = episodes[episodeIndex]; + const state = { + seasonKey, + seasonNumber: Number(episode.season) || Number(seasonKey) || 0, + episodeNumber: Number(episode.episode_num) || episodeIndex + 1, + episode, + previous: episodes[episodeIndex - 1] ?? null, + next: episodes[episodeIndex + 1] ?? null, + }; + if (!matches(episode, state)) continue; + if (!requireUnique) return state; + if (match) return null; + match = state; + } + } + return match; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 1542d6c88..66d0195e1 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -167,6 +167,7 @@ @if (inlinePlayback(); as playback) { (); readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesTitle = input(null); @@ -771,17 +772,17 @@ describe('StalkerSeriesViewComponent', () => { }), }) ); - expect(inlinePlayer.episodeMetadata()).toEqual({ - label: 'S01E01', - title: 'Pilot', - seasonNumber: 1, - episodeNumber: 1, - }); + // Prettier expands these compact fixtures enough to breach the spec's + // hard 1,200-line lint limit. + // prettier-ignore + expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 }); expect(inlinePlayer.seriesNavigation()).toEqual({ canPrevious: false, canNext: true, autoplayEnabled: true, }); + const firstEpisodeKey = inlinePlayer.playbackSessionKey(); + expect(firstEpisodeKey).not.toBe(''); inlinePlayer.playbackEnded.emit(); await fixture.whenStable(); @@ -806,6 +807,7 @@ describe('StalkerSeriesViewComponent', () => { canNext: false, autoplayEnabled: true, }); + expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey); inlinePlayer.playbackEnded.emit(); await fixture.whenStable(); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 3d3d459c8..b85af5472 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -61,7 +61,6 @@ import { getSeriesPlaybackNavigation, type PlaybackFallbackRequest, PortalInlinePlayerComponent, - resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, type UpNextRailItem, } from '@iptvnator/ui/playback'; @@ -85,6 +84,15 @@ import { createStalkerSeriesDownloadAdapter, STALKER_SERIES_DOWNLOAD_MODES, } from './stalker-series-download.adapter'; +import { + captureStalkerEpisodePlaybackSessionIdentity, + resolveSelectedStalkerEpisodeState, + resolveStalkerEpisodeStateByIdentity, + resolveStalkerEpisodeStateByStructuralIdentity, + toStalkerEpisodePlaybackStructuralIdentity, + type StalkerEpisodePlaybackSessionIdentity, + type StalkerEpisodePlaybackStructuralIdentity, +} from './stalker-episode-playback-session-key'; interface SeriesPositionContext { readonly generation: number; @@ -93,46 +101,10 @@ interface SeriesPositionContext { readonly mutationKey: string; } -function snapshotTextList(value: string | undefined): string[] | undefined { - const entries = value - ?.split(',') - .map((entry) => entry.trim()) - .filter(Boolean); - return entries?.length ? entries : undefined; -} - -function snapshotNumber( - value: string | number | undefined -): number | undefined { - if (value === undefined || String(value).trim() === '') { - return undefined; - } - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - -function snapshotYear(value: string | undefined): number | undefined { - const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/); - return match ? Number(match[1]) : undefined; -} - -function snapshotPeople( - enriched: TmdbEnrichedCastMember[] | undefined, - fallback: string | undefined -) { - return enriched?.length - ? enriched.map((person) => ({ - name: person.name, - role: person.character, - profileUrl: person.profileUrl ?? undefined, - tmdbPersonId: person.tmdbPersonId, - })) - : snapshotTextList(fallback)?.map((name) => ({ name })); -} - -function positiveCoordinate(value: number | string | undefined): number { - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1; +interface StalkerSeriesPlaybackRequestContext { + readonly generation: number; + readonly usesEmbeddedPlayer: boolean; + readonly identity: StalkerEpisodePlaybackSessionIdentity | null; } /** @@ -215,6 +187,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { >(); private readonly seriesPositionReloadKeys = new Set(); private seriesPositionsLoadGeneration = 0; + private seriesPlaybackRequestGeneration = 0; + private currentSeriesPlaybackOwnerKey = ''; + private readonly inlinePlaybackEpisodeIdentity = + signal(null); private lastSaveTime = 0; private unsubscribePositionUpdates: (() => void) | null = null; readonly openingEpisodeId = signal(null); @@ -308,6 +284,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading; constructor() { + effect(() => { + const ownerKey = this.seriesPlaybackOwnerKey(); + untracked(() => this.syncSeriesPlaybackOwner(ownerKey)); + }); + // TMDB season fetch, keyed on (tmdb_id, selected season). With season // tabs the first seasonSelected fires immediately when seasons load — // usually BEFORE the async show-level TMDB enrichment has written @@ -426,9 +407,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { ) { return; } - this.episodePlaybackPositions.set( - reconciled.positionsByTrackingId - ); + this.episodePlaybackPositions.set(reconciled.positionsByTrackingId); this.legacyPositionByTrackingId.set( reconciled.legacyPositionByTrackingId ); @@ -488,15 +467,14 @@ export class StalkerSeriesViewComponent implements OnDestroy { // The facade/runtime already saved this row. Repeat the // idempotent upsert because only this view owns the // scoped-to-legacy cleanup mapping. - void this.persistSeriesPosition( - playlistId, - data - ).catch((error: unknown) => { - this.logger.error( - 'Failed to persist runtime series position', - error - ); - }); + void this.persistSeriesPosition(playlistId, data).catch( + (error: unknown) => { + this.logger.error( + 'Failed to persist runtime series position', + error + ); + } + ); } ) ?? null; } @@ -534,6 +512,22 @@ export class StalkerSeriesViewComponent implements OnDestroy { youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer) ); + readonly seriesMode = computed(() => + this.isVodSeries() + ? STALKER_SERIES_DOWNLOAD_MODES.LazyVod + : this.vodWithSeries() + ? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod + : STALKER_SERIES_DOWNLOAD_MODES.RegularSeries + ); + + private readonly seriesPlaybackOwnerKey = computed(() => { + const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? ''; + const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id); + return sourceId && parentSeriesId + ? JSON.stringify([sourceId, parentSeriesId, this.seriesMode()]) + : ''; + }); + readonly episodeDownloadAdapter = computed(() => { const playlist = this.stalkerStore.currentPlaylist(); const item = this.displayItem(); @@ -545,11 +539,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.translateService.defaultLang || 'en', seriesId: this.toSeriesId(item?.id ?? 0), - seriesMode: this.isVodSeries() - ? STALKER_SERIES_DOWNLOAD_MODES.LazyVod - : this.vodWithSeries() - ? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod - : STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + seriesMode: this.seriesMode(), resolveUrl: (command, episodeNumber) => this.stalkerStore.fetchLinkToPlay( playlist?.portalUrl ?? '', @@ -591,10 +581,25 @@ export class StalkerSeriesViewComponent implements OnDestroy { vodSeriesSeasons: this.vodSeriesSeasons(), }); }); - readonly inlineEpisodeState = - computed | null>(() => - this.getInlineEpisodeState() - ); + readonly inlineEpisodeState = computed(() => { + const identity = this.inlinePlaybackEpisodeIdentity(); + const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? ''; + const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id); + if ( + !identity || + this.playbackSessionKey() !== identity.sessionKey || + sourceId !== identity.sourceId || + parentSeriesId !== identity.parentSeriesId || + this.seriesMode() !== identity.seriesMode + ) { + return null; + } + return resolveStalkerEpisodeStateByStructuralIdentity({ + episodesBySeason: this.mappedSeasons(), + identity, + }); + }); + readonly playbackSessionKey = signal(''); readonly inlineEpisodeMetadata = computed(() => getSeriesEpisodeMetadata(this.inlineEpisodeState()) ); @@ -768,27 +773,33 @@ export class StalkerSeriesViewComponent implements OnDestroy { * Handles episode click from the container */ onEpisodeClicked(episode: XtreamSerieEpisode) { - const mappedEpisode = episode as StalkerMappedEpisode; + const item = this.displayItem(); + const episodeState = resolveSelectedStalkerEpisodeState({ + episodesBySeason: this.mappedSeasons(), + episode, + }); + if (!item || !episodeState) return; + this.syncSeriesPlaybackOwner(this.seriesPlaybackOwnerKey()); - if (mappedEpisode.custom_sid === 'vod-series') { - // It's a VOD series episode (is_series=1 mode) - // Use originalId for playback URL, but use generated id for tracking - this.playVodSeriesEpisode({ - originalId: mappedEpisode.originalId, // For constructing playback URL - trackingId: episode.id, // The generated unique ID for position tracking - name: episode.title, - series_number: episode.episode_num, - }); - } else { - // Regular series or vclub mode (VOD with embedded series array) - // Use originalCmd for playback, episode.id for tracking - const trackingId = Number(episode.id); - this.playEpisodeClicked( - episode.episode_num, - mappedEpisode.originalCmd, - trackingId - ); - } + const mappedEpisode = episodeState.episode as StalkerMappedEpisode; + const isLazyVod = mappedEpisode.custom_sid === 'vod-series'; + const command = isLazyVod + ? `/media/file_${mappedEpisode.originalId ?? ''}.mpg` + : mappedEpisode.originalCmd; + const title = isLazyVod + ? `${item.info.name} - ${mappedEpisode.title || `Episode ${episodeState.episodeNumber}`}` + : item.info.name; + const trackingId = Number(mappedEpisode.id); + const startTime = + this.episodePlaybackPositions().get(trackingId)?.positionSeconds; + + void this.startPlayback( + command, + title, + item.info.movie_image, + episodeState, + startTime + ); } async playQuickStartEpisode(): Promise { @@ -807,69 +818,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } - /** - * Play episode - handles regular series and vclub mode - */ - playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) { - const item = this.displayItem(); - if (!item) return; - this.logger.debug('playEpisodeClicked', { - episodeNum, - cmd, - trackingId, - seriesId: item.id, - }); - - const position = trackingId - ? this.episodePlaybackPositions().get(trackingId) - : undefined; - const startTime = position?.positionSeconds; - void this.startPlayback( - cmd, - item.info.name, - item.info.movie_image, - episodeNum, - trackingId, - startTime - ); - } - - /** - * Play VOD series episode - */ - playVodSeriesEpisode(episode: { - originalId?: string; - trackingId: string; - name: string; - series_number: number; - }) { - const item = this.displayItem(); - if (!item) return; - // Use originalId for playback URL (this is what the Stalker API expects) - const cmd = `/media/file_${episode.originalId ?? ''}.mpg`; - const episodeName = episode.name || `Episode ${episode.series_number}`; - // Use trackingId (generated unique ID) for playback position tracking - const trackingId = Number(episode.trackingId); - - this.logger.debug('playVodSeriesEpisode', { - originalId: episode.originalId, - trackingId, - seriesId: item.id, - episodeName, - }); - - const position = this.episodePlaybackPositions().get(trackingId); - const startTime = position?.positionSeconds; - void this.startPlayback( - cmd, - `${item.info.name} - ${episodeName}`, - item.info.movie_image, - episode.series_number, - trackingId, - startTime - ); - } - openSimilarInPortals(item: CrossPortalSimilarItem): void { void this.router.navigate(this.crossPortalSimilar.buildLink(item)); } @@ -907,7 +855,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { } closeInlinePlayer(): void { + this.seriesPlaybackRequestGeneration += 1; this.inlinePlayback.set(null); + this.inlinePlaybackEpisodeIdentity.set(null); + this.playbackSessionKey.set(''); this.lastSaveTime = 0; } @@ -984,13 +935,27 @@ export class StalkerSeriesViewComponent implements OnDestroy { } private async startPlayback( - cmd?: string, - title?: string, - thumbnail?: string, - episodeNum?: number, - episodeId?: number, + cmd: string | undefined, + title: string | undefined, + thumbnail: string | undefined, + episodeState: SeriesPlaybackEpisodeState, startTime?: number ): Promise { + const generation = ++this.seriesPlaybackRequestGeneration; + const episodeNum = episodeState.episodeNumber; + const episodeId = Number(episodeState.episode.id); + const request: StalkerSeriesPlaybackRequestContext = { + generation, + usesEmbeddedPlayer: this.portalPlayer.isEmbeddedPlayer(), + identity: captureStalkerEpisodePlaybackSessionIdentity({ + sourceId: this.stalkerStore.currentPlaylist()?._id, + parentSeriesId: this.displayItem()?.id, + seriesMode: this.seriesMode(), + episodeState, + }), + }; + if (request.usesEmbeddedPlayer && !request.identity) return; + try { const playback = await this.stalkerStore.resolveVodPlayback( cmd, @@ -1000,14 +965,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { episodeId, startTime ); - const episodeState = - episodeId === undefined - ? null - : resolveSeriesPlaybackEpisodeState({ - episodesBySeason: this.mappedSeasons(), - currentEpisodeId: episodeId, - fallbackEpisodeNumber: episodeNum, - }); + if (!this.isPlaybackRequestCurrent(request)) return; + const resolvedPlayback = episodeState && playback.contentInfo?.contentType === 'episode' ? { @@ -1021,14 +980,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { : playback; this.lastSaveTime = 0; - if (this.portalPlayer.isEmbeddedPlayer()) { - this.inlinePlayback.set(resolvedPlayback); + if (request.usesEmbeddedPlayer && request.identity) { + this.setInlinePlayback(resolvedPlayback, request.identity); return; } this.closeInlinePlayer(); void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true); } catch (error) { + if (!this.isPlaybackRequestCurrent(request)) return; this.logger.error('Failed to start inline series playback', error); const errorMessage = error instanceof Error && error.message === 'nothing_to_play' @@ -1042,25 +1002,48 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } - private getInlineEpisodeState(): SeriesPlaybackEpisodeState | null { - const playback = this.inlinePlayback(); - const currentEpisodeId = playback?.contentInfo?.contentXtreamId; - - if ( - playback?.contentInfo?.contentType !== 'episode' || - currentEpisodeId === undefined - ) { - return null; + private isPlaybackRequestCurrent( + request: StalkerSeriesPlaybackRequestContext + ): boolean { + if (request.generation !== this.seriesPlaybackRequestGeneration) { + return false; } + if (!request.identity) return true; - return resolveSeriesPlaybackEpisodeState({ + const identity = request.identity; + const episodeState = resolveStalkerEpisodeStateByIdentity({ episodesBySeason: this.mappedSeasons(), - currentEpisodeId, + identity, }); + const currentIdentity = captureStalkerEpisodePlaybackSessionIdentity({ + sourceId: this.stalkerStore.currentPlaylist()?._id, + parentSeriesId: this.displayItem()?.id, + seriesMode: this.seriesMode(), + episodeState, + }); + return currentIdentity?.sessionKey === identity.sessionKey; } ngOnDestroy(): void { this.unsubscribePositionUpdates?.(); + this.closeInlinePlayer(); + } + + private setInlinePlayback( + playback: ResolvedPortalPlayback, + identity: StalkerEpisodePlaybackSessionIdentity + ): void { + this.playbackSessionKey.set(identity.sessionKey); + this.inlinePlaybackEpisodeIdentity.set( + toStalkerEpisodePlaybackStructuralIdentity(identity) + ); + this.inlinePlayback.set(playback); + } + + private syncSeriesPlaybackOwner(ownerKey: string): void { + if (ownerKey === this.currentSeriesPlaybackOwnerKey) return; + this.currentSeriesPlaybackOwnerKey = ownerKey; + this.closeInlinePlayer(); } async handlePlaybackToggleRequested( @@ -1072,17 +1055,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { } if (request.nextPosition) { - await this.persistSeriesPosition( - playlistId, - request.nextPosition - ); + await this.persistSeriesPosition(playlistId, request.nextPosition); return; } - await this.clearSeriesPosition( - playlistId, - request.contentXtreamId - ); + await this.clearSeriesPosition(playlistId, request.contentXtreamId); } handlePlaybackToggleRequestedFromUi( @@ -1104,9 +1081,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { const generation = ++this.seriesPositionsLoadGeneration; this.trackPendingSeriesPositionLoad(context, generation); try { - await this.waitForSeriesPositionMutations( - context.mutationKey - ); + await this.waitForSeriesPositionMutations(context.mutationKey); if ( generation !== this.seriesPositionsLoadGeneration || @@ -1155,16 +1130,13 @@ export class StalkerSeriesViewComponent implements OnDestroy { return ( activeContext === context && activeContext.generation === context.generation && - this.stalkerStore.currentPlaylist()?._id === - context.playlistId && + this.stalkerStore.currentPlaylist()?._id === context.playlistId && this.toSeriesId(this.displayItem()?.id ?? 0) === context.seriesXtreamId ); } - private waitForSeriesPositionMutations( - mutationKey: string - ): Promise { + private waitForSeriesPositionMutations(mutationKey: string): Promise { return ( this.seriesPositionMutationQueues.get(mutationKey) ?? Promise.resolve() @@ -1176,8 +1148,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { generation: number ): void { const generations = - this.pendingSeriesPositionLoads.get(context) ?? - new Set(); + this.pendingSeriesPositionLoads.get(context) ?? new Set(); generations.add(generation); this.pendingSeriesPositionLoads.set(context, generations); } @@ -1186,8 +1157,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { context: SeriesPositionContext, generation: number ): void { - const generations = - this.pendingSeriesPositionLoads.get(context); + const generations = this.pendingSeriesPositionLoads.get(context); generations?.delete(generation); if (generations?.size === 0) { this.pendingSeriesPositionLoads.delete(context); @@ -1220,30 +1190,20 @@ export class StalkerSeriesViewComponent implements OnDestroy { () => undefined, () => undefined ); - this.seriesPositionMutationQueues.set( - context.mutationKey, - barrier - ); + this.seriesPositionMutationQueues.set(context.mutationKey, barrier); void barrier.then(() => { if ( - this.seriesPositionMutationQueues.get( - context.mutationKey - ) === barrier + this.seriesPositionMutationQueues.get(context.mutationKey) === + barrier ) { - this.seriesPositionMutationQueues.delete( - context.mutationKey - ); - this.reloadSeriesPositionsAfterMutations( - context.mutationKey - ); + this.seriesPositionMutationQueues.delete(context.mutationKey); + this.reloadSeriesPositionsAfterMutations(context.mutationKey); } }); return result; } - private reloadSeriesPositionsAfterMutations( - mutationKey: string - ): void { + private reloadSeriesPositionsAfterMutations(mutationKey: string): void { if (!this.seriesPositionReloadKeys.delete(mutationKey)) { return; } @@ -1286,10 +1246,9 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (!context) { return Promise.resolve(); } - const legacyPosition = - this.legacyPositionByTrackingId().get( - position.contentXtreamId - ); + const legacyPosition = this.legacyPositionByTrackingId().get( + position.contentXtreamId + ); return this.enqueueSeriesPositionMutation(context, async () => { let clearedLegacy: boolean; try { @@ -1301,8 +1260,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { }); } catch (error) { if ( - error instanceof - StalkerSeriesPositionPartialSaveError && + error instanceof StalkerSeriesPositionPartialSaveError && this.isSeriesPositionContextActive(context) ) { this.publishSavedSeriesPosition( @@ -1329,14 +1287,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { legacyPosition: PlaybackPositionData | undefined, clearedLegacy: boolean ): void { - const removedTrackingIds = new Set([ - position.contentXtreamId, - ]); + const removedTrackingIds = new Set([position.contentXtreamId]); if (clearedLegacy && legacyPosition) { removedTrackingIds.add(legacyPosition.contentXtreamId); - const legacyPositions = new Map( - this.legacyPositionByTrackingId() - ); + const legacyPositions = new Map(this.legacyPositionByTrackingId()); legacyPositions.delete(position.contentXtreamId); this.legacyPositionByTrackingId.set(legacyPositions); } @@ -1344,9 +1298,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.rawSeriesPositions.set([ ...this.rawSeriesPositions().filter( (candidate) => - !removedTrackingIds.has( - candidate.contentXtreamId - ) + !removedTrackingIds.has(candidate.contentXtreamId) ), position, ]); @@ -1361,14 +1313,15 @@ export class StalkerSeriesViewComponent implements OnDestroy { if (!context) { return Promise.resolve(); } - const position = - this.episodePlaybackPositions().get(contentXtreamId) ?? { - contentXtreamId, - contentType: 'episode', - positionSeconds: 0, - playlistId, - seriesXtreamId: context.seriesXtreamId, - }; + const position = this.episodePlaybackPositions().get( + contentXtreamId + ) ?? { + contentXtreamId, + contentType: 'episode', + positionSeconds: 0, + playlistId, + seriesXtreamId: context.seriesXtreamId, + }; const legacyPosition = this.legacyPositionByTrackingId().get(contentXtreamId); return this.enqueueSeriesPositionMutation(context, async () => { @@ -1397,9 +1350,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { const removedTrackingIds = new Set([contentXtreamId]); if (clearedLegacy && legacyPosition) { removedTrackingIds.add(legacyPosition.contentXtreamId); - const legacyPositions = new Map( - this.legacyPositionByTrackingId() - ); + const legacyPositions = new Map(this.legacyPositionByTrackingId()); legacyPositions.delete(contentXtreamId); this.legacyPositionByTrackingId.set(legacyPositions); } @@ -1407,9 +1358,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { this.rawSeriesPositions.set( this.rawSeriesPositions().filter( (candidate) => - !removedTrackingIds.has( - candidate.contentXtreamId - ) + !removedTrackingIds.has(candidate.contentXtreamId) ) ); this.removeEpisodePlaybackPosition(contentXtreamId); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts new file mode 100644 index 000000000..98985aa76 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.playback-session-key.spec.ts @@ -0,0 +1,753 @@ +import { EMPTY, of } from 'rxjs'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { + StalkerStore, + type StalkerMappedEpisode, + type StalkerVodSource, +} from '@iptvnator/portal/stalker/data-access'; +import { TmdbEnrichmentService } from '@iptvnator/services'; +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { StalkerSeriesViewComponent } from './stalker-series-view.component'; +import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter'; +import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('StalkerSeriesViewComponent playback session key', () => { + let fixture: ComponentFixture; + const selectedContentType = signal<'series' | 'vod'>('series'); + const selectedItem = signal(null); + const serialSeasons = signal([]); + const vodSeasons = signal([]); + const resolveVodPlayback = jest.fn(); + const tmdbGetSeason = jest.fn(); + const currentPlaylist = signal({ + _id: 'stalker|playlist', + title: 'Portal', + portalUrl: 'https://stalker.example', + macAddress: '00:1A:79:12:34:56', + }); + + beforeEach(async () => { + selectedContentType.set('series'); + selectedItem.set({ + id: 'series|parent', + info: { name: 'Series', movie_image: 'poster.jpg' }, + }); + serialSeasons.set([ + { + id: 'regular-season', + name: 'Season 1', + cmd: 'ffrt4://regular|full-command', + series: [1, 2], + }, + ]); + vodSeasons.set([]); + resolveVodPlayback + .mockReset() + .mockImplementation( + async ( + _command: string, + title: string, + thumbnail: string, + _episodeNumber: number, + episodeId: number + ) => ({ + streamUrl: 'https://resolved.example/temporary.mpg', + title, + thumbnail, + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: episodeId, + contentType: 'episode', + seriesXtreamId: 999, + }, + }) + ); + tmdbGetSeason.mockReset().mockResolvedValue(null); + + await TestBed.configureTestingModule({ + imports: [StalkerSeriesViewComponent], + providers: [ + { + provide: StalkerStore, + useValue: { + selectedItem, + selectedContentType, + currentPlaylist, + getSerialSeasonsResource: () => serialSeasons(), + getVodSeriesSeasonsResource: () => vodSeasons(), + isVodSeriesSeasonsLoading: signal(false), + isSerialSeasonsLoading: signal(false), + fetchVodSeriesEpisodes: jest.fn(), + resolveVodPlayback, + fetchLinkToPlay: jest.fn(), + clearSelectedItem: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: signal(null) }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getSeriesPlaybackPositions: jest + .fn() + .mockResolvedValue([]), + savePlaybackPosition: jest.fn(), + clearPlaybackPosition: jest.fn(), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: () => true, + openResolvedPlayback: jest.fn(), + }, + }, + { provide: Router, useValue: { navigateByUrl: jest.fn() } }, + { + provide: TmdbEnrichmentService, + useValue: { + isEnabled: () => false, + getSeason: tmdbGetSeason, + getSeasonEpisodes: jest.fn(), + }, + }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + currentLang: 'en', + defaultLang: 'en', + onLangChange: EMPTY, + onTranslationChange: EMPTY, + onDefaultLangChange: EMPTY, + }, + }, + ], + }) + .overrideComponent(StalkerSeriesViewComponent, { + set: { template: '' }, + }) + .compileComponents(); + fixture = TestBed.createComponent(StalkerSeriesViewComponent); + }); + + afterEach(() => fixture.destroy()); + + async function playFirstEpisode(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + const episode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(episode); + await fixture.whenStable(); + return episode; + } + + function setLazySeries(parentId: string, episodeId: string): void { + selectedContentType.set('vod'); + selectedItem.set({ + id: parentId, + is_series: true, + info: { name: 'Lazy series', movie_image: 'poster.jpg' }, + }); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'lazy-season', + video_id: parentId, + season_number: '2', + name: 'Season 2', + episodes: [ + { + id: episodeId, + series_number: 3, + name: 'Replacement episode', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + } + + it.each([ + STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod, + STALKER_SERIES_DOWNLOAD_MODES.LazyVod, + ])('owns a collision-safe key for %s playback', async (expectedMode) => { + if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod) { + fixture.componentRef.setInput('vodWithSeries', { + id: 'series|parent', + cmd: 'ffrt4://embedded|full-command', + series: [1, 2], + info: { name: 'Embedded series', movie_image: 'poster.jpg' }, + }); + } else if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod) { + selectedContentType.set('vod'); + selectedItem.set({ + id: 'series|parent', + is_series: true, + info: { name: 'Lazy series', movie_image: 'poster.jpg' }, + }); + vodSeasons.set([]); + fixture.detectChanges(); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'season|provider', + video_id: 'series|parent', + season_number: '1', + name: 'Season | One', + episodes: [ + { + id: 'episode|provider:full', + series_number: 1, + name: 'Pilot', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + } + + const episode = await playFirstEpisode(); + const key = fixture.componentInstance.playbackSessionKey(); + + expect(fixture.componentInstance.seriesMode()).toBe(expectedMode); + expect(key).not.toBe(''); + expect(key).not.toContain(String(episode.id)); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://alternative.example/replacement.mkv', + title: 'Replacement payload', + contentInfo: { + playlistId: 'alternative-playlist', + contentXtreamId: 987654321, + contentType: 'episode', + seriesXtreamId: 123, + seasonNumber: 9, + episodeNumber: 9, + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + }); + + it('preserves the logical key when the provider command rotates', async () => { + await playFirstEpisode(); + const firstKey = fixture.componentInstance.playbackSessionKey(); + serialSeasons.set([ + { + id: 'replacement-season', + name: 'Season 1', + cmd: 'ffrt4://different|full-command', + series: [1], + }, + ]); + + await playFirstEpisode(); + + expect(fixture.componentInstance.playbackSessionKey()).toBe(firstKey); + expect(firstKey).not.toContain('ffrt4://regular|full-command'); + expect(firstKey).not.toContain('ffrt4://different|full-command'); + }); + + it('rejects a pending command refresh but lets the refreshed episode reuse its logical key', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const pending = deferred(); + resolveVodPlayback.mockReturnValueOnce(pending.promise); + const [seasonKeyA, episodesA] = Object.entries( + fixture.componentInstance.mappedSeasons() + )[0]; + const episodeA = episodesA[0] as StalkerMappedEpisode; + const expectedKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: seasonKeyA, + seasonNumber: Number(episodeA.season) || Number(seasonKeyA), + episodeNumber: Number(episodeA.episode_num), + episode: episodeA, + previous: null, + next: null, + }, + }); + fixture.componentInstance.onEpisodeClicked(episodeA); + + serialSeasons.set([ + { + id: 'replacement-season', + name: 'Season 1', + cmd: 'ffrt4://refreshed|full-command', + series: [1, 2], + }, + ]); + fixture.detectChanges(); + const [seasonKeyB, episodesB] = Object.entries( + fixture.componentInstance.mappedSeasons() + )[0]; + const episodeB = episodesB[0] as StalkerMappedEpisode; + const refreshedKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: seasonKeyB, + seasonNumber: Number(episodeB.season) || Number(seasonKeyB), + episodeNumber: Number(episodeB.episode_num), + episode: episodeB, + previous: null, + next: episodesB[1] ?? null, + }, + }); + + expect(episodeB.originalCmd).not.toBe(episodeA.originalCmd); + expect(episodeB.id).not.toBe(episodeA.id); + expect(refreshedKey).toBe(expectedKey); + + pending.resolve({ + streamUrl: 'https://stale.example/old-command.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: episodeA.id, + contentType: 'episode', + }, + }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe(''); + expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled(); + + fixture.componentInstance.onEpisodeClicked(episodeB); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenLastCalledWith( + 'ffrt4://refreshed|full-command', + 'Series', + 'poster.jpg', + 1, + Number(episodeB.id), + undefined + ); + expect(fixture.componentInstance.inlinePlayback()).not.toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + expectedKey + ); + }); + + it('selects the exact clicked episode when distinct provider commands collide in the tracking hash', async () => { + serialSeasons.set([ + { + id: 'collision-a', + name: 'Season A', + cmd: 'collision-cmd-1000', + series: [1], + }, + { + id: 'collision-b', + name: 'Season B', + cmd: 'collision-cmd-425721', + series: [1], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + const [episodeA, episodeB] = Object.values( + fixture.componentInstance.mappedSeasons() + ).map((episodes) => episodes[0] as StalkerMappedEpisode); + + expect(episodeA.id).toBe('1511026353'); + expect(episodeB.id).toBe(episodeA.id); + + fixture.componentInstance.onEpisodeClicked(episodeB); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenLastCalledWith( + 'collision-cmd-425721', + 'Series', + 'poster.jpg', + 1, + 1511026353, + undefined + ); + const expectedBKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: '2', + seasonNumber: 2, + episodeNumber: 1, + episode: episodeB, + previous: null, + next: null, + }, + }); + const wrongAKey = createStalkerEpisodePlaybackSessionKey({ + sourceId: 'stalker|playlist', + parentSeriesId: 'series|parent', + seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries, + episodeState: { + seasonKey: '1', + seasonNumber: 1, + episodeNumber: 1, + episode: episodeA, + previous: null, + next: null, + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + expectedBKey + ); + expect(expectedBKey).not.toBe(wrongAKey); + }); + + it.each(['parent', 'mode', 'playlist'] as const)( + 'clears committed playback when the canonical %s owner changes', + async (transition) => { + await playFirstEpisode(); + expect(fixture.componentInstance.inlinePlayback()).not.toBeNull(); + + if (transition === 'parent') { + selectedItem.set({ + ...selectedItem()!, + id: 'replacement-parent', + }); + } else if (transition === 'mode') { + selectedContentType.set('vod'); + selectedItem.set({ ...selectedItem()!, is_series: true }); + } else { + currentPlaylist.set({ + ...currentPlaylist(), + _id: 'replacement-playlist', + }); + } + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe(''); + } + ); + + it('preserves committed playback across a same-owner object refresh', async () => { + await playFirstEpisode(); + const playback = fixture.componentInstance.inlinePlayback(); + const key = fixture.componentInstance.playbackSessionKey(); + + selectedItem.set({ ...selectedItem()! }); + currentPlaylist.set({ ...currentPlaylist() }); + serialSeasons.set( + serialSeasons().map((season) => ({ + ...(season as Record), + })) + ); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + }); + + it('re-resolves mounted episode metadata and neighbors after a same-owner season refresh', async () => { + serialSeasons.set([ + { + id: 'regular-season', + name: 'Season 1', + cmd: 'ffrt4://regular|full-command', + series: [1, 2, 3], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + const initialEpisodes = Object.values( + fixture.componentInstance.mappedSeasons() + )[0] as StalkerMappedEpisode[]; + fixture.componentInstance.onEpisodeClicked(initialEpisodes[1]); + await fixture.whenStable(); + const key = fixture.componentInstance.playbackSessionKey(); + + serialSeasons.set([ + { + id: 'refreshed-season', + name: 'Season 1', + cmd: 'ffrt4://refreshed|full-command', + series: [1, 2, 3], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + const refreshedEpisodes = Object.values( + fixture.componentInstance.mappedSeasons() + )[0] as StalkerMappedEpisode[]; + const state = fixture.componentInstance.inlineEpisodeState(); + + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + expect(state?.episode).toBe(refreshedEpisodes[1]); + expect(state?.previous).toBe(refreshedEpisodes[0]); + expect(state?.next).toBe(refreshedEpisodes[2]); + + fixture.componentInstance.handleInlinePlaybackEnded(); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenCalledTimes(2); + expect(resolveVodPlayback).toHaveBeenLastCalledWith( + 'ffrt4://refreshed|full-command', + 'Series', + 'poster.jpg', + 3, + Number(refreshedEpisodes[2].id), + undefined + ); + }); + + it('re-resolves mounted episode metadata after same-owner TMDB enrichment', async () => { + await playFirstEpisode(); + const key = fixture.componentInstance.playbackSessionKey(); + tmdbGetSeason.mockResolvedValue({ + overview: 'Season overview', + episodes: [{ episode_number: 1, name: 'TMDB Pilot' }], + }); + const item = selectedItem(); + if (!item) throw new Error('Expected the selected series'); + selectedItem.set({ + ...item, + info: { ...item.info, tmdb_id: 314 }, + }); + + fixture.componentInstance.onSeasonSelected('1'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const currentEpisode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0]; + + expect(tmdbGetSeason).toHaveBeenCalledWith(314, 1); + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + expect(fixture.componentInstance.inlineEpisodeState()?.episode).toBe( + currentEpisode + ); + expect(fixture.componentInstance.inlineEpisodeMetadata()?.title).toBe( + 'TMDB Pilot' + ); + }); + + it('uses the current mapped episode when a stale lazy-series event resolves', async () => { + selectedContentType.set('vod'); + selectedItem.set({ + id: 'series|parent', + is_series: true, + info: { + name: 'Lazy series', + movie_image: 'poster.jpg', + tmdb_id: 2718, + }, + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.vodSeriesSeasons.set([ + { + id: 'lazy-season', + video_id: 'series|parent', + season_number: '2', + name: 'Season 2', + episodes: [ + { + id: 'provider-episode', + series_number: 3, + name: 'Episode 3', + }, + ], + isLoading: false, + isExpanded: false, + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + const staleEpisode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + tmdbGetSeason.mockResolvedValue({ + episodes: [{ episode_number: 3, name: 'Current TMDB title' }], + }); + + fixture.componentInstance.onSeasonSelected('2'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect( + Object.values(fixture.componentInstance.mappedSeasons())[0][0].title + ).toBe('Current TMDB title'); + + fixture.componentInstance.onEpisodeClicked(staleEpisode); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenLastCalledWith( + '/media/file_provider-episode.mpg', + 'Lazy series - Current TMDB title', + 'poster.jpg', + 3, + expect.any(Number), + undefined + ); + }); + + it('fails mounted episode commands closed when a same-owner refresh removes the episode', async () => { + serialSeasons.set([ + { + id: 'regular-season', + name: 'Season 1', + cmd: 'ffrt4://regular|full-command', + series: [1, 2, 3], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + const episodes = Object.values( + fixture.componentInstance.mappedSeasons() + )[0] as StalkerMappedEpisode[]; + fixture.componentInstance.onEpisodeClicked(episodes[1]); + await fixture.whenStable(); + const key = fixture.componentInstance.playbackSessionKey(); + + serialSeasons.set([ + { + id: 'refreshed-season', + name: 'Season 1', + cmd: 'ffrt4://refreshed|full-command', + series: [1], + }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + expect(fixture.componentInstance.inlineEpisodeState()).toBeNull(); + expect(fixture.componentInstance.inlineEpisodeMetadata()).toBeNull(); + expect(fixture.componentInstance.inlineSeriesNavigation()).toBeNull(); + + fixture.componentInstance.playPreviousEpisode(); + fixture.componentInstance.playNextEpisode(); + fixture.componentInstance.handleInlinePlaybackEnded(); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenCalledTimes(1); + }); + + it('rejects a completion after its playlist, parent, mode, and episode owner change', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const pending = deferred(); + resolveVodPlayback.mockReturnValueOnce(pending.promise); + const episode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(episode); + + currentPlaylist.set({ + ...currentPlaylist(), + _id: 'replacement-playlist', + }); + setLazySeries('replacement-parent', 'replacement-provider-episode'); + pending.resolve({ + streamUrl: 'https://stale.example/old-episode.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: episode.id, + contentType: 'episode', + }, + }); + await fixture.whenStable(); + + expect(fixture.componentInstance.inlinePlayback()).toBeNull(); + expect(fixture.componentInstance.playbackSessionKey()).toBe(''); + }); + + it('keeps the newest episode when owner-scoped requests resolve out of order', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + const oldRequest = deferred(); + const newRequest = deferred(); + resolveVodPlayback + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(newRequest.promise); + const oldEpisode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(oldEpisode); + + currentPlaylist.set({ ...currentPlaylist(), _id: 'new-playlist' }); + setLazySeries('new-parent', 'new-provider-episode'); + const newEpisode = Object.values( + fixture.componentInstance.mappedSeasons() + )[0][0] as StalkerMappedEpisode; + fixture.componentInstance.onEpisodeClicked(newEpisode); + const newestPlayback = { + streamUrl: 'https://new.example/episode.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: newEpisode.id, + contentType: 'episode' as const, + }, + }; + newRequest.resolve(newestPlayback); + oldRequest.resolve({ + streamUrl: 'https://old.example/episode.mpg', + contentInfo: { + playlistId: 'transport-playlist', + contentXtreamId: oldEpisode.id, + contentType: 'episode', + }, + }); + await fixture.whenStable(); + + expect(resolveVodPlayback).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.inlinePlayback()?.streamUrl).toBe( + newestPlayback.streamUrl + ); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe(''); + }); + + it('preserves a valid mounted episode when original identity is missing', async () => { + await playFirstEpisode(); + const playback = fixture.componentInstance.inlinePlayback(); + const key = fixture.componentInstance.playbackSessionKey(); + serialSeasons.set([ + { + id: 'identity-less-season', + name: 'Season 1', + series: [1], + }, + ]); + + await playFirstEpisode(); + + expect(fixture.componentInstance.inlinePlayback()).toBe(playback); + expect(fixture.componentInstance.playbackSessionKey()).toBe(key); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts index ba1c94ae5..152bcb820 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.spec.ts @@ -80,6 +80,7 @@ function createController() { return { controller, + inlinePlayback, playbackPositions, selectedVodPosition, }; @@ -115,4 +116,31 @@ describe('StalkerVodPlaybackController', () => { expect(selectedVodPosition()?.contentXtreamId).toBe(202); expect(selectedVodPosition()?.positionSeconds).toBe(20); }); + + it('does not mount playback that resolves after the detail closes', async () => { + const { controller, inlinePlayback } = createController(); + const pending = createDeferred<{ streamUrl: string }>(); + const playback = controller.startVodPlayback(() => pending.promise); + + controller.closeInlinePlayer(); + pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' }); + await playback; + + expect(inlinePlayback()).toBeNull(); + }); + + it('keeps the newest VOD request when resolutions finish out of order', async () => { + const { controller, inlinePlayback } = createController(); + const older = createDeferred<{ streamUrl: string }>(); + const newer = createDeferred<{ streamUrl: string }>(); + const olderRequest = controller.startVodPlayback(() => older.promise); + const newerRequest = controller.startVodPlayback(() => newer.promise); + const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' }; + + newer.resolve(newestPlayback); + older.resolve({ streamUrl: 'https://old.example/movie.mpg' }); + await Promise.all([olderRequest, newerRequest]); + + expect(inlinePlayback()).toBe(newestPlayback); + }); }); diff --git a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts index ed1f5df40..2d477d356 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-vod-playback-controller.ts @@ -21,22 +21,30 @@ interface StalkerVodPlaybackControllerConfig { translateService: TranslateService; logger: Logger; playbackErrorLogMessage: string; + playbackOwnerKey?: () => string; } export class StalkerVodPlaybackController { private lastInlineSaveTime = 0; private loadSelectedVodPositionRequestId = 0; + private playbackRequestId = 0; constructor(private readonly config: StalkerVodPlaybackControllerConfig) {} async startVodPlayback( resolvePlayback: () => Promise ): Promise { + const requestId = ++this.playbackRequestId; + const usesEmbeddedPlayer = this.config.portalPlayer.isEmbeddedPlayer(); + const playbackOwnerKey = this.config.playbackOwnerKey?.(); try { const playback = await resolvePlayback(); + if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) { + return; + } this.lastInlineSaveTime = 0; - if (this.config.portalPlayer.isEmbeddedPlayer()) { + if (usesEmbeddedPlayer) { this.config.inlinePlayback.set(playback); return; } @@ -44,6 +52,9 @@ export class StalkerVodPlaybackController { this.closeInlinePlayer(); void this.config.portalPlayer.openResolvedPlayback(playback, true); } catch (error) { + if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) { + return; + } this.config.logger.error( this.config.playbackErrorLogMessage, error @@ -115,10 +126,22 @@ export class StalkerVodPlaybackController { } closeInlinePlayer(): void { + this.playbackRequestId += 1; this.config.inlinePlayback.set(null); this.lastInlineSaveTime = 0; } + private isPlaybackRequestCurrent( + requestId: number, + playbackOwnerKey: string | undefined + ): boolean { + return ( + requestId === this.playbackRequestId && + (this.config.playbackOwnerKey === undefined || + this.config.playbackOwnerKey() === playbackOwnerKey) + ); + } + showCopyNotification(): void { this.config.snackBar.open( this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'), diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 8b2d57657..61b006185 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -96,6 +96,7 @@ @if (isEmbeddedPlayer) {
(); readonly streamUrl = input(''); readonly title = input(''); readonly playback = input(null); - readonly externalFallbackRequested = output(); + readonly externalFallbackRequested = output(); } // Matches both live-panel selectors so the host's timeline ↔ list swap can be @@ -195,6 +204,7 @@ describe('LiveStreamLayoutComponent', () => { }; const portalPlayer = { isEmbeddedPlayer: jest.fn().mockReturnValue(true), + openExternalPlayback: jest.fn(), }; const settingsStore = { openStreamOnDoubleClick: signal(false), @@ -206,6 +216,7 @@ describe('LiveStreamLayoutComponent', () => { const originalElectron = window.electron; beforeEach(async () => { + currentPlaylist.set(playlist); jest.useFakeTimers(); jest.setSystemTime(fixedNow); settingsStore.resolvedEpgViewMode.set('timeline'); @@ -240,6 +251,7 @@ describe('LiveStreamLayoutComponent', () => { xtreamUrlService.resolveCatchupUrl.mockClear(); portalPlayer.isEmbeddedPlayer.mockReset(); portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + portalPlayer.openExternalPlayback.mockClear(); epgItems.set([]); currentEpgItem.set(null); @@ -590,6 +602,58 @@ describe('LiveStreamLayoutComponent', () => { ).not.toBeNull(); }); + it('keeps the host-owned live key across URL replacement and changes it with the channel', () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + currentPlaylist.set({ ...playlist, id: 'playlist|one' }); + const first = { ...sampleChannel, xtream_id: 101 }; + component.playLive(first); + fixture.detectChanges(); + + const webPlayer = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + const expected = createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist|one', + contentId: 101, + }); + expect(webPlayer.playbackSessionKey()).toBe(expected); + + xtreamStore.constructStreamUrl.mockReturnValueOnce( + 'https://example.com/replaced-timeshift.ts' + ); + component.playLive(first); + fixture.detectChanges(); + expect(webPlayer.playbackSessionKey()).toBe(expected); + + component.playLive({ ...first, xtream_id: 102 }); + fixture.detectChanges(); + expect(webPlayer.playbackSessionKey()).not.toBe(expected); + }); + + it('forwards the exact resolved live playback to external fallback', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.com/fallback.m3u8', + title: 'Channel 101', + isLive: true, + headers: { Authorization: 'Bearer token' }, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 101, + contentType: 'live', + }, + }; + component.handleExternalFallbackRequest({ + player: 'mpv', + playback, + } as PlaybackFallbackRequest); + + const [forwardedPlayback, forwardedPlayer] = + portalPlayer.openExternalPlayback.mock.calls[0]; + expect(forwardedPlayback).toBe(playback); + expect(forwardedPlayer).toBe('mpv'); + }); + it('updates live root pagination from the header paginator', () => { selectedCategoryId.set(null); selectedTypeContentLoading.set(false); @@ -794,17 +858,44 @@ describe('LiveStreamLayoutComponent', () => { ); }); - it('shows the active archive program in the live EPG panel summary', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, + it('does not attach a pending catchup result to a newer live owner', async () => { + let resolveCatchup!: (url: string) => void; + xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce( + new Promise((resolve) => { + resolveCatchup = resolve; + }) + ); + component.playLive(sampleChannel); + const catchup = component.onProgramActivated({ + type: 'timeshift', + program: buildArchivedProgram(), + }); + const newerChannel = { + ...sampleChannel, + xtream_id: 202, + name: 'Channel 202', }; + xtreamStore.constructStreamUrl.mockReturnValueOnce( + 'https://example.com/channel-202.ts' + ); + component.playLive(newerChannel); + resolveCatchup('https://stale.example/channel-101-timeshift.ts'); + await catchup; + + expect(component.activePlayback()?.streamUrl).toBe( + 'https://example.com/channel-202.ts' + ); + expect(component.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'live', + sourceId: 'playlist-1', + contentId: 202, + }) + ); + }); + + it('shows the active archive program in the live EPG panel summary', async () => { + const archivedProgram = buildArchivedProgram(); epgItems.set([ buildEpgItem( 'archived', @@ -854,16 +945,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('returns archive playback to the selected live stream from the panel action', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, - }; + const archivedProgram = buildArchivedProgram(); currentEpgItem.set( buildEpgItem( 'current', @@ -893,7 +975,9 @@ describe('LiveStreamLayoutComponent', () => { isLive: true, }) ); - expect(timeline.componentInstance.summary()?.title).toBe('Current Show'); + expect(timeline.componentInstance.summary()?.title).toBe( + 'Current Show' + ); expect(timeline.componentInstance.summaryLabelKey()).toBe( 'EPG.CURRENT_PROGRAM' ); @@ -901,16 +985,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('publishes the active archive program in remote-control status', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, - }; + const archivedProgram = buildArchivedProgram(); currentEpgItem.set( buildEpgItem( 'current', @@ -942,16 +1017,7 @@ describe('LiveStreamLayoutComponent', () => { }); it('passes the active catchup program to the EPG list until live playback resumes', async () => { - const archivedProgram: EpgProgram = { - start: '2026-04-04T10:00:00.000Z', - stop: '2026-04-04T11:00:00.000Z', - channel: 'channel-101', - title: 'Archived Show', - desc: null, - category: null, - startTimestamp: 1775296800, - stopTimestamp: 1775300400, - }; + const archivedProgram = buildArchivedProgram(); epgItems.set([ buildEpgItem( '1', @@ -1066,7 +1132,9 @@ describe('LiveStreamLayoutComponent', () => { const timeline = fixture.debugElement.query( By.directive(StubEpgTimelineComponent) ); - expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true); + expect(timeline.componentInstance.archivePlaybackAvailable()).toBe( + true + ); }); it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => { @@ -1259,6 +1327,15 @@ describe('LiveStreamLayoutComponent', () => { }); }); +const buildArchivedProgram = (): EpgProgram => ({ + start: '2026-04-04T10:00:00.000Z', + stop: '2026-04-04T11:00:00.000Z', + channel: 'channel-101', + title: 'Archived Show', + desc: null, + category: null, +}); + function buildEpgItem( id: string, title: string, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index cec491234..bbb34c0c4 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -70,6 +70,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode'; @@ -269,11 +270,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { private unsubscribeRemoteChannelChange?: () => void; private unsubscribeRemoteCommand?: () => void; + private playbackRequestId = 0; readonly usesEmbeddedPlayer = computed(() => this.portalPlayer.isEmbeddedPlayer() ); readonly activePlayback = signal(null); + private readonly activeLiveItemId = signal(null); + readonly playbackSessionKey = computed(() => { + const sourceId = this.xtreamStore.currentPlaylist()?.id; + const contentId = this.activeLiveItemId(); + return sourceId && contentId + ? createPlaybackSessionKey({ kind: 'live', sourceId, contentId }) + : ''; + }); readonly activeStreamUrl = computed( () => this.activePlayback()?.streamUrl ?? '' ); @@ -435,12 +445,14 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { item: XtreamLiveChannelItem, startPlayback = !this.settingsStore.openStreamOnDoubleClick() ) { + this.playbackRequestId += 1; const streamUrl = this.xtreamStore.constructStreamUrl(item); this.activeCatchupProgram.set(null); // Keep both root/recently-added playback and same-category replays in // sync with the category rail. For already-selected channels this is a // store no-op. this.selectLiveItemCategory(item); + this.activeLiveItemId.set(item.xtream_id); this.activePlayback.set({ streamUrl, title: item.title ?? item.name ?? '', @@ -541,6 +553,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this.playbackRequestId += 1; this.unsubscribeRemoteChannelChange?.(); this.unsubscribeRemoteCommand?.(); } @@ -643,6 +656,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { return; } + const requestId = ++this.playbackRequestId; + this.activeLiveItemId.set(item.xtream_id); const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl( playlist.id, { @@ -656,6 +671,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy { stopTimestamp, playlist.serverTimezone ); + if ( + requestId !== this.playbackRequestId || + this.xtreamStore.currentPlaylist()?.id !== playlist.id || + this.activeLiveItemId() !== item.xtream_id + ) { + return; + } this.activeCatchupProgram.set(program); this.activePlayback.set({ diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 8669ed032..134a8378e 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -71,6 +71,8 @@ export class SerialDetailsPlaybackService { private lastSaveTime = 0; readonly inlinePlayback = signal(null); + readonly inlinePlaybackSessionEpisodeState = + signal | null>(null); readonly episodePlaybackPositions = this.playbackPositionState.positions; readonly openingEpisodeId = signal(null); readonly activeEpisodeId = signal(null); @@ -221,7 +223,13 @@ export class SerialDetailsPlaybackService { contentInfo, }; - this.startPlayback(playback); + const episodeState = resolveSeriesPlaybackEpisodeState({ + episodesBySeason: selectedItem.episodes, + currentEpisodeId: episode.id, + fallbackSeasonNumber: Number(episode.season), + fallbackEpisodeNumber: Number(episode.episode_num), + }); + this.startPlayback(playback, episodeState); } playQuickStartEpisode(): void { @@ -259,6 +267,7 @@ export class SerialDetailsPlaybackService { closeInlinePlayer(): void { this.inlinePlayback.set(null); + this.inlinePlaybackSessionEpisodeState.set(null); this.lastSaveTime = 0; } @@ -350,9 +359,13 @@ export class SerialDetailsPlaybackService { }); } - private startPlayback(playback: ResolvedPortalPlayback): void { + private startPlayback( + playback: ResolvedPortalPlayback, + episodeState: SeriesPlaybackEpisodeState | null + ): void { this.lastSaveTime = 0; if (this.portalPlayer.isEmbeddedPlayer()) { + this.inlinePlaybackSessionEpisodeState.set(episodeState); this.inlinePlayback.set(playback); return; } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index cbe1231ba..12ca6c012 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -179,6 +179,7 @@ @if (inlinePlayback(); as playback) { (); readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesTitle = input(null); @@ -108,6 +110,10 @@ describe('SerialDetailsComponent', () => { let seriesResumeTarget: ReturnType< typeof signal >; + let routeParams: BehaviorSubject<{ + categoryId: string; + serialId: string; + }>; beforeEach(async () => { window.history.replaceState({}, '', window.location.href); @@ -174,6 +180,10 @@ describe('SerialDetailsComponent', () => { getSeriesPlaybackPositions.mockClear(); getSeriesPlaybackPositions.mockResolvedValue([]); seriesResumeTarget = signal(null); + routeParams = new BehaviorSubject({ + categoryId: '3', + serialId: '103', + }); await TestBed.configureTestingModule({ imports: [SerialDetailsComponent], @@ -181,10 +191,7 @@ describe('SerialDetailsComponent', () => { { provide: ActivatedRoute, useValue: { - params: of({ - categoryId: '3', - serialId: '103', - }), + params: routeParams, snapshot: { params: { categoryId: '3', @@ -846,6 +853,15 @@ describe('SerialDetailsComponent', () => { seasonNumber: 1, episodeNumber: 1, }); + const firstEpisodeKey = createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'xtream-1', + contentId: 1001, + seriesId: 103, + seasonNumber: 1, + episodeNumber: 1, + }); + expect(inlinePlayer.playbackSessionKey()).toBe(firstEpisodeKey); expect(inlinePlayer.seriesNavigation()).toEqual({ canPrevious: false, canNext: true, @@ -874,6 +890,7 @@ describe('SerialDetailsComponent', () => { canNext: false, autoplayEnabled: true, }); + expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey); inlinePlayer.playbackEnded.emit(); fixture.detectChanges(); @@ -887,4 +904,59 @@ describe('SerialDetailsComponent', () => { expect.objectContaining({ id: '2001' }) ); }); + + it('owns the parent identity from the route and ignores replacement playback payloads', async () => { + isEmbeddedPlayer.mockReturnValue(true); + fixture.detectChanges(); + await fixture.whenStable(); + const item = fixture.componentInstance.selectedItem(); + const episode = item?.episodes?.['1'][0]; + if (!item || !episode) { + throw new Error('Expected the serial fixture and first episode'); + } + fixture.componentInstance.playEpisode(episode); + fixture.detectChanges(); + const expected = createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'xtream-1', + contentId: 1001, + seriesId: 103, + seasonNumber: 1, + episodeNumber: 1, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://alternative.example/replaced.mkv', + title: 'Alternative payload', + headers: { Authorization: 'Bearer replacement' }, + contentInfo: { + playlistId: 'alternative-playlist', + contentXtreamId: 999001, + contentType: 'episode', + seriesXtreamId: 999, + seasonNumber: 9, + episodeNumber: 9, + }, + }); + expect(fixture.componentInstance.playbackSessionKey()).toBe(expected); + + selectedItem.set({ ...item, series_id: 999 }); + routeParams.next({ categoryId: '3', serialId: '104' }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance.playEpisode(episode); + fixture.detectChanges(); + + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'episode', + sourceId: 'xtream-1', + contentId: 1001, + seriesId: 104, + seasonNumber: 1, + episodeNumber: 1, + }) + ); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index f004af19b..382c16ff1 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -53,6 +53,7 @@ import { } from '../tmdb-similar.util'; import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata'; import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter'; +import { createSerialPlaybackSessionKey } from './serial-playback-session-key'; @Component({ selector: 'app-serial-details', @@ -152,6 +153,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly quickStartAction = this.playback.quickStartAction; readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata; readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation; + readonly playbackSessionKey = computed(() => + createSerialPlaybackSessionKey( + this.xtreamStore.currentPlaylist()?.id, + this.routeParams().serialId, + this.playback.inlinePlaybackSessionEpisodeState() + ) + ); /** "Up Next" rail entries for the inline player (series only). */ readonly upNextEpisodes = computed(() => buildUpNextRailItems({ diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts new file mode 100644 index 000000000..fe7924547 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-playback-session-key.ts @@ -0,0 +1,28 @@ +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; +import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback'; + +export function createSerialPlaybackSessionKey( + sourceId: string | undefined, + seriesId: number | string | undefined, + episodeState: SeriesPlaybackEpisodeState | null +): string { + const normalizedSeriesId = Number(seriesId); + if ( + !sourceId || + !Number.isSafeInteger(normalizedSeriesId) || + normalizedSeriesId <= 0 || + !episodeState + ) { + return ''; + } + + return createPlaybackSessionKey({ + kind: 'episode', + sourceId, + contentId: episodeState.episode.id, + seriesId: normalizedSeriesId, + seasonNumber: episodeState.seasonNumber, + episodeNumber: episodeState.episodeNumber, + }); +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 250f71fed..e24bc806d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -27,6 +27,7 @@ import { import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; import { BehaviorSubject, NEVER, of } from 'rxjs'; import { VodDetailsRouteComponent } from './vod-details-route.component'; +import { createPlaybackSessionKey } from '@iptvnator/playback/util'; @Component({ selector: 'app-portal-inline-player', @@ -34,6 +35,7 @@ import { VodDetailsRouteComponent } from './vod-details-route.component'; template: '
', }) class StubPortalInlinePlayerComponent { + readonly playbackSessionKey = input.required(); readonly playback = input(null); // Multi-source wiring: the real player takes these, so the stub has to // accept them or the template binding fails to compile. @@ -369,6 +371,25 @@ describe('VodDetailsRouteComponent fallback actions', () => { thumbnail: 'https://example.com/catalog-poster.jpg', }) ); + const expectedKey = createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist-1', + contentId: 650020, + }); + expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey); + + fixture.componentInstance.inlinePlayback.set({ + streamUrl: 'https://copy.example/movie/9001.mkv', + title: 'Catalog movie', + headers: { 'User-Agent': 'Copy Provider' }, + contentInfo: { + playlistId: 'copy-playlist', + contentXtreamId: 9001, + contentType: 'vod', + }, + }); + fixture.detectChanges(); + expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey); expect( host.querySelector('app-portal-detail-shell')?.classList ).toContain('shell-host--watch'); @@ -581,6 +602,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { selectedItem.set(richItem()); fixture.detectChanges(); expect(fixture.componentInstance.providerOnly()).toBe(true); + const firstSessionKey = fixture.componentInstance.playbackSessionKey(); window.history.replaceState({}, '', window.location.href); selectedItem.set({ @@ -594,6 +616,16 @@ describe('VodDetailsRouteComponent fallback actions', () => { fixture.detectChanges(); expect(fixture.componentInstance.providerOnly()).toBe(false); + expect(fixture.componentInstance.playbackSessionKey()).not.toBe( + firstSessionKey + ); + expect(fixture.componentInstance.playbackSessionKey()).toBe( + createPlaybackSessionKey({ + kind: 'vod', + sourceId: 'playlist-1', + contentId: 650021, + }) + ); }); it('plays a rich downloaded movie locally without a usable provider source', async () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 536d415e6..c1b46def5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -254,6 +254,7 @@ @if (playableVodItem()) { @if (inlinePlayback(); as playback) { Number(this.routeParams().vodId)); + readonly playbackSessionKey = computed(() => { + const sourceId = this.xtreamStore.currentPlaylist()?.id; + const contentId = this.selectedVodId(); + return sourceId && Number.isFinite(contentId) && contentId > 0 + ? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId }) + : ''; + }); readonly providerOnly = computed(() => { this.routeParams(); return isProviderOnlyDetailState(window.history.state); diff --git a/libs/ui/playback/src/index.ts b/libs/ui/playback/src/index.ts index f06705daa..73366aa88 100644 --- a/libs/ui/playback/src/index.ts +++ b/libs/ui/playback/src/index.ts @@ -5,7 +5,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-player.component'; export * from './lib/embedded-mpv-player/embedded-mpv-session-controller'; export * from './lib/external-player-info-dialog/external-player-info-dialog.component'; export * from './lib/html-video-player/html-video-player.component'; -export * from './lib/playback-diagnostics/playback-diagnostics.util'; +export * from '@iptvnator/playback/util'; export * from './lib/player-controls'; export * from './lib/portal-inline-player/portal-inline-player.component'; export * from './lib/portal-inline-player/series-playback-navigation'; diff --git a/libs/ui/playback/src/lib/art-player/art-player-setup.ts b/libs/ui/playback/src/lib/art-player/art-player-setup.ts index 9e78fe990..3cd91304e 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-setup.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-setup.ts @@ -1,4 +1,4 @@ -import { getPlaybackMediaExtensionFromUrl } from '../playback-diagnostics/playback-diagnostics.util'; +import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util'; /** * ArtPlayer option overrides for the legacy and shared-control surfaces. diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts index e0c839e19..102282f24 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.dash.spec.ts @@ -4,7 +4,7 @@ import { type PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from '@iptvnator/playback/util'; import { createFakeShakaEnvironment, flushShakaMicrotasks, diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts index 4d97dec50..239351d13 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec-fixtures.ts @@ -1,6 +1,6 @@ import type Artplayer from 'artplayer'; import type { ChannelDrm } from '@iptvnator/shared/interfaces'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import { WebVideoControlsAdapter } from '../player-controls'; import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types'; import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session'; diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts index fcf24cf3b..4f3515791 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.spec.ts @@ -1,4 +1,4 @@ -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import { MockHls, MockMpegTsPlayer, @@ -90,6 +90,84 @@ describe('ArtPlayerSourceSession', () => { expect(attach).not.toHaveBeenCalled(); }); + it('uses browser media-type support for HLS manifest codec diagnostics', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { isTypeSupported: jest.fn(() => false) }, + }); + const emitted: PlaybackDiagnostic[] = []; + const { session, player, video } = createSession({ + sharedControls: true, + emitPlaybackIssue: (issue) => emitted.push(issue), + }); + session.attach(player); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + + try { + hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, { + levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }], + }); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(emitted).toEqual([ + expect.objectContaining({ + code: 'unsupported-codec', + source: 'source', + }), + ]); + }); + + it('keeps HLS manifest handling alive when the browser support probe throws', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { + isTypeSupported: () => { + throw new Error('provider-controlled-codec'); + }, + }, + }); + const emitted: PlaybackDiagnostic[] = []; + const { session, player, video } = createSession({ + sharedControls: true, + emitPlaybackIssue: (issue) => emitted.push(issue), + }); + session.attach(player); + session.customType['m3u8']?.( + video, + 'https://example.test/live.m3u8', + player + ); + + try { + expect(() => + hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, { + levels: [ + { + audioCodec: 'provider-controlled-codec', + videoCodec: 'avc1.64001f', + }, + ], + }) + ).not.toThrow(); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(emitted).toEqual([]); + }); + it('uses authoritative VOD metadata for shared MPEG-TS and catches autoplay rejection', async () => { const { session, player, video, adapter } = createSession({ sharedControls: true, @@ -299,3 +377,11 @@ describe('ArtPlayerSourceSession', () => { expect(refresh).toHaveBeenCalled(); }); }); + +function restoreMediaSource(descriptor?: PropertyDescriptor): void { + if (descriptor) { + Object.defineProperty(globalThis, 'MediaSource', descriptor); + return; + } + Reflect.deleteProperty(globalThis, 'MediaSource'); +} diff --git a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts index 5c71da94b..226c05daa 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-source-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-source-session.ts @@ -12,7 +12,8 @@ import { createHlsPlaybackEvidence, createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; +import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support'; import type { WebVideoControlsAdapter } from '../player-controls'; import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types'; import { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; @@ -307,7 +308,10 @@ export class ArtPlayerSourceSession { .map((level) => level.videoCodec) .filter((codec): codec is string => Boolean(codec)) ); - const issue = classifyUnsupportedHlsManifestCodecs(metadata); + const issue = classifyUnsupportedHlsManifestCodecs( + metadata, + isBrowserMediaTypeSupported + ); if (issue) { this.config.emitPlaybackIssue(issue); } diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts index 20bf5ef20..c01485bf4 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts @@ -4,7 +4,7 @@ import { type PlaybackDiagnostic, classifyNativePlaybackIssue, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; export interface ArtPlayerVideoSessionConfig { player: Artplayer; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts index 60f0f0bcb..166deb8b6 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.spec.ts @@ -93,7 +93,6 @@ describe('ArtPlayerComponent', () => { code: 'unsupported-container', source: 'native', sourceUrl: 'https://example.com/archive/movie.mkv', - externalFallbackRecommended: true, }), ]); }); @@ -124,7 +123,6 @@ describe('ArtPlayerComponent', () => { code: 'unsupported-codec', source: 'hls', sourceUrl: 'https://example.com/live/playlist.m3u8', - externalFallbackRecommended: true, }), ]); }); @@ -256,7 +254,6 @@ describe('ArtPlayerComponent', () => { engineDetails: 'CodecUnsupported', failure: 'codec', }), - externalFallbackRecommended: true, }), ]); expect(JSON.stringify(issues)).not.toContain(secret); diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 61cb5235f..8b659eba0 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -13,7 +13,7 @@ import { } from '@angular/core'; import Artplayer from 'artplayer'; import { Channel, createDevLogger } from '@iptvnator/shared/interfaces'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import { PlayerControlsComponent, type PlayerMediaTitle, diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index 3e320c19b..ef9593c54 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -4,7 +4,7 @@ import { type PlaybackDiagnostic, classifyNativePlaybackIssue, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts index 048176b95..ac3da7102 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-diagnostics.ts @@ -9,7 +9,8 @@ import { createHlsPlaybackEvidence, createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; +import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support'; /** * HTML5-player diagnostics glue extracted from the component: builds source @@ -46,7 +47,10 @@ export function emitUnsupportedHlsManifestCodecs( .map((level) => level.videoCodec) .filter((codec): codec is string => Boolean(codec)) ); - const issue = classifyUnsupportedHlsManifestCodecs(metadata); + const issue = classifyUnsupportedHlsManifestCodecs( + metadata, + isBrowserMediaTypeSupported + ); if (issue) { emitPlaybackIssue(issue); } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts index ac5f748d5..a0ad2352f 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts @@ -154,7 +154,6 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { failure: 'http', httpStatus: 404, }), - externalFallbackRecommended: false, }), ]); expect(JSON.stringify(issues)).not.toContain(secret); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index cd1f8f540..db0fda8fd 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -4,7 +4,12 @@ import { By } from '@angular/platform-browser'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; import { Channel } from '@iptvnator/shared/interfaces'; -import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js'; +import { + ErrorDetails, + ErrorTypes, + type ErrorData, + type ManifestParsedData, +} from 'hls.js'; import { PlayerControlsComponent, WebVideoControlsAdapter, @@ -198,11 +203,90 @@ describe('HtmlVideoPlayerComponent', () => { code: 'unsupported-container', source: 'native', sourceUrl: 'http://test.ts', - externalFallbackRecommended: true, }), ]); }); + it('uses browser media-type support for HLS manifest codec diagnostics', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { isTypeSupported: jest.fn(() => false) }, + }); + const issues: unknown[] = []; + component.playbackIssue.subscribe((issue) => issues.push(issue)); + + try { + ( + component as unknown as { + handleHlsManifestParsed: ( + url: string, + data: ManifestParsedData + ) => void; + } + ).handleHlsManifestParsed( + 'https://example.com/live/playlist.m3u8', + { + levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }], + } as ManifestParsedData + ); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(issues).toEqual([ + expect.objectContaining({ + code: 'unsupported-codec', + source: 'source', + }), + ]); + }); + + it('keeps HLS manifest handling alive when the browser support probe throws', () => { + const mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value: { + isTypeSupported: () => { + throw new Error('provider-controlled-codec'); + }, + }, + }); + const issues: unknown[] = []; + component.playbackIssue.subscribe((issue) => issues.push(issue)); + + try { + expect(() => + ( + component as unknown as { + handleHlsManifestParsed: ( + url: string, + data: ManifestParsedData + ) => void; + } + ).handleHlsManifestParsed( + 'https://example.com/live/playlist.m3u8', + { + levels: [ + { + audioCodec: 'provider-controlled-codec', + videoCodec: 'avc1.64001f', + }, + ], + } as ManifestParsedData + ) + ).not.toThrow(); + } finally { + restoreMediaSource(mediaSourceDescriptor); + } + expect(issues).toEqual([]); + }); + it('does not emit a playback issue when HLS.js reports a recoverable error', () => { const issues: unknown[] = []; component.playbackIssue.subscribe((issue) => { @@ -355,3 +439,11 @@ describe('HtmlVideoPlayerComponent', () => { expect(events).toEqual(['previous']); }); }); + +function restoreMediaSource(descriptor?: PropertyDescriptor): void { + if (descriptor) { + Object.defineProperty(globalThis, 'MediaSource', descriptor); + return; + } + Reflect.deleteProperty(globalThis, 'MediaSource'); +} diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 4562aecfa..41f9c4a25 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -21,7 +21,7 @@ import { InlinePlaybackPlayer, PlaybackDiagnostic, getPlaybackMediaExtensionFromUrl, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; import { PlayerControlsComponent, type PlayerMediaTitle, diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html new file mode 100644 index 000000000..a3930a3c8 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html @@ -0,0 +1,184 @@ +@let issue = diagnostic(); +
+
+
+ + {{ getDiagnosticTitleKey(issue) | translate }} +
+ +

+ {{ diagnosticHeadlineKey() | translate }} +

+ +
+ {{ getDiagnosticDescriptionKey(issue) | translate }} +
+ + @if (getDiagnosticMeta(issue); as meta) { +
+ {{ meta }} +
+ } + + @if (getDiagnosticCodecHint(issue); as codecHint) { +
+ {{ + 'PLAYBACK_DIAGNOSTICS.CODEC_HINT' + | translate: { codecs: codecHint } + }} +
+ } + +
+ @for ( + recommendation of recommendations(); + track getRecommendationKey(recommendation) + ) { + @if (recommendation.action === 'alternative-source') { +
+ + {{ + 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' + | translate + }} + + @for ( + source of visibleAlternatives(); + track source.id + ) { + + } + @if (hiddenAlternativeCount() > 0) { +

+ {{ + 'PORTALS.MULTI_SOURCE.MORE_SOURCES' + | translate + : { + count: hiddenAlternativeCount(), + } + }} +

+ } +
+ } @else { + + } + } +
+ +
+ + + + +
+ + + {{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }} + +
+ @for ( + detail of getDiagnosticDetails(issue); + track detail.labelKey + ) { +
+
{{ detail.labelKey | translate }}
+
{{ detail.value }}
+
+ } +
+
+
+
+
diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss new file mode 100644 index 000000000..3503ec392 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss @@ -0,0 +1,530 @@ +:host { + display: contents; +} + +.web-player-diagnostic { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px); + overflow: auto; + background: + radial-gradient( + circle at 18% 18%, + rgb(255 176 76 / 6%), + transparent 28% + ), + rgb(0 0 0 / 96%); + color: #f6f6f6; +} + +.web-player-diagnostic button, +.web-player-diagnostic summary { + app-region: no-drag; +} + +.web-player-diagnostic__content { + width: min(100%, 720px); + min-width: 0; +} + +.web-player-diagnostic__badge { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 8px; + padding: 5px 11px; + border: 1px solid rgb(255 174 66 / 42%); + border-radius: 6px; + background: rgb(255 148 35 / 15%); + color: #ffb24c; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0; + line-height: 1.1; + text-transform: uppercase; +} + +.web-player-diagnostic__badge mat-icon { + width: 16px; + height: 16px; + flex: 0 0 16px; + font-size: 16px; +} + +.web-player-diagnostic__badge span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.web-player-diagnostic__headline { + max-width: 660px; + margin: 20px 0 0; + color: #fff; + font-size: 1.65rem; + font-weight: 760; + letter-spacing: 0; + line-height: 1.05; +} + +.web-player-diagnostic__description { + max-width: 760px; + margin-top: 12px; + color: rgb(255 255 255 / 73%); + font-size: 1rem; + line-height: 1.48; +} + +.web-player-diagnostic__meta { + margin-top: 9px; + color: rgb(255 255 255 / 48%); + font-family: var(--app-monospace-font, monospace); + font-size: 0.82rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.web-player-diagnostic__codec-hint { + display: inline-block; + max-width: 100%; + margin-top: 12px; + padding: 4px 8px; + border: 1px solid rgb(255 255 255 / 12%); + border-radius: 6px; + background: rgb(255 255 255 / 5%); + color: rgb(255 255 255 / 68%); + font-size: 0.78rem; + font-weight: 620; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.web-player-diagnostic__recommendations { + display: grid; + grid-template-columns: repeat(2, minmax(168px, 192px)); + gap: 12px; + margin-top: 28px; +} + +.web-player-diagnostic__recommendations:empty { + display: none; +} + +.web-player-diagnostic__alternatives { + /* The diagnostic surface is always dark, independent of the application + * theme. Map every source-row surface and foreground locally so light-theme + * app tokens cannot leak dark text into this overlay. */ + --playback-diagnostic-source-surface: #161b24; + --playback-diagnostic-source-foreground: #f3f6fb; + --playback-diagnostic-source-muted: #b9c2d3; + --playback-diagnostic-source-accent: #8bb7ff; + --playback-diagnostic-source-on-accent: #151515; + --playback-diagnostic-source-hover: #252d3b; + --playback-diagnostic-source-selected: #243b5c; + --playback-diagnostic-source-border: #657086; + --playback-diagnostic-source-ok-foreground: #4ade80; + --playback-diagnostic-source-ok-surface: #173928; + --playback-diagnostic-source-warn-foreground: #ffcf99; + --playback-diagnostic-source-warn-surface: #422e1d; + --playback-diagnostic-source-bad-foreground: #ff9aa6; + --playback-diagnostic-source-bad-surface: #451f27; + --app-on-surface: var(--playback-diagnostic-source-foreground); + --app-eyebrow-color: var(--playback-diagnostic-source-muted); + --app-card-hover-bg: var(--playback-diagnostic-source-hover); + --app-selection-color: var(--playback-diagnostic-source-accent); + --app-selection-surface: var(--playback-diagnostic-source-selected); + --app-widget-bg: var(--playback-diagnostic-source-surface); + --app-widget-border: var(--playback-diagnostic-source-border); + --mat-sys-on-primary: var(--playback-diagnostic-source-on-accent); + width: min(420px, 100%); + min-width: 0; + grid-column: 1 / -1; + margin: 0; + padding: 0; + border: 0; + border-radius: 12px; + background: var(--playback-diagnostic-source-surface); + text-align: left; + max-height: min(46vh, 320px); + overflow-y: auto; +} + +/* VodSourceRow defines status variables on its own host. This more-specific, + * panel-owned mapping keeps those tags on the same dark-overlay contract. */ +.web-player-diagnostic__alternatives app-vod-source-row { + --vod-source-ok-fg: var(--playback-diagnostic-source-ok-foreground); + --vod-source-ok-bg: var(--playback-diagnostic-source-ok-surface); + --vod-source-warn-fg: var(--playback-diagnostic-source-warn-foreground); + --vod-source-warn-bg: var(--playback-diagnostic-source-warn-surface); + --vod-source-bad-fg: var(--playback-diagnostic-source-bad-foreground); + --vod-source-bad-bg: var(--playback-diagnostic-source-bad-surface); +} + +.web-player-diagnostic__alternatives--primary { + outline: 1px solid rgb(255 255 255 / 34%); + outline-offset: 1px; +} + +.web-player-diagnostic__alternatives:disabled { + opacity: 0.56; +} + +.web-player-diagnostic__alternatives-more { + margin: 0; + padding: 6px 12px 8px; + color: rgb(255 255 255 / 45%); + font-size: 0.75rem; +} + +.web-player-diagnostic__alternatives-title { + margin: 0; + padding: 8px 12px 4px; + color: rgb(255 255 255 / 55%); + font-size: 0.594rem; + font-weight: 600; + letter-spacing: 0.12em; + overflow-wrap: anywhere; + text-transform: uppercase; +} + +.web-player-diagnostic__player-card { + display: grid; + min-width: 0; + min-height: 60px; + grid-template-columns: 22px minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid rgb(255 255 255 / 13%); + border-radius: 8px; + background: rgb(0 0 0 / 22%); + color: #f5f5f5; + cursor: pointer; + text-align: left; + transition: + background-color 140ms ease, + border-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.web-player-diagnostic__player-card:hover { + border-color: rgb(255 255 255 / 28%); + background: rgb(255 255 255 / 6%); +} + +.web-player-diagnostic__player-card:active { + transform: translateY(1px); +} + +.web-player-diagnostic__player-card:disabled { + cursor: wait; + opacity: 0.56; + transform: none; +} + +.web-player-diagnostic__player-card:focus-visible, +.web-player-diagnostic__utility-button:focus-visible, +.web-player-diagnostic__details summary:focus-visible { + outline: 2px solid #ffb24c; + outline-offset: 3px; +} + +.web-player-diagnostic__player-card--primary { + border-color: transparent; + background: #fff; + color: #151515; +} + +.web-player-diagnostic__player-card--primary:hover { + border-color: transparent; + background: rgb(255 255 255 / 88%); +} + +.web-player-diagnostic__player-card mat-icon { + width: 20px; + height: 20px; + font-size: 20px; +} + +.web-player-diagnostic__player-copy { + display: grid; + min-width: 0; + gap: 2px; +} + +.web-player-diagnostic__player-label { + overflow: hidden; + font-size: 0.95rem; + font-weight: 760; + line-height: 1.05; + text-overflow: ellipsis; + white-space: nowrap; +} + +.web-player-diagnostic__player-hint { + min-width: 0; + overflow: visible; + overflow-wrap: anywhere; + color: currentColor; + font-size: 0.78rem; + line-height: 1.1; + opacity: 0.62; + text-overflow: clip; + white-space: normal; +} + +.web-player-diagnostic__utilities { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px 12px; + margin-top: 22px; + color: rgb(255 255 255 / 45%); +} + +.web-player-diagnostic__utility-button, +.web-player-diagnostic__details summary { + display: inline-flex; + min-height: 24px; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + font-size: 0.86rem; + font-weight: 640; + line-height: 1.2; +} + +.web-player-diagnostic__utility-button:hover, +.web-player-diagnostic__details summary:hover { + color: rgb(255 255 255 / 76%); +} + +.web-player-diagnostic__utility-button mat-icon, +.web-player-diagnostic__details summary mat-icon { + width: 16px; + height: 16px; + font-size: 16px; +} + +.web-player-diagnostic__divider { + width: 1px; + height: 18px; + margin-top: 3px; + background: rgb(255 255 255 / 16%); +} + +.web-player-diagnostic__details { + min-width: 0; + max-width: 100%; +} + +.web-player-diagnostic__details summary { + list-style: none; +} + +.web-player-diagnostic__details summary::-webkit-details-marker { + display: none; +} + +.web-player-diagnostic__details[open] { + flex-basis: 100%; +} + +.web-player-diagnostic__details dl { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 7px 14px; + max-width: min(100%, 640px); + margin: 12px 0 0; + padding: 12px; + border: 1px solid rgb(255 255 255 / 10%); + border-radius: 8px; + background: rgb(255 255 255 / 5%); + color: rgb(255 255 255 / 68%); + font-size: 0.78rem; + line-height: 1.3; +} + +.web-player-diagnostic__detail-row { + display: contents; +} + +.web-player-diagnostic__detail-row dt { + color: rgb(255 255 255 / 48%); + font-weight: 700; + white-space: nowrap; +} + +.web-player-diagnostic__detail-row dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +@container (max-width: 760px) { + .web-player-diagnostic { + align-items: flex-start; + padding: 22px 16px; + } + + .web-player-diagnostic__badge, + .web-player-diagnostic__badge span { + white-space: normal; + } + + .web-player-diagnostic__headline { + margin-top: 16px; + font-size: 1.48rem; + } + + .web-player-diagnostic__utilities { + gap: 10px 12px; + } + + .web-player-diagnostic__details dl { + grid-template-columns: minmax(0, 1fr); + max-width: 100%; + } + + .web-player-diagnostic__detail-row { + display: grid; + gap: 2px; + } + + .web-player-diagnostic__detail-row dt { + white-space: normal; + } +} + +@container (max-width: 520px) { + .web-player-diagnostic { + align-items: flex-start; + padding: clamp(14px, 5cqi, 22px); + } + + .web-player-diagnostic__content { + width: 100%; + } + + .web-player-diagnostic__badge { + gap: 6px; + padding: 4px 8px; + font-size: 0.7rem; + white-space: normal; + } + + .web-player-diagnostic__badge span { + white-space: nowrap; + } + + .web-player-diagnostic__headline { + margin-top: 14px; + font-size: 1.35rem; + line-height: 1.08; + } + + .web-player-diagnostic__description { + margin-top: 10px; + font-size: 0.9rem; + line-height: 1.38; + } + + .web-player-diagnostic__meta { + margin-top: 8px; + font-size: 0.74rem; + } + + .web-player-diagnostic__codec-hint { + margin-top: 10px; + font-size: 0.72rem; + } + + .web-player-diagnostic__recommendations { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + margin-top: 16px; + } + + .web-player-diagnostic__alternatives { + width: 100%; + } + + .web-player-diagnostic__player-card { + min-height: 52px; + padding: 10px 12px; + } + + .web-player-diagnostic__player-label { + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + } + + .web-player-diagnostic__utilities { + display: grid; + gap: 7px; + margin-top: 16px; + } + + .web-player-diagnostic__divider { + display: none; + } + + .web-player-diagnostic__utility-button, + .web-player-diagnostic__details summary { + width: fit-content; + min-height: 22px; + font-size: 0.82rem; + } + + .web-player-diagnostic__details[open] { + flex-basis: auto; + } + + .web-player-diagnostic__details dl { + max-height: 260px; + overflow: auto; + grid-template-columns: minmax(0, 1fr); + gap: 6px; + margin-top: 8px; + padding: 10px; + font-size: 0.72rem; + } + + .web-player-diagnostic__detail-row { + display: grid; + gap: 2px; + } + + .web-player-diagnostic__detail-row dt { + white-space: normal; + } +} + +@container (max-width: 340px) { + .web-player-diagnostic { + padding: 12px; + } + + .web-player-diagnostic__headline { + font-size: 1.22rem; + } + + .web-player-diagnostic__description { + font-size: 0.84rem; + } + + .web-player-diagnostic__player-label { + font-size: 0.88rem; + } +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts new file mode 100644 index 000000000..7ca962fff --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts @@ -0,0 +1,619 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + PlaybackRecommendationReason, + type PlaybackDiagnostic, + type PlaybackRecommendation, + type PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import type { + ResolvedPortalPlayback, + VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; +import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component'; + +const DIAGNOSTIC: PlaybackDiagnostic = { + code: PlaybackDiagnosticCode.UnsupportedContainer, + source: PlaybackDiagnosticSource.Source, + sourceUrl: 'https://example.com/archive/movie.mkv', + container: 'matroska', + mimeType: 'video/matroska', + player: 'videojs', + audioCodecs: [], + videoCodecs: [], +}; + +const PLAYBACK: ResolvedPortalPlayback = { + streamUrl: 'https://example.com/archive/movie.mkv', + title: 'Example Movie', +}; + +const PANEL_STYLE_SOURCE = readFileSync( + resolve( + process.cwd(), + 'libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss' + ), + 'utf8' +); + +function contrastRatio(foreground: string, background: string): number { + const luminance = (hex: string): number => { + const channels = [1, 3, 5].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16) + ); + const [red, green, blue] = channels.map((channel) => { + const normalized = channel / 255; + return normalized <= 0.04045 + ? normalized / 12.92 + : ((normalized + 0.055) / 1.055) ** 2.4; + }); + return red * 0.2126 + green * 0.7152 + blue * 0.0722; + }; + const values = [luminance(foreground), luminance(background)].sort( + (left, right) => right - left + ); + + return (values[0] + 0.05) / (values[1] + 0.05); +} + +function recommendation( + action: PlaybackRecommendation['action'], + overrides: Partial = {} +): PlaybackRecommendation { + if (action === 'retry') { + return { + action, + reason: PlaybackRecommendationReason.RetryTransientFailure, + priority: 'secondary', + ...overrides, + } as PlaybackRecommendation; + } + if (action === 'alternative-source') { + return { + action, + reason: PlaybackRecommendationReason.AlternativeSourceAvailable, + priority: 'secondary', + ...overrides, + } as PlaybackRecommendation; + } + return { + action, + target: 'html5', + reason: PlaybackRecommendationReason.DifferentEngineFamily, + priority: 'secondary', + ...overrides, + } as PlaybackRecommendation; +} + +function source(index: number): VodSourceDescriptor { + return { + id: `playlist-${index}:xtream:${index}`, + playlistId: `playlist-${index}`, + playlistName: `Portal ${index}`, + portalType: 'xtream', + contentId: index, + rawTitle: `Example Movie ${index}`, + matchConfidence: 'exact', + isActive: false, + isPinned: false, + isTried: false, + probe: { status: 'idle' }, + }; +} + +describe('PlaybackDiagnosticPanelComponent', () => { + let fixture: ComponentFixture; + let component: PlaybackDiagnosticPanelComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + PlaybackDiagnosticPanelComponent, + TranslateModule.forRoot(), + ], + }) + .overrideComponent(PlaybackDiagnosticPanelComponent, { + add: { styles: [PANEL_STYLE_SOURCE] }, + }) + .compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + PLAYBACK_DIAGNOSTICS: { + ACTION_TRY_PLAYER: 'Try {{player}}', + ACTION_TRY_PLAYER_HINT: + 'This temporary player change applies only to the current playback session and leaves your saved preference unchanged.', + REASON_DIFFERENT_ENGINE_FAMILY: + 'This player uses a different playback engine that may support the stream format more reliably.', + RECOMMENDATIONS_LABEL: 'Recommended recovery actions', + }, + PORTALS: { + MULTI_SOURCE: { + MORE_SOURCES: '{{count}} more sources', + TRY_ANOTHER_SOURCE: 'Try another source', + }, + }, + }); + translate.use('en'); + + fixture = TestBed.createComponent(PlaybackDiagnosticPanelComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('diagnostic', DIAGNOSTIC); + fixture.componentRef.setInput('recommendations', []); + fixture.componentRef.setInput('playback', PLAYBACK); + fixture.componentRef.setInput('supportsManagedExternalPlayers', false); + fixture.componentRef.setInput('playbackExternallyTransferable', true); + }); + + it('applies the component host style from the actual panel stylesheet', () => { + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + + expect(getComputedStyle(host).display).toBe('contents'); + }); + + it('renders supplied policy-ranked action buttons in order with priority styling', () => { + const rankedRecommendations = [ + recommendation('player', { + target: 'html5', + priority: 'primary', + }), + recommendation('player', { target: 'mpv' }), + recommendation('retry'), + ]; + + fixture.componentRef.setInput('recommendations', rankedRecommendations); + fixture.detectChanges(); + + const cards = fixture.debugElement.queryAll( + By.css('.web-player-diagnostic__player-card') + ); + + expect(cards.map((card) => card.attributes['data-test-id'])).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-mpv', + 'playback-retry', + ]); + expect( + cards.map( + (card) => + card.classes[ + 'web-player-diagnostic__player-card--primary' + ] ?? false + ) + ).toEqual([true, false, false]); + for (const card of cards) { + const button = card.nativeElement as HTMLButtonElement; + + expect(button.tagName).toBe('BUTTON'); + expect(button.getAttribute('type')).toBe('button'); + expect(button.type).toBe('button'); + } + }); + + it('exposes the recommendation set and alternatives with native grouping semantics', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('alternative-source', { priority: 'primary' }), + ]); + fixture.componentRef.setInput('alternativeSources', [source(1)]); + fixture.detectChanges(); + + const group = fixture.nativeElement.querySelector( + '.web-player-diagnostic__recommendations' + ) as HTMLElement; + const alternatives = fixture.nativeElement.querySelector( + '[data-test-id="playback-alternative-sources"]' + ) as HTMLFieldSetElement; + const legend = alternatives.firstElementChild as HTMLElement; + + expect(group.getAttribute('role')).toBe('group'); + expect(group.getAttribute('aria-label')).toBe( + 'Recommended recovery actions' + ); + expect(legend.tagName).toBe('LEGEND'); + expect(legend.classList).toContain( + 'web-player-diagnostic__alternatives-title' + ); + expect(legend.textContent).toContain('Try another source'); + }); + + it('provides a high-contrast dark token contract to alternative source rows', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('alternative-source', { priority: 'primary' }), + ]); + fixture.componentRef.setInput('alternativeSources', [source(1)]); + fixture.detectChanges(); + + const alternatives = fixture.nativeElement.querySelector( + '[data-test-id="playback-alternative-sources"]' + ) as HTMLFieldSetElement; + const styles = getComputedStyle(alternatives); + const token = (name: string): string => + styles.getPropertyValue(name).trim(); + const expectedTokens: Readonly> = { + '--playback-diagnostic-source-surface': '#161b24', + '--playback-diagnostic-source-foreground': '#f3f6fb', + '--playback-diagnostic-source-muted': '#b9c2d3', + '--playback-diagnostic-source-accent': '#8bb7ff', + '--playback-diagnostic-source-on-accent': '#151515', + '--playback-diagnostic-source-hover': '#252d3b', + '--playback-diagnostic-source-selected': '#243b5c', + '--playback-diagnostic-source-border': '#657086', + '--playback-diagnostic-source-ok-foreground': '#4ade80', + '--playback-diagnostic-source-ok-surface': '#173928', + '--playback-diagnostic-source-warn-foreground': '#ffcf99', + '--playback-diagnostic-source-warn-surface': '#422e1d', + '--playback-diagnostic-source-bad-foreground': '#ff9aa6', + '--playback-diagnostic-source-bad-surface': '#451f27', + }; + + expect(alternatives.classList).toContain('dark-theme'); + for (const [name, value] of Object.entries(expectedTokens)) { + expect(token(name)).toBe(value); + } + expect(PANEL_STYLE_SOURCE).toMatch( + /--app-on-surface:\s*var\(--playback-diagnostic-source-foreground\)/ + ); + expect(PANEL_STYLE_SOURCE).toMatch( + /--app-eyebrow-color:\s*var\(--playback-diagnostic-source-muted\)/ + ); + expect(PANEL_STYLE_SOURCE).toMatch( + /--app-card-hover-bg:\s*var\(--playback-diagnostic-source-hover\)/ + ); + expect(PANEL_STYLE_SOURCE).toMatch( + /--app-selection-surface:\s*var\(--playback-diagnostic-source-selected\)/ + ); + expect(PANEL_STYLE_SOURCE).toMatch( + /--app-selection-color:\s*var\(--playback-diagnostic-source-accent\)/ + ); + expect(PANEL_STYLE_SOURCE).toMatch( + /--app-widget-border:\s*var\(--playback-diagnostic-source-border\)/ + ); + expect(PANEL_STYLE_SOURCE).toMatch( + /--app-widget-bg:\s*var\(--playback-diagnostic-source-surface\)/ + ); + expect(PANEL_STYLE_SOURCE).toMatch( + /--mat-sys-on-primary:\s*var\(--playback-diagnostic-source-on-accent\)/ + ); + expect(PANEL_STYLE_SOURCE).toContain('--vod-source-ok-fg:'); + expect(PANEL_STYLE_SOURCE).toContain('--vod-source-warn-fg:'); + expect(PANEL_STYLE_SOURCE).toContain('--vod-source-bad-fg:'); + expect(PANEL_STYLE_SOURCE).toMatch( + /web-player-diagnostic__alternatives app-vod-source-row[\s\S]*--vod-source-ok-fg:/ + ); + + const color = (name: string): string => expectedTokens[name]; + const surface = color('--playback-diagnostic-source-surface'); + for (const foreground of [ + '--playback-diagnostic-source-foreground', + '--playback-diagnostic-source-muted', + '--playback-diagnostic-source-accent', + ]) { + expect( + contrastRatio(color(foreground), surface) + ).toBeGreaterThanOrEqual(4.5); + } + expect( + contrastRatio(color('--playback-diagnostic-source-border'), surface) + ).toBeGreaterThanOrEqual(3); + for (const tone of ['ok', 'warn', 'bad']) { + expect( + contrastRatio( + color(`--playback-diagnostic-source-${tone}-foreground`), + color(`--playback-diagnostic-source-${tone}-surface`) + ) + ).toBeGreaterThanOrEqual(4.5); + } + expect( + contrastRatio( + color('--playback-diagnostic-source-on-accent'), + color('--playback-diagnostic-source-accent') + ) + ).toBeGreaterThanOrEqual(4.5); + }); + + it('disables recommendation buttons and source controls while pending', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('retry', { priority: 'primary' }), + recommendation('alternative-source'), + ]); + fixture.componentRef.setInput('alternativeSources', [source(1)]); + fixture.componentRef.setInput('pending', true); + fixture.detectChanges(); + + const retry = fixture.nativeElement.querySelector( + '[data-test-id="playback-retry"]' + ) as HTMLButtonElement; + const alternatives = fixture.nativeElement.querySelector( + '[data-test-id="playback-alternative-sources"]' + ) as HTMLFieldSetElement; + const sourcePlay = fixture.nativeElement.querySelector( + '.source-row__action--play' + ) as HTMLButtonElement; + + expect(retry.disabled).toBe(true); + expect(alternatives.disabled).toBe(true); + expect(sourcePlay.matches(':disabled')).toBe(true); + }); + + it('bounds alternative sources to five and reports the hidden count', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('alternative-source', { priority: 'primary' }), + ]); + fixture.componentRef.setInput( + 'alternativeSources', + Array.from({ length: 7 }, (_, index) => source(index + 1)) + ); + fixture.detectChanges(); + + expect( + fixture.debugElement.queryAll(By.css('app-vod-source-row')) + ).toHaveLength(5); + expect( + fixture.nativeElement.querySelector( + '.web-player-diagnostic__alternatives-more' + ).textContent + ).toContain('2'); + }); + + it('emits exact retry, player, source play, and source check requests', () => { + const retries: void[] = []; + const players: PlaybackRecommendationTarget[] = []; + const playedSources: string[] = []; + const checkedSources: string[] = []; + component.retryRequested.subscribe(() => retries.push(undefined)); + component.playerRequested.subscribe((target) => players.push(target)); + component.alternativeSourceRequested.subscribe((id) => + playedSources.push(id) + ); + component.sourceCheckRequested.subscribe((id) => + checkedSources.push(id) + ); + fixture.componentRef.setInput('recommendations', [ + recommendation('player', { + target: 'artplayer', + priority: 'primary', + }), + recommendation('retry'), + recommendation('alternative-source'), + ]); + fixture.componentRef.setInput('alternativeSources', [source(8)]); + fixture.detectChanges(); + + ( + fixture.nativeElement.querySelector( + '[data-test-id="playback-recommendation-artplayer"]' + ) as HTMLButtonElement + ).click(); + ( + fixture.nativeElement.querySelector( + '[data-test-id="playback-retry"]' + ) as HTMLButtonElement + ).click(); + ( + fixture.nativeElement.querySelector( + '.source-row__action--play' + ) as HTMLButtonElement + ).click(); + ( + fixture.nativeElement.querySelector( + '.source-tag--action' + ) as HTMLButtonElement + ).click(); + + expect(players).toEqual(['artplayer']); + expect(retries).toEqual([undefined]); + expect(playedSources).toEqual(['playlist-8:xtream:8']); + expect(checkedSources).toEqual(['playlist-8:xtream:8']); + }); + + it('always renders Copy URL and Technical details utilities', () => { + fixture.detectChanges(); + + const copy = fixture.nativeElement.querySelector( + '[data-test-id="playback-copy-url"]' + ) as HTMLButtonElement; + const details = fixture.nativeElement.querySelector( + '[data-test-id="playback-diagnostic-details"]' + ) as HTMLDetailsElement; + + expect(copy).not.toBeNull(); + expect(copy.type).toBe('button'); + expect(copy.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' + ); + expect(details).not.toBeNull(); + expect(details.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' + ); + }); + + it('uses runtime capability for browser-access guidance without implying an external action', () => { + const browserIssue: PlaybackDiagnostic = { + ...DIAGNOSTIC, + code: PlaybackDiagnosticCode.BrowserAccessError, + }; + fixture.componentRef.setInput('diagnostic', browserIssue); + fixture.componentRef.setInput('supportsManagedExternalPlayers', true); + fixture.componentRef.setInput('recommendations', []); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE' + ); + expect(fixture.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION' + ); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="playback-fallback-mpv"], [data-test-id="playback-fallback-vlc"]' + ) + ).toBeNull(); + }); + + it('uses PWA browser-access guidance when managed external players are unavailable', () => { + fixture.componentRef.setInput('diagnostic', { + ...DIAGNOSTIC, + code: PlaybackDiagnosticCode.BrowserAccessError, + }); + fixture.componentRef.setInput('supportsManagedExternalPlayers', false); + fixture.componentRef.setInput('recommendations', []); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE' + ); + expect(fixture.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION' + ); + }); + + it('uses a native headline only when an external player is ranked', () => { + fixture.componentRef.setInput('diagnostic', { + ...DIAGNOSTIC, + code: PlaybackDiagnosticCode.BrowserAccessError, + }); + fixture.componentRef.setInput('supportsManagedExternalPlayers', true); + fixture.componentRef.setInput('recommendations', [ + recommendation('player', { + target: 'vlc', + priority: 'primary', + reason: PlaybackRecommendationReason.ExternalBrowserAccess, + }), + ]); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE' + ); + expect(fixture.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION' + ); + }); + + it('uses a neutral headline for untransferable DRM without a ranked external target', () => { + fixture.componentRef.setInput('diagnostic', { + ...DIAGNOSTIC, + code: PlaybackDiagnosticCode.DrmOrEncryption, + }); + fixture.componentRef.setInput('supportsManagedExternalPlayers', true); + fixture.componentRef.setInput( + 'playbackExternallyTransferable', + false + ); + fixture.componentRef.setInput('recommendations', [ + recommendation('alternative-source', { priority: 'primary' }), + ]); + fixture.componentRef.setInput('alternativeSources', [source(1)]); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE' + ); + expect(fixture.nativeElement.textContent).not.toContain( + 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE' + ); + }); + + it('shows the temporary hint only for built-in player targets', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('player', { + target: 'videojs', + priority: 'primary', + }), + recommendation('player', { target: 'mpv' }), + recommendation('player', { target: 'vlc' }), + ]); + fixture.detectChanges(); + + const builtIn = fixture.nativeElement.querySelector( + '[data-test-id="playback-recommendation-videojs"]' + ) as HTMLButtonElement; + const mpv = fixture.nativeElement.querySelector( + '[data-test-id="playback-fallback-mpv"]' + ) as HTMLButtonElement; + const vlc = fixture.nativeElement.querySelector( + '[data-test-id="playback-fallback-vlc"]' + ) as HTMLButtonElement; + + expect(builtIn.textContent).toContain( + 'leaves your saved preference unchanged' + ); + expect(mpv.textContent).not.toContain( + 'leaves your saved preference unchanged' + ); + expect(vlc.textContent).not.toContain( + 'leaves your saved preference unchanged' + ); + }); + + it('wraps long translated reasons and temporary hints without clipping', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('player', { + target: 'videojs', + priority: 'primary', + }), + ]); + fixture.detectChanges(); + + const hints = Array.from( + fixture.nativeElement.querySelectorAll( + '.web-player-diagnostic__player-hint' + ) as NodeListOf + ); + + expect(hints).toHaveLength(2); + expect(hints[0].textContent).toContain( + 'different playback engine that may support the stream format' + ); + expect(hints[1].textContent).toContain( + 'leaves your saved preference unchanged' + ); + for (const hint of hints) { + const styles = getComputedStyle(hint); + expect(styles.whiteSpace).toBe('normal'); + expect(styles.overflowWrap).toBe('anywhere'); + expect(styles.textOverflow).toBe('clip'); + expect(styles.overflow).not.toBe('hidden'); + } + }); + + it('uses native focus order without autofocus or a focus trap', () => { + fixture.componentRef.setInput('recommendations', [ + recommendation('retry', { priority: 'primary' }), + ]); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector('[autofocus], [cdkTrapFocus]') + ).toBeNull(); + expect(document.activeElement).not.toBe( + fixture.nativeElement.querySelector( + '[data-test-id="playback-retry"]' + ) + ); + }); + + it('declares focus, container-query, and drag-region CSS contracts for browser integration', () => { + const styles = PANEL_STYLE_SOURCE; + + expect(styles).toContain( + '.web-player-diagnostic__player-card:focus-visible' + ); + expect(styles).toContain('outline: 2px solid #ffb24c'); + expect(styles).toContain('@container (max-width: 520px)'); + expect(styles).toMatch( + /web-player-diagnostic__recommendations[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/ + ); + expect(styles).toContain('flex-wrap: wrap'); + expect(styles).toContain('min-width: 0'); + expect(styles).toContain('overflow-wrap: anywhere'); + expect(styles).toContain('app-region: no-drag'); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts new file mode 100644 index 000000000..8a176a8f3 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts @@ -0,0 +1,126 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule } from '@ngx-translate/core'; +import { + type PlaybackDiagnostic, + type PlaybackRecommendation, + type PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import type { + ResolvedPortalPlayback, + VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; +import { VodSourceRowComponent } from '@iptvnator/ui/components'; +import { + getDiagnosticCodecHint, + getDiagnosticDescriptionKey, + getDiagnosticDetails, + getDiagnosticMeta, + getDiagnosticTitleKey, +} from './playback-diagnostic-view.util'; +import { + getRecommendationIcon, + getRecommendationKey, + getRecommendationLabelKey, + getRecommendationParams, + getRecommendationReasonKey, + getRecommendationTestId, + isExternalPlayerRecommendation, + isTemporaryBuiltInRecommendation, +} from './playback-recommendation-view.util'; + +/** How many recovery options the error screen shows before it stops helping. */ +const ERROR_SCREEN_ALTERNATIVES = 5; + +@Component({ + selector: 'app-playback-diagnostic-panel', + templateUrl: './playback-diagnostic-panel.component.html', + styleUrl: './playback-diagnostic-panel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ClipboardModule, + MatIconModule, + MatTooltipModule, + TranslateModule, + VodSourceRowComponent, + ], + host: { class: 'playback-diagnostic-panel' }, +}) +export class PlaybackDiagnosticPanelComponent { + readonly diagnostic = input.required(); + readonly recommendations = + input.required(); + readonly playback = input.required(); + readonly supportsManagedExternalPlayers = input.required(); + readonly playbackExternallyTransferable = input.required(); + readonly alternativeSources = input([]); + readonly pending = input(false); + + readonly retryRequested = output(); + readonly playerRequested = output(); + readonly alternativeSourceRequested = output(); + readonly sourceCheckRequested = output(); + + readonly visibleAlternatives = computed(() => + this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES) + ); + readonly hiddenAlternativeCount = computed(() => + Math.max( + 0, + this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES + ) + ); + readonly hasExternalPlayerRecommendation = computed(() => + this.recommendations().some(isExternalPlayerRecommendation) + ); + readonly diagnosticHeadlineKey = computed(() => { + if (this.hasExternalPlayerRecommendation()) { + return 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'; + } + return this.playbackExternallyTransferable() + ? 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE' + : 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE'; + }); + + readonly getDiagnosticTitleKey = getDiagnosticTitleKey; + readonly getDiagnosticMeta = getDiagnosticMeta; + readonly getDiagnosticCodecHint = getDiagnosticCodecHint; + readonly getDiagnosticDetails = getDiagnosticDetails; + readonly getRecommendationKey = getRecommendationKey; + readonly getRecommendationTestId = getRecommendationTestId; + readonly getRecommendationIcon = getRecommendationIcon; + readonly getRecommendationLabelKey = getRecommendationLabelKey; + readonly getRecommendationParams = getRecommendationParams; + readonly getRecommendationReasonKey = getRecommendationReasonKey; + readonly isTemporaryBuiltInRecommendation = + isTemporaryBuiltInRecommendation; + + getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string { + return getDiagnosticDescriptionKey( + issue, + this.supportsManagedExternalPlayers(), + this.playbackExternallyTransferable() + ); + } + + activate(recommendation: PlaybackRecommendation): void { + switch (recommendation.action) { + case 'retry': + this.retryRequested.emit(); + return; + case 'player': + this.playerRequested.emit(recommendation.target); + return; + case 'alternative-source': + return; + } + } +} diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts new file mode 100644 index 000000000..c2aff28e2 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.spec.ts @@ -0,0 +1,139 @@ +import { + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from '@iptvnator/playback/util'; +import { + getDiagnosticCodecHint, + getDiagnosticDescriptionKey, + getDiagnosticDetails, + getDiagnosticMeta, + getDiagnosticTitleKey, +} from './playback-diagnostic-view.util'; + +describe('playback diagnostic view formatters', () => { + const issue: PlaybackDiagnostic = { + code: PlaybackDiagnosticCode.UnsupportedCodec, + source: PlaybackDiagnosticSource.Native, + sourceUrl: 'https://example.com/movie.mkv', + container: 'matroska', + mimeType: 'video/matroska', + player: 'videojs', + audioCodecs: ['ac-3'], + videoCodecs: ['hvc1.1.6.L93.B0'], + }; + + it('preserves existing title, metadata, codec and detail formatting', () => { + expect(getDiagnosticTitleKey(issue)).toBe( + 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.TITLE' + ); + expect(getDiagnosticMeta(issue)).toBe('hvc1.1.6.L93.B0, ac-3'); + expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3'); + expect(getDiagnosticDetails(issue)).toEqual( + expect.arrayContaining([ + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER', + value: 'Video.js', + }, + { + labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE', + value: 'Native media element', + }, + ]) + ); + }); + + describe.each([ + { + diagnostic: 'browser access', + code: PlaybackDiagnosticCode.BrowserAccessError, + desktopKey: + 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION', + pwaKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION', + }, + { + diagnostic: 'unsupported codec', + code: PlaybackDiagnosticCode.UnsupportedCodec, + desktopKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.DESCRIPTION', + pwaKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.DESCRIPTION', + }, + { + diagnostic: 'unsupported container', + code: PlaybackDiagnosticCode.UnsupportedContainer, + desktopKey: + 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION', + pwaKey: + 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION', + }, + { + diagnostic: 'media decode', + code: PlaybackDiagnosticCode.MediaDecodeError, + desktopKey: 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR.DESCRIPTION', + pwaKey: 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR.DESCRIPTION', + }, + ])('$diagnostic description', ({ code, desktopKey, pwaKey }) => { + const diagnosticIssue: PlaybackDiagnostic = { ...issue, code }; + + it.each([ + { + runtime: 'desktop', + supportsManagedExternalPlayers: true, + expected: desktopKey, + }, + { + runtime: 'PWA', + supportsManagedExternalPlayers: false, + expected: pwaKey, + }, + ])('preserves transferable $runtime copy', ({ + supportsManagedExternalPlayers, + expected, + }) => { + expect( + getDiagnosticDescriptionKey( + diagnosticIssue, + supportsManagedExternalPlayers, + true + ) + ).toBe(expected); + }); + + it.each([ + ['desktop', true], + ['PWA', false], + ])( + 'uses neutral protected copy in the %s runtime', + (_runtime, supportsManagedExternalPlayers) => { + expect( + getDiagnosticDescriptionKey( + diagnosticIssue, + supportsManagedExternalPlayers, + false + ) + ).toBe('PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION'); + } + ); + }); + + it.each([ + [ + 'network', + PlaybackDiagnosticCode.NetworkError, + 'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.DESCRIPTION', + ], + [ + 'unknown', + PlaybackDiagnosticCode.UnknownPlaybackError, + 'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR.DESCRIPTION', + ], + [ + 'DRM or encryption', + PlaybackDiagnosticCode.DrmOrEncryption, + 'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION.DESCRIPTION', + ], + ])('preserves neutral %s copy for protected playback', (_label, code, expected) => { + expect( + getDiagnosticDescriptionKey({ ...issue, code }, true, false) + ).toBe(expected); + }); +}); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts similarity index 91% rename from libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts rename to libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts index 284ab8575..9afebe81e 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view-diagnostics.utils.ts +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-view.util.ts @@ -2,7 +2,16 @@ import { type PlaybackDiagnostic, PlaybackDiagnosticCode, getLikelyBrowserUnsupportedCodecLabels, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; + +const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet< + PlaybackDiagnostic['code'] +> = new Set([ + PlaybackDiagnosticCode.BrowserAccessError, + PlaybackDiagnosticCode.UnsupportedCodec, + PlaybackDiagnosticCode.UnsupportedContainer, + PlaybackDiagnosticCode.MediaDecodeError, +]); export type PlaybackDiagnosticDetail = { readonly labelKey: string; @@ -15,8 +24,16 @@ export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string { export function getDiagnosticDescriptionKey( issue: PlaybackDiagnostic, - supportsManagedExternalPlayers: boolean + supportsManagedExternalPlayers: boolean, + playbackExternallyTransferable: boolean ): string { + if ( + !playbackExternallyTransferable && + UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES.has(issue.code) + ) { + return 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION'; + } + if ( issue.code === PlaybackDiagnosticCode.BrowserAccessError && !supportsManagedExternalPlayers diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts new file mode 100644 index 000000000..0bbad462a --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts @@ -0,0 +1,162 @@ +import { + PlaybackRecommendationReason, + type PlaybackRecommendation, + type PlaybackRecommendationPriority, + type PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import { + getRecommendationIcon, + getRecommendationKey, + getRecommendationLabelKey, + getRecommendationParams, + getRecommendationReasonKey, + getRecommendationTestId, + isTemporaryBuiltInRecommendation, +} from './playback-recommendation-view.util'; + +function player( + target: PlaybackRecommendationTarget, + priority: PlaybackRecommendationPriority = 'secondary' +): PlaybackRecommendation { + return { + action: 'player', + target, + reason: PlaybackRecommendationReason.DifferentEngineFamily, + priority, + }; +} + +function retry(): PlaybackRecommendation { + return { + action: 'retry', + reason: PlaybackRecommendationReason.RetryTransientFailure, + priority: 'primary', + }; +} + +function alternative(): PlaybackRecommendation { + return { + action: 'alternative-source', + reason: PlaybackRecommendationReason.AlternativeSourceAvailable, + priority: 'secondary', + }; +} + +describe('playback recommendation view mappings', () => { + it.each([ + ['videojs', 'playback-recommendation-videojs'], + ['html5', 'playback-recommendation-html5'], + ['artplayer', 'playback-recommendation-artplayer'], + ['mpv', 'playback-fallback-mpv'], + ['vlc', 'playback-fallback-vlc'], + ] as const)('maps %s to its stable test id', (target, testId) => { + expect(getRecommendationTestId(player(target))).toBe(testId); + }); + + it('keeps stable retry and alternative-source test ids', () => { + expect(getRecommendationTestId(retry())).toBe('playback-retry'); + expect(getRecommendationTestId(alternative())).toBe( + 'playback-alternative-sources' + ); + }); + + it.each([ + ['videojs', 'Video.js'], + ['html5', 'HTML5'], + ['artplayer', 'ArtPlayer'], + ] as const)( + 'maps built-in target %s to translated player copy', + (target, name) => { + const recommendation = player(target); + + expect(getRecommendationLabelKey(recommendation)).toBe( + 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER' + ); + expect(getRecommendationParams(recommendation)).toEqual({ + player: name, + }); + expect(getRecommendationIcon(recommendation)).toBe('play_circle'); + expect(isTemporaryBuiltInRecommendation(recommendation)).toBe(true); + } + ); + + it.each([ + ['mpv', 'MPV', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'], + ['vlc', 'VLC', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'], + ] as const)( + 'maps external target %s without a temporary-player hint', + (target, name, labelKey) => { + const recommendation = player(target); + + expect(getRecommendationLabelKey(recommendation)).toBe(labelKey); + expect(getRecommendationParams(recommendation)).toEqual({ + player: name, + }); + expect(getRecommendationIcon(recommendation)).toBe('open_in_new'); + expect(isTemporaryBuiltInRecommendation(recommendation)).toBe( + false + ); + } + ); + + it('maps non-player actions exhaustively', () => { + expect(getRecommendationLabelKey(retry())).toBe( + 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY' + ); + expect(getRecommendationParams(retry())).toEqual({}); + expect(getRecommendationIcon(retry())).toBe('refresh'); + expect(getRecommendationKey(retry())).toBe('retry'); + expect(isTemporaryBuiltInRecommendation(retry())).toBe(false); + + expect(getRecommendationLabelKey(alternative())).toBe( + 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' + ); + expect(getRecommendationParams(alternative())).toEqual({}); + expect(getRecommendationIcon(alternative())).toBe('swap_horiz'); + expect(getRecommendationKey(alternative())).toBe('alternative-source'); + expect(isTemporaryBuiltInRecommendation(alternative())).toBe(false); + }); + + it.each([ + [ + PlaybackRecommendationReason.RetryTransientFailure, + 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE', + ], + [ + PlaybackRecommendationReason.RetryUnknownFailure, + 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE', + ], + [ + PlaybackRecommendationReason.AlternativeSourceAvailable, + 'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE', + ], + [ + PlaybackRecommendationReason.DifferentEngineFamily, + 'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY', + ], + [ + PlaybackRecommendationReason.ExternalCodecOrContainerSupport, + 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT', + ], + [ + PlaybackRecommendationReason.ExternalBrowserAccess, + 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS', + ], + [ + PlaybackRecommendationReason.CompatibleDrmPath, + 'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH', + ], + ] as const)('maps reason %s to its translation key', (reason, key) => { + expect(getRecommendationReasonKey(reason)).toBe(key); + }); + + it('keeps player recommendation keys stable and target-specific', () => { + expect(getRecommendationKey(player('videojs'))).toBe('player-videojs'); + expect(getRecommendationKey(player('html5'))).toBe('player-html5'); + expect(getRecommendationKey(player('artplayer'))).toBe( + 'player-artplayer' + ); + expect(getRecommendationKey(player('mpv'))).toBe('player-mpv'); + expect(getRecommendationKey(player('vlc'))).toBe('player-vlc'); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts new file mode 100644 index 000000000..79285ad2c --- /dev/null +++ b/libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts @@ -0,0 +1,148 @@ +import { + type PlaybackRecommendation, + type PlaybackRecommendationReason, + type PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; + +export function getRecommendationKey( + recommendation: PlaybackRecommendation +): string { + return recommendation.action === 'player' + ? `player-${recommendation.target}` + : recommendation.action; +} + +export function getRecommendationTestId( + recommendation: PlaybackRecommendation +): string { + switch (recommendation.action) { + case 'retry': + return 'playback-retry'; + case 'alternative-source': + return 'playback-alternative-sources'; + case 'player': + return getPlayerTestId(recommendation.target); + } +} + +export function getRecommendationLabelKey( + recommendation: PlaybackRecommendation +): string { + switch (recommendation.action) { + case 'retry': + return 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY'; + case 'alternative-source': + return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'; + case 'player': + return getPlayerLabelKey(recommendation.target); + } +} + +export function getRecommendationParams( + recommendation: PlaybackRecommendation +): Readonly> { + return recommendation.action === 'player' + ? { player: getPlayerName(recommendation.target) } + : {}; +} + +export function getRecommendationIcon( + recommendation: PlaybackRecommendation +): string { + switch (recommendation.action) { + case 'retry': + return 'refresh'; + case 'alternative-source': + return 'swap_horiz'; + case 'player': + return isExternalTarget(recommendation.target) + ? 'open_in_new' + : 'play_circle'; + } +} + +export function getRecommendationReasonKey( + reason: PlaybackRecommendationReason +): string { + switch (reason) { + case 'retry-transient-failure': + return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE'; + case 'retry-unknown-failure': + return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE'; + case 'alternative-source-available': + return 'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE'; + case 'different-engine-family': + return 'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY'; + case 'external-codec-or-container-support': + return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT'; + case 'external-browser-access': + return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS'; + case 'compatible-drm-path': + return 'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH'; + } +} + +export function isTemporaryBuiltInRecommendation( + recommendation: PlaybackRecommendation +): boolean { + return ( + recommendation.action === 'player' && + !isExternalTarget(recommendation.target) + ); +} + +export function isExternalPlayerRecommendation( + recommendation: PlaybackRecommendation +): boolean { + return ( + recommendation.action === 'player' && + isExternalTarget(recommendation.target) + ); +} + +function getPlayerTestId(target: PlaybackRecommendationTarget): string { + switch (target) { + case 'videojs': + return 'playback-recommendation-videojs'; + case 'html5': + return 'playback-recommendation-html5'; + case 'artplayer': + return 'playback-recommendation-artplayer'; + case 'mpv': + return 'playback-fallback-mpv'; + case 'vlc': + return 'playback-fallback-vlc'; + } +} + +function getPlayerLabelKey(target: PlaybackRecommendationTarget): string { + switch (target) { + case 'videojs': + case 'html5': + case 'artplayer': + return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER'; + case 'mpv': + return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'; + case 'vlc': + return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'; + } +} + +function getPlayerName(target: PlaybackRecommendationTarget): string { + switch (target) { + case 'videojs': + return 'Video.js'; + case 'html5': + return 'HTML5'; + case 'artplayer': + return 'ArtPlayer'; + case 'mpv': + return 'MPV'; + case 'vlc': + return 'VLC'; + } +} + +function isExternalTarget(target: PlaybackRecommendationTarget): boolean { + return target === 'mpv' || target === 'vlc'; +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 01f1e87c7..4db6ae12e 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -21,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); template: '
', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); @@ -65,12 +66,10 @@ describe('PortalInlinePlayerComponent sources picker', () => { let component: PortalInlinePlayerComponentInstance; beforeAll(async () => { - ({ PortalInlinePlayerComponent } = await import( - './portal-inline-player.component' - )); - ({ WebPlayerViewComponent } = await import( - '../web-player-view/web-player-view.component' - )); + ({ PortalInlinePlayerComponent } = + await import('./portal-inline-player.component')); + ({ WebPlayerViewComponent } = + await import('../web-player-view/web-player-view.component')); }); afterEach(() => { @@ -101,6 +100,7 @@ describe('PortalInlinePlayerComponent sources picker', () => { fixture = TestBed.createComponent(PortalInlinePlayerComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('playbackSessionKey', 'sources-key'); fixture.componentRef.setInput('playback', { streamUrl: 'https://example.com/movie.mkv', diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index dc6bcd277..7b09f18de 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -18,6 +18,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); template: '
', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); @@ -41,12 +42,10 @@ describe('PortalInlinePlayerComponent up next rail', () => { let component: PortalInlinePlayerComponentInstance; beforeAll(async () => { - ({ PortalInlinePlayerComponent } = await import( - './portal-inline-player.component' - )); - ({ WebPlayerViewComponent } = await import( - '../web-player-view/web-player-view.component' - )); + ({ PortalInlinePlayerComponent } = + await import('./portal-inline-player.component')); + ({ WebPlayerViewComponent } = + await import('../web-player-view/web-player-view.component')); }); afterEach(() => { @@ -77,9 +76,11 @@ describe('PortalInlinePlayerComponent up next rail', () => { fixture = TestBed.createComponent(PortalInlinePlayerComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('playbackSessionKey', 'up-next-key'); } - const railEl = () => fixture.nativeElement.querySelector('app-up-next-rail'); + const railEl = () => + fixture.nativeElement.querySelector('app-up-next-rail'); const seriesPlayback = { streamUrl: 'https://example.com/episode.mp4', diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 43d54185d..50f3235bf 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -84,6 +84,7 @@ >
} ({ @@ -19,6 +21,7 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); template: '
', }) class StubWebPlayerViewComponent { + readonly playbackSessionKey = input.required(); readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); @@ -27,7 +30,7 @@ class StubWebPlayerViewComponent { readonly seriesNavigation = input(null); readonly alternativeSources = input([]); readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly externalFallbackRequested = output(); + readonly externalFallbackRequested = output(); readonly alternativeSourceRequested = output(); readonly playbackFailed = output(); readonly playbackEnded = output(); @@ -42,12 +45,10 @@ describe('PortalInlinePlayerComponent', () => { let component: PortalInlinePlayerComponentInstance; beforeAll(async () => { - ({ PortalInlinePlayerComponent } = await import( - './portal-inline-player.component' - )); - ({ WebPlayerViewComponent } = await import( - '../web-player-view/web-player-view.component' - )); + ({ PortalInlinePlayerComponent } = + await import('./portal-inline-player.component')); + ({ WebPlayerViewComponent } = + await import('../web-player-view/web-player-view.component')); }); beforeEach(async () => { @@ -66,6 +67,10 @@ describe('PortalInlinePlayerComponent', () => { fixture = TestBed.createComponent(PortalInlinePlayerComponent); component = fixture.componentInstance; + fixture.componentRef.setInput( + 'playbackSessionKey', + 'host-owned-session-key' + ); }); afterEach(() => { @@ -94,13 +99,17 @@ describe('PortalInlinePlayerComponent', () => { ( component as unknown as { playbackEnded: { subscribe: (fn: () => void) => void }; - previousEpisodeRequested: { subscribe: (fn: () => void) => void }; + previousEpisodeRequested: { + subscribe: (fn: () => void) => void; + }; nextEpisodeRequested: { subscribe: (fn: () => void) => void }; } ).playbackEnded.subscribe(() => events.push('ended')); ( component as unknown as { - previousEpisodeRequested: { subscribe: (fn: () => void) => void }; + previousEpisodeRequested: { + subscribe: (fn: () => void) => void; + }; } ).previousEpisodeRequested.subscribe(() => events.push('previous')); ( @@ -119,6 +128,7 @@ describe('PortalInlinePlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ).componentInstance as StubWebPlayerViewComponent; expect(webPlayer.seriesNavigation()).toBe(seriesNavigation); + expect(webPlayer.playbackSessionKey()).toBe('host-owned-session-key'); webPlayer.playbackEnded.emit(); webPlayer.previousEpisodeRequested.emit(); @@ -127,6 +137,47 @@ describe('PortalInlinePlayerComponent', () => { expect(events).toEqual(['ended', 'previous', 'next']); }); + it('forwards the exact resolved playback fallback request unchanged', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.test/live.m3u8', + title: 'Header-bearing live stream', + isLive: true, + headers: { Authorization: 'Bearer token' }, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 42, + contentType: 'live', + }, + }; + const request: PlaybackFallbackRequest = { + player: 'mpv', + playback, + diagnostic: { + code: 'network-error', + player: 'videojs', + source: 'hls', + container: '', + mimeType: '', + videoCodecs: [], + audioCodecs: [], + }, + }; + let forwarded: PlaybackFallbackRequest | undefined; + component.externalFallbackRequested.subscribe( + (value) => (forwarded = value) + ); + fixture.componentRef.setInput('playback', playback); + fixture.detectChanges(); + + const webPlayer = fixture.debugElement.query( + By.directive(StubWebPlayerViewComponent) + ).componentInstance as StubWebPlayerViewComponent; + webPlayer.externalFallbackRequested.emit(request); + + expect(forwarded).toBe(request); + expect(forwarded?.playback).toBe(playback); + }); + describe('playerMediaTitle', () => { it('uses the series title with the episode label for episode playback', () => { fixture.componentRef.setInput('playback', { @@ -254,6 +305,10 @@ describe('PortalInlinePlayerComponent', () => { .compileComponents(); fixture = TestBed.createComponent(PortalInlinePlayerComponent); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'host-owned-session-key' + ); component = fixture.componentInstance; }); @@ -308,6 +363,10 @@ describe('PortalInlinePlayerComponent', () => { .compileComponents(); fixture = TestBed.createComponent(PortalInlinePlayerComponent); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'host-owned-session-key' + ); component = fixture.componentInstance; } diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 7555504d3..25437a2bc 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -22,8 +22,8 @@ import { type VodSourceDescriptor, type VodSourceMatchKind, } from '@iptvnator/shared/interfaces'; -import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; -import type { PlaybackDiagnosticCode } from '../playback-diagnostics/playback-diagnostics.model'; +import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; +import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; import type { PlayerMediaTitle } from '../player-controls'; @@ -63,6 +63,7 @@ const RAIL_STAGE_GAP = 18; }, }) export class PortalInlinePlayerComponent { + readonly playbackSessionKey = input.required(); readonly playback = input(null); readonly episodeMetadata = input(null); readonly seriesNavigation = input(null); @@ -77,8 +78,7 @@ export class PortalInlinePlayerComponent { readonly title = computed(() => applyChannelNameStrip( this.playback()?.title, - this.playback()?.isLive && - this.settingsStore.stripCountryPrefix?.() + this.playback()?.isLive && this.settingsStore.stripCountryPrefix?.() ) ); readonly streamUrl = computed(() => this.playback()?.streamUrl ?? ''); @@ -149,18 +149,15 @@ export class PortalInlinePlayerComponent { return { primary, secondary: metadata?.label ?? null }; }); - private readonly stageViewport = viewChild>( - 'stageViewport' - ); + private readonly stageViewport = + viewChild>('stageViewport'); /** * Border-box size of the theater stage, written by a ResizeObserver. * Measuring the border box (not the content box) keeps the value stable * when the rail modifier toggles the stage's own padding, so the gate * below cannot oscillate around its threshold. */ - readonly stageSize = signal<{ width: number; height: number } | null>( - null - ); + readonly stageSize = signal<{ width: number; height: number } | null>(null); /** * Width the rail would actually get: the stage minus its docked-mode * padding, the 16:9 player sized to the remaining height, and the flex diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts index 353d5d8c6..f0a089d7f 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts @@ -22,12 +22,7 @@ export interface ShakaTextTrackLike { kind: string | null; } -export interface ShakaErrorLike { - severity: number; - category: number; - code: number; - data?: readonly unknown[]; -} +export type { ShakaErrorLike } from '@iptvnator/playback/util'; export interface ShakaPlayerLike { attach(mediaElement: HTMLMediaElement): Promise; diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts index bcdc7f5cc..f9cc1d0e4 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.spec.ts @@ -4,7 +4,7 @@ import { PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.model'; +} from '@iptvnator/playback/util'; import { FakeShakaPlayer, createFakeShakaEnvironment, @@ -116,8 +116,6 @@ describe('ShakaVideoSession', () => { stage: 'unknown', failure: 'drm', }); - // Without KODIPROP config the DRM hint may still help externally. - expect(issues[0].externalFallbackRecommended).toBe(true); // Critical errors end playback: the dead engine must be torn down. expect(player.destroyCount).toBe(1); expect(session.getPlayer()).toBeNull(); @@ -142,7 +140,7 @@ describe('ShakaVideoSession', () => { expect(session.getPlayer()).toBe(player); }); - it('drops the external-fallback hint for every failure on ClearKey channels', async () => { + it('emits structured failures without owning ClearKey recovery policy', async () => { const environment = createFakeShakaEnvironment(); const session = createSession(environment); session.start(video, 'http://example.com/enc.mpd', { @@ -160,12 +158,6 @@ describe('ShakaVideoSession', () => { expect(issues).toHaveLength(1); expect(issues[0].code).toBe(PlaybackDiagnosticCode.DrmOrEncryption); - // MPV/VLC never receive the KODIPROP license config, so they are not - // offered as a fallback for key failures on ClearKey channels. - expect(issues[0].externalFallbackRecommended).toBe(false); - - // Non-DRM failures (media/codec/manifest/…) are equally unsolvable - // externally while the stream itself stays encrypted. session.start(video, 'http://example.com/enc.mpd', { licenseType: 'clearkey', supported: true, @@ -180,7 +172,6 @@ describe('ShakaVideoSession', () => { expect(issues).toHaveLength(2); expect(issues[1].code).toBe(PlaybackDiagnosticCode.MediaDecodeError); - expect(issues[1].externalFallbackRecommended).toBe(false); }); it('emits a diagnostic and tears the engine down when load rejects', async () => { @@ -407,6 +398,7 @@ describe('ShakaVideoSession', () => { }); expect(JSON.stringify(issues[0])).not.toContain(secret); expect(JSON.stringify(issues[0])).not.toContain('provider.example'); + expect(issues[0].runtimeSupport).toBeUndefined(); }); it('recovers from a stalled load: stop() interrupts it and the next start proceeds', async () => { @@ -497,7 +489,7 @@ describe('ShakaVideoSession', () => { expect(player.selectTextTrackCalls).toHaveLength(2); }); - it('keeps external fallback available when the browser cannot run Shaka for clear DASH', async () => { + it('emits an unknown diagnostic when the browser cannot run Shaka for clear DASH', async () => { const environment = createFakeShakaEnvironment(); environment.browserSupported = false; const session = createSession(environment); @@ -517,10 +509,13 @@ describe('ShakaVideoSession', () => { stage: 'unknown', failure: 'unknown', }); - expect(issues[0].externalFallbackRecommended).toBe(true); + expect(issues[0]).toHaveProperty( + 'runtimeSupport', + 'shaka-browser-unsupported' + ); }); - it('keeps external fallback unavailable when browser support fails for KODIPROP DRM', async () => { + it('emits the same unknown diagnostic when browser support fails with DRM', async () => { const environment = createFakeShakaEnvironment(); environment.browserSupported = false; const session = createSession(environment); @@ -536,7 +531,10 @@ describe('ShakaVideoSession', () => { expect(issues[0].code).toBe( PlaybackDiagnosticCode.UnknownPlaybackError ); - expect(issues[0].externalFallbackRecommended).toBe(false); + expect(issues[0]).toHaveProperty( + 'runtimeSupport', + 'shaka-browser-unsupported' + ); }); it('destroy tears down the engine and blocks later starts', async () => { diff --git a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts index 6898e23d7..5443d3f77 100644 --- a/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts +++ b/libs/ui/playback/src/lib/shaka-engine/shaka-video-session.ts @@ -1,20 +1,17 @@ import type { ChannelDrm } from '@iptvnator/shared/interfaces'; -import type { - InlinePlaybackPlayer, - PlaybackDiagnostic, - PlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.model'; -import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model'; -import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util'; import { + ShakaPlaybackDisposition as ShakaDisposition, asShakaError, classifyShakaPlaybackIssue, + createPlaybackSourceMetadata, + createShakaBrowserUnsupportedDiagnostic, createUnsupportedDrmDiagnostic, -} from './shaka-error-classifier'; -import { getShakaErrorEventDisposition, isShakaLoadInterrupted, -} from './shaka-error-lifecycle'; + type InlinePlaybackPlayer, + type PlaybackDiagnostic, + type PlaybackSourceMetadata, +} from '@iptvnator/playback/util'; import { ShakaTextTrackSuppression } from './shaka-text-track-suppression'; import { loadShakaModule, @@ -143,18 +140,18 @@ export class ShakaVideoSession { } if (!module.Player.isBrowserSupported()) { - this.emitTerminalIfCurrent( + this.emitIfCurrent( generation, - null, - url, - drm === undefined + createShakaBrowserUnsupportedDiagnostic( + this.createMetadata(url) + ) ); return; } const player = new module.Player(); this.player = player; - this.bindPlayerListeners(player, generation, url, drm !== undefined); + this.bindPlayerListeners(player, generation, url); if (drm?.clearKeys) { player.configure({ drm: { clearKeys: drm.clearKeys } }); @@ -164,13 +161,7 @@ export class ShakaVideoSession { await player.attach(video); await player.load(url); } catch (error: unknown) { - this.handleLoadFailure( - generation, - player, - url, - error, - drm !== undefined - ); + this.handleLoadFailure(generation, player, url, error); return; } @@ -206,8 +197,7 @@ export class ShakaVideoSession { generation: number, player: ShakaPlayerLike, url: string, - error: unknown, - drmProvided: boolean + error: unknown ): void { const shakaError = asShakaError(error); if ( @@ -224,36 +214,17 @@ export class ShakaVideoSession { ShakaDisposition.Terminal ); if (issue) { - this.config.emitPlaybackIssue( - this.withoutUnusableDrmFallback(issue, drmProvided) - ); + this.config.emitPlaybackIssue(issue); } // Never leave a non-functional engine attached to the media element // or exposed to the shared-controls bridge. this.beginPlayerTeardown(); } - /** - * Channels carrying KODIPROP ClearKey config cannot be handed to MPV/VLC - * at all — external players never receive the license config, so the - * encrypted stream fails there regardless of what broke inline (DRM, - * manifest, codec, network, …). Suppress the fallback hint entirely. - */ - private withoutUnusableDrmFallback( - issue: PlaybackDiagnostic, - drmProvided: boolean - ): PlaybackDiagnostic { - if (!drmProvided || !issue.externalFallbackRecommended) { - return issue; - } - return { ...issue, externalFallbackRecommended: false }; - } - private bindPlayerListeners( player: ShakaPlayerLike, generation: number, - url: string, - drmProvided: boolean + url: string ): void { const errorListener = (event: Event): void => { if (this.isStale(generation) || this.player !== player) { @@ -276,9 +247,7 @@ export class ShakaVideoSession { return; } - this.config.emitPlaybackIssue( - this.withoutUnusableDrmFallback(issue, drmProvided) - ); + this.config.emitPlaybackIssue(issue); // Critical errors end playback; never leave the dead engine // attached or exposed to the shared-controls bridge. this.beginPlayerTeardown(); @@ -370,8 +339,7 @@ export class ShakaVideoSession { private emitTerminalIfCurrent( generation: number, error: Partial | null, - url: string, - externalFallbackRecommended?: boolean + url: string ): void { const issue = classifyShakaPlaybackIssue( error, @@ -379,12 +347,7 @@ export class ShakaVideoSession { ShakaDisposition.Terminal ); if (issue) { - this.emitIfCurrent( - generation, - externalFallbackRecommended === undefined - ? issue - : { ...issue, externalFallbackRecommended } - ); + this.emitIfCurrent(generation, issue); } } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts index e8603a3ad..f9194cd40 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.spec.ts @@ -143,7 +143,6 @@ describe('VjsMpegTsSession', () => { failure: 'http', httpStatus: 503, }), - externalFallbackRecommended: false, }) ); expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain( @@ -167,7 +166,6 @@ describe('VjsMpegTsSession', () => { expect.objectContaining({ code: 'network-error', mpegTs: expect.objectContaining({ failure: 'network' }), - externalFallbackRecommended: false, }) ); expect(JSON.stringify(emitPlaybackIssue.mock.calls)).not.toContain( diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts index 3e272eb0e..08b0347a6 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-mpegts-session.ts @@ -6,7 +6,7 @@ import { createMpegTsPlaybackEvidence, createPlaybackSourceMetadata, getPlaybackMediaExtensionFromUrl, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; interface VjsDurationPlayer { duration(value?: number): unknown; diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts index c5c37e6f5..bb2d7ce58 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { NativePlaybackErrorInput } from '@iptvnator/playback/util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; import type { VjsPlayerComponent as VjsPlayerComponentInstance } from './vjs-player.component'; import type { VideoJsPlayer } from './vjs-player.types'; @@ -196,7 +196,6 @@ describe('VjsPlayerComponent', () => { code: 'unsupported-container', source: 'native', sourceUrl: 'https://example.test/archive/movie.mkv', - externalFallbackRecommended: true, }) ); }); @@ -236,7 +235,6 @@ describe('VjsPlayerComponent', () => { stage: 'unknown', httpStatus: 404, }, - externalFallbackRecommended: false, }) ); }); @@ -272,7 +270,6 @@ describe('VjsPlayerComponent', () => { disposition: 'terminal', stage: 'unknown', }, - externalFallbackRecommended: false, }) ); }); @@ -300,7 +297,6 @@ describe('VjsPlayerComponent', () => { code: 'media-decode-error', source: 'native', nativeErrorMessage: 'Native media decode failed', - externalFallbackRecommended: true, }) ); }); diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index b7cf69e15..2b2ea6974 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -23,7 +23,7 @@ import { classifyNativePlaybackIssue, classifyVhsPlaybackIssue, createPlaybackSourceMetadata, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; import { PlayerControlsComponent, type PlayerMediaTitle, diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts index 32361a32f..31cdff7bf 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.types.ts @@ -1,5 +1,5 @@ import type videoJs from 'video.js'; -import type { NativePlaybackErrorInput } from '../playback-diagnostics/playback-diagnostics.model'; +import type { NativePlaybackErrorInput } from '@iptvnator/playback/util'; export type VideoPlayerSource = { src: string; diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.html b/libs/ui/playback/src/lib/vod-details/vod-details.component.html index ec360df08..b142889e3 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.html +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.html @@ -265,6 +265,7 @@ @if (inlinePlayback(); as playback) { { } = {}) => { fixture = TestBed.createComponent(VodDetailsComponent); fixture.componentRef.setInput('item', STALKER_VOD); + fixture.componentRef.setInput( + 'playbackSessionKey', + 'stalker-host-owned-key' + ); fixture.componentRef.setInput('playbackPosition', playbackPosition); fixture.componentRef.setInput('externalPlayback', externalPlayback); fixture.componentRef.setInput('providerOnly', providerOnly); @@ -128,6 +133,24 @@ describe('VodDetailsComponent offline playback', () => { await fixture.whenStable(); }; + it('passes the host-owned session key unchanged to inline playback', async () => { + await render(); + fixture.componentRef.setInput('inlinePlayback', { + streamUrl: 'https://portal.example/movie.mp4', + title: 'Catalog Movie', + }); + fixture.detectChanges(); + + const inlinePlayer = fixture.debugElement.query( + By.css('app-portal-inline-player') + ).componentInstance as { + playbackSessionKey(): string; + }; + expect(inlinePlayer.playbackSessionKey()).toBe( + 'stalker-host-owned-key' + ); + }); + const completeDownload = ( fileAvailability: DownloadItem['fileAvailability'] = 'available' ) => { diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 63235b3e6..ab505e0b8 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -36,7 +36,7 @@ import { CrossPortalSimilarService, DownloadsService, } from '@iptvnator/services'; -import type { PlaybackFallbackRequest } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; import { PortalInlinePlayerComponent } from '../portal-inline-player/portal-inline-player.component'; import { createVodDownloadState } from './vod-download-state.util'; @@ -79,6 +79,7 @@ export class VodDetailsComponent { /** VOD item with discriminated union type */ readonly item = input.required(); + readonly playbackSessionKey = input.required(); /** Whether this item is in favorites (managed by parent) */ readonly isFavorite = input(false); diff --git a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts new file mode 100644 index 000000000..6814720ce --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts @@ -0,0 +1,500 @@ +import { + InlinePlaybackPlayer, + type PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import { PlaybackRecoverySession } from './playback-recovery-session'; + +describe('PlaybackRecoverySession', () => { + it('tracks failure, switch resume, and a reset across sessions', () => { + const session = new PlaybackRecoverySession(); + + expect(session.syncSession('movie-a')).toBe(true); + const firstBinding = session.beginPlayback( + InlinePlaybackPlayer.VideoJs + ); + expect(session.recordFailure(firstBinding)).toBe(true); + expect([...session.attemptedTargets()]).toEqual([ + InlinePlaybackPlayer.VideoJs, + ]); + + session.recordTimeUpdate({ currentTime: 42, duration: 120 }, false); + expect( + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false) + ).toBe(true); + const replacementBinding = session.beginPlayback( + InlinePlaybackPlayer.Html5 + ); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.resumeStartTime(0, false)).toBe(42); + + const previousAttempts = session.attemptedTargets(); + expect(session.syncSession('movie-b')).toBe(true); + expect(session.attemptedTargets()).not.toBe(previousAttempts); + expect(session.attemptedTargets()).toEqual(new Set()); + expect(session.temporaryPlayerOverride()).toBeNull(); + expect(session.switchPending()).toBe(false); + expect(session.activeBinding()).toBeNull(); + expect(session.recordFailure(firstBinding)).toBe(false); + expect(session.resumeStartTime(0, false)).toBe(0); + const newBinding = session.beginPlayback( + InlinePlaybackPlayer.ArtPlayer + ); + expect(newBinding.generation).toBe(replacementBinding.generation + 2); + }); + + it('treats the same exact session key as a no-op', () => { + const session = new PlaybackRecoverySession(); + session.syncSession('movie-a'); + const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer); + session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false); + const attempts = session.attemptedTargets(); + + expect(session.syncSession('movie-a')).toBe(false); + expect(session.attemptedTargets()).toBe(attempts); + expect(session.activeBinding()).toBe(binding); + expect(session.accepts(binding)).toBe(true); + + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false); + const switchAttempts = session.attemptedTargets(); + expect(session.syncSession('movie-a')).toBe(false); + expect(session.attemptedTargets()).toBe(switchAttempts); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.switchPending()).toBe(true); + expect(session.resumeStartTime(5, false)).toBe(24); + expect(session.activeBinding()).toBeNull(); + expect(session.accepts(binding)).toBe(false); + }); + + it('resets only the resume position when the source revision changes', () => { + const session = new PlaybackRecoverySession(); + const firstRevision = Symbol(); + session.syncSession('channel-a'); + expect(session.syncSourceRevision(firstRevision)).toBe(true); + session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs); + session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false); + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false); + const attempts = session.attemptedTargets(); + + expect(session.syncSourceRevision(firstRevision)).toBe(false); + expect(session.resumeStartTime(5, false)).toBe(24); + + expect(session.syncSourceRevision(Symbol())).toBe(true); + expect(session.resumeStartTime(5, false)).toBe(5); + expect(session.attemptedTargets()).toBe(attempts); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.switchPending()).toBe(true); + }); + + it('accepts an empty session key and compares keys exactly', () => { + const session = new PlaybackRecoverySession(); + + expect(session.syncSession('')).toBe(true); + expect(session.syncSession('')).toBe(false); + expect(session.syncSession(' ')).toBe(true); + }); + + it('advances the generation whenever playback begins', () => { + const session = new PlaybackRecoverySession(); + session.syncSession('movie-a'); + session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer); + + const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + const second = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + + expect(second.generation).toBe(first.generation + 1); + expect(session.accepts(first)).toBe(false); + expect(session.accepts(second)).toBe(true); + expect(session.attemptedTargets()).toEqual( + new Set([InlinePlaybackPlayer.ArtPlayer]) + ); + }); + + it('preserves session attempts across an alternative source binding', () => { + const session = new PlaybackRecoverySession(); + session.syncSession('movie-a'); + const first = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + session.recordFailure(first); + session.beginRetry(); + + const alternative = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + session.settle(first); + + expect(session.accepts(first)).toBe(false); + expect(session.accepts(alternative)).toBe(true); + expect(session.switchPending()).toBe(true); + expect(session.attemptedTargets()).toEqual( + new Set([InlinePlaybackPlayer.VideoJs]) + ); + session.settle(alternative); + expect(session.switchPending()).toBe(false); + }); + + it('clears playback binding and prevents retry without resetting session data', () => { + const session = new PlaybackRecoverySession(); + session.syncSession('movie-a'); + session.recordTimeUpdate({ currentTime: 17, duration: 80 }, false); + session.beginPlayback(InlinePlaybackPlayer.VideoJs); + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false); + const binding = session.beginPlayback(InlinePlaybackPlayer.Html5); + session.settle(binding); + session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer); + + session.clearPlaybackBinding(); + + expect(session.activeBinding()).toBeNull(); + expect(session.accepts(binding)).toBe(false); + expect(session.beginRetry()).toBe(false); + expect(session.attemptedTargets()).toEqual( + new Set([ + InlinePlaybackPlayer.Html5, + InlinePlaybackPlayer.ArtPlayer, + ]) + ); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.resumeStartTime(0, false)).toBe(17); + const nextBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + expect(nextBinding.generation).toBe(binding.generation + 2); + }); + + it('accepts only a matching active generation and target', () => { + const session = new PlaybackRecoverySession(); + expect( + session.accepts({ + generation: 0, + target: InlinePlaybackPlayer.VideoJs, + }) + ).toBe(false); + + const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + + expect(session.accepts({ ...binding })).toBe(true); + expect( + session.accepts({ + generation: binding.generation, + target: InlinePlaybackPlayer.Html5, + }) + ).toBe(false); + }); + + it('exposes an immutable active binding snapshot', () => { + const session = new PlaybackRecoverySession(); + const binding = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + const activeBinding = session.activeBinding(); + + expect(Object.isFrozen(binding)).toBe(true); + expect(activeBinding).toBe(binding); + expect(activeBinding).toEqual({ + generation: 1, + target: InlinePlaybackPlayer.VideoJs, + }); + expect(Object.keys(activeBinding ?? {})).toEqual([ + 'generation', + 'target', + ]); + expect(Object.isFrozen(activeBinding)).toBe(true); + expect('set' in session.attemptedTargets).toBe(false); + expect('set' in session.temporaryPlayerOverride).toBe(false); + expect('set' in session.switchPending).toBe(false); + expect('set' in session.activeBinding).toBe(false); + expect( + Reflect.set( + binding as unknown as Record, + 'target', + InlinePlaybackPlayer.Html5 + ) + ).toBe(false); + expect(session.activeBinding()?.target).toBe( + InlinePlaybackPlayer.VideoJs + ); + expect( + session.accepts({ + generation: binding.generation, + target: InlinePlaybackPlayer.Html5, + }) + ).toBe(false); + }); + + it('copies attempted targets for every inline and external attempt', () => { + const session = new PlaybackRecoverySession(); + const initial = session.attemptedTargets(); + + session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs); + const inline = session.attemptedTargets(); + session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs); + const duplicate = session.attemptedTargets(); + session.recordExternalAttempt('mpv'); + const external = session.attemptedTargets(); + + expect(initial).toEqual(new Set()); + expect(inline).toEqual(new Set([InlinePlaybackPlayer.VideoJs])); + expect(duplicate).toEqual(new Set([InlinePlaybackPlayer.VideoJs])); + expect(external).toEqual( + new Set([ + InlinePlaybackPlayer.VideoJs, + 'mpv', + ]) + ); + expect(inline).not.toBe(initial); + expect(duplicate).not.toBe(inline); + expect(external).not.toBe(duplicate); + }); + + it('copies attempts and clears pending for an accepted failure', () => { + const session = new PlaybackRecoverySession(); + session.beginPlayback(InlinePlaybackPlayer.Html5); + session.beginRetry(); + const binding = session.beginPlayback(InlinePlaybackPlayer.Html5); + const attempts = session.attemptedTargets(); + + expect(session.recordFailure(binding)).toBe(true); + expect(session.attemptedTargets()).not.toBe(attempts); + expect(session.attemptedTargets()).toEqual( + new Set([InlinePlaybackPlayer.Html5]) + ); + expect(session.switchPending()).toBe(false); + }); + + it('rejects the old binding throughout the switch pre-effect interval', () => { + const session = new PlaybackRecoverySession(); + const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + + expect( + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false) + ).toBe(true); + const attempts = session.attemptedTargets(); + expect(session.activeBinding()).toBeNull(); + expect(session.recordFailure(oldBinding)).toBe(false); + expect(session.attemptedTargets()).toBe(attempts); + session.settle(oldBinding); + expect(session.switchPending()).toBe(true); + expect(session.attemptedTargets()).toBe(attempts); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + + const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5); + session.settle(replacement); + expect(session.switchPending()).toBe(false); + }); + + it('rejects the old binding throughout the retry pre-effect interval', () => { + const session = new PlaybackRecoverySession(); + session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer); + const oldBinding = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + + expect(session.beginRetry()).toBe(true); + const attempts = session.attemptedTargets(); + expect(session.activeBinding()).toBeNull(); + expect(session.recordFailure(oldBinding)).toBe(false); + expect(session.attemptedTargets()).toBe(attempts); + session.settle(oldBinding); + expect(session.switchPending()).toBe(true); + expect(session.attemptedTargets()).toBe(attempts); + expect(session.temporaryPlayerOverride()).toBeNull(); + + const replacement = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + expect(session.recordFailure(replacement)).toBe(true); + expect(session.switchPending()).toBe(false); + expect(session.attemptedTargets()).not.toBe(attempts); + expect(session.attemptedTargets()).toEqual( + new Set([ + InlinePlaybackPlayer.ArtPlayer, + InlinePlaybackPlayer.VideoJs, + ]) + ); + }); + + it('leaves all state unchanged for a stale failure', () => { + const session = new PlaybackRecoverySession(); + const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false); + const current = session.beginPlayback(InlinePlaybackPlayer.Html5); + const attempts = session.attemptedTargets(); + + expect(session.recordFailure(stale)).toBe(false); + expect(session.attemptedTargets()).toBe(attempts); + expect(session.switchPending()).toBe(true); + expect(session.activeBinding()).toBe(current); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + }); + + it('settles only the matching binding without recording an attempt', () => { + const session = new PlaybackRecoverySession(); + const stale = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false); + const current = session.beginPlayback(InlinePlaybackPlayer.Html5); + const attempts = session.attemptedTargets(); + + session.settle(stale); + expect(session.switchPending()).toBe(true); + expect(session.attemptedTargets()).toBe(attempts); + + session.settle(current); + expect(session.switchPending()).toBe(false); + expect(session.attemptedTargets()).toBe(attempts); + }); + + it('records a switch immediately and clears resume only for live playback', () => { + const session = new PlaybackRecoverySession(); + session.recordTimeUpdate({ currentTime: 31, duration: 90 }, false); + const attempts = session.attemptedTargets(); + + expect( + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, true) + ).toBe(true); + + expect(session.attemptedTargets()).not.toBe(attempts); + expect(session.attemptedTargets()).toEqual( + new Set([InlinePlaybackPlayer.Html5]) + ); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.switchPending()).toBe(true); + expect(session.resumeStartTime(7, false)).toBe(7); + }); + + it('allows only the switch when a switch races a retry', () => { + const session = new PlaybackRecoverySession(); + session.beginPlayback(InlinePlaybackPlayer.VideoJs); + + expect( + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false) + ).toBe(true); + const replacement = session.beginPlayback(InlinePlaybackPlayer.Html5); + const attempts = session.attemptedTargets(); + expect(session.beginRetry()).toBe(false); + + expect(session.attemptedTargets()).toBe(attempts); + expect(session.activeBinding()).toBe(replacement); + expect(session.accepts(replacement)).toBe(true); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.switchPending()).toBe(true); + session.settle(replacement); + const bindingAfterRejectedRetry = session.beginPlayback( + InlinePlaybackPlayer.Html5 + ); + expect(bindingAfterRejectedRetry.generation).toBe( + replacement.generation + 1 + ); + }); + + it('allows only the retry when a retry races a switch', () => { + const session = new PlaybackRecoverySession(); + session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer); + session.beginPlayback(InlinePlaybackPlayer.VideoJs); + session.beginPlayerSwitch(InlinePlaybackPlayer.ArtPlayer, false); + const switched = session.beginPlayback(InlinePlaybackPlayer.ArtPlayer); + session.settle(switched); + + expect(session.beginRetry()).toBe(true); + const replacement = session.beginPlayback( + InlinePlaybackPlayer.ArtPlayer + ); + const attempts = session.attemptedTargets(); + expect( + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false) + ).toBe(false); + + expect(session.attemptedTargets()).toBe(attempts); + expect(session.activeBinding()).toBe(replacement); + expect(session.accepts(replacement)).toBe(true); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.ArtPlayer + ); + expect(session.switchPending()).toBe(true); + session.settle(replacement); + const bindingAfterRejectedSwitch = session.beginPlayback( + InlinePlaybackPlayer.ArtPlayer + ); + expect(bindingAfterRejectedSwitch.generation).toBe( + replacement.generation + 1 + ); + }); + + it('begins retry only with an active binding and preserves state', () => { + const session = new PlaybackRecoverySession(); + session.recordInlineAttempt(InlinePlaybackPlayer.ArtPlayer); + expect(session.beginRetry()).toBe(false); + + const initial = session.beginPlayback(InlinePlaybackPlayer.VideoJs); + expect(initial.generation).toBe(1); + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false); + const switched = session.beginPlayback(InlinePlaybackPlayer.Html5); + session.settle(switched); + const attempts = session.attemptedTargets(); + + expect(session.beginRetry()).toBe(true); + expect(session.attemptedTargets()).toBe(attempts); + expect(session.activeBinding()).toBeNull(); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.beginRetry()).toBe(false); + }); + + it.each([ + [{ currentTime: 9, duration: 20 }, true], + [{ currentTime: Number.NaN, duration: 20 }, false], + [{ currentTime: Number.POSITIVE_INFINITY, duration: 20 }, false], + [{ currentTime: Number.NEGATIVE_INFINITY, duration: 20 }, false], + [{ currentTime: -1, duration: 20 }, false], + ])('ignores invalid or live time update %#', (event, isLive) => { + const session = new PlaybackRecoverySession(); + session.recordTimeUpdate({ currentTime: 13, duration: 40 }, false); + + session.recordTimeUpdate(event, isLive); + + expect(session.resumeStartTime(3, false)).toBe(13); + }); + + it('stores any finite non-negative VOD time without interpreting duration', () => { + const session = new PlaybackRecoverySession(); + + session.recordTimeUpdate( + { currentTime: 0, duration: Number.NaN }, + false + ); + expect(session.resumeStartTime(8, false)).toBe(0); + session.recordTimeUpdate({ currentTime: 75, duration: 10 }, false); + expect(session.resumeStartTime(8, false)).toBe(75); + }); + + it('always returns zero for live and otherwise preserves the host start time', () => { + const session = new PlaybackRecoverySession(); + + expect(session.resumeStartTime(19, false)).toBe(19); + expect(session.resumeStartTime(19, true)).toBe(0); + + session.recordTimeUpdate({ currentTime: 44, duration: 100 }, false); + expect(session.resumeStartTime(19, true)).toBe(0); + expect(session.resumeStartTime(19, false)).toBe(44); + }); + + it('does not let stale callbacks clear pending for a new session', () => { + const stale = new PlaybackRecoverySession(); + stale.syncSession('movie-a'); + const oldBinding = stale.beginPlayback(InlinePlaybackPlayer.VideoJs); + stale.syncSession('movie-b'); + stale.beginPlayback(InlinePlaybackPlayer.VideoJs); + stale.beginRetry(); + + stale.settle(oldBinding); + expect(stale.switchPending()).toBe(true); + expect(stale.recordFailure(oldBinding)).toBe(false); + expect(stale.switchPending()).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts new file mode 100644 index 000000000..bc25fef82 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts @@ -0,0 +1,174 @@ +import { signal, type Signal } from '@angular/core'; +import type { + InlinePlaybackPlayer, + PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import type { ExternalPlayerName } from '@iptvnator/shared/interfaces'; + +export interface PlaybackBinding { + readonly generation: number; + readonly target: InlinePlaybackPlayer; +} + +export class PlaybackRecoverySession { + private readonly attemptedTargetsState = signal< + ReadonlySet + >(new Set()); + private readonly temporaryPlayerOverrideState = + signal(null); + private readonly switchPendingState = signal(false); + private readonly activeBindingState = signal(null); + private readonly sessionKey = signal(null); + private readonly generation = signal(0); + private readonly resumePosition = signal(null); + private sourceRevision: symbol | null = null; + + public readonly attemptedTargets: Signal< + ReadonlySet + > = this.attemptedTargetsState.asReadonly(); + public readonly temporaryPlayerOverride: Signal = + this.temporaryPlayerOverrideState.asReadonly(); + public readonly switchPending: Signal = + this.switchPendingState.asReadonly(); + public readonly activeBinding: Signal = + this.activeBindingState.asReadonly(); + + syncSession(key: string): boolean { + if (this.sessionKey() === key) { + return false; + } + + this.sessionKey.set(key); + this.attemptedTargetsState.set(new Set()); + this.temporaryPlayerOverrideState.set(null); + this.switchPendingState.set(false); + this.resumePosition.set(null); + this.sourceRevision = null; + this.activeBindingState.set(null); + this.generation.update((generation) => generation + 1); + return true; + } + + syncSourceRevision(revision: symbol): boolean { + if (this.sourceRevision === revision) { + return false; + } + + this.sourceRevision = revision; + this.resumePosition.set(null); + return true; + } + + beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding { + const generation = this.generation() + 1; + const binding: PlaybackBinding = Object.freeze({ + generation, + target, + }); + this.generation.set(generation); + this.activeBindingState.set(binding); + return binding; + } + + clearPlaybackBinding(): void { + this.invalidatePlaybackBinding(); + } + + endPlayback(): void { + this.switchPendingState.set(false); + this.invalidatePlaybackBinding(); + } + + recordFailure(binding: PlaybackBinding): boolean { + if (!this.accepts(binding)) { + return false; + } + + this.recordAttempt(binding.target); + this.switchPendingState.set(false); + return true; + } + + recordInlineAttempt(target: InlinePlaybackPlayer): void { + this.recordAttempt(target); + } + + recordExternalAttempt(target: ExternalPlayerName): void { + this.recordAttempt(target); + } + + recordTimeUpdate( + event: { readonly currentTime: number; readonly duration: number }, + isLive: boolean + ): void { + if ( + !isLive && + Number.isFinite(event.currentTime) && + event.currentTime >= 0 + ) { + this.resumePosition.set(event.currentTime); + } + } + + beginPlayerSwitch(target: InlinePlaybackPlayer, isLive: boolean): boolean { + if (this.switchPendingState()) { + return false; + } + + this.recordInlineAttempt(target); + if (isLive) { + this.resumePosition.set(null); + } + this.temporaryPlayerOverrideState.set(target); + this.switchPendingState.set(true); + this.invalidatePlaybackBinding(); + return true; + } + + beginRetry(): boolean { + if (this.switchPendingState() || this.activeBindingState() === null) { + return false; + } + + this.switchPendingState.set(true); + this.invalidatePlaybackBinding(); + return true; + } + + settle(binding: PlaybackBinding): void { + if (this.accepts(binding)) { + this.switchPendingState.set(false); + } + } + + resumeStartTime(inputStartTime: number, isLive: boolean): number { + if (isLive) { + return 0; + } + + const position = this.resumePosition(); + return position !== null && Number.isFinite(position) && position >= 0 + ? position + : inputStartTime; + } + + accepts(binding: PlaybackBinding): boolean { + const activeBinding = this.activeBindingState(); + return ( + activeBinding !== null && + activeBinding.generation === binding.generation && + activeBinding.target === binding.target + ); + } + + private recordAttempt(target: PlaybackRecommendationTarget): void { + const attempts = new Set(this.attemptedTargetsState()); + attempts.add(target); + this.attemptedTargetsState.set(attempts); + } + + private invalidatePlaybackBinding(): void { + this.generation.update((generation) => generation + 1); + this.activeBindingState.set(null); + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts b/libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts new file mode 100644 index 000000000..4139ec020 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-application-handoff.ts @@ -0,0 +1,116 @@ +import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types'; +import { ElectronStreamHeadersService } from './electron-stream-headers.service'; +import { + type PlaybackBinding, + PlaybackRecoverySession, +} from './playback-recovery-session'; +import type { WebPlayerApplicationToken } from './web-player-application-state'; +import { + createVideoJsOptions, + createWebPlayerChannel, +} from './web-player-playback-state'; + +export interface WebPlayerApplicationHandoff { + readonly channel: ReturnType; + readonly vjsOptions: VideoPlayerOptions; +} + +interface WebPlayerApplicationOwnership { + readonly binding: PlaybackBinding; + readonly token: WebPlayerApplicationToken; +} + +export class WebPlayerApplicationHandoffCoordinator { + private headerScopeStreamUrl: string | null = null; + private activeOwnership: WebPlayerApplicationOwnership | null = null; + + constructor( + private readonly streamHeaders: ElectronStreamHeadersService, + private readonly recoverySession: PlaybackRecoverySession + ) {} + + apply( + playback: ResolvedPortalPlayback, + isLive: boolean, + reloadToken: number, + binding: PlaybackBinding, + token: WebPlayerApplicationToken, + currentToken: () => WebPlayerApplicationToken, + accept: (handoff: WebPlayerApplicationHandoff) => void + ): void { + this.activeOwnership = Object.freeze({ binding, token }); + const headerSync = this.streamHeaders.apply(playback); + this.headerScopeStreamUrl = playback.streamUrl; + const acceptSource = (): void => { + const options = createVideoJsOptions({ + streamUrl: playback.streamUrl, + isLive, + reloadToken, + }); + accept({ + channel: createWebPlayerChannel(playback), + vjsOptions: { + ...options, + sources: options.sources.map((source) => ({ ...source })), + }, + }); + }; + if (!headerSync) { + acceptSource(); + return; + } + const handOff = (): void => { + if (this.ownsCurrentApplication(binding, token, currentToken)) { + acceptSource(); + } + }; + void headerSync.then( + (stillCurrent) => { + if (stillCurrent) { + handOff(); + } else if ( + this.ownsCurrentApplication(binding, token, currentToken) + ) { + this.recoverySession.settle(binding); + } + }, + () => { + if (this.ownsCurrentApplication(binding, token, currentToken)) { + this.recoverySession.settle(binding); + } + } + ); + } + + owns(binding: PlaybackBinding, token: WebPlayerApplicationToken): boolean { + if (!this.recoverySession.accepts(binding)) { + return false; + } + const active = this.activeOwnership; + return active?.binding === binding && active.token === token; + } + + invalidate(): void { + this.activeOwnership = null; + } + + private ownsCurrentApplication( + binding: PlaybackBinding, + token: WebPlayerApplicationToken, + currentToken: () => WebPlayerApplicationToken + ): boolean { + return this.owns(binding, token) && currentToken() === token; + } + + release(): void { + this.invalidate(); + this.streamHeaders.clear(this.headerScopeStreamUrl); + this.headerScopeStreamUrl = null; + } + + destroy(): void { + this.recoverySession.endPlayback(); + this.release(); + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts b/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts new file mode 100644 index 000000000..cd12b5922 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts @@ -0,0 +1,58 @@ +import { computed, type Signal } from '@angular/core'; +import type { + ResolvedPortalPlayback, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import { + resolveWebPlayerIsLive, + resolveWebPlayerPlayback, +} from './web-player-playback-state'; + +export type WebPlayerApplicationToken = symbol; +export type WebPlayerSourceRevisionToken = symbol; + +export function createWebPlayerApplicationState(sources: { + readonly playback: Signal; + readonly streamUrl: Signal; + readonly title: Signal; + readonly startTime: Signal; + readonly selectedPlayer: Signal; + readonly reloadToken: Signal; +}): { + readonly playback: Signal; + readonly isLive: Signal; + readonly sourceRevision: Signal; + readonly token: Signal; +} { + const playback = computed(() => { + const explicit = sources.playback(); + return ( + explicit ?? + resolveWebPlayerPlayback({ + playback: null, + streamUrl: sources.streamUrl(), + title: sources.title(), + startTime: sources.startTime(), + }) + ); + }); + const isLive = computed(() => { + const explicit = sources.playback(); + return explicit ? resolveWebPlayerIsLive(explicit) : true; + }); + const sourceRevision = computed(() => { + if (sources.playback() === null) { + void sources.streamUrl(); + void sources.startTime(); + } + void isLive(); + return Symbol(); + }); + const token = computed(() => { + void sourceRevision(); + void sources.selectedPlayer(); + void sources.reloadToken(); + return Symbol(); + }); + return { playback, isLive, sourceRevision, token }; +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts new file mode 100644 index 000000000..0864280d4 --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts @@ -0,0 +1,175 @@ +import { type ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; +import { + createVideoJsOptions, + createWebPlayerChannel, + resolveWebPlayerIsLive, + resolveWebPlayerMediaTitle, + resolveWebPlayerPlayback, +} from './web-player-playback-state'; + +describe('web player playback state', () => { + it('preserves an existing resolved playback payload', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://provider.example/movie/7.ts', + title: 'Movie Seven', + startTime: 42, + headers: { Authorization: 'Bearer secret' }, + }; + + expect( + resolveWebPlayerPlayback({ + playback, + streamUrl: 'https://fallback.example/stream', + title: 'Fallback', + startTime: 0, + }) + ).toBe(playback); + }); + + it('constructs fallback playback and uses the URL for a missing title', () => { + expect( + resolveWebPlayerPlayback({ + playback: null, + streamUrl: 'https://example.com/live/1.ts', + title: '', + startTime: 19, + }) + ).toEqual({ + streamUrl: 'https://example.com/live/1.ts', + title: 'https://example.com/live/1.ts', + startTime: 19, + }); + }); + + it('uses explicit live metadata before inferring VOD from content info', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.com/movie.ts', + title: 'Movie', + isLive: true, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 7, + contentType: 'vod', + }, + }; + + expect(resolveWebPlayerIsLive(playback)).toBe(true); + expect(resolveWebPlayerIsLive({ ...playback, isLive: undefined })).toBe( + false + ); + }); + + it('resolves an explicit media title, playback title, and raw URL fallback', () => { + const playback: ResolvedPortalPlayback = { + streamUrl: 'https://example.com/movie.ts', + title: 'Movie', + }; + + expect( + resolveWebPlayerMediaTitle( + { primary: 'Series', secondary: 'S01E02' }, + playback + ) + ).toEqual({ primary: 'Series', secondary: 'S01E02' }); + expect(resolveWebPlayerMediaTitle(null, playback)).toEqual({ + primary: 'Movie', + secondary: null, + }); + expect( + resolveWebPlayerMediaTitle(null, { + ...playback, + title: playback.streamUrl, + }) + ).toBeNull(); + }); + + it('prefers explicit HTTP metadata when constructing a channel', () => { + expect( + createWebPlayerChannel({ + streamUrl: 'https://example.com/live/channel.m3u8', + title: 'Header Locked Channel', + thumbnail: 'https://example.com/logo.png', + userAgent: 'ProviderAgent/1.0', + referer: 'https://provider.example/ref', + origin: 'https://provider.example', + headers: { + 'User-Agent': 'IgnoredAgent/1.0', + Referer: 'https://ignored.example/ref', + Origin: 'https://ignored.example', + }, + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { '0011': '2233' }, + }, + }) + ).toEqual( + expect.objectContaining({ + id: 'https://example.com/live/channel.m3u8', + url: 'https://example.com/live/channel.m3u8', + name: 'Header Locked Channel', + tvg: expect.objectContaining({ + name: 'Header Locked Channel', + logo: 'https://example.com/logo.png', + }), + http: { + 'user-agent': 'ProviderAgent/1.0', + referrer: 'https://provider.example/ref', + origin: 'https://provider.example', + }, + radio: 'false', + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { '0011': '2233' }, + }, + }) + ); + }); + + it('falls back to case-insensitive HTTP headers', () => { + expect( + createWebPlayerChannel({ + streamUrl: 'https://example.com/live/channel.m3u8', + title: '', + headers: { + 'uSeR-aGeNt': 'HeaderAgent/1.0', + rEfErEr: 'https://headers.example/ref', + ORIGIN: 'https://headers.example', + }, + }).http + ).toEqual({ + 'user-agent': 'HeaderAgent/1.0', + referrer: 'https://headers.example/ref', + origin: 'https://headers.example', + }); + }); + + it.each([ + ['https://example.com/live/index.m3u8', 'application/x-mpegURL'], + [ + 'https://example.com/play?extension=m3u8&token=signed', + 'application/x-mpegURL', + ], + ['https://example.com/live/channel.ts', 'video/mp2t'], + ['https://example.com/live.php?stream=123', 'video/mp2t'], + ['https://example.com/archive/movie.mkv', 'video/matroska'], + [ + 'https://example.com/play?container=mkv&token=signed', + 'video/matroska', + ], + ['https://example.com/archive/movie.mp4', 'video/mp4'], + ])('selects the existing MIME type for %s', (streamUrl, type) => { + expect( + createVideoJsOptions({ + streamUrl, + isLive: false, + reloadToken: 3, + }) + ).toEqual({ + isLive: false, + reloadToken: 3, + sources: [{ src: streamUrl, type }], + }); + }); +}); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts new file mode 100644 index 000000000..16308fc5b --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts @@ -0,0 +1,119 @@ +import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util'; +import type { + Channel, + ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import type { PlayerMediaTitle } from '../player-controls'; + +export function resolveWebPlayerPlayback(options: { + readonly playback: ResolvedPortalPlayback | null; + readonly streamUrl: string; + readonly title: string; + readonly startTime: number; +}): ResolvedPortalPlayback { + return ( + options.playback ?? { + streamUrl: options.streamUrl, + title: options.title || options.streamUrl, + startTime: options.startTime, + } + ); +} + +export function resolveWebPlayerIsLive( + playback: ResolvedPortalPlayback +): boolean { + return typeof playback.isLive === 'boolean' + ? playback.isLive + : !playback.contentInfo; +} + +export function resolveWebPlayerMediaTitle( + explicit: PlayerMediaTitle | null, + playback: ResolvedPortalPlayback +): PlayerMediaTitle | null { + if (explicit?.primary?.trim()) { + return explicit; + } + const title = playback.title?.trim(); + return !title || title === playback.streamUrl + ? null + : { primary: title, secondary: null }; +} + +export function createWebPlayerChannel( + playback: ResolvedPortalPlayback +): Channel { + const title = playback.title || playback.streamUrl; + return { + id: playback.streamUrl, + url: playback.streamUrl, + name: title, + group: { title: '' }, + tvg: { + id: '', + name: title, + url: '', + logo: playback.thumbnail ?? '', + rec: '', + }, + http: { + referrer: + playback.referer ?? + getHeaderValue(playback.headers, 'Referer') ?? + '', + 'user-agent': + playback.userAgent ?? + getHeaderValue(playback.headers, 'User-Agent') ?? + '', + origin: + playback.origin ?? + getHeaderValue(playback.headers, 'Origin') ?? + '', + }, + radio: 'false', + drm: playback.drm, + }; +} + +export function createVideoJsOptions(options: { + readonly streamUrl: string; + readonly isLive: boolean; + readonly reloadToken: number; +}): { + readonly isLive: boolean; + readonly reloadToken: number; + readonly sources: readonly { + readonly src: string; + readonly type: string; + }[]; +} { + const extension = getPlaybackMediaExtensionFromUrl(options.streamUrl); + const type = + extension === 'm3u' || extension === 'm3u8' + ? 'application/x-mpegURL' + : extension === 'ts' || !extension + ? 'video/mp2t' + : extension === 'mkv' + ? 'video/matroska' + : 'video/mp4'; + + return { + isLive: options.isLive, + reloadToken: options.reloadToken, + sources: [{ src: options.streamUrl, type }], + }; +} + +function getHeaderValue( + headers: ResolvedPortalPlayback['headers'] | undefined, + name: string +): string | undefined { + if (!headers) { + return undefined; + } + const matchingKey = Object.keys(headers).find( + (key) => key.toLowerCase() === name.toLowerCase() + ); + return matchingKey ? headers[matchingKey] : undefined; +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts new file mode 100644 index 000000000..c97ecd3ce --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-recovery-policy.ts @@ -0,0 +1,82 @@ +import { + InlinePlaybackPlayer, + createPlaybackTargetCapabilities, + recommendPlaybackRecovery, + resolvePlaybackSourceKind, + type PlaybackDiagnostic, + type PlaybackRecommendation, + type PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import { + VideoPlayer, + type ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import type { PlaybackBinding } from './playback-recovery-session'; + +export function createWebPlayerRecommendations(options: { + readonly diagnostic: PlaybackDiagnostic | null; + readonly binding: PlaybackBinding | null; + readonly attemptedTargets: ReadonlySet; + readonly managedExternalPlayersAvailable: boolean; + readonly playbackExternallyTransferable: boolean; + readonly isLive: boolean; + readonly alternativeSourceCount: number; +}): readonly PlaybackRecommendation[] { + if (!options.diagnostic || !options.binding) { + return []; + } + const sourceKind = resolvePlaybackSourceKind(options.diagnostic); + return recommendPlaybackRecovery({ + diagnostic: options.diagnostic, + activeTarget: options.binding.target, + attemptedTargets: options.attemptedTargets, + targetCapabilities: createPlaybackTargetCapabilities({ + sourceKind, + managedExternalPlayersAvailable: + options.managedExternalPlayersAvailable, + }), + source: { + kind: sourceKind, + isLive: options.isLive, + drm: options.playbackExternallyTransferable + ? 'none' + : 'untransferable', + externalTransferable: options.playbackExternallyTransferable, + }, + alternativeSourceCount: options.alternativeSourceCount, + }); +} + +export function isPlaybackExternallyTransferable( + playback: ResolvedPortalPlayback +): boolean { + return playback.drm === undefined; +} + +export function toVideoPlayer(target: InlinePlaybackPlayer): VideoPlayer { + switch (target) { + case InlinePlaybackPlayer.VideoJs: + return VideoPlayer.VideoJs; + case InlinePlaybackPlayer.Html5: + return VideoPlayer.Html5Player; + case InlinePlaybackPlayer.ArtPlayer: + return VideoPlayer.ArtPlayer; + } +} + +export function toInlinePlaybackPlayer( + player: VideoPlayer +): InlinePlaybackPlayer | null { + switch (player) { + case VideoPlayer.VideoJs: + return InlinePlaybackPlayer.VideoJs; + case VideoPlayer.Html5Player: + return InlinePlaybackPlayer.Html5; + case VideoPlayer.ArtPlayer: + return InlinePlaybackPlayer.ArtPlayer; + case VideoPlayer.EmbeddedMpv: + case VideoPlayer.MPV: + case VideoPlayer.VLC: + return null; + } +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 5865fee59..b55da54d0 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -1,278 +1,112 @@ -@if (selectedPlayer() === 'videojs') { - @defer (on immediate) { - - @if (vjsOptions) { - +@for (application of renderedApplications(); track application.token) { + @if (application.binding; as binding) { + @if (binding.target === 'videojs') { + @defer (on immediate) { + @if (vjsOptions) { + + } + } @placeholder { + + } + } @else if (binding.target === 'html5') { + @defer (on immediate) { + @if (channel) { + + } + } @placeholder { + + } + } @else if (binding.target === 'artplayer') { + @defer (on immediate) { + @if (channel) { + + } + } @placeholder { + + } } - } @placeholder { - - } -} @else if (selectedPlayer() === 'html5') { - @defer (on immediate) { - @if (channel) { - - } - } @placeholder { - - } -} @else if (selectedPlayer() === 'artplayer') { - @defer (on immediate) { - @if (channel) { - - } - } @placeholder { - - } -} @else if (selectedPlayer() === 'embedded-mpv') { - -} @else { - @defer (on immediate) { - - } @placeholder { - } } @if (visiblePlaybackDiagnostic(); as issue) { -
-
-
- - {{ getDiagnosticTitleKey(issue) | translate }} -
- -

- {{ diagnosticHeadlineKey() | translate }} -

- -
- {{ getDiagnosticDescriptionKey(issue) | translate }} -
- - @if (getDiagnosticMeta(issue); as meta) { -
- {{ meta }} -
- } - - @if (getDiagnosticCodecHint(issue); as codecHint) { -
- {{ - 'PLAYBACK_DIAGNOSTICS.CODEC_HINT' - | translate: { codecs: codecHint } - }} -
- } - - @if (alternativeSources().length > 0) { -
-

- {{ - 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' - | translate - }} -

- @for (source of visibleAlternatives(); track source.id) { - - } - @if (hiddenAlternativeCount() > 0) { -

- {{ - 'PORTALS.MULTI_SOURCE.MORE_SOURCES' - | translate - : { count: hiddenAlternativeCount() } - }} -

- } -
- } - - @if (canShowExternalFallbackActions()) { -
- - - -
- } - -
- - - - - - - - -
- - - {{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }} - -
- @for ( - detail of getDiagnosticDetails(issue); - track detail.labelKey - ) { -
-
{{ detail.labelKey | translate }}
-
{{ detail.value }}
-
- } -
-
-
-
-
+ } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts new file mode 100644 index 000000000..2a1450c7e --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts @@ -0,0 +1,1312 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { + ComponentFixture, + DeferBlockBehavior, + TestBed, +} from '@angular/core/testing'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { By } from '@angular/platform-browser'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslateModule } from '@ngx-translate/core'; +import { + InlinePlaybackPlayer, + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, + type PlaybackDiagnostic, +} from '@iptvnator/playback/util'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { + STORE_KEY, + VideoPlayer, + type ResolvedPortalPlayback, +} from '@iptvnator/shared/interfaces'; +import { VodSourceRowComponent } from '@iptvnator/ui/components'; +import { of } from 'rxjs'; +import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; +import { ElectronStreamHeadersService } from './electron-stream-headers.service'; +import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; + +jest.unstable_mockModule('video.js', () => ({ default: jest.fn() })); +jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({})); +jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({})); +jest.unstable_mockModule('videojs-quality-selector-hls', () => ({})); + +describe('WebPlayerViewComponent recovery integration', () => { + let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture; + let component: WebPlayerViewComponentInstance; + const storedSettings = { player: VideoPlayer.VideoJs }; + const storage = { + get: jest.fn((key: string) => + of(key === STORE_KEY.Settings ? storedSettings : undefined) + ), + }; + let runtime: { supportsManagedExternalPlayers: boolean }; + let holdHeaderHandoff: boolean; + let headerResolvers: Array<(stillCurrent: boolean) => void>; + let headerRejectors: Array<(reason?: unknown) => void>; + const streamHeaders = { + apply: jest.fn( + () => + (holdHeaderHandoff + ? new Promise((resolve, reject) => { + headerResolvers.push(resolve); + headerRejectors.push(reject); + }) + : null) as Promise | null + ), + clear: jest.fn(), + }; + + beforeAll(async () => { + ({ WebPlayerViewComponent } = + await import('./web-player-view.component')); + }); + + beforeEach(async () => { + runtime = { supportsManagedExternalPlayers: true }; + holdHeaderHandoff = false; + headerResolvers = []; + headerRejectors = []; + streamHeaders.apply.mockClear(); + streamHeaders.clear.mockClear(); + await TestBed.configureTestingModule({ + deferBlockBehavior: DeferBlockBehavior.Playthrough, + imports: [WebPlayerViewComponent, TranslateModule.forRoot()], + providers: [ + { provide: StorageMap, useValue: storage }, + { provide: RuntimeCapabilitiesService, useValue: runtime }, + { + provide: ElectronStreamHeadersService, + useValue: streamHeaders, + }, + { + provide: SettingsStore, + useValue: { + showCaptions: () => false, + webPlayerSharedControls: () => false, + }, + }, + ], + }) + .overrideComponent(WebPlayerViewComponent, { + set: { + imports: [ + ClipboardModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + PlaybackDiagnosticPanelComponent, + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, + TranslateModule, + VodSourceRowComponent, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(WebPlayerViewComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput( + 'streamUrl', + 'https://example.com/live.m3u8' + ); + fixture.componentRef.setInput('title', 'Recovery stream'); + fixture.componentRef.setInput('playbackSessionKey', 'content-a'); + }); + + afterEach(() => fixture.destroy()); + + it('ranks HTML5, MPV, then VLC for a fatal Video.js HLS media failure', async () => { + await render(); + + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('mounts HTML5 temporarily without changing the saved Video.js setting', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.debugElement.query( + By.directive(StubHtmlVideoPlayerComponent) + ) + ).not.toBeNull(); + expect( + fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + ).toBeNull(); + expect(storedSettings.player).toBe(VideoPlayer.VideoJs); + }); + + it('excludes Video.js and HTML5 player actions after the switched target fails', async () => { + await switchToHtml5(); + + html5().playbackIssue.emit(mediaIssue('html5')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual([ + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('retries the active target with a reload while preserving attempts', async () => { + const fallbackRequests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + fallbackRequests.push(request) + ); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-fallback-mpv'); + expect(fallbackRequests).toHaveLength(1); + vjs().playbackIssue.emit(networkIssue('videojs')); + fixture.detectChanges(); + + click('playback-retry'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(vjs().options()).toEqual( + expect.objectContaining({ reloadToken: 1 }) + ); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-vlc', + ]); + }); + + it('keeps desktop browser-access guidance after external recommendations are exhausted', async () => { + await render(); + vjs().playbackIssue.emit(browserAccessIssue('videojs')); + fixture.detectChanges(); + + click('playback-fallback-mpv'); + fixture.detectChanges(); + click('playback-fallback-vlc'); + fixture.detectChanges(); + + const banner = query('playback-diagnostic-banner'); + expect(banner?.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE' + ); + expect(banner?.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION' + ); + expect(banner?.textContent).not.toContain( + 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION' + ); + expect(playerActionIds()).toEqual([]); + }); + + it('preserves attempts when the URL changes under the same content key', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-fallback-mpv'); + + setPlayback({ streamUrl: 'https://example.com/alternate.m3u8' }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'alternate.m3u8')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-vlc', + ]); + }); + + it('clears attempts and the temporary override for a new content key', async () => { + await switchToHtml5(); + + fixture.componentRef.setInput('playbackSessionKey', 'content-b'); + setPlayback({ streamUrl: 'https://example.com/next.m3u8' }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect( + fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + ).not.toBeNull(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'next.m3u8')); + fixture.detectChanges(); + expect(playerActionIds()).toEqual([ + 'playback-recommendation-html5', + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('applies a new content once after clearing a temporary player override', async () => { + await switchToHtml5(); + const oldIssue = mediaIssue('html5', 'live.m3u8'); + html5().playbackIssue.emit(oldIssue); + fixture.detectChanges(); + expect(component.playbackDiagnostic()).toBe(oldIssue); + holdHeaderHandoff = true; + streamHeaders.apply.mockClear(); + + fixture.componentRef.setInput('playbackSessionKey', 'content-b'); + setPlayback({ streamUrl: 'https://example.com/next.m3u8' }); + fixture.detectChanges(); + + expect(streamHeaders.apply).toHaveBeenCalledTimes(1); + expect(headerResolvers).toHaveLength(1); + expect(component.activeBinding()?.target).toBe( + InlinePlaybackPlayer.VideoJs + ); + expect(component.playbackDiagnostic()).toBeNull(); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + + headerResolvers[0](true); + await fixture.whenStable(); + + expect(streamHeaders.apply).toHaveBeenCalledTimes(1); + expect(component.selectedPlayer()).toBe(VideoPlayer.VideoJs); + expect(component.channel?.url).toBe('https://example.com/next.m3u8'); + expect(component.playbackDiagnostic()).toBeNull(); + }); + + it('hands the latest finite VOD time to a switch and starts live at zero', async () => { + setPlayback({ + streamUrl: 'https://example.com/movie.m3u8', + isLive: false, + startTime: 7, + contentInfo: { + playlistId: 'playlist-1', + contentXtreamId: 9, + contentType: 'vod', + }, + }); + await render(); + vjs().timeUpdate.emit({ currentTime: 48, duration: 120 }); + vjs().timeUpdate.emit({ currentTime: Number.NaN, duration: 120 }); + vjs().playbackIssue.emit(mediaIssue('videojs', 'movie.m3u8')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().startTime()).toBe(48); + + fixture.componentRef.setInput('playbackSessionKey', 'content-live'); + setPlayback({ + streamUrl: 'https://example.com/live-2.m3u8', + isLive: true, + startTime: 99, + }); + fixture.detectChanges(); + vjs().timeUpdate.emit({ currentTime: 74, duration: 100 }); + vjs().playbackIssue.emit(mediaIssue('videojs', 'live-2.m3u8')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().startTime()).toBe(0); + }); + + it('resets the VOD handoff for a new same-key source without clearing recovery history', async () => { + const timeUpdates: Array<{ + currentTime: number; + duration: number; + }> = []; + component.timeUpdate.subscribe((event) => timeUpdates.push(event)); + setPlayback({ + streamUrl: 'https://example.com/program-a.m3u8', + isLive: false, + }); + await render(); + vjs().timeUpdate.emit({ currentTime: 48, duration: 120 }); + vjs().playbackIssue.emit(mediaIssue('videojs', 'program-a.m3u8')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().startTime()).toBe(48); + const sourceAOwnership = captureTimeUpdateOwnership(); + + setPlayback({ + streamUrl: 'https://example.com/program-b.m3u8', + isLive: false, + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(html5().startTime()).toBe(0); + timeUpdates.length = 0; + deliverTimeUpdate({ currentTime: 79, duration: 120 }, sourceAOwnership); + expect(timeUpdates).toEqual([]); + component.retryPlayback(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().startTime()).toBe(0); + + html5().playbackIssue.emit(mediaIssue('html5', 'program-b.m3u8')); + fixture.detectChanges(); + expect(playerActionIds()).toEqual([ + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('rejects a late Embedded MPV VOD time update after a same-key VOD source replaces it', async () => { + const timeUpdates: Array<{ + currentTime: number; + duration: number; + }> = []; + component.timeUpdate.subscribe((event) => timeUpdates.push(event)); + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.EmbeddedMpv + ); + setPlayback({ + streamUrl: 'https://example.com/vod-program-a.m3u8', + isLive: false, + }); + await render(); + const sourceAOwnership = captureTimeUpdateOwnership(); + + setPlayback({ + streamUrl: 'https://example.com/vod-program-b.m3u8', + isLive: false, + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + deliverTimeUpdate({ currentTime: 91, duration: 120 }, sourceAOwnership); + expect(timeUpdates).toEqual([]); + + fixture.componentRef.setInput('playerOverride', VideoPlayer.VideoJs); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(vjs().startTime()).toBe(0); + }); + + it('emits and records a time update owned by the current application', async () => { + const timeUpdates: Array<{ + currentTime: number; + duration: number; + }> = []; + component.timeUpdate.subscribe((event) => timeUpdates.push(event)); + setPlayback({ + streamUrl: 'https://example.com/current-movie.m3u8', + isLive: false, + }); + await render(); + const event = { currentTime: 37, duration: 120 }; + + vjs().timeUpdate.emit(event); + + expect(timeUpdates).toEqual([event]); + component.retryPlayback(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(vjs().startTime()).toBe(37); + }); + + it('preserves the latest VOD position across a same-source retry', async () => { + setPlayback({ + streamUrl: 'https://example.com/retry-movie.m3u8', + isLive: false, + }); + await render(); + vjs().timeUpdate.emit({ currentTime: 63, duration: 120 }); + vjs().playbackIssue.emit(networkIssue('videojs')); + fixture.detectChanges(); + + click('playback-retry'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(vjs().startTime()).toBe(63); + }); + + it('ignores actual outputs from the destroyed target generation after switching players', async () => { + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + await render(); + const staleVjs = vjs(); + staleVjs.playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + // Expected when probing an output owned by a destroyed stub. + }); + staleVjs.playbackIssue.emit(mediaIssue('videojs', 'stale.m3u8')); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + html5().playbackIssue.emit(mediaIssue('html5')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.player).toBe('html5'); + + staleVjs.playbackIssue.emit(null); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.player).toBe('html5'); + expect(failures).toEqual([ + PlaybackDiagnosticCode.MediaDecodeError, + PlaybackDiagnosticCode.MediaDecodeError, + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Unexpected emit for destroyed') + ); + warn.mockRestore(); + }); + + it('recreates a same-target generation and ignores old diagnostic and clear outputs', async () => { + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + await render(); + const staleVjs = vjs(); + staleVjs.playbackIssue.emit(networkIssue('videojs')); + fixture.detectChanges(); + + click('playback-retry'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const currentVjs = vjs(); + expect(currentVjs).not.toBe(staleVjs); + expect(query('playback-diagnostic-banner')).toBeNull(); + + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + // Expected when probing an output owned by a destroyed stub. + }); + staleVjs.playbackIssue.emit(mediaIssue('videojs', 'stale.m3u8')); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + currentVjs.playbackIssue.emit(mediaIssue('videojs', 'current.m3u8')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.sourceUrl).toContain( + 'current.m3u8' + ); + + staleVjs.playbackIssue.emit(null); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.sourceUrl).toContain( + 'current.m3u8' + ); + expect(failures).toEqual([ + PlaybackDiagnosticCode.NetworkError, + PlaybackDiagnosticCode.MediaDecodeError, + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Unexpected emit for destroyed') + ); + warn.mockRestore(); + }); + + it('rejects old player outputs when playback changes before the application effect runs', async () => { + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + await render(); + component.retryPlayback(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const sourceAPlayer = vjs(); + expect(component.recoveryPending()).toBe(true); + + setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' }); + sourceAPlayer.playbackIssue.emit( + mediaIssue('videojs', 'source-a.m3u8') + ); + sourceAPlayer.playbackIssue.emit(null); + + expect(component.playbackDiagnostic()).toBeNull(); + expect(component.recoveryPending()).toBe(true); + expect(component.activeBinding()).toBeNull(); + expect(failures).toEqual([]); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const sourceBPlayer = vjs(); + expect(sourceBPlayer).not.toBe(sourceAPlayer); + sourceBPlayer.playbackIssue.emit( + mediaIssue('videojs', 'source-b.m3u8') + ); + fixture.detectChanges(); + + expect(component.playbackDiagnostic()?.sourceUrl).toBe( + 'https://example.com/source-b.m3u8' + ); + expect(component.recoveryPending()).toBe(false); + expect(failures).toEqual([PlaybackDiagnosticCode.MediaDecodeError]); + }); + + it('keeps the active recovery binding exact and credential-free', async () => { + const sentinels = [ + 'credential-stream.example/private.m3u8', + 'sentinel-cookie=secret', + 'Bearer sentinel-authorization', + 'SentinelAgent/7.0', + 'https://sentinel-referer.example', + 'https://sentinel-origin.example', + 'sentinel-clear-key-id', + 'sentinel-clear-key-value', + 'Sentinel private title', + ]; + setPlayback({ + streamUrl: `https://${sentinels[0]}`, + title: sentinels[8], + headers: { + Cookie: sentinels[1], + Authorization: sentinels[2], + }, + userAgent: sentinels[3], + referer: sentinels[4], + origin: sentinels[5], + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { [sentinels[6]]: sentinels[7] }, + }, + }); + await render(); + + const binding = component.activeBinding(); + expect(binding).toEqual({ + generation: expect.any(Number), + target: InlinePlaybackPlayer.VideoJs, + }); + expect(Object.keys(binding ?? {})).toEqual(['generation', 'target']); + const inspected = JSON.stringify(binding); + for (const sentinel of sentinels) { + expect(inspected).not.toContain(sentinel); + } + }); + + it('exposes fieldless opaque application tokens without source material', async () => { + const sentinels = [ + 'opaque-stream.example/private.m3u8', + 'OpaqueAgent/4.0', + 'opaque-cookie=secret', + 'opaque-clear-key-value', + ]; + setPlayback({ + streamUrl: `https://${sentinels[0]}`, + headers: { Cookie: sentinels[2] }, + userAgent: sentinels[1], + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { key: sentinels[3] }, + }, + }); + await render(); + + const tokens = component as unknown as { + readonly playbackApplicationToken?: () => unknown; + readonly playbackSourceRevisionToken?: () => unknown; + }; + const applicationToken = tokens.playbackApplicationToken?.(); + const sourceRevisionToken = tokens.playbackSourceRevisionToken?.(); + const ownership = captureTimeUpdateOwnership(); + expect(applicationToken).toBeDefined(); + expect(sourceRevisionToken).toBeDefined(); + expect(typeof applicationToken).toBe('symbol'); + expect(typeof sourceRevisionToken).toBe('symbol'); + expect(Reflect.ownKeys(Object(applicationToken))).toEqual([]); + expect(Reflect.ownKeys(Object(sourceRevisionToken))).toEqual([]); + expect(Object.isFrozen(ownership)).toBe(true); + expect(Object.keys(ownership)).toEqual([ + 'binding', + 'embeddedMpv', + 'isLive', + 'sourceRevision', + 'token', + ]); + const inspected = `${String(applicationToken)} ${String(sourceRevisionToken)} ${JSON.stringify({ applicationToken, sourceRevisionToken, ownership })}`; + for (const sentinel of sentinels) { + expect(inspected).not.toContain(sentinel); + } + }); + + it('keeps fallback ownership across title and time updates', async () => { + await render(); + const binding = component.activeBinding(); + const player = vjs(); + + fixture.componentRef.setInput('title', 'Updated display title'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.activeBinding()).toBe(binding); + expect(vjs()).toBe(player); + + player.timeUpdate.emit({ currentTime: 31, duration: 90 }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.activeBinding()).toBe(binding); + expect(vjs()).toBe(player); + }); + + it('rebinds for source, headers, DRM, live mode, target, and reload changes', async () => { + await render(); + await expectBindingChange(() => + fixture.componentRef.setInput( + 'streamUrl', + 'https://example.com/relevant-url.m3u8' + ) + ); + await expectBindingChange(() => + fixture.componentRef.setInput('startTime', 14) + ); + + let playback: Partial = { + streamUrl: 'https://example.com/explicit.m3u8', + isLive: false, + headers: { Authorization: 'Bearer first' }, + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { first: 'key-one' }, + }, + }; + await expectBindingChange(() => setPlayback(playback)); + playback = { + ...playback, + headers: { Authorization: 'Bearer second' }, + }; + await expectBindingChange(() => setPlayback(playback)); + playback = { + ...playback, + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { second: 'key-two' }, + }, + }; + await expectBindingChange(() => setPlayback(playback)); + playback = { ...playback, isLive: true }; + await expectBindingChange(() => setPlayback(playback)); + await expectBindingChange(() => + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.ArtPlayer + ) + ); + await expectBindingChange(() => component.retryPlayback()); + }); + + it('never ranks MPV or VLC for DRM playback', async () => { + setPlayback({ + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { + '00112233445566778899aabbccddeeff': + 'ffeeddccbbaa99887766554433221100', + }, + }, + }); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual(['playback-recommendation-html5']); + }); + + it('does not render managed external actions in the PWA runtime', async () => { + runtime.supportsManagedExternalPlayers = false; + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + + expect(playerActionIds()).toEqual(['playback-recommendation-html5']); + }); + + it('detaches the diagnostic while a player switch handoff is pending', async () => { + const fallbackRequests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + fallbackRequests.push(request) + ); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + const htmlButton = requiredButton('playback-recommendation-html5'); + const staleMpvButton = requiredButton('playback-fallback-mpv'); + + htmlButton.click(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + staleMpvButton.click(); + expect(fallbackRequests).toEqual([]); + + expect(headerResolvers).toHaveLength(1); + headerResolvers[0](true); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().channel()).toEqual( + expect.objectContaining({ + url: 'https://example.com/live.m3u8', + }) + ); + expect(query('playback-diagnostic-banner')).toBeNull(); + }); + + it.each([ + ['source', 'false'], + ['source', 'rejection'], + ['player', 'false'], + ['player', 'rejection'], + ['reload', 'false'], + ['reload', 'rejection'], + ] as const)( + 'clears the backing diagnostic for a $intent intent whose handoff ends in $outcome', + async (intent, outcome) => { + await render(); + const oldIssue = mediaIssue('videojs'); + vjs().playbackIssue.emit(oldIssue); + fixture.detectChanges(); + expect(component.playbackDiagnostic()).toBe(oldIssue); + holdHeaderHandoff = true; + + if (intent === 'source') { + setPlayback({ + streamUrl: 'https://example.com/replacement.m3u8', + }); + } else if (intent === 'player') { + click('playback-recommendation-html5'); + } else { + component.retryPlayback(); + } + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(1); + expect(component.recoveryPending()).toBe(intent !== 'source'); + expect(component.playbackDiagnostic()).toBeNull(); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + if (outcome === 'false') { + headerResolvers[0](false); + } else { + headerRejectors[0](new Error('header IPC failed')); + } + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.recoveryPending()).toBe(false); + expect(component.playbackDiagnostic()).toBeNull(); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + expect(query('playback-diagnostic-banner')).toBeNull(); + expect(component.channel).toBeUndefined(); + } + ); + + it.each(['success', 'false', 'rejection'] as const)( + 'preserves the exact newer diagnostic after a stale handoff $outcome', + async (outcome) => { + await render(); + holdHeaderHandoff = true; + setPlayback({ streamUrl: 'https://example.com/stale.m3u8' }); + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(1); + + holdHeaderHandoff = false; + setPlayback({ streamUrl: 'https://example.com/current.m3u8' }); + fixture.detectChanges(); + const currentBinding = component.activeBinding(); + const currentIssue = mediaIssue('videojs', 'current.m3u8'); + vjs().playbackIssue.emit(currentIssue); + fixture.detectChanges(); + expect(component.playbackDiagnostic()).toBe(currentIssue); + expect(component.visiblePlaybackDiagnostic()).toBe(currentIssue); + + if (outcome === 'success') { + headerResolvers[0](true); + } else if (outcome === 'false') { + headerResolvers[0](false); + } else { + headerRejectors[0](new Error('stale header IPC failed')); + } + await fixture.whenStable(); + + expect(component.activeBinding()).toBe(currentBinding); + expect(component.playbackDiagnostic()).toBe(currentIssue); + expect(component.visiblePlaybackDiagnostic()).toBe(currentIssue); + expect(component.channel?.url).toBe( + 'https://example.com/current.m3u8' + ); + } + ); + + it.each([ + { + outcome: 'false', + completeStale: () => headerResolvers[0](false), + }, + { + outcome: 'rejection', + completeStale: () => + headerRejectors[0](new Error('stale header IPC failed')), + }, + ])( + 'does not settle the current handoff when a stale one completes with $outcome', + async ({ completeStale }) => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + click('playback-recommendation-html5'); + fixture.detectChanges(); + + setPlayback({ streamUrl: 'https://example.com/current.m3u8' }); + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(2); + + completeStale(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.recoveryPending()).toBe(true); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + expect(component.channel).toBeUndefined(); + + headerResolvers[1](false); + await fixture.whenStable(); + } + ); + + it('rejects a pending header success for a newer pre-effect source intent', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + holdHeaderHandoff = true; + + setPlayback({ streamUrl: 'https://example.com/source-a.m3u8' }); + fixture.detectChanges(); + const sourceABinding = component.activeBinding(); + expect(sourceABinding).not.toBeNull(); + expect(headerResolvers).toHaveLength(1); + expect(component.channel).toBeUndefined(); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + + setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' }); + const pendingState = { + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures: [...failures], + }; + + headerResolvers[0](true); + await Promise.resolve(); + + expect({ + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures, + }).toEqual(pendingState); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + expect(streamHeaders.apply).toHaveBeenCalledTimes(2); + + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(2); + expect(component.activeBinding()).not.toBe(sourceABinding); + headerResolvers[1](true); + await fixture.whenStable(); + + expect(component.channel?.url).toBe( + 'https://example.com/source-b.m3u8' + ); + expect(component.vjsOptions?.sources).toEqual([ + expect.objectContaining({ + src: 'https://example.com/source-b.m3u8', + }), + ]); + expect(component.playbackDiagnostic()).toBeNull(); + expect(failures).toEqual([]); + }); + + it.each([ + { + outcome: 'success', + complete: () => headerResolvers[0](true), + }, + { + outcome: 'false', + complete: () => headerResolvers[0](false), + }, + { + outcome: 'rejection', + complete: () => + headerRejectors[0](new Error('destroyed header IPC failed')), + }, + ])( + 'invalidates a pending handoff before destruction and ignores its $outcome', + async ({ complete }) => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + click('playback-recommendation-html5'); + fixture.detectChanges(); + const diagnostic = component.playbackDiagnostic(); + const failures: PlaybackDiagnosticCode[] = []; + component.playbackFailed.subscribe((code) => failures.push(code)); + + expect(component.activeBinding()).not.toBeNull(); + expect(component.recoveryPending()).toBe(true); + expect(component.channel).toBeUndefined(); + fixture.destroy(); + + expect(component.activeBinding()).toBeNull(); + expect(component.recoveryPending()).toBe(false); + const destroyedState = { + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures: [...failures], + }; + + complete(); + await Promise.resolve(); + + expect({ + binding: component.activeBinding(), + pending: component.recoveryPending(), + diagnostic: component.playbackDiagnostic(), + channel: component.channel, + vjsOptions: component.vjsOptions, + failures, + }).toEqual(destroyedState); + expect(component.playbackDiagnostic()).toBe(diagnostic); + } + ); + + it('releases web headers and diagnostics across a same-instance Embedded MPV transition', async () => { + setPlayback({ streamUrl: 'https://example.com/source-a.m3u8' }); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'source-a.m3u8')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()).not.toBeNull(); + + fixture.componentRef.setInput( + 'playerOverride', + VideoPlayer.EmbeddedMpv + ); + fixture.detectChanges(); + + expect(streamHeaders.clear).toHaveBeenCalledWith( + 'https://example.com/source-a.m3u8' + ); + expect(component.activeBinding()).toBeNull(); + expect(component.playbackDiagnostic()).toBeNull(); + + setPlayback({ streamUrl: 'https://example.com/source-b.m3u8' }); + fixture.componentRef.setInput('playerOverride', VideoPlayer.VideoJs); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(streamHeaders.apply).toHaveBeenLastCalledWith( + expect.objectContaining({ + streamUrl: 'https://example.com/source-b.m3u8', + }) + ); + expect(vjs().options()).toEqual( + expect.objectContaining({ + sources: [ + expect.objectContaining({ + src: 'https://example.com/source-b.m3u8', + }), + ], + }) + ); + expect(component.playbackDiagnostic()).toBeNull(); + vjs().playbackIssue.emit(mediaIssue('videojs', 'source-b.m3u8')); + fixture.detectChanges(); + expect(component.playbackDiagnostic()?.sourceUrl).toBe( + 'https://example.com/source-b.m3u8' + ); + }); + + it.each([ + { target: 'mpv' as const, testId: 'playback-fallback-mpv' }, + { target: 'vlc' as const, testId: 'playback-fallback-vlc' }, + ])( + 'emits one exact $target request for a same-tick double activation', + async ({ target, testId }) => { + const requests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + requests.push(request) + ); + await render(); + const issue = mediaIssue('videojs'); + vjs().playbackIssue.emit(issue); + fixture.detectChanges(); + const button = requiredButton(testId); + + button.click(); + button.click(); + + expect(requests).toEqual([ + { + player: target, + playback: component.resolvedPlayback(), + diagnostic: issue, + }, + ]); + } + ); + + it.each([ + { target: 'mpv' as const, testId: 'playback-fallback-mpv' }, + { target: 'vlc' as const, testId: 'playback-fallback-vlc' }, + ])( + 'rejects a stale $target activation after playback becomes DRM protected', + async ({ testId }) => { + const requests: unknown[] = []; + component.externalFallbackRequested.subscribe((request) => + requests.push(request) + ); + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + const staleButton = requiredButton(testId); + + setPlayback({ + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { + '00112233445566778899aabbccddeeff': + 'ffeeddccbbaa99887766554433221100', + }, + }, + }); + staleButton.click(); + + expect(requests).toEqual([]); + } + ); + + it('keeps a replaced-source diagnostic detached across every handoff outcome', async () => { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + holdHeaderHandoff = true; + + setPlayback({ streamUrl: 'https://example.com/alternate.m3u8' }); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + setPlayback({ streamUrl: 'https://example.com/current.m3u8' }); + fixture.detectChanges(); + expect(headerResolvers).toHaveLength(2); + expect(query('playback-diagnostic-banner')).toBeNull(); + + headerResolvers[0](true); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + headerResolvers[1](false); + await fixture.whenStable(); + fixture.detectChanges(); + expect(query('playback-diagnostic-banner')).toBeNull(); + + setPlayback({ streamUrl: 'https://example.com/final.m3u8' }); + fixture.detectChanges(); + headerResolvers[2](true); + await fixture.whenStable(); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); + expect(component.vjsOptions).toEqual( + expect.objectContaining({ + sources: [ + expect.objectContaining({ + src: 'https://example.com/final.m3u8', + }), + ], + }) + ); + }); + + async function render(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + async function switchToHtml5(): Promise { + await render(); + vjs().playbackIssue.emit(mediaIssue('videojs')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + async function expectBindingChange(change: () => void): Promise { + const binding = component.activeBinding(); + change(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(component.activeBinding()).not.toBeNull(); + expect(component.activeBinding()).not.toBe(binding); + } + + function setPlayback(overrides: Partial): void { + fixture.componentRef.setInput('playback', { + streamUrl: 'https://example.com/live.m3u8', + title: 'Recovery stream', + isLive: false, + ...overrides, + }); + } + + function vjs(): StubVjsPlayerComponent { + return fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + .componentInstance as StubVjsPlayerComponent; + } + + function html5(): StubHtmlVideoPlayerComponent { + return fixture.debugElement.query( + By.directive(StubHtmlVideoPlayerComponent) + ).componentInstance as StubHtmlVideoPlayerComponent; + } + + function query(testId: string): HTMLElement | null { + return fixture.nativeElement.querySelector( + `[data-test-id="${testId}"]` + ); + } + + function click(testId: string): void { + requiredButton(testId).click(); + } + + function requiredButton(testId: string): HTMLButtonElement { + const button = query(testId); + expect(button).toBeInstanceOf(HTMLButtonElement); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${testId} button`); + } + return button; + } + + function playerActionIds(): string[] { + return Array.from( + fixture.nativeElement.querySelectorAll( + '.web-player-diagnostic__player-card' + ) as NodeListOf + ).map((element) => element.dataset['testId'] ?? ''); + } + + interface TestTimeUpdateOwnership { + readonly binding: unknown; + readonly embeddedMpv: boolean; + readonly isLive: boolean; + readonly sourceRevision: symbol; + readonly token: symbol; + } + + function captureTimeUpdateOwnership(): TestTimeUpdateOwnership { + const ownership = component.renderedApplications()[0]; + expect(ownership).toBeDefined(); + if (!ownership) { + throw new Error('Expected a rendered playback application'); + } + return ownership; + } + + function deliverTimeUpdate( + event: { readonly currentTime: number; readonly duration: number }, + ownership: TestTimeUpdateOwnership + ): void { + const handler = component.handleTimeUpdate as unknown as ( + event: { readonly currentTime: number; readonly duration: number }, + ownership: TestTimeUpdateOwnership + ) => void; + handler.call(component, event, ownership); + } +}); + +function mediaIssue( + player: 'videojs' | 'html5', + path = 'live.m3u8' +): PlaybackDiagnostic { + return { + code: PlaybackDiagnosticCode.MediaDecodeError, + source: PlaybackDiagnosticSource.Vhs, + sourceUrl: `https://example.com/${path}`, + container: 'm3u8', + mimeType: 'application/x-mpegURL', + player, + audioCodecs: [], + videoCodecs: [], + }; +} + +function networkIssue(player: 'videojs' | 'html5'): PlaybackDiagnostic { + return { + ...mediaIssue(player), + code: PlaybackDiagnosticCode.NetworkError, + }; +} + +function browserAccessIssue(player: 'videojs' | 'html5'): PlaybackDiagnostic { + return { + ...mediaIssue(player), + code: PlaybackDiagnosticCode.BrowserAccessError, + }; +} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss index 42a9728fb..7f7189311 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss @@ -22,462 +22,3 @@ app-html-video-player { height: 100%; background: #000; } - -.web-player-diagnostic { - position: absolute; - inset: 0; - z-index: 5; - display: flex; - align-items: center; - padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px); - overflow: auto; - background: - radial-gradient( - circle at 18% 18%, - rgb(255 176 76 / 6%), - transparent 28% - ), - rgb(0 0 0 / 96%); - color: #f6f6f6; -} - -.web-player-diagnostic__content { - width: min(100%, 720px); -} - -.web-player-diagnostic__badge { - display: inline-flex; - max-width: 100%; - align-items: center; - gap: 8px; - padding: 5px 11px; - border: 1px solid rgb(255 174 66 / 42%); - border-radius: 6px; - background: rgb(255 148 35 / 15%); - color: #ffb24c; - font-size: 0.78rem; - font-weight: 800; - letter-spacing: 0; - line-height: 1.1; - text-transform: uppercase; -} - -.web-player-diagnostic__badge mat-icon { - width: 16px; - height: 16px; - flex: 0 0 16px; - font-size: 16px; -} - -.web-player-diagnostic__badge span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.web-player-diagnostic__headline { - max-width: 660px; - margin: 20px 0 0; - color: #fff; - font-size: 1.65rem; - font-weight: 760; - letter-spacing: 0; - line-height: 1.05; -} - -.web-player-diagnostic__description { - max-width: 760px; - margin-top: 12px; - color: rgb(255 255 255 / 73%); - font-size: 1rem; - line-height: 1.48; -} - -.web-player-diagnostic__meta { - margin-top: 9px; - color: rgb(255 255 255 / 48%); - font-family: var(--app-monospace-font, monospace); - font-size: 0.82rem; - line-height: 1.35; - overflow-wrap: anywhere; -} - -.web-player-diagnostic__codec-hint { - display: inline-block; - max-width: 100%; - margin-top: 12px; - padding: 4px 8px; - border: 1px solid rgb(255 255 255 / 12%); - border-radius: 6px; - background: rgb(255 255 255 / 5%); - color: rgb(255 255 255 / 68%); - font-size: 0.78rem; - font-weight: 620; - line-height: 1.3; - overflow-wrap: anywhere; -} - -// "Try another source" — the alternatives block that turns the error screen -// into a recovery point. The overlay is always dark, so these are literals -// rather than theme tokens, matching the rest of this diagnostic surface. -.web-player-diagnostic__alternatives { - width: min(420px, 100%); - margin: 20px auto 0; - padding: 6px; - border: 1px solid rgb(255 255 255 / 10%); - border-radius: 12px; - background: rgb(22 27 36 / 90%); - text-align: left; - // Scrolls itself rather than growing the whole overlay: otherwise a long - // list pushes the error headline and message off the top of the screen, - // which is precisely the information the user needs first. - max-height: min(46vh, 320px); - overflow-y: auto; -} - -.web-player-diagnostic__alternatives-more { - margin: 0; - padding: 6px 12px 8px; - color: rgb(255 255 255 / 45%); - font-size: 0.75rem; -} - -.web-player-diagnostic__alternatives-title { - margin: 0; - padding: 8px 12px 4px; - color: rgb(255 255 255 / 55%); - font-size: 0.594rem; - font-weight: 600; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.web-player-diagnostic__player-actions { - display: grid; - grid-template-columns: repeat(2, minmax(168px, 192px)); - gap: 12px; - margin-top: 28px; -} - -.web-player-diagnostic__player-card { - display: grid; - min-height: 60px; - grid-template-columns: 22px minmax(0, 1fr); - align-items: center; - gap: 12px; - padding: 12px 16px; - border: 1px solid rgb(255 255 255 / 13%); - border-radius: 8px; - background: rgb(0 0 0 / 22%); - color: #f5f5f5; - cursor: pointer; - text-align: left; - transition: - background-color 140ms ease, - border-color 140ms ease, - color 140ms ease, - transform 140ms ease; -} - -.web-player-diagnostic__player-card:hover { - border-color: rgb(255 255 255 / 28%); - background: rgb(255 255 255 / 6%); -} - -.web-player-diagnostic__player-card:active { - transform: translateY(1px); -} - -.web-player-diagnostic__player-card:focus-visible, -.web-player-diagnostic__utility-button:focus-visible, -.web-player-diagnostic__details summary:focus-visible { - outline: 2px solid #ffb24c; - outline-offset: 3px; -} - -.web-player-diagnostic__player-card--primary { - border-color: transparent; - background: #fff; - color: #151515; -} - -.web-player-diagnostic__player-card--primary:hover { - border-color: transparent; - background: rgb(255 255 255 / 88%); -} - -.web-player-diagnostic__player-card mat-icon { - width: 20px; - height: 20px; - font-size: 20px; -} - -.web-player-diagnostic__player-copy { - display: grid; - min-width: 0; - gap: 2px; -} - -.web-player-diagnostic__player-label { - overflow: hidden; - font-size: 0.95rem; - font-weight: 760; - line-height: 1.05; - text-overflow: ellipsis; - white-space: nowrap; -} - -.web-player-diagnostic__player-hint { - overflow: hidden; - color: currentColor; - font-size: 0.78rem; - line-height: 1.1; - opacity: 0.62; - text-overflow: ellipsis; - white-space: nowrap; -} - -.web-player-diagnostic__utilities { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 8px 12px; - margin-top: 22px; - color: rgb(255 255 255 / 45%); -} - -.web-player-diagnostic__utility-button, -.web-player-diagnostic__details summary { - display: inline-flex; - min-height: 24px; - align-items: center; - gap: 6px; - padding: 0; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - font-size: 0.86rem; - font-weight: 640; - line-height: 1.2; -} - -.web-player-diagnostic__utility-button:hover, -.web-player-diagnostic__details summary:hover { - color: rgb(255 255 255 / 76%); -} - -.web-player-diagnostic__utility-button mat-icon, -.web-player-diagnostic__details summary mat-icon { - width: 16px; - height: 16px; - font-size: 16px; -} - -.web-player-diagnostic__divider { - width: 1px; - height: 18px; - margin-top: 3px; - background: rgb(255 255 255 / 16%); -} - -.web-player-diagnostic__details { - min-width: 0; - max-width: 100%; -} - -.web-player-diagnostic__details summary { - list-style: none; -} - -.web-player-diagnostic__details summary::-webkit-details-marker { - display: none; -} - -.web-player-diagnostic__details[open] { - flex-basis: 100%; -} - -.web-player-diagnostic__details dl { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 7px 14px; - max-width: min(100%, 640px); - margin: 12px 0 0; - padding: 12px; - border: 1px solid rgb(255 255 255 / 10%); - border-radius: 8px; - background: rgb(255 255 255 / 5%); - color: rgb(255 255 255 / 68%); - font-size: 0.78rem; - line-height: 1.3; -} - -.web-player-diagnostic__detail-row { - display: contents; -} - -.web-player-diagnostic__detail-row dt { - color: rgb(255 255 255 / 48%); - font-weight: 700; - white-space: nowrap; -} - -.web-player-diagnostic__detail-row dd { - min-width: 0; - margin: 0; - overflow-wrap: anywhere; -} - -@container (max-width: 760px) { - .web-player-diagnostic { - align-items: flex-start; - padding: 22px 16px; - } - - .web-player-diagnostic__badge { - white-space: normal; - } - - .web-player-diagnostic__badge span { - white-space: normal; - } - - .web-player-diagnostic__headline { - margin-top: 16px; - font-size: 1.48rem; - } - - .web-player-diagnostic__utilities { - gap: 10px 12px; - } - - .web-player-diagnostic__details dl { - grid-template-columns: minmax(0, 1fr); - max-width: 100%; - } - - .web-player-diagnostic__detail-row { - display: grid; - gap: 2px; - } - - .web-player-diagnostic__detail-row dt { - white-space: normal; - } -} - -@container (max-width: 520px) { - .web-player-diagnostic { - align-items: flex-start; - padding: clamp(14px, 5cqi, 22px); - } - - .web-player-diagnostic__content { - width: 100%; - } - - .web-player-diagnostic__badge { - gap: 6px; - padding: 4px 8px; - font-size: 0.7rem; - white-space: normal; - } - - .web-player-diagnostic__badge span { - white-space: nowrap; - } - - .web-player-diagnostic__headline { - margin-top: 14px; - font-size: 1.35rem; - line-height: 1.08; - } - - .web-player-diagnostic__description { - margin-top: 10px; - font-size: 0.9rem; - line-height: 1.38; - } - - .web-player-diagnostic__meta { - margin-top: 8px; - font-size: 0.74rem; - } - - .web-player-diagnostic__codec-hint { - margin-top: 10px; - font-size: 0.72rem; - } - - .web-player-diagnostic__player-actions { - grid-template-columns: minmax(0, 1fr); - gap: 8px; - margin-top: 16px; - } - - .web-player-diagnostic__player-card { - min-height: 52px; - padding: 10px 12px; - } - - .web-player-diagnostic__utilities { - display: grid; - gap: 7px; - margin-top: 16px; - } - - .web-player-diagnostic__divider { - display: none; - } - - .web-player-diagnostic__utility-button, - .web-player-diagnostic__details summary { - width: fit-content; - min-height: 22px; - font-size: 0.82rem; - } - - .web-player-diagnostic__details[open] { - flex-basis: auto; - } - - .web-player-diagnostic__details dl { - max-height: 260px; - overflow: auto; - grid-template-columns: minmax(0, 1fr); - gap: 6px; - margin-top: 8px; - padding: 10px; - font-size: 0.72rem; - } - - .web-player-diagnostic__detail-row { - display: grid; - gap: 2px; - } - - .web-player-diagnostic__detail-row dt { - white-space: normal; - } -} - -@container (max-width: 340px) { - .web-player-diagnostic { - padding: 12px; - } - - .web-player-diagnostic__headline { - font-size: 1.22rem; - } - - .web-player-diagnostic__description { - font-size: 0.84rem; - } - - .web-player-diagnostic__player-label { - font-size: 0.88rem; - } -} diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts index 71b8b09b3..b62123766 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.shared-controls.spec.ts @@ -29,7 +29,8 @@ import { type PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; +import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; jest.unstable_mockModule('video.js', () => ({ default: jest.fn(), @@ -85,6 +86,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { StubEmbeddedMpvPlayerComponent, StubHtmlVideoPlayerComponent, StubVjsPlayerComponent, + PlaybackDiagnosticPanelComponent, ClipboardModule, MatButtonModule, MatIconModule, @@ -96,6 +98,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { .compileComponents(); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', @@ -124,6 +127,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { fixture.destroy(); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', @@ -197,11 +201,11 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { expect(artPlayer.showCaptions()).toBe(true); expect(artPlayer.interactionEnabled()).toBe(true); - component.handlePlaybackIssue(createNetworkDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); expect(artPlayer.interactionEnabled()).toBe(false); - component.handlePlaybackIssue(null); + emitPlaybackIssue(null); fixture.detectChanges(); expect(artPlayer.interactionEnabled()).toBe(true); }); @@ -209,7 +213,7 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { it('disables HTML5 surface interaction while a diagnostic is visible', async () => { const htmlPlayer = await renderHtmlPlayer(); - component.handlePlaybackIssue(createNetworkDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); expect(component.playbackInteractionEnabled()).toBe(false); @@ -219,43 +223,49 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { it('re-enables HTML5 interaction after retrying or clearing the issue', async () => { const htmlPlayer = await renderHtmlPlayer(); - component.handlePlaybackIssue(createNetworkDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); expect(htmlPlayer.interactionEnabled()).toBe(false); component.retryPlayback(); fixture.detectChanges(); + const retriedHtmlPlayer = fixture.debugElement.query( + By.directive(StubHtmlVideoPlayerComponent) + ).componentInstance as StubHtmlVideoPlayerComponent; expect(component.playbackInteractionEnabled()).toBe(true); - expect(htmlPlayer.interactionEnabled()).toBe(true); + expect(retriedHtmlPlayer.interactionEnabled()).toBe(true); - component.handlePlaybackIssue(createNetworkDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); - expect(htmlPlayer.interactionEnabled()).toBe(false); + expect(retriedHtmlPlayer.interactionEnabled()).toBe(false); - component.handlePlaybackIssue(null); + emitPlaybackIssue(null); fixture.detectChanges(); expect(component.playbackInteractionEnabled()).toBe(true); - expect(htmlPlayer.interactionEnabled()).toBe(true); + expect(retriedHtmlPlayer.interactionEnabled()).toBe(true); }); it('disables and restores Video.js interaction around diagnostics', async () => { const vjsPlayer = await renderVjsPlayer(); - component.handlePlaybackIssue(createNetworkDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); expect(vjsPlayer.interactionEnabled()).toBe(false); component.retryPlayback(); fixture.detectChanges(); - expect(vjsPlayer.interactionEnabled()).toBe(true); + const retriedVjsPlayer = fixture.debugElement.query( + By.directive(StubVjsPlayerComponent) + ).componentInstance as StubVjsPlayerComponent; + expect(retriedVjsPlayer.interactionEnabled()).toBe(true); - component.handlePlaybackIssue(createNetworkDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); - expect(vjsPlayer.interactionEnabled()).toBe(false); + expect(retriedVjsPlayer.interactionEnabled()).toBe(false); - component.handlePlaybackIssue(null); + emitPlaybackIssue(null); fixture.detectChanges(); - expect(vjsPlayer.interactionEnabled()).toBe(true); + expect(retriedVjsPlayer.interactionEnabled()).toBe(true); }); it.each([ @@ -269,17 +279,20 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { 'uses the resolved value for Video.js effect and retry: %s', (_label, metadata, expected) => { const streamUrl = 'https://example.com/video.ts'; - const setVjsOptions = jest.spyOn(component, 'setVjsOptions'); setPlayback(metadata, streamUrl); fixture.detectChanges(); - expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected); + expect(component.vjsOptions).toEqual( + expect.objectContaining({ isLive: expected, reloadToken: 0 }) + ); - setVjsOptions.mockClear(); component.retryPlayback(); + fixture.detectChanges(); - expect(setVjsOptions).toHaveBeenCalledWith(streamUrl, expected); + expect(component.vjsOptions).toEqual( + expect.objectContaining({ isLive: expected, reloadToken: 1 }) + ); } ); @@ -336,6 +349,16 @@ describe('WebPlayerViewComponent shared web controls metadata', () => { return fixture.debugElement.query(By.directive(StubArtPlayerComponent)) .componentInstance as StubArtPlayerComponent; } + + function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void { + fixture.detectChanges(); + const binding = component.activeBinding(); + expect(binding).not.toBeNull(); + if (!binding) { + throw new Error('Expected an active inline playback binding'); + } + component.handlePlaybackIssue(issue, binding); + } }); function createVodContentInfo() { @@ -356,6 +379,5 @@ function createNetworkDiagnostic(): PlaybackDiagnostic { player: 'html5', audioCodecs: [], videoCodecs: [], - externalFallbackRecommended: false, }; } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index ee0e90dd5..76575d748 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -21,7 +21,15 @@ import { PlaybackDiagnostic, PlaybackDiagnosticCode, PlaybackDiagnosticSource, -} from '../playback-diagnostics/playback-diagnostics.util'; +} from '@iptvnator/playback/util'; +import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; +import { + getDiagnosticCodecHint, + getDiagnosticDescriptionKey as resolveDiagnosticDescriptionKey, + getDiagnosticDetails, + getDiagnosticMeta, + getDiagnosticTitleKey, +} from '../playback-diagnostic-panel/playback-diagnostic-view.util'; jest.unstable_mockModule('video.js', () => ({ default: jest.fn(), @@ -141,6 +149,7 @@ describe('WebPlayerViewComponent', () => { StubEmbeddedMpvPlayerComponent, StubHtmlVideoPlayerComponent, StubVjsPlayerComponent, + PlaybackDiagnosticPanelComponent, // Real, not stubbed: the point of the test below is // that this row's Check action reaches the host. VodSourceRowComponent, @@ -156,6 +165,7 @@ describe('WebPlayerViewComponent', () => { storageMap.get.mockReturnValue(of({ player: VideoPlayer.VideoJs })); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', @@ -236,7 +246,7 @@ describe('WebPlayerViewComponent', () => { ]); fixture.detectChanges(); - component.handlePlaybackIssue(createUnsupportedContainerDiagnostic()); + emitPlaybackIssue(createUnsupportedContainerDiagnostic()); fixture.detectChanges(); const check = fixture.debugElement.query( @@ -258,7 +268,7 @@ describe('WebPlayerViewComponent', () => { ); fixture.detectChanges(); - component.handlePlaybackIssue(createUnsupportedContainerDiagnostic()); + emitPlaybackIssue(createUnsupportedContainerDiagnostic()); fixture.detectChanges(); const banner = fixture.debugElement.query( @@ -295,7 +305,7 @@ describe('WebPlayerViewComponent', () => { const issue = createHttpDiagnostic(); fixture.detectChanges(); - component.handlePlaybackIssue(issue); + emitPlaybackIssue(issue); fixture.detectChanges(); const banner = fixture.debugElement.query( @@ -307,8 +317,8 @@ describe('WebPlayerViewComponent', () => { expect(banner.nativeElement.textContent).toContain('HTTP 404'); expect(mpvButton).toBeNull(); - expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404'); - expect(component.getDiagnosticDetails(issue)).toEqual( + expect(getDiagnosticMeta(issue)).toBe('HTTP 404'); + expect(getDiagnosticDetails(issue)).toEqual( expect.arrayContaining([ { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_ERROR_DETAILS', @@ -318,13 +328,57 @@ describe('WebPlayerViewComponent', () => { ); }); + it.each([ + ['browser-access', createBrowserAccessDiagnostic], + ['unsupported-codec', createUnsupportedCodecDiagnostic], + ])( + 'uses neutral %s guidance when ClearKey playback cannot be transferred', + (_diagnostic, createDiagnostic) => { + runtimeCapabilities.supportsManagedExternalPlayers = true; + fixture.componentRef.setInput('playback', { + streamUrl: 'https://provider.example/protected.mpd', + title: 'Protected stream', + drm: { + licenseType: 'clearkey', + supported: true, + clearKeys: { + '00112233445566778899aabbccddeeff': + 'ffeeddccbbaa99887766554433221100', + }, + }, + }); + + fixture.detectChanges(); + emitPlaybackIssue(createDiagnostic()); + fixture.detectChanges(); + + const banner = fixture.debugElement.query( + By.css('[data-test-id="playback-diagnostic-banner"]') + ); + + expect(banner.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE' + ); + expect(banner.nativeElement.textContent).toContain( + 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION' + ); + expect( + banner.query( + By.css( + '[data-test-id="playback-fallback-mpv"], [data-test-id="playback-fallback-vlc"]' + ) + ) + ).toBeNull(); + } + ); + it('renders only sanitized structured HLS evidence in technical details', () => { const issue = createStructuredHlsDiagnostic(); - component.handlePlaybackIssue(issue); + emitPlaybackIssue(issue); fixture.detectChanges(); - const details = component.getDiagnosticDetails(issue); + const details = getDiagnosticDetails(issue); const renderedDetails = details.map(({ value }) => value).join(' '); expect(details).toEqual( @@ -345,10 +399,10 @@ describe('WebPlayerViewComponent', () => { it('renders only sanitized structured VHS evidence in technical details', () => { const issue = createStructuredVhsDiagnostic(); - component.handlePlaybackIssue(issue); + emitPlaybackIssue(issue); fixture.detectChanges(); - const details = component.getDiagnosticDetails(issue); + const details = getDiagnosticDetails(issue); const renderedDetails = details.map(({ value }) => value).join(' '); expect(details).toEqual( @@ -374,10 +428,10 @@ describe('WebPlayerViewComponent', () => { it('renders only sanitized structured Shaka evidence in technical details', () => { const issue = createStructuredShakaDiagnostic(); - component.handlePlaybackIssue(issue); + emitPlaybackIssue(issue); fixture.detectChanges(); - const details = component.getDiagnosticDetails(issue); + const details = getDiagnosticDetails(issue); const renderedDetails = details.map(({ value }) => value).join(' '); expect(details).toEqual( @@ -404,13 +458,13 @@ describe('WebPlayerViewComponent', () => { it('renders only sanitized structured mpegts evidence in technical details', () => { const issue = createStructuredMpegTsDiagnostic(); - component.handlePlaybackIssue(issue); + emitPlaybackIssue(issue); fixture.detectChanges(); - const details = component.getDiagnosticDetails(issue); + const details = getDiagnosticDetails(issue); const renderedDetails = details.map(({ value }) => value).join(' '); - expect(component.getDiagnosticMeta(issue)).toBe('HTTP 404'); + expect(getDiagnosticMeta(issue)).toBe('HTTP 404'); expect(details).toEqual( expect.arrayContaining([ { @@ -432,59 +486,6 @@ describe('WebPlayerViewComponent', () => { expect(renderedDetails).not.toContain('response body'); }); - it('keeps query-declared HLS streams on the HLS mime type', () => { - const streamUrl = - 'https://example.com/play?extension=m3u8&token=signed'; - - component.setVjsOptions(streamUrl); - - expect(component.vjsOptions.sources).toEqual([ - { - src: streamUrl, - type: 'application/x-mpegURL', - }, - ]); - }); - - it('uses the Matroska mime type for MKV paths', () => { - const streamUrl = 'https://example.com/archive/movie.mkv'; - - component.setVjsOptions(streamUrl); - - expect(component.vjsOptions.sources).toEqual([ - { - src: streamUrl, - type: 'video/matroska', - }, - ]); - }); - - it('uses the Matroska mime type for query-declared MKV streams', () => { - const streamUrl = 'https://example.com/play?container=mkv&token=signed'; - - component.setVjsOptions(streamUrl); - - expect(component.vjsOptions.sources).toEqual([ - { - src: streamUrl, - type: 'video/matroska', - }, - ]); - }); - - it('treats web script playback URLs without declared media extension as MPEG-TS', () => { - const streamUrl = 'https://example.com/live.php?stream=123&token=x'; - - component.setVjsOptions(streamUrl); - - expect(component.vjsOptions.sources).toEqual([ - { - src: streamUrl, - type: 'video/mp2t', - }, - ]); - }); - it('marks portal VOD playback as non-live for Video.js MPEG-TS playback', async () => { const streamUrl = 'https://example.com/movie/123.ts'; fixture.componentRef.setInput('playback', { @@ -596,6 +597,7 @@ describe('WebPlayerViewComponent', () => { const pendingSettings = new Subject(); storageMap.get.mockReturnValue(pendingSettings.asObservable()); fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('playbackSessionKey', 'test-session'); component = fixture.componentInstance; fixture.componentRef.setInput( 'streamUrl', @@ -627,11 +629,11 @@ describe('WebPlayerViewComponent', () => { ); fixture.detectChanges(); - component.handlePlaybackIssue(createUnsupportedCodecDiagnostic()); + component.playbackDiagnostic.set(createUnsupportedCodecDiagnostic()); fixture.detectChanges(); - component.requestExternalFallback('mpv'); + component.requestRecommendedPlayer('mpv'); - expect(component.playbackDiagnostic()).toBeNull(); + expect(component.visiblePlaybackDiagnostic()).toBeNull(); expect( fixture.debugElement.query( By.directive(StubEmbeddedMpvPlayerComponent) @@ -796,10 +798,10 @@ describe('WebPlayerViewComponent', () => { it('uses the PWA browser access diagnostic description key outside desktop', () => { const issue = createBrowserAccessDiagnostic(); - expect(component.getDiagnosticTitleKey(issue)).toBe( + expect(getDiagnosticTitleKey(issue)).toBe( 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.TITLE' ); - expect(component.getDiagnosticDescriptionKey(issue)).toBe( + expect(getDiagnosticDescriptionKey(issue)).toBe( 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION' ); }); @@ -808,14 +810,14 @@ describe('WebPlayerViewComponent', () => { runtimeCapabilities.supportsManagedExternalPlayers = true; const issue = createBrowserAccessDiagnostic(); - expect(component.getDiagnosticDescriptionKey(issue)).toBe( + expect(getDiagnosticDescriptionKey(issue)).toBe( 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION' ); }); it('uses an inline recovery headline when external fallback actions are unavailable', () => { fixture.detectChanges(); - component.handlePlaybackIssue(createNetworkDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); const banner = fixture.debugElement.query( @@ -838,7 +840,7 @@ describe('WebPlayerViewComponent', () => { fixture.detectChanges(); const issue = createUnsupportedCodecDiagnostic(); - component.handlePlaybackIssue(issue); + emitPlaybackIssue(issue); fixture.detectChanges(); const details = fixture.debugElement.query( @@ -851,8 +853,8 @@ describe('WebPlayerViewComponent', () => { expect(details.nativeElement.textContent).toContain( 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' ); - expect(component.getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3'); - expect(component.getDiagnosticDetails(issue)).toEqual( + expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3'); + expect(getDiagnosticDetails(issue)).toEqual( expect.arrayContaining([ { labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_CODE', @@ -888,7 +890,7 @@ describe('WebPlayerViewComponent', () => { expect.objectContaining({ reloadToken: 0 }) ); - component.handlePlaybackIssue(createUnsupportedCodecDiagnostic()); + emitPlaybackIssue(createNetworkDiagnostic()); fixture.detectChanges(); const retryButton = fixture.debugElement.query( @@ -902,9 +904,12 @@ describe('WebPlayerViewComponent', () => { retryButton.nativeElement.click(); fixture.detectChanges(); + const retriedPlayer = fixture.debugElement.query( + By.directive(StubVjsPlayerComponent) + ).componentInstance as StubVjsPlayerComponent; expect(component.playbackDiagnostic()).toBeNull(); - expect(player.options()).toEqual( + expect(retriedPlayer.options()).toEqual( expect.objectContaining({ reloadToken: 1 }) ); expect( @@ -1029,6 +1034,24 @@ describe('WebPlayerViewComponent', () => { ); }); }); + + function emitPlaybackIssue(issue: PlaybackDiagnostic | null): void { + fixture.detectChanges(); + const binding = component.activeBinding(); + expect(binding).not.toBeNull(); + if (!binding) { + throw new Error('Expected an active inline playback binding'); + } + component.handlePlaybackIssue(issue, binding); + } + + function getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string { + return resolveDiagnosticDescriptionKey( + issue, + runtimeCapabilities.supportsManagedExternalPlayers, + true + ); + } }); function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic { @@ -1041,7 +1064,6 @@ function createUnsupportedContainerDiagnostic(): PlaybackDiagnostic { player: 'videojs', audioCodecs: [], videoCodecs: [], - externalFallbackRecommended: true, }; } @@ -1056,7 +1078,6 @@ function createBrowserAccessDiagnostic(): PlaybackDiagnostic { audioCodecs: [], videoCodecs: [], details: 'blocked by CORS policy', - externalFallbackRecommended: true, }; } @@ -1071,7 +1092,6 @@ function createUnsupportedCodecDiagnostic(): PlaybackDiagnostic { audioCodecs: ['ac-3'], videoCodecs: ['hvc1.1.6.L93.B0'], details: 'manifestIncompatibleCodecsError', - externalFallbackRecommended: true, }; } @@ -1086,7 +1106,6 @@ function createNetworkDiagnostic(): PlaybackDiagnostic { audioCodecs: [], videoCodecs: [], details: 'HttpStatusCodeInvalid {"code":456,"msg":""}', - externalFallbackRecommended: false, }; } @@ -1104,7 +1123,6 @@ function createHttpDiagnostic(): PlaybackDiagnostic { nativeErrorMessage: 'source not supported', httpStatus: 404, nativeErrorType: 'networkrequestfailed', - externalFallbackRecommended: false, }; } @@ -1128,7 +1146,6 @@ function createStructuredHlsDiagnostic(): PlaybackDiagnostic { failure: 'http', httpStatus: 404, }, - externalFallbackRecommended: false, }; } @@ -1154,7 +1171,6 @@ function createStructuredVhsDiagnostic(): PlaybackDiagnostic { stage: 'unknown', httpStatus: 503, }, - externalFallbackRecommended: false, }; } @@ -1182,7 +1198,6 @@ function createStructuredShakaDiagnostic(): PlaybackDiagnostic { failure: 'network', httpStatus: 503, }, - externalFallbackRecommended: false, }; } @@ -1209,6 +1224,5 @@ function createStructuredMpegTsDiagnostic(): PlaybackDiagnostic { failure: 'http', httpStatus: 404, }, - externalFallbackRecommended: false, }; } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index b4c94525b..859d6caae 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -12,50 +12,52 @@ import { untracked, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ClipboardModule } from '@angular/cdk/clipboard'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; import { StorageMap } from '@ngx-pwa/local-storage'; -import { TranslatePipe } from '@ngx-translate/core'; import { - Channel, - ResolvedPortalPlayback, - Settings, + type PlaybackDiagnostic, + type PlaybackDiagnosticCode, + type PlaybackFallbackRequest, + type PlaybackRecommendationTarget, +} from '@iptvnator/playback/util'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { STORE_KEY, VideoPlayer, + type Channel, + type ResolvedPortalPlayback, + type Settings, type VodSourceDescriptor, } from '@iptvnator/shared/interfaces'; -import type { ExternalPlayerName } from '@iptvnator/shared/interfaces'; -import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; -import { VodSourceRowComponent } from '@iptvnator/ui/components'; - -/** How many recovery options the error screen shows before it stops helping. */ -const ERROR_SCREEN_ALTERNATIVES = 5; import { ArtPlayerComponent } from '../art-player/art-player.component'; import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv-player.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; +import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; import { type PlayerMediaTitle, WEB_PLAYER_SHARED_CONTROLS, WEB_PLAYER_SHARED_CONTROLS_ENABLED, } from '../player-controls'; -import { - type PlaybackDiagnostic, - type PlaybackDiagnosticCode, - type PlaybackFallbackRequest, - getPlaybackMediaExtensionFromUrl, -} from '../playback-diagnostics/playback-diagnostics.util'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { VjsPlayerComponent } from '../vjs-player/vjs-player.component'; +import type { VideoPlayerOptions } from '../vjs-player/vjs-player.types'; import { ElectronStreamHeadersService } from './electron-stream-headers.service'; import { - getDiagnosticCodecHint, - getDiagnosticDescriptionKey, - getDiagnosticDetails, - getDiagnosticMeta, - getDiagnosticTitleKey, -} from './web-player-view-diagnostics.utils'; + type PlaybackBinding, + PlaybackRecoverySession, +} from './playback-recovery-session'; +import { WebPlayerApplicationHandoffCoordinator } from './web-player-application-handoff'; +import { + createWebPlayerApplicationState, + type WebPlayerApplicationToken, + type WebPlayerSourceRevisionToken, +} from './web-player-application-state'; +import { resolveWebPlayerMediaTitle } from './web-player-playback-state'; +import { + createWebPlayerRecommendations, + isPlaybackExternallyTransferable, + toInlinePlaybackPlayer, + toVideoPlayer, +} from './web-player-recovery-policy'; function resolveWebPlayerSharedControls(): boolean { const storedValue = inject(SettingsStore).webPlayerSharedControls?.(); @@ -64,24 +66,25 @@ function resolveWebPlayerSharedControls(): boolean { : WEB_PLAYER_SHARED_CONTROLS_ENABLED; } +interface PlaybackApplicationOwnership { + readonly binding: PlaybackBinding | null; + readonly embeddedMpv: boolean; + readonly isLive: boolean; + readonly sourceRevision: WebPlayerSourceRevisionToken; + readonly token: WebPlayerApplicationToken; +} + @Component({ selector: 'app-web-player-view', templateUrl: './web-player-view.component.html', styleUrls: ['./web-player-view.component.scss'], - host: { - class: 'web-player-view', - }, + host: { class: 'web-player-view' }, imports: [ ArtPlayerComponent, - ClipboardModule, EmbeddedMpvPlayerComponent, HtmlVideoPlayerComponent, - MatButtonModule, - MatIconModule, - MatTooltipModule, - TranslatePipe, + PlaybackDiagnosticPanelComponent, VjsPlayerComponent, - VodSourceRowComponent, ], providers: [ { @@ -92,307 +95,310 @@ function resolveWebPlayerSharedControls(): boolean { encapsulation: ViewEncapsulation.None, }) export class WebPlayerViewComponent implements OnDestroy { - storage = inject(StorageMap); + private readonly storage = inject(StorageMap); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsStore = inject(SettingsStore); + private readonly recoverySession = new PlaybackRecoverySession(); + private readonly applicationHandoff = + new WebPlayerApplicationHandoffCoordinator( + inject(ElectronStreamHeadersService), + this.recoverySession + ); + + readonly streamUrl = input.required(); + readonly playbackSessionKey = input.required(); + readonly title = input(''); + readonly playback = input(null); + readonly startTime = input(0); + readonly volume = input(1); + readonly playerOverride = input(null); + readonly seriesNavigation = input(null); + readonly mediaTitle = input(null); + readonly alternativeSources = input([]); - streamUrl = input.required(); - title = input(''); - playback = input(null); - startTime = input(0); - volume = input(1); - playerOverride = input(null); - seriesNavigation = input(null); - /** Display-ready title lines for the fullscreen overlay; hosts with richer - * context (e.g. series name + episode label) pass it explicitly. */ - mediaTitle = input(null); readonly timeUpdate = output<{ currentTime: number; duration: number; }>(); readonly externalFallbackRequested = output(); - /** - * Alternative sources offered on the error screen, turning a dead end into - * a recovery point. Empty (the default) keeps the overlay exactly as it - * was for every non-multi-source host. - */ - readonly alternativeSources = input([]); readonly alternativeSourceRequested = output(); - /** The row's Check action, which has to reach the host that can probe. */ readonly sourceCheckRequested = output(); - /** - * A playback failure the host may be able to recover from by switching - * source. Emitted alongside showing the overlay, never instead of it: if - * the host does nothing, the user still sees the honest error. - */ readonly playbackFailed = output(); - - /** - * An error screen is a recovery point, not a catalogue. Offering fifty - * options here is worse than offering the best few — the full list stays - * one click away behind the player's own sources button. - */ - readonly visibleAlternatives = computed(() => - this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES) - ); - readonly hiddenAlternativeCount = computed(() => - Math.max(0, this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES) - ); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); - settings = toSignal(this.storage.get(STORE_KEY.Settings)) as Signal< - Settings | undefined - >; - - /** - * Subtitle preference for the built-in web players. Read from the settings - * store instead of an input so every host (M3U, Xtream, Stalker, portal - * detail pages) gets it without having to wire it through — the missing - * bindings were why the setting looked like a no-op (#1155). - */ + readonly settings = toSignal( + this.storage.get(STORE_KEY.Settings) + ) as Signal; readonly showCaptions = computed( () => this.settingsStore.showCaptions?.() ?? false ); - - channel!: Channel; - vjsOptions!: { - isLive: boolean; - reloadToken: number; - sources: { src: string; type: string }[]; - }; readonly reloadToken = signal(0); readonly playbackDiagnostic = signal(null); + readonly recoveryPending = this.recoverySession.switchPending; + readonly activeBinding = this.recoverySession.activeBinding; + + channel: Channel | undefined; + vjsOptions: VideoPlayerOptions | undefined; + + readonly selectedPlayer = computed(() => { + const temporary = this.recoverySession.temporaryPlayerOverride(); + return temporary + ? toVideoPlayer(temporary) + : (this.playerOverride() ?? + this.settings()?.player ?? + VideoPlayer.VideoJs); + }); + private readonly applicationState = createWebPlayerApplicationState({ + playback: this.playback, + streamUrl: this.streamUrl, + title: this.title, + startTime: this.startTime, + selectedPlayer: this.selectedPlayer, + reloadToken: this.reloadToken, + }); + readonly resolvedPlayback = this.applicationState.playback; + readonly resolvedIsLive = this.applicationState.isLive; + readonly playbackSourceRevisionToken = this.applicationState.sourceRevision; + readonly playbackApplicationToken = this.applicationState.token; + // Diagnostic ownership follows raw intent so an old action disappears; + // the application effect then clears its backing state before handoff. + // Keep this token opaque: it must never retain playback payload fields. + private readonly playbackDiagnosticIntentToken = + computed(() => { + if (this.playback() === null) { + void this.streamUrl(); + void this.startTime(); + } + void this.selectedPlayer(); + void this.reloadToken(); + return Symbol(); + }); + private readonly playbackDiagnosticOwnerToken = + signal(null); + readonly effectiveStartTime = computed(() => + this.recoverySession.resumeStartTime( + this.startTime(), + this.resolvedIsLive() + ) + ); readonly visiblePlaybackDiagnostic = computed(() => - this.selectedPlayer() === VideoPlayer.EmbeddedMpv + this.selectedPlayer() === VideoPlayer.EmbeddedMpv || + this.playbackDiagnosticOwnerToken() !== + this.playbackDiagnosticIntentToken() ? null : this.playbackDiagnostic() ); readonly playbackInteractionEnabled = computed( () => this.visiblePlaybackDiagnostic() === null ); - readonly canShowExternalFallbackActions = computed( - () => - this.runtime.supportsManagedExternalPlayers && - !!this.visiblePlaybackDiagnostic()?.externalFallbackRecommended + readonly resolvedMediaTitle = computed(() => + resolveWebPlayerMediaTitle(this.mediaTitle(), this.resolvedPlayback()) ); - readonly diagnosticHeadlineKey = computed(() => - this.canShowExternalFallbackActions() - ? 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE' - : 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE' + readonly playbackExternallyTransferable = computed(() => + isPlaybackExternallyTransferable(this.resolvedPlayback()) ); - - readonly resolvedPlayback = computed(() => { - const playback = this.playback(); - if (playback) { - return playback; + readonly recordingFolder = computed( + () => this.settings()?.recordingFolder ?? '' + ); + get supportsManagedExternalPlayers(): boolean { + return this.runtime.supportsManagedExternalPlayers; + } + readonly recommendations = computed(() => { + const binding = this.activeBinding(); + const token = this.playbackApplicationToken(); + return createWebPlayerRecommendations({ + diagnostic: this.visiblePlaybackDiagnostic(), + binding: + binding && this.applicationHandoff.owns(binding, token) + ? binding + : null, + attemptedTargets: this.recoverySession.attemptedTargets(), + managedExternalPlayersAvailable: + this.runtime.supportsManagedExternalPlayers, + playbackExternallyTransferable: + this.playbackExternallyTransferable(), + isLive: this.resolvedIsLive(), + alternativeSourceCount: this.alternativeSources().length, + }); + }); + readonly renderedApplications = computed< + readonly PlaybackApplicationOwnership[] + >(() => { + const binding = this.activeBinding(); + const embeddedMpv = + this.selectedPlayer() === VideoPlayer.EmbeddedMpv && !binding; + if (!binding && !embeddedMpv) { + return []; } - return { - streamUrl: this.streamUrl(), - title: this.title() || this.streamUrl(), - startTime: this.startTime(), - }; + return [ + Object.freeze({ + binding, + embeddedMpv, + isLive: this.resolvedIsLive(), + sourceRevision: this.playbackSourceRevisionToken(), + token: this.playbackApplicationToken(), + }), + ]; }); - readonly resolvedIsLive = computed(() => { - const playback = this.resolvedPlayback(); - return typeof playback.isLive === 'boolean' - ? playback.isLive - : !playback.contentInfo; - }); - readonly selectedPlayer = computed( - () => - this.playerOverride() ?? - this.settings()?.player ?? - VideoPlayer.VideoJs - ); - readonly resolvedMediaTitle = computed(() => { - const explicit = this.mediaTitle(); - if (explicit?.primary?.trim()) { - return explicit; - } - const playback = this.resolvedPlayback(); - const title = playback.title?.trim(); - // resolvedPlayback() falls back to the stream URL as title; a raw URL - // is not a watchable overlay title. - if (!title || title === playback.streamUrl) { - return null; - } - return { primary: title, secondary: null }; - }); - readonly recordingFolder = computed(() => this.settings()?.recordingFolder ?? ''); - - /** Stream URL the currently configured Electron header override belongs to. */ - private headerScopeStreamUrl: string | null = null; - private readonly streamHeaders = inject(ElectronStreamHeadersService); constructor() { effect(() => { - // Track player changes so stale browser diagnostics are cleared on switch. - this.selectedPlayer(); - - const playback = this.resolvedPlayback(); - const isLive = this.resolvedIsLive(); - this.playbackDiagnostic.set(null); - this.applyPlayback(playback, isLive); + // Session sync may clear a temporary player override. Run it before + // tracking intent so that reset is folded into this application. + this.syncRecoverySession(); + void this.playbackDiagnosticIntentToken(); + const sourceRevision = this.playbackSourceRevisionToken(); + untracked(() => + this.recoverySession.syncSourceRevision(sourceRevision) + ); + const token = this.playbackApplicationToken(); + const playback = untracked(this.resolvedPlayback); + const isLive = untracked(this.resolvedIsLive); + const selectedPlayer = untracked(this.selectedPlayer); + const reloadToken = untracked(this.reloadToken); + const target = toInlinePlaybackPlayer(selectedPlayer); + this.channel = undefined; + this.vjsOptions = undefined; + this.clearPlaybackDiagnostic(); + if (target === null) { + this.applicationHandoff.release(); + this.recoverySession.clearPlaybackBinding(); + return; + } + const binding = untracked(() => + this.recoverySession.beginPlayback(target) + ); + this.applicationHandoff.apply( + playback, + isLive, + reloadToken, + binding, + token, + () => this.playbackApplicationToken(), + (handoff) => { + this.channel = handoff.channel; + this.vjsOptions = handoff.vjsOptions; + } + ); }); } ngOnDestroy(): void { - // Portal credentials must not outlive the playback session that - // needed them: dropping the scoped override here keeps only the - // playlist-level (unscoped) User-Agent/Referer defaults active. The - // service no-ops if a newer consumer already owns the override slot. - this.streamHeaders.clear(this.headerScopeStreamUrl); + this.applicationHandoff.destroy(); } - /** - * Configures the scoped Electron request headers BEFORE the stream source - * is handed to a player, so the very first media request already carries - * them — an auth-gated portal stream answers 403 without its - * Cookie/Authorization, and several engines treat that first failure as - * fatal. In the PWA there is no header bridge and the source applies - * synchronously, exactly as before. - */ - private applyPlayback( - playback: ResolvedPortalPlayback, - isLive: boolean + handlePlaybackIssue( + issue: PlaybackDiagnostic | null, + binding: PlaybackBinding ): void { - const headerSync = this.streamHeaders.apply(playback); - this.headerScopeStreamUrl = playback.streamUrl; - const handOff = (): void => { - this.setChannel(playback); - this.setVjsOptions(playback.streamUrl, isLive); - }; - - if (!headerSync) { - handOff(); + this.syncRecoverySession(); + if (!this.recoverySession.accepts(binding)) { return; } - - void headerSync.then((stillCurrent) => { - if (stillCurrent) { - handOff(); - } - }); - } - - setVjsOptions(streamUrl: string, isLive = true) { - const extension = getPlaybackMediaExtensionFromUrl(streamUrl); - const mimeType = - extension === 'm3u' || extension === 'm3u8' - ? 'application/x-mpegURL' - : extension === 'ts' || !extension - ? 'video/mp2t' - : extension === 'mkv' - ? 'video/matroska' - : 'video/mp4'; - - this.vjsOptions = { - isLive, - reloadToken: untracked(() => this.reloadToken()), - sources: [{ src: streamUrl, type: mimeType }], - }; - } - - setChannel(playbackOrUrl: ResolvedPortalPlayback | string) { - const playback = - typeof playbackOrUrl === 'string' - ? { - streamUrl: playbackOrUrl, - title: playbackOrUrl, - } - : playbackOrUrl; - - this.channel = { - id: playback.streamUrl, - url: playback.streamUrl, - name: playback.title || playback.streamUrl, - group: { title: '' }, - tvg: { - id: '', - name: playback.title || playback.streamUrl, - url: '', - logo: playback.thumbnail ?? '', - rec: '', - }, - http: { - referrer: - playback.referer ?? - this.getHeaderValue(playback.headers, 'Referer') ?? - '', - 'user-agent': - playback.userAgent ?? - this.getHeaderValue(playback.headers, 'User-Agent') ?? - '', - origin: - playback.origin ?? - this.getHeaderValue(playback.headers, 'Origin') ?? - '', - }, - radio: 'false', - drm: playback.drm, - }; - } - - handlePlaybackIssue(issue: PlaybackDiagnostic | null): void { - if (this.selectedPlayer() === VideoPlayer.EmbeddedMpv) { - this.playbackDiagnostic.set(null); + if ( + !this.applicationHandoff.owns( + binding, + this.playbackApplicationToken() + ) + ) { + this.applicationHandoff.invalidate(); + this.recoverySession.clearPlaybackBinding(); + this.clearPlaybackDiagnostic(); return; } - + if (!issue) { + this.recoverySession.settle(binding); + this.clearPlaybackDiagnostic(); + return; + } + if (!this.recoverySession.recordFailure(binding)) { + return; + } + this.playbackDiagnosticOwnerToken.set( + this.playbackDiagnosticIntentToken() + ); this.playbackDiagnostic.set(issue); - if (issue) { - this.playbackFailed.emit(issue.code); - } + this.playbackFailed.emit(issue.code); } - requestExternalFallback(player: ExternalPlayerName): void { + handleTimeUpdate( + event: { currentTime: number; duration: number }, + ownership: PlaybackApplicationOwnership + ): void { + if (!this.ownsPlaybackApplication(ownership)) { + return; + } + + this.recoverySession.recordTimeUpdate(event, ownership.isLive); + this.timeUpdate.emit(event); + } + + requestRecommendedPlayer(target: PlaybackRecommendationTarget): void { const diagnostic = this.visiblePlaybackDiagnostic(); if (!diagnostic) { return; } - - this.externalFallbackRequested.emit({ - player, - playback: this.resolvedPlayback(), - diagnostic, - }); + const available = this.recommendations().some( + (item) => item.action === 'player' && item.target === target + ); + if (!available) { + if (target !== 'mpv' && target !== 'vlc') { + this.recoverySession.recordInlineAttempt(target); + } + return; + } + if (target === 'mpv' || target === 'vlc') { + this.recoverySession.recordExternalAttempt(target); + this.externalFallbackRequested.emit({ + player: target, + playback: this.resolvedPlayback(), + diagnostic, + }); + return; + } + this.recoverySession.beginPlayerSwitch(target, this.resolvedIsLive()); } retryPlayback(): void { - const playback = this.resolvedPlayback(); - - this.playbackDiagnostic.set(null); - this.reloadToken.update((value) => value + 1); - this.setChannel(playback); - this.setVjsOptions(playback.streamUrl, this.resolvedIsLive()); - } - - readonly getDiagnosticTitleKey = getDiagnosticTitleKey; - readonly getDiagnosticMeta = getDiagnosticMeta; - readonly getDiagnosticCodecHint = getDiagnosticCodecHint; - readonly getDiagnosticDetails = getDiagnosticDetails; - - getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string { - return getDiagnosticDescriptionKey( - issue, - this.runtime.supportsManagedExternalPlayers - ); - } - - private getHeaderValue( - headers: ResolvedPortalPlayback['headers'] | undefined, - name: string - ): string | undefined { - if (!headers) { - return undefined; + if (!this.recoverySession.beginRetry()) { + return; } + this.reloadToken.update((value) => value + 1); + } - const matchingKey = Object.keys(headers).find( - (key) => key.toLowerCase() === name.toLowerCase() + private syncRecoverySession(): void { + const sessionKey = this.playbackSessionKey(); + if (untracked(() => this.recoverySession.syncSession(sessionKey))) { + this.clearPlaybackDiagnostic(); + } + } + + private clearPlaybackDiagnostic(): void { + this.playbackDiagnosticOwnerToken.set(null); + this.playbackDiagnostic.set(null); + } + + private ownsPlaybackApplication( + ownership: PlaybackApplicationOwnership + ): boolean { + if ( + ownership.token !== this.playbackApplicationToken() || + ownership.sourceRevision !== this.playbackSourceRevisionToken() + ) { + return false; + } + if (ownership.binding) { + return ( + !ownership.embeddedMpv && + this.applicationHandoff.owns(ownership.binding, ownership.token) + ); + } + return ( + ownership.embeddedMpv && + this.selectedPlayer() === VideoPlayer.EmbeddedMpv ); - return matchingKey ? headers[matchingKey] : undefined; } } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index 553ad2646..bc58931ba 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util'; +import type { PlaybackDiagnostic } from '@iptvnator/playback/util'; /** * Player stand-ins for WebPlayerViewComponent specs. They mirror the real @@ -7,7 +7,10 @@ import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagno * without pulling in video.js, ArtPlayer or the Electron bridge. */ -@Component({ selector: 'app-vjs-player', template: '' }) +@Component({ + selector: 'app-vjs-player', + template: '
', +}) export class StubVjsPlayerComponent { readonly options = input(); readonly mediaTitle = input(null); @@ -23,7 +26,10 @@ export class StubVjsPlayerComponent { readonly nextEpisodeRequested = output(); } -@Component({ selector: 'app-html-video-player', template: '' }) +@Component({ + selector: 'app-html-video-player', + template: '
', +}) export class StubHtmlVideoPlayerComponent { readonly channel = input(); readonly mediaTitle = input(null); @@ -40,7 +46,10 @@ export class StubHtmlVideoPlayerComponent { readonly nextEpisodeRequested = output(); } -@Component({ selector: 'app-art-player', template: '' }) +@Component({ + selector: 'app-art-player', + template: '
', +}) export class StubArtPlayerComponent { readonly channel = input(); readonly mediaTitle = input(null); @@ -57,7 +66,10 @@ export class StubArtPlayerComponent { readonly nextEpisodeRequested = output(); } -@Component({ selector: 'app-embedded-mpv-player', template: '' }) +@Component({ + selector: 'app-embedded-mpv-player', + template: '
', +}) export class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly mediaTitle = input(null); diff --git a/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts new file mode 100644 index 000000000..2d518a3a5 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.spec.ts @@ -0,0 +1,71 @@ +import { isBrowserMediaTypeSupported } from './browser-media-type-support'; + +describe('isBrowserMediaTypeSupported', () => { + let mediaSourceDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + mediaSourceDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'MediaSource' + ); + }); + + afterEach(() => { + restoreMediaSource(mediaSourceDescriptor); + }); + + it('returns undefined when MediaSource is unavailable', () => { + Reflect.deleteProperty(globalThis, 'MediaSource'); + + expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined(); + }); + + it.each([undefined, null, true, {}])( + 'returns undefined when isTypeSupported is not callable: %p', + (isTypeSupported) => { + setMediaSource({ isTypeSupported }); + + expect(isBrowserMediaTypeSupported('video/mp4')).toBeUndefined(); + } + ); + + it('returns undefined when the browser capability probe throws', () => { + const providerMimeType = + 'video/mp4; codecs="provider-controlled-codec"'; + setMediaSource({ + isTypeSupported: () => { + throw new Error(providerMimeType); + }, + }); + + expect(isBrowserMediaTypeSupported(providerMimeType)).toBeUndefined(); + }); + + it.each([true, false])( + 'returns the browser capability result %s', + (supportResult) => { + const isTypeSupported = jest.fn(() => supportResult); + setMediaSource({ isTypeSupported }); + + expect(isBrowserMediaTypeSupported('video/mp4')).toBe( + supportResult + ); + expect(isTypeSupported).toHaveBeenCalledWith('video/mp4'); + } + ); +}); + +function setMediaSource(value: unknown): void { + Object.defineProperty(globalThis, 'MediaSource', { + configurable: true, + value, + }); +} + +function restoreMediaSource(descriptor?: PropertyDescriptor): void { + if (descriptor) { + Object.defineProperty(globalThis, 'MediaSource', descriptor); + return; + } + Reflect.deleteProperty(globalThis, 'MediaSource'); +} diff --git a/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts new file mode 100644 index 000000000..23f611d84 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts @@ -0,0 +1,16 @@ +export function isBrowserMediaTypeSupported( + mimeType: string +): boolean | undefined { + if (typeof MediaSource === 'undefined') { + return undefined; + } + + try { + const probe: unknown = MediaSource.isTypeSupported; + return typeof probe === 'function' + ? probe.call(MediaSource, mimeType) + : undefined; + } catch { + return undefined; + } +} diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts index 6dcd6c642..251755462 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-shaka-controls.spec.ts @@ -1,4 +1,4 @@ -import { InlinePlaybackPlayer } from '../playback-diagnostics/playback-diagnostics.model'; +import { InlinePlaybackPlayer } from '@iptvnator/playback/util'; import { createFakeShakaEnvironment, flushShakaMicrotasks as flush, diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts index 34ae1087e..2d6e7345b 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts @@ -4,13 +4,17 @@ import { TranslateService } from '@ngx-translate/core'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util'; import { GlobalFavoritesService } from './global-favorites.service'; +import { of } from 'rxjs'; describe('GlobalFavoritesService', () => { let service: GlobalFavoritesService; let electronApi: { + dbGetAppState: jest.Mock; dbReorderGlobalFavorites: jest.Mock; dbSetAppState: jest.Mock; }; + const storeSelect = jest.fn(); + const getPlaylistById = jest.fn(); const makeChannel = ( overrides: Partial & @@ -25,7 +29,10 @@ describe('GlobalFavoritesService', () => { }); beforeEach(() => { + storeSelect.mockReset().mockReturnValue(of([])); + getPlaylistById.mockReset(); electronApi = { + dbGetAppState: jest.fn().mockResolvedValue(null), dbReorderGlobalFavorites: jest .fn() .mockResolvedValue({ success: true }), @@ -39,9 +46,12 @@ describe('GlobalFavoritesService', () => { TestBed.configureTestingModule({ providers: [ GlobalFavoritesService, - { provide: Store, useValue: { select: jest.fn() } }, + { provide: Store, useValue: { select: storeSelect } }, { provide: DatabaseService, useValue: {} }, - { provide: PlaylistsService, useValue: {} }, + { + provide: PlaylistsService, + useValue: { getPlaylistById }, + }, { provide: TranslateService, useValue: { instant: (key: string) => key }, @@ -58,6 +68,52 @@ describe('GlobalFavoritesService', () => { }); }); + it('keeps legacy URL UIDs aligned with the persisted M3U order', async () => { + const firstUrl = 'https://streams.example/one.ts'; + const secondUrl = 'https://streams.example/two.ts'; + storeSelect.mockReturnValue( + of([ + { + _id: 'playlist-one', + title: 'Playlist', + favorites: [firstUrl, secondUrl], + }, + ]) + ); + getPlaylistById.mockReturnValue( + of({ + playlist: { + items: [ + { + id: 'channel-one', + name: 'Channel One', + url: firstUrl, + }, + { + id: 'channel-two', + name: 'Channel Two', + url: secondUrl, + }, + ], + }, + }) + ); + electronApi.dbGetAppState.mockResolvedValue( + JSON.stringify([ + `m3u::playlist-one::${secondUrl}`, + `m3u::playlist-one::${firstUrl}`, + ]) + ); + + const channels = await service.getUnifiedLiveFavorites(); + + expect(channels.map((channel) => channel.uid)).toEqual([ + `m3u::playlist-one::${secondUrl}`, + `m3u::playlist-one::${firstUrl}`, + ]); + expect(channels.map((channel) => channel.position)).toEqual([0, 1]); + }); + describe('reorder', () => { it('sends playlist-scoped position updates for Xtream favorites', async () => { // The backend UPDATE filters by (contentId, playlistId); a payload @@ -109,7 +165,10 @@ describe('GlobalFavoritesService', () => { expect(electronApi.dbReorderGlobalFavorites).not.toHaveBeenCalled(); expect(electronApi.dbSetAppState).toHaveBeenCalledWith( 'global-favorites-channel-order-v1', - JSON.stringify(['m3u::playlist-m::url', 'stalker::playlist-s::5']) + JSON.stringify([ + 'm3u::playlist-m::url', + 'stalker::playlist-s::5', + ]) ); }); }); diff --git a/tools/coverage/coverage-policy.json b/tools/coverage/coverage-policy.json index b2c2b69ba..e9ee12961 100644 --- a/tools/coverage/coverage-policy.json +++ b/tools/coverage/coverage-policy.json @@ -89,6 +89,13 @@ "validationCommand": "pnpm nx test m3u-state", "e2eTags": ["@m3u", "@persistence"] }, + { + "name": "playback-util", + "root": "libs/playback/util", + "sourceRoot": "libs/playback/util/src", + "validationCommand": "pnpm nx test playback-util", + "e2eTags": ["@playback", "@m3u", "@xtream", "@stalker"] + }, { "name": "playlist-import-feature", "root": "libs/playlist/import/feature", diff --git a/tsconfig.base.json b/tsconfig.base.json index 78aa9fdc2..c934e2bb9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,6 +32,7 @@ "libs/portal/shared/util/src/index.ts" ], "@iptvnator/epg/data-access": ["libs/epg/data-access/src/index.ts"], + "@iptvnator/playback/util": ["libs/playback/util/src/index.ts"], "@iptvnator/playlist/shared/ui": [ "libs/playlist/shared/ui/src/index.ts" ],