mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
61 files changed
+1582
-10
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 adaptLine truncated
|
||||
- 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 coLine truncated
|
||||
- Shared web picture-in-picture stays inside that default-off rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "مسارات الصوت",
|
||||
"SUBTITLES": "الترجمات",
|
||||
"SUBTITLES_OFF": "إيقاف",
|
||||
"QUALITY": "الجودة",
|
||||
"QUALITY_AUTO": "تلقائي",
|
||||
"TRACK_DEFAULT": "افتراضي",
|
||||
"AUDIO_TRACK_FALLBACK": "صوت {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "مسارات الصوت",
|
||||
"SUBTITLES": "الترجمة",
|
||||
"SUBTITLES_OFF": "معطلة",
|
||||
"QUALITY": "الجودة",
|
||||
"QUALITY_AUTO": "تلقائي",
|
||||
"TRACK_DEFAULT": "افتراضي",
|
||||
"AUDIO_TRACK_FALLBACK": "صوت {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "ترجمة {{index}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "Аўдыядарожкі",
|
||||
"SUBTITLES": "Субтытры",
|
||||
"SUBTITLES_OFF": "Выкл.",
|
||||
"QUALITY": "Якасць",
|
||||
"QUALITY_AUTO": "Аўта",
|
||||
"TRACK_DEFAULT": "Па змаўчанні",
|
||||
"AUDIO_TRACK_FALLBACK": "Аўдыя {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтытры {{index}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "Κομμάτια ήχου",
|
||||
"SUBTITLES": "Υπότιτλοι",
|
||||
"SUBTITLES_OFF": "Ανενεργοί",
|
||||
"QUALITY": "Ποιότητα",
|
||||
"QUALITY_AUTO": "Αυτόματη",
|
||||
"TRACK_DEFAULT": "Προεπιλογή",
|
||||
"AUDIO_TRACK_FALLBACK": "Ήχος {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "Υπότιτλος {{index}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "音声トラック",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "オフ",
|
||||
"QUALITY": "画質",
|
||||
"QUALITY_AUTO": "自動",
|
||||
"TRACK_DEFAULT": "デフォルト",
|
||||
"AUDIO_TRACK_FALLBACK": "音声 {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "오디오 트랙",
|
||||
"SUBTITLES": "자막",
|
||||
"SUBTITLES_OFF": "끄기",
|
||||
"QUALITY": "화질",
|
||||
"QUALITY_AUTO": "자동",
|
||||
"TRACK_DEFAULT": "기본",
|
||||
"AUDIO_TRACK_FALLBACK": "오디오 {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "자막 {{index}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "Аудиодорожки",
|
||||
"SUBTITLES": "Субтитры",
|
||||
"SUBTITLES_OFF": "Выкл.",
|
||||
"QUALITY": "Качество",
|
||||
"QUALITY_AUTO": "Авто",
|
||||
"TRACK_DEFAULT": "По умолчанию",
|
||||
"AUDIO_TRACK_FALLBACK": "Аудио {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "Субтитры {{index}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "音轨",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "关闭",
|
||||
"QUALITY": "画质",
|
||||
"QUALITY_AUTO": "自动",
|
||||
"TRACK_DEFAULT": "默认",
|
||||
"AUDIO_TRACK_FALLBACK": "音轨 {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
|
||||
@@ -534,6 +534,8 @@
|
||||
"AUDIO_TRACKS": "音軌",
|
||||
"SUBTITLES": "字幕",
|
||||
"SUBTITLES_OFF": "關閉",
|
||||
"QUALITY": "畫質",
|
||||
"QUALITY_AUTO": "自動",
|
||||
"TRACK_DEFAULT": "預設",
|
||||
"AUDIO_TRACK_FALLBACK": "音軌 {{index}}",
|
||||
"SUBTITLE_TRACK_FALLBACK": "字幕 {{index}}",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+26
@@ -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<string, Set<FakeHlsListener>>();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'.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);
|
||||
|
||||
@@ -410,6 +410,83 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (hasQualityLevels()) {
|
||||
<div class="player-controls__popover-anchor">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-controls__button"
|
||||
data-test-id="player-controls-quality"
|
||||
(click)="toggleMenu('quality')"
|
||||
[attr.aria-expanded]="menus.qualityOpen()"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.QUALITY' | translate
|
||||
"
|
||||
[matTooltip]="'EMBEDDED_MPV.PLAYER.QUALITY' | translate"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>high_quality</mat-icon>
|
||||
</button>
|
||||
@if (menus.qualityOpen()) {
|
||||
<div
|
||||
class="player-controls__panel player-controls__track-popover"
|
||||
role="menu"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.QUALITY' | translate
|
||||
"
|
||||
>
|
||||
<div class="player-controls__menu-title">
|
||||
{{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }}
|
||||
</div>
|
||||
<div class="player-controls__track-list">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="
|
||||
state().qualityAutoEnabled
|
||||
"
|
||||
class="player-controls__track"
|
||||
[class.player-controls__track--selected]="
|
||||
state().qualityAutoEnabled
|
||||
"
|
||||
(click)="menuSelection.qualityLevel(-1)"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.QUALITY_AUTO'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (state().qualityAutoEnabled) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@for (
|
||||
level of state().qualityLevels;
|
||||
track level.id
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="level.selected"
|
||||
class="player-controls__track"
|
||||
[class.player-controls__track--selected]="
|
||||
level.selected
|
||||
"
|
||||
(click)="
|
||||
menuSelection.qualityLevel(level.id)
|
||||
"
|
||||
>
|
||||
<span>{{ level.label }}</span>
|
||||
@if (level.selected) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (capabilities().playbackSpeed) {
|
||||
<div class="player-controls__popover-anchor">
|
||||
<button
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
+1
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -25,6 +25,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -27,6 +27,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -24,6 +24,7 @@ function createFakeController() {
|
||||
setVolume: jest.fn(),
|
||||
setAudioTrack: jest.fn(),
|
||||
setSubtitleTrack: jest.fn(),
|
||||
setQualityLevel: jest.fn(),
|
||||
setPlaybackSpeed: jest.fn(),
|
||||
setAspectRatio: jest.fn(),
|
||||
toggleRecording: jest.fn(),
|
||||
|
||||
@@ -127,6 +127,7 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
readonly canTogglePlay = this.vm.canTogglePlay;
|
||||
readonly hasAudioTracks = this.vm.hasAudioTracks;
|
||||
readonly hasSubtitleTracks = this.vm.hasSubtitleTracks;
|
||||
readonly hasQualityLevels = this.vm.hasQualityLevels;
|
||||
readonly canRecord = this.vm.canRecord;
|
||||
readonly isRecording = this.vm.isRecording;
|
||||
readonly recordingStatusText = this.vm.recordingStatusText;
|
||||
@@ -300,7 +301,9 @@ export class PlayerControlsComponent implements OnDestroy {
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
toggleMenu(menu: 'audio' | 'subtitle' | 'speed' | 'aspect'): void {
|
||||
toggleMenu(
|
||||
menu: 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect'
|
||||
): void {
|
||||
this.menus.toggle(menu);
|
||||
this.reveal();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface PlayerControlsCapabilities {
|
||||
volume: boolean;
|
||||
audioTracks: boolean;
|
||||
subtitles: boolean;
|
||||
/** Manifest-driven: true only when the source exposes >1 video rendition. */
|
||||
qualityLevels: boolean;
|
||||
playbackSpeed: boolean;
|
||||
aspectRatio: boolean;
|
||||
recording: boolean;
|
||||
@@ -28,6 +30,12 @@ export interface PlayerTrack {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `setQualityLevel(AUTO_QUALITY_LEVEL_ID)` re-enables the engine's adaptive
|
||||
* (ABR) selection, mirroring how `setSubtitleTrack(-1)` means "off".
|
||||
*/
|
||||
export const AUTO_QUALITY_LEVEL_ID = -1;
|
||||
|
||||
/**
|
||||
* Content title lines rendered over the player while the controls are visible
|
||||
* in fullscreen. Hosts pass display-ready strings; the controls never derive
|
||||
@@ -69,6 +77,13 @@ export interface PlayerControlsState {
|
||||
subtitleTracks: PlayerTrack[];
|
||||
/** True when a subtitle track is active (selected id !== null/off). */
|
||||
subtitlesEnabled: boolean;
|
||||
/**
|
||||
* Per-level quality options ("1080p", …). A level reports `selected` only
|
||||
* while a manual selection is active; with ABR on, none is selected.
|
||||
*/
|
||||
qualityLevels: PlayerTrack[];
|
||||
/** True while the engine picks the quality level itself (ABR/auto). */
|
||||
qualityAutoEnabled: boolean;
|
||||
playbackSpeed: number;
|
||||
speedPresets: ReadonlyArray<PlayerPreset<number>>;
|
||||
aspectRatio: string;
|
||||
@@ -88,6 +103,7 @@ export interface PlayerControlsCommands {
|
||||
setVolume(value: number): void; // 0..1
|
||||
setAudioTrack(id: number): void;
|
||||
setSubtitleTrack(id: number): void; // -1 = off
|
||||
setQualityLevel(id: number): void; // AUTO_QUALITY_LEVEL_ID (-1) = auto
|
||||
setPlaybackSpeed(speed: number): void;
|
||||
setAspectRatio(value: string): void;
|
||||
toggleRecording(): void;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AUTO_QUALITY_LEVEL_ID, type PlayerTrack } from './player-controls.model';
|
||||
import { WebVideoControlsAdapter } from './web-video-controls.adapter';
|
||||
|
||||
function createVideo(): HTMLVideoElement {
|
||||
const video = document.createElement('video');
|
||||
Object.defineProperties(video, {
|
||||
duration: { configurable: true, value: 120 },
|
||||
readyState: { configurable: true, value: 4 },
|
||||
networkState: { configurable: true, value: 1 },
|
||||
paused: { configurable: true, value: true },
|
||||
seekable: { configurable: true, value: { length: 1 } },
|
||||
});
|
||||
return video;
|
||||
}
|
||||
|
||||
const TWO_LEVELS: PlayerTrack[] = [
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
];
|
||||
|
||||
describe('WebVideoControlsAdapter quality levels', () => {
|
||||
let adapter: WebVideoControlsAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new WebVideoControlsAdapter();
|
||||
});
|
||||
|
||||
afterEach(() => adapter.detach());
|
||||
|
||||
it('advertises the capability only for a multi-rendition source', () => {
|
||||
let levels: PlayerTrack[] = TWO_LEVELS;
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => levels,
|
||||
setQualityLevel: () => undefined,
|
||||
});
|
||||
expect(adapter.capabilities().qualityLevels).toBe(true);
|
||||
|
||||
levels = [TWO_LEVELS[0]];
|
||||
adapter.refresh();
|
||||
expect(adapter.capabilities().qualityLevels).toBe(false);
|
||||
});
|
||||
|
||||
it('advertises no capability without a setter or without accessors', () => {
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => TWO_LEVELS,
|
||||
});
|
||||
expect(adapter.capabilities().qualityLevels).toBe(false);
|
||||
expect(adapter.state().qualityLevels).toEqual(TWO_LEVELS);
|
||||
|
||||
adapter.attach(createVideo(), {});
|
||||
expect(adapter.capabilities().qualityLevels).toBe(false);
|
||||
expect(adapter.state().qualityLevels).toEqual([]);
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('projects the auto flag and level list into state', () => {
|
||||
let auto = true;
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => TWO_LEVELS,
|
||||
setQualityLevel: () => undefined,
|
||||
isAutoQualityEnabled: () => auto,
|
||||
});
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(true);
|
||||
|
||||
auto = false;
|
||||
adapter.refresh();
|
||||
expect(adapter.state().qualityAutoEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('routes the command through the setter and refreshes', () => {
|
||||
let levels = TWO_LEVELS;
|
||||
const setQualityLevel = jest.fn((id: number) => {
|
||||
levels = levels.map((level) => ({
|
||||
...level,
|
||||
selected: level.id === id,
|
||||
}));
|
||||
});
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => levels,
|
||||
setQualityLevel,
|
||||
});
|
||||
|
||||
adapter.commands.setQualityLevel(1);
|
||||
expect(setQualityLevel).toHaveBeenCalledWith(1);
|
||||
expect(adapter.state().qualityLevels[1].selected).toBe(true);
|
||||
|
||||
adapter.commands.setQualityLevel(AUTO_QUALITY_LEVEL_ID);
|
||||
expect(setQualityLevel).toHaveBeenCalledWith(AUTO_QUALITY_LEVEL_ID);
|
||||
});
|
||||
|
||||
it('contains a synchronous setter exception', () => {
|
||||
adapter.attach(createVideo(), {
|
||||
getQualityLevels: () => TWO_LEVELS,
|
||||
setQualityLevel: () => {
|
||||
throw new Error('engine changing source');
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => adapter.commands.setQualityLevel(0)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,9 @@ export interface WebVideoControlsOptions extends WebVideoMetadataOptions {
|
||||
setAudioTrack?: (id: number) => void | Promise<void>;
|
||||
getSubtitleTracks?: () => PlayerTrack[];
|
||||
setSubtitleTrack?: (id: number) => void | Promise<void>;
|
||||
getQualityLevels?: () => PlayerTrack[];
|
||||
setQualityLevel?: (id: number) => void | Promise<void>;
|
||||
isAutoQualityEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
interface WebVideoControlsContext {
|
||||
@@ -90,6 +93,10 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
const hasSubtitles =
|
||||
typeof this.opts.setSubtitleTrack === 'function' &&
|
||||
(this.opts.getSubtitleTracks?.().length ?? 0) > 0;
|
||||
// Manifest-driven: a single-rendition source advertises no selector.
|
||||
const hasQualityLevels =
|
||||
typeof this.opts.setQualityLevel === 'function' &&
|
||||
(this.opts.getQualityLevels?.().length ?? 0) > 1;
|
||||
const isLive = readVideoIsLive(this.video, this.opts);
|
||||
const pictureInPicture = this.pictureInPicture.snapshot();
|
||||
return {
|
||||
@@ -100,6 +107,7 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
fullscreen: true,
|
||||
audioTracks: hasAudioTracks,
|
||||
subtitles: hasSubtitles,
|
||||
qualityLevels: hasQualityLevels,
|
||||
aspectRatio: false,
|
||||
recording: false,
|
||||
pictureInPicture: pictureInPicture.supported,
|
||||
@@ -124,6 +132,7 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
|
||||
const audioTracks = this.opts.getAudioTracks?.() ?? [];
|
||||
const subtitleTracks = this.opts.getSubtitleTracks?.() ?? [];
|
||||
const qualityLevels = this.opts.getQualityLevels?.() ?? [];
|
||||
const pictureInPicture = this.pictureInPicture.snapshot();
|
||||
|
||||
return {
|
||||
@@ -138,6 +147,8 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
audioTracks,
|
||||
subtitleTracks,
|
||||
subtitlesEnabled: subtitleTracks.some((track) => track.selected),
|
||||
qualityLevels,
|
||||
qualityAutoEnabled: this.opts.isAutoQualityEnabled?.() ?? true,
|
||||
playbackSpeed: video?.playbackRate ?? 1,
|
||||
speedPresets: DEFAULT_SPEED_PRESETS,
|
||||
aspectRatio: 'no',
|
||||
@@ -172,6 +183,10 @@ export class WebVideoControlsAdapter implements PlayerController {
|
||||
applyTrackSelection(this.opts.setSubtitleTrack, id, () =>
|
||||
this.refresh()
|
||||
),
|
||||
setQualityLevel: (id) =>
|
||||
applyTrackSelection(this.opts.setQualityLevel, id, () =>
|
||||
this.refresh()
|
||||
),
|
||||
setPlaybackSpeed: (speed) => applyVideoSpeed(this.video, speed),
|
||||
setAspectRatio: () => undefined,
|
||||
toggleRecording: () => undefined,
|
||||
|
||||
@@ -22,6 +22,22 @@ export interface ShakaTextTrackLike {
|
||||
kind: string | null;
|
||||
}
|
||||
|
||||
export interface ShakaVariantTrackLike {
|
||||
id: number;
|
||||
active: boolean;
|
||||
language: string;
|
||||
label: string | null;
|
||||
height: number | null;
|
||||
width: number | null;
|
||||
bandwidth: number;
|
||||
/**
|
||||
* Identifies the exact audio stream inside the variant. Two same-language
|
||||
* audio tracks (main vs. commentary, stereo vs. 5.1) have different ids,
|
||||
* so quality filtering pins to it rather than to `language`.
|
||||
*/
|
||||
audioId?: number | null;
|
||||
}
|
||||
|
||||
export type { ShakaErrorLike } from '@iptvnator/playback/util';
|
||||
|
||||
export interface ShakaPlayerLike {
|
||||
@@ -33,6 +49,16 @@ export interface ShakaPlayerLike {
|
||||
removeEventListener(type: string, listener: (event: Event) => void): void;
|
||||
getAudioTracks(): ShakaAudioTrackLike[];
|
||||
selectAudioTrack(track: ShakaAudioTrackLike): void;
|
||||
getVariantTracks(): ShakaVariantTrackLike[];
|
||||
/**
|
||||
* Manual quality selection. Callers must disable ABR first via
|
||||
* `configure({abr: {enabled: false}})` or the ABR manager immediately
|
||||
* overrides the choice. `clearBuffer` keeps the switch immediate.
|
||||
*/
|
||||
selectVariantTrack(
|
||||
track: ShakaVariantTrackLike,
|
||||
clearBuffer?: boolean
|
||||
): void;
|
||||
getTextTracks(): ShakaTextTrackLike[];
|
||||
/**
|
||||
* Shaka 5 visibility model: selecting a track shows it, `null` unloads
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ShakaModuleLoader,
|
||||
ShakaPlayerLike,
|
||||
ShakaTextTrackLike,
|
||||
ShakaVariantTrackLike,
|
||||
} from './shaka-module.types';
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,11 @@ export class FakeShakaPlayer implements ShakaPlayerLike {
|
||||
destroyCount = 0;
|
||||
audioTracks: ShakaAudioTrackLike[] = [];
|
||||
textTracks: ShakaTextTrackLike[] = [];
|
||||
variantTracks: ShakaVariantTrackLike[] = [];
|
||||
readonly selectVariantTrackCalls: {
|
||||
track: ShakaVariantTrackLike;
|
||||
clearBuffer: boolean | undefined;
|
||||
}[] = [];
|
||||
/** When true, the next `load()` stays pending until `destroy()`. */
|
||||
stallNextLoad = false;
|
||||
loadResult: Promise<unknown> = Promise.resolve();
|
||||
@@ -88,6 +94,20 @@ export class FakeShakaPlayer implements ShakaPlayerLike {
|
||||
this.selectedAudioTracks.push(track);
|
||||
}
|
||||
|
||||
getVariantTracks(): ShakaVariantTrackLike[] {
|
||||
return this.variantTracks;
|
||||
}
|
||||
|
||||
selectVariantTrack(
|
||||
track: ShakaVariantTrackLike,
|
||||
clearBuffer?: boolean
|
||||
): void {
|
||||
this.selectVariantTrackCalls.push({ track, clearBuffer });
|
||||
for (const candidate of this.variantTracks) {
|
||||
candidate.active = candidate === track;
|
||||
}
|
||||
}
|
||||
|
||||
getTextTracks(): ShakaTextTrackLike[] {
|
||||
return this.textTracks;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { WebVideoControlsOptions } from '../player-controls/web-video-contr
|
||||
import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter';
|
||||
import { VjsAudioTracks } from './vjs-audio-tracks';
|
||||
import type { VideoJsPlayer } from './vjs-player.types';
|
||||
import { VjsQualityLevels } from './vjs-quality-levels';
|
||||
import { VjsTextTracks } from './vjs-text-tracks';
|
||||
|
||||
export interface VjsPlayerControlsBridgeConfig {
|
||||
@@ -14,6 +15,7 @@ export interface VjsPlayerControlsBridgeConfig {
|
||||
export class VjsPlayerControlsBridge {
|
||||
private readonly audioTracks: VjsAudioTracks;
|
||||
private readonly textTracks: VjsTextTracks;
|
||||
private readonly qualityLevels: VjsQualityLevels;
|
||||
private video: HTMLVideoElement | null = null;
|
||||
private sourceActive = false;
|
||||
private destroyed = false;
|
||||
@@ -25,6 +27,9 @@ export class VjsPlayerControlsBridge {
|
||||
setAudioTrack: (id) => this.audioTracks.setAudioTrack(id),
|
||||
getSubtitleTracks: () => this.textTracks.getSubtitleTracks(),
|
||||
setSubtitleTrack: (id) => this.textTracks.setSubtitleTrack(id),
|
||||
getQualityLevels: () => this.qualityLevels.getQualityLevels(),
|
||||
setQualityLevel: (id) => this.qualityLevels.setQualityLevel(id),
|
||||
isAutoQualityEnabled: () => this.qualityLevels.isAutoQualityEnabled(),
|
||||
};
|
||||
|
||||
constructor(private readonly config: VjsPlayerControlsBridgeConfig) {
|
||||
@@ -38,6 +43,10 @@ export class VjsPlayerControlsBridge {
|
||||
showCaptions: config.showCaptions,
|
||||
refresh,
|
||||
});
|
||||
this.qualityLevels = new VjsQualityLevels({
|
||||
player: config.player,
|
||||
refresh,
|
||||
});
|
||||
}
|
||||
|
||||
attach(video: HTMLVideoElement): void {
|
||||
@@ -63,6 +72,7 @@ export class VjsPlayerControlsBridge {
|
||||
|
||||
this.audioTracks.resetSource();
|
||||
this.textTracks.resetSource();
|
||||
this.qualityLevels.resetSource();
|
||||
this.sourceActive = true;
|
||||
this.bindTrackLists();
|
||||
this.config.adapter.refresh();
|
||||
@@ -87,6 +97,7 @@ export class VjsPlayerControlsBridge {
|
||||
|
||||
this.audioTracks.clear();
|
||||
this.textTracks.clear();
|
||||
this.qualityLevels.clear();
|
||||
this.sourceActive = false;
|
||||
this.config.adapter.refresh();
|
||||
}
|
||||
@@ -98,6 +109,7 @@ export class VjsPlayerControlsBridge {
|
||||
|
||||
this.audioTracks.clear();
|
||||
this.textTracks.clear();
|
||||
this.qualityLevels.clear();
|
||||
if (this.video) {
|
||||
this.config.adapter.detach();
|
||||
}
|
||||
@@ -109,6 +121,7 @@ export class VjsPlayerControlsBridge {
|
||||
private bindTrackLists(): void {
|
||||
this.audioTracks.bind();
|
||||
this.textTracks.bind();
|
||||
this.qualityLevels.bind();
|
||||
}
|
||||
|
||||
private readDuration(): number {
|
||||
|
||||
@@ -52,6 +52,21 @@ export type VideoJsTrackList<TTrack> = {
|
||||
export type VideoJsAudioTrackList = VideoJsTrackList<VideoJsAudioTrack>;
|
||||
export type VideoJsTextTrackList = VideoJsTrackList<VideoJsTextTrack>;
|
||||
|
||||
/** One rendition from videojs-contrib-quality-levels. */
|
||||
export type VideoJsQualityLevel = {
|
||||
id?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bitrate?: number;
|
||||
/** Property accessor: assigning false excludes the level from ABR. */
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type VideoJsQualityLevelList =
|
||||
VideoJsTrackList<VideoJsQualityLevel> & {
|
||||
selectedIndex?: number;
|
||||
};
|
||||
|
||||
export type VideoJsTech = {
|
||||
el?: () => Element | null;
|
||||
vhs?: {
|
||||
@@ -79,6 +94,8 @@ export type VideoJsPlayer = Omit<
|
||||
qualitySelectorHls?: (options?: {
|
||||
displayCurrentQuality?: boolean;
|
||||
}) => void;
|
||||
/** Registered by the videojs-contrib-quality-levels plugin import. */
|
||||
qualityLevels?: () => VideoJsQualityLevelList | null;
|
||||
aspectRatioPanel?: () => void;
|
||||
audioTracks: () => VideoJsAudioTrackList | null;
|
||||
textTracks: () => VideoJsTextTrackList | null;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import type {
|
||||
VideoJsQualityLevel,
|
||||
VideoJsQualityLevelList,
|
||||
} from './vjs-player.types';
|
||||
import { VjsQualityLevels } from './vjs-quality-levels';
|
||||
|
||||
class FakeQualityLevelList {
|
||||
private readonly listeners = new Map<
|
||||
string,
|
||||
Set<EventListenerOrEventListenerObject>
|
||||
>();
|
||||
private levels: VideoJsQualityLevel[] = [];
|
||||
|
||||
readonly addEventListener = jest.fn(
|
||||
(event: string, listener: EventListenerOrEventListenerObject): void => {
|
||||
const eventListeners =
|
||||
this.listeners.get(event) ??
|
||||
new Set<EventListenerOrEventListenerObject>();
|
||||
eventListeners.add(listener);
|
||||
this.listeners.set(event, eventListeners);
|
||||
}
|
||||
);
|
||||
|
||||
readonly removeEventListener = jest.fn(
|
||||
(event: string, listener: EventListenerOrEventListenerObject): void => {
|
||||
this.listeners.get(event)?.delete(listener);
|
||||
}
|
||||
);
|
||||
|
||||
get length(): number {
|
||||
return this.levels.length;
|
||||
}
|
||||
|
||||
replace(levels: VideoJsQualityLevel[]): void {
|
||||
for (let index = 0; index < this.levels.length; index += 1) {
|
||||
Reflect.deleteProperty(this, index);
|
||||
}
|
||||
this.levels = levels;
|
||||
levels.forEach((level, index) => {
|
||||
Object.defineProperty(this, index, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: level,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
emit(eventName: string): void {
|
||||
const event = new Event(eventName);
|
||||
for (const listener of this.listeners.get(eventName) ?? []) {
|
||||
if (typeof listener === 'function') {
|
||||
listener.call(this, event);
|
||||
} else {
|
||||
listener.handleEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asList(): VideoJsQualityLevelList {
|
||||
return this as unknown as VideoJsQualityLevelList;
|
||||
}
|
||||
}
|
||||
|
||||
function createHarness(levels: VideoJsQualityLevel[]) {
|
||||
const levelList = new FakeQualityLevelList();
|
||||
levelList.replace(levels);
|
||||
const refresh = jest.fn();
|
||||
const helper = new VjsQualityLevels({
|
||||
player: { qualityLevels: () => levelList.asList() },
|
||||
refresh,
|
||||
});
|
||||
helper.bind();
|
||||
return { helper, levelList, refresh };
|
||||
}
|
||||
|
||||
function level(
|
||||
facts: Partial<VideoJsQualityLevel> = {}
|
||||
): VideoJsQualityLevel {
|
||||
return { enabled: true, ...facts };
|
||||
}
|
||||
|
||||
describe('VjsQualityLevels', () => {
|
||||
it('projects labelled levels with auto derived from all-enabled', () => {
|
||||
const { helper } = createHarness([
|
||||
level({ height: 1080, bitrate: 8_000_000 }),
|
||||
level({ height: 720, bitrate: 4_000_000 }),
|
||||
]);
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(helper.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('enables exactly one level for a manual selection', () => {
|
||||
const { helper, levelList } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
level({ height: 480 }),
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(1);
|
||||
|
||||
expect([0, 1, 2].map((i) => levelList.asList()[i]?.enabled)).toEqual([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(false);
|
||||
expect(helper.getQualityLevels()[1].selected).toBe(true);
|
||||
});
|
||||
|
||||
it('re-enables every level for the auto sentinel', () => {
|
||||
const { helper, levelList } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
helper.setQualityLevel(0);
|
||||
|
||||
helper.setQualityLevel(-1);
|
||||
|
||||
expect([0, 1].map((i) => levelList.asList()[i]?.enabled)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
helper.getQualityLevels().some((entry) => entry.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores invalid and out-of-range ids', () => {
|
||||
const { helper, levelList } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(2);
|
||||
helper.setQualityLevel(-2);
|
||||
helper.setQualityLevel(0.5);
|
||||
helper.setQualityLevel(NaN);
|
||||
|
||||
expect([0, 1].map((i) => levelList.asList()[i]?.enabled)).toEqual([
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a single-level list as auto even when only it is enabled', () => {
|
||||
const { helper } = createHarness([level({ height: 720 })]);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(helper.getQualityLevels()[0].selected).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mistake VHS error-exclusions for a manual selection', () => {
|
||||
const levels = [
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
level({ height: 480 }),
|
||||
];
|
||||
const { helper } = createHarness(levels);
|
||||
|
||||
// VHS excludes failing renditions by flipping `enabled` off itself;
|
||||
// only one survivor must still read as auto, not as a user pick.
|
||||
levels[0].enabled = false;
|
||||
levels[2].enabled = false;
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
helper.getQualityLevels().some((entry) => entry.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reverts to auto and re-enables survivors when the picked level leaves the list', () => {
|
||||
const survivorA = level({ height: 1080 });
|
||||
const picked = level({ height: 720 });
|
||||
const survivorB = level({ height: 480 });
|
||||
const { helper, levelList } = createHarness([
|
||||
survivorA,
|
||||
picked,
|
||||
survivorB,
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(1);
|
||||
expect([survivorA.enabled, survivorB.enabled]).toEqual([false, false]);
|
||||
|
||||
// The list drops the picked rendition but keeps the (still disabled)
|
||||
// survivors; reverting to auto must give ABR renditions back.
|
||||
levelList.replace([survivorA, survivorB]);
|
||||
levelList.emit('removequalitylevel');
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect([survivorA.enabled, survivorB.enabled]).toEqual([true, true]);
|
||||
expect(
|
||||
helper.getQualityLevels().some((entry) => entry.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('re-enables survivors lazily when no removal event was observed', () => {
|
||||
const survivor = level({ height: 1080 });
|
||||
const picked = level({ height: 720 });
|
||||
const { helper, levelList } = createHarness([survivor, picked]);
|
||||
|
||||
helper.setQualityLevel(1);
|
||||
levelList.replace([survivor]);
|
||||
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
expect(survivor.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('forgets manual intent on resetSource', () => {
|
||||
const { helper } = createHarness([
|
||||
level({ height: 1080 }),
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
|
||||
helper.setQualityLevel(0);
|
||||
expect(helper.isAutoQualityEnabled()).toBe(false);
|
||||
|
||||
helper.resetSource();
|
||||
expect(helper.isAutoQualityEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('refreshes on list events and detaches exact listeners on clear', () => {
|
||||
const { helper, levelList, refresh } = createHarness([
|
||||
level({ height: 720 }),
|
||||
]);
|
||||
|
||||
levelList.emit('addqualitylevel');
|
||||
levelList.emit('removequalitylevel');
|
||||
levelList.emit('change');
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
|
||||
helper.clear();
|
||||
expect(levelList.removeEventListener).toHaveBeenCalledTimes(3);
|
||||
for (const [event, listener] of levelList.addEventListener.mock
|
||||
.calls) {
|
||||
expect(levelList.removeEventListener).toHaveBeenCalledWith(
|
||||
event,
|
||||
listener
|
||||
);
|
||||
}
|
||||
expect(helper.getQualityLevels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives a missing or throwing qualityLevels plugin', () => {
|
||||
const withoutPlugin = new VjsQualityLevels({
|
||||
player: {},
|
||||
refresh: jest.fn(),
|
||||
});
|
||||
withoutPlugin.bind();
|
||||
expect(withoutPlugin.getQualityLevels()).toEqual([]);
|
||||
expect(withoutPlugin.isAutoQualityEnabled()).toBe(true);
|
||||
expect(() => withoutPlugin.setQualityLevel(0)).not.toThrow();
|
||||
|
||||
const throwing = new VjsQualityLevels({
|
||||
player: {
|
||||
qualityLevels: () => {
|
||||
throw new Error('plugin unavailable');
|
||||
},
|
||||
},
|
||||
refresh: jest.fn(),
|
||||
});
|
||||
throwing.bind();
|
||||
expect(throwing.getQualityLevels()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
AUTO_QUALITY_LEVEL_ID,
|
||||
type PlayerTrack,
|
||||
} from '../player-controls/player-controls.model';
|
||||
import { buildQualityLevelLabels } from '../web-video-support/quality-level-labels';
|
||||
import type {
|
||||
VideoJsPlayer,
|
||||
VideoJsQualityLevel,
|
||||
VideoJsQualityLevelList,
|
||||
} from './vjs-player.types';
|
||||
|
||||
const QUALITY_LEVEL_LISTENERS = [
|
||||
'addqualitylevel',
|
||||
'removequalitylevel',
|
||||
'change',
|
||||
] as const;
|
||||
|
||||
export type VjsQualityLevelPlayer = Pick<VideoJsPlayer, 'qualityLevels'>;
|
||||
|
||||
export interface VjsQualityLevelsConfig {
|
||||
player: VjsQualityLevelPlayer;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects the videojs-contrib-quality-levels list onto the shared-controls
|
||||
* quality contract, mirroring {@link VjsAudioTracks}. VHS has no manual-level
|
||||
* setter; 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 inferring the mode from the enabled count would report a manual
|
||||
* selection the user never made. Object identity (not an index) keeps the
|
||||
* intent valid across list mutations, and a picked level that leaves the list
|
||||
* reverts to auto. Ids are list indices for the current snapshot.
|
||||
*/
|
||||
export class VjsQualityLevels {
|
||||
private levelList: VideoJsQualityLevelList | null = null;
|
||||
private manualLevel: VideoJsQualityLevel | null = null;
|
||||
private readonly handleListChange: EventListener = () => {
|
||||
this.readManualLevel(this.listLevels());
|
||||
this.config.refresh();
|
||||
};
|
||||
|
||||
constructor(private readonly config: VjsQualityLevelsConfig) {}
|
||||
|
||||
bind(): void {
|
||||
const levelList = this.readLevelList();
|
||||
if (levelList === this.levelList) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.detachLevelList();
|
||||
this.levelList = levelList;
|
||||
if (typeof levelList?.addEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventName of QUALITY_LEVEL_LISTENERS) {
|
||||
levelList.addEventListener(eventName, this.handleListChange);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.detachLevelList();
|
||||
this.levelList = null;
|
||||
this.manualLevel = null;
|
||||
}
|
||||
|
||||
/** Forgets manual intent when the bridge activates a new source. */
|
||||
resetSource(): void {
|
||||
this.manualLevel = null;
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
const levels = this.listLevels();
|
||||
const labels = buildQualityLevelLabels(levels);
|
||||
const manualLevel = this.readManualLevel(levels);
|
||||
return levels.map((level, index) => ({
|
||||
id: index,
|
||||
label: labels[index],
|
||||
selected: level === manualLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
if (!Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const levels = this.listLevels();
|
||||
if (id === AUTO_QUALITY_LEVEL_ID) {
|
||||
this.manualLevel = null;
|
||||
for (const level of levels) {
|
||||
level.enabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (id < 0 || id >= levels.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.manualLevel = levels[id];
|
||||
levels.forEach((level, index) => {
|
||||
level.enabled = index === id;
|
||||
});
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
return this.readManualLevel(this.listLevels()) === null;
|
||||
}
|
||||
|
||||
private readManualLevel(
|
||||
levels: VideoJsQualityLevel[]
|
||||
): VideoJsQualityLevel | null {
|
||||
if (this.manualLevel && !levels.includes(this.manualLevel)) {
|
||||
// The picked rendition left the list (source change or removal):
|
||||
// there is nothing the intent can hold on to, so revert to auto —
|
||||
// including re-enabling the survivors the manual pick disabled,
|
||||
// or ABR would be left with no selectable rendition.
|
||||
this.manualLevel = null;
|
||||
for (const level of levels) {
|
||||
level.enabled = true;
|
||||
}
|
||||
}
|
||||
return this.manualLevel;
|
||||
}
|
||||
|
||||
private listLevels(): VideoJsQualityLevel[] {
|
||||
const levelList = this.levelList;
|
||||
if (!levelList) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const levels: VideoJsQualityLevel[] = [];
|
||||
for (let index = 0; index < levelList.length; index += 1) {
|
||||
const level = levelList[index];
|
||||
if (level) {
|
||||
levels.push(level);
|
||||
}
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
private readLevelList(): VideoJsQualityLevelList | null {
|
||||
try {
|
||||
return this.config.player.qualityLevels?.() ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private detachLevelList(): void {
|
||||
const levelList = this.levelList;
|
||||
if (typeof levelList?.removeEventListener !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const eventName of QUALITY_LEVEL_LISTENERS) {
|
||||
levelList.removeEventListener(eventName, this.handleListChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { buildQualityLevelLabels } from './quality-level-labels';
|
||||
|
||||
describe('buildQualityLevelLabels', () => {
|
||||
it('labels levels by frame height', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ height: 1080, width: 1920, bitrate: 8_000_000 },
|
||||
{ height: 720, width: 1280, bitrate: 4_000_000 },
|
||||
{ height: 480, width: 854, bitrate: 1_500_000 },
|
||||
])
|
||||
).toEqual(['1080p', '720p', '480p']);
|
||||
});
|
||||
|
||||
it('projects a 16:9 height from a width-only level', () => {
|
||||
expect(buildQualityLevelLabels([{ width: 1920 }])).toEqual(['1080p']);
|
||||
});
|
||||
|
||||
it('falls back to the bitrate and then to a positional label', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ bitrate: 4_500_000 },
|
||||
{ bitrate: 640_000 },
|
||||
{},
|
||||
])
|
||||
).toEqual(['4.5 Mbps', '640 kbps', 'Level 3']);
|
||||
});
|
||||
|
||||
it('rounds Mbps values at 10 and above to whole numbers', () => {
|
||||
expect(buildQualityLevelLabels([{ bitrate: 15_400_000 }])).toEqual([
|
||||
'15 Mbps',
|
||||
]);
|
||||
});
|
||||
|
||||
it('disambiguates same-height levels with their bitrates', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ height: 1080, bitrate: 8_000_000 },
|
||||
{ height: 1080, bitrate: 4_000_000 },
|
||||
{ height: 720, bitrate: 2_000_000 },
|
||||
])
|
||||
).toEqual(['1080p (8.0 Mbps)', '1080p (4.0 Mbps)', '720p']);
|
||||
});
|
||||
|
||||
it('keeps colliding labels plain when no bitrate can disambiguate', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([{ height: 1080 }, { height: 1080 }])
|
||||
).toEqual(['1080p', '1080p']);
|
||||
});
|
||||
|
||||
it('ignores non-finite and non-positive facts', () => {
|
||||
expect(
|
||||
buildQualityLevelLabels([
|
||||
{ height: 0, width: NaN, bitrate: -5 },
|
||||
{ height: null, width: null, bitrate: null },
|
||||
])
|
||||
).toEqual(['Level 1', 'Level 2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/** Rendition facts an engine can report for one quality level. */
|
||||
export interface QualityLevelFacts {
|
||||
height?: number | null;
|
||||
width?: number | null;
|
||||
bitrate?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds display labels for a source's quality levels, shared by the hls.js,
|
||||
* Shaka, and Video.js quality projections so every engine renders the same
|
||||
* vocabulary: "1080p" from the frame height, a bitrate ("4.5 Mbps") when no
|
||||
* dimension is known, and a positional fallback otherwise. When two levels
|
||||
* would collide on the same base label (same height at different bitrates),
|
||||
* every colliding label carries its bitrate as a disambiguating suffix.
|
||||
*/
|
||||
export function buildQualityLevelLabels(
|
||||
levels: readonly QualityLevelFacts[]
|
||||
): string[] {
|
||||
const baseLabels = levels.map((level, index) => baseLabel(level, index));
|
||||
const occurrences = new Map<string, number>();
|
||||
for (const label of baseLabels) {
|
||||
occurrences.set(label, (occurrences.get(label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return baseLabels.map((label, index) => {
|
||||
if ((occurrences.get(label) ?? 0) <= 1) {
|
||||
return label;
|
||||
}
|
||||
const bitrate = formatBitrate(levels[index]?.bitrate);
|
||||
return bitrate ? `${label} (${bitrate})` : label;
|
||||
});
|
||||
}
|
||||
|
||||
function baseLabel(level: QualityLevelFacts, index: number): string {
|
||||
const height = readPositive(level.height);
|
||||
if (height !== null) {
|
||||
return `${height}p`;
|
||||
}
|
||||
|
||||
// Height can be absent while width survives (some panels only fill
|
||||
// RESOLUTION partially); a 16:9 projection is close enough for a label.
|
||||
const width = readPositive(level.width);
|
||||
if (width !== null) {
|
||||
return `${Math.round((width * 9) / 16)}p`;
|
||||
}
|
||||
|
||||
return formatBitrate(level.bitrate) ?? `Level ${index + 1}`;
|
||||
}
|
||||
|
||||
function formatBitrate(bitrate: number | null | undefined): string | null {
|
||||
const value = readPositive(bitrate);
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
const mbps = value / 1_000_000;
|
||||
const rendered = mbps >= 10 ? Math.round(mbps).toString() : mbps.toFixed(1);
|
||||
return `${rendered} Mbps`;
|
||||
}
|
||||
return `${Math.round(value / 1000)} kbps`;
|
||||
}
|
||||
|
||||
function readPositive(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.round(value)
|
||||
: null;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import Hls from 'hls.js';
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
import {
|
||||
AUTO_QUALITY_LEVEL_ID,
|
||||
type PlayerTrack,
|
||||
} from '../player-controls/player-controls.model';
|
||||
import { buildQualityLevelLabels } from './quality-level-labels';
|
||||
|
||||
export interface WebVideoHlsControlsConfig {
|
||||
showCaptions: () => boolean;
|
||||
@@ -77,6 +81,46 @@ export class WebVideoHlsControls {
|
||||
}
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
const hls = this.hls;
|
||||
if (!hls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const levels = hls.levels ?? [];
|
||||
const labels = buildQualityLevelLabels(levels);
|
||||
// `manualLevel` is -1 in auto mode, so no level reports selected then.
|
||||
const manualLevel =
|
||||
typeof hls.manualLevel === 'number' ? hls.manualLevel : -1;
|
||||
return levels.map((_level, index) => ({
|
||||
id: index,
|
||||
label: labels[index],
|
||||
selected: index === manualLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
const hls = this.hls;
|
||||
if (!hls || !Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// `nextLevel` switches at the next fragment instead of flushing the
|
||||
// buffer (`currentLevel` would), so the picture never stalls.
|
||||
if (id === AUTO_QUALITY_LEVEL_ID) {
|
||||
hls.nextLevel = -1;
|
||||
return;
|
||||
}
|
||||
if (id >= 0 && id < (hls.levels?.length ?? 0)) {
|
||||
hls.nextLevel = id;
|
||||
}
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
// A fake or torn-down instance without the flag counts as auto.
|
||||
return this.hls ? this.hls.autoLevelEnabled !== false : true;
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
if (!this.hls) {
|
||||
return [];
|
||||
@@ -204,5 +248,8 @@ function getHlsRefreshEvents() {
|
||||
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,
|
||||
] as const;
|
||||
}
|
||||
@@ -114,3 +114,202 @@ describe('WebVideoShakaControls', () => {
|
||||
expect(environment.instances[0].textTracks[0].active).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebVideoShakaControls quality levels', () => {
|
||||
const video = {} as HTMLVideoElement;
|
||||
|
||||
function makeVariant(overrides: {
|
||||
id: number;
|
||||
height: number;
|
||||
bandwidth: number;
|
||||
audioId?: number;
|
||||
active?: boolean;
|
||||
language?: string;
|
||||
}) {
|
||||
return {
|
||||
active: false,
|
||||
language: 'en',
|
||||
label: null,
|
||||
width: Math.round((overrides.height * 16) / 9),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const environment = createFakeShakaEnvironment({
|
||||
onCreate: (player) => {
|
||||
player.variantTracks = [
|
||||
{
|
||||
id: 1,
|
||||
active: false,
|
||||
language: 'en',
|
||||
label: null,
|
||||
height: 720,
|
||||
width: 1280,
|
||||
bandwidth: 4_000_000,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
active: true,
|
||||
language: 'en',
|
||||
label: null,
|
||||
height: 1080,
|
||||
width: 1920,
|
||||
bandwidth: 8_000_000,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
active: false,
|
||||
language: 'de',
|
||||
label: null,
|
||||
height: 1080,
|
||||
width: 1920,
|
||||
bandwidth: 8_000_000,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
const session = new ShakaVideoSession({
|
||||
player: InlinePlaybackPlayer.Html5,
|
||||
emitPlaybackIssue: () => undefined,
|
||||
showCaptions: () => true,
|
||||
loadShaka: environment.loader,
|
||||
});
|
||||
const controls = new WebVideoShakaControls({
|
||||
showCaptions: () => true,
|
||||
refresh: () => undefined,
|
||||
});
|
||||
return { environment, session, controls };
|
||||
}
|
||||
|
||||
it('lists active-language variants sorted by resolution, none selected in auto', async () => {
|
||||
const { session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
expect(controls.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables ABR before selecting a variant and marks the selection', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
const player = environment.instances[0];
|
||||
const configureCountBefore = player.configureCalls.length;
|
||||
controls.setQualityLevel(1);
|
||||
|
||||
expect(player.configureCalls.slice(configureCountBefore)).toEqual([
|
||||
{ abr: { enabled: false } },
|
||||
]);
|
||||
expect(player.selectVariantTrackCalls).toEqual([
|
||||
{
|
||||
track: expect.objectContaining({ id: 1, language: 'en' }),
|
||||
clearBuffer: true,
|
||||
},
|
||||
]);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(false);
|
||||
expect(controls.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('re-enables ABR through the auto sentinel', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
controls.setQualityLevel(0);
|
||||
controls.setQualityLevel(-1);
|
||||
|
||||
const player = environment.instances[0];
|
||||
expect(player.configureCalls.at(-1)).toEqual({
|
||||
abr: { enabled: true },
|
||||
});
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
controls.getQualityLevels().some((level) => level.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores invalid and out-of-range ids', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
const player = environment.instances[0];
|
||||
const configureCountBefore = player.configureCalls.length;
|
||||
controls.setQualityLevel(5);
|
||||
controls.setQualityLevel(0.5);
|
||||
controls.setQualityLevel(NaN);
|
||||
|
||||
expect(player.configureCalls.length).toBe(configureCountBefore);
|
||||
expect(player.selectVariantTrackCalls).toEqual([]);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('pins candidates to the active audio id, not just the language', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
// Same language twice: main (audioId 10, active) vs. commentary
|
||||
// (audioId 20). Quality choices must never leak onto the commentary
|
||||
// track's variants.
|
||||
const player = environment.instances[0];
|
||||
player.variantTracks = [
|
||||
makeVariant({ id: 1, height: 720, bandwidth: 4e6, audioId: 10 }),
|
||||
makeVariant({
|
||||
id: 2,
|
||||
height: 1080,
|
||||
bandwidth: 8e6,
|
||||
audioId: 10,
|
||||
active: true,
|
||||
}),
|
||||
makeVariant({ id: 3, height: 720, bandwidth: 4e6, audioId: 20 }),
|
||||
makeVariant({ id: 4, height: 1080, bandwidth: 8e6, audioId: 20 }),
|
||||
];
|
||||
|
||||
expect(controls.getQualityLevels()).toEqual([
|
||||
{ id: 0, label: '1080p', selected: false },
|
||||
{ id: 1, label: '720p', selected: false },
|
||||
]);
|
||||
|
||||
controls.setQualityLevel(1);
|
||||
expect(player.selectVariantTrackCalls).toEqual([
|
||||
{
|
||||
track: expect.objectContaining({ id: 1, audioId: 10 }),
|
||||
clearBuffer: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('forgets a manual selection when the session restarts with a fresh player', async () => {
|
||||
const { environment, session, controls } = createHarness();
|
||||
controls.bind(session);
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
controls.setQualityLevel(0);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(false);
|
||||
|
||||
session.start(video, 'http://example.com/movie.mpd');
|
||||
await flush();
|
||||
|
||||
expect(environment.instances).toHaveLength(2);
|
||||
expect(controls.isAutoQualityEnabled()).toBe(true);
|
||||
expect(
|
||||
controls.getQualityLevels().some((level) => level.selected)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { PlayerTrack } from '../player-controls/player-controls.model';
|
||||
import {
|
||||
AUTO_QUALITY_LEVEL_ID,
|
||||
type PlayerTrack,
|
||||
} from '../player-controls/player-controls.model';
|
||||
import type {
|
||||
ShakaPlayerLike,
|
||||
ShakaTextTrackLike,
|
||||
ShakaVariantTrackLike,
|
||||
} from '../shaka-engine/shaka-module.types';
|
||||
import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
|
||||
import { buildQualityLevelLabels } from './quality-level-labels';
|
||||
|
||||
export interface WebVideoShakaControlsConfig {
|
||||
showCaptions: () => boolean;
|
||||
@@ -29,6 +34,13 @@ export class WebVideoShakaControls {
|
||||
private session: ShakaVideoSession | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private subtitleOverride: number | null = null;
|
||||
/**
|
||||
* The exact player a manual quality selection disabled ABR on. Each
|
||||
* `ShakaVideoSession.start` creates a fresh player with ABR back on, so
|
||||
* keying manual state to the instance (not a boolean) means a session
|
||||
* restart can never render a stale manual selection.
|
||||
*/
|
||||
private manualQualityPlayer: ShakaPlayerLike | null = null;
|
||||
|
||||
constructor(private readonly config: WebVideoShakaControlsConfig) {}
|
||||
|
||||
@@ -47,6 +59,7 @@ export class WebVideoShakaControls {
|
||||
this.unsubscribe = null;
|
||||
this.session = null;
|
||||
this.subtitleOverride = null;
|
||||
this.manualQualityPlayer = null;
|
||||
}
|
||||
|
||||
refreshInputs(): void {
|
||||
@@ -78,6 +91,56 @@ export class WebVideoShakaControls {
|
||||
}
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
const player = this.getPlayer();
|
||||
if (!player) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = listQualityCandidates(player);
|
||||
const labels = buildQualityLevelLabels(
|
||||
candidates.map((track) => ({
|
||||
height: track.height,
|
||||
width: track.width,
|
||||
bitrate: track.bandwidth,
|
||||
}))
|
||||
);
|
||||
const manual = !this.isAutoQualityEnabled();
|
||||
return candidates.map((track, index) => ({
|
||||
id: index,
|
||||
label: labels[index],
|
||||
selected: manual && track.active,
|
||||
}));
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
const player = this.getPlayer();
|
||||
if (!player || !Number.isInteger(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === AUTO_QUALITY_LEVEL_ID) {
|
||||
player.configure({ abr: { enabled: true } });
|
||||
this.manualQualityPlayer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const track = listQualityCandidates(player)[id];
|
||||
if (!track) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ABR must be off first or the ABR manager overrides the selection.
|
||||
player.configure({ abr: { enabled: false } });
|
||||
player.selectVariantTrack(track, true);
|
||||
this.manualQualityPlayer = player;
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
const player = this.getPlayer();
|
||||
return !player || this.manualQualityPlayer !== player;
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
const player = this.getPlayer();
|
||||
if (!player) {
|
||||
@@ -146,3 +209,40 @@ function formatTextTrackLabel(
|
||||
): string {
|
||||
return track.label || track.language || `Subtitle ${index + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variants are audio+video combinations; picking a quality must not switch
|
||||
* the audio track. Candidates are pinned to the active variant's exact
|
||||
* `audioId` when Shaka reports one — two same-language audio tracks (main vs.
|
||||
* commentary, stereo vs. 5.1) share a language but never an id — falling back
|
||||
* to the language only when no id is available. Sorting (resolution, then
|
||||
* bandwidth, descending) keeps the ids deterministic across refreshes of the
|
||||
* same track set.
|
||||
*/
|
||||
function listQualityCandidates(
|
||||
player: ShakaPlayerLike
|
||||
): ShakaVariantTrackLike[] {
|
||||
const variants = player.getVariantTracks();
|
||||
const active = variants.find((track) => track.active);
|
||||
const candidates = variants.filter((track) =>
|
||||
sharesAudioStream(track, active)
|
||||
);
|
||||
return candidates.sort(
|
||||
(a, b) =>
|
||||
(b.height ?? 0) - (a.height ?? 0) ||
|
||||
(b.bandwidth ?? 0) - (a.bandwidth ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
function sharesAudioStream(
|
||||
track: ShakaVariantTrackLike,
|
||||
active: ShakaVariantTrackLike | undefined
|
||||
): boolean {
|
||||
if (!active) {
|
||||
return true;
|
||||
}
|
||||
if (typeof active.audioId === 'number') {
|
||||
return track.audioId === active.audioId;
|
||||
}
|
||||
return track.language === active.language;
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export class WebVideoSourceControlsBridge {
|
||||
setAudioTrack: (id) => this.setAudioTrack(id),
|
||||
getSubtitleTracks: () => this.getSubtitleTracks(),
|
||||
setSubtitleTrack: (id) => this.setSubtitleTrack(id),
|
||||
getQualityLevels: () => this.tracks.getQualityLevels(),
|
||||
setQualityLevel: (id) => this.tracks.setQualityLevel(id),
|
||||
isAutoQualityEnabled: () => this.tracks.isAutoQualityEnabled(),
|
||||
});
|
||||
this.attached = true;
|
||||
}
|
||||
|
||||
@@ -156,6 +156,35 @@ export class WebVideoSourceTracks {
|
||||
}
|
||||
}
|
||||
|
||||
getQualityLevels(): PlayerTrack[] {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.getQualityLevels();
|
||||
}
|
||||
if (this.source?.kind === 'shaka') {
|
||||
return this.shakaControls.getQualityLevels();
|
||||
}
|
||||
// Native and raw MPEG-TS sources carry a single video rendition.
|
||||
return [];
|
||||
}
|
||||
|
||||
setQualityLevel(id: number): void {
|
||||
if (this.source?.kind === 'hls') {
|
||||
this.hlsControls.setQualityLevel(id);
|
||||
} else if (this.source?.kind === 'shaka') {
|
||||
this.shakaControls.setQualityLevel(id);
|
||||
}
|
||||
}
|
||||
|
||||
isAutoQualityEnabled(): boolean {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.isAutoQualityEnabled();
|
||||
}
|
||||
if (this.source?.kind === 'shaka') {
|
||||
return this.shakaControls.isAutoQualityEnabled();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
getSubtitleTracks(): PlayerTrack[] {
|
||||
if (this.source?.kind === 'hls') {
|
||||
return this.hlsControls.getSubtitleTracks();
|
||||
|
||||
Reference in new issue
Block a user