From 6c78977de92cc7f10c7a3df7df004d62d2f3d570 Mon Sep 17 00:00:00 2001 From: Rahim Date: Wed, 27 May 2026 00:11:06 -0700 Subject: [PATCH] feat(vidstack): redesign default layout settings menu - Remove Playback parent submenu and loop checkbox - Add top-level Speed submenu with slider - Add top-level Quality submenu with radio group - Show audio track hint on Audio menu button - Update icons: odometer for speed, settings-menu for quality --- .../src/components/layouts/default/icons.tsx | 10 +- .../src/components/layouts/default/slots.tsx | 7 +- .../layouts/default/ui/menus/audio-menu.tsx | 3 +- .../default/ui/menus/playback-menu.tsx | 232 ------------------ .../layouts/default/ui/menus/quality-menu.tsx | 61 +++++ .../default/ui/menus/settings-menu.tsx | 6 +- .../layouts/default/ui/menus/speed-menu.tsx | 90 +++++++ .../elements/define/layouts/default/icons.ts | 10 +- .../layouts/default/ui/menu/audio-menu.ts | 3 +- .../layouts/default/ui/menu/playback-menu.ts | 183 -------------- .../layouts/default/ui/menu/quality-menu.ts | 43 ++++ .../layouts/default/ui/menu/settings-menu.ts | 6 +- .../layouts/default/ui/menu/speed-menu.ts | 82 +++++++ 13 files changed, 304 insertions(+), 432 deletions(-) delete mode 100644 packages/react/src/components/layouts/default/ui/menus/playback-menu.tsx create mode 100644 packages/react/src/components/layouts/default/ui/menus/quality-menu.tsx create mode 100644 packages/react/src/components/layouts/default/ui/menus/speed-menu.tsx delete mode 100644 packages/vidstack/src/elements/define/layouts/default/ui/menu/playback-menu.ts create mode 100644 packages/vidstack/src/elements/define/layouts/default/ui/menu/quality-menu.ts create mode 100644 packages/vidstack/src/elements/define/layouts/default/ui/menu/speed-menu.ts 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 ?? '', })} ${[DefaultAudioTracksMenu(), DefaultAudioBoostSection()]} diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/menu/playback-menu.ts b/packages/vidstack/src/elements/define/layouts/default/ui/menu/playback-menu.ts deleted file mode 100644 index 6afe62ce3..000000000 --- a/packages/vidstack/src/elements/define/layouts/default/ui/menu/playback-menu.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { html } from 'lit-html'; -import { computed } from 'maverick.js'; -import { isArray } from 'maverick.js/std'; - -import { useDefaultLayoutContext } from '../../../../../../components/layouts/default/context'; -import { i18n } from '../../../../../../components/layouts/default/translations'; -import { useMediaContext, useMediaState } from '../../../../../../core/api/media-context'; -import { sortVideoQualities } from '../../../../../../core/quality/utils'; -import { $signal } from '../../../../../lit/directives/signal'; -import { $i18n } from '../utils'; -import { DefaultMenuCheckbox } from './items/menu-checkbox'; -import { DefaultMenuButton, DefaultMenuItem, DefaultMenuSection } from './items/menu-items'; -import { DefaultMenuSliderItem, DefaultSliderParts, DefaultSliderSteps } from './items/menu-slider'; - -export function DefaultPlaybackMenu() { - return $signal(() => { - const { translations } = useDefaultLayoutContext(); - return html` - - ${DefaultMenuButton({ - label: () => i18n(translations, 'Playback'), - icon: 'menu-playback', - })} - - ${[ - DefaultMenuSection({ - children: DefaultLoopCheckbox(), - }), - DefaultSpeedMenuSection(), - DefaultQualityMenuSection(), - ]} - - - `; - }); -} - -function DefaultLoopCheckbox() { - const { remote } = useMediaContext(), - { translations } = useDefaultLayoutContext(), - label = 'Loop'; - - return DefaultMenuItem({ - label: $i18n(translations, label), - children: DefaultMenuCheckbox({ - label, - storageKey: 'vds-player::user-loop', - onChange(checked, trigger) { - remote.userPrefersLoopChange(checked, trigger); - }, - }), - }); -} - -function DefaultSpeedMenuSection() { - return $signal(() => { - const { translations } = useDefaultLayoutContext(), - { canSetPlaybackRate, playbackRate } = useMediaState(); - - if (!canSetPlaybackRate()) return null; - - return DefaultMenuSection({ - label: $i18n(translations, 'Speed'), - value: $signal(() => - playbackRate() === 1 ? i18n(translations, 'Normal') : playbackRate() + 'x', - ), - children: [ - DefaultMenuSliderItem({ - upIcon: 'menu-speed-up', - downIcon: 'menu-speed-down', - children: DefaultSpeedSlider(), - isMin: () => playbackRate() === getSpeedMin(), - isMax: () => playbackRate() === getSpeedMax(), - }), - ], - }); - }); -} - -function getSpeedMin() { - const { playbackRates } = useDefaultLayoutContext(), - rates = playbackRates(); - return isArray(rates) ? (rates[0] ?? 0) : rates.min; -} - -function getSpeedMax() { - const { playbackRates } = useDefaultLayoutContext(), - rates = playbackRates(); - return isArray(rates) ? (rates[rates.length - 1] ?? 2) : rates.max; -} - -function getSpeedStep() { - const { playbackRates } = useDefaultLayoutContext(), - rates = playbackRates(); - return isArray(rates) ? rates[1] - rates[0] || 0.25 : rates.step; -} - -function DefaultSpeedSlider() { - const { translations } = useDefaultLayoutContext(), - $label = $i18n(translations, 'Speed'), - $min = getSpeedMin, - $max = getSpeedMax, - $step = getSpeedStep; - - return html` - - ${DefaultSliderParts()}${DefaultSliderSteps()} - - `; -} -function DefaultAutoQualityCheckbox() { - const { remote, qualities } = useMediaContext(), - { autoQuality, canSetQuality, qualities: $qualities } = useMediaState(), - { translations } = useDefaultLayoutContext(), - label = 'Auto', - $disabled = computed(() => !canSetQuality() || $qualities().length <= 1); - - if ($disabled()) return null; - - return DefaultMenuItem({ - label: $i18n(translations, label), - children: DefaultMenuCheckbox({ - label, - checked: autoQuality, - onChange(checked, trigger) { - if (checked) { - remote.requestAutoQuality(trigger); - } else { - remote.changeQuality(qualities.selectedIndex, trigger); - } - }, - }), - }); -} - -function DefaultQualityMenuSection() { - return $signal(() => { - const { hideQualityBitrate, translations } = useDefaultLayoutContext(), - { canSetQuality, qualities, quality } = useMediaState(), - $disabled = computed(() => !canSetQuality() || qualities().length <= 1), - $sortedQualities = computed(() => sortVideoQualities(qualities())); - - if ($disabled()) return null; - - return DefaultMenuSection({ - label: $i18n(translations, 'Quality'), - value: $signal(() => { - const height = quality()?.height, - bitrate = !hideQualityBitrate() ? quality()?.bitrate : null, - bitrateText = bitrate && bitrate > 0 ? `${(bitrate / 1000000).toFixed(2)} Mbps` : null, - autoText = i18n(translations, 'Auto'); - return height ? `${height}p${bitrateText ? ` (${bitrateText})` : ''}` : autoText; - }), - children: [ - DefaultMenuSliderItem({ - upIcon: 'menu-quality-up', - downIcon: 'menu-quality-down', - children: DefaultQualitySlider(), - isMin: () => $sortedQualities()[0] === quality(), - isMax: () => $sortedQualities().at(-1) === quality(), - }), - DefaultAutoQualityCheckbox(), - ], - }); - }); -} - -function DefaultQualitySlider() { - const { translations } = useDefaultLayoutContext(), - $label = $i18n(translations, 'Quality'); - return html` - - ${DefaultSliderParts()}${DefaultSliderSteps()} - - `; -} diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/menu/quality-menu.ts b/packages/vidstack/src/elements/define/layouts/default/ui/menu/quality-menu.ts new file mode 100644 index 000000000..840d20efd --- /dev/null +++ b/packages/vidstack/src/elements/define/layouts/default/ui/menu/quality-menu.ts @@ -0,0 +1,43 @@ +import { html } from 'lit-html'; + +import { useDefaultLayoutContext } from '../../../../../../components/layouts/default/context'; +import { i18n } from '../../../../../../components/layouts/default/translations'; +import { useMediaState } from '../../../../../../core/api/media-context'; +import { $signal } from '../../../../../lit/directives/signal'; +import { $i18n } from '../utils'; +import { DefaultMenuButton } from './items/menu-items'; + +export function DefaultQualityMenu() { + return $signal(() => { + const { hideQualityBitrate, translations } = useDefaultLayoutContext(), + { canSetQuality, qualities } = useMediaState(); + + if (!canSetQuality() || qualities().length <= 1) return null; + + const $autoLabel = $i18n(translations, 'Auto'); + + return html` + + ${DefaultMenuButton({ + label: () => i18n(translations, 'Quality'), + icon: 'menu-quality-up', + })} + + + + + + + `; + }); +} diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts b/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts index 4f909ad3b..1efb0a963 100644 --- a/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts +++ b/packages/vidstack/src/elements/define/layouts/default/ui/menu/settings-menu.ts @@ -14,7 +14,8 @@ import { DefaultAccessibilityMenu } from './accessibility-menu'; import { DefaultAudioMenu } from './audio-menu'; import { DefaultCaptionsMenu } from './captions-menu'; import { MenuPortal } from './menu-portal'; -import { DefaultPlaybackMenu } from './playback-menu'; +import { DefaultQualityMenu } from './quality-menu'; +import { DefaultSpeedMenu } from './speed-menu'; export function DefaultSettingsMenu({ placement, @@ -64,7 +65,8 @@ export function DefaultSettingsMenu({ } return [ - DefaultPlaybackMenu(), + DefaultSpeedMenu(), + DefaultQualityMenu(), DefaultAccessibilityMenu(), DefaultAudioMenu(), DefaultCaptionsMenu(), diff --git a/packages/vidstack/src/elements/define/layouts/default/ui/menu/speed-menu.ts b/packages/vidstack/src/elements/define/layouts/default/ui/menu/speed-menu.ts new file mode 100644 index 000000000..ffaf71a77 --- /dev/null +++ b/packages/vidstack/src/elements/define/layouts/default/ui/menu/speed-menu.ts @@ -0,0 +1,82 @@ +import { html } from 'lit-html'; +import { isArray } from 'maverick.js/std'; + +import { useDefaultLayoutContext } from '../../../../../../components/layouts/default/context'; +import { i18n } from '../../../../../../components/layouts/default/translations'; +import { useMediaState } from '../../../../../../core/api/media-context'; +import { $signal } from '../../../../../lit/directives/signal'; +import { $i18n } from '../utils'; +import { DefaultMenuButton, DefaultMenuSection } from './items/menu-items'; +import { DefaultMenuSliderItem, DefaultSliderParts, DefaultSliderSteps } from './items/menu-slider'; + +export function DefaultSpeedMenu() { + return $signal(() => { + const { translations } = useDefaultLayoutContext(), + { canSetPlaybackRate, playbackRate } = useMediaState(); + + if (!canSetPlaybackRate()) return null; + + return html` + + ${DefaultMenuButton({ + label: () => i18n(translations, 'Speed'), + icon: 'menu-speed-up', + })} + + ${DefaultMenuSection({ + label: $i18n(translations, 'Speed'), + value: $signal(() => + playbackRate() === 1 ? i18n(translations, 'Normal') : playbackRate() + 'x', + ), + children: DefaultMenuSliderItem({ + upIcon: 'menu-font-size-up', + downIcon: 'menu-font-size-down', + children: DefaultSpeedSlider(), + isMin: () => playbackRate() === getSpeedMin(), + isMax: () => playbackRate() === getSpeedMax(), + }), + })} + + + `; + }); +} + +function getSpeedMin() { + const { playbackRates } = useDefaultLayoutContext(), + rates = playbackRates(); + return isArray(rates) ? (rates[0] ?? 0) : rates.min; +} + +function getSpeedMax() { + const { playbackRates } = useDefaultLayoutContext(), + rates = playbackRates(); + return isArray(rates) ? (rates[rates.length - 1] ?? 2) : rates.max; +} + +function getSpeedStep() { + const { playbackRates } = useDefaultLayoutContext(), + rates = playbackRates(); + return isArray(rates) ? rates[1] - rates[0] || 0.25 : rates.step; +} + +function DefaultSpeedSlider() { + const { translations } = useDefaultLayoutContext(), + $label = $i18n(translations, 'Speed'), + $min = getSpeedMin, + $max = getSpeedMax, + $step = getSpeedStep; + + return html` + + ${DefaultSliderParts()}${DefaultSliderSteps()} + + `; +}