mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(react): port volume popover and slider styling into skin presets (#667)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ebb75f5863
commit
6a1eddadd0
+11
-1
@@ -60,7 +60,17 @@
|
||||
"noUnusedVariables": "warn",
|
||||
"noUnusedImports": "warn",
|
||||
"noUnusedFunctionParameters": "off",
|
||||
"useExhaustiveDependencies": "warn"
|
||||
"useExhaustiveDependencies": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"hooks": [
|
||||
{
|
||||
"name": "useForceRender",
|
||||
"stableResult": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "warn"
|
||||
|
||||
@@ -467,8 +467,6 @@
|
||||
/* Thumb */
|
||||
.media-minimal-skin .media-slider__thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill, 0%);
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
width: 0.75rem;
|
||||
@@ -489,12 +487,21 @@
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb[data-orientation="horizontal"] {
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill);
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb[data-orientation="vertical"] {
|
||||
left: 50%;
|
||||
top: calc(100% - var(--media-slider-fill));
|
||||
}
|
||||
.media-minimal-skin .media-slider__thumb:focus-visible {
|
||||
outline-color: oklch(1 0 0);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-minimal-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-minimal-skin .media-slider:focus-within .media-slider__thumb {
|
||||
.media-minimal-skin .media-slider:focus-within .media-slider__thumb,
|
||||
.media-minimal-skin .media-slider__thumb--persistent {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
@@ -503,11 +510,19 @@
|
||||
.media-minimal-skin .media-slider__buffer,
|
||||
.media-minimal-skin .media-slider__fill {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="horizontal"],
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
}
|
||||
.media-minimal-skin .media-slider__buffer[data-orientation="vertical"],
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Buffer */
|
||||
.media-minimal-skin .media-slider__buffer {
|
||||
@@ -519,7 +534,12 @@
|
||||
/* Fill */
|
||||
.media-minimal-skin .media-slider__fill {
|
||||
background-color: oklch(1 0 0);
|
||||
width: var(--media-slider-fill, 0%);
|
||||
}
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-fill);
|
||||
}
|
||||
.media-minimal-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
height: var(--media-slider-fill);
|
||||
}
|
||||
|
||||
/* Time display within slider */
|
||||
@@ -549,11 +569,14 @@
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-minimal-skin .media-popover-popup {
|
||||
.media-minimal-skin .media-popup {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
--media-popover-side-offset: 0.5rem;
|
||||
}
|
||||
.media-minimal-skin .media-popup--volume {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
|
||||
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-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 { MinimalVideoSkinProps } from './minimal-skin';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
@@ -123,6 +125,94 @@ function FullscreenButtonIcon({ state, className, ...rest }: { state: Fullscreen
|
||||
);
|
||||
}
|
||||
|
||||
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-[4.5rem]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
|
||||
{ className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-0.75',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-0.75 data-[orientation=vertical]:h-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderFill({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute rounded-[inherit] pointer-events-none bg-white',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0 data-[orientation=horizontal]:w-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0 data-[orientation=vertical]:h-(--media-slider-fill)',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persistent?: boolean }>(function SliderThumb(
|
||||
{ persistent, className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-10 absolute size-3 -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-white rounded-full',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
|
||||
// Visibility
|
||||
persistent
|
||||
? undefined
|
||||
: cn(
|
||||
'opacity-0 scale-70 origin-center',
|
||||
'group-hover/slider:opacity-100 group-hover/slider:scale-100',
|
||||
'group-focus-within/slider:opacity-100 group-focus-within/slider:scale-100'
|
||||
),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/* ------------------------------------------ Skin ------------------------------------------- */
|
||||
|
||||
export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNode {
|
||||
@@ -294,34 +384,12 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
/>
|
||||
</Time.Group>
|
||||
|
||||
<TimeSlider.Root
|
||||
className={cn(
|
||||
'group/slider relative flex items-center justify-center flex-1 rounded-full outline-none',
|
||||
'min-w-20 w-full h-5'
|
||||
)}
|
||||
>
|
||||
<TimeSlider.Track
|
||||
className={cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
'w-full h-0.75'
|
||||
)}
|
||||
>
|
||||
<TimeSlider.Fill className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white w-(--media-slider-fill)" />
|
||||
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
|
||||
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
|
||||
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
|
||||
<TimeSlider.Buffer className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white/20 w-(--media-slider-buffer) transition-[width] duration-250 ease-out" />
|
||||
</TimeSlider.Track>
|
||||
<TimeSlider.Thumb
|
||||
className={cn(
|
||||
'z-10 absolute top-1/2 left-(--media-slider-fill) -translate-x-1/2 -translate-y-1/2',
|
||||
'size-3 bg-white rounded-full origin-center',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'opacity-0 scale-70 transition-[opacity,scale,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-white focus-visible:outline-offset-2',
|
||||
'group-hover/slider:opacity-100 group-hover/slider:scale-100',
|
||||
'group-focus-within/slider:opacity-100 group-focus-within/slider:scale-100'
|
||||
)}
|
||||
/>
|
||||
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
|
||||
</TimeSlider.Root>
|
||||
</span>
|
||||
|
||||
@@ -334,13 +402,44 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Popover.Popup
|
||||
className={cn(
|
||||
// Reset & offset
|
||||
'm-0 border-0 bg-transparent [--media-popover-side-offset:0.5rem]',
|
||||
// Volume popup
|
||||
'p-1',
|
||||
// Animation
|
||||
'opacity-100 scale-100 origin-bottom blur-none',
|
||||
'transition-[transform,scale,opacity,filter] duration-200',
|
||||
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
|
||||
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
|
||||
'data-[instant]:duration-0'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
|
||||
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-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;
|
||||
@@ -178,13 +180,28 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
|
||||
@@ -482,8 +482,6 @@
|
||||
.media-default-skin .media-slider__thumb {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill);
|
||||
transform: translate(-50%, -50%);
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
@@ -501,16 +499,26 @@
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.media-default-skin .media-slider__thumb[data-orientation="horizontal"] {
|
||||
top: 50%;
|
||||
left: var(--media-slider-fill);
|
||||
}
|
||||
.media-default-skin .media-slider__thumb[data-orientation="vertical"] {
|
||||
left: 50%;
|
||||
top: calc(100% - var(--media-slider-fill));
|
||||
}
|
||||
.media-default-skin .media-slider__thumb:focus-visible {
|
||||
outline-color: oklch(62.3% 0.214 259.815);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-default-skin .media-slider:active .media-slider__thumb {
|
||||
.media-default-skin .media-slider:active .media-slider__thumb,
|
||||
.media-default-skin .media-slider__thumb--persistent {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.media-default-skin .media-slider:hover .media-slider__thumb,
|
||||
.media-default-skin .media-slider__thumb:focus-visible {
|
||||
.media-default-skin .media-slider__thumb:focus-visible,
|
||||
.media-default-skin .media-slider__thumb--persistent {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -518,24 +526,42 @@
|
||||
.media-default-skin .media-slider__buffer,
|
||||
.media-default-skin .media-slider__fill {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="horizontal"],
|
||||
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="vertical"],
|
||||
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Buffer */
|
||||
.media-default-skin .media-slider__buffer {
|
||||
background-color: oklch(1 0 0 / 0.2);
|
||||
width: var(--media-slider-buffer);
|
||||
transition: width 0.25s ease-out;
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-buffer);
|
||||
}
|
||||
.media-default-skin .media-slider__buffer[data-orientation="vertical"] {
|
||||
height: var(--media-slider-buffer);
|
||||
}
|
||||
|
||||
/* Fill */
|
||||
.media-default-skin .media-slider__fill {
|
||||
background-color: oklch(1 0 0);
|
||||
}
|
||||
.media-default-skin .media-slider__fill[data-orientation="horizontal"] {
|
||||
width: var(--media-slider-fill);
|
||||
}
|
||||
.media-default-skin .media-slider__fill[data-orientation="vertical"] {
|
||||
height: var(--media-slider-fill);
|
||||
}
|
||||
|
||||
/* Time display within slider */
|
||||
.media-default-skin .media-slider__time-display {
|
||||
@@ -564,10 +590,13 @@
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
|
||||
.media-default-skin .media-popover-popup {
|
||||
.media-default-skin .media-popup {
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.25rem;
|
||||
border: 0;
|
||||
--media-popover-side-offset: 0.5rem;
|
||||
}
|
||||
.media-default-skin .media-popup--volume {
|
||||
padding: 0.625rem 0.25rem;
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-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 { VideoSkinProps } from './skin';
|
||||
|
||||
const SEEK_TIME = 10;
|
||||
@@ -137,6 +139,94 @@ function FullscreenButtonIcon({ state, className, ...rest }: { state: Fullscreen
|
||||
);
|
||||
}
|
||||
|
||||
const SliderRoot = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderRoot({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group/slider relative flex flex-1 items-center justify-center rounded-full outline-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:min-w-20 data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-5',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-5 data-[orientation=vertical]:h-20',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SliderTrack = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderTrack(
|
||||
{ className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:w-full data-[orientation=horizontal]:h-1',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:w-1 data-[orientation=vertical]:h-full',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SliderFill = forwardRef<HTMLDivElement, ComponentProps<'div'>>(function SliderFill({ className, ...props }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute rounded-[inherit] pointer-events-none bg-white',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:inset-y-0 data-[orientation=horizontal]:left-0 data-[orientation=horizontal]:w-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:inset-x-0 data-[orientation=vertical]:bottom-0 data-[orientation=vertical]:h-(--media-slider-fill)',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persistent?: boolean }>(function SliderThumb(
|
||||
{ persistent, className, ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-10 absolute -translate-x-1/2 -translate-y-1/2',
|
||||
'bg-white rounded-full',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2',
|
||||
// Horizontal
|
||||
'data-[orientation=horizontal]:top-1/2 data-[orientation=horizontal]:left-(--media-slider-fill)',
|
||||
// Vertical
|
||||
'data-[orientation=vertical]:left-1/2 data-[orientation=vertical]:top-[calc(100%-var(--media-slider-fill))]',
|
||||
// Visibility
|
||||
persistent
|
||||
? 'size-3'
|
||||
: cn(
|
||||
'size-2.5',
|
||||
'opacity-0 focus-visible:opacity-100 group-hover/slider:opacity-100',
|
||||
'group-active/slider:size-3'
|
||||
),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/* ------------------------------------------ Skin ------------------------------------------- */
|
||||
|
||||
export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
@@ -301,34 +391,12 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
type="current"
|
||||
className="hidden @2xs/media-time:block text-shadow-2xs text-shadow-black/25 tabular-nums"
|
||||
/>
|
||||
<TimeSlider.Root
|
||||
className={cn(
|
||||
'group/slider relative flex items-center justify-center flex-1 rounded-full outline-none',
|
||||
'min-w-20 w-full h-5'
|
||||
)}
|
||||
>
|
||||
<TimeSlider.Track
|
||||
className={cn(
|
||||
'relative isolate overflow-hidden bg-white/20 rounded-[inherit]',
|
||||
'shadow-[0_0_0_1px_oklch(0_0_0/0.05)] select-none',
|
||||
'w-full h-1'
|
||||
)}
|
||||
>
|
||||
<TimeSlider.Fill className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white w-(--media-slider-fill)" />
|
||||
<TimeSlider.Root render={(props) => <SliderRoot {...props} />}>
|
||||
<TimeSlider.Track render={(props) => <SliderTrack {...props} />}>
|
||||
<TimeSlider.Fill render={(props) => <SliderFill {...props} />} />
|
||||
<TimeSlider.Buffer className="absolute inset-y-0 left-0 rounded-[inherit] pointer-events-none bg-white/20 w-(--media-slider-buffer) transition-[width] duration-250 ease-out" />
|
||||
</TimeSlider.Track>
|
||||
<TimeSlider.Thumb
|
||||
className={cn(
|
||||
'z-10 absolute top-1/2 left-(--media-slider-fill) -translate-x-1/2 -translate-y-1/2',
|
||||
'size-2.5 bg-white rounded-full',
|
||||
'ring ring-black/10 shadow-sm shadow-black/15',
|
||||
'opacity-0 transition-[opacity,height,width,outline-offset] duration-150 ease-out select-none',
|
||||
'outline-2 outline-transparent -outline-offset-2',
|
||||
'focus-visible:outline-blue-500 focus-visible:outline-offset-2 focus-visible:opacity-100',
|
||||
'group-hover/slider:opacity-100',
|
||||
'group-active/slider:size-3'
|
||||
)}
|
||||
/>
|
||||
<TimeSlider.Thumb render={(props) => <SliderThumb {...props} />} />
|
||||
</TimeSlider.Root>
|
||||
<Time.Value type="duration" className="text-shadow-2xs text-shadow-black/25 tabular-nums" />
|
||||
</Time.Group>
|
||||
@@ -341,13 +409,46 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button variant="icon" {...props}>
|
||||
<MuteButtonIcon state={state} className={icon} />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Popover.Popup
|
||||
className={cn(
|
||||
// Reset & offset
|
||||
'm-0 border-0 [--media-popover-side-offset:0.5rem]',
|
||||
// Volume popup
|
||||
'py-2.5 px-1 rounded-full',
|
||||
// Surface
|
||||
surface,
|
||||
// Animation
|
||||
'opacity-100 scale-100 origin-bottom blur-none',
|
||||
'transition-[transform,scale,opacity,filter] duration-200',
|
||||
'data-[starting-style]:opacity-0 data-[starting-style]:scale-0 data-[starting-style]:blur-[8px]',
|
||||
'data-[ending-style]:opacity-0 data-[ending-style]:scale-0 data-[ending-style]:blur-[8px]',
|
||||
'data-[instant]:duration-0'
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
|
||||
@@ -26,9 +26,11 @@ import { MuteButton } from '@/ui/mute-button';
|
||||
import { PiPButton } from '@/ui/pip-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;
|
||||
@@ -173,13 +175,28 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
)}
|
||||
/>
|
||||
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Popover.Root openOnHover delay={200} closeDelay={100} side="top">
|
||||
<Popover.Trigger
|
||||
render={
|
||||
<MuteButton
|
||||
render={(props, state) => (
|
||||
<Button {...props} className="media-button--icon">
|
||||
<MuteButtonIcon state={state} className="media-icon" />
|
||||
</Button>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<CaptionsButton
|
||||
render={(props, state) => (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from '@videojs/core/dom';
|
||||
import { useSnapshot } from '@videojs/store/react';
|
||||
import { isRTL } from '@videojs/utils/dom';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useForceRender } from '@/utils/use-force-render';
|
||||
import { useLatestRef } from '../../utils/use-latest-ref';
|
||||
|
||||
export interface UseSliderOptions<State extends SliderState = SliderState>
|
||||
@@ -57,6 +58,7 @@ export function useSlider<State extends SliderState = SliderState>(
|
||||
|
||||
const rootElementRef = useRef<HTMLElement | null>(null);
|
||||
const thumbElementRef = useRef<HTMLElement | null>(null);
|
||||
const forceRender = useForceRender();
|
||||
|
||||
// Lazy-init the slider handle. Stable across re-renders.
|
||||
const [slider] = useState<SliderHandle>(() => {
|
||||
@@ -82,6 +84,14 @@ export function useSlider<State extends SliderState = SliderState>(
|
||||
// Cleanup on unmount.
|
||||
useEffect(() => () => slider.destroy(), [slider]);
|
||||
|
||||
// Force a synchronous re-render after mount so edge thumb alignment
|
||||
// can read DOM measurements from the now-populated element refs.
|
||||
useLayoutEffect(() => {
|
||||
if (rootElementRef.current && thumbElementRef.current) {
|
||||
forceRender();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subscribe to interaction state.
|
||||
const interaction = useSnapshot(slider.interaction);
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useReducer } from 'react';
|
||||
|
||||
export function useForceRender() {
|
||||
const [, forceRender] = useReducer((c: number) => c + 1, 0);
|
||||
return forceRender;
|
||||
}
|
||||
|
||||
export namespace useForceRender {
|
||||
export type Result = () => void;
|
||||
}
|
||||
Reference in New Issue
Block a user