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 · lighticons@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 && <>−</>}
<HoverTime />
{!isLive && (
<>
/
<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>
)
}