shadimage
limeplay
blockvideo-playersource: ok · hit

Video Player

Modern seamless flat video player

npx shadcn add "https://limeplay.winoffrg.dev/r/video-player.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
icons@phosphor-icons/react
ui-primitives@base-ui/react@radix-ui/react-toggle

Registry dependencies

https://bazza.dev/r/hit-areadropdown-menuhttps://limeplay.winoffrg.dev/r/player-layout.jsonhttps://limeplay.winoffrg.dev/r/media.jsonhttps://limeplay.winoffrg.dev/r/media-provider.jsonhttps://limeplay.winoffrg.dev/r/mute-control.jsonhttps://limeplay.winoffrg.dev/r/playback-control.jsonhttps://limeplay.winoffrg.dev/r/picture-in-picture-control.jsonhttps://limeplay.winoffrg.dev/r/timeline-control.jsonhttps://limeplay.winoffrg.dev/r/volume-control.jsonhttps://limeplay.winoffrg.dev/r/use-timeline.jsonhttps://limeplay.winoffrg.dev/r/use-volume.jsonhttps://limeplay.winoffrg.dev/r/use-player.jsonhttps://limeplay.winoffrg.dev/r/use-track-events.jsonhttps://limeplay.winoffrg.dev/r/utils.jsonhttps://limeplay.winoffrg.dev/r/error-screen.jsonhttps://limeplay.winoffrg.dev/r/fallback-poster.jsonhttps://limeplay.winoffrg.dev/r/timeline-labels.jsonhttps://limeplay.winoffrg.dev/r/limeplay-logo.jsonhttps://limeplay.winoffrg.dev/r/root-container.jsonhttps://limeplay.winoffrg.dev/r/captions.jsonhttps://limeplay.winoffrg.dev/r/use-captions.jsonhttps://limeplay.winoffrg.dev/r/playback-rate.jsonhttps://limeplay.winoffrg.dev/r/use-playback-rate.jsonhttps://limeplay.winoffrg.dev/r/use-playback.jsonhttps://limeplay.winoffrg.dev/r/use-picture-in-picture.jsonhttps://limeplay.winoffrg.dev/r/use-playlist.jsonhttps://limeplay.winoffrg.dev/r/use-asset.jsonhttps://limeplay.winoffrg.dev/r/use-media.jsonhttps://limeplay.winoffrg.dev/r/use-media-session.jsonhttps://limeplay.winoffrg.dev/r/use-controls-visibility.jsonhttps://limeplay.winoffrg.dev/r/use-playback-source.json

Files20 · source cached 10/8/2026, 6:00:40 PM

registry/default/blocks/video-player/player.tsx
"use client"

import React from "react"

import type { PlaybackSourceControllerProps } from "@/registry/default/hooks/use-playback-source"

import { cn } from "@/lib/utils"
import { BottomControls } from "@/registry/default/blocks/video-player/components/bottom-controls"
import { VideoMediaSessionController } from "@/registry/default/blocks/video-player/components/media-session-controller"
import { PlayerErrorScreen } from "@/registry/default/blocks/video-player/components/player-error-screen"
import {
  PlayerRootContainer,
  type PlayerRootContainerLayout,
} from "@/registry/default/blocks/video-player/components/player-root-container"
import { TopOverlayContainer } from "@/registry/default/blocks/video-player/components/top-overlay-container"
import { MediaProvider } from "@/registry/default/blocks/video-player/lib/media-kit"
import { type TAsset, useAsset } from "@/registry/default/hooks/use-asset"
import { PlaybackSourceController } from "@/registry/default/hooks/use-playback-source"
import { CaptionsContainer } from "@/registry/default/ui/captions"
import { FallbackPoster } from "@/registry/default/ui/fallback-poster"
import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo"
import { Media } from "@/registry/default/ui/media"
import * as Layout from "@/registry/default/ui/player-layout"

import "./styles.css"

export interface VideoPlayerAsset extends TAsset {
  description?: string
  poster?: string
  title?: string
  year?: string
}

export interface VideoPlayerProps extends PlaybackSourceControllerProps<VideoPlayerAsset> {
  children?: React.ReactNode
  className?: string
  debug?: boolean
  /**
   * Controls how the player frame gets its size.
   *
   * `aspect` keeps the default 16:9 box. `fill` lets a height-constrained
   * parent own the player size.
   *
   * @default "aspect"
   */
  layout?: PlayerRootContainerLayout
  /**
   * Props to pass to the underlying video element.
   */
  mediaProps?: Omit<React.VideoHTMLAttributes<HTMLVideoElement>, "as" | "src">
  /**
   * @default dark
   */
  theme?: "dark" | "light"
}

