From 50871e581fb7e27071980a6d65698afc71cd11be Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:39:39 +0200 Subject: [PATCH] feat(playback): add quality selection to shared player controls (#1470) * feat(playback): add quality selection to shared player controls Adds a per-session video quality menu (Auto + "1080p"-style levels) to the shared player-controls layer, mirroring the audio-track pattern: - Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state, setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR. - hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with list-index ids, smooth switching through nextLevel, selection read from manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED, LEVEL_SWITCHED. - Shaka (DASH): variant tracks filtered to the active audio language, ABR disabled before selectVariantTrack; manual state keyed to the exact player instance so a session restart never shows a stale selection. - Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual = exactly one enabled level, auto = all enabled, derived statelessly). - Embedded MPV and external players report the capability false. The capability derives from the manifest (advertised only for >1 video rendition), nothing persists to Settings, and the menu rides the default-off webPlayerSharedControls rollout gate. Labels come from one shared helper so all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to all 19 i18n files. Co-Authored-By: Claude Fable 5 * fix(playback): pin DASH quality candidates to the active audio stream Review findings on #1470: - Shaka quality candidates now match the active variant's exact audioId (language fallback only when Shaka reports none), so a DASH manifest with same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no longer switch the audio track or show duplicate levels when a quality is picked. Regression test added. - Mirror the quality-selection contract into AGENTS.md's Shared Player Controls section, which must stay in sync with CLAUDE.md. Co-Authored-By: Claude Fable 5 * fix(playback): track Video.js manual quality intent explicitly Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off itself when it temporarily excludes failing renditions, so inferring the manual/auto mode from the enabled count could report a manual selection the user never made once exclusions leave a single survivor. VjsQualityLevels now records the picked level object as explicit manual intent: error exclusions read as auto, a picked level that leaves the list reverts to auto, and the bridge resets the intent on every new source. Regression tests added. Co-Authored-By: Claude Fable 5 * fix(playback): re-enable surviving renditions when the picked level is removed Codex follow-up on #1470: dropping manual intent when the picked QualityLevel leaves the list reverted the UI to auto but left the surviving renditions disabled by the earlier manual pick, pinning VHS with no selectable rendition. Reverting to auto now re-enables every remaining level, both on the removal event and lazily from the state read. Regression tests added. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: 4gray Co-authored-by: Claude Fable 5 --- .changes/playback-quality-selection.md | 10 + AGENTS.md | 9 + CLAUDE.md | 2 +- apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + docs/architecture/player-controls-contract.md | 67 ++++- ...t-player.component.shared-controls.spec.ts | 2 + .../embedded-mpv-controls.adapter.spec.ts | 2 + .../embedded-mpv-controls.adapter.ts | 4 + .../html-video-player-controls.hls.spec.ts | 77 +++++ ...tml-video-player-controls.spec-fixtures.ts | 26 ++ ....component.shared-controls.sources.spec.ts | 4 +- .../controls-menu-selection.spec.ts | 10 + .../controls-menu-selection.ts | 6 +- .../controls-menu-state.spec.ts | 18 +- .../player-controls/controls-menu-state.ts | 10 + .../player-controls/controls-view-model.ts | 4 + .../player-controls-defaults.spec.ts | 8 + .../player-controls-defaults.ts | 3 + ...player-controls.component.contract.spec.ts | 58 ++++ .../player-controls.component.html | 77 +++++ ...er-controls.component.interactions.spec.ts | 1 + ...yer-controls.component.media-title.spec.ts | 1 + ...trols.component.picture-in-picture.spec.ts | 1 + .../player-controls.component.spec.ts | 1 + .../player-controls.component.surface.spec.ts | 1 + ...player-controls.component.timeline.spec.ts | 1 + .../player-controls.component.ts | 5 +- .../player-controls/player-controls.model.ts | 16 ++ ...web-video-controls.adapter.quality.spec.ts | 101 +++++++ .../web-video-controls.adapter.ts | 15 + .../lib/shaka-engine/shaka-module.types.ts | 26 ++ .../shaka-engine/shaka-player-test-double.ts | 20 ++ .../vjs-player/vjs-player-controls.bridge.ts | 13 + .../src/lib/vjs-player/vjs-player.types.ts | 17 ++ .../lib/vjs-player/vjs-quality-levels.spec.ts | 269 ++++++++++++++++++ .../src/lib/vjs-player/vjs-quality-levels.ts | 162 +++++++++++ .../quality-level-labels.spec.ts | 58 ++++ .../web-video-support/quality-level-labels.ts | 67 +++++ .../web-video-hls-controls.ts | 49 +++- .../web-video-shaka-controls.spec.ts | 199 +++++++++++++ .../web-video-shaka-controls.ts | 102 ++++++- .../web-video-source-controls.bridge.ts | 3 + .../web-video-source-tracks.ts | 29 ++ 61 files changed, 1582 insertions(+), 10 deletions(-) create mode 100644 .changes/playback-quality-selection.md create mode 100644 libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.quality.spec.ts create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-quality-levels.spec.ts create mode 100644 libs/ui/playback/src/lib/vjs-player/vjs-quality-levels.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/quality-level-labels.spec.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/quality-level-labels.ts diff --git a/.changes/playback-quality-selection.md b/.changes/playback-quality-selection.md new file mode 100644 index 000000000..3e62cf2ef --- /dev/null +++ b/.changes/playback-quality-selection.md @@ -0,0 +1,10 @@ +--- +type: feature +area: playback +--- + +The built-in web players' shared controls gain a quality menu: multi-bitrate +HLS and DASH streams can be pinned to a specific rendition ("1080p", "720p", …) +or left on Auto, which keeps the player's adaptive selection. The menu appears +only when the stream actually offers more than one quality, and the choice +lasts for the current playback session. diff --git a/AGENTS.md b/AGENTS.md index bce27e7e8..1800d84d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,15 @@ Key files: `subtitleDisplay` would silently override whatever the vendor menu picks. For DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads, so the helper only stops re-suppressing afterwards. +- Shared controls include a per-session quality menu (Auto + "1080p"-style + levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR). The + capability derives from the manifest — advertised only when the source + exposes >1 video rendition (multi-variant HLS via hls.js + `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the + active variant's exact audio stream (`audioId`, language fallback) with ABR + toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — + so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to + Settings, and Embedded MPV/external players report the capability false. - Embedded MPV ignores the web-player preference. Frame-copy always uses shared DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while native-view retains the legacy compositor-safe dock and external MPV/VLC diff --git a/CLAUDE.md b/CLAUDE.md index 2f8892bb7..37afba79e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1101,7 +1101,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 ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `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. Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. 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 — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `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 diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 5bdb8539c..f05901cc3 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمات", "SUBTITLES_OFF": "إيقاف", + "QUALITY": "الجودة", + "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", "AUDIO_TRACK_FALLBACK": "صوت {{index}}", "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 25494236c..5bf907456 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "مسارات الصوت", "SUBTITLES": "الترجمة", "SUBTITLES_OFF": "معطلة", + "QUALITY": "الجودة", + "QUALITY_AUTO": "تلقائي", "TRACK_DEFAULT": "افتراضي", "AUDIO_TRACK_FALLBACK": "صوت {{index}}", "SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index aee725a4f..f115fa435 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Аўдыядарожкі", "SUBTITLES": "Субтытры", "SUBTITLES_OFF": "Выкл.", + "QUALITY": "Якасць", + "QUALITY_AUTO": "Аўта", "TRACK_DEFAULT": "Па змаўчанні", "AUDIO_TRACK_FALLBACK": "Аўдыя {{index}}", "SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index a3ad928c6..94aba1023 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Tonspuren", "SUBTITLES": "Untertitel", "SUBTITLES_OFF": "Aus", + "QUALITY": "Qualität", + "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standard", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Untertitel {{index}}", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a289814ff..5fc9a209e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Κομμάτια ήχου", "SUBTITLES": "Υπότιτλοι", "SUBTITLES_OFF": "Ανενεργοί", + "QUALITY": "Ποιότητα", + "QUALITY_AUTO": "Αυτόματη", "TRACK_DEFAULT": "Προεπιλογή", "AUDIO_TRACK_FALLBACK": "Ήχος {{index}}", "SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 7c1ea9b9b..ab0c8f2e4 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Audio tracks", "SUBTITLES": "Subtitles", "SUBTITLES_OFF": "Off", + "QUALITY": "Quality", + "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Default", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Subtitle {{index}}", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 467f9df8e..d711a5001 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Pistas de audio", "SUBTITLES": "Subtítulos", "SUBTITLES_OFF": "Desactivados", + "QUALITY": "Calidad", + "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Predeterminada", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Subtítulo {{index}}", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 548b4652a..b25aa2bbd 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Pistes audio", "SUBTITLES": "Sous-titres", "SUBTITLES_OFF": "Désactivés", + "QUALITY": "Qualité", + "QUALITY_AUTO": "Auto", "TRACK_DEFAULT": "Par défaut", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Sous-titre {{index}}", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 0a4a6c21a..0ff554239 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Hangsávok", "SUBTITLES": "Feliratok", "SUBTITLES_OFF": "Kikapcsolva", + "QUALITY": "Minőség", + "QUALITY_AUTO": "Automatikus", "TRACK_DEFAULT": "Alapértelmezett", "AUDIO_TRACK_FALLBACK": "{{index}}. hangsáv", "SUBTITLE_TRACK_FALLBACK": "{{index}}. feliratsáv", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index bab2ca47e..0619dc614 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Tracce audio", "SUBTITLES": "Sottotitoli", "SUBTITLES_OFF": "Disattivati", + "QUALITY": "Qualità", + "QUALITY_AUTO": "Automatica", "TRACK_DEFAULT": "Predefinita", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Sottotitoli {{index}}", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index bf13d04bd..1c349bc67 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "音声トラック", "SUBTITLES": "字幕", "SUBTITLES_OFF": "オフ", + "QUALITY": "画質", + "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "デフォルト", "AUDIO_TRACK_FALLBACK": "音声 {{index}}", "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index e804ad942..7a2ef66ea 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "오디오 트랙", "SUBTITLES": "자막", "SUBTITLES_OFF": "끄기", + "QUALITY": "화질", + "QUALITY_AUTO": "자동", "TRACK_DEFAULT": "기본", "AUDIO_TRACK_FALLBACK": "오디오 {{index}}", "SUBTITLE_TRACK_FALLBACK": "자막 {{index}}", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 30f342d11..1e9e71774 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Audiosporen", "SUBTITLES": "Ondertiteling", "SUBTITLES_OFF": "Uit", + "QUALITY": "Kwaliteit", + "QUALITY_AUTO": "Automatisch", "TRACK_DEFAULT": "Standaard", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Ondertitel {{index}}", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 57ba589f0..51f0a92db 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Ścieżki audio", "SUBTITLES": "Napisy", "SUBTITLES_OFF": "Wyłączone", + "QUALITY": "Jakość", + "QUALITY_AUTO": "Automatycznie", "TRACK_DEFAULT": "Domyślna", "AUDIO_TRACK_FALLBACK": "Audio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Napisy {{index}}", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 1c701907d..bb07e4918 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Faixas de áudio", "SUBTITLES": "Legendas", "SUBTITLES_OFF": "Desativadas", + "QUALITY": "Qualidade", + "QUALITY_AUTO": "Automática", "TRACK_DEFAULT": "Padrão", "AUDIO_TRACK_FALLBACK": "Áudio {{index}}", "SUBTITLE_TRACK_FALLBACK": "Legenda {{index}}", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 96b813fc0..93c0e284d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Аудиодорожки", "SUBTITLES": "Субтитры", "SUBTITLES_OFF": "Выкл.", + "QUALITY": "Качество", + "QUALITY_AUTO": "Авто", "TRACK_DEFAULT": "По умолчанию", "AUDIO_TRACK_FALLBACK": "Аудио {{index}}", "SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index f1685059f..ebc9975c4 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "Ses parçaları", "SUBTITLES": "Altyazılar", "SUBTITLES_OFF": "Kapalı", + "QUALITY": "Kalite", + "QUALITY_AUTO": "Otomatik", "TRACK_DEFAULT": "Varsayılan", "AUDIO_TRACK_FALLBACK": "Ses {{index}}", "SUBTITLE_TRACK_FALLBACK": "Altyazı {{index}}", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index fe55031d2..ff0d877ab 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "音轨", "SUBTITLES": "字幕", "SUBTITLES_OFF": "关闭", + "QUALITY": "画质", + "QUALITY_AUTO": "自动", "TRACK_DEFAULT": "默认", "AUDIO_TRACK_FALLBACK": "音轨 {{index}}", "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 38d293f2d..b4ad1eef7 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -534,6 +534,8 @@ "AUDIO_TRACKS": "音軌", "SUBTITLES": "字幕", "SUBTITLES_OFF": "關閉", + "QUALITY": "畫質", + "QUALITY_AUTO": "自動", "TRACK_DEFAULT": "預設", "AUDIO_TRACK_FALLBACK": "音軌 {{index}}", "SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}", diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 61e220f3b..034f17a6a 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -172,8 +172,8 @@ interface PlayerController { ### Capabilities `PlayerControlsCapabilities` contains booleans for `seek`, `volume`, -`audioTracks`, `subtitles`, `playbackSpeed`, `aspectRatio`, `recording`, -`pictureInPicture`, `fullscreen`, and `seriesNavigation`. +`audioTracks`, `subtitles`, `qualityLevels`, `playbackSpeed`, `aspectRatio`, +`recording`, `pictureInPicture`, `fullscreen`, and `seriesNavigation`. The default is all-false. An adapter enables only features that its engine and current runtime support. Capability flags primarily control whether optional UI @@ -188,6 +188,7 @@ is rendered; state such as `canSeek`, `canPreviousEpisode`, and - current position, optional duration, live/VOD classification, and seekability; - volume; - pre-labelled audio/subtitle tracks and subtitle-enabled state; +- pre-labelled quality levels and the ABR/auto flag; - playback speed and aspect-ratio selections/presets; - recording state; - picture-in-picture active state and runtime availability; and @@ -208,6 +209,7 @@ owner. - `seekTo` / `seekBy` - `setVolume` - `setAudioTrack` / `setSubtitleTrack` +- `setQualityLevel` (`AUTO_QUALITY_LEVEL_ID` = `-1` re-enables auto) - `setPlaybackSpeed` - `setAspectRatio` - `toggleRecording` @@ -487,6 +489,64 @@ control chrome. Subtitle rendering in that surface is browser-dependent. AirPlay, Cast, Document Picture-in-Picture, a PiP keyboard shortcut, and an Embedded MPV popup or native mini-window are out of scope. +### Quality (bitrate/level) selection + +Quality selection is part of the shared controls and therefore rides the same +default-off `WEB_PLAYER_SHARED_CONTROLS` rollout. The contract exposes: + +- capability `qualityLevels`; +- state `qualityLevels` (pre-labelled options such as "1080p") and + `qualityAutoEnabled`; and +- command `setQualityLevel(id)`, where `AUTO_QUALITY_LEVEL_ID` (`-1`) + re-enables the engine's adaptive (ABR) selection. + +The capability derives from the manifest, not the content type: it is +advertised only when the current source exposes **more than one** video +rendition, so single-bitrate Xtream VOD files and raw MPEG-TS streams never +show the menu, while multi-variant live HLS does. The menu renders next to the +audio/subtitle menus with an Auto entry first; Auto is the default, a level +reports `selected` only while a manual choice is active, and the choice is +per-session — nothing is persisted to Settings. + +Labels come from one shared helper (`quality-level-labels.ts` in +`web-video-support/`): frame height first ("1080p"), a 16:9 projection when +only the width is known, the bitrate when no dimension is known, and a bitrate +suffix only when two levels would otherwise collide on the same label. + +Engine mechanics: + +- **hls.js** (HTML5 and ArtPlayer via the neutral source bridge): levels are + `hls.levels` with list-index ids; a manual switch assigns `hls.nextLevel` + (switches at the next fragment instead of flushing the buffer), `-1` restores + auto, and the selected level is read from the public `manualLevel`. The HLS + refresh-event list additionally observes `MANIFEST_PARSED`, + `LEVELS_UPDATED`, and `LEVEL_SWITCHED`. +- **Shaka (DASH)**: options are variant tracks pinned to the active variant's + exact audio stream — variants are audio+video combinations, and picking a + quality must not switch the audio track. The filter matches the active + variant's `audioId` when Shaka reports one (two same-language audio tracks + such as main vs. commentary share a language but never an id) and falls back + to the language only when no id is available — sorted by resolution then + bandwidth. Manual + selection disables ABR via `configure({abr: {enabled: false}})` before + `selectVariantTrack(track, true)`; the auto sentinel re-enables ABR. Manual + state is keyed to the exact player instance, so a session restart (which + creates a fresh player with ABR on) can never render a stale manual + selection. +- **Video.js**: `VjsQualityLevels` projects the videojs-contrib-quality-levels + list (registered by the component's plugin import). VHS has no manual-level + setter, so a manual selection enables exactly one level and auto re-enables + all. Manual intent is tracked explicitly by the picked level object — VHS + also flips `enabled` off for renditions it temporarily excludes after + delivery errors, so counting enabled levels would misreport a manual + selection. A picked level that leaves the list, a source change, and + `clear()` all revert to auto. A missing or throwing plugin degrades to no + capability. +- **Embedded MPV and external players**: `qualityLevels` stays false — + single-program transport streams have no rendition list to offer and no HLS + level API is surfaced there. `EmbeddedMpvControlsAdapter.setQualityLevel` is + a no-op. + Native media events refresh the adapter automatically. An engine host must call the public `refresh()` hook after engine-specific getters change without a corresponding media event, including track lists, corrected duration, @@ -705,8 +765,10 @@ The neutral web-video source support shared by HTML5 and ArtPlayer lives in: ```text libs/ui/playback/src/lib/web-video-support/ +├── quality-level-labels.ts ├── web-video-hls-controls.ts ├── web-video-native-text-tracks.ts +├── web-video-shaka-controls.ts ├── web-video-source-tracks.ts └── web-video-source-controls.bridge.ts ``` @@ -745,6 +807,7 @@ libs/ui/playback/src/lib/vjs-player/ ├── vjs-audio-tracks.ts ├── vjs-legacy-tracks.ts ├── vjs-mpegts-session.ts +├── vjs-quality-levels.ts ├── vjs-player-controls.bridge.ts ├── vjs-player-reset-coordinator.ts ├── vjs-player-setup.ts diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts index a780ba53e..0fc442485 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.shared-controls.spec.ts @@ -71,6 +71,8 @@ class MockHls { SUBTITLE_TRACKS_CLEARED: 'subtitleTracksCleared', SUBTITLE_TRACK_SWITCH: 'subtitleTrackSwitch', MANIFEST_LOADING: 'manifestLoading', + LEVELS_UPDATED: 'levelsUpdated', + LEVEL_SWITCHED: 'levelSwitched', }; static isSupported = jest.fn(() => true); } diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts index a08d6e3d1..4461baafa 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts @@ -195,6 +195,7 @@ describe('EmbeddedMpvControlsAdapter', () => { volume: true, audioTracks: true, subtitles: true, + qualityLevels: false, playbackSpeed: true, aspectRatio: true, recording: true, @@ -220,6 +221,7 @@ describe('EmbeddedMpvControlsAdapter', () => { volume: true, audioTracks: true, subtitles: false, + qualityLevels: false, playbackSpeed: false, aspectRatio: false, recording: false, diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts index cfed72188..742d19e77 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts @@ -176,6 +176,9 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { }) ), subtitlesEnabled: (session?.selectedSubtitleTrackId ?? -1) >= 0, + // MPV demuxes one program; no HLS/DASH rendition list is surfaced. + qualityLevels: [], + qualityAutoEnabled: true, playbackSpeed: session?.playbackSpeed ?? 1, speedPresets: DEFAULT_SPEED_PRESETS, aspectRatio: session?.aspectOverride ?? 'no', @@ -207,6 +210,7 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { setVolume: (value) => void this.controller.applyVolume(value), setAudioTrack: (id) => void this.controller.setAudioTrack(id), setSubtitleTrack: (id) => void this.controller.setSubtitleTrack(id), + setQualityLevel: () => undefined, setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed), setAspectRatio: (value) => void this.controller.setAspect(value), toggleRecording: () => void this.toggleRecording(), diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts index 7daf64d08..bae2fd685 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.hls.spec.ts @@ -121,6 +121,80 @@ describe('HtmlVideoPlayerControlsBridge HLS tracks', () => { }); }); +describe('HtmlVideoPlayerControlsBridge HLS quality levels', () => { + it('projects labelled levels and advertises the capability above one level', () => { + const hls = new FakeHls(); + hls.levels = [ + { height: 1080, width: 1920, bitrate: 8_000_000 }, + { height: 720, width: 1280, bitrate: 4_000_000 }, + ]; + const { adapter, bridge } = bindHls(hls); + + expect(adapter.capabilities().qualityLevels).toBe(true); + expect(adapter.state().qualityAutoEnabled).toBe(true); + expect(adapter.state().qualityLevels).toEqual([ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ]); + bridge.destroy(); + }); + + it('hides the capability for a single-rendition manifest', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 720, bitrate: 2_000_000 }]; + const { adapter, bridge } = bindHls(hls); + + expect(adapter.capabilities().qualityLevels).toBe(false); + bridge.destroy(); + }); + + it('switches through nextLevel and marks the manual selection', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 1080 }, { height: 720 }]; + const { adapter, bridge } = bindHls(hls); + hls.assignments.length = 0; + + adapter.commands.setQualityLevel(1); + expect(hls.assignments).toEqual(['nextLevel:1']); + expect(adapter.state().qualityAutoEnabled).toBe(false); + expect(adapter.state().qualityLevels[1].selected).toBe(true); + expect(adapter.state().qualityLevels[0].selected).toBe(false); + bridge.destroy(); + }); + + it('returns to auto with the sentinel id', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 1080 }, { height: 720 }]; + const { adapter, bridge } = bindHls(hls); + adapter.commands.setQualityLevel(0); + hls.assignments.length = 0; + + adapter.commands.setQualityLevel(-1); + expect(hls.assignments).toEqual(['nextLevel:-1']); + expect(adapter.state().qualityAutoEnabled).toBe(true); + expect( + adapter.state().qualityLevels.some((level) => level.selected) + ).toBe(false); + bridge.destroy(); + }); + + it('ignores invalid and out-of-range level ids', () => { + const hls = new FakeHls(); + hls.levels = [{ height: 1080 }, { height: 720 }]; + const { adapter, bridge } = bindHls(hls); + hls.assignments.length = 0; + + adapter.commands.setQualityLevel(-2); + adapter.commands.setQualityLevel(0.5); + adapter.commands.setQualityLevel(2); + adapter.commands.setQualityLevel(NaN); + + expect(hls.assignments).toEqual([]); + expect(adapter.state().qualityAutoEnabled).toBe(true); + bridge.destroy(); + }); +}); + describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => { const refreshEvents = [ Hls.Events.AUDIO_TRACKS_UPDATED, @@ -130,6 +204,9 @@ describe('HtmlVideoPlayerControlsBridge HLS listener lifecycle', () => { Hls.Events.SUBTITLE_TRACKS_CLEARED, Hls.Events.SUBTITLE_TRACK_SWITCH, Hls.Events.MANIFEST_LOADING, + Hls.Events.MANIFEST_PARSED, + Hls.Events.LEVELS_UPDATED, + Hls.Events.LEVEL_SWITCHED, ]; it('refreshes from every relevant HLS event with one callback reference', () => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts index 0de7e84a9..e161c9b46 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player-controls.spec-fixtures.ts @@ -17,11 +17,18 @@ interface FakeHlsTrack { lang?: string; } +interface FakeHlsLevel { + height?: number; + width?: number; + bitrate?: number; +} + type FakeHlsListener = (...args: unknown[]) => void; export class FakeHls { audioTracks: FakeHlsTrack[] = []; subtitleTracks: FakeHlsTrack[] = []; + levels: FakeHlsLevel[] = []; readonly assignments: string[] = []; subtitleTrackSwitchEvents = 0; private readonly listeners = new Map>(); @@ -29,6 +36,7 @@ export class FakeHls { private selectedAudioTrack = -1; private selectedSubtitleTrack = -1; private displaySubtitles = false; + private manualQualityLevel = -1; readonly on = jest.fn((event: string, listener: FakeHlsListener): void => { const eventListeners = @@ -66,6 +74,24 @@ export class FakeHls { } } + // Mirrors hls.js: assigning nextLevel records the manual level; -1 = auto. + get nextLevel(): number { + return this.manualQualityLevel; + } + + set nextLevel(value: number) { + this.assignments.push(`nextLevel:${value}`); + this.manualQualityLevel = value; + } + + get manualLevel(): number { + return this.manualQualityLevel; + } + + get autoLevelEnabled(): boolean { + return this.manualQualityLevel === -1; + } + get subtitleDisplay(): boolean { return this.displaySubtitles; } 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 a0ad2352f..ab475a296 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 @@ -303,7 +303,7 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { lifecycle .slice(0, destroyIndex) .filter((event) => event.startsWith('off:')) - ).toHaveLength(7); + ).toHaveLength(10); const internals = readHtmlPlayerInternals(component); expect(setSource).toHaveBeenCalledTimes(1); expect(setSource).toHaveBeenCalledWith({ kind: 'native' }); @@ -312,7 +312,7 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => { expect(internals.mpegtsPlayer).toBeNull(); expect(hlsInstances).toHaveLength(1); expect(mpegTsInstances).toHaveLength(0); - expect(hls.off).toHaveBeenCalledTimes(7); + expect(hls.off).toHaveBeenCalledTimes(10); expect(adapter.state().audioTracks).toEqual([]); expect(adapter.state().subtitleTracks).toEqual([]); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts index 791b81a87..fa90cb99e 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts @@ -18,6 +18,7 @@ describe('ControlsMenuSelection', () => { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), toggleRecording: jest.fn(), @@ -62,6 +63,15 @@ describe('ControlsMenuSelection', () => { expect(visibility.scheduleHide).toHaveBeenCalled(); }); + it('selects a quality level and closes the quality menu', () => { + menus.open('quality'); + selection.qualityLevel(-1); + + expect(commands.setQualityLevel).toHaveBeenCalledWith(-1); + expect(menus.qualityOpen()).toBe(false); + expect(visibility.scheduleHide).toHaveBeenCalled(); + }); + it('applies a playback speed and closes the speed menu', () => { menus.open('speed'); selection.speed(1.5); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts index b676207ff..7e2938d3c 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts @@ -2,7 +2,7 @@ import type { ControlsMenuState } from './controls-menu-state'; import type { ControlsVisibility } from './controls-visibility'; import type { PlayerControlsCommands } from './player-controls.model'; -type MenuKey = 'audio' | 'subtitle' | 'speed' | 'aspect'; +type MenuKey = 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect'; export interface MenuSelectionDeps { commands: () => PlayerControlsCommands; @@ -32,6 +32,10 @@ export class ControlsMenuSelection { this.apply('subtitle', (c) => c.setSubtitleTrack(trackId)); } + qualityLevel(levelId: number): void { + this.apply('quality', (c) => c.setQualityLevel(levelId)); + } + speed(value: number): void { this.apply('speed', (c) => c.setPlaybackSpeed(value)); } diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts index dee25dfa2..59de54bd3 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts @@ -28,7 +28,14 @@ describe('ControlsMenuState', () => { expect(menus.anyOpen()).toBe(false); }); - it.each(['volume', 'audio', 'subtitle', 'speed', 'aspect'] as const)( + it.each([ + 'volume', + 'audio', + 'subtitle', + 'quality', + 'speed', + 'aspect', + ] as const)( 'closes an open %s menu when it becomes unavailable', (menu) => { const menus = new ControlsMenuState(); @@ -38,6 +45,7 @@ describe('ControlsMenuState', () => { volume: menu !== 'volume', audio: menu !== 'audio', subtitle: menu !== 'subtitle', + quality: menu !== 'quality', speed: menu !== 'speed', aspect: menu !== 'aspect', }); @@ -56,6 +64,7 @@ describe('ControlsMenuState', () => { volume: true, audio: true, subtitle: true, + quality: true, speed: true, aspect: true, }) @@ -73,6 +82,7 @@ describe('ControlsMenuState', () => { volume: false, audio: false, subtitle: false, + quality: false, speed: false, aspect: false, }) @@ -84,6 +94,7 @@ describe('ControlsMenuState', () => { ['volume', { volume: false }, {}], ['audio', {}, { audioTracks: [] }], ['subtitle', {}, { subtitleTracks: [] }], + ['quality', {}, { qualityLevels: [] }], ['speed', { playbackSpeed: false }, {}], ['aspect', { aspectRatio: false }, {}], ] as const)( @@ -99,6 +110,7 @@ describe('ControlsMenuState', () => { volume: true, audioTracks: true, subtitles: true, + qualityLevels: true, playbackSpeed: true, aspectRatio: true, ...capabilityOverrides, @@ -112,6 +124,10 @@ describe('ControlsMenuState', () => { subtitleTracks: [ { id: 1, label: 'English', selected: true }, ], + qualityLevels: [ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ], ...stateOverrides, } ); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts index 835ae1b15..98a0fd765 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts @@ -8,6 +8,7 @@ const CONTROL_MENUS = [ 'volume', 'audio', 'subtitle', + 'quality', 'speed', 'aspect', ] as const; @@ -30,6 +31,10 @@ function getControlsMenuAvailability( showControls && capabilities.subtitles && state.subtitleTracks.length > 0, + quality: + showControls && + capabilities.qualityLevels && + state.qualityLevels.length > 1, speed: showControls && capabilities.playbackSpeed, aspect: showControls && capabilities.aspectRatio, }; @@ -43,6 +48,7 @@ export class ControlsMenuState { readonly volumeOpen = signal(false); readonly audioOpen = signal(false); readonly subtitleOpen = signal(false); + readonly qualityOpen = signal(false); readonly speedOpen = signal(false); readonly aspectOpen = signal(false); @@ -51,6 +57,7 @@ export class ControlsMenuState { this.volumeOpen() || this.audioOpen() || this.subtitleOpen() || + this.qualityOpen() || this.speedOpen() || this.aspectOpen() ); @@ -78,6 +85,7 @@ export class ControlsMenuState { this.volumeOpen.set(false); this.audioOpen.set(false); this.subtitleOpen.set(false); + this.qualityOpen.set(false); this.speedOpen.set(false); this.aspectOpen.set(false); } @@ -112,6 +120,8 @@ export class ControlsMenuState { return this.audioOpen; case 'subtitle': return this.subtitleOpen; + case 'quality': + return this.qualityOpen; case 'speed': return this.speedOpen; case 'aspect': diff --git a/libs/ui/playback/src/lib/player-controls/controls-view-model.ts b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts index c4dcf3ff5..8ff1525c6 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-view-model.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts @@ -53,6 +53,9 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) { const hasSubtitleTracks = computed( () => capabilities().subtitles && state().subtitleTracks.length > 0 ); + const hasQualityLevels = computed( + () => capabilities().qualityLevels && state().qualityLevels.length > 1 + ); const canRecord = computed( () => capabilities().recording && @@ -103,6 +106,7 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) { canTogglePlay, hasAudioTracks, hasSubtitleTracks, + hasQualityLevels, canRecord, isRecording, recordingStatusText, diff --git a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts index 53aaf4381..126f04b86 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.spec.ts @@ -11,4 +11,12 @@ describe('player-controls defaults', () => { canPictureInPicture: false, }); }); + + it('defaults quality selection to unsupported with auto enabled', () => { + expect(DEFAULT_PLAYER_CAPABILITIES.qualityLevels).toBe(false); + expect(createEmptyControlsState()).toMatchObject({ + qualityLevels: [], + qualityAutoEnabled: true, + }); + }); }); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts index ed003689a..d32c7871a 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts @@ -9,6 +9,7 @@ export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = { volume: false, audioTracks: false, subtitles: false, + qualityLevels: false, playbackSpeed: false, aspectRatio: false, recording: false, @@ -47,6 +48,8 @@ export function createEmptyControlsState(): PlayerControlsState { audioTracks: [], subtitleTracks: [], subtitlesEnabled: false, + qualityLevels: [], + qualityAutoEnabled: true, playbackSpeed: 1, speedPresets: DEFAULT_SPEED_PRESETS, aspectRatio: 'no', diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts index c71123976..587908a71 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -30,6 +30,7 @@ function createFakeController(): FakeController { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), toggleRecording: jest.fn(), @@ -240,6 +241,63 @@ describe('PlayerControlsComponent capability contract', () => { expect(component.anyMenuOpen()).toBe(false); }); + it('renders the quality menu with Auto and routes level selection', () => { + const fake = createFakeController(); + const fixture = createControls(fake); + const component = fixture.componentInstance; + + fake.capabilities.set({ + ...DEFAULT_PLAYER_CAPABILITIES, + qualityLevels: true, + }); + fake.state.set({ + ...createEmptyControlsState(), + qualityLevels: [ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ], + qualityAutoEnabled: true, + }); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + const trigger = root.querySelector( + '[data-test-id="player-controls-quality"]' + ); + expect(trigger).not.toBeNull(); + trigger?.click(); + fixture.detectChanges(); + expect(component.menus.qualityOpen()).toBe(true); + + const entries = Array.from( + root.querySelectorAll( + '.player-controls__track-popover .player-controls__track' + ) + ); + expect(entries.map((entry) => entry.textContent?.trim())).toEqual([ + 'EMBEDDED_MPV.PLAYER.QUALITY_AUTOcheck', + '1080p', + '720p', + ]); + expect(entries[0].getAttribute('aria-checked')).toBe('true'); + + entries[2].click(); + fixture.detectChanges(); + expect(fake.commands.setQualityLevel).toHaveBeenCalledWith(1); + expect(component.menus.qualityOpen()).toBe(false); + + component.toggleMenu('quality'); + fake.state.set({ + ...createEmptyControlsState(), + qualityLevels: [{ id: 0, label: '1080p', selected: false }], + }); + fixture.detectChanges(); + expect(component.anyMenuOpen()).toBe(false); + expect( + root.querySelector('[data-test-id="player-controls-quality"]') + ).toBeNull(); + }); + it('hides the scrub slider without hiding live and recording status', () => { const fake = createFakeController(); const fixture = createControls(fake); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 585db0b26..3d356f1ad 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -410,6 +410,83 @@ } + @if (hasQualityLevels()) { +
+ + @if (menus.qualityOpen()) { + + } +
+ } + @if (capabilities().playbackSpeed) {