feat(skin): add audio skins for HTML and React presets (#772)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Potts
2026-03-09 12:12:49 +11:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 038ad8b4f5
commit d751fdabea
125 changed files with 4174 additions and 2264 deletions
@@ -1,4 +1,6 @@
export { audioFeatures } from '@videojs/core/dom';
export { Audio, type AudioProps } from '@/media/audio';
export * from './minimal-skin';
export * from './minimal-skin.tailwind';
export * from './skin';
export * from './skin.tailwind';
@@ -1,3 +1 @@
.media-minimal-skin {
color: var(--media-color, red);
}
@import "@videojs/skins/minimal/css/audio.css";
@@ -0,0 +1,191 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { playbackRate } from '@videojs/skins/default/tailwind/audio.tailwind';
import {
button,
buttonGroup,
controls,
icon,
iconContainer,
iconFlipped,
iconState,
popup,
root,
seek,
slider,
time,
} from '@videojs/skins/minimal/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { MuteButton } from '@/ui/mute-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { MinimalAudioSkinProps } from './minimal-skin';
const SEEK_TIME = 10;
/* --------------------------------------- Components ---------------------------------------- */
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
{ className, variant, ...props },
ref
) {
return (
<button
ref={ref}
type="button"
className={cn(button.base, variant === 'icon' ? button.icon : button.default, className)}
{...props}
/>
);
});
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
return <div ref={ref} className={cn(slider.root, className)} {...props} />;
});
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
{ className, ...props },
ref
) {
return <div ref={ref} className={cn(slider.track, className)} {...props} />;
});
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: 'fill' | 'buffer' }>(function SliderFill(
{ type = 'fill', className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(slider.fill.base, type === 'fill' ? slider.fill.fill : slider.fill.buffer, className)}
{...props}
/>
);
});
const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persistent?: boolean }>(function SliderThumb(
{ persistent, className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(slider.thumb.base, persistent ? undefined : slider.thumb.interactive, className)}
{...props}
/>
);
});
/* ------------------------------------------ Skin ------------------------------------------- */
export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn(root, className)} {...rest}>
{children}
<div className={controls}>
<span className={buttonGroup}>
<PlayButton
render={(props) => (
<Button variant="icon" {...props} className={iconState.play.button}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</Button>
)}
/>
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={cn(icon, iconFlipped)} />
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
</span>
</Button>
)}
/>
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={icon} />
<span className={cn(seek.label, seek.labelForward)}>{SEEK_TIME}</span>
</span>
</Button>
)}
/>
</span>
<span className={time.controls}>
<Time.Group className={time.group}>
<Time.Value type="current" className={time.current} />
<Time.Separator className={time.separator} />
<Time.Value type="duration" className={time.duration} />
</Time.Group>
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
<TimeSlider.Buffer render={(props) => <SliderFill type="buffer" {...props} />} />
</TimeSlider.Track>
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
</TimeSlider.Root>
</span>
<span className={buttonGroup}>
<PlaybackRateButton
render={(props) => <Button variant="icon" {...props} className={playbackRate.button} />}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="left">
<Popover.Trigger
render={
<MuteButton
render={(props) => (
<Button variant="icon" {...props} className={iconState.mute.button}>
<VolumeOffIcon className={cn(icon, iconState.mute.volumeOff)} />
<VolumeLowIcon className={cn(icon, iconState.mute.volumeLow)} />
<VolumeHighIcon className={cn(icon, iconState.mute.volumeHigh)} />
</Button>
)}
/>
}
/>
<Popover.Popup className={cn(popup.base, popup.volume)}>
<VolumeSlider.Root
orientation="horizontal"
thumbAlignment="edge"
render={(props) => <SliderRoot {...props} />}
>
<VolumeSlider.Track render={(props) => <SliderTrack {...props} />}>
<VolumeSlider.Fill render={(props) => <SliderFill {...props} />} />
</VolumeSlider.Track>
<VolumeSlider.Thumb render={(props) => <SliderThumb persistent {...props} />} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
</span>
</div>
</Container>
);
}
@@ -1,8 +1,123 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { MuteButton } from '@/ui/mute-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { BaseSkinProps } from '../types';
const SEEK_TIME = 10;
export type MinimalAudioSkinProps = BaseSkinProps;
export function MinimalAudioSkin(props: MinimalAudioSkinProps) {
const { children } = props;
return <div>{children}</div>;
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button({ className, ...props }, ref) {
return <button ref={ref} type="button" className={cn('media-button', className)} {...props} />;
});
export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn('media-minimal-skin media-minimal-skin--audio', className)} {...rest}>
{children}
<div className="media-controls">
<span className="media-button-group">
<PlayButton
render={(props) => (
<Button {...props} className="media-button--icon media-button--play">
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</Button>
)}
/>
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek media-icon--flipped" />
<span className="media-icon__label">{SEEK_TIME}</span>
</span>
</Button>
)}
/>
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek" />
<span className="media-icon__label">{SEEK_TIME}</span>
</span>
</Button>
)}
/>
</span>
<span className="media-time-controls">
<Time.Group className="media-time">
<Time.Value type="current" className="media-time__value media-time__value--current" />
<Time.Separator className="media-time__separator" />
<Time.Value type="duration" className="media-time__value media-time__value--duration" />
</Time.Group>
<TimeSlider.Root className="media-slider">
<TimeSlider.Track className="media-slider__track">
<TimeSlider.Fill className="media-slider__fill" />
<TimeSlider.Buffer className="media-slider__buffer" />
</TimeSlider.Track>
<TimeSlider.Thumb className="media-slider__thumb" />
</TimeSlider.Root>
</span>
<span className="media-button-group">
<PlaybackRateButton
render={(props) => <Button {...props} className="media-button--icon media-button--playback-rate" />}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="left">
<Popover.Trigger
render={
<MuteButton
render={(props) => (
<Button {...props} className="media-button--icon media-button--mute">
<VolumeOffIcon className="media-icon media-icon--volume-off" />
<VolumeLowIcon className="media-icon media-icon--volume-low" />
<VolumeHighIcon className="media-icon media-icon--volume-high" />
</Button>
)}
/>
}
/>
<Popover.Popup className="media-surface media-popup media-popup--volume media-popup-animation">
<VolumeSlider.Root className="media-slider" orientation="horizontal" thumbAlignment="edge">
<VolumeSlider.Track className="media-slider__track">
<VolumeSlider.Fill className="media-slider__fill" />
</VolumeSlider.Track>
<VolumeSlider.Thumb className="media-slider__thumb media-slider__thumb--persistent" />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
</span>
</div>
</Container>
);
}
+1 -1
View File
@@ -1 +1 @@
@import "@videojs/skins/audio/default.css";
@import "@videojs/skins/default/css/audio.css";
@@ -0,0 +1,180 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import {
button,
controls,
icon,
iconContainer,
iconFlipped,
iconState,
playbackRate,
popup,
root,
seek,
slider,
time,
} from '@videojs/skins/default/tailwind/audio.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { MuteButton } from '@/ui/mute-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { AudioSkinProps } from './skin';
const SEEK_TIME = 10;
/* --------------------------------------- Components ---------------------------------------- */
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'> & { variant?: 'icon' }>(function Button(
{ className, variant, ...props },
ref
) {
return (
<button
ref={ref}
type="button"
className={cn(button.base, variant === 'icon' ? button.icon : button.default, className)}
{...props}
/>
);
});
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
return <div ref={ref} className={cn(slider.root, className)} {...props} />;
});
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
{ className, ...props },
ref
) {
return <div ref={ref} className={cn(slider.track, className)} {...props} />;
});
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'> & { type?: 'fill' | 'buffer' }>(function SliderFill(
{ type = 'fill', className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(slider.fill.base, type === 'fill' ? slider.fill.fill : slider.fill.buffer, className)}
{...props}
/>
);
});
const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persistent?: boolean }>(function SliderThumb(
{ persistent, className, ...props },
ref
) {
return (
<div
ref={ref}
className={cn(slider.thumb.base, persistent ? slider.thumb.persistent : slider.thumb.interactive, className)}
{...props}
/>
);
});
/* ------------------------------------------ Skin ------------------------------------------- */
export function AudioSkinTailwind(props: AudioSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn(root, className)} {...rest}>
{children}
<div className={controls}>
<PlayButton
render={(props) => (
<Button variant="icon" {...props} className={iconState.play.button}>
<RestartIcon className={cn(icon, iconState.play.restart)} />
<PlayIcon className={cn(icon, iconState.play.play)} />
<PauseIcon className={cn(icon, iconState.play.pause)} />
</Button>
)}
/>
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={cn(icon, iconFlipped)} />
<span className={cn(seek.label, seek.labelBackward)}>{SEEK_TIME}</span>
</span>
</Button>
)}
/>
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button variant="icon" {...props} className={seek.button}>
<span className={iconContainer}>
<SeekIcon className={icon} />
<span className={cn(seek.label, seek.labelForward)}>{SEEK_TIME}</span>
</span>
</Button>
)}
/>
<Time.Group className={time.group}>
<Time.Value type="current" className={time.current} />
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
<TimeSlider.Buffer render={(props) => <SliderFill type="buffer" {...props} />} />
</TimeSlider.Track>
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
</TimeSlider.Root>
<Time.Value type="duration" className={time.duration} />
</Time.Group>
<PlaybackRateButton render={(props) => <Button variant="icon" {...props} className={playbackRate.button} />} />
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props) => (
<Button variant="icon" {...props} className={iconState.mute.button}>
<VolumeOffIcon className={cn(icon, iconState.mute.volumeOff)} />
<VolumeLowIcon className={cn(icon, iconState.mute.volumeLow)} />
<VolumeHighIcon className={cn(icon, iconState.mute.volumeHigh)} />
</Button>
)}
/>
}
/>
<Popover.Popup className={cn(popup.base, popup.volume)}>
<VolumeSlider.Root
orientation="vertical"
thumbAlignment="edge"
render={(props) => <SliderRoot {...props} />}
>
<VolumeSlider.Track render={(props) => <SliderTrack {...props} />}>
<VolumeSlider.Fill render={(props) => <SliderFill {...props} />} />
</VolumeSlider.Track>
<VolumeSlider.Thumb render={(props) => <SliderThumb persistent {...props} />} />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
</div>
</Container>
);
}
+110 -3
View File
@@ -1,8 +1,115 @@
import {
PauseIcon,
PlayIcon,
RestartIcon,
SeekIcon,
VolumeHighIcon,
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
import { MuteButton } from '@/ui/mute-button';
import { PlayButton } from '@/ui/play-button';
import { PlaybackRateButton } from '@/ui/playback-rate-button';
import { Popover } from '@/ui/popover';
import { SeekButton } from '@/ui/seek-button';
import { Time } from '@/ui/time';
import { TimeSlider } from '@/ui/time-slider';
import { VolumeSlider } from '@/ui/volume-slider';
import type { BaseSkinProps } from '../types';
const SEEK_TIME = 10;
export type AudioSkinProps = BaseSkinProps;
export function AudioSkin(props: AudioSkinProps) {
const { children } = props;
return <div>{children}</div>;
const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button({ className, ...props }, ref) {
return <button ref={ref} type="button" className={cn('media-button', className)} {...props} />;
});
export function AudioSkin(props: AudioSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn('media-default-skin media-default-skin--audio', className)} {...rest}>
{children}
<div className="media-surface media-controls">
<PlayButton
render={(props) => (
<Button {...props} className="media-button--icon media-button--play">
<RestartIcon className="media-icon media-icon--restart" />
<PlayIcon className="media-icon media-icon--play" />
<PauseIcon className="media-icon media-icon--pause" />
</Button>
)}
/>
<SeekButton
seconds={-SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek media-icon--flipped" />
<span className="media-icon__label">{SEEK_TIME}</span>
</span>
</Button>
)}
/>
<SeekButton
seconds={SEEK_TIME}
render={(props) => (
<Button {...props} className="media-button--icon media-button--seek">
<span className="media-icon__container">
<SeekIcon className="media-icon media-icon--seek" />
<span className="media-icon__label">{SEEK_TIME}</span>
</span>
</Button>
)}
/>
<Time.Group className="media-time">
<Time.Value type="current" className="media-time__value" />
<TimeSlider.Root className="media-slider">
<TimeSlider.Track className="media-slider__track">
<TimeSlider.Fill className="media-slider__fill" />
<TimeSlider.Buffer className="media-slider__buffer" />
</TimeSlider.Track>
<TimeSlider.Thumb className="media-slider__thumb" />
</TimeSlider.Root>
<Time.Value type="duration" className="media-time__value" />
</Time.Group>
<PlaybackRateButton
render={(props) => <Button {...props} className="media-button--icon media-button--playback-rate" />}
/>
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
<Popover.Trigger
render={
<MuteButton
render={(props) => (
<Button {...props} className="media-button--icon media-button--mute">
<VolumeOffIcon className="media-icon media-icon--volume-off" />
<VolumeLowIcon className="media-icon media-icon--volume-low" />
<VolumeHighIcon className="media-icon media-icon--volume-high" />
</Button>
)}
/>
}
/>
<Popover.Popup className="media-surface media-popup media-popup--volume media-popup-animation">
<VolumeSlider.Root className="media-slider" orientation="vertical" thumbAlignment="edge">
<VolumeSlider.Track className="media-slider__track">
<VolumeSlider.Fill className="media-slider__fill" />
</VolumeSlider.Track>
<VolumeSlider.Thumb className="media-slider__thumb media-slider__thumb--persistent" />
</VolumeSlider.Root>
</Popover.Popup>
</Popover.Root>
</div>
</Container>
);
}
@@ -1 +1 @@
@import "@videojs/skins/background/default.css";
@import "@videojs/skins/default/css/background.css";
@@ -1 +1 @@
@import "@videojs/skins/video/minimal.css";
@import "@videojs/skins/minimal/css/video.css";
@@ -13,7 +13,7 @@ import {
VolumeLowIcon,
VolumeOffIcon,
} from '@videojs/icons/react/minimal';
import { playbackRate } from '@videojs/skins/video/default.tailwind';
import { playbackRate } from '@videojs/skins/default/tailwind/video.tailwind';
import {
bufferingIndicator,
button,
@@ -30,7 +30,7 @@ import {
seek,
slider,
time,
} from '@videojs/skins/video/minimal.tailwind';
} from '@videojs/skins/minimal/tailwind/video.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
@@ -120,7 +120,9 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
const { children, className, ...rest } = props;
return (
<Container className={cn(root, className)} {...rest}>
<Container className={cn(root(false), className)} {...rest}>
{children}
<BufferingIndicator
render={(props) => (
<div {...props} className={bufferingIndicator}>
@@ -257,8 +259,6 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
</div> */}
<div className={overlay} />
{children}
</Container>
);
}
@@ -54,7 +54,9 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn('media-minimal-skin', className)} {...rest}>
<Container className={cn('media-minimal-skin media-minimal-skin--video', className)} {...rest}>
{children}
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
@@ -185,8 +187,6 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
</div> */}
<div className="media-overlay" />
{children}
</Container>
);
}
+1 -1
View File
@@ -1 +1 @@
@import "@videojs/skins/video/default.css";
@import "@videojs/skins/default/css/video.css";
@@ -29,7 +29,7 @@ import {
seek,
slider,
time,
} from '@videojs/skins/video/default.tailwind';
} from '@videojs/skins/default/tailwind/video.tailwind';
import { cn } from '@videojs/utils/style';
import { type ComponentProps, forwardRef, type ReactNode } from 'react';
import { Container } from '@/player/context';
@@ -120,7 +120,9 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn(root, className)} {...rest}>
<Container className={cn(root(false), className)} {...rest}>
{children}
<BufferingIndicator
render={(props) => (
<div {...props} className={bufferingIndicator.root}>
@@ -249,8 +251,6 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
</div> */}
<div className={overlay} />
{children}
</Container>
);
}
+3 -3
View File
@@ -54,7 +54,9 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
const { children, className, ...rest } = props;
return (
<Container className={cn('media-default-skin', className)} {...rest}>
<Container className={cn('media-default-skin media-default-skin--video', className)} {...rest}>
{children}
<BufferingIndicator
render={(props) => (
<div {...props} className="media-buffering-indicator">
@@ -179,8 +181,6 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
</div> */}
<div className="media-overlay" />
{children}
</Container>
);
}