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
@@ -0,0 +1,6 @@
export {
type AudioTrackOption,
type AudioTrackOptionsProps,
type AudioTrackOptionsResult,
useAudioTrackOptions,
} from './use-audio-track-options';
@@ -0,0 +1,93 @@
'use client';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import type { MediaAudioTrack } from '@videojs/core';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createPlayerWrapper } from '../../../testing/mocks';
import { Menu } from '../../menu';
import { useAudioTrackOptions } from '../use-audio-track-options';
afterEach(cleanup);
function renderAudioTrackOptions({
audioTrackList = [
{ id: '0', kind: 'main', label: 'English', language: 'en', enabled: true },
{ id: '1', kind: 'alternative', label: 'Spanish', language: 'es', enabled: false },
],
selectAudioTrack = vi.fn(),
formatTrack,
}: {
audioTrackList?: MediaAudioTrack[];
selectAudioTrack?: (value: string) => void;
formatTrack?: ((track: MediaAudioTrack) => string) | undefined;
} = {}) {
const { Wrapper } = createPlayerWrapper({ audioTrackList, selectAudioTrack });
render(
<Menu.Root defaultOpen align="center">
<Menu.Content data-testid="content">
<AudioTrackRadioGroup formatTrack={formatTrack} />
</Menu.Content>
</Menu.Root>,
{ 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 (
<Menu.RadioGroup value={value} onValueChange={setValue} aria-label="Audio tracks">
{options.map((option) => (
<Menu.RadioItem key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</Menu.RadioItem>
))}
</Menu.RadioGroup>
);
}
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');
});
});
@@ -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;
}
@@ -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 (
<MenuItemSettingContextProvider value={{ type: 'audio-track', label, availability: state.availability }}>
{children}
</MenuItemSettingContextProvider>
);
}
export function MenuItemSettingProvider({ type, children }: MenuItemSettingProviderProps): ReactNode {
if (type === 'playback-rate')
return <PlaybackRateMenuItemSettingProvider>{children}</PlaybackRateMenuItemSettingProvider>;
if (type === 'quality') return <QualityMenuItemSettingProvider>{children}</QualityMenuItemSettingProvider>;
if (type === 'audio-track') return <AudioTrackMenuItemSettingProvider>{children}</AudioTrackMenuItemSettingProvider>;
if (type === 'captions') return <CaptionsMenuItemSettingProvider>{children}</CaptionsMenuItemSettingProvider>;
return children;
}
+1 -1
View File
@@ -1 +1 @@
export type MenuItemSettingType = 'playback-rate' | 'quality' | 'captions';
export type MenuItemSettingType = 'playback-rate' | 'quality' | 'audio-track' | 'captions';