diff --git a/packages/react/src/components/layouts/default/icons.tsx b/packages/react/src/components/layouts/default/icons.tsx
index 51297d1f0..291e6b73e 100644
--- a/packages/react/src/components/layouts/default/icons.tsx
+++ b/packages/react/src/components/layouts/default/icons.tsx
@@ -20,6 +20,7 @@ import enterFullscreenIconPaths from 'media-icons/dist/icons/fullscreen.js';
import musicIconPaths from 'media-icons/dist/icons/music.js';
import muteIconPaths from 'media-icons/dist/icons/mute.js';
import noEyeIconPaths from 'media-icons/dist/icons/no-eye.js';
+import odometerIconPaths from 'media-icons/dist/icons/odometer.js';
import pauseIconPaths from 'media-icons/dist/icons/pause.js';
import exitPIPIconPaths from 'media-icons/dist/icons/picture-in-picture-exit.js';
import enterPIPIconPaths from 'media-icons/dist/icons/picture-in-picture.js';
@@ -28,6 +29,7 @@ import playbackIconPaths from 'media-icons/dist/icons/playback-speed-circle.js';
import replayIconPaths from 'media-icons/dist/icons/replay.js';
import seekBackwardIconPaths from 'media-icons/dist/icons/seek-backward-10.js';
import seekForwardIconPaths from 'media-icons/dist/icons/seek-forward-10.js';
+import settingsMenuIconPaths from 'media-icons/dist/icons/settings-menu.js';
import settingsIconPaths from 'media-icons/dist/icons/settings.js';
import volumeHighIconPaths from 'media-icons/dist/icons/volume-high.js';
import volumeLowIconPaths from 'media-icons/dist/icons/volume-low.js';
@@ -90,10 +92,10 @@ export const defaultLayoutIcons: DefaultLayoutIcons = {
Settings: createIcon(settingsIconPaths),
AudioBoostUp: createIcon(volumeHighIconPaths),
AudioBoostDown: createIcon(volumeLowIconPaths),
- SpeedUp: createIcon(fastForwardIconPaths),
- SpeedDown: createIcon(fastBackwardIconPaths),
- QualityUp: createIcon(arrowUpIconPaths),
- QualityDown: createIcon(arrowDownIconPaths),
+ SpeedUp: createIcon(odometerIconPaths),
+ SpeedDown: createIcon(odometerIconPaths),
+ QualityUp: createIcon(settingsMenuIconPaths),
+ QualityDown: createIcon(settingsMenuIconPaths),
FontSizeUp: createIcon(arrowUpIconPaths),
FontSizeDown: createIcon(arrowDownIconPaths),
OpacityUp: createIcon(eyeIconPaths),
diff --git a/packages/react/src/components/layouts/default/slots.tsx b/packages/react/src/components/layouts/default/slots.tsx
index 2f4e1f65b..f3d3890a1 100644
--- a/packages/react/src/components/layouts/default/slots.tsx
+++ b/packages/react/src/components/layouts/default/slots.tsx
@@ -54,9 +54,10 @@ export type DefaultLayoutMenuSlotName =
| 'settingsMenuEndItems'
| 'settingsMenuItemsStart'
| 'settingsMenuItemsEnd'
- | 'playbackMenuItemsStart'
- | 'playbackMenuItemsEnd'
- | 'playbackMenuLoop'
+ | 'speedMenuItemsStart'
+ | 'speedMenuItemsEnd'
+ | 'qualityMenuItemsStart'
+ | 'qualityMenuItemsEnd'
| 'accessibilityMenuItemsStart'
| 'accessibilityMenuItemsEnd'
| 'audioMenuItemsStart'
diff --git a/packages/react/src/components/layouts/default/ui/menus/audio-menu.tsx b/packages/react/src/components/layouts/default/ui/menus/audio-menu.tsx
index d9d4b19a7..fc934f54b 100644
--- a/packages/react/src/components/layouts/default/ui/menus/audio-menu.tsx
+++ b/packages/react/src/components/layouts/default/ui/menus/audio-menu.tsx
@@ -22,6 +22,7 @@ interface DefaultAudioMenuProps {
function DefaultAudioMenu({ slots }: DefaultAudioMenuProps) {
const label = useDefaultLayoutWord('Audio'),
$canSetAudioGain = useMediaState('canSetAudioGain'),
+ $audioTrack = useMediaState('audioTrack'),
$audioTracks = useMediaState('audioTracks'),
{ noAudioGain, icons: Icons } = useDefaultLayoutContext(),
hasGainSlider = $canSetAudioGain && !noAudioGain,
@@ -31,7 +32,7 @@ function DefaultAudioMenu({ slots }: DefaultAudioMenuProps) {
return (
-
+
{slot(slots, 'audioMenuItemsStart', null)}
diff --git a/packages/react/src/components/layouts/default/ui/menus/playback-menu.tsx b/packages/react/src/components/layouts/default/ui/menus/playback-menu.tsx
deleted file mode 100644
index 0d8ff2368..000000000
--- a/packages/react/src/components/layouts/default/ui/menus/playback-menu.tsx
+++ /dev/null
@@ -1,232 +0,0 @@
-import * as React from 'react';
-
-import { isArray } from 'maverick.js/std';
-import { sortVideoQualities } from 'vidstack';
-
-import { useMediaContext } from '../../../../../hooks/use-media-context';
-import { useMediaState } from '../../../../../hooks/use-media-state';
-import * as Menu from '../../../../ui/menu';
-import * as QualitySlider from '../../../../ui/sliders/quality-slider';
-import * as SpeedSlider from '../../../../ui/sliders/speed-slider';
-import { useDefaultLayoutContext, useDefaultLayoutWord } from '../../context';
-import { slot, type DefaultLayoutMenuSlotName, type Slots } from '../../slots';
-import { DefaultMenuCheckbox } from './items/menu-checkbox';
-import { DefaultMenuButton, DefaultMenuItem, DefaultMenuSection } from './items/menu-items';
-import { DefaultMenuSliderItem, DefaultSliderParts, DefaultSliderSteps } from './items/menu-slider';
-
-/* -------------------------------------------------------------------------------------------------
- * DefaultPlaybackMenu
- * -----------------------------------------------------------------------------------------------*/
-
-interface DefaultPlaybackMenuProps {
- slots?: Slots;
-}
-
-function DefaultPlaybackMenu({ slots }: DefaultPlaybackMenuProps) {
- const label = useDefaultLayoutWord('Playback'),
- { icons: Icons } = useDefaultLayoutContext();
-
- return (
-
-
-
- {slot(slots, 'playbackMenuItemsStart', null)}
-
-
- {slot(slots, 'playbackMenuLoop', )}
-
-
-
-
-
-
- {slot(slots, 'playbackMenuItemsEnd', null)}
-
-
- );
-}
-
-DefaultPlaybackMenu.displayName = 'DefaultPlaybackMenu';
-export { DefaultPlaybackMenu };
-
-/* -------------------------------------------------------------------------------------------------
- * DefaultLoopMenuCheckbox
- * -----------------------------------------------------------------------------------------------*/
-
-function DefaultLoopMenuCheckbox() {
- const { remote } = useMediaContext(),
- label = useDefaultLayoutWord('Loop');
-
- function onChange(checked: boolean, trigger?: Event) {
- remote.userPrefersLoopChange(checked, trigger);
- }
-
- return (
-
-
-
- );
-}
-
-DefaultLoopMenuCheckbox.displayName = 'DefaultLoopMenuCheckbox';
-
-/* -------------------------------------------------------------------------------------------------
- * DefaultAutoQualityMenuCheckbox
- * -----------------------------------------------------------------------------------------------*/
-
-function DefaultAutoQualityMenuCheckbox() {
- const { remote, qualities } = useMediaContext(),
- $autoQuality = useMediaState('autoQuality'),
- label = useDefaultLayoutWord('Auto');
-
- function onChange(checked: boolean, trigger?: Event) {
- if (checked) {
- remote.requestAutoQuality(trigger);
- } else {
- remote.changeQuality(qualities.selectedIndex, trigger);
- }
- }
-
- return (
-
-
-
- );
-}
-
-DefaultAutoQualityMenuCheckbox.displayName = 'DefaultAutoQualityMenuCheckbox';
-
-/* -------------------------------------------------------------------------------------------------
- * DefaultQualityMenuSection
- * -----------------------------------------------------------------------------------------------*/
-
-function DefaultQualityMenuSection() {
- const { hideQualityBitrate, icons: Icons } = useDefaultLayoutContext(),
- $canSetQuality = useMediaState('canSetQuality'),
- $qualities = useMediaState('qualities'),
- $quality = useMediaState('quality'),
- label = useDefaultLayoutWord('Quality'),
- autoText = useDefaultLayoutWord('Auto'),
- sortedQualities = React.useMemo(() => sortVideoQualities($qualities), [$qualities]);
-
- if (!$canSetQuality || $qualities.length <= 1) return null;
-
- const height = $quality?.height,
- bitrate = !hideQualityBitrate ? $quality?.bitrate : null,
- bitrateText = bitrate && bitrate > 0 ? `${(bitrate / 1000000).toFixed(2)} Mbps` : null,
- value = height ? `${height}p${bitrateText ? ` (${bitrateText})` : ''}` : autoText,
- isMin = sortedQualities[0] === $quality,
- isMax = sortedQualities.at(-1) === $quality;
-
- return (
-
-
-
-
-
-
- );
-}
-
-DefaultQualityMenuSection.displayName = 'DefaultQualityMenuSection';
-
-/* -------------------------------------------------------------------------------------------------
- * DefaultQualitySlider
- * -----------------------------------------------------------------------------------------------*/
-
-function DefaultQualitySlider() {
- const label = useDefaultLayoutWord('Quality');
- return (
-
-
-
-
- );
-}
-
-DefaultQualitySlider.displayName = 'DefaultQualitySlider';
-
-/* -------------------------------------------------------------------------------------------------
- * DefaultSpeedMenuSection
- * -----------------------------------------------------------------------------------------------*/
-
-function DefaultSpeedMenuSection() {
- const { icons: Icons } = useDefaultLayoutContext(),
- $playbackRate = useMediaState('playbackRate'),
- $canSetPlaybackRate = useMediaState('canSetPlaybackRate'),
- label = useDefaultLayoutWord('Speed'),
- normalText = useDefaultLayoutWord('Normal'),
- min = useSpeedMin(),
- max = useSpeedMax(),
- value = $playbackRate === 1 ? normalText : $playbackRate + 'x';
-
- if (!$canSetPlaybackRate) return null;
-
- return (
-
-
-
-
-
- );
-}
-
-function useSpeedMin() {
- const { playbackRates } = useDefaultLayoutContext(),
- rates = playbackRates;
- return (isArray(rates) ? rates[0] : rates?.min) ?? 0;
-}
-
-function useSpeedMax() {
- const { playbackRates } = useDefaultLayoutContext(),
- rates = playbackRates;
- return (isArray(rates) ? rates[rates.length - 1] : rates?.max) ?? 2;
-}
-
-function useSpeedStep() {
- const { playbackRates } = useDefaultLayoutContext(),
- rates = playbackRates;
- return (isArray(rates) ? rates[1] - rates[0] : rates?.step) || 0.25;
-}
-
-/* -------------------------------------------------------------------------------------------------
- * DefaultSpeedSlider
- * -----------------------------------------------------------------------------------------------*/
-
-function DefaultSpeedSlider() {
- const label = useDefaultLayoutWord('Speed'),
- min = useSpeedMin(),
- max = useSpeedMax(),
- step = useSpeedStep();
-
- return (
-
-
-
-
- );
-}
-
-DefaultSpeedSlider.displayName = 'DefaultSpeedSlider';
diff --git a/packages/react/src/components/layouts/default/ui/menus/quality-menu.tsx b/packages/react/src/components/layouts/default/ui/menus/quality-menu.tsx
new file mode 100644
index 000000000..a4fb5d277
--- /dev/null
+++ b/packages/react/src/components/layouts/default/ui/menus/quality-menu.tsx
@@ -0,0 +1,61 @@
+import * as React from 'react';
+
+import { useVideoQualityOptions } from '../../../../../hooks/options/use-video-quality-options';
+import { useMediaState } from '../../../../../hooks/use-media-state';
+import * as Menu from '../../../../ui/menu';
+import { useDefaultLayoutContext, useDefaultLayoutWord } from '../../context';
+import { slot, type DefaultLayoutMenuSlotName, type Slots } from '../../slots';
+import { DefaultMenuButton } from './items/menu-items';
+
+/* -------------------------------------------------------------------------------------------------
+ * DefaultQualityMenu
+ * -----------------------------------------------------------------------------------------------*/
+
+interface DefaultQualityMenuProps {
+ slots?: Slots;
+}
+
+function DefaultQualityMenu({ slots }: DefaultQualityMenuProps) {
+ const { hideQualityBitrate, icons: Icons } = useDefaultLayoutContext(),
+ label = useDefaultLayoutWord('Quality'),
+ autoText = useDefaultLayoutWord('Auto'),
+ $canSetQuality = useMediaState('canSetQuality'),
+ $qualities = useMediaState('qualities'),
+ options = useVideoQualityOptions({ auto: autoText });
+
+ if (!$canSetQuality || $qualities.length <= 1 || options.disabled) return null;
+
+ return (
+
+
+
+ {slot(slots, 'qualityMenuItemsStart', null)}
+
+
+ {options.map(({ label, value, bitrateText, select }) => (
+
+
+ {label}
+ {!hideQualityBitrate && bitrateText ? (
+ {bitrateText}
+ ) : null}
+
+ ))}
+
+
+ {slot(slots, 'qualityMenuItemsEnd', null)}
+
+
+ );
+}
+
+DefaultQualityMenu.displayName = 'DefaultQualityMenu';
+export { DefaultQualityMenu };
diff --git a/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx b/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx
index 07096989c..5f1826480 100644
--- a/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx
+++ b/packages/react/src/components/layouts/default/ui/menus/settings-menu.tsx
@@ -14,7 +14,8 @@ import { DefaultTooltip } from '../tooltip';
import { DefaultAccessibilityMenu } from './accessibility-menu';
import { DefaultAudioMenu } from './audio-menu';
import { DefaultCaptionMenu } from './captions-menu';
-import { DefaultPlaybackMenu } from './playback-menu';
+import { DefaultQualityMenu } from './quality-menu';
+import { DefaultSpeedMenu } from './speed-menu';
import { useParentDialogEl } from './utils';
export interface DefaultMediaMenuProps {
@@ -68,7 +69,8 @@ function DefaultSettingsMenu({
<>
{slot(slots, 'settingsMenuItemsStart', null)}
{slot(slots, 'settingsMenuStartItems', null)}
-
+
+
diff --git a/packages/react/src/components/layouts/default/ui/menus/speed-menu.tsx b/packages/react/src/components/layouts/default/ui/menus/speed-menu.tsx
new file mode 100644
index 000000000..2ef8b726e
--- /dev/null
+++ b/packages/react/src/components/layouts/default/ui/menus/speed-menu.tsx
@@ -0,0 +1,90 @@
+import * as React from 'react';
+
+import { isArray } from 'maverick.js/std';
+
+import { useMediaState } from '../../../../../hooks/use-media-state';
+import * as Menu from '../../../../ui/menu';
+import * as SpeedSlider from '../../../../ui/sliders/speed-slider';
+import { useDefaultLayoutContext, useDefaultLayoutWord } from '../../context';
+import { slot, type DefaultLayoutMenuSlotName, type Slots } from '../../slots';
+import { DefaultMenuButton, DefaultMenuSection } from './items/menu-items';
+import { DefaultMenuSliderItem, DefaultSliderParts, DefaultSliderSteps } from './items/menu-slider';
+
+/* -------------------------------------------------------------------------------------------------
+ * DefaultSpeedMenu
+ * -----------------------------------------------------------------------------------------------*/
+
+interface DefaultSpeedMenuProps {
+ slots?: Slots;
+}
+
+function DefaultSpeedMenu({ slots }: DefaultSpeedMenuProps) {
+ const { icons: Icons } = useDefaultLayoutContext(),
+ $playbackRate = useMediaState('playbackRate'),
+ $canSetPlaybackRate = useMediaState('canSetPlaybackRate'),
+ label = useDefaultLayoutWord('Speed'),
+ normalText = useDefaultLayoutWord('Normal'),
+ min = useSpeedMin(),
+ max = useSpeedMax(),
+ step = useSpeedStep(),
+ value = $playbackRate === 1 ? normalText : $playbackRate + 'x';
+
+ if (!$canSetPlaybackRate) return null;
+
+ return (
+
+
+
+ {slot(slots, 'speedMenuItemsStart', null)}
+
+
+
+
+
+
+
+
+
+
+ {slot(slots, 'speedMenuItemsEnd', null)}
+
+
+ );
+}
+
+DefaultSpeedMenu.displayName = 'DefaultSpeedMenu';
+export { DefaultSpeedMenu };
+
+/* -------------------------------------------------------------------------------------------------
+ * Helpers
+ * -----------------------------------------------------------------------------------------------*/
+
+function useSpeedMin() {
+ const { playbackRates } = useDefaultLayoutContext(),
+ rates = playbackRates;
+ return (isArray(rates) ? rates[0] : rates?.min) ?? 0;
+}
+
+function useSpeedMax() {
+ const { playbackRates } = useDefaultLayoutContext(),
+ rates = playbackRates;
+ return (isArray(rates) ? rates[rates.length - 1] : rates?.max) ?? 2;
+}
+
+function useSpeedStep() {
+ const { playbackRates } = useDefaultLayoutContext(),
+ rates = playbackRates;
+ return (isArray(rates) ? rates[1] - rates[0] : rates?.step) || 0.25;
+}
diff --git a/packages/vidstack/src/elements/define/layouts/default/icons.ts b/packages/vidstack/src/elements/define/layouts/default/icons.ts
index 0b2c7bf9e..355b182a3 100644
--- a/packages/vidstack/src/elements/define/layouts/default/icons.ts
+++ b/packages/vidstack/src/elements/define/layouts/default/icons.ts
@@ -19,6 +19,7 @@ import fsEnter from 'media-icons/dist/icons/fullscreen.js';
import menuAudio from 'media-icons/dist/icons/music.js';
import mute from 'media-icons/dist/icons/mute.js';
import menuOpacityDown from 'media-icons/dist/icons/no-eye.js';
+import odometer from 'media-icons/dist/icons/odometer.js';
import pause from 'media-icons/dist/icons/pause.js';
import pipExit from 'media-icons/dist/icons/picture-in-picture-exit.js';
import pipEnter from 'media-icons/dist/icons/picture-in-picture.js';
@@ -27,6 +28,7 @@ import menuPlayback from 'media-icons/dist/icons/playback-speed-circle.js';
import replay from 'media-icons/dist/icons/replay.js';
import seekBackward from 'media-icons/dist/icons/seek-backward-10.js';
import seekForward from 'media-icons/dist/icons/seek-forward-10.js';
+import settingsMenu from 'media-icons/dist/icons/settings-menu.js';
import settings from 'media-icons/dist/icons/settings.js';
import volumeHigh from 'media-icons/dist/icons/volume-high.js';
import volumeLow from 'media-icons/dist/icons/volume-low.js';
@@ -58,11 +60,11 @@ export const icons = {
'menu-audio-boost-up': volumeHigh,
'menu-audio-boost-down': volumeLow,
'menu-playback': menuPlayback,
- 'menu-speed-up': fastForward,
- 'menu-speed-down': fastBackward,
+ 'menu-speed-up': odometer,
+ 'menu-speed-down': odometer,
'menu-captions': menuCaptions,
- 'menu-quality-up': arrowUp,
- 'menu-quality-down': arrowDown,
+ 'menu-quality-up': settingsMenu,
+ 'menu-quality-down': settingsMenu,
'menu-radio-check': menuRadioCheck,
'menu-font-size-up': arrowUp,
'menu-font-size-down': arrowDown,
diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/menu/audio-menu.ts b/packages/vidstack/src/elements/define/layouts/default/ui/menu/audio-menu.ts
index 5e46b4ffe..6055c0ffa 100644
--- a/packages/vidstack/src/elements/define/layouts/default/ui/menu/audio-menu.ts
+++ b/packages/vidstack/src/elements/define/layouts/default/ui/menu/audio-menu.ts
@@ -13,7 +13,7 @@ import { DefaultMenuSliderItem, DefaultSliderParts, DefaultSliderSteps } from '.
export function DefaultAudioMenu() {
return $signal(() => {
const { noAudioGain, translations } = useDefaultLayoutContext(),
- { audioTracks, canSetAudioGain } = useMediaState(),
+ { audioTrack, audioTracks, canSetAudioGain } = useMediaState(),
$disabled = computed(() => {
const hasGainSlider = canSetAudioGain() && !noAudioGain();
return !hasGainSlider && audioTracks().length <= 1;
@@ -26,6 +26,7 @@ export function DefaultAudioMenu() {
${DefaultMenuButton({
label: () => i18n(translations, 'Audio'),
icon: 'menu-audio',
+ hint: () => audioTrack()?.label ?? '',
})}