Skip to content

Commit cd413ba

Browse files
committed
feat(player): new speed slider component
1 parent f364d71 commit cd413ba

25 files changed

Lines changed: 535 additions & 419 deletions

File tree

packages/react/src/components/layouts/default/font-menu.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ FontResetContext.displayName = 'FontResetContext';
4040
* -----------------------------------------------------------------------------------------------*/
4141

4242
function DefaultFontSubmenu() {
43-
const { icons: Icons } = React.useContext(DefaultLayoutContext),
44-
label = useDefaultLayoutWord('Caption Styles'),
43+
const label = useDefaultLayoutWord('Caption Styles'),
4544
$hasCaptions = useMediaState('hasCaptions'),
4645
resets = React.useMemo<FontReset>(() => ({ all: new Set() }), []);
4746

@@ -50,7 +49,7 @@ function DefaultFontSubmenu() {
5049
return (
5150
<FontResetContext.Provider value={resets}>
5251
<Menu.Root className="vds-font-menu vds-menu">
53-
<DefaultSubmenuButton label={label} Icon={Icons.Menu.Font} />
52+
<DefaultSubmenuButton label={label} />
5453
<Menu.Content className="vds-font-style-items vds-menu-items">
5554
<DefaultFontFamilySubmenu />
5655
<DefaultFontSizeSubmenu />

packages/react/src/components/layouts/default/icons.tsx

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,14 @@ import exitFullscreenIconPaths from 'media-icons/dist/icons/fullscreen-exit.js';
1313
import enterFullscreenIconPaths from 'media-icons/dist/icons/fullscreen.js';
1414
import musicIconPaths from 'media-icons/dist/icons/music.js';
1515
import muteIconPaths from 'media-icons/dist/icons/mute.js';
16-
import odometerIconPaths from 'media-icons/dist/icons/odometer.js';
1716
import pauseIconPaths from 'media-icons/dist/icons/pause.js';
1817
import exitPIPIconPaths from 'media-icons/dist/icons/picture-in-picture-exit.js';
1918
import enterPIPIconPaths from 'media-icons/dist/icons/picture-in-picture.js';
2019
import playIconPaths from 'media-icons/dist/icons/play.js';
21-
import loopIconPaths from 'media-icons/dist/icons/repeat.js';
20+
import playbackIconPaths from 'media-icons/dist/icons/playback-speed-circle.js';
2221
import replayIconPaths from 'media-icons/dist/icons/replay.js';
2322
import seekBackwardIconPaths from 'media-icons/dist/icons/seek-backward-10.js';
2423
import seekForwardIconPaths from 'media-icons/dist/icons/seek-forward-10.js';
25-
import qualityIconPaths from 'media-icons/dist/icons/settings-menu.js';
2624
import settingsIconPaths from 'media-icons/dist/icons/settings.js';
2725
import volumeHighIconPaths from 'media-icons/dist/icons/volume-high.js';
2826
import volumeLowIconPaths from 'media-icons/dist/icons/volume-low.js';
@@ -79,14 +77,13 @@ export const defaultLayoutIcons: DefaultLayoutIcons = {
7977
ArrowRight: createIcon(arrowRightIconPaths),
8078
Audio: createIcon(musicIconPaths),
8179
Chapters: createIcon(chaptersIconPaths),
82-
Loop: createIcon(loopIconPaths),
83-
Quality: createIcon(qualityIconPaths),
8480
Captions: createIcon(ccIconPaths),
81+
Playback: createIcon(playbackIconPaths),
8582
Settings: createIcon(settingsIconPaths),
86-
Speed: createIcon(odometerIconPaths),
87-
Font: createIcon(
88-
`<path d="M22.6667 10.6667H26.6667V26.6667H28V28H22.6667V26.6667H24V22.6667H18.6667L16.6667 26.6667H18.6667V28H13.3333V26.6667H14.6667L22.6667 10.6667ZM24 12L19.3333 21.3333H24V12ZM6.66667 4H13.3333C14.8133 4 16 5.18667 16 6.66667V21.3333H12V14.6667H8V21.3333H4V6.66667C4 5.18667 5.18667 4 6.66667 4ZM8 6.66667V12H12V6.66667H8Z" fill="currentColor" />`,
89-
),
83+
AudioBoostUp: createIcon(volumeHighIconPaths),
84+
AudioBoostDown: createIcon(volumeLowIconPaths),
85+
SpeedUp: createIcon(fastForwardIconPaths),
86+
SpeedDown: createIcon(fastBackwardIconPaths),
9087
},
9188
KeyboardAction: {
9289
Play: createIcon(playIconPaths),
@@ -161,13 +158,14 @@ export interface DefaultMenuIcons {
161158
ArrowLeft: DefaultLayoutIcon;
162159
ArrowRight: DefaultLayoutIcon;
163160
Audio: DefaultLayoutIcon;
161+
AudioBoostUp: DefaultLayoutIcon;
162+
AudioBoostDown: DefaultLayoutIcon;
164163
Chapters: DefaultLayoutIcon;
165-
Loop: DefaultLayoutIcon;
166-
Quality: DefaultLayoutIcon;
167164
Captions: DefaultLayoutIcon;
165+
Playback: DefaultLayoutIcon;
168166
Settings: DefaultLayoutIcon;
169-
Speed: DefaultLayoutIcon;
170-
Font: DefaultLayoutIcon;
167+
SpeedUp: DefaultLayoutIcon;
168+
SpeedDown: DefaultLayoutIcon;
171169
}
172170

173171
export interface DefaultKeyboardActionIcons {

packages/react/src/components/layouts/default/media-layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export interface DefaultLayoutProps<Slots = unknown> extends PrimitivePropsWithR
103103
/**
104104
* The playback rate options to be displayed in the settings menu.
105105
*/
106-
playbackRates?: number[];
106+
playbackRates?: number[] | { min: number; max: number; step: number };
107107
/**
108108
* The number of seconds to seek forward or backward when pressing the seek button or using
109109
* keyboard shortcuts.

packages/react/src/components/layouts/default/shared-layout.tsx

Lines changed: 85 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import * as React from 'react';
22

33
import { useSignal } from 'maverick.js/react';
4-
import { isKeyboardClick, uppercaseFirstChar } from 'maverick.js/std';
4+
import { isArray, isKeyboardClick, uppercaseFirstChar } from 'maverick.js/std';
55
import { isTrackCaptionKind, type DefaultLayoutWord, type TooltipPlacement } from 'vidstack';
66

77
import { useAudioOptions } from '../../../hooks/options/use-audio-options';
88
import { useCaptionOptions } from '../../../hooks/options/use-caption-options';
99
import { useChapterOptions } from '../../../hooks/options/use-chapter-options';
10-
import { usePlaybackRateOptions } from '../../../hooks/options/use-playback-rate-options';
1110
import { useVideoQualityOptions } from '../../../hooks/options/use-video-quality-options';
1211
import { useResizeObserver } from '../../../hooks/use-dom';
1312
import { useMediaContext } from '../../../hooks/use-media-context';
1413
import { useMediaState } from '../../../hooks/use-media-state';
15-
import { createComputed } from '../../../hooks/use-signals';
1614
import { isRemotionSource } from '../../../providers/remotion/type-check';
1715
import { MediaAnnouncer } from '../../announcer';
1816
import type { TimeSliderInstance } from '../../primitives/instances';
@@ -29,6 +27,7 @@ import { Captions } from '../../ui/captions';
2927
import { ChapterTitle } from '../../ui/chapter-title';
3028
import * as Menu from '../../ui/menu';
3129
import * as AudioGainSlider from '../../ui/sliders/audio-gain-slider';
30+
import * as SpeedSlider from '../../ui/sliders/speed-slider';
3231
import * as TimeSlider from '../../ui/sliders/time-slider';
3332
import * as VolumeSlider from '../../ui/sliders/volume-slider';
3433
import * as Thumbnail from '../../ui/thumbnail';
@@ -630,40 +629,16 @@ export { DefaultChaptersMenu };
630629
* -----------------------------------------------------------------------------------------------*/
631630

632631
function DefaultSettingsMenu({ tooltip, placement, portalClass, slots }: DefaultMediaMenuProps) {
633-
const { $state } = useMediaContext(),
634-
{
632+
const {
635633
showMenuDelay,
636634
icons: Icons,
637635
isSmallLayout,
638636
menuGroup,
639637
noModal,
640-
playbackRates,
641-
noAudioGainSlider,
642638
} = useDefaultLayoutContext(),
643639
settingsText = useDefaultLayoutWord('Settings'),
644640
$viewType = useMediaState('viewType'),
645-
$offset = !isSmallLayout && menuGroup === 'bottom' && $viewType === 'video' ? 26 : 0,
646-
// Create as a computed signal to avoid unnecessary re-rendering.
647-
$$hasMenuItems = createComputed(() => {
648-
const {
649-
canSetPlaybackRate,
650-
canSetQuality,
651-
canSetAudioGain,
652-
qualities,
653-
audioTracks,
654-
hasCaptions,
655-
} = $state;
656-
return (
657-
!!(canSetPlaybackRate() && playbackRates?.length) ||
658-
!!(canSetQuality() && qualities().length) ||
659-
!!audioTracks().length ||
660-
(!noAudioGainSlider && canSetAudioGain()) ||
661-
hasCaptions()
662-
);
663-
}, [playbackRates, noAudioGainSlider]),
664-
$hasMenuItems = useSignal($$hasMenuItems);
665-
666-
if (!$hasMenuItems) return null;
641+
$offset = !isSmallLayout && menuGroup === 'bottom' && $viewType === 'video' ? 26 : 0;
667642

668643
const Content = (
669644
<Menu.Content
@@ -672,12 +647,10 @@ function DefaultSettingsMenu({ tooltip, placement, portalClass, slots }: Default
672647
offset={$offset}
673648
>
674649
{slot(slots, 'settingsMenuStartItems', null)}
675-
<DefaultMenuLoopCheckbox />
650+
<DefaultPlaybackSubmenu />
676651
<DefaultAccessibilitySubmenu />
677652
<DefaultAudioSubmenu />
678653
<DefaultCaptionSubmenu />
679-
<DefaultSpeedSubmenu />
680-
<DefaultQualitySubmenu />
681654
{slot(slots, 'settingsMenuEndItems', null)}
682655
</Menu.Content>
683656
);
@@ -707,14 +680,34 @@ function DefaultSettingsMenu({ tooltip, placement, portalClass, slots }: Default
707680
DefaultSettingsMenu.displayName = 'DefaultSettingsMenu';
708681
export { DefaultSettingsMenu };
709682

683+
/* -------------------------------------------------------------------------------------------------
684+
* DefaultPlaybackSubmenu
685+
* -----------------------------------------------------------------------------------------------*/
686+
687+
function DefaultPlaybackSubmenu() {
688+
const label = useDefaultLayoutWord('Playback'),
689+
{ icons: Icons } = useDefaultLayoutContext();
690+
691+
return (
692+
<Menu.Root className="vds-accessibility-menu vds-menu">
693+
<DefaultSubmenuButton label={label} Icon={Icons.Menu.Playback} />
694+
<Menu.Content className="vds-menu-items">
695+
<DefaultMenuLoopCheckbox />
696+
<DefaultMenuSpeedSlider />
697+
</Menu.Content>
698+
</Menu.Root>
699+
);
700+
}
701+
702+
DefaultPlaybackSubmenu.displayName = 'DefaultPlaybackSubmenu';
703+
710704
/* -------------------------------------------------------------------------------------------------
711705
* DefaultMenuLoopCheckbox
712706
* -----------------------------------------------------------------------------------------------*/
713707

714708
function DefaultMenuLoopCheckbox() {
715709
const label = 'Loop',
716710
{ remote } = useMediaContext(),
717-
{ icons: Icons } = useDefaultLayoutContext(),
718711
translatedLabel = useDefaultLayoutWord(label);
719712

720713
function onChange(checked: boolean, trigger?: Event) {
@@ -723,7 +716,6 @@ function DefaultMenuLoopCheckbox() {
723716

724717
return (
725718
<div className="vds-menu-item vds-menu-item-checkbox">
726-
<Icons.Menu.Loop className="vds-menu-checkbox-icon" />
727719
<div className="vds-menu-checkbox-label">{translatedLabel}</div>
728720
<DefaultMenuCheckbox label={label} storageKey="vds-player::user-loop" onChange={onChange} />
729721
</div>
@@ -732,6 +724,61 @@ function DefaultMenuLoopCheckbox() {
732724

733725
DefaultMenuLoopCheckbox.displayName = 'DefaultMenuLoopCheckbox';
734726

727+
/* -------------------------------------------------------------------------------------------------
728+
* DefaultMenuSpeedSlider
729+
* -----------------------------------------------------------------------------------------------*/
730+
731+
function DefaultMenuSpeedSlider() {
732+
const { icons: Icons } = useDefaultLayoutContext(),
733+
$playbackRate = useMediaState('playbackRate'),
734+
label = useDefaultLayoutWord('Speed'),
735+
normalText = useDefaultLayoutWord('Normal'),
736+
value = $playbackRate === 1 ? normalText : $playbackRate + 'x';
737+
738+
return (
739+
<div className="vds-menu-item vds-menu-item-slider">
740+
<div className="vds-menu-slider-title">
741+
<span className="vds-menu-slider-label">{label}</span>
742+
<span className="vds-menu-slider-value">{value}</span>
743+
</div>
744+
<div className="vds-menu-slider-group">
745+
<Icons.Menu.SpeedDown className="vds-icon" />
746+
<DefaultSpeedSlider />
747+
<Icons.Menu.SpeedUp className="vds-icon" />
748+
</div>
749+
</div>
750+
);
751+
}
752+
753+
DefaultMenuSpeedSlider.displayName = 'DefaultMenuSpeedSlider';
754+
755+
/* -------------------------------------------------------------------------------------------------
756+
* DefaultSpeedSlider
757+
* -----------------------------------------------------------------------------------------------*/
758+
759+
function DefaultSpeedSlider() {
760+
const label = useDefaultLayoutWord('Speed'),
761+
{ playbackRates: rates } = useDefaultLayoutContext(),
762+
min = (isArray(rates) ? rates[0] : rates?.min) || 0,
763+
max = (isArray(rates) ? rates[rates.length - 1] : rates?.max) || 2,
764+
step = (isArray(rates) ? rates[1] - rates[0] : rates?.step) || 0.25;
765+
return (
766+
<SpeedSlider.Root
767+
className="vds-speed-slider vds-slider"
768+
aria-label={label}
769+
min={min}
770+
max={max}
771+
step={step}
772+
>
773+
<SpeedSlider.Track className="vds-slider-track" />
774+
<SpeedSlider.TrackFill className="vds-slider-track-fill vds-slider-track" />
775+
<SpeedSlider.Thumb className="vds-slider-thumb" />
776+
</SpeedSlider.Root>
777+
);
778+
}
779+
780+
DefaultSpeedSlider.displayName = 'DefaultSpeedSlider';
781+
735782
/* -------------------------------------------------------------------------------------------------
736783
* DefaultAccessibilitySubmenu
737784
* -----------------------------------------------------------------------------------------------*/
@@ -924,9 +971,9 @@ function DefaultMenuAudioGainSlider() {
924971
<span className="vds-menu-slider-value">{value}</span>
925972
</div>
926973
<div className="vds-menu-slider-group">
927-
<Icons.MuteButton.VolumeLow className="vds-icon" />
974+
<Icons.Menu.AudioBoostDown className="vds-icon" />
928975
<DefaultAudioGainSlider />
929-
<Icons.MuteButton.VolumeHigh className="vds-icon" />
976+
<Icons.Menu.AudioBoostUp className="vds-icon" />
930977
</div>
931978
</div>
932979
);
@@ -1001,60 +1048,12 @@ function DefaultAudioTracksSubmenu() {
10011048

10021049
DefaultAudioTracksSubmenu.displayName = 'DefaultAudioTracksSubmenu';
10031050

1004-
/* -------------------------------------------------------------------------------------------------
1005-
* DefaultSpeedSubmenu
1006-
* -----------------------------------------------------------------------------------------------*/
1007-
1008-
function DefaultSpeedSubmenu() {
1009-
const { icons: Icons, playbackRates } = useDefaultLayoutContext(),
1010-
label = useDefaultLayoutWord('Speed'),
1011-
normalText = useDefaultLayoutWord('Normal'),
1012-
options = usePlaybackRateOptions({
1013-
normalLabel: normalText,
1014-
rates: playbackRates,
1015-
}),
1016-
hint = options.selectedValue === '1' ? normalText : options.selectedValue + 'x';
1017-
1018-
if (options.disabled) return null;
1019-
1020-
return (
1021-
<Menu.Root className="vds-speed-menu vds-menu">
1022-
<DefaultSubmenuButton
1023-
label={label}
1024-
hint={hint}
1025-
disabled={options.disabled}
1026-
Icon={Icons.Menu.Speed}
1027-
/>
1028-
<Menu.Content className="vds-menu-items">
1029-
<Menu.RadioGroup
1030-
className="vds-speed-radio-group vds-radio-group"
1031-
value={options.selectedValue}
1032-
>
1033-
{options.map(({ label, value, select }) => (
1034-
<Menu.Radio
1035-
className="vds-speed-radio vds-radio"
1036-
value={value}
1037-
onSelect={select}
1038-
key={value}
1039-
>
1040-
<div className="vds-radio-check" />
1041-
<span className="vds-radio-label">{label}</span>
1042-
</Menu.Radio>
1043-
))}
1044-
</Menu.RadioGroup>
1045-
</Menu.Content>
1046-
</Menu.Root>
1047-
);
1048-
}
1049-
1050-
DefaultSpeedSubmenu.displayName = 'DefaultSpeedSubmenu';
1051-
10521051
/* -------------------------------------------------------------------------------------------------
10531052
* DefaultQualitySubmenu
10541053
* -----------------------------------------------------------------------------------------------*/
10551054

10561055
function DefaultQualitySubmenu() {
1057-
const { hideQualityBitrate, icons: Icons } = useDefaultLayoutContext(),
1056+
const { hideQualityBitrate } = useDefaultLayoutContext(),
10581057
label = useDefaultLayoutWord('Quality'),
10591058
autoText = useDefaultLayoutWord('Auto'),
10601059
options = useVideoQualityOptions({ auto: autoText, sort: 'descending' }),
@@ -1068,12 +1067,7 @@ function DefaultQualitySubmenu() {
10681067

10691068
return (
10701069
<Menu.Root className="vds-quality-menu vds-menu">
1071-
<DefaultSubmenuButton
1072-
label={label}
1073-
hint={hint}
1074-
disabled={options.disabled}
1075-
Icon={Icons.Menu.Quality}
1076-
/>
1070+
<DefaultSubmenuButton label={label} hint={hint} disabled={options.disabled} />
10771071
<Menu.Content className="vds-menu-items">
10781072
<Menu.RadioGroup
10791073
className="vds-quality-radio-group vds-radio-group"

packages/react/src/components/primitives/instances.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
SliderThumbnail,
3131
SliderValue,
3232
SliderVideo,
33+
SpeedSlider,
3334
Thumbnail,
3435
Time,
3536
TimeSlider,
@@ -67,6 +68,7 @@ export class SliderInstance extends Slider {}
6768
export class TimeSliderInstance extends TimeSlider {}
6869
export class VolumeSliderInstance extends VolumeSlider {}
6970
export class AudioGainSliderInstance extends AudioGainSlider {}
71+
export class SpeedSliderInstance extends SpeedSlider {}
7072
export class SliderThumbnailInstance extends SliderThumbnail {}
7173
export class SliderValueInstance extends SliderValue {}
7274
export class SliderVideoInstance extends SliderVideo {}

0 commit comments

Comments
 (0)