feat(packages): add playback rate menu (#1527)

This commit is contained in:
Sam Potts
2026-05-14 08:37:52 +10:00
committed by GitHub
parent ad515d3a24
commit ad831d25f0
141 changed files with 3990 additions and 397 deletions
@@ -1,7 +1,8 @@
import { ControlsCore, ControlsDataAttrs } from '@videojs/core';
import { ControlsCore, ControlsDataAttrs, POPUP_HOST_SELECTOR } from '@videojs/core';
import { applyStateDataAttrs, logMissingFeature, selectControls } from '@videojs/core/dom';
import type { PropertyValues } from '@videojs/element';
import { ContextProvider } from '@videojs/element/context';
import { isFunction } from '@videojs/utils/predicate';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
@@ -14,6 +15,7 @@ export class ControlsElement extends MediaElement {
readonly #core = new ControlsCore();
readonly #mediaState = new PlayerController(this, playerContext, selectControls);
readonly #provider = new ContextProvider(this, { context: controlsContext });
#visible = true;
override connectedCallback(): void {
super.connectedCallback();
@@ -38,5 +40,21 @@ export class ControlsElement extends MediaElement {
state,
stateAttrMap: ControlsDataAttrs,
});
const wasVisible = this.#visible;
this.#visible = state.visible;
if (wasVisible && !state.visible) {
this.#closeOwnedOverlays();
}
}
#closeOwnedOverlays(): void {
for (const element of this.querySelectorAll(POPUP_HOST_SELECTOR)) {
const host = element as Element & { close?: unknown };
if (!isFunction(host.close)) continue;
host.close('imperative-action');
}
}
}
@@ -0,0 +1,170 @@
import { type MediaControlsState, POPUP_HOST_ATTR } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore, flush } from '@videojs/store';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { playerContext } from '../../../player/context';
import { MediaElement } from '../../media-element';
import { MenuElement } from '../../menu/menu-element';
import { PlaybackRateMenuElement } from '../../playback-rate-menu/playback-rate-menu-element';
import { PopoverElement } from '../../popover/popover-element';
import { TooltipElement } from '../../tooltip/tooltip-element';
import { ControlsElement } from '../controls-element';
function ensureCustomElementDefined(Constructor: CustomElementConstructor & { readonly tagName: string }): void {
const { tagName } = Constructor;
if (!customElements.get(tagName)) {
customElements.define(tagName, Constructor);
}
}
function createDefinedElement<Class extends CustomElementConstructor & { readonly tagName: string }>(
Constructor: Class
): InstanceType<Class> {
ensureCustomElementDefined(Constructor);
return document.createElement(Constructor.tagName) as InstanceType<Class>;
}
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
function createControlsStore(): AnyPlayerStore {
return createStore<unknown>()<MediaControlsState>({
name: 'controls',
state: ({ get, set }) => {
return {
userActive: true,
controlsVisible: true,
toggleControls() {
const visible = !(get().controlsVisible as boolean);
set({ userActive: visible, controlsVisible: visible });
return visible;
},
};
},
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store = createControlsStore();
readonly #provider = new ContextProvider(this, { context: playerContext, initialValue: this.store });
override connectedCallback(): void {
super.connectedCallback();
this.#provider.setValue(this.store);
}
setVisible(visible: boolean): void {
const state = this.store.state as MediaControlsState;
if (state.controlsVisible === visible) return;
state.toggleControls();
flush();
}
}
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
let error: unknown;
for (let index = 0; index < 10; index++) {
try {
assertion();
return;
} catch (caught) {
error = caught;
await nextFrame();
}
}
throw error;
}
defineElement('test-controls-player-provider', TestPlayerProviderElement);
afterEach(() => {
document.body.innerHTML = '';
});
describe('ControlsElement', () => {
it('closes owned popovers, menus, playback-rate menus, and tooltips when controls hide', async () => {
const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement;
const controls = createDefinedElement(ControlsElement);
const popover = createDefinedElement(PopoverElement);
const menu = createDefinedElement(MenuElement);
const playbackRateMenu = createDefinedElement(PlaybackRateMenuElement);
const tooltip = createDefinedElement(TooltipElement);
const popoverClose = vi.spyOn(popover, 'close');
const menuClose = vi.spyOn(menu, 'close');
const playbackRateMenuClose = vi.spyOn(playbackRateMenu, 'close');
const tooltipClose = vi.spyOn(tooltip, 'close');
controls.append(popover, menu, playbackRateMenu, tooltip);
document.body.append(provider);
provider.append(controls);
await controls.updateComplete;
expect(popover.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
expect(menu.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
expect(playbackRateMenu.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
expect(tooltip.hasAttribute(POPUP_HOST_ATTR)).toBe(true);
provider.setVisible(false);
await waitForAssertion(() => {
expect(popoverClose).toHaveBeenCalledWith('imperative-action');
expect(menuClose).toHaveBeenCalledWith('imperative-action');
expect(playbackRateMenuClose).toHaveBeenCalledWith('imperative-action');
expect(tooltipClose).toHaveBeenCalledWith('imperative-action');
});
});
it('does not call close on native dialogs inside controls when controls hide', async () => {
const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement;
const controls = createDefinedElement(ControlsElement);
const dialog = document.createElement('dialog');
const closeSpy = vi.spyOn(dialog, 'close');
controls.append(dialog);
document.body.append(provider);
provider.append(controls);
await controls.updateComplete;
provider.setVisible(false);
await waitForAssertion(() => {
expect(closeSpy).not.toHaveBeenCalled();
});
});
it('ignores popup host markers when close is missing or not a function', async () => {
const provider = document.createElement('test-controls-player-provider') as TestPlayerProviderElement;
const controls = createDefinedElement(ControlsElement);
const withoutClose = document.createElement('div');
withoutClose.setAttribute(POPUP_HOST_ATTR, '');
const wrongClose = document.createElement('div');
wrongClose.setAttribute(POPUP_HOST_ATTR, '');
Object.assign(wrongClose, { close: 'not-callable' });
controls.append(withoutClose, wrongClose);
document.body.append(provider);
provider.append(controls);
await controls.updateComplete;
expect(() => provider.setVisible(false)).not.toThrow();
});
});