feat(packages): add audio tracks menu (#1714)

This commit is contained in:
Sam Potts
2026-06-30 13:40:24 +10:00
committed by GitHub
parent f3652bd90b
commit 5d44c4debf
41 changed files with 1550 additions and 26 deletions
@@ -0,0 +1,10 @@
import { AudioTrackRadioGroupElement } from '../../ui/audio-track-radio-group/audio-track-radio-group-element';
import { safeDefine } from '../safe-define';
safeDefine(AudioTrackRadioGroupElement);
declare global {
interface HTMLElementTagNameMap {
[AudioTrackRadioGroupElement.tagName]: AudioTrackRadioGroupElement;
}
}
@@ -152,6 +152,14 @@ function getTemplateHTML() {
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="${cn(menu.item, 'media-menu__item--submenu')}">
${renderIcon('speech', { class: icon })}
<span>Audio</span>
<span class="${menu.hint}">
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
${renderIcon('chevron', { class: cn(icon, menu.chevron) })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="${cn(menu.item, 'media-menu__item--submenu')}">
${renderIcon('speed', { class: cn(icon, menu.icon) })}
<span>Speed</span>
@@ -193,6 +201,24 @@ function getTemplateHTML() {
</media-quality-radio-group>
</media-menu>
<media-menu id="settings-audio-menu" class="${menu.submenuPanel}">
<media-menu-back class="${menu.back}">
${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })}
Audio
</media-menu-back>
<div class="${menu.separator}"></div>
<media-audio-track-radio-group class="${menu.group}">
<template>
<media-menu-radio-item class="${menu.item}">
<span data-part="label"></span>
<media-menu-item-indicator force-mount class="${menu.indicator}">
${renderIcon('check', { class: icon })}
</media-menu-item-indicator>
</media-menu-radio-item>
</template>
</media-audio-track-radio-group>
</media-menu>
<media-menu id="settings-speed-menu" class="${menu.submenuPanel}">
<media-menu-back class="${menu.back}">
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
@@ -130,6 +130,14 @@ function getTemplateHTML() {
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="media-menu__item media-menu__item--submenu">
${renderIcon('speech', { class: 'media-icon' })}
<span>Audio</span>
<span class="media-menu__hint">
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="media-menu__item media-menu__item--submenu">
${renderIcon('speed', { class: 'media-icon' })}
<span>Speed</span>
@@ -171,6 +179,24 @@ function getTemplateHTML() {
</media-quality-radio-group>
</media-menu>
<media-menu id="settings-audio-menu" class="media-menu__panel">
<media-menu-back class="media-menu__back">
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
Audio
</media-menu-back>
<div class="media-menu__separator"></div>
<media-audio-track-radio-group class="media-menu__group">
<template>
<media-menu-radio-item class="media-menu__item">
<span data-part="label"></span>
<media-menu-item-indicator force-mount class="media-menu__indicator">
${renderIcon('check', { class: 'media-icon' })}
</media-menu-item-indicator>
</media-menu-radio-item>
</template>
</media-audio-track-radio-group>
</media-menu>
<media-menu id="settings-speed-menu" class="media-menu__panel">
<media-menu-back class="media-menu__back">
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
@@ -4,6 +4,7 @@
import { MediaContainerElement } from '../../media/container-element';
import { AirPlayButtonElement } from '../../ui/airplay-button/airplay-button-element';
import { AudioTrackRadioGroupElement } from '../../ui/audio-track-radio-group/audio-track-radio-group-element';
import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element';
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-element';
@@ -54,6 +55,7 @@ defineMenu();
// Standalone elements.
safeDefine(AirPlayButtonElement);
safeDefine(AudioTrackRadioGroupElement);
safeDefine(BufferingIndicatorElement);
safeDefine(CaptionsButtonElement);
safeDefine(CastButtonElement);
@@ -148,6 +148,14 @@ function getTemplateHTML() {
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="${cn(menu.item, 'media-menu__item--submenu')}">
${renderIcon('speech', { class: icon })}
<span>Audio</span>
<span class="${menu.hint}">
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
${renderIcon('chevron', { class: cn(icon, menu.chevron) })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="${cn(menu.item, 'media-menu__item--submenu')}">
${renderIcon('speed', { class: cn(icon, menu.icon) })}
<span>Speed</span>
@@ -189,6 +197,24 @@ function getTemplateHTML() {
</media-quality-radio-group>
</media-menu>
<media-menu id="settings-audio-menu" class="${menu.submenuPanel}">
<media-menu-back class="${menu.back}">
${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })}
Audio
</media-menu-back>
<div class="${menu.separator}"></div>
<media-audio-track-radio-group class="${menu.group}">
<template>
<media-menu-radio-item class="${menu.item}">
<span data-part="label"></span>
<media-menu-item-indicator force-mount class="${menu.indicator}">
${renderIcon('check', { class: icon })}
</media-menu-item-indicator>
</media-menu-radio-item>
</template>
</media-audio-track-radio-group>
</media-menu>
<media-menu id="settings-speed-menu" class="${menu.submenuPanel}">
<media-menu-back class="${menu.back}">
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
+26
View File
@@ -126,6 +126,14 @@ function getTemplateHTML() {
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="media-menu__item media-menu__item--submenu">
${renderIcon('speech', { class: 'media-icon' })}
<span>Audio</span>
<span class="media-menu__hint">
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
</span>
</media-menu-item>
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="media-menu__item media-menu__item--submenu">
${renderIcon('speed', { class: 'media-icon' })}
<span>Speed</span>
@@ -167,6 +175,24 @@ function getTemplateHTML() {
</media-quality-radio-group>
</media-menu>
<media-menu id="settings-audio-menu" class="media-menu__panel">
<media-menu-back class="media-menu__back">
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
Audio
</media-menu-back>
<div class="media-menu__separator"></div>
<media-audio-track-radio-group class="media-menu__group">
<template>
<media-menu-radio-item class="media-menu__item">
<span data-part="label"></span>
<media-menu-item-indicator force-mount class="media-menu__indicator">
${renderIcon('check', { class: 'media-icon' })}
</media-menu-item-indicator>
</media-menu-radio-item>
</template>
</media-audio-track-radio-group>
</media-menu>
<media-menu id="settings-speed-menu" class="media-menu__panel">
<media-menu-back class="media-menu__back">
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
+2
View File
@@ -3,6 +3,7 @@
// (light DOM) player layout.
import { MediaContainerElement } from '../../media/container-element';
import { AirPlayButtonElement } from '../../ui/airplay-button/airplay-button-element';
import { AudioTrackRadioGroupElement } from '../../ui/audio-track-radio-group/audio-track-radio-group-element';
import { BufferingIndicatorElement } from '../../ui/buffering-indicator/buffering-indicator-element';
import { CaptionsButtonElement } from '../../ui/captions-button/captions-button-element';
import { CaptionsRadioGroupElement } from '../../ui/captions-radio-group/captions-radio-group-element';
@@ -52,6 +53,7 @@ defineMenu();
// Standalone elements.
safeDefine(AirPlayButtonElement);
safeDefine(AudioTrackRadioGroupElement);
safeDefine(BufferingIndicatorElement);
safeDefine(CaptionsButtonElement);
safeDefine(CastButtonElement);
+2 -1
View File
@@ -25,13 +25,14 @@ export * from './store/container-mixin';
export * from './store/media-attach-mixin';
export * from './store/provider-mixin';
export * from './store/types';
// UI Components
export { AirPlayButtonElement } from './ui/airplay-button/airplay-button-element';
export { AlertDialogCloseElement } from './ui/alert-dialog/alert-dialog-close-element';
export { AlertDialogDescriptionElement } from './ui/alert-dialog/alert-dialog-description-element';
export { AlertDialogElement } from './ui/alert-dialog/alert-dialog-element';
export { AlertDialogTitleElement } from './ui/alert-dialog/alert-dialog-title-element';
export { type AlertDialogContextValue, alertDialogContext } from './ui/alert-dialog/context';
// UI Components
export { AudioTrackRadioGroupElement } from './ui/audio-track-radio-group/audio-track-radio-group-element';
export { BufferingIndicatorElement } from './ui/buffering-indicator/buffering-indicator-element';
export { CaptionsButtonElement } from './ui/captions-button/captions-button-element';
export { CaptionsRadioGroupElement } from './ui/captions-radio-group/captions-radio-group-element';
@@ -0,0 +1,149 @@
import { AudioTrackRadioGroupCore, AudioTrackRadioGroupDataAttrs } from '@videojs/core';
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectAudioTrack } from '@videojs/core/dom';
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
import { playerContext } from '../../player/context';
import { PlayerController } from '../../player/player-controller';
import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element';
import { MenuRadioGroupElement } from '../menu/menu-radio-group-element';
import { MenuRadioItemElement } from '../menu/menu-radio-item-element';
export class AudioTrackRadioGroupElement extends MenuRadioGroupElement {
static override readonly tagName = 'media-audio-track-radio-group';
static override properties = {
...MenuRadioGroupElement.properties,
disabled: { type: Boolean },
label: { type: String },
} satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>;
disabled = false;
label = '';
formatTrack = AudioTrackRadioGroupCore.defaultProps.formatTrack;
readonly #core = new AudioTrackRadioGroupCore();
readonly #mediaState = new PlayerController(this, playerContext, selectAudioTrack);
#tracksKey = '';
#disconnect: AbortController | null = null;
override connectedCallback(): void {
super.connectedCallback();
if (this.destroyed) return;
this.#disconnect = new AbortController();
this.addEventListener('value-change', this.#handleValueChange, { signal: this.#disconnect.signal });
if (__DEV__ && !this.#mediaState.value && this.#mediaState.displayName) {
logMissingFeature(this.localName, this.#mediaState.displayName);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.#disconnect?.abort();
this.#disconnect = null;
}
protected override update(changed: PropertyValues): void {
const media = this.#mediaState.value;
let state: AudioTrackRadioGroupCore.State | null = null;
if (media) {
this.#core.setProps({ formatTrack: this.formatTrack, disabled: this.disabled });
this.#core.setMedia(media);
state = this.#core.getState();
this.value = state.value;
this.label = this.label || 'Audio tracks';
applyElementProps(this, this.#core.getAttrs(state));
this.#syncContent(state);
}
super.update(changed);
if (state) applyStateDataAttrs(this, state, AudioTrackRadioGroupDataAttrs);
}
#syncContent(state: AudioTrackRadioGroupCore.State): void {
const template = this.#getTemplate();
const templateKey = template?.innerHTML ?? '';
const tracksKey = `${state.tracks.map((track) => `${track.value}:${track.label}`).join('|')}::${templateKey}`;
if (tracksKey !== this.#tracksKey) {
this.#tracksKey = tracksKey;
for (const child of [...this.children]) {
if (child instanceof HTMLTemplateElement) continue;
child.remove();
}
this.append(...state.tracks.map((track) => this.#createItem(track.value, track.label, template)));
}
for (const item of this.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)) {
const checked = item.value === this.value;
item.disabled = state.disabled;
for (const indicator of item.querySelectorAll<MenuItemIndicatorElement>(MenuItemIndicatorElement.tagName)) {
indicator.checked = checked;
}
}
}
#createItem(value: string, label: string, template: HTMLTemplateElement | null): MenuRadioItemElement {
const item = this.#createItemFromTemplate(template);
item.value = value;
item.setAttribute('data-track', value);
this.#setLabel(item, label);
return item;
}
#createItemFromTemplate(template: HTMLTemplateElement | null): MenuRadioItemElement {
if (!template) return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement;
const fragment = template.content.cloneNode(true) as DocumentFragment;
const root = fragment.firstElementChild;
if (!root || root.localName !== MenuRadioItemElement.tagName || root.nextElementSibling) {
return document.createElement(MenuRadioItemElement.tagName) as MenuRadioItemElement;
}
return root as MenuRadioItemElement;
}
#setLabel(item: MenuRadioItemElement, label: string): void {
const labelPart = item.querySelector<HTMLElement>('[data-part~="label"]');
if (labelPart) {
labelPart.textContent = label;
} else {
item.textContent = label;
}
}
#getTemplate(): HTMLTemplateElement | null {
for (const child of this.children) {
if (child instanceof HTMLTemplateElement) return child;
}
return null;
}
#handleValueChange = (event: Event): void => {
if (event.target !== this) return;
const media = this.#mediaState.value;
if (!media) return;
const { value } = (event as CustomEvent<{ value: string }>).detail;
this.#core.selectValue(media, value);
};
}
export namespace AudioTrackRadioGroupElement {
export type State = AudioTrackRadioGroupCore.State;
}
@@ -0,0 +1,186 @@
import type { MediaAudioTrackState } from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore } 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 { MenuItemIndicatorElement } from '../../menu/menu-item-indicator-element';
import { MenuRadioGroupElement } from '../../menu/menu-radio-group-element';
import { MenuRadioItemElement } from '../../menu/menu-radio-item-element';
import { AudioTrackRadioGroupElement } from '../audio-track-radio-group-element';
let tagCounter = 0;
function uniqueTag(base: string): string {
return `${base}-${tagCounter++}`;
}
function createElement<Element extends HTMLElement>(Base: abstract new () => Element): Element {
const tag = uniqueTag('test-el');
customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {});
return document.createElement(tag) as Element;
}
function defineElement(tagName: string, Base: CustomElementConstructor): void {
if (!customElements.get(tagName)) {
customElements.define(tagName, Base);
}
}
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;
}
function createAudioTrackStore({
audioTrackList = [
{ id: '0', kind: 'main', label: 'English', language: 'en', enabled: true },
{ id: '1', kind: 'alternative', label: 'Spanish', language: 'es', enabled: false },
],
selectAudioTrack = vi.fn(),
}: {
audioTrackList?: MediaAudioTrackState['audioTrackList'] | undefined;
selectAudioTrack?: MediaAudioTrackState['selectAudioTrack'] | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaAudioTrackState>({
name: 'audioTrack',
state: () => ({
audioTrackList,
selectAudioTrack,
}),
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store: AnyPlayerStore = createAudioTrackStore();
readonly #provider = new ContextProvider(this, { context: playerContext });
override connectedCallback(): void {
this.#provider.setValue(this.store);
super.connectedCallback();
}
setStore(store: AnyPlayerStore): void {
this.store = store;
this.#provider.setValue(store);
}
}
defineElement(MenuElement.tagName, MenuElement);
defineElement(MenuRadioGroupElement.tagName, MenuRadioGroupElement);
defineElement(MenuRadioItemElement.tagName, MenuRadioItemElement);
defineElement(MenuItemIndicatorElement.tagName, MenuItemIndicatorElement);
defineElement(AudioTrackRadioGroupElement.tagName, AudioTrackRadioGroupElement);
defineElement('test-audio-track-player', TestPlayerProviderElement);
function setup({
audioTrackList,
selectAudioTrack,
template,
}: {
audioTrackList?: MediaAudioTrackState['audioTrackList'] | undefined;
selectAudioTrack?: MediaAudioTrackState['selectAudioTrack'] | undefined;
template?: string | undefined;
} = {}) {
const store = createAudioTrackStore({ audioTrackList, selectAudioTrack });
const provider = document.createElement('test-audio-track-player') as TestPlayerProviderElement;
const menu = createElement(MenuElement);
const options = createElement(AudioTrackRadioGroupElement);
provider.setStore(store);
if (template) {
const templateElement = document.createElement('template');
templateElement.innerHTML = template;
options.append(templateElement);
}
menu.append(options);
provider.append(menu);
document.body.append(provider);
return { menu, options };
}
async function waitForMenu(menu: MenuElement, options?: AudioTrackRadioGroupElement): Promise<void> {
await menu.updateComplete;
await options?.updateComplete;
const group = menu.querySelector<AudioTrackRadioGroupElement>(AudioTrackRadioGroupElement.tagName);
await group?.updateComplete;
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
const indicators = [...menu.querySelectorAll<MenuItemIndicatorElement>(MenuItemIndicatorElement.tagName)];
await Promise.all(items.map((item) => item.updateComplete));
await Promise.all(indicators.map((indicator) => indicator.updateComplete));
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('AudioTrackRadioGroupElement', () => {
it('renders audio track radio items', async () => {
const { menu, options } = setup();
await waitForMenu(menu, options);
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
expect(items.map((item) => item.textContent)).toEqual(['English', 'Spanish']);
await waitForAssertion(() => {
expect(items.map((item) => item.getAttribute('aria-checked'))).toEqual(['true', 'false']);
});
});
it('renders radio items from a template', async () => {
const { menu, options } = setup({
template:
'<media-menu-radio-item class="custom-item"><span class="custom-label" data-part="label"></span><media-menu-item-indicator force-mount class="custom-indicator"></media-menu-item-indicator></media-menu-radio-item>',
});
await waitForMenu(menu, options);
const item = menu.querySelector<MenuRadioItemElement>(MenuRadioItemElement.tagName)!;
const indicators = [...menu.querySelectorAll<MenuItemIndicatorElement>(MenuItemIndicatorElement.tagName)];
expect(item.className).toBe('custom-item');
expect(item.querySelector('[data-part~="label"]')?.textContent).toBe('English');
expect(indicators.map((indicator) => indicator.checked)).toEqual([true, false]);
});
it('sets the selected audio track', async () => {
const selectAudioTrack = vi.fn();
const { menu, options } = setup({ selectAudioTrack });
await waitForMenu(menu, options);
const item = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)].find(
(candidate) => candidate.value === '1'
)!;
item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(selectAudioTrack).toHaveBeenCalledWith('1');
});
});
@@ -1,5 +1,11 @@
import type { MediaPlaybackRateState, MediaQualityState, MediaTextTrackState } from '@videojs/core';
import type {
MediaAudioTrackState,
MediaPlaybackRateState,
MediaQualityState,
MediaTextTrackState,
} from '@videojs/core';
import {
type AudioTrackRadioGroupCore,
CAPTIONS_OFF_VALUE,
type CaptionsRadioGroupCore,
type PlaybackRateRadioGroupCore,
@@ -19,9 +25,10 @@ export function getMenuItemSettingState(
cores: {
playbackRate: PlaybackRateRadioGroupCore;
quality: QualityRadioGroupCore;
audioTrack: AudioTrackRadioGroupCore;
captions: CaptionsRadioGroupCore;
},
media: MediaPlaybackRateState | MediaQualityState | MediaTextTrackState
media: MediaPlaybackRateState | MediaQualityState | MediaAudioTrackState | MediaTextTrackState
): MenuItemSettingState {
if (type === 'playback-rate') {
cores.playbackRate.setMedia(media as MediaPlaybackRateState);
@@ -49,6 +56,17 @@ export function getMenuItemSettingState(
};
}
if (type === 'audio-track') {
cores.audioTrack.setMedia(media as MediaAudioTrackState);
const state = cores.audioTrack.getState();
const track = state.tracks.find((candidate) => candidate.value === state.value);
return {
label: track?.label ?? '',
availability: state.availability,
};
}
cores.captions.setMedia(media as MediaTextTrackState);
const state = cores.captions.getState();
+25 -4
View File
@@ -1,8 +1,14 @@
import { CaptionsRadioGroupCore, PlaybackRateRadioGroupCore, QualityRadioGroupCore } from '@videojs/core';
import {
AudioTrackRadioGroupCore,
CaptionsRadioGroupCore,
PlaybackRateRadioGroupCore,
QualityRadioGroupCore,
} from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import {
applyElementProps,
completeMenuItemSelection,
selectAudioTrack,
selectPlaybackRate,
selectQuality,
selectTextTrack,
@@ -19,6 +25,7 @@ import type { MenuItemSettingType } from './menu-item-type';
type PlaybackRateState = ReturnType<typeof selectPlaybackRate>;
type QualityState = ReturnType<typeof selectQuality>;
type AudioTrackState = ReturnType<typeof selectAudioTrack>;
type TextTrackState = ReturnType<typeof selectTextTrack>;
export class MenuItemElement extends MediaElement {
@@ -33,14 +40,16 @@ export class MenuItemElement extends MediaElement {
disabled = false;
/** ID of a nested `<media-menu>` to open when this item is activated. */
commandfor: string | undefined = undefined;
/** Setting kind for submenu triggers (`playback-rate`, `quality`, or `captions`). */
/** Setting kind for submenu triggers (`playback-rate`, `quality`, `audio-track`, or `captions`). */
type: MenuItemSettingType | null = null;
readonly #playbackRateCore = new PlaybackRateRadioGroupCore();
readonly #qualityCore = new QualityRadioGroupCore();
readonly #audioTrackCore = new AudioTrackRadioGroupCore();
readonly #captionsCore = new CaptionsRadioGroupCore();
#playbackRateValue: PlayerController<AnyPlayerStore, PlaybackRateState> | null = null;
#qualityValue: PlayerController<AnyPlayerStore, QualityState> | null = null;
#audioTrackValue: PlayerController<AnyPlayerStore, AudioTrackState> | null = null;
#captionsValue: PlayerController<AnyPlayerStore, TextTrackState> | null = null;
readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true });
readonly #settingProvider = new ContextProvider(this, { context: menuItemSettingContext });
@@ -145,7 +154,12 @@ export class MenuItemElement extends MediaElement {
const setting = getMenuItemSettingState(
this.type,
{ playbackRate: this.#playbackRateCore, quality: this.#qualityCore, captions: this.#captionsCore },
{
playbackRate: this.#playbackRateCore,
quality: this.#qualityCore,
audioTrack: this.#audioTrackCore,
captions: this.#captionsCore,
},
value
);
@@ -162,7 +176,9 @@ export class MenuItemElement extends MediaElement {
this.#settingUnavailable = unavailable;
}
#getSettingValue(type: MenuItemSettingType): PlaybackRateState | QualityState | TextTrackState | undefined {
#getSettingValue(
type: MenuItemSettingType
): PlaybackRateState | QualityState | AudioTrackState | TextTrackState | undefined {
if (type === 'playback-rate') {
this.#playbackRateValue ??= new PlayerController(this, playerContext, selectPlaybackRate);
return this.#playbackRateValue.value;
@@ -173,6 +189,11 @@ export class MenuItemElement extends MediaElement {
return this.#qualityValue.value;
}
if (type === 'audio-track') {
this.#audioTrackValue ??= new PlayerController(this, playerContext, selectAudioTrack);
return this.#audioTrackValue.value;
}
this.#captionsValue ??= new PlayerController(this, playerContext, selectTextTrack);
return this.#captionsValue.value;
}
+1 -1
View File
@@ -1 +1 @@
export type MenuItemSettingType = 'playback-rate' | 'quality' | 'captions';
export type MenuItemSettingType = 'playback-rate' | 'quality' | 'audio-track' | 'captions';
@@ -1,4 +1,9 @@
import type { MediaPlaybackRateState, MediaQualityState, MediaTextTrackState } from '@videojs/core';
import type {
MediaAudioTrackState,
MediaPlaybackRateState,
MediaQualityState,
MediaTextTrackState,
} from '@videojs/core';
import type { AnyPlayerStore } from '@videojs/core/dom';
import { ContextProvider } from '@videojs/element/context';
import { createStore } from '@videojs/store';
@@ -95,6 +100,23 @@ function createQualityStore({
}) as unknown as AnyPlayerStore;
}
function createAudioTrackStore({
audioTrackList = [
{ id: '0', kind: 'main', label: 'English', language: 'en', enabled: true },
{ id: '1', kind: 'alternative', label: 'Spanish', language: 'es', enabled: false },
],
}: {
audioTrackList?: MediaAudioTrackState['audioTrackList'] | undefined;
} = {}): AnyPlayerStore {
return createStore<unknown>()<MediaAudioTrackState>({
name: 'audioTrack',
state: () => ({
audioTrackList,
selectAudioTrack: vi.fn(),
}),
}) as unknown as AnyPlayerStore;
}
class TestPlayerProviderElement extends MediaElement {
store: AnyPlayerStore = createPlaybackRateStore();
@@ -204,6 +226,23 @@ describe('MenuItemValueElement', () => {
});
});
it('renders the selected audio track label', async () => {
const { value } = setup(
createAudioTrackStore({
audioTrackList: [
{ id: '0', kind: 'main', label: 'English', language: 'en', enabled: false },
{ id: '1', kind: 'alternative', label: 'Spanish', language: 'es', enabled: true },
],
}),
'audio-track'
);
await value.updateComplete;
await waitForAssertion(() => {
expect(value.textContent).toBe('Spanish');
});
});
it('renders the active caption track label', async () => {
const { value } = setup(
createTextTrackStore({