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) {