Skip to content

Video Player

A compact video result with controllable playback, seek position, and metadata.

Usage

import { VideoPlayer } from "@/components/ai/video-player"


export function Example() {
  return <VideoPlayer title="Generated demo" duration={74} currentTime={time} onSeek={setTime} />
}

Install

pnpm dlx shadcn@latest add @pastaui/video-player

Source

import { MaximizeIcon as Maximize2Icon } from '@solar-icons/react/linear/maximize'
import { PauseIcon } from '@solar-icons/react/linear/pause'
import { PlayIcon } from '@solar-icons/react/linear/play'
import { Rewind10SecondsBackIcon as RewindIcon } from '@solar-icons/react/linear/rewind-10-seconds-back'
import { Rewind10SecondsForwardIcon as ForwardIcon } from '@solar-icons/react/linear/rewind-10-seconds-forward'
import { VolumeLoudIcon as Volume2Icon } from '@solar-icons/react/linear/volume-loud'
import { VolumeCrossIcon as VolumeMutedIcon } from '@solar-icons/react/linear/volume-cross'
import * as React from 'react'


import { cn } from 'cn'


interface VideoPlayerProps extends Omit<
  React.ComponentProps<'section'>,
  'title' | 'onVolumeChange'
> {
  currentTime: number
  duration: number
  onPlayingChange?: (playing: boolean) => void
  onSeek?: (seconds: number) => void
  onVolumeChange?: (volume: number) => void
  playing?: boolean
  poster?: React.ReactNode
  subtitle?: string
  title: React.ReactNode
}


function formatTime(seconds: number) {
  const minutes = Math.floor(seconds / 60)
  return `${minutes}:${Math.floor(seconds % 60)
    .toString()
    .padStart(2, '0')}`
}


const controlSurface = 'bg-black/45'
const control =
  'flex shrink-0 items-center justify-center rounded-md transition-[background-color,scale] duration-150 ease-out hover:bg-black/65 active:scale-[0.96] motion-reduce:active:scale-100 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white'
const slider =
  'block cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-4 focus-visible:ring-offset-black/40 [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-moz-range-thumb]:size-2.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white'


