mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add audio tracks menu (#1714)
This commit is contained in:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user