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
+2
View File
@@ -6,6 +6,8 @@ export * from './ui/airplay-button/airplay-button-core';
export * from './ui/airplay-button/airplay-button-data-attrs';
export * from './ui/alert-dialog/alert-dialog-core';
export * from './ui/alert-dialog/alert-dialog-data-attrs';
export * from './ui/audio-track-radio-group/audio-track-radio-group-core';
export * from './ui/audio-track-radio-group/audio-track-radio-group-data-attrs';
export * from './ui/buffering-indicator/buffering-indicator-core';
export * from './ui/buffering-indicator/buffering-indicator-data-attrs';
export * from './ui/captions-button/captions-button-core';
@@ -2,6 +2,7 @@ import { isFunction, isObject, isUndefined } from '@videojs/utils/predicate';
import { EMPTY_REMOTE, EMPTY_TEXT_TRACKS, EMPTY_TIME_RANGES } from './constants';
import type {
MediaAudioTrackCapability,
MediaBufferCapability,
MediaErrorCapability,
MediaLiveCapability,
@@ -85,6 +86,12 @@ export function isMediaVideoRenditionCapable(value: unknown): value is MediaVide
return !isUndefined(media.videoRenditions);
}
export function isMediaAudioTrackCapable(value: unknown): value is MediaAudioTrackCapability {
if (!isObject(value)) return false;
const media = value as Record<string, unknown>;
return !isUndefined(media.audioTracks);
}
export function isMediaVideoDimensionsCapable(value: unknown): value is MediaVideoDimensionsCapability {
if (!isObject(value)) return false;
const media = value as Record<string, unknown>;
+15
View File
@@ -249,6 +249,21 @@ export interface MediaQualityState {
selectVideoRendition(value: string): void;
}
export interface MediaAudioTrack {
id?: string;
kind?: string;
label: string;
language: string;
enabled: boolean;
}
export interface MediaAudioTrackState {
/** Audio tracks available for manual track selection. */
audioTrackList: MediaAudioTrack[];
/** Select an audio track by menu value. */
selectAudioTrack(value: string): void;
}
/**
* A text cue.
*
@@ -0,0 +1,132 @@
import { createState } from '@videojs/store';
import { defaults } from '@videojs/utils/object';
import { isFunction } from '@videojs/utils/predicate';
import type { NonNullableObject } from '@videojs/utils/types';
import type { MediaAudioTrack, MediaAudioTrackState } from '../../media/state';
import type { ButtonState } from '../types';
export interface AudioTrackRadioGroupProps {
/** Custom label for the options group. */
label?: string | ((state: AudioTrackRadioGroupState) => string) | undefined;
/** Custom formatter for visible track labels. */
formatTrack?: ((track: MediaAudioTrack) => string) | undefined;
/** Whether audio track selection is disabled. */
disabled?: boolean | undefined;
}
export interface AudioTrackRadioGroupTrack {
value: string;
label: string;
}
export interface AudioTrackRadioGroupState extends ButtonState {
tracks: readonly AudioTrackRadioGroupTrack[];
value: string;
disabled: boolean;
availability: 'available' | 'unavailable';
}
function formatTrackLabel(track: MediaAudioTrack): string {
if (track.label) return track.label;
if (track.language) return track.language;
if (track.kind) return track.kind;
return 'Audio';
}
function getTrackValue(track: MediaAudioTrack, index: number): string {
return track.id || String(index);
}
export class AudioTrackRadioGroupCore {
static readonly defaultProps: NonNullableObject<AudioTrackRadioGroupProps> = {
label: '',
formatTrack: formatTrackLabel,
disabled: false,
};
readonly state = createState<AudioTrackRadioGroupState>({
tracks: [],
value: '',
disabled: false,
availability: 'unavailable',
label: '',
});
#props = { ...AudioTrackRadioGroupCore.defaultProps };
#media: MediaAudioTrackState | null = null;
constructor(props?: AudioTrackRadioGroupProps) {
if (props) this.setProps(props);
}
setProps(props: AudioTrackRadioGroupProps): void {
this.#props = defaults(props, AudioTrackRadioGroupCore.defaultProps);
}
getLabel(state: AudioTrackRadioGroupState): string {
const { label } = this.#props;
if (isFunction(label)) {
const customLabel = label(state);
if (customLabel) return customLabel;
} else if (label) {
return label;
}
return 'Audio';
}
getTrackLabel(track: MediaAudioTrack): string {
return this.#props.formatTrack(track);
}
getAttrs(state: AudioTrackRadioGroupState) {
return {
'aria-label': this.getLabel(state),
'aria-disabled': state.disabled ? 'true' : undefined,
};
}
setMedia(media: MediaAudioTrackState): void {
this.#media = media;
}
getState(): AudioTrackRadioGroupState {
const media = this.#media!;
const enabledIndex = media.audioTrackList.findIndex((track) => track.enabled);
const tracks = media.audioTrackList.map((track, index) => ({
value: getTrackValue(track, index),
label: this.getTrackLabel(track),
}));
const availability: AudioTrackRadioGroupState['availability'] = tracks.length > 1 ? 'available' : 'unavailable';
this.state.patch({
tracks,
value: enabledIndex === -1 ? '' : getTrackValue(media.audioTrackList[enabledIndex]!, enabledIndex),
disabled: this.#props.disabled || availability === 'unavailable',
availability,
});
this.state.patch({ label: this.getLabel(this.state.current) });
return this.state.current;
}
select(media: MediaAudioTrackState, value: string): void {
if (this.#props.disabled) return;
const hasValue = media.audioTrackList.some((track, index) => getTrackValue(track, index) === value);
if (!hasValue) return;
media.selectAudioTrack(value);
}
selectValue(media: MediaAudioTrackState, value: string): void {
this.select(media, value);
}
}
export namespace AudioTrackRadioGroupCore {
export type Props = AudioTrackRadioGroupProps;
export type State = AudioTrackRadioGroupState;
}
@@ -0,0 +1,11 @@
import type { StateAttrMap } from '../types';
import type { AudioTrackRadioGroupState } from './audio-track-radio-group-core';
export const AudioTrackRadioGroupDataAttrs = {
/** Current audio track value. */
value: 'data-audio-track',
/** Present when audio track selection is disabled. */
disabled: 'data-disabled',
/** Indicates audio track availability (`available` or `unavailable`). */
availability: 'data-availability',
} as const satisfies StateAttrMap<AudioTrackRadioGroupState>;
@@ -0,0 +1,141 @@
import { describe, expect, it, vi } from 'vitest';
import type { MediaAudioTrackState } from '../../../media/state';
import type { AudioTrackRadioGroupState } from '../audio-track-radio-group-core';
import { AudioTrackRadioGroupCore } from '../audio-track-radio-group-core';
function createMediaState(overrides: Partial<MediaAudioTrackState> = {}): MediaAudioTrackState {
return {
audioTrackList: [
{ id: '0', kind: 'main', label: 'English', language: 'en', enabled: true },
{ id: '1', kind: 'alternative', label: 'Spanish', language: 'es', enabled: false },
],
selectAudioTrack: vi.fn(),
...overrides,
};
}
function createState(overrides: Partial<AudioTrackRadioGroupState> = {}): AudioTrackRadioGroupState {
return {
tracks: [
{ value: '0', label: 'English' },
{ value: '1', label: 'Spanish' },
],
value: '0',
disabled: false,
availability: 'available',
label: '',
...overrides,
};
}
describe('AudioTrackRadioGroupCore', () => {
describe('getState', () => {
it('projects audio tracks', () => {
const core = new AudioTrackRadioGroupCore();
const media = createMediaState();
core.setMedia(media);
const state = core.getState();
expect(state.tracks).toEqual([
{ value: '0', label: 'English' },
{ value: '1', label: 'Spanish' },
]);
expect(state.value).toBe('0');
});
it('falls back to language, kind, then Audio labels', () => {
const core = new AudioTrackRadioGroupCore();
const media = createMediaState({
audioTrackList: [
{ id: '0', kind: 'main', label: '', language: 'en', enabled: true },
{ id: '1', kind: 'commentary', label: '', language: '', enabled: false },
{ id: '2', label: '', language: '', enabled: false },
],
});
core.setMedia(media);
expect(core.getState().tracks).toEqual([
{ value: '0', label: 'en' },
{ value: '1', label: 'commentary' },
{ value: '2', label: 'Audio' },
]);
});
it('uses index values when ids are missing', () => {
const core = new AudioTrackRadioGroupCore();
const media = createMediaState({
audioTrackList: [
{ label: 'English', language: 'en', enabled: false },
{ label: 'Spanish', language: 'es', enabled: true },
],
});
core.setMedia(media);
expect(core.getState().tracks.map((track) => track.value)).toEqual(['0', '1']);
expect(core.getState().value).toBe('1');
});
it('marks availability unavailable with one track', () => {
const core = new AudioTrackRadioGroupCore();
core.setMedia(
createMediaState({ audioTrackList: [{ id: '0', label: 'English', language: 'en', enabled: true }] })
);
expect(core.getState().availability).toBe('unavailable');
expect(core.getState().disabled).toBe(true);
});
});
describe('getLabel', () => {
it('returns the default label', () => {
const core = new AudioTrackRadioGroupCore();
expect(core.getLabel(createState())).toBe('Audio');
});
it('returns a custom string label', () => {
const core = new AudioTrackRadioGroupCore({ label: 'Audio tracks' });
expect(core.getLabel(createState())).toBe('Audio tracks');
});
});
describe('getTrackLabel', () => {
it('uses a custom formatter', () => {
const core = new AudioTrackRadioGroupCore({
formatTrack: (track) => `${track.language}: ${track.label}`,
});
expect(core.getTrackLabel({ label: 'English', language: 'en', enabled: false })).toBe('en: English');
});
});
describe('selectValue', () => {
it('selects a known track', () => {
const core = new AudioTrackRadioGroupCore();
const media = createMediaState();
core.selectValue(media, '1');
expect(media.selectAudioTrack).toHaveBeenCalledWith('1');
});
it('does nothing for an unknown track', () => {
const core = new AudioTrackRadioGroupCore();
const media = createMediaState();
core.selectValue(media, '3');
expect(media.selectAudioTrack).not.toHaveBeenCalled();
});
it('does nothing when disabled', () => {
const core = new AudioTrackRadioGroupCore({ disabled: true });
const media = createMediaState();
core.selectValue(media, '1');
expect(media.selectAudioTrack).not.toHaveBeenCalled();
});
});
});
+2
View File
@@ -1,5 +1,6 @@
import type { AnySlice, Slice, Store, UnionSliceState } from '@videojs/store';
import type {
MediaAudioTrackState,
MediaBufferState,
MediaControlsState,
MediaErrorState,
@@ -44,6 +45,7 @@ export type VideoFeatures = [
PlayerFeature<MediaPlaybackState>,
PlayerFeature<MediaPlaybackRateState>,
PlayerFeature<MediaQualityState>,
PlayerFeature<MediaAudioTrackState>,
PlayerFeature<MediaVolumeState>,
PlayerFeature<MediaTimeState>,
PlayerFeature<MediaSourceState>,
@@ -0,0 +1,75 @@
import { listen } from '@videojs/utils/dom';
import { isMediaAudioTrackCapable } from '../../../core/media/predicate';
import type { MediaAudioTrack, MediaAudioTrackState } from '../../../core/media/state';
import type { AudioTrackLike, AudioTrackListLike } from '../../../core/media/types';
import { definePlayerFeature } from '../../feature';
function getTrackValue(track: AudioTrackLike, index: number): string {
return track.id || String(index);
}
function toMediaTrack(track: AudioTrackLike): MediaAudioTrack {
return {
...(track.id !== undefined && { id: track.id }),
...(track.kind !== undefined && { kind: track.kind }),
label: track.label,
language: track.language,
enabled: track.enabled,
};
}
export const audioTrackFeature = definePlayerFeature({
name: 'audioTrack',
state: ({ target }): MediaAudioTrackState => ({
audioTrackList: [],
selectAudioTrack(value: string) {
const { media } = target();
if (!isMediaAudioTrackCapable(media)) return;
const tracks = [...media.audioTracks];
const track = tracks.find((candidate, index) => getTrackValue(candidate, index) === value);
if (!track) return;
for (const candidate of tracks) {
candidate.enabled = candidate === track;
}
},
}),
attach({ target, signal, set }) {
const { media } = target;
let audioTracks: AudioTrackListLike | null = null;
let cleanup: AbortController | null = null;
const getAudioTracks = () => (isMediaAudioTrackCapable(media) ? media.audioTracks : null);
const sync = (list = getAudioTracks()) => {
set({ audioTrackList: list ? [...list].map(toMediaTrack) : [] });
};
const bind = () => {
const nextAudioTracks = getAudioTracks();
if (nextAudioTracks === audioTracks) {
sync(nextAudioTracks);
return;
}
cleanup?.abort();
cleanup = new AbortController();
audioTracks = nextAudioTracks;
if (audioTracks) {
listen(audioTracks, 'addtrack', () => sync(audioTracks), { signal: cleanup.signal });
listen(audioTracks, 'removetrack', () => sync(audioTracks), { signal: cleanup.signal });
listen(audioTracks, 'change', () => sync(audioTracks), { signal: cleanup.signal });
}
sync(audioTracks);
};
bind();
listen(media, 'loadstart', bind, { signal });
signal.addEventListener('abort', () => cleanup?.abort(), { once: true });
},
});
@@ -1,3 +1,4 @@
import { audioTrackFeature } from './audio-track';
import { bufferFeature } from './buffer';
import { controlsFeature } from './controls';
import { fullscreenFeature } from './fullscreen';
@@ -18,6 +19,7 @@ export { audioFeatures, backgroundFeatures, videoFeatures } from './presets';
// Short aliases
export {
audioTrackFeature as audioTrack,
bufferFeature as buffer,
controlsFeature as controls,
fullscreenFeature as fullscreen,
@@ -1,3 +1,4 @@
export * from './audio-track';
export * from './buffer';
export * from './controls';
export * from './error';
@@ -5,6 +5,7 @@ import type {
LiveVideoFeatures,
VideoFeatures,
} from '../../media/types';
import { audioTrackFeature } from './audio-track';
import { bufferFeature } from './buffer';
import { controlsFeature } from './controls';
import { errorFeature } from './error';
@@ -24,6 +25,7 @@ export const videoFeatures: VideoFeatures = [
playbackFeature,
playbackRateFeature,
qualityFeature,
audioTrackFeature,
volumeFeature,
timeFeature,
sourceFeature,
@@ -0,0 +1,128 @@
import { createStore } from '@videojs/store';
import { describe, expect, it } from 'vitest';
import type { AudioTrackLike } from '../../../../core/media/types';
import type { PlayerTarget } from '../../../media/types';
import { audioTrackFeature } from '../audio-track';
class TestAudioTrackList extends EventTarget {
tracks: AudioTrackLike[];
constructor(tracks: AudioTrackLike[]) {
super();
this.tracks = tracks;
}
[Symbol.iterator](): Iterator<AudioTrackLike> {
return this.tracks.values();
}
get length(): number {
return this.tracks.length;
}
}
class TestMedia extends EventTarget {
audioTracks: TestAudioTrackList | undefined = undefined;
constructor(tracks?: AudioTrackLike[]) {
super();
if (tracks) this.audioTracks = new TestAudioTrackList(tracks);
}
async play() {}
}
function createTrack(overrides: Partial<AudioTrackLike>): AudioTrackLike {
return {
id: undefined,
kind: undefined,
label: '',
language: '',
enabled: false,
addRendition: () => ({ id: undefined, bitrate: undefined, codec: undefined, selected: false }),
removeRendition: () => {},
...overrides,
};
}
function createMedia(tracks: AudioTrackLike[]): PlayerTarget['media'] {
return new TestMedia(tracks) as unknown as PlayerTarget['media'];
}
describe('audioTrackFeature', () => {
it('syncs audio tracks on attach', () => {
const media = createMedia([
createTrack({ id: '0', kind: 'main', label: 'English', language: 'en', enabled: true }),
createTrack({ id: '1', kind: 'alternative', label: 'Spanish', language: 'es' }),
]);
const store = createStore<PlayerTarget>()(audioTrackFeature);
store.attach({ media, container: null });
expect(store.state.audioTrackList).toEqual([
{ id: '0', kind: 'main', label: 'English', language: 'en', enabled: true },
{ id: '1', kind: 'alternative', label: 'Spanish', language: 'es', enabled: false },
]);
});
it('syncs audio tracks after loadstart', () => {
const media = new TestMedia() as unknown as PlayerTarget['media'];
const store = createStore<PlayerTarget>()(audioTrackFeature);
store.attach({ media, container: null });
expect(store.state.audioTrackList).toEqual([]);
const list = new TestAudioTrackList([createTrack({ id: '0', label: 'English', enabled: true })]);
(media as unknown as TestMedia).audioTracks = list;
media.dispatchEvent(new Event('loadstart'));
expect(store.state.audioTrackList).toEqual([{ id: '0', label: 'English', language: '', enabled: true }]);
list.tracks.push(createTrack({ id: '1', label: 'Spanish' }));
list.dispatchEvent(new Event('addtrack'));
expect(store.state.audioTrackList).toEqual([
{ id: '0', label: 'English', language: '', enabled: true },
{ id: '1', label: 'Spanish', language: '', enabled: false },
]);
});
it('resyncs on audio track change', () => {
const media = createMedia([createTrack({ id: '0', label: 'English' }), createTrack({ id: '1', label: 'Spanish' })]);
const store = createStore<PlayerTarget>()(audioTrackFeature);
store.attach({ media, container: null });
(media as any).audioTracks.tracks[1].enabled = true;
(media as any).audioTracks.dispatchEvent(new Event('change'));
expect(store.state.audioTrackList[1]?.enabled).toBe(true);
});
it('selects an audio track exclusively', () => {
const media = createMedia([
createTrack({ id: '0', label: 'English', enabled: true }),
createTrack({ id: '1', label: 'Spanish' }),
]);
const store = createStore<PlayerTarget>()(audioTrackFeature);
store.attach({ media, container: null });
store.state.selectAudioTrack('1');
expect([...((media as any).audioTracks as TestAudioTrackList)].map((track) => track.enabled)).toEqual([
false,
true,
]);
});
it('ignores unknown audio track values', () => {
const media = createMedia([createTrack({ id: '0', label: 'English', enabled: true })]);
const store = createStore<PlayerTarget>()(audioTrackFeature);
store.attach({ media, container: null });
store.state.selectAudioTrack('missing');
expect([...((media as any).audioTracks as TestAudioTrackList)].map((track) => track.enabled)).toEqual([true]);
});
});
+3
View File
@@ -1,5 +1,6 @@
import { createSelector } from '@videojs/store';
import { audioTrackFeature } from './features/audio-track';
import { bufferFeature } from './features/buffer';
import { controlsFeature } from './features/controls';
import { errorFeature } from './features/error';
@@ -16,6 +17,8 @@ import { textTrackFeature } from './features/text-track';
import { timeFeature } from './features/time';
import { volumeFeature } from './features/volume';
/** Select the audio track state (audioTrackList, selectAudioTrack). */
export const selectAudioTrack = createSelector(audioTrackFeature);
/** Select the buffer state (buffered ranges, percent buffered). */
export const selectBuffer = createSelector(bufferFeature);
/** Select the controls state (controls visible, user-active). */