function VideoPlayer({
  className,
  currentTime,
  duration,
  onPlayingChange,
  onSeek,
  onVolumeChange,
  playing = false,
  poster,
  subtitle,
  title,
  ...props
}: VideoPlayerProps) {
  const playerRef = React.useRef<HTMLElement>(null)
  const [volume, setVolume] = React.useState(0.7)
  const [fullscreenError, setFullscreenError] = React.useState('')
  const safeDuration = Math.max(0, duration)
  const time = Math.max(0, Math.min(currentTime, safeDuration))
  const progress = safeDuration ? (time / safeDuration) * 100 : 0


  function changeVolume(value: number) {
    setVolume(value)
    onVolumeChange?.(value)
  }


  async function toggleFullscreen() {
    try {
      if (document.fullscreenElement === playerRef.current) {
        await document.exitFullscreen()
      } else {
        await playerRef.current?.requestFullscreen()
      }
      setFullscreenError('')
    } catch {
      setFullscreenError('Fullscreen is unavailable in this browser.')
    }
  }


  return (
    <section
      ref={playerRef}
      data-slot="video-player"
      aria-label="Video player"
      className={cn(
        'relative isolate aspect-video min-h-64 overflow-hidden rounded-[16px] bg-neutral-950 font-sans text-sm font-[450] tracking-[-0.05px] text-white shadow-card sm:min-h-80',
        className,
      )}
      {...props}
    >
      <div className="absolute inset-0 -z-20 [&>*]:size-full [&>*]:object-cover">
        {poster ?? (
          <img
            alt="Sunlight falling over a forested mountain valley"
            className="object-cover"
            src="https://images.unsplash.com/photo-1472396961693-142e6e269027?auto=format&fit=crop&w=1600&q=85"
          />
        )}
      </div>
      <div className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-t from-black/75 via-black/10 to-black/25" />


      <div className="absolute inset-x-5 top-5 flex items-center justify-between gap-4 sm:inset-x-7 sm:top-6">
        <button
          aria-label="Toggle fullscreen"
          className={cn(controlSurface, control, 'size-8')}
          onClick={() => void toggleFullscreen()}
          type="button"
        >
          <Maximize2Icon className="size-4" />
        </button>
        <div className={cn(controlSurface, 'flex h-8 items-center gap-2 rounded-md px-3')}>
          <input
            aria-label="Volume"
            className={cn(slider, 'h-1 w-16')}
            max={1}
            min={0}
            onChange={(event) => changeVolume(Number(event.target.value))}
            step={0.01}
            style={{
              background: `linear-gradient(to right, white ${volume * 100}%, #ffffff40 ${volume * 100}%)`,
            }}
            type="range"
            value={volume}
          />
          <button
            aria-label={volume === 0 ? 'Unmute' : 'Mute'}
            className={cn(control, 'size-5')}
            onClick={() => changeVolume(volume === 0 ? 0.7 : 0)}
            type="button"
          >
            <span className="grid *:col-start-1 *:row-start-1 *:transition-[opacity,scale] *:duration-150 *:ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:*:transition-opacity">
              <Volume2Icon className={cn('size-4', volume === 0 && 'scale-75 opacity-0')} />
              <VolumeMutedIcon className={cn('size-4', volume !== 0 && 'scale-75 opacity-0')} />
            </span>
          </button>
        </div>
      </div>


      <div className="absolute inset-x-0 top-[45%] flex -translate-y-1/2 items-center justify-center gap-4">
        <button
          aria-label="Rewind 10 seconds"
          className={cn(controlSurface, control, 'size-9')}
          onClick={() => onSeek?.(Math.max(0, time - 10))}
          type="button"
        >
          <RewindIcon className="size-5" />
        </button>
        <button
          aria-label={playing ? 'Pause video' : 'Play video'}
          className={cn(controlSurface, control, 'size-12')}
          onClick={() => onPlayingChange?.(!playing)}
          type="button"
        >
          <span className="grid *:col-start-1 *:row-start-1 *:transition-[opacity,scale] *:duration-150 *:ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:*:transition-opacity">
            <PlayIcon
              className={cn('ml-0.5 size-6 fill-current', playing && 'scale-75 opacity-0')}
            />
            <PauseIcon className={cn('size-6 fill-current', !playing && 'scale-75 opacity-0')} />
          </span>
        </button>
        <button
          aria-label="Forward 10 seconds"
          className={cn(controlSurface, control, 'size-9')}
          onClick={() => onSeek?.(Math.min(safeDuration, time + 10))}
          type="button"
        >
          <ForwardIcon className="size-5" />
        </button>
      </div>


      <div className="absolute inset-x-5 bottom-5 sm:inset-x-7 sm:bottom-6">
        {subtitle && (
          <p className="mb-1 truncate text-xs leading-5 font-medium text-white/80 sm:text-sm">
            {subtitle}
          </p>
        )}
        <h3 className="truncate card-heading">{title}</h3>
        <input
          aria-label="Video progress"
          aria-valuetext={`${formatTime(time)} of ${formatTime(safeDuration)}`}
          className={cn(slider, 'mt-4 h-1 w-full')}
          max={safeDuration}
          min={0}
          onChange={(event) => onSeek?.(Number(event.target.value))}
          style={{
            background: `linear-gradient(to right, white ${progress}%, #ffffff40 ${progress}%)`,
          }}
          type="range"
          value={time}
        />
        <div className="mt-2 flex justify-between text-[10px] leading-5 font-medium text-white/70 tabular-nums sm:text-xs">
          <span>{formatTime(time)}</span>
          <span>{formatTime(safeDuration)}</span>
        </div>
        {fullscreenError && (
          <output className="mt-2 block text-xs leading-5">{fullscreenError}</output>
        )}
      </div>
    </section>
  )
}


export { VideoPlayer }
export type { VideoPlayerProps }


Props

Video Player props
PropTypeDefaultDescription
titleReactNode= —Labels the generated or retrieved video.
durationnumber= —Sets the duration in seconds.
currentTimenumber= —Controls the playhead position.
playingboolean= falseControls the playback state.
posterReactNode= —Renders a custom poster surface.