export const VideoPlayer = React.forwardRef<HTMLDivElement, VideoPlayerProps>(
  function VideoPlayer(
    {
      autoLoad,
      children,
      className,
      debug,
      initialIndex,
      layout = "aspect",
      loading,
      mediaProps,
      source,
      sourceKey,
      theme = "auto",
    },
    ref
  ) {
    return (
      <MediaProvider debug={debug}>
        <PlayerRootContainer
          className={cn(className, theme === "dark" && "dark")}
          layout={layout}
          ref={ref}
        >
          <PlaybackSourceController
            autoLoad={autoLoad}
            initialIndex={initialIndex}
            loading={loading}
            source={source}
            sourceKey={sourceKey}
          />
          <VideoMediaSessionController />
          <PlayerErrorScreen
            initialIndex={initialIndex}
            loading={loading}
            source={source}
            sourceKey={sourceKey}
          />
          <Layout.PlayerContainer className="size-full min-h-0">
            <FallbackPoster>
              <LimeplayLogo />
            </FallbackPoster>
            <CurrentAssetMedia {...(mediaProps ?? {})} />
            {children}
            <Layout.ControlsOverlayContainer
              className="
                bg-linear-[to_top,var(--lp-controls-fade-stops)] bg-size-[100%_80%] bg-top bg-no-repeat transition-opacity duration-300 ease-out
                group-data-[idle=true]/root:opacity-0
                group-data-[status=buffering]/root:opacity-100
                group-data-[status=error]/root:opacity-100
                group-data-[status=paused]/root:opacity-100
              "
            />
            <Layout.ControlsOverlayContainer
              className="
                bg-linear-[to_bottom,var(--lp-controls-fade-stops)] bg-size-[100%_45%] bg-bottom bg-no-repeat transition-opacity duration-300 ease-out
                group-data-[idle=true]/root:opacity-0
                group-data-[status=buffering]/root:opacity-100
                group-data-[status=error]/root:opacity-100
                group-data-[status=paused]/root:opacity-100
              "
            />
            <Layout.ControlsContainer
              className={`
                mx-auto w-full
                @3xl/root:max-w-6xl
                @5xl/root:max-w-[1728px]
              `}
            >
              <TopOverlayContainer
                className="
                  px-[5%] pt-[clamp(0.75rem,7svh,4rem)] transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]
                  group-data-[idle=false]/root:translate-y-0 group-data-[idle=false]/root:opacity-100
                  group-data-[idle=true]/root:-translate-y-4 group-data-[idle=true]/root:opacity-0
                  group-data-[status=buffering]/root:translate-y-0 group-data-[status=buffering]/root:opacity-100
                  group-data-[status=error]/root:translate-y-0 group-data-[status=error]/root:opacity-100
                  group-data-[status=paused]/root:translate-y-0 group-data-[status=paused]/root:opacity-100
                  @3xl/root:px-[min(80px,10%)]
                "
              />
              <CaptionsContainer
                className="
                  px-[5%] pb-[clamp(0.5rem,4svh,1.5rem)]
                  @3xl/root:px-[min(80px,10%)]
                "
              />
              <BottomControls
                className="
                  px-[5%] pb-[clamp(0.75rem,7svh,4rem)] transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]
                  group-data-[idle=false]/root:translate-y-0 group-data-[idle=false]/root:opacity-100
                  group-data-[idle=true]/root:translate-y-4 group-data-[idle=true]/root:opacity-0
                  group-data-[status=buffering]/root:translate-y-0 group-data-[status=buffering]/root:opacity-100
                  group-data-[status=error]/root:translate-y-0 group-data-[status=error]/root:opacity-100
                  group-data-[status=paused]/root:translate-y-0 group-data-[status=paused]/root:opacity-100
                  @3xl/root:px-[min(80px,10%)]
                "
              />
            </Layout.ControlsContainer>
          </Layout.PlayerContainer>
        </PlayerRootContainer>
      </MediaProvider>
    )
  }
)

VideoPlayer.displayName = "VideoPlayer"

// TODO: Fix this to FallbackPoster component with multisupport
function CurrentAssetMedia({
  className,
  poster,
  ...mediaProps
}: NonNullable<VideoPlayerProps["mediaProps"]>) {
  const { currentItem } = useAsset<VideoPlayerAsset>()
  const currentPoster = currentItem?.properties.poster

  return (
    <Media
      {...(mediaProps as React.ComponentPropsWithoutRef<typeof Media>)}
      as="video"
      className={cn("size-full bg-black object-contain", className)}
      poster={poster ?? currentPoster}
    />
  )
}
registry/default/blocks/video-player/styles.css
:root {
  --lp-timeline-track-height: 4px;
  --lp-timeline-track-height-active: 7px;

  --lp-controls-fade-stops:
    hsla(0, 0%, 91%, 0) 0%, hsla(0, 0%, 91%, 0.002) 10.6%,
    hsla(0, 0%, 91%, 0.008) 19.7%, hsla(0, 0%, 91%, 0.019) 27.6%,
    hsla(0, 0%, 91%, 0.035) 34.4%, hsla(0, 0%, 91%, 0.057) 40.4%,
    hsla(0, 0%, 91%, 0.086) 45.7%, hsla(0, 0%, 91%, 0.122) 50.6%,
    hsla(0, 0%, 91%, 0.165) 55.3%, hsla(0, 0%, 91%, 0.217) 60%,
    hsla(0, 0%, 91%, 0.278) 65%, hsla(0, 0%, 91%, 0.349) 70.3%,
    hsla(0, 0%, 91%, 0.43) 76.3%, hsla(0, 0%, 91%, 0.522) 83.1%,
    hsla(0, 0%, 91%, 0.625) 90.9%, hsla(0, 0%, 91%, 0.74) 100%;
}

.dark {
  --lp-controls-fade-stops:
    hsla(0, 0%, 0%, 0) 0%, hsla(0, 0%, 0%, 0.009) 8.1%,
    hsla(0, 0%, 0%, 0.035) 15.5%, hsla(0, 0%, 0%, 0.074) 22.5%,
    hsla(0, 0%, 0%, 0.125) 29%, hsla(0, 0%, 0%, 0.184) 35.3%,
    hsla(0, 0%, 0%, 0.25) 41.2%, hsla(0, 0%, 0%, 0.32) 47.1%,
    hsla(0, 0%, 0%, 0.39) 52.9%, hsla(0, 0%, 0%, 0.46) 58.8%,
    hsla(0, 0%, 0%, 0.526) 64.7%, hsla(0, 0%, 0%, 0.585) 71%,
    hsla(0, 0%, 0%, 0.636) 77.5%, hsla(0, 0%, 0%, 0.675) 84.5%,
    hsla(0, 0%, 0%, 0.701) 91.9%, hsla(0, 0%, 0%, 0.71) 100%;
}
registry/default/blocks/video-player/components/player-root-container.tsx
"use client"

import React from "react"

import type { RootContainerProps } from "@/registry/default/ui/root-container"

import { cn } from "@/lib/utils"
import { useControlsVisibility } from "@/registry/default/hooks/use-controls-visibility"
import { RootContainer } from "@/registry/default/ui/root-container"

export type PlayerRootContainerLayout = "aspect" | "fill"

