ADR-0005: Hand-built settings flyout, and hoisting the native track menus
- Status: Accepted
- Date: 2026-08-28
Context
The player needs a settings menu (quality, playback speed) and restyled audio-track / subs-caps menus that all match one design: a stacked flyout anchored to the player's bottom-right, dark translucent panel, drill-in sub-panels with a back arrow (the pattern established by YouTube and other mature players).
Video.js gives menus only as MenuButton popups that live inside their
control-bar button. The base skin opens them on hover via a button-descendant
selector (.vjs-workinghover .vjs-menu-button-popup.vjs-hover .vjs-menu), they
inherit button-local positioning, and MenuButton.update() rebuilds the menu
from scratch inside the button on every track-list change. The earlier approach
was an inline "Auto"-text quality button (.vjsp-quality-menu,
.vjsp-quality-item) styled in place, which could not be made to share layout
or behaviour with the native audio/subs menus.
Decision
Settings flyout, built by hand (_renderSettings). .vjsp-settings-menu is
a sibling of the control bar, anchored by a hardcoded right: 6px, re-rendered
from scratch on every level change (_settingsLevel: 'root' | 'quality' | 'speed') and on external quality/rate changes while open (_syncSettingsMenu).
The root shows one row per section with its current value; each drills into a
sub-panel. Sections appear conditionally (Quality only with ≥ 2 VHS renditions
and quality !== false; Speed unless hideSpeed); the gear itself is added
only if a section could show and settings !== false. Video.js's native
playbackRateMenuButton is always removed.
Native track menus, hoisted (_hoistNativeMenus, called from ready()).
The native audio-track and subs/caps popup menus are re-parented out of their
button to the player root and tagged .vjsp-hoisted-menu; style.css then
styles them to match the settings flyout (same panel, same right: 6px anchor,
left-aligned rows, accent checkmark). A per-button MutationObserver re-hoists
the menu whenever MenuButton.update() rebuilds it; observers are disconnected
in dispose(). Because the base skin's hover-open rule is a button-descendant
selector, moving the menu out makes it click-only for free (click toggles
.vjs-lock-showing on the menu element directly). Track switching and VTT
rendering stay Video.js's own. userinactive closes the flyout and unpresses
open native menus so nothing floats over a chromeless video.
Alternatives considered
- Style Video.js's native
MenuButtonpopups in place. Rejected: they can't be positioned to a shared bottom-right anchor from inside the button, hover-open can't be turned off without fighting the skin selector, and the settings menu (quality + speed in one stack) has no native equivalent. - A menu component from a library. Rejected: still has to interoperate with
Video.js's own audio/subs menus and their
update()rebuilds; two menu systems on screen. - Fork the Video.js skin. Rejected: a standing maintenance cost against every Video.js release for what a handful of scoped overrides achieve.
- Keep the old inline "Auto" quality button. Rejected: no path to one menu system covering quality, speed, audio, and subtitles.
Consequences
Good
- One visual menu system for quality, speed, audio tracks, and subtitles.
- All popups open in the same place and are click-only, so nothing lingers over a faded-out player.
- Track switching / rendering is still Video.js's, so no regression there.
Costs / watch-outs
- We fight the base skin: every override is
.video-js-qualified to beat base specificity, and a Video.js skin change can shift what needs overriding. - The
MutationObserverre-hoist is load-bearing: ifMenuButton.update()'s DOM shape changes, hoisting silently stops and the menu reappears inside the button, unstyled and hover-only. - The
right: 6px/bottomanchors are hardcoded, not measured. - Consuming apps that hooked CSS/DOM onto the old
.vjsp-quality-menu/.vjsp-quality-itemclass names must migrate.
History
The first version was an inline quality button. It was later replaced with the
_renderSettings flyout and _hoistNativeMenus, and the captions-settings
entry (with a captionSettings option) was layered on the same flyout
afterwards.