mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 18:04:49 +00:00
feat(packages): add audio tracks menu (#1714)
This commit is contained in:
@@ -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) })}
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+186
@@ -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();
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user