export interface PlayerRootContainerProps extends RootContainerProps {
  /**
   * Controls how the player frame gets its size.
   *
   * `aspect` keeps the default aspect-ratio driven player box.
   * `fill` lets a height-constrained parent own the player box.
   */
  layout?: PlayerRootContainerLayout
}

export const PlayerRootContainer = React.forwardRef<
  HTMLDivElement,
  PlayerRootContainerProps
>(function PlayerRootContainer(props, ref) {
  const { aspectRatio, className, layout = "aspect", ...rootProps } = props
  const controlsVisibility = useControlsVisibility()

  return (
    <RootContainer
      aria-label="Video Player"
      {...rootProps}
      aspectRatio={layout === "fill" ? false : aspectRatio}
      className={cn(
        "m-auto max-h-full min-h-0",
        layout === "fill" ? "size-full" : "w-full",
        controlsVisibility.className,
        className
      )}
      onBlur={composeEventHandlers(
        rootProps.onBlur,
        controlsVisibility.rootProps.onBlur
      )}
      onFocus={composeEventHandlers(
        rootProps.onFocus,
        controlsVisibility.rootProps.onFocus
      )}
      onPointerEnter={composeEventHandlers(
        rootProps.onPointerEnter,
        controlsVisibility.rootProps.onPointerEnter
      )}
      onPointerLeave={composeEventHandlers(
        rootProps.onPointerLeave,
        controlsVisibility.rootProps.onPointerLeave
      )}
      onPointerMove={composeEventHandlers(
        rootProps.onPointerMove,
        controlsVisibility.rootProps.onPointerMove
      )}
      onPointerOver={composeEventHandlers(
        rootProps.onPointerOver,
        controlsVisibility.rootProps.onPointerOver
      )}
      onPointerUp={composeEventHandlers(
        rootProps.onPointerUp,
        controlsVisibility.rootProps.onPointerUp
      )}
      ref={ref}
    />
  )
})

PlayerRootContainer.displayName = "PlayerRootContainer"

function composeEventHandlers<TEvent extends React.SyntheticEvent>(
  userHandler: ((event: TEvent) => void) | undefined,
  internalHandler: ((event: TEvent) => void) | undefined
) {
  if (!userHandler) return internalHandler
  if (!internalHandler) return userHandler

  return (event: TEvent) => {
    userHandler(event)
    if (!event.defaultPrevented) internalHandler(event)
  }
}
registry/default/blocks/video-player/components/player-error-screen.tsx
"use client"

import { RotateCwIcon } from "lucide-react"
import React from "react"

import type { VideoPlayerAsset } from "@/registry/default/blocks/video-player/player"
import type {
  PlayerSource,
  UseAssetOptions,
} from "@/registry/default/hooks/use-asset"

import { Button } from "@/registry/default/blocks/video-player/components/button"
import { useAsset } from "@/registry/default/hooks/use-asset"
import { usePlaybackStore } from "@/registry/default/hooks/use-playback"
import { ErrorScreen } from "@/registry/default/ui/error-screen"

export interface PlayerErrorScreenProps {
  initialIndex?: number
  loading?: UseAssetOptions<VideoPlayerAsset>
  source?: PlayerSource<VideoPlayerAsset>
  sourceKey?: string
}

export function PlayerErrorScreen({
  initialIndex,
  loading,
  source,
  sourceKey,
}: PlayerErrorScreenProps) {
  const error = usePlaybackStore((s) => s.error)
  const status = usePlaybackStore((s) => s.status)
  const { currentItem, loadAsset, loadSource } = useAsset<VideoPlayerAsset>()

  const retryStream = React.useCallback(() => {
    if (currentItem) {
      void loadAsset(currentItem.properties)
      return
    }

    if (source === undefined) return

    loadSource(source, {
      initialIndex,
      loading,
      sourceKey,
    })
  }, [
    currentItem,
    initialIndex,
    loadAsset,
    loading,
    loadSource,
    source,
    sourceKey,
  ])

  if (status !== "error") return null

  return (
    <ErrorScreen className="z-10 rounded-lg" error={error}>
      {(currentItem || source !== undefined) && (
        <Button onClick={retryStream} size="sm">
          <RotateCwIcon />
          Retry
        </Button>
      )}
    </ErrorScreen>
  )
}
registry/default/blocks/video-player/components/top-overlay-container.tsx
"use client"

import type { VideoPlayerAsset } from "@/registry/default/blocks/video-player/player"

import { useAsset } from "@/registry/default/hooks/use-asset"
import { ControlsTopContainer } from "@/registry/default/ui/player-layout"

export interface TopOverlayContainerProps {
  className?: string
}

export function TopOverlayContainer({ className }: TopOverlayContainerProps) {
  const { currentItem } = useAsset<VideoPlayerAsset>()
  const asset = currentItem?.properties
  const description = asset?.description
  const title = asset?.title?.trim()

  if (!title && !description) return null

  return (
    <ControlsTopContainer className={className}>
      <div
        className="
          max-w-xs
          @3xl/root:max-w-md
        "
      >
        {title && (
          <h2
            className="
              line-clamp-2 text-sm/tight font-semibold tracking-normal text-balance text-foreground drop-shadow-[0_3px_18px_rgba(0,0,0,0.86)]
              @3xl/root:text-lg/tight
            "
          >
            {title}
          </h2>
        )}
        {description && (
          <p
            className="
              mt-1 line-clamp-1 max-w-sm text-xs/relaxed text-pretty text-foreground/80 drop-shadow-[0_2px_14px_rgba(0,0,0,0.78)]
              @3xl/root:line-clamp-2 @3xl/root:text-sm/relaxed
            "
          >
            {description}
          </p>
        )}
      </div>
    </ControlsTopContainer>
  )
}
registry/default/blocks/video-player/components/volume-state-control.tsx
"use client"

import {
  SpeakerHighIcon,
  SpeakerLowIcon,
  SpeakerXIcon,
} from "@phosphor-icons/react"

import { Button } from "@/registry/default/blocks/video-player/components/button"
import { useVolumeStore } from "@/registry/default/hooks/use-volume"
import { MuteControl } from "@/registry/default/ui/mute-control"

