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:
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<Menu.Root side="top" align="center">
|
||||
@@ -187,7 +191,7 @@ function SettingsMenu(): ReactNode {
|
||||
<Menu.Content className={menu.settings}>
|
||||
<Menu.View className={menu.rootView}>
|
||||
<div className={menu.group}>
|
||||
{hasQuality && quality ? (
|
||||
{hasQuality ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="quality"
|
||||
@@ -240,7 +244,58 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasPlaybackRate && playbackRate ? (
|
||||
|
||||
{hasAudioTrack ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="audio-track"
|
||||
className={cn(menu.item, 'media-menu__item--submenu')}
|
||||
render={(props) => (
|
||||
<div {...props}>
|
||||
<SpeechIcon className={icon} />
|
||||
<span>Audio</span>
|
||||
<span className={menu.hint}>
|
||||
<Menu.ItemValue className={menu.hintLabel} />
|
||||
<MenuChevron />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Menu.Content className={menu.submenuPanel}>
|
||||
<Menu.Back className={menu.back}>
|
||||
<MenuChevron flipped />
|
||||
Audio
|
||||
</Menu.Back>
|
||||
<Menu.Separator className={menu.separator} />
|
||||
<Menu.RadioGroup
|
||||
className={menu.group}
|
||||
value={audioTrack.value}
|
||||
onValueChange={audioTrack.setValue}
|
||||
aria-label="Audio tracks"
|
||||
>
|
||||
{audioTrack.options.map((option) => (
|
||||
<Menu.RadioItem
|
||||
key={option.value}
|
||||
className={menu.item}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<Menu.ItemIndicator
|
||||
checked={option.value === audioTrack.value}
|
||||
forceMount
|
||||
className={menu.indicator}
|
||||
>
|
||||
<CheckIcon className={icon} />
|
||||
</Menu.ItemIndicator>
|
||||
</Menu.RadioItem>
|
||||
))}
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
|
||||
{hasPlaybackRate ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="playback-rate"
|
||||
@@ -289,7 +344,8 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasCaptions && captions ? (
|
||||
|
||||
{hasCaptions ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="captions"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
QualityIcon,
|
||||
RestartIcon,
|
||||
SeekIcon,
|
||||
SpeechIcon,
|
||||
SpeedIcon,
|
||||
SpinnerIcon,
|
||||
VolumeHighIcon,
|
||||
@@ -28,6 +29,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';
|
||||
@@ -109,12 +111,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 (
|
||||
<Menu.Root side="top" align="center">
|
||||
@@ -124,7 +128,7 @@ function SettingsMenu(): ReactNode {
|
||||
<Menu.Content className="media-popover media-menu media-menu--settings">
|
||||
<Menu.View className="media-menu__panel">
|
||||
<div className="media-menu__group">
|
||||
{hasQuality && quality ? (
|
||||
{hasQuality ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="quality"
|
||||
@@ -177,7 +181,58 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasPlaybackRate && playbackRate ? (
|
||||
|
||||
{hasAudioTrack ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="audio-track"
|
||||
className="media-menu__item media-menu__item--submenu"
|
||||
render={(props) => (
|
||||
<div {...props}>
|
||||
<SpeechIcon className="media-icon" />
|
||||
<span>Audio</span>
|
||||
<span className="media-menu__hint">
|
||||
<Menu.ItemValue className="media-menu__hint-label" />
|
||||
<MenuChevron />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Menu.Content className="media-menu__panel">
|
||||
<Menu.Back className="media-menu__back">
|
||||
<MenuChevron flipped />
|
||||
Audio
|
||||
</Menu.Back>
|
||||
<Menu.Separator className="media-menu__separator" />
|
||||
<Menu.RadioGroup
|
||||
className="media-menu__group"
|
||||
value={audioTrack.value}
|
||||
onValueChange={audioTrack.setValue}
|
||||
aria-label="Audio tracks"
|
||||
>
|
||||
{audioTrack.options.map((option) => (
|
||||
<Menu.RadioItem
|
||||
key={option.value}
|
||||
className="media-menu__item"
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<Menu.ItemIndicator
|
||||
checked={option.value === audioTrack.value}
|
||||
forceMount
|
||||
className="media-menu__indicator"
|
||||
>
|
||||
<CheckIcon className="media-icon" />
|
||||
</Menu.ItemIndicator>
|
||||
</Menu.RadioItem>
|
||||
))}
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
|
||||
{hasPlaybackRate ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="playback-rate"
|
||||
@@ -226,7 +281,8 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasCaptions && captions ? (
|
||||
|
||||
{hasCaptions ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="captions"
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
QualityIcon,
|
||||
RestartIcon,
|
||||
SeekIcon,
|
||||
SpeechIcon,
|
||||
SpeedIcon,
|
||||
SpinnerIcon,
|
||||
VolumeHighIcon,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
} from '@/icons';
|
||||
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 (
|
||||
<Menu.Root side="top" align="center">
|
||||
@@ -187,7 +191,7 @@ function SettingsMenu(): ReactNode {
|
||||
<Menu.Content className={menu.settings}>
|
||||
<Menu.View className={menu.rootView}>
|
||||
<div className={menu.group}>
|
||||
{hasQuality && quality ? (
|
||||
{hasQuality ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="quality"
|
||||
@@ -240,7 +244,58 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasPlaybackRate && playbackRate ? (
|
||||
|
||||
{hasAudioTrack ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="audio-track"
|
||||
className={cn(menu.item, 'media-menu__item--submenu')}
|
||||
render={(props) => (
|
||||
<div {...props}>
|
||||
<SpeechIcon className={icon} />
|
||||
<span>Audio</span>
|
||||
<span className={menu.hint}>
|
||||
<Menu.ItemValue className={menu.hintLabel} />
|
||||
<MenuChevron />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Menu.Content className={menu.submenuPanel}>
|
||||
<Menu.Back className={menu.back}>
|
||||
<MenuChevron flipped />
|
||||
Audio
|
||||
</Menu.Back>
|
||||
<Menu.Separator className={menu.separator} />
|
||||
<Menu.RadioGroup
|
||||
className={menu.group}
|
||||
value={audioTrack.value}
|
||||
onValueChange={audioTrack.setValue}
|
||||
aria-label="Audio tracks"
|
||||
>
|
||||
{audioTrack.options.map((option) => (
|
||||
<Menu.RadioItem
|
||||
key={option.value}
|
||||
className={menu.item}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<Menu.ItemIndicator
|
||||
checked={option.value === audioTrack.value}
|
||||
forceMount
|
||||
className={menu.indicator}
|
||||
>
|
||||
<CheckIcon className={icon} />
|
||||
</Menu.ItemIndicator>
|
||||
</Menu.RadioItem>
|
||||
))}
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
|
||||
{hasPlaybackRate ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="playback-rate"
|
||||
@@ -289,7 +344,8 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasCaptions && captions ? (
|
||||
|
||||
{hasCaptions ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="captions"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
QualityIcon,
|
||||
RestartIcon,
|
||||
SeekIcon,
|
||||
SpeechIcon,
|
||||
SpeedIcon,
|
||||
SpinnerIcon,
|
||||
VolumeHighIcon,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
} from '@/icons';
|
||||
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';
|
||||
@@ -109,12 +111,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 (
|
||||
<Menu.Root side="top" align="center">
|
||||
@@ -124,7 +128,7 @@ function SettingsMenu(): ReactNode {
|
||||
<Menu.Content className="media-surface media-popover media-menu media-menu--settings">
|
||||
<Menu.View className="media-menu__panel">
|
||||
<div className="media-menu__group">
|
||||
{hasQuality && quality ? (
|
||||
{hasQuality ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="quality"
|
||||
@@ -177,7 +181,58 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasPlaybackRate && playbackRate ? (
|
||||
|
||||
{hasAudioTrack ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="audio-track"
|
||||
className="media-menu__item media-menu__item--submenu"
|
||||
render={(props) => (
|
||||
<div {...props}>
|
||||
<SpeechIcon className="media-icon" />
|
||||
<span>Audio</span>
|
||||
<span className="media-menu__hint">
|
||||
<Menu.ItemValue className="media-menu__hint-label" />
|
||||
<MenuChevron />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Menu.Content className="media-menu__panel">
|
||||
<Menu.Back className="media-menu__back">
|
||||
<MenuChevron flipped />
|
||||
Audio
|
||||
</Menu.Back>
|
||||
<Menu.Separator className="media-menu__separator" />
|
||||
<Menu.RadioGroup
|
||||
className="media-menu__group"
|
||||
value={audioTrack.value}
|
||||
onValueChange={audioTrack.setValue}
|
||||
aria-label="Audio tracks"
|
||||
>
|
||||
{audioTrack.options.map((option) => (
|
||||
<Menu.RadioItem
|
||||
key={option.value}
|
||||
className="media-menu__item"
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<Menu.ItemIndicator
|
||||
checked={option.value === audioTrack.value}
|
||||
forceMount
|
||||
className="media-menu__indicator"
|
||||
>
|
||||
<CheckIcon className="media-icon" />
|
||||
</Menu.ItemIndicator>
|
||||
</Menu.RadioItem>
|
||||
))}
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
|
||||
{hasPlaybackRate ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="playback-rate"
|
||||
@@ -226,7 +281,8 @@ function SettingsMenu(): ReactNode {
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
) : null}
|
||||
{hasCaptions && captions ? (
|
||||
|
||||
{hasCaptions ? (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
type="captions"
|
||||
|
||||
@@ -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 @@
|
||||
export type MenuItemSettingType = 'playback-rate' | 'quality' | 'captions';
|
||||
export type MenuItemSettingType = 'playback-rate' | 'quality' | 'audio-track' | 'captions';
|
||||
|
||||
Reference in New Issue
Block a user