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
@@ -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). */