From 5d44c4debf7a2ca50f6f0519e5cd3ff040bcc933 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Tue, 30 Jun 2026 13:40:24 +1000 Subject: [PATCH] feat(packages): add audio tracks menu (#1714) --- packages/core/src/core/index.ts | 2 + packages/core/src/core/media/predicate.ts | 7 + packages/core/src/core/media/state.ts | 15 ++ .../audio-track-radio-group-core.ts | 132 +++++++++++++ .../audio-track-radio-group-data-attrs.ts | 11 ++ .../audio-track-radio-group-core.test.ts | 141 +++++++++++++ packages/core/src/dom/media/types.ts | 2 + .../src/dom/store/features/audio-track.ts | 75 +++++++ .../src/dom/store/features/feature.parts.ts | 2 + packages/core/src/dom/store/features/index.ts | 1 + .../core/src/dom/store/features/presets.ts | 2 + .../store/features/tests/audio-track.test.ts | 128 ++++++++++++ packages/core/src/dom/store/selectors.ts | 3 + .../src/define/ui/audio-track-radio-group.ts | 10 + .../src/define/video/minimal-skin.tailwind.ts | 26 +++ .../html/src/define/video/minimal-skin.ts | 26 +++ packages/html/src/define/video/minimal-ui.ts | 2 + .../html/src/define/video/skin.tailwind.ts | 26 +++ packages/html/src/define/video/skin.ts | 26 +++ packages/html/src/define/video/ui.ts | 2 + packages/html/src/index.ts | 3 +- .../audio-track-radio-group-element.ts | 149 ++++++++++++++ .../audio-track-radio-group-element.test.ts | 186 ++++++++++++++++++ .../ui/menu/get-menu-item-setting-state.ts | 22 ++- .../html/src/ui/menu/menu-item-element.ts | 29 ++- packages/html/src/ui/menu/menu-item-type.ts | 2 +- .../tests/menu-item-value-element.test.ts | 41 +++- packages/icons/src/assets/default/speech.svg | 4 + packages/icons/src/assets/minimal/speech.svg | 13 ++ packages/react/src/index.ts | 6 + .../presets/video/minimal-skin.tailwind.tsx | 64 +++++- .../react/src/presets/video/minimal-skin.tsx | 64 +++++- .../react/src/presets/video/skin.tailwind.tsx | 64 +++++- packages/react/src/presets/video/skin.tsx | 64 +++++- packages/react/src/ui/audio-track/index.ts | 6 + .../tests/use-audio-track-options.test.tsx | 93 +++++++++ .../ui/audio-track/use-audio-track-options.ts | 60 ++++++ .../ui/menu/menu-item-setting-provider.tsx | 16 ++ packages/react/src/ui/menu/menu-item-type.ts | 2 +- .../docs/reference/feature-audio-track.mdx | 48 +++++ site/src/docs.config.ts | 1 + 41 files changed, 1550 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts create mode 100644 packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-data-attrs.ts create mode 100644 packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts create mode 100644 packages/core/src/dom/store/features/audio-track.ts create mode 100644 packages/core/src/dom/store/features/tests/audio-track.test.ts create mode 100644 packages/html/src/define/ui/audio-track-radio-group.ts create mode 100644 packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts create mode 100644 packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts create mode 100644 packages/icons/src/assets/default/speech.svg create mode 100644 packages/icons/src/assets/minimal/speech.svg create mode 100644 packages/react/src/ui/audio-track/index.ts create mode 100644 packages/react/src/ui/audio-track/tests/use-audio-track-options.test.tsx create mode 100644 packages/react/src/ui/audio-track/use-audio-track-options.ts create mode 100644 site/src/content/docs/reference/feature-audio-track.mdx diff --git a/packages/core/src/core/index.ts b/packages/core/src/core/index.ts index 7fbacec7..37434780 100644 --- a/packages/core/src/core/index.ts +++ b/packages/core/src/core/index.ts @@ -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'; diff --git a/packages/core/src/core/media/predicate.ts b/packages/core/src/core/media/predicate.ts index 49b24bd1..061a87ee 100644 --- a/packages/core/src/core/media/predicate.ts +++ b/packages/core/src/core/media/predicate.ts @@ -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; + return !isUndefined(media.audioTracks); +} + export function isMediaVideoDimensionsCapable(value: unknown): value is MediaVideoDimensionsCapability { if (!isObject(value)) return false; const media = value as Record; diff --git a/packages/core/src/core/media/state.ts b/packages/core/src/core/media/state.ts index 55489e8d..5b467a56 100644 --- a/packages/core/src/core/media/state.ts +++ b/packages/core/src/core/media/state.ts @@ -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. * diff --git a/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts b/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts new file mode 100644 index 00000000..af58723f --- /dev/null +++ b/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-core.ts @@ -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 = { + label: '', + formatTrack: formatTrackLabel, + disabled: false, + }; + + readonly state = createState({ + 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; +} diff --git a/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-data-attrs.ts b/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-data-attrs.ts new file mode 100644 index 00000000..f1817e37 --- /dev/null +++ b/packages/core/src/core/ui/audio-track-radio-group/audio-track-radio-group-data-attrs.ts @@ -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; diff --git a/packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts b/packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts new file mode 100644 index 00000000..1387e882 --- /dev/null +++ b/packages/core/src/core/ui/audio-track-radio-group/tests/audio-track-radio-group-core.test.ts @@ -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 { + 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 { + 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(); + }); + }); +}); diff --git a/packages/core/src/dom/media/types.ts b/packages/core/src/dom/media/types.ts index c1a23411..b4d32675 100644 --- a/packages/core/src/dom/media/types.ts +++ b/packages/core/src/dom/media/types.ts @@ -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, PlayerFeature, PlayerFeature, + PlayerFeature, PlayerFeature, PlayerFeature, PlayerFeature, diff --git a/packages/core/src/dom/store/features/audio-track.ts b/packages/core/src/dom/store/features/audio-track.ts new file mode 100644 index 00000000..e14fa489 --- /dev/null +++ b/packages/core/src/dom/store/features/audio-track.ts @@ -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 }); + }, +}); diff --git a/packages/core/src/dom/store/features/feature.parts.ts b/packages/core/src/dom/store/features/feature.parts.ts index 59f0295f..e0c9ca39 100644 --- a/packages/core/src/dom/store/features/feature.parts.ts +++ b/packages/core/src/dom/store/features/feature.parts.ts @@ -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, diff --git a/packages/core/src/dom/store/features/index.ts b/packages/core/src/dom/store/features/index.ts index 7598fefb..83fdfef6 100644 --- a/packages/core/src/dom/store/features/index.ts +++ b/packages/core/src/dom/store/features/index.ts @@ -1,3 +1,4 @@ +export * from './audio-track'; export * from './buffer'; export * from './controls'; export * from './error'; diff --git a/packages/core/src/dom/store/features/presets.ts b/packages/core/src/dom/store/features/presets.ts index 39986656..ef2d0826 100644 --- a/packages/core/src/dom/store/features/presets.ts +++ b/packages/core/src/dom/store/features/presets.ts @@ -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, diff --git a/packages/core/src/dom/store/features/tests/audio-track.test.ts b/packages/core/src/dom/store/features/tests/audio-track.test.ts new file mode 100644 index 00000000..814d99b8 --- /dev/null +++ b/packages/core/src/dom/store/features/tests/audio-track.test.ts @@ -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 { + 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 { + 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()(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()(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()(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()(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()(audioTrackFeature); + store.attach({ media, container: null }); + + store.state.selectAudioTrack('missing'); + + expect([...((media as any).audioTracks as TestAudioTrackList)].map((track) => track.enabled)).toEqual([true]); + }); +}); diff --git a/packages/core/src/dom/store/selectors.ts b/packages/core/src/dom/store/selectors.ts index a709b4f5..7ce4dbc5 100644 --- a/packages/core/src/dom/store/selectors.ts +++ b/packages/core/src/dom/store/selectors.ts @@ -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). */ diff --git a/packages/html/src/define/ui/audio-track-radio-group.ts b/packages/html/src/define/ui/audio-track-radio-group.ts new file mode 100644 index 00000000..b3de0d77 --- /dev/null +++ b/packages/html/src/define/ui/audio-track-radio-group.ts @@ -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; + } +} diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index ba26d216..ab52c727 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -152,6 +152,14 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })} + + ${renderIcon('speech', { class: icon })} + Audio + + + ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + + ${renderIcon('speed', { class: cn(icon, menu.icon) })} Speed @@ -193,6 +201,24 @@ function getTemplateHTML() { + + + ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + Audio + +
+ + + +
+ ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 00f5ad9f..33007780 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -130,6 +130,14 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
+ + ${renderIcon('speech', { class: 'media-icon' })} + Audio + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} + + ${renderIcon('speed', { class: 'media-icon' })} Speed @@ -171,6 +179,24 @@ function getTemplateHTML() { + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} + Audio + +
+ + + +
+ ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} diff --git a/packages/html/src/define/video/minimal-ui.ts b/packages/html/src/define/video/minimal-ui.ts index 763c051d..853b860e 100644 --- a/packages/html/src/define/video/minimal-ui.ts +++ b/packages/html/src/define/video/minimal-ui.ts @@ -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); diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 581366e8..25775475 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -148,6 +148,14 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
+ + ${renderIcon('speech', { class: icon })} + Audio + + + ${renderIcon('chevron', { class: cn(icon, menu.chevron) })} + + ${renderIcon('speed', { class: cn(icon, menu.icon) })} Speed @@ -189,6 +197,24 @@ function getTemplateHTML() { + + + ${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })} + Audio + +
+ + + +
+ ${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })} diff --git a/packages/html/src/define/video/skin.ts b/packages/html/src/define/video/skin.ts index f87f83a0..ead2f4db 100644 --- a/packages/html/src/define/video/skin.ts +++ b/packages/html/src/define/video/skin.ts @@ -126,6 +126,14 @@ function getTemplateHTML() { ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
+ + ${renderIcon('speech', { class: 'media-icon' })} + Audio + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })} + + ${renderIcon('speed', { class: 'media-icon' })} Speed @@ -167,6 +175,24 @@ function getTemplateHTML() { + + + ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} + Audio + +
+ + + +
+ ${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })} diff --git a/packages/html/src/define/video/ui.ts b/packages/html/src/define/video/ui.ts index 1fb0c322..623173f1 100644 --- a/packages/html/src/define/video/ui.ts +++ b/packages/html/src/define/video/ui.ts @@ -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); diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index 38791dd6..79d98ced 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -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'; diff --git a/packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts b/packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts new file mode 100644 index 00000000..b6e5d05b --- /dev/null +++ b/packages/html/src/ui/audio-track-radio-group/audio-track-radio-group-element.ts @@ -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.tagName)) { + const checked = item.value === this.value; + + item.disabled = state.disabled; + + for (const indicator of item.querySelectorAll(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('[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; +} diff --git a/packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts b/packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts new file mode 100644 index 00000000..cb900c65 --- /dev/null +++ b/packages/html/src/ui/audio-track-radio-group/tests/audio-track-radio-group-element.test.ts @@ -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(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 { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + 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()({ + 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 { + await menu.updateComplete; + await options?.updateComplete; + + const group = menu.querySelector(AudioTrackRadioGroupElement.tagName); + await group?.updateComplete; + + const items = [...menu.querySelectorAll(MenuRadioItemElement.tagName)]; + const indicators = [...menu.querySelectorAll(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.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: + '', + }); + + await waitForMenu(menu, options); + + const item = menu.querySelector(MenuRadioItemElement.tagName)!; + const indicators = [...menu.querySelectorAll(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.tagName)].find( + (candidate) => candidate.value === '1' + )!; + + item.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(selectAudioTrack).toHaveBeenCalledWith('1'); + }); +}); diff --git a/packages/html/src/ui/menu/get-menu-item-setting-state.ts b/packages/html/src/ui/menu/get-menu-item-setting-state.ts index 3969ff36..f8908419 100644 --- a/packages/html/src/ui/menu/get-menu-item-setting-state.ts +++ b/packages/html/src/ui/menu/get-menu-item-setting-state.ts @@ -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(); diff --git a/packages/html/src/ui/menu/menu-item-element.ts b/packages/html/src/ui/menu/menu-item-element.ts index d8fc28b9..b58b8373 100644 --- a/packages/html/src/ui/menu/menu-item-element.ts +++ b/packages/html/src/ui/menu/menu-item-element.ts @@ -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; type QualityState = ReturnType; +type AudioTrackState = ReturnType; type TextTrackState = ReturnType; export class MenuItemElement extends MediaElement { @@ -33,14 +40,16 @@ export class MenuItemElement extends MediaElement { disabled = false; /** ID of a nested `` 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 | null = null; #qualityValue: PlayerController | null = null; + #audioTrackValue: PlayerController | null = null; #captionsValue: PlayerController | 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; } diff --git a/packages/html/src/ui/menu/menu-item-type.ts b/packages/html/src/ui/menu/menu-item-type.ts index b6891071..ab2723f6 100644 --- a/packages/html/src/ui/menu/menu-item-type.ts +++ b/packages/html/src/ui/menu/menu-item-type.ts @@ -1 +1 @@ -export type MenuItemSettingType = 'playback-rate' | 'quality' | 'captions'; +export type MenuItemSettingType = 'playback-rate' | 'quality' | 'audio-track' | 'captions'; diff --git a/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts b/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts index 18dabb10..78e9a601 100644 --- a/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts +++ b/packages/html/src/ui/menu/tests/menu-item-value-element.test.ts @@ -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()({ + 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({ diff --git a/packages/icons/src/assets/default/speech.svg b/packages/icons/src/assets/default/speech.svg new file mode 100644 index 00000000..fe9f914c --- /dev/null +++ b/packages/icons/src/assets/default/speech.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/icons/src/assets/minimal/speech.svg b/packages/icons/src/assets/minimal/speech.svg new file mode 100644 index 00000000..c3347546 --- /dev/null +++ b/packages/icons/src/assets/minimal/speech.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d99560ef..a7dab9ea 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -33,6 +33,12 @@ export { // UI export { AirPlayButton, type AirPlayButtonProps } from './ui/airplay-button/airplay-button'; export { AlertDialog, type AlertDialogContextValue, useAlertDialogContext } from './ui/alert-dialog'; +export { + type AudioTrackOption, + type AudioTrackOptionsProps, + type AudioTrackOptionsResult, + useAudioTrackOptions, +} from './ui/audio-track'; export { BufferingIndicator, type BufferingIndicatorProps } from './ui/buffering-indicator/buffering-indicator'; export { CaptionsButton, type CaptionsButtonProps } from './ui/captions-button/captions-button'; export { diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 46510823..bbd28b1e 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -43,6 +43,7 @@ import { QualityIcon, RestartIcon, SeekIcon, + SpeechIcon, SpeedIcon, SpinnerIcon, VolumeHighIcon, @@ -51,6 +52,7 @@ import { } from '@/icons/minimal'; import { Container, usePlayer } from '@/player/context'; import { AirPlayButton } from '@/ui/airplay-button'; +import { useAudioTrackOptions } from '@/ui/audio-track'; import { BufferingIndicator } from '@/ui/buffering-indicator'; import { useCaptionsOptions } from '@/ui/captions-radio-group'; import { CastButton } from '@/ui/cast-button'; @@ -168,12 +170,14 @@ function MenuChevron({ flipped = false }: { flipped?: boolean }): ReactNode { function SettingsMenu(): ReactNode { const playbackRate = usePlaybackRateOptions(); const quality = useQualityOptions(); + const audioTrack = useAudioTrackOptions(); const captions = useCaptionsOptions(); const hasPlaybackRate = playbackRate?.state.availability === 'available'; const hasQuality = quality?.state.availability === 'available'; + const hasAudioTrack = audioTrack?.state.availability === 'available'; const hasCaptions = captions?.state.availability === 'available'; - if (!hasPlaybackRate && !hasQuality && !hasCaptions) return null; + if (!hasPlaybackRate && !hasQuality && !hasAudioTrack && !hasCaptions) return null; return ( @@ -187,7 +191,7 @@ function SettingsMenu(): ReactNode {
- {hasQuality && quality ? ( + {hasQuality ? ( ) : null} - {hasPlaybackRate && playbackRate ? ( + + {hasAudioTrack ? ( + + ( +
+ + Audio + + + + +
+ )} + /> + + + + Audio + + + + {audioTrack.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + + {hasPlaybackRate ? ( ) : null} - {hasCaptions && captions ? ( + + {hasCaptions ? ( @@ -124,7 +128,7 @@ function SettingsMenu(): ReactNode {
- {hasQuality && quality ? ( + {hasQuality ? ( ) : null} - {hasPlaybackRate && playbackRate ? ( + + {hasAudioTrack ? ( + + ( +
+ + Audio + + + + +
+ )} + /> + + + + Audio + + + + {audioTrack.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + + {hasPlaybackRate ? ( ) : null} - {hasCaptions && captions ? ( + + {hasCaptions ? ( @@ -187,7 +191,7 @@ function SettingsMenu(): ReactNode {
- {hasQuality && quality ? ( + {hasQuality ? ( ) : null} - {hasPlaybackRate && playbackRate ? ( + + {hasAudioTrack ? ( + + ( +
+ + Audio + + + + +
+ )} + /> + + + + Audio + + + + {audioTrack.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + + {hasPlaybackRate ? ( ) : null} - {hasCaptions && captions ? ( + + {hasCaptions ? ( @@ -124,7 +128,7 @@ function SettingsMenu(): ReactNode {
- {hasQuality && quality ? ( + {hasQuality ? ( ) : null} - {hasPlaybackRate && playbackRate ? ( + + {hasAudioTrack ? ( + + ( +
+ + Audio + + + + +
+ )} + /> + + + + Audio + + + + {audioTrack.options.map((option) => ( + + {option.label} + + + + + ))} + + +
+ ) : null} + + {hasPlaybackRate ? ( ) : null} - {hasCaptions && captions ? ( + + {hasCaptions ? ( void; + formatTrack?: ((track: MediaAudioTrack) => string) | undefined; +} = {}) { + const { Wrapper } = createPlayerWrapper({ audioTrackList, selectAudioTrack }); + + render( + + + + + , + { wrapper: Wrapper } + ); + + return { selectAudioTrack }; +} + +function AudioTrackRadioGroup({ + formatTrack, +}: { + formatTrack?: ((track: MediaAudioTrack) => string) | undefined; +}): ReactNode { + const audioTrack = useAudioTrackOptions(formatTrack ? { formatTrack } : undefined); + if (!audioTrack) return null; + + const { options, setValue, value } = audioTrack; + + return ( + + {options.map((option) => ( + + {option.label} + + ))} + + ); +} + +describe('useAudioTrackOptions', () => { + it('renders audio track options', () => { + renderAudioTrackOptions(); + + expect(screen.getByRole('menuitemradio', { name: 'English' }).getAttribute('aria-checked')).toBe('true'); + expect(screen.getByRole('menuitemradio', { name: 'Spanish' }).getAttribute('aria-checked')).toBe('false'); + }); + + it('sets the selected audio track', () => { + const selectAudioTrack = vi.fn(); + renderAudioTrackOptions({ selectAudioTrack }); + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'Spanish' })); + + expect(selectAudioTrack).toHaveBeenCalledWith('1'); + }); + + it('uses a custom track formatter', () => { + renderAudioTrackOptions({ + formatTrack: (track) => `${track.language}: ${track.label}`, + }); + + expect(screen.getByRole('menuitemradio', { name: 'en: English' })).toBeTruthy(); + }); + + it('disables options when only one audio track is available', () => { + renderAudioTrackOptions({ + audioTrackList: [{ id: '0', kind: 'main', label: 'English', language: 'en', enabled: true }], + }); + + expect(screen.getByRole('menuitemradio', { name: 'English' }).getAttribute('aria-disabled')).toBe('true'); + }); +}); diff --git a/packages/react/src/ui/audio-track/use-audio-track-options.ts b/packages/react/src/ui/audio-track/use-audio-track-options.ts new file mode 100644 index 00000000..dadbae6a --- /dev/null +++ b/packages/react/src/ui/audio-track/use-audio-track-options.ts @@ -0,0 +1,60 @@ +'use client'; + +import { AudioTrackRadioGroupCore } from '@videojs/core'; +import { logMissingFeature, selectAudioTrack } from '@videojs/core/dom'; +import { useCallback, useState } from 'react'; + +import { usePlayer } from '../../player/context'; + +export interface AudioTrackOptionsProps extends AudioTrackRadioGroupCore.Props {} + +export interface AudioTrackOption { + value: string; + label: string; + disabled: boolean; +} + +export interface AudioTrackOptionsResult { + state: AudioTrackRadioGroupCore.State; + value: string; + options: AudioTrackOption[]; + disabled: boolean; + setValue: (value: string) => void; +} + +export function useAudioTrackOptions(props?: AudioTrackOptionsProps): AudioTrackOptionsResult | null { + 'use no memo'; + + const media = usePlayer(selectAudioTrack); + const [core] = useState(() => new AudioTrackRadioGroupCore()); + + core.setProps(props ?? {}); + + const setValue = useCallback((value: string) => core.selectValue(media!, value), [core, media]); + + if (!media) { + if (__DEV__) logMissingFeature('useAudioTrackOptions', selectAudioTrack.displayName ?? 'audioTrack'); + return null; + } + + core.setMedia(media); + const state = core.getState(); + + return { + state, + value: state.value, + options: state.tracks.map((track) => ({ + value: track.value, + label: track.label, + disabled: state.disabled, + })), + disabled: state.disabled, + setValue, + }; +} + +export namespace useAudioTrackOptions { + export type Props = AudioTrackOptionsProps; + export type Result = AudioTrackOptionsResult; + export type Option = AudioTrackOption; +} diff --git a/packages/react/src/ui/menu/menu-item-setting-provider.tsx b/packages/react/src/ui/menu/menu-item-setting-provider.tsx index 3001b5ba..bd7e34a0 100644 --- a/packages/react/src/ui/menu/menu-item-setting-provider.tsx +++ b/packages/react/src/ui/menu/menu-item-setting-provider.tsx @@ -3,6 +3,7 @@ import { CAPTIONS_OFF_VALUE } from '@videojs/core'; import type { ReactNode } from 'react'; +import { useAudioTrackOptions } from '../audio-track/use-audio-track-options'; import { useCaptionsOptions } from '../captions-radio-group/use-captions-options'; import { usePlaybackRateOptions } from '../playback-rate/use-playback-rate-options'; import { useQualityOptions } from '../quality/use-quality-options'; @@ -57,10 +58,25 @@ function QualityMenuItemSettingProvider({ children }: { children: ReactNode }): ); } +function AudioTrackMenuItemSettingProvider({ children }: { children: ReactNode }): ReactNode { + const audioTrack = useAudioTrackOptions(); + if (!audioTrack) return children; + + const { state, options, value } = audioTrack; + const label = options.find((option) => option.value === value)?.label ?? ''; + + return ( + + {children} + + ); +} + export function MenuItemSettingProvider({ type, children }: MenuItemSettingProviderProps): ReactNode { if (type === 'playback-rate') return {children}; if (type === 'quality') return {children}; + if (type === 'audio-track') return {children}; if (type === 'captions') return {children}; return children; } diff --git a/packages/react/src/ui/menu/menu-item-type.ts b/packages/react/src/ui/menu/menu-item-type.ts index b6891071..ab2723f6 100644 --- a/packages/react/src/ui/menu/menu-item-type.ts +++ b/packages/react/src/ui/menu/menu-item-type.ts @@ -1 +1 @@ -export type MenuItemSettingType = 'playback-rate' | 'quality' | 'captions'; +export type MenuItemSettingType = 'playback-rate' | 'quality' | 'audio-track' | 'captions'; diff --git a/site/src/content/docs/reference/feature-audio-track.mdx b/site/src/content/docs/reference/feature-audio-track.mdx new file mode 100644 index 00000000..ad6afc96 --- /dev/null +++ b/site/src/content/docs/reference/feature-audio-track.mdx @@ -0,0 +1,48 @@ +--- +title: Audio track +description: Audio track state and actions for the player store +--- + +import FeatureReference from "@/components/docs/api-reference/FeatureReference.astro"; +import DocsLink from "@/components/docs/DocsLink.astro"; +import FrameworkCase from "@/components/docs/FrameworkCase.astro"; + +Tracks available audio tracks and selects the enabled track. + + + +### Selector + + +Pass `selectAudioTrack` to `usePlayer` to subscribe to audio track state. Returns `undefined` if the audio track feature is not configured. + + + +Pass `selectAudioTrack` to `PlayerController` to subscribe to audio track state. Returns `undefined` if the audio track feature is not configured. + + + +```tsx title="AudioTrackInfo.tsx" +import { selectAudioTrack, usePlayer } from '@videojs/react'; + +function AudioTrackInfo() { + const audioTrack = usePlayer(selectAudioTrack); + if (!audioTrack) return null; + + return {audioTrack.audioTrackList.length} audio tracks; +} +``` + + + +```ts title="audio-track-info.ts" +import { createPlayer, MediaElement, selectAudioTrack } from '@videojs/html'; +import { videoFeatures } from '@videojs/html/video'; + +const { PlayerController, context } = createPlayer({ features: videoFeatures }); + +class AudioTrackInfo extends MediaElement { + readonly #audioTrack = new PlayerController(this, context, selectAudioTrack); +} +``` + diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts index 99841737..9127d321 100644 --- a/site/src/docs.config.ts +++ b/site/src/docs.config.ts @@ -155,6 +155,7 @@ export const sidebar: Sidebar = [ { slug: 'reference/feature-playback' }, { slug: 'reference/feature-playback-rate' }, { slug: 'reference/feature-quality' }, + { slug: 'reference/feature-audio-track' }, { slug: 'reference/feature-remote-playback' }, { slug: 'reference/feature-source' }, { slug: 'reference/feature-stream-type' },