diff --git a/biome.json b/biome.json index e5659257..3ed24a3d 100644 --- a/biome.json +++ b/biome.json @@ -60,7 +60,17 @@ "noUnusedVariables": "warn", "noUnusedImports": "warn", "noUnusedFunctionParameters": "off", - "useExhaustiveDependencies": "warn" + "useExhaustiveDependencies": { + "level": "warn", + "options": { + "hooks": [ + { + "name": "useForceRender", + "stableResult": true + } + ] + } + } }, "a11y": { "noSvgWithoutTitle": "warn" diff --git a/packages/react/src/presets/video/minimal-skin.css b/packages/react/src/presets/video/minimal-skin.css index 1ffe58bd..f9cfb5e4 100644 --- a/packages/react/src/presets/video/minimal-skin.css +++ b/packages/react/src/presets/video/minimal-skin.css @@ -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; } /* ========================================================================== diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index 550cbbf5..0208d829 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -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>(function SliderRoot({ className, ...props }, ref) { + return ( +
+ ); +}); + +const SliderTrack = forwardRef>(function SliderTrack( + { className, ...props }, + ref +) { + return ( +
+ ); +}); + +const SliderFill = forwardRef>(function SliderFill({ className, ...props }, ref) { + return ( +
+ ); +}); + +const SliderThumb = forwardRef & { persistent?: boolean }>(function SliderThumb( + { persistent, className, ...props }, + ref +) { + return ( +
+ ); +}); + /* ------------------------------------------ Skin ------------------------------------------- */ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNode { @@ -294,34 +384,12 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod /> - - - + }> + }> + } /> - + } /> @@ -334,13 +402,44 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod )} /> - ( - - )} - /> + + ( + + )} + /> + } + /> + + } + > + }> + } /> + + } /> + + + ( diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index edb9120a..6de868fd 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -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 { )} /> - ( - - )} - /> + + ( + + )} + {...props} + /> + } + /> + + + + + + + + + ( diff --git a/packages/react/src/presets/video/skin.css b/packages/react/src/presets/video/skin.css index 76d86af4..7ed7f10e 100644 --- a/packages/react/src/presets/video/skin.css +++ b/packages/react/src/presets/video/skin.css @@ -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); } diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 776204a6..8800308f 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -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>(function SliderRoot({ className, ...props }, ref) { + return ( +
+ ); +}); + +const SliderTrack = forwardRef>(function SliderTrack( + { className, ...props }, + ref +) { + return ( +
+ ); +}); + +const SliderFill = forwardRef>(function SliderFill({ className, ...props }, ref) { + return ( +
+ ); +}); + +const SliderThumb = forwardRef & { persistent?: boolean }>(function SliderThumb( + { persistent, className, ...props }, + ref +) { + return ( +
+ ); +}); + /* ------------------------------------------ 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" /> - - - + }> + }> + } /> - + } /> @@ -341,13 +409,46 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { )} /> - ( - - )} - /> + + ( + + )} + /> + } + /> + + } + > + }> + } /> + + } /> + + + ( diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index c2ddb470..a993e747 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -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 { )} /> - ( - - )} - /> + + ( + + )} + {...props} + /> + } + /> + + + + + + + + + ( diff --git a/packages/react/src/ui/hooks/use-slider.ts b/packages/react/src/ui/hooks/use-slider.ts index 3057992a..5201b363 100644 --- a/packages/react/src/ui/hooks/use-slider.ts +++ b/packages/react/src/ui/hooks/use-slider.ts @@ -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 @@ -57,6 +58,7 @@ export function useSlider( const rootElementRef = useRef(null); const thumbElementRef = useRef(null); + const forceRender = useForceRender(); // Lazy-init the slider handle. Stable across re-renders. const [slider] = useState(() => { @@ -82,6 +84,14 @@ export function useSlider( // 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); diff --git a/packages/react/src/utils/use-force-render.ts b/packages/react/src/utils/use-force-render.ts new file mode 100644 index 00000000..181151bc --- /dev/null +++ b/packages/react/src/utils/use-force-render.ts @@ -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; +}