volume-control

PreviousNext
Docs
limeplayui

Preview

Loading preview…
registry/default/ui/volume-control.tsx
"use client"

import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import React, { useImperativeHandle, useRef, useState } from "react"

import { cn } from "@/lib/utils"
import { MediaReadyState } from "@/registry/default/hooks/use-player"
import { useTrackEvents } from "@/registry/default/hooks/use-track-events"
import { useVolume } from "@/registry/default/hooks/use-volume"
import { useMediaStore } from "@/registry/default/ui/media-provider"

const VOLUME_RESET_BASE = 0.05

export type VolumeRootPropsDocs = Pick<
  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>,
  "disabled" | "orientation"
>

export const Root = React.forwardRef<
  HTMLDivElement,
  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>((props, ref) => {
  const internalRef = useRef<HTMLDivElement>(
    null
  ) as React.RefObject<HTMLDivElement>
  const { className, orientation = "horizontal", ...etc } = props
  const volume = useMediaStore((state) => state.volume)
  const hasAudio = useMediaStore((state) => state.hasAudio)
  const muted = useMediaStore((state) => state.muted)
  const readyState = useMediaStore((state) => state.readyState)
  const [currentValue, setCurrentValue] = useState(volume)

  const disabled = props.disabled || readyState < MediaReadyState.HAVE_METADATA

  useImperativeHandle(ref, () => internalRef.current)
  const { setVolume } = useVolume()

  const getVolumeFromEvent = (event: React.PointerEvent) => {
    const rect = event.currentTarget.getBoundingClientRect()
    let percentage: number

    if (orientation === "vertical") {
      percentage = 1 - (event.clientY - rect.top) / rect.height
    } else {
      percentage = (event.clientX - rect.left) / rect.width
    }

    return Math.max(0, Math.min(1, percentage))
  }

  const trackEvents = useTrackEvents({
    onPointerDown: (progress, event) => {
      if (disabled) return
      const newVolume = getVolumeFromEvent(event)
      setCurrentValue(newVolume)
      setVolume(newVolume)
    },
    onPointerMove: (progress, isPointerDown, event) => {
      if (disabled) return
      if (isPointerDown) {
        const newVolume = getVolumeFromEvent(event)
        setCurrentValue(newVolume)
        setVolume(newVolume)
      }
    },
    orientation,
  })

  if (!hasAudio) {
    return null
  }

  const currentVolumeValue = muted
    ? 0
    : currentValue === 0
      ? VOLUME_RESET_BASE
      : volume

  return (
    <SliderPrimitive.Root
      className={cn(
        "relative flex touch-none items-center justify-center select-none",
        className
      )}
      max={1}
      min={0}
      orientation={orientation}
      ref={internalRef}
      step={0.01}
      value={[currentVolumeValue]}
      {...trackEvents}
      {...etc}
      disabled={disabled}
    />
  )
})

Root.displayName = "VolumeRoot"

export const Track = React.forwardRef<
  HTMLDivElement,
  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Track>
>((props, ref) => {
  const { className, ...etc } = props

  return (
    <SliderPrimitive.Track
      className={cn(
        "relative size-full overflow-hidden rounded-md bg-primary/30",
        className
      )}
      ref={ref}
      {...etc}
    />
  )
})

Track.displayName = "VolumeTrack"

export const Progress = React.forwardRef<
  HTMLDivElement,
  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Track>
>((props, ref) => {
  const { className, ...etc } = props
  const volume = useMediaStore((state) => state.volume)
  const muted = useMediaStore((state) => state.muted)
  const currentValue = muted ? 0 : volume

  return (
    <SliderPrimitive.Indicator
      className={cn(
        `
          h-full w-(--lp-volume-value) bg-primary
          data-disabled:bg-primary/20
        `,
        "data-[orientation=vertical]:h-(--lp-volume-value) data-[orientation=vertical]:w-full",
        className
      )}
      ref={ref}
      style={
        {
          "--lp-volume-value": `${(currentValue * 100).toString()}%`,
        } as React.CSSProperties
      }
      {...etc}
    />
  )
})

Progress.displayName = "VolumeProgress"

export type VolumeThumbPropsDocs = Pick<ThumbProps, "showVolumeText">

interface ThumbProps
  extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Thumb> {
  /**
   * Whether to show volume percentage as aria text
   * @default true
   */
  showVolumeText?: boolean
}

export const Thumb = React.forwardRef<HTMLDivElement, ThumbProps>(
  (props, ref) => {
    const { className, showVolumeText = true, ...etc } = props
    const volume = useMediaStore((state) => state.volume)
    const displayValue = Number((volume * 100).toFixed(2))

    return (
      <SliderPrimitive.Thumb
        aria-label="Volume"
        aria-valuemax={100}
        aria-valuemin={0}
        aria-valuenow={displayValue}
        aria-valuetext={
          showVolumeText ? `${displayValue.toString()}% volume` : undefined
        }
        className={cn(
          `
            block size-2 rounded-full bg-primary
            focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/50
            data-disabled:bg-primary/85
          `,
          className
        )}
        ref={ref}
        {...etc}
      />
    )
  }
)

Thumb.displayName = "VolumeThumb"

Installation

npx shadcn@latest add @limeplay/volume-control

Usage

import { VolumeControl } from "@/components/ui/volume-control"
<VolumeControl />