export function VolumeStateControl() {
  const muted = useVolumeStore((state) => state.muted)
  const volume = useVolumeStore((state) => state.level)

  return (
    <MuteControl asChild>
      <Button className="cursor-pointer" size="icon" variant="glass">
        {muted || volume === 0 ? (
          <SpeakerXIcon weight="fill" />
        ) : volume < 0.5 ? (
          <SpeakerLowIcon weight="fill" />
        ) : (
          <SpeakerHighIcon weight="fill" />
        )}
      </Button>
    </MuteControl>
  )
}
registry/default/blocks/video-player/components/playback-state-control.tsx
"use client"

import {
  CircleNotchIcon,
  PauseIcon,
  PlayIcon,
  RepeatIcon,
} from "@phosphor-icons/react"

import { Button } from "@/registry/default/blocks/video-player/components/button"
import { usePlaybackStore } from "@/registry/default/hooks/use-playback"
import { PlaybackControl } from "@/registry/default/ui/playback-control"

export function PlaybackStateControl() {
  const status = usePlaybackStore((state) => state.status)

  return (
    <PlaybackControl asChild>
      <Button className="cursor-pointer" size="icon" variant="glass">
        {status === "playing" ? (
          <PauseIcon weight="fill" />
        ) : status === "ended" ? (
          <RepeatIcon />
        ) : status === "buffering" || status === "loading" ? (
          <CircleNotchIcon className="animate-spin" weight="bold" />
        ) : (
          <PlayIcon weight="fill" />
        )}
      </Button>
    </PlaybackControl>
  )
}
registry/default/blocks/video-player/components/volume-slider-control.tsx
import { cn } from "@/lib/utils"
import * as VolumeSlider from "@/registry/default/ui/volume-control"

export function HorizontalVolumeSliderControl() {
  return (
    <VolumeSliderControlRoot
      className="
        hit-area-x-[12px] hit-area-y-[2px] relative h-1 w-14 opacity-100
        @3xl/root:hidden
      "
      orientation="horizontal"
    />
  )
}

export function VerticalVolumeSliderControl() {
  return (
    <VolumeSliderControlRoot
      className="
        hit-area-x-[8px] hit-area-y-[12px] absolute bottom-full left-1/2 mb-2 hidden h-16 w-1 -translate-x-1/2 pb-1 opacity-0
        @3xl/root:flex
        @3xl/root:group-focus-within:opacity-100
        @3xl/root:group-hover:opacity-100
      "
      orientation="vertical"
    />
  )
}

function VolumeSliderControlRoot({
  className,
  orientation,
}: {
  className?: string
  orientation: "horizontal" | "vertical"
}) {
  return (
    <VolumeSlider.Root
      className={cn(
        `hit-area z-10 cursor-crosshair rounded-md transition-opacity duration-300 ease-in-out`,
        className
      )}
      orientation={orientation}
    >
      <VolumeSlider.Track>
        <VolumeSlider.Progress />
      </VolumeSlider.Track>
      <VolumeSlider.Thumb className="size-2.5" />
    </VolumeSlider.Root>
  )
}
registry/default/blocks/video-player/components/timeline-slider-control.tsx
"use client"

import { useState } from "react"

import { Button } from "@/registry/default/blocks/video-player/components/button"
import { usePlayerStore } from "@/registry/default/hooks/use-player"
import { useTimelineStore } from "@/registry/default/hooks/use-timeline"
import * as TimelineSlider from "@/registry/default/ui/timeline-control"
import {
  Duration,
  Elapsed,
  HoverTime,
  LiveLatency,
  Remaining,
} from "@/registry/default/ui/timeline-labels"

const LIVE_DELAY_VISIBLE_SEC = 3

export function TimelineSliderControl() {
  const [showRemaining, setShowRemaining] = useState(false)
  const liveLatency = useTimelineStore((state) => state.liveLatency)
  const isLive = useTimelineStore((state) => state.isLive)
  const player = usePlayerStore((state) => state.instance)
  const hasLiveLatency = liveLatency != null
  const showGoToLive = hasLiveLatency && liveLatency >= LIVE_DELAY_VISIBLE_SEC
  const showLiveBadge = hasLiveLatency && liveLatency < LIVE_DELAY_VISIBLE_SEC

  return (
    <div
      className="
        me-auto flex grow items-center gap-1.5 select-none
        @3xl/root:gap-3
      "
    >
      {!isLive && <Elapsed className="px-2 text-xs font-medium" />}
      <div className="group/timeline relative w-full grow">
        <TimelineSlider.Root
          className={`
            group hit-area-x-[2px] hit-area-y-[14px] hit-area h-1 cursor-crosshair rounded-full transition-[height] duration-150 ease-in-out
            active:data-[orientation=horizontal]:h-(--lp-timeline-track-height-active)
          `}
        >
          <TimelineSlider.Track className="overflow-hidden rounded-full">
            <TimelineSlider.Progress className="rounded-s-full" />
            <TimelineSlider.Buffered variant="combined" />
          </TimelineSlider.Track>
          <TimelineSlider.Thumb
            className={`
              absolute h-8 w-px rounded-full bg-primary/60 opacity-0 transition-opacity duration-300
              group-hover/timeline:opacity-100
              group-active/timeline:bg-white
            `}
            showWithHover
          />
          <TimelineSlider.Thumb
            className={`
              top-auto! bottom-[calc(100%+16px)] flex h-auto w-fit bg-transparent text-xs font-medium opacity-0 transition-opacity duration-300
              group-hover/timeline:opacity-100
            `}
            showWithHover
          >
            {isLive && <>&minus;</>}
            <HoverTime />
            {!isLive && (
              <>
                &nbsp;/&nbsp;
                <Duration className="text-primary/60" />
              </>
            )}
          </TimelineSlider.Thumb>
        </TimelineSlider.Root>
      </div>
      {!isLive && (
        <button
          aria-label={
            showRemaining ? "Show total duration" : "Show remaining time"
          }
          aria-pressed={showRemaining}
          className="
            inline-flex h-7 w-[6ch] cursor-pointer items-center justify-center rounded-sm
            focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/50 focus-visible:outline-solid
          "
          onClick={() => {
            setShowRemaining(!showRemaining)
          }}
          tabIndex={0}
          type="button"
        >
          {showRemaining ? (
            <Remaining className="text-xs font-medium" />
          ) : (
            <Duration className="text-xs font-medium" />
          )}
        </button>
      )}
      {isLive && (
        <>
          {showGoToLive && player && (
            <>
              <LiveLatency className="text-xs font-medium" />
              <Button
                aria-label="Go to live"
                className="
                  cursor-pointer px-2
                  @md/root:px-2.5
                  @3xl/root:px-3
                "
                onClick={() => void player.goToLive()}
                size="sm"
                variant="glass"
              >
                <span className="text-xs font-medium text-primary">
                  Go to live
                </span>
              </Button>
            </>
          )}
          {showLiveBadge && (
            <div className="flex items-center rounded-full bg-red-600 px-2 py-0.5 text-xs font-semibold tracking-wide">
              <span className="tracking-widest">LIVE</span>
            </div>
          )}
        </>
      )}
    </div>
  )
}
registry/default/blocks/video-player/components/bottom-controls.tsx
import { CaptionsStateControl } from "@/registry/default/blocks/video-player/components/captions-state-control"
import { PictureInPictureControl } from "@/registry/default/blocks/video-player/components/pip-control"
import { PlaybackStateControl } from "@/registry/default/blocks/video-player/components/playback-state-control"
import { Playlist } from "@/registry/default/blocks/video-player/components/playlist"
import { PlaylistNextControl } from "@/registry/default/blocks/video-player/components/playlist-navigation-controls"
import { TimelineSliderControl } from "@/registry/default/blocks/video-player/components/timeline-slider-control"
import { VolumeGroupControl } from "@/registry/default/blocks/video-player/components/volume-group-control"
import { CONTROLS_FORCE_VISIBLE_ATTRIBUTE } from "@/registry/default/hooks/use-controls-visibility"
import * as Layout from "@/registry/default/ui/player-layout"

