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
@@ -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"
+60 -4
View File
@@ -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"