export interface BottomControlsProps {
  className?: string
}

export function BottomControls({ className }: BottomControlsProps) {
  return (
    <Layout.ControlsBottomContainer
      className={className}
      {...{ [CONTROLS_FORCE_VISIBLE_ATTRIBUTE]: "" }}
    >
      <div
        className="
          grid grid-cols-[1fr_auto] items-center gap-1
          @3xl/root:grid-cols-[1fr_3fr_1fr] @3xl/root:gap-2
        "
      >
        <div
          className="
            col-start-1 row-start-2 flex flex-row items-center gap-2 justify-self-start
            @3xl/root:col-start-1 @3xl/root:row-start-1
          "
        >
          <PlaybackStateControl />
          <PlaylistNextControl />
          <VolumeGroupControl />
        </div>

        <div
          className="
            col-span-2 col-start-1 row-start-1 w-full
            @3xl/root:col-span-1 @3xl/root:col-start-2 @3xl/root:row-start-1
          "
        >
          <TimelineSliderControl />
        </div>

        <div
          className="
            col-start-2 row-start-2 flex flex-row items-center gap-2 justify-self-end
            @3xl/root:col-start-3 @3xl/root:row-start-1
          "
        >
          <CaptionsStateControl />
          <PictureInPictureControl />
          <Playlist />
        </div>
      </div>
    </Layout.ControlsBottomContainer>
  )
}
registry/default/blocks/video-player/components/media-session-controller.tsx
"use client"

import * as React from "react"

import type { VideoPlayerAsset } from "@/registry/default/blocks/video-player/player"

import { useMediaApi } from "@/registry/default/blocks/video-player/lib/media-kit"
import { useAsset, useAssetStore } from "@/registry/default/hooks/use-asset"
import { useMediaStore } from "@/registry/default/hooks/use-media"
import {
  canMoveToNextMediaSessionTrack,
  canMoveToPreviousMediaSessionTrack,
  canStartMediaSessionPlayback,
  getMediaSessionCurrentTime,
  getMediaSessionMetadataValue,
  getMediaSessionPlaybackState,
  getMediaSessionPositionState,
  isMediaSessionPlaylistSource,
  useMediaSessionActionHandlers,
  useMediaSessionSync,
} from "@/registry/default/hooks/use-media-session"
import {
  canUsePictureInPicture,
  usePictureInPictureStore,
} from "@/registry/default/hooks/use-picture-in-picture"
import { usePlaybackStore } from "@/registry/default/hooks/use-playback"
import { usePlaybackRateStore } from "@/registry/default/hooks/use-playback-rate"
import { useTimelineStore } from "@/registry/default/hooks/use-timeline"

export function VideoMediaSessionController() {
  const mediaApi = useMediaApi()
  const { currentItem, hasNext, hasPrevious } = useAsset<VideoPlayerAsset>()
  const currentTime = useTimelineStore((state) => state.currentTime)
  const duration = useTimelineStore((state) => state.duration)
  const pictureInPictureSupported = usePictureInPictureStore(
    (state) => state.supported
  )
  const mediaElement = useMediaStore((state) => state.mediaElement)
  const playbackRate = usePlaybackRateStore((state) => state.value)
  const sourceType = useAssetStore((state) => state.sourceType)
  const status = usePlaybackStore((state) => state.status)
  const asset = currentItem?.properties ?? null
  const active = asset !== null
  const playlistSource = isMediaSessionPlaylistSource(sourceType)
  const canEnterPictureInPicture =
    active &&
    canUsePictureInPicture({
      mediaElement,
      supported: pictureInPictureSupported,
    })

  const metadata = React.useMemo(
    () => (asset ? getVideoMediaSessionMetadata(asset) : null),
    [asset]
  )
  const position = React.useMemo(
    () =>
      status === "loading"
        ? null
        : getMediaSessionPositionState({
            active,
            currentTime,
            duration,
            playbackRate,
          }),
    [active, currentTime, duration, playbackRate, status]
  )

  const actions = useMediaSessionActionHandlers({
    canEnterPictureInPicture,
    canGoNext: playlistSource && hasNext,
    canGoPrevious: playlistSource && hasPrevious,
    getCurrentTime: () => {
      const state = mediaApi.getState()

      return getMediaSessionCurrentTime({
        currentTime: state.timeline.currentTime,
        isLive: state.timeline.isLive,
        mediaElement: state.media.mediaElement,
      })
    },
    getSeekRange: () => {
      const state = mediaApi.getState()

      return state.timeline.isLive ? state.player.instance?.seekRange() : null
    },
    onEnterPictureInPicture: () => {
      void mediaApi.getState().pictureInPicture.enter()
    },
    onNextTrack: () => {
      const state = mediaApi.getState()
      if (
        canMoveToNextMediaSessionTrack(state.asset.sourceType, state.playlist)
      ) {
        state.playlist.next()
      }
    },
    onPause: () => {
      mediaApi.getState().playback.pause()
    },
    onPlay: () => {
      const state = mediaApi.getState()
      if (!canStartMediaSessionPlayback(state.playback.status)) return

      return state.playback.play()
    },
    onPreviousTrack: () => {
      const state = mediaApi.getState()
      if (
        canMoveToPreviousMediaSessionTrack(
          state.asset.sourceType,
          state.playlist
        )
      ) {
        state.playlist.previous()
      }
    },
    onSeek: (time) => {
      mediaApi.getState().timeline.seek(time)
    },
  })

  useMediaSessionSync({
    actions,
    active,
    claim: status === "playing",
    metadata,
    playbackState: getMediaSessionPlaybackState({ active, status }),
    position,
    positionKey: currentItem?.id ?? null,
  })

  return null
}

function getVideoMediaSessionMetadata(
  asset: VideoPlayerAsset
): MediaMetadataInit | null {
  const title = getMediaSessionMetadataValue(asset.title)
  const artist = getMediaSessionMetadataValue(asset.description, asset.year)
  const poster = getMediaSessionMetadataValue(asset.poster)

  if (!title && !artist && !poster) return null

  return {
    artist,
    artwork: poster ? [{ sizes: "512x512", src: poster }] : [],
    title,
  }
}
registry/default/blocks/video-player/components/button.tsx
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  `
    inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md text-xs font-medium whitespace-nowrap
    transition-[background-color,color,box-shadow,scale,backdrop-filter] duration-150 ease-out
    focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/50
    active:scale-[0.96]
    disabled:pointer-events-none disabled:opacity-50
    motion-reduce:transition-none
    motion-reduce:active:scale-100
    @md/root:gap-2 @md/root:text-sm
    [&_svg]:pointer-events-none [&_svg]:shrink-0
    [&_svg:not([class*='size-'])]:size-3.5
    @md/root:[&_svg:not([class*='size-'])]:size-4
    @3xl/root:[&_svg:not([class*='size-'])]:size-4.5
  `,
  {
    defaultVariants: {
      size: "default",
      variant: "default",
    },
    variants: {
      size: {
        default: `
          h-8 px-3 py-1.5
          has-[>svg]:px-2.5
          @md/root:h-9 @md/root:px-4 @md/root:py-2
          @md/root:has-[>svg]:px-3
          @3xl/root:h-10 @3xl/root:px-5
          @3xl/root:has-[>svg]:px-4
        `,
        icon: `
          size-7 rounded-lg p-1.5
          @md/root:size-8 @md/root:p-2
          @3xl/root:size-9
        `,
        lg: `
          h-9 rounded-md px-4
          has-[>svg]:px-3
          @md/root:h-10 @md/root:px-6
          @md/root:has-[>svg]:px-4
          @3xl/root:h-11 @3xl/root:px-7
          @3xl/root:has-[>svg]:px-5
        `,
        sm: `
          h-7 gap-1.5 rounded-md px-2.5 text-xs
          has-[>svg]:px-2
          @md/root:h-8 @md/root:px-3 @md/root:text-sm
          @md/root:has-[>svg]:px-2.5
          @3xl/root:h-9 @3xl/root:px-3.5
          @3xl/root:has-[>svg]:px-3
        `,
      },
      variant: {
        default: `
          bg-primary text-primary-foreground
          hover:bg-primary/90
        `,
        ghost: `hover:bg-accent hover:text-accent-foreground`,
        glass: `
          bg-transparent text-foreground
          hover:bg-foreground/10
          focus-visible:bg-background/20
        `,
        link: `
          text-primary underline-offset-4
          hover:underline
        `,
        outline: `
          border border-input bg-background
          hover:bg-accent hover:text-accent-foreground
        `,
        secondary: `
          bg-secondary text-secondary-foreground
          hover:bg-secondary/80
        `,
      },
    },
  }
)

export interface ButtonProps
  extends
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
  render?: React.ReactElement
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    { asChild = false, children, className, render, size, variant, ...props },
    ref
  ) => {
    const Comp = render ? Slot : asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ className, size, variant }))}
        data-slot="button"
        ref={ref}
        {...props}
      >
        {render ? React.cloneElement(render, undefined, children) : children}
      </Comp>
    )
  }
)

Button.displayName = "Button"

export { Button, buttonVariants }
registry/default/blocks/video-player/lib/media-kit.ts
"use client"

import { assetFeature } from "@/registry/default/hooks/use-asset"
import { captionsFeature } from "@/registry/default/hooks/use-captions"
import { mediaFeature } from "@/registry/default/hooks/use-media"
import { pictureInPictureFeature } from "@/registry/default/hooks/use-picture-in-picture"
import { playbackFeature } from "@/registry/default/hooks/use-playback"
import { playbackRateFeature } from "@/registry/default/hooks/use-playback-rate"
import { playerFeature } from "@/registry/default/hooks/use-player"
import { playlistFeature } from "@/registry/default/hooks/use-playlist"
import { timelineFeature } from "@/registry/default/hooks/use-timeline"
import { volumeFeature } from "@/registry/default/hooks/use-volume"
import { createMediaKit } from "@/registry/default/ui/media-provider"

export const media = createMediaKit({
  features: [
    mediaFeature(),
    playerFeature(),
    playbackFeature(),
    playlistFeature(),
    volumeFeature(),
    timelineFeature(),
    captionsFeature(),
    playbackRateFeature(),
    pictureInPictureFeature(),
    assetFeature(),
  ] as const,
})

export const MediaProvider = media.MediaProvider
export const useMediaApi = media.useMediaApi
export const useMediaEvents = media.useMediaEvents
registry/default/blocks/video-player/components/playlist.tsx
"use client"

import { CardsThreeIcon, PlayIcon } from "@phosphor-icons/react"
import { useEffect, useMemo } from "react"

import type { VideoPlayerAsset } from "@/registry/default/blocks/video-player/player"

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/registry/default/blocks/video-player/components/button"
import { useMediaStore } from "@/registry/default/hooks/use-media"
import { usePlayerStore } from "@/registry/default/hooks/use-player"
import { usePlaylistStore } from "@/registry/default/hooks/use-playlist"

export function Playlist() {
  const currentItem = usePlaylistStore(
    (state) =>
      state.currentItem as null | { id: string; properties: VideoPlayerAsset }
  )
  const containerRef = usePlayerStore((state) => state.containerRef)
  const setForceIdle = useMediaStore((state) => state.setForceIdle)
  const queue = usePlaylistStore(
    (state) => state.queue as { id: string; properties: VideoPlayerAsset }[]
  )
  const shuffle = usePlaylistStore((state) => state.shuffle)
  const shuffleOrder = usePlaylistStore((state) => state.shuffleOrder)
  const skipToId = usePlaylistStore((state) => state.skipToId)

  const orderedItems = useMemo(() => {
    if (!shuffle || shuffleOrder.length === 0) return queue

    return shuffleOrder
      .map((index) => queue[index])
      .filter((item): item is { id: string; properties: VideoPlayerAsset } =>
        Boolean(item)
      )
  }, [queue, shuffle, shuffleOrder])

  useEffect(() => {
    return () => {
      setForceIdle(false)
    }
  }, [setForceIdle])

  if (orderedItems.length < 2) return null

  const handleAssetSelect = async (assetId: string) => {
    await skipToId(assetId)
  }

  const dropdownCollisionProps: {
    collisionBoundary?: Element
    collisionPadding?: number
  } = {
    collisionBoundary: containerRef ?? undefined,
    collisionPadding: 12,
  }

  return (
    <DropdownMenu onOpenChange={setForceIdle}>
      <Button aria-label="Open Playlist" asChild size="icon" variant="glass">
        <DropdownMenuTrigger>
          <CardsThreeIcon weight="fill" />
        </DropdownMenuTrigger>
      </Button>
      <DropdownMenuContent
        align="end"
        alignOffset={-12}
        className="dark w-sm border border-border p-2"
        side="top"
        sideOffset={24}
        {...dropdownCollisionProps}
      >
        <DropdownMenuGroup>
          <DropdownMenuLabel>Playlist</DropdownMenuLabel>
          <DropdownMenuSeparator />
          <div className="space-y-1">
            {orderedItems.map((item) => {
              const asset = item.properties
              const isCurrentAsset = currentItem?.id === item.id

              return (
                <DropdownMenuItem
                  className={`
                    dark cursor-pointer rounded-md p-0 transition-colors
                    focus:bg-accent/50 focus:outline-none
                    ${
                      isCurrentAsset
                        ? "border-primary/20 bg-primary/10"
                        : "hover:bg-accent/50"
                    }
                  `}
                  key={item.id}
                  onClick={() => handleAssetSelect(item.id)}
                >
                  <div className="flex w-full items-center gap-3 p-2">
                    <div className="relative aspect-video w-20 shrink-0 overflow-hidden rounded-sm">
                      <img
                        alt={asset.title ?? "Playlist item poster"}
                        className="object-cover"
                        loading="lazy"
                        sizes="80px"
                        src={asset.poster}
                      />
                      {isCurrentAsset && (
                        <div className="absolute inset-0 flex items-center justify-center bg-black/40">
                          <PlayIcon
                            className="size-6 text-white"
                            weight="fill"
                          />
                        </div>
                      )}
                    </div>
                    <div className="min-w-0 flex-1">
                      <div className="flex w-full items-center gap-2">
                        <div className="min-w-0 flex-1 truncate text-sm font-medium">
                          {asset.title}
                        </div>
                        {isCurrentAsset && (
                          <div className="size-2 shrink-0 rounded-full bg-primary" />
                        )}
                        <span
                          className="ml-auto shrink-0 text-[11px] text-muted-foreground tabular-nums"
                          title={asset.year}
                        >
                          {asset.year}
                        </span>
                      </div>
                      {asset.description && (
                        <div className="line-clamp-2 text-[11px] text-muted-foreground">
                          {asset.description}
                        </div>
                      )}
                    </div>
                  </div>
                </DropdownMenuItem>
              )
            })}
          </div>
        </DropdownMenuGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
registry/default/blocks/video-player/components/playlist-navigation-controls.tsx
"use client"

import { SkipForwardIcon } from "@phosphor-icons/react"

import { Button } from "@/registry/default/blocks/video-player/components/button"
import {
  AssetSourceType,
  useAssetStore,
} from "@/registry/default/hooks/use-asset"
import { usePlaylistStore } from "@/registry/default/hooks/use-playlist"

export function PlaylistNextControl() {
  const next = usePlaylistStore((state) => state.next)
  const hasPlaylist = useAssetStore(
    (state) => state.sourceType === AssetSourceType.Playlist
  )
  const hasNext = usePlaylistStore((state) => {
    if (state.repeatMode === "all" && state.queue.length > 0) return true

    return state.getNextIndex() !== -1
  })

  if (!hasPlaylist) return null

  return (
    <Button
      aria-label="Next video"
      disabled={!hasNext}
      onClick={next}
      size="icon"
      variant="glass"
    >
      <SkipForwardIcon weight="fill" />
    </Button>
  )
}
registry/default/blocks/video-player/components/captions-state-control.tsx
"use client"

import { ClosedCaptioningIcon } from "@phosphor-icons/react"

import { Button } from "@/registry/default/blocks/video-player/components/button"
import { useCaptionsStore } from "@/registry/default/hooks/use-captions"
import { CaptionsControl } from "@/registry/default/ui/captions"

export function CaptionsStateControl() {
  const textTrackVisible = useCaptionsStore((state) => state.visible)

  return (
    <CaptionsControl asChild>
      <Button className="cursor-pointer" size="icon" variant="glass">
        <ClosedCaptioningIcon weight={textTrackVisible ? "fill" : "bold"} />
      </Button>
    </CaptionsControl>
  )
}
registry/default/blocks/video-player/components/playback-rate-control.tsx
"use client"

import React from "react"

import * as Select from "@/components/ui/select"
import * as PlaybackRate from "@/registry/default/ui/playback-rate"

import { Button } from "./button"

export function PlaybackRateControl() {
  return (
    <PlaybackRate.SelectRoot>
      <Button
        asChild
        className="
          border-none bg-transparent shadow-none
          hover:bg-foreground/10
          dark:bg-transparent dark:shadow-none
        "
        variant="glass"
      >
        <Select.SelectTrigger>
          <Select.SelectValue />
        </Select.SelectTrigger>
      </Button>
      <Select.SelectContent
        align="start"
        className={`min-w-28 border border-border backdrop-blur-lg`}
        side="top"
        sideOffset={12}
        {...({
          alignItemWithTrigger: false,
          position: "popper",
        } as React.ComponentProps<typeof Select.SelectContent>)}
      >
        <PlaybackRate.SelectGroup className={`tracking-wider`}>
          <Select.SelectLabel className="whitespace-nowrap">
            Playback Rate
          </Select.SelectLabel>
        </PlaybackRate.SelectGroup>
      </Select.SelectContent>
    </PlaybackRate.SelectRoot>
  )
}
registry/default/blocks/video-player/ui/toggle.tsx
"use client"

import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react"

import { cn } from "@/lib/utils"

const toggleVariants = cva(
  `
    inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow]
    hover:bg-muted
    focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground/50
    disabled:pointer-events-none disabled:opacity-50
    aria-invalid:border-destructive aria-invalid:ring-destructive/20
    data-[state=on]:bg-accent data-[state=on]:text-accent-foreground
    dark:aria-invalid:ring-destructive/40
    [&_svg]:pointer-events-none [&_svg]:shrink-0
    [&_svg:not([class*='size-'])]:size-4
  `,
  {
    defaultVariants: {
      size: "default",
      variant: "default",
    },
    variants: {
      size: {
        default: "h-9 min-w-9 px-2",
        lg: "h-10 min-w-10 px-2.5",
        sm: "h-8 min-w-8 px-1.5",
      },
      variant: {
        default: "bg-transparent",
        glass: `
          bg-transparent text-primary
          hover:bg-primary/10
          active:scale-[0.97]
          data-[state=on]:bg-transparent
          data-[state=on]:hover:bg-primary/10
        `,
        outline: `
          border border-input bg-transparent shadow-xs
          hover:bg-accent hover:text-accent-foreground
        `,
      },
    },
  }
)

function Toggle({
  className,
  size,
  variant,
  ...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
  VariantProps<typeof toggleVariants>) {
  return (
    <TogglePrimitive.Root
      className={cn(toggleVariants({ className, size, variant }))}
      data-slot="toggle"
      {...props}
    />
  )
}

export { Toggle, toggleVariants }
registry/default/blocks/video-player/components/volume-group-control.tsx
import { cn } from "@/lib/utils"
import {
  HorizontalVolumeSliderControl,
  VerticalVolumeSliderControl,
} from "@/registry/default/blocks/video-player/components/volume-slider-control"
import { VolumeStateControl } from "@/registry/default/blocks/video-player/components/volume-state-control"

export function VolumeGroupControl() {
  return (
    <div
      className={cn(`
        group relative isolate me-2 flex min-w-24 flex-row items-center gap-1.5 rounded-md pe-3
        @3xl/root:me-0 @3xl/root:min-w-0 @3xl/root:flex-col-reverse @3xl/root:gap-1 @3xl/root:pe-0
      `)}
    >
      <div
        aria-hidden
        className={cn(`
          pointer-events-none absolute inset-0 z-0 rounded-md opacity-0 transition-opacity duration-300 ease-in-out
          group-focus-within:bg-foreground/10 group-focus-within:opacity-100
          group-hover:bg-foreground/10 group-hover:opacity-100
          @3xl/root:-top-22
        `)}
      />
      <div className="relative z-10">
        <VolumeStateControl />
      </div>
      <HorizontalVolumeSliderControl />
      <VerticalVolumeSliderControl />
    </div>
  )
}
registry/default/blocks/video-player/components/pip-control.tsx
"use client"

import { PictureInPictureIcon } from "@phosphor-icons/react"

import { Button } from "@/registry/default/blocks/video-player/components/button"
import { usePictureInPictureStore } from "@/registry/default/hooks/use-picture-in-picture"
import { PictureInPictureControl as PictureInPictureControlPrimitive } from "@/registry/default/ui/picture-in-picture-control"

export function PictureInPictureControl() {
  const isPictureInPictureActive = usePictureInPictureStore(
    (state) => state.active
  )

  return (
    <PictureInPictureControlPrimitive asChild shortcut="P">
      <Button className="cursor-pointer" size="icon" variant="glass">
        <PictureInPictureIcon
          weight={isPictureInPictureActive ? "fill" : "bold"}
        />
      </Button>
    </PictureInPictureControlPrimitive>
  )
}

More from limeplay

Browse all components in this library →