limeplay
blockaudio-playersource: ok · hit
Audio Player
Compact audio player with playlist support
npx shadcn add "https://limeplay.winoffrg.dev/r/audio-player.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumicons@phosphor-icons/react
ui-primitives@radix-ui/react-slot
animationmotion
Registry dependencies
https://bazza.dev/r/hit-areahttps://limeplay.winoffrg.dev/r/media-provider.jsonhttps://limeplay.winoffrg.dev/r/media.jsonhttps://limeplay.winoffrg.dev/r/root-container.jsonhttps://limeplay.winoffrg.dev/r/timeline-control.jsonhttps://limeplay.winoffrg.dev/r/timeline-labels.jsonhttps://limeplay.winoffrg.dev/r/mute-control.jsonhttps://limeplay.winoffrg.dev/r/volume-control.jsonhttps://limeplay.winoffrg.dev/r/playback-control.jsondropdown-menutoggle-grouphttps://limeplay.winoffrg.dev/r/use-player.jsonhttps://limeplay.winoffrg.dev/r/use-playback.jsonhttps://limeplay.winoffrg.dev/r/use-volume.jsonhttps://limeplay.winoffrg.dev/r/use-timeline.jsonhttps://limeplay.winoffrg.dev/r/use-captions.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-playback-source.jsonhttps://limeplay.winoffrg.dev/r/use-media-session.jsonhttps://limeplay.winoffrg.dev/r/limeplay-logo.jsonhttps://limeplay.winoffrg.dev/r/utils.json
Files16 · source cached 10/14/2026, 10:00:26 PM
registry/default/blocks/audio-player/player.tsx
"use client"
import React from "react"
import type { TAsset } from "@/registry/default/hooks/use-asset"
import type { PlaybackSourceControllerProps } from "@/registry/default/hooks/use-playback-source"
import { cn } from "@/lib/utils"
import { AudioSourceProvider } from "@/registry/default/blocks/audio-player/components/audio-source"
import { PlayerControls } from "@/registry/default/blocks/audio-player/components/controls"
import { TimelineControl } from "@/registry/default/blocks/audio-player/components/fixed-timeline-control"
import { AudioMediaSessionController } from "@/registry/default/blocks/audio-player/components/media-session-controller"
import { MediaProvider } from "@/registry/default/blocks/audio-player/lib/media-kit"
import { Media } from "@/registry/default/ui/media"
import { RootContainer } from "@/registry/default/ui/root-container"
import "./styles.css"
export interface AudioPlayerAsset extends TAsset {
albumName?: string
artistName?: string
artwork?: {
templateUrl?: string
url?: string
}
description?: string
duration?: number
features?: string[]
genre?: string
group?: string
images?: {
backdrop?: string
poster?: string
}
name?: string
playbackUrls?: PlaybackUrls
poster?: string
releaseYear?: number | string
subtitle?: string
title?: string
year?: number | string
}
export interface AudioPlayerProps extends PlaybackSourceControllerProps<AudioPlayerAsset> {
children?: React.ReactNode
className?: string
debug?: boolean
/**
* Props to pass to the underlying audio element.
*/
mediaProps?: Omit<React.AudioHTMLAttributes<HTMLAudioElement>, "as" | "src">
/**
* @default dark
*/
theme?: "dark" | "light"
}
export interface PlaybackUrls {
primary: string
secondary?: string
}
export const AudioPlayer = React.forwardRef<HTMLDivElement, AudioPlayerProps>(
function AudioPlayer(
{
autoLoad,
children,
className,
debug,
initialIndex,
loading,
mediaProps,
source,
sourceKey,
theme = "dark",
},
ref
) {
const { className: mediaClassName, ...safeMediaProps } = mediaProps ?? {}
return (
<MediaProvider debug={debug}>
<AudioSourceProvider
autoLoad={autoLoad}
initialIndex={initialIndex}
loading={loading}
source={source}
sourceKey={sourceKey}
>
<AudioMediaSessionController />
<RootContainer
aria-label="Audio player"
aspectRatio={false}
className={cn(
"limeplay relative z-50 h-18 w-full border-t border-border bg-background",
theme === "dark" && "dark",
theme === "light" && "light",
className
)}
ref={ref}
>
<Media
{...(safeMediaProps as React.ComponentPropsWithoutRef<
typeof Media
>)}
as="audio"
className={mediaClassName}
/>
<TimelineControl />
<PlayerControls />
</RootContainer>
{children}
</AudioSourceProvider>
</MediaProvider>
)
}
)
AudioPlayer.displayName = "AudioPlayer"
registry/default/blocks/audio-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 { playbackFeature } from "@/registry/default/hooks/use-playback"
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(),
assetFeature(),
] as const,
})
export const MediaProvider = media.MediaProvider
export const useMediaApi = media.useMediaApi
registry/default/blocks/audio-player/components/audio-source.tsx
"use client"
import * as React from "react"
import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/player"
import type {
PlayerSource,
UseAssetOptions,
} from "@/registry/default/hooks/use-asset"
import { AssetRecoveryAction } from "@/registry/default/hooks/use-asset"
import { PlaybackSourceController } from "@/registry/default/hooks/use-playback-source"
export interface AudioAssetDisplayMetadata {
poster?: string
subtitle?: string
title: string
}
export interface AudioSourceProviderProps {
autoLoad?: boolean
children?: React.ReactNode
initialIndex?: number
loading?: UseAssetOptions<AudioPlayerAsset>
source?: PlayerSource<AudioPlayerAsset>
sourceKey?: string
}
interface RawPlaybackResponse {
expires_at: string
url: string
}
export function AudioSourceProvider({
autoLoad = true,
children,
initialIndex,
loading,
source,
sourceKey,
}: AudioSourceProviderProps) {
const resolvedLoading = React.useMemo<UseAssetOptions<AudioPlayerAsset>>(
() => ({
...loading,
getAssetId: (asset, context) =>
loading?.getAssetId?.(asset, context) ??
asset.id ??
asset.src ??
asset.playbackUrls?.primary,
recover: {
loadError: (_asset: AudioPlayerAsset, _error: unknown, { hasNext }) => {
return hasNext ? AssetRecoveryAction.Skip : AssetRecoveryAction.Stop
},
playbackError: async (
_asset: AudioPlayerAsset,
error: Error,
{ currentTime }: { currentTime: number }
) => {
if (isNetworkError(error)) {
return {
action: AssetRecoveryAction.Reload,
startTime: currentTime,
}
}
return { action: AssetRecoveryAction.Skip }
},
...loading?.recover,
},
resolveSource: async (context) => {
if (loading?.resolveSource) return loading.resolveSource(context)
const { asset, signal } = context
if (asset.src) {
return {
config: asset.config,
src: asset.src,
}
}
if (!asset.playbackUrls?.primary) {
throw new Error(
"AudioPlayerAsset requires src or playbackUrls.primary"
)
}
const src = await fetchPlaybackUrl(asset.playbackUrls.primary, signal)
if (signal.aborted) {
throw new DOMException(
"Playback source request aborted",
"AbortError"
)
}
return {
config: asset.config,
src,
}
},
}),
[loading]
)
return (
<>
<PlaybackSourceController
autoLoad={autoLoad}
initialIndex={initialIndex}
loading={resolvedLoading}
source={source}
sourceKey={sourceKey}
/>
{children}
</>
)
}
export function getAudioAssetMetadata(
asset: AudioPlayerAsset | null | undefined,
fallbackTitle = "Unknown Title"
): AudioAssetDisplayMetadata {
if (!asset) {
return {
title: fallbackTitle,
}
}
const releaseYear = asset.releaseYear ?? asset.year
const artistAlbum = joinDisplayParts([asset.artistName, asset.albumName])
const genreYear = joinDisplayParts([asset.genre, releaseYear])
const streamLabel = getStreamLabel(asset)
return {
poster:
firstNonEmpty(
asset.poster,
getArtworkUrl(asset.artwork),
asset.images?.poster,
asset.images?.backdrop
) ?? undefined,
subtitle:
firstNonEmpty(
artistAlbum,
genreYear,
asset.subtitle,
streamLabel,
asset.description
) ?? undefined,
title:
firstNonEmpty(
asset.title,
asset.name,
asset.albumName,
asset.description
) ?? fallbackTitle,
}
}
async function fetchPlaybackUrl(
playbackUrl: string,
signal?: AbortSignal
): Promise<string> {
const url = new URL(playbackUrl)
url.searchParams.set("raw", "true")
const response = await fetch(url.toString(), { signal })
if (!response.ok) {
throw new Error(`Failed to fetch playback URL: ${response.statusText}`)
}
const data: RawPlaybackResponse = await response.json()
return data.url
}
function firstNonEmpty(
...values: (null | number | string | undefined)[]
): string | undefined {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
if (typeof value !== "string") continue
const trimmed = value.trim()
if (trimmed) return trimmed
}
return undefined
}
function getArtworkUrl(
artwork: AudioPlayerAsset["artwork"]
): string | undefined {
const url = firstNonEmpty(artwork?.url, artwork?.templateUrl)
if (!url) return undefined
return url
.replaceAll("{w}", "80")
.replaceAll("{h}", "80")
.replaceAll("{f}", "jpg")
}
function getStreamLabel(asset: AudioPlayerAsset): string | undefined {
return joinDisplayParts([asset.group, ...(asset.features ?? [])])
}
function isNetworkError(error: unknown) {
return (
error &&
typeof error === "object" &&
"category" in error &&
(error as { category: number }).category === 1
)
}
function joinDisplayParts(
values: (null | number | string | undefined)[]
): string | undefined {
const parts = values
.map((value) => firstNonEmpty(value))
.filter((value): value is string => Boolean(value))
return parts.length > 0 ? parts.join(" • ") : undefined
}
registry/default/blocks/audio-player/components/controls.tsx
"use client"
import { ActionControls } from "@/registry/default/blocks/audio-player/components/action-controls"
import { TimeLabels } from "@/registry/default/blocks/audio-player/components/fixed-timeline-control"
import { PlaybackControls } from "@/registry/default/blocks/audio-player/components/playback-controls"
import {
RepeatControl,
ShuffleControl,
} from "@/registry/default/blocks/audio-player/components/playback-mode-controls"
import { Playlist } from "@/registry/default/blocks/audio-player/components/playlist"
import { TrackInfo } from "@/registry/default/blocks/audio-player/components/track-info"
import { VolumeControl } from "@/registry/default/blocks/audio-player/components/volume-group-control"
import {
AssetSourceType,
useAssetStore,
} from "@/registry/default/hooks/use-asset"
import { usePlaylistStore } from "@/registry/default/hooks/use-playlist"
export function PlayerControls() {
const sourceType = useAssetStore((state) => state.sourceType)
const queueLength = usePlaylistStore((state) => state.queue.length)
const isPlaylistMode =
sourceType === AssetSourceType.Playlist || queueLength > 1
return (
<div className="grid h-full grid-cols-3 items-center px-4">
<div className="flex items-center justify-start gap-4">
<PlaybackControls showNavigation={isPlaylistMode} />
<TimeLabels />
</div>
<div className="flex items-center justify-center gap-2">
<TrackInfo />
<ActionControls />
</div>
<div className="flex items-center justify-end gap-2">
<VolumeControl />
<RepeatControl variant={isPlaylistMode ? "playlist" : "asset"} />
{isPlaylistMode ? (
<>
<ShuffleControl />
<Playlist />
</>
) : null}
</div>
</div>
)
}
registry/default/blocks/audio-player/components/playback-controls.tsx
"use client"
import { CircleNotchIcon } from "@phosphor-icons/react"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/registry/default/blocks/audio-player/components/button"
import {
NextIcon,
PauseIcon,
PlayIcon,
PreviousIcon,
} from "@/registry/default/blocks/audio-player/components/icons"
import { usePlaybackStore } from "@/registry/default/hooks/use-playback"
import { RECOMMENDED_PLAYER_BUFFERING_THROTTLE_MS } from "@/registry/default/hooks/use-player"
import { usePlaylist } from "@/registry/default/hooks/use-playlist"
import { PlaybackControl } from "@/registry/default/ui/playback-control"
export function PlaybackControls({
showNavigation = true,
}: {
showNavigation?: boolean
}) {
const status = usePlaybackStore((state) => state.status)
const { hasNext, hasPrevious, next, previous } = usePlaylist()
const rawBuffering = status === "buffering" || status === "loading"
const { isBuffering, isPlaying } = useStablePlaybackState(
rawBuffering,
status,
RECOMMENDED_PLAYER_BUFFERING_THROTTLE_MS
)
return (
<div className="flex items-center gap-1">
{showNavigation ? (
<Button
aria-label="Previous track"
disabled={!hasPrevious}
onClick={previous}
>
<PreviousIcon aria-hidden="true" />
</Button>
) : null}
<PlaybackControl asChild>
<Button aria-label={isPlaying ? "Pause" : "Play"} size="xl">
<AnimatedIcon
id={isBuffering ? "loading" : isPlaying ? "pause" : "play"}
>
{isBuffering ? (
<CircleNotchIcon
aria-hidden="true"
className="animate-spin"
size={6}
/>
) : isPlaying ? (
<PauseIcon aria-hidden="true" />
) : (
<PlayIcon aria-hidden="true" className="relative left-px" />
)}
</AnimatedIcon>
</Button>
</PlaybackControl>
{showNavigation ? (
<Button aria-label="Next track" disabled={!hasNext} onClick={next}>
<NextIcon aria-hidden="true" />
</Button>
) : null}
</div>
)
}
function AnimatedIcon({
children,
id,
}: {
children: React.ReactNode
id: string
}) {
return (
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ filter: "blur(0px)", opacity: 1, scale: 1 }}
exit={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
initial={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
key={id}
transition={{ bounce: 0, duration: 0.3, type: "spring" }}
>
{children}
</motion.span>
</AnimatePresence>
)
}
function useStablePlaybackState(
isBuffering: boolean,
status: string,
delayMs: number
) {
const [showSpinner, setShowSpinner] = useState(false)
const timeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null)
const lastIntentRef = useRef<string>("paused")
useEffect(() => {
if (status === "playing" || status === "paused") {
lastIntentRef.current = status
}
}, [status])
useEffect(() => {
if (isBuffering && lastIntentRef.current === "playing") {
timeoutRef.current = setTimeout(() => setShowSpinner(true), delayMs)
} else {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setShowSpinner(false)
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
// DEV: The spinner from lastIntentRef.current, but this effect never reruns when status changes while isBuffering stays true. A pause during buffering can still show the delayed loading spinner because the old timeout is left in place.
}, [isBuffering, delayMs, status])
// During transient buffering, hold the last intent as the visual state
const stableIsPlaying = isBuffering
? lastIntentRef.current === "playing"
: status === "playing"
return { isBuffering: showSpinner, isPlaying: stableIsPlaying }
}
registry/default/blocks/audio-player/components/action-controls.tsx
"use client"
import { DotsThreeVerticalIcon } from "@phosphor-icons/react"
import { AnimatePresence, motion } from "motion/react"
import * as React from "react"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Button } from "@/registry/default/blocks/audio-player/components/button"
import {
DislikeIcon,
DislikeSelectedIcon,
LikeIcon,
LikeSelectedIcon,
} from "@/registry/default/blocks/audio-player/components/icons"
export function ActionControls() {
const [value, setValue] = React.useState<string>("")
const toggleGroupProps = {
onValueChange: (next: string | string[]) => {
setValue(Array.isArray(next) ? (next[0] ?? "") : next)
},
spacing: 2,
type: "single" as const,
value,
}
return (
<div className="flex items-center gap-1">
<ToggleGroup
{...(toggleGroupProps as unknown as React.ComponentProps<
typeof ToggleGroup
>)}
>
<ToggleGroupItem
aria-label="Dislike"
className={`
cursor-pointer rounded-full p-1.5
hover:bg-muted
data-[state=on]:bg-transparent
`}
value="dislike"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ filter: "blur(0px)", opacity: 1, scale: 1 }}
exit={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
initial={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
key={value === "dislike" ? "dislike-on" : "dislike-off"}
transition={{ bounce: 0, duration: 0.3, type: "spring" }}
>
<Button asChild size="sm">
{value === "dislike" ? (
<DislikeSelectedIcon aria-hidden="true" />
) : (
<DislikeIcon aria-hidden="true" />
)}
</Button>
</motion.span>
</AnimatePresence>
</ToggleGroupItem>
<ToggleGroupItem
aria-label="Like"
className={`
cursor-pointer rounded-full p-1.5
hover:bg-muted
data-[state=on]:bg-transparent
`}
value="like"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ filter: "blur(0px)", opacity: 1, scale: 1 }}
exit={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
initial={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
key={value === "like" ? "like-on" : "like-off"}
transition={{ bounce: 0, duration: 0.3, type: "spring" }}
>
<Button asChild size="sm">
{value === "like" ? (
<LikeSelectedIcon aria-hidden="true" />
) : (
<LikeIcon aria-hidden="true" />
)}
</Button>
</motion.span>
</AnimatePresence>
</ToggleGroupItem>
</ToggleGroup>
<Button aria-label="More actions">
<DotsThreeVerticalIcon aria-hidden="true" weight="bold" />
</Button>
</div>
)
}
registry/default/blocks/audio-player/components/playback-mode-controls.tsx
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useEffect, useMemo } from "react"
import type { RepeatMode } from "@/registry/default/hooks/use-playlist"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/default/blocks/audio-player/components/button"
import {
RepeatAllIcon,
RepeatIcon,
RepeatOneIcon,
ShuffleOffIcon,
ShuffleOneIcon,
} from "@/registry/default/blocks/audio-player/components/icons"
import {
usePlaylist,
usePlaylistStore,
} from "@/registry/default/hooks/use-playlist"
type RepeatControlVariant = "asset" | "playlist"
const REPEAT_MODES: Record<RepeatControlVariant, RepeatMode[]> = {
asset: ["off", "one"],
playlist: ["off", "all", "one"],
}
export function RepeatControl({
variant = "playlist",
}: {
variant?: RepeatControlVariant
}) {
const setRepeatMode = usePlaylistStore((state) => state.setRepeatMode)
const repeatMode = usePlaylistStore((state) => state.repeatMode)
const modes = REPEAT_MODES[variant]
const normalizedRepeatMode = useMemo(
() => normalizeRepeatMode(repeatMode, modes),
[modes, repeatMode]
)
const isActive = normalizedRepeatMode !== "off"
useEffect(() => {
if (repeatMode === normalizedRepeatMode) return
setRepeatMode(normalizedRepeatMode)
}, [normalizedRepeatMode, repeatMode, setRepeatMode])
const handleCycleRepeatMode = () => {
const modeIndex = modes.indexOf(normalizedRepeatMode)
const nextMode = modes[(modeIndex + 1) % modes.length] ?? "off"
setRepeatMode(nextMode)
}
return (
<Button
aria-label={`Repeat: ${normalizedRepeatMode}`}
aria-pressed={isActive}
className={cn({
"text-muted-foreground": !isActive,
})}
onClick={handleCycleRepeatMode}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ filter: "blur(0px)", opacity: 1, scale: 1 }}
className="flex items-center justify-center"
exit={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
initial={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
key={normalizedRepeatMode}
transition={{ bounce: 0, duration: 0.3, type: "spring" }}
>
{normalizedRepeatMode === "one" ? (
<RepeatOneIcon aria-hidden="true" />
) : normalizedRepeatMode === "off" ? (
<RepeatIcon aria-hidden="true" />
) : (
<RepeatAllIcon aria-hidden="true" />
)}
</motion.span>
</AnimatePresence>
</Button>
)
}
export function ShuffleControl() {
const { toggleShuffle } = usePlaylist()
const shuffle = usePlaylistStore((state) => state.shuffle)
const handleToggleShuffle = () => {
toggleShuffle()
}
return (
<Button
aria-label={shuffle ? "Shuffle on" : "Shuffle off"}
aria-pressed={shuffle}
className={cn({
"text-muted-foreground": !shuffle,
})}
onClick={handleToggleShuffle}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ filter: "blur(0px)", opacity: 1, scale: 1 }}
className="flex items-center justify-center"
exit={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
initial={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
key={shuffle ? "on" : "off"}
transition={{ bounce: 0, duration: 0.3, type: "spring" }}
>
{shuffle ? (
<ShuffleOneIcon aria-hidden="true" />
) : (
<ShuffleOffIcon aria-hidden="true" />
)}
</motion.span>
</AnimatePresence>
</Button>
)
}
function normalizeRepeatMode(
mode: RepeatMode,
modes: RepeatMode[]
): RepeatMode {
if (modes.includes(mode)) return mode
if (mode === "all" && modes.includes("one")) return "one"
return modes[0] ?? "off"
}
registry/default/blocks/audio-player/components/volume-group-control.tsx
"use client"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/default/blocks/audio-player/components/button"
import {
VolumeFullIcon,
VolumeMutedIcon,
} from "@/registry/default/blocks/audio-player/components/icons"
import { useVolumeStore } from "@/registry/default/hooks/use-volume"
import { MuteControl } from "@/registry/default/ui/mute-control"
import * as VolumeSlider from "@/registry/default/ui/volume-control"
export function VolumeControl() {
const muted = useVolumeStore((state) => state.muted)
const volume = useVolumeStore((state) => state.level)
const isMuted = muted || volume === 0
return (
<div className="group flex items-center gap-3">
<VolumeSlider.Root
className={cn(
"relative flex h-3 w-0 cursor-pointer touch-none items-center select-none",
`
transition-[width] duration-200 ease-out
group-hover:w-20
focus-within:w-20
`
)}
>
<VolumeSlider.Track className="relative h-0.75 w-full overflow-hidden rounded-sm bg-secondary">
<VolumeSlider.Progress className="absolute h-full bg-foreground" />
</VolumeSlider.Track>
<VolumeSlider.Thumb
className={cn(
"absolute size-3 rounded-full bg-foreground opacity-0 transition-opacity",
`
group-hover:opacity-100
focus-within:opacity-100
`
)}
/>
</VolumeSlider.Root>
<MuteControl asChild>
<Button
aria-label={muted ? "Unmute" : "Mute"}
className="relative text-muted-foreground"
title={muted ? "Unmute" : "Mute"}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ filter: "blur(0px)", opacity: 1, scale: 1 }}
className="flex items-center justify-center"
exit={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
initial={{ filter: "blur(4px)", opacity: 0, scale: 0.25 }}
key={isMuted ? "muted" : "unmuted"}
transition={{ bounce: 0, duration: 0.3, type: "spring" }}
>
{isMuted ? (
<VolumeMutedIcon aria-hidden="true" />
) : (
<VolumeFullIcon aria-hidden="true" />
)}
</motion.span>
</AnimatePresence>
</Button>
</MuteControl>
</div>
)
}
registry/default/blocks/audio-player/components/fixed-timeline-control.tsx
"use client"
import { cn } from "@/lib/utils"
import * as TimelineSlider from "@/registry/default/ui/timeline-control"
import {
Duration,
Elapsed,
HoverTime,
} from "@/registry/default/ui/timeline-labels"
export function TimeLabels() {
return (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Elapsed />
<span>/</span>
<Duration />
</div>
)
}
export function TimelineControl() {
return (
<div className="group/timeline absolute -top-2 flex h-4 w-full items-center pr-2">
<TimelineSlider.Root
className={cn(
`
flex w-full cursor-pointer touch-none items-center transition-[height] duration-150 select-none
group-hover/timeline:data-[orientation=horizontal]:h-1
`,
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
"hit-area-x-0 hit-area-y-[12px] hit-area"
)}
>
<TimelineSlider.Track className={`overflow-hidden bg-[#383838]`}>
<TimelineSlider.Buffered
className="absolute h-full bg-[#4C4C4C]"
variant="combined"
/>
<TimelineSlider.Progress className="absolute h-full bg-linear-to-r from-rose-600 from-80% to-pink-500" />
</TimelineSlider.Track>
<TimelineSlider.Thumb
className={cn(
"absolute top-1/2 size-0 -translate-y-1/2 rounded-full bg-rose-600!",
"transition-[height,width] duration-150 ease-in-out",
"group-hover/timeline:size-4"
)}
/>
<TimelineSlider.Thumb
className={`
pointer-events-none top-auto! bottom-2 flex h-auto w-fit rounded-sm bg-background/90! px-2 py-1 text-xs whitespace-nowrap text-foreground
opacity-0 transition-opacity duration-200
group-hover/timeline:opacity-100
`}
showWithHover
style={
{
"--lp-timeline-thumb-left":
"clamp(calc((8ch + 1rem) / 2), var(--lp-timeline-thumb-position), calc(100% - calc((8ch + 1rem) / 2)))",
} as unknown as React.CSSProperties
}
>
<HoverTime />
</TimelineSlider.Thumb>
</TimelineSlider.Root>
</div>
)
}
registry/default/blocks/audio-player/components/media-session-controller.tsx
"use client"
import * as React from "react"
import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/player"
import { getAudioAssetMetadata } from "@/registry/default/blocks/audio-player/components/audio-source"
import { useMediaApi } from "@/registry/default/blocks/audio-player/lib/media-kit"
import { useAsset, useAssetStore } from "@/registry/default/hooks/use-asset"
import {
canMoveToNextMediaSessionTrack,
canMoveToPreviousMediaSessionTrack,
canStartMediaSessionPlayback,
getMediaSessionCurrentTime,
getMediaSessionMetadataValue,
getMediaSessionPlaybackState,
getMediaSessionPositionState,
isMediaSessionPlaylistSource,
useMediaSessionActionHandlers,
useMediaSessionSync,
} from "@/registry/default/hooks/use-media-session"
import { usePlaybackStore } from "@/registry/default/hooks/use-playback"
import { useTimelineStore } from "@/registry/default/hooks/use-timeline"
export function AudioMediaSessionController() {
const mediaApi = useMediaApi()
const { currentItem, hasNext, hasPrevious } = useAsset<AudioPlayerAsset>()
const currentTime = useTimelineStore((state) => state.currentTime)
const duration = useTimelineStore((state) => state.duration)
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 metadata = React.useMemo(
() => (asset ? getAudioMediaSessionMetadata(asset) : null),
[asset]
)
const position = React.useMemo(
() =>
status === "loading"
? null
: getMediaSessionPositionState({
active,
currentTime,
duration,
}),
[active, currentTime, duration, status]
)
const actions = useMediaSessionActionHandlers({
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
},
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 getAudioMediaSessionArtwork(
asset: AudioPlayerAsset,
fallbackPoster: string | undefined
): MediaImage[] {
const templateUrl = getMediaSessionMetadataValue(asset.artwork?.templateUrl)
if (templateUrl) {
return [
{
sizes: "512x512",
src: templateUrl
.replaceAll("{w}", "512")
.replaceAll("{h}", "512")
.replaceAll("{f}", "jpg"),
},
]
}
const poster = getMediaSessionMetadataValue(
asset.poster,
asset.artwork?.url,
asset.images?.poster,
asset.images?.backdrop,
fallbackPoster
)
return poster ? [{ src: poster }] : []
}
function getAudioMediaSessionMetadata(
asset: AudioPlayerAsset
): MediaMetadataInit {
const metadata = getAudioAssetMetadata(asset)
const artwork = getAudioMediaSessionArtwork(asset, metadata.poster)
return {
album: getMediaSessionMetadataValue(asset.albumName),
artist: getMediaSessionMetadataValue(asset.artistName),
artwork,
title: metadata.title,
}
}
registry/default/blocks/audio-player/components/track-info.tsx
"use client"
import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/player"
import { getAudioAssetMetadata } from "@/registry/default/blocks/audio-player/components/audio-source"
import { useAsset } from "@/registry/default/hooks/use-asset"
import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo"
export function TrackInfo() {
const { currentItem } = useAsset<AudioPlayerAsset>()
const asset = currentItem?.properties
const metadata = getAudioAssetMetadata(asset)
return (
<div className="flex max-w-sm min-w-0 items-center gap-3">
{metadata.poster ? (
<div className="relative size-10 shrink-0 overflow-hidden rounded-sm bg-secondary outline-1 -outline-offset-1 outline-white/10">
<img
alt={`Album art for ${metadata.title}`}
className="size-full object-cover"
height={40}
src={metadata.poster}
width={40}
/>
</div>
) : (
<LimeplayLogo className="size-10" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm/snug font-medium text-secondary-foreground">
{metadata.title}
</div>
{metadata.subtitle && (
<div className="truncate text-xs/snug text-secondary-foreground/80">
{metadata.subtitle}
</div>
)}
</div>
</div>
)
}
registry/default/blocks/audio-player/components/playlist.tsx
"use client"
import { CardsThreeIcon, PlayIcon } from "@phosphor-icons/react"
import { XIcon } from "lucide-react"
import { type ReactNode, useCallback, useMemo, useState } from "react"
import type { AudioPlayerAsset } from "@/registry/default/blocks/audio-player/player"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { getAudioAssetMetadata } from "@/registry/default/blocks/audio-player/components/audio-source"
import { Button } from "@/registry/default/blocks/audio-player/components/button"
import { usePlayerStore } from "@/registry/default/hooks/use-player"
import { usePlaylistStore } from "@/registry/default/hooks/use-playlist"
import { LimeplayLogo } from "@/registry/default/ui/limeplay-logo"
const PLAYLIST_SIDE_OFFSET = 24
export function Playlist() {
const [open, setOpen] = useState(false)
const currentItem = usePlaylistStore(
(state) =>
state.currentItem as null | { id: string; properties: AudioPlayerAsset }
)
const preloadManagers = usePlayerStore((state) => state.preloadManagers)
const queue = usePlaylistStore(
(state) => state.queue as { id: string; properties: AudioPlayerAsset }[]
)
const repeatMode = usePlaylistStore((state) => state.repeatMode)
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.map((item, queueIndex) => ({ item, queueIndex }))
}
return shuffleOrder
.map((queueIndex) => ({
item: queue[queueIndex],
queueIndex,
}))
.filter(
(
entry
): entry is {
item: { id: string; properties: AudioPlayerAsset }
queueIndex: number
} => Boolean(entry.item)
)
}, [queue, shuffle, shuffleOrder])
const displayAssets = useMemo(
() =>
orderedItems.map(({ item, queueIndex }) => ({
asset: item.properties,
id: item.id,
queueIndex,
})),
[orderedItems]
)
const currentDisplayAsset = useMemo(() => {
if (!currentItem) return null
return (
displayAssets.find(({ id }) => id === currentItem.id) ?? {
asset: currentItem.properties,
id: currentItem.id,
queueIndex: queue.findIndex((item) => item.id === currentItem.id),
}
)
}, [currentItem, displayAssets, queue])
const currentDisplayIndex = useMemo(() => {
if (!currentItem) return -1
return displayAssets.findIndex(({ id }) => id === currentItem.id)
}, [currentItem, displayAssets])
const nextDisplayAssets = useMemo(() => {
if (!currentItem) return displayAssets
if (currentDisplayIndex === -1) return displayAssets
if (repeatMode === "all" && displayAssets.length > 1) {
return [
...displayAssets.slice(currentDisplayIndex + 1),
...displayAssets.slice(0, currentDisplayIndex),
]
}
return displayAssets.slice(currentDisplayIndex + 1)
}, [currentDisplayIndex, currentItem, displayAssets, repeatMode])
const playlistName = useMemo(
() => getPlaylistName(currentItem?.properties, queue),
[currentItem, queue]
)
const handleAssetSelect = useCallback(
(assetId: string) => {
skipToId(assetId)
},
[skipToId]
)
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<Button aria-label="Open Playlist" asChild>
<DropdownMenuTrigger>
<CardsThreeIcon weight="fill" />
</DropdownMenuTrigger>
</Button>
<DropdownMenuContent
align="end"
alignOffset={-8}
className="dark w-92 rounded-xl border border-border bg-popover p-0 shadow-xl ring-0 shadow-black/25"
side="top"
sideOffset={PLAYLIST_SIDE_OFFSET}
>
<div className="flex items-center justify-between px-4 pt-4 pb-5">
<h2 className="text-lg leading-none font-semibold tracking-normal text-foreground">
Queue
</h2>
<Button
aria-label="Close playlist"
className="
size-8 text-muted-foreground
hover:bg-muted hover:text-foreground
focus-visible:outline-offset-0
[&_svg]:size-5
"
onClick={() => setOpen(false)}
>
<XIcon strokeWidth={2} />
</Button>
</div>
<div
className={`relative max-h-100 scrollbar-none overflow-y-auto overscroll-contain px-4 pb-4`}
>
{displayAssets.length === 0 && (
<div className="py-14 text-center text-[13px] text-muted-foreground">
Queue is empty
</div>
)}
{displayAssets.length > 0 && (
<div className="space-y-4">
{currentDisplayAsset && (
<section aria-labelledby="audio-playlist-now-playing">
<SectionHeading id="audio-playlist-now-playing">
Now playing
</SectionHeading>
<TrackRow
asset={currentDisplayAsset.asset}
index={0}
isActive
key={currentDisplayAsset.id}
onSelect={() => handleAssetSelect(currentDisplayAsset.id)}
preloaded={preloadManagers.has(currentDisplayAsset.id)}
setSize={1}
showArtworkOverlay={false}
/>
</section>
)}
<section aria-labelledby="audio-playlist-next-from">
<SectionHeading id="audio-playlist-next-from">
Next From: {playlistName}
</SectionHeading>
{nextDisplayAssets.length === 0 ? (
<div className="rounded-lg px-3 py-6 text-center text-[13px] text-muted-foreground">
Nothing queued next
</div>
) : (
<div className="space-y-1">
{nextDisplayAssets.map(({ asset, id }, index) => (
<TrackRow
asset={asset}
index={index}
isActive={currentItem?.id === id}
key={id}
onSelect={() => handleAssetSelect(id)}
preloaded={preloadManagers.has(id)}
setSize={nextDisplayAssets.length}
/>
))}
</div>
)}
</section>
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
function firstNonEmpty(
...values: (null | number | string | undefined)[]
): string | undefined {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
if (typeof value !== "string") continue
const trimmed = value.trim()
if (trimmed) return trimmed
}
return undefined
}
function formatDuration(ms?: number) {
if (typeof ms !== "number" || !Number.isFinite(ms)) return "--:--"
const secs = Math.floor(ms / 1000)
const m = Math.floor(secs / 60)
const s = secs % 60
return `${m}:${String(s).padStart(2, "0")}`
}
function getAssetPlaylistName(
asset: AudioPlayerAsset | null | undefined
): string | undefined {
if (!asset) return undefined
const assetWithPlaylist = asset as AudioPlayerAsset & {
collectionName?: string
playlistName?: string
playlistTitle?: string
sourceName?: string
}
return firstNonEmpty(
assetWithPlaylist.playlistName,
assetWithPlaylist.playlistTitle,
assetWithPlaylist.collectionName,
assetWithPlaylist.sourceName,
asset.group
)
}
function getPlaylistName(
currentAsset: AudioPlayerAsset | null | undefined,
queue: { id: string; properties: AudioPlayerAsset }[]
) {
return (
getAssetPlaylistName(currentAsset) ??
queue.map((item) => getAssetPlaylistName(item.properties)).find(Boolean) ??
"Queue"
)
}
function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
return (
<h3
className="px-0 pb-2 text-[15px] leading-none font-semibold tracking-normal text-foreground"
id={id}
>
{children}
</h3>
)
}
function TrackRow({
asset,
index,
isActive,
onSelect,
preloaded,
setSize,
showArtworkOverlay = true,
}: {
asset: AudioPlayerAsset
index: number
isActive: boolean
onSelect: () => void
preloaded: boolean
setSize: number
showArtworkOverlay?: boolean
}) {
const metadata = getAudioAssetMetadata(asset, "Untitled track")
return (
<Button
aria-posinset={index + 1}
aria-setsize={setSize}
className={cn(
`
group/track h-auto w-full cursor-pointer justify-start gap-3 rounded-lg bg-transparent px-3 py-2.5 text-left transition-[background-color]
duration-150 ease-out
hover:bg-muted
focus-visible:outline-offset-0
`,
isActive
? `
bg-accent/70
hover:bg-accent/70
`
: ""
)}
data-active={isActive ? "true" : undefined}
onClick={onSelect}
>
<div className="relative size-10 shrink-0 overflow-hidden rounded-md bg-muted outline-1 -outline-offset-1 outline-white/10">
{metadata.poster && (
<img
alt=""
className="absolute inset-0 size-full object-cover"
src={metadata.poster}
/>
)}
{!metadata.poster && (
<div
aria-hidden="true"
className="absolute inset-0 flex items-center justify-center text-muted-foreground"
>
<LimeplayLogo className="size-5" />
</div>
)}
{showArtworkOverlay && (
<div
className={cn(
"absolute inset-0 flex items-center justify-center transition-[background-color] duration-150 ease-out",
isActive
? "bg-black/50"
: `
bg-black/0
group-hover/track:bg-black/45
`
)}
>
<PlayIcon
className={cn(
"size-4 text-white drop-shadow-sm transition-[opacity,transform] duration-150 ease-out",
isActive
? "scale-100 opacity-100"
: `
scale-50 opacity-0
group-hover/track:scale-100 group-hover/track:opacity-100
`
)}
weight="fill"
/>
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p
className={cn(
"truncate text-[13px] leading-snug transition-[color] duration-150 ease-out",
isActive
? "font-medium text-foreground"
: `
text-foreground/75
group-hover/track:text-foreground
`
)}
>
{metadata.title}
</p>
{preloaded && !isActive && (
<span
aria-label="Preloaded"
className="size-1.5 shrink-0 rounded-full bg-foreground/35"
/>
)}
</div>
{metadata.subtitle && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
{metadata.subtitle}
</p>
)}
</div>
<span className="ml-auto shrink-0 pl-2 text-[11px] text-muted-foreground tabular-nums">
{formatDuration(asset.duration)}
</span>
</Button>
)
}
registry/default/blocks/audio-player/components/icons.tsx
import type { SVGProps } from "react"
export function DislikeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="m11.31 2 .392.007c1.824.06 3.61.534 5.223 1.388l.343.189.27.154c.264.152.56.24.863.26l.13.004H20.5a1.5 1.5 0 011.5 1.5V11.5a1.5 1.5 0 01-1.5 1.5h-1.79l-.158.013a1 1 0 00-.723.512l-.064.145-2.987 8.535a1 1 0 01-1.109.656l-1.04-.174a4 4 0 01-3.251-4.783L10 15H5.938a3.664 3.664 0 01-3.576-2.868A3.682 3.682 0 013 9.15l-.02-.088A3.816 3.816 0 014 5.5v-.043l.008-.227a2.86 2.86 0 01.136-.664l.107-.28A3.754 3.754 0 017.705 2h3.605ZM7.705 4c-.755 0-1.425.483-1.663 1.2l-.032.126a.818.818 0 00-.01.131v.872l-.587.586a1.816 1.816 0 00-.524 1.465l.038.23.02.087.21.9-.55.744a1.686 1.686 0 00-.321 1.18l.029.177c.17.76.844 1.302 1.623 1.302H10a2.002 2.002 0 011.956 2.419l-.623 2.904-.034.208a2.002 2.002 0 001.454 2.139l.206.045.21.035 2.708-7.741A3.001 3.001 0 0118.71 11H20V6.002h-1.47c-.696 0-1.38-.183-1.985-.528l-.27-.155-.285-.157A10.002 10.002 0 0011.31 4H7.705Z" />
</svg>
)
}
export function DislikeSelectedIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M11.313 2.002c2.088 0 4.14.546 5.953 1.583l.273.156a2 2 0 00.993.264H21a1 1 0 011 1V11a1 1 0 01-1.002 1l-2.787-.005a1 1 0 00-.946.67l-3.02 8.628a.815.815 0 01-.966.522 3.262 3.262 0 01-2.35-4.062l.707-2.477a1 1 0 00-.961-1.274h-5.29a2.24 2.24 0 01-2.004-1.238l-.18-.359a1.784 1.784 0 01.601-2.278.446.446 0 00.198-.37v-.07a.578.578 0 00-.116-.347 2.374 2.374 0 01.412-3.278l.498-.399a.379.379 0 00.123-.415l-.07-.207a2.1 2.1 0 01.313-1.923A2.798 2.798 0 017.4 2l3.913.002Z" />
</svg>
)
}
export function LikeIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M9.221 1.795a1 1 0 011.109-.656l1.04.173a4 4 0 013.252 4.784L14 9h4.061a3.664 3.664 0 013.576 2.868A3.68 3.68 0 0121 14.85l.02.087A3.815 3.815 0 0120 18.5v.043l-.01.227a2.82 2.82 0 01-.135.663l-.106.282A3.754 3.754 0 0116.295 22h-3.606l-.392-.007a12.002 12.002 0 01-5.223-1.388l-.343-.189-.27-.154a2.005 2.005 0 00-.863-.26l-.13-.004H3.5a1.5 1.5 0 01-1.5-1.5V12.5A1.5 1.5 0 013.5 11h1.79l.157-.013a1 1 0 00.724-.512l.063-.145 2.987-8.535Zm-1.1 9.196A3 3 0 015.29 13H4v4.998h1.468a4 4 0 011.986.528l.27.155.285.157A10 10 0 0012.69 20h3.606c.754 0 1.424-.483 1.663-1.2l.03-.126a.819.819 0 00.012-.131v-.872l.587-.586c.388-.388.577-.927.523-1.465l-.038-.23-.02-.087-.21-.9.55-.744A1.663 1.663 0 0018.061 11H14a2.002 2.002 0 01-1.956-2.418l.623-2.904a2 2 0 00-1.626-2.392l-.21-.035-2.71 7.741Z"></path>
</svg>
)
}
export function LikeSelectedIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M10.72 2.18a3.263 3.263 0 012.352 4.063l-.708 2.476a1 1 0 00.962 1.275h5.29c.848 0 1.624.48 2.003 1.238l.179.359a1.785 1.785 0 01-.6 2.279.446.446 0 00-.198.37v.07c0 .124.041.246.116.346a2.375 2.375 0 01-.41 3.278l-.5.399a.38.38 0 00-.123.416l.07.206c.217.653.1 1.372-.313 1.923a2.8 2.8 0 01-2.24 1.12l-3.914-.002a12 12 0 01-5.952-1.584l-.272-.155a2.002 2.002 0 00-.993-.265H3a1 1 0 01-1-1v-5.996a1 1 0 011.002-1L5.789 12a1 1 0 00.945-.67l3.02-8.628a.816.816 0 01.967-.523Z" />
</svg>
)
}
export function NextIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M20 20a1 1 0 001-1V5a1 1 0 00-2 0v14a1 1 0 001 1Zm-14.955-.226L18 12 5.045 4.228A1.35 1.35 0 003 5.386v13.23a1.35 1.35 0 002.045 1.158Z" />
</svg>
)
}
export function PauseIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M6.5 3A1.5 1.5 0 005 4.5v15A1.5 1.5 0 006.5 21h2a1.5 1.5 0 001.5-1.5v-15A1.5 1.5 0 008.5 3h-2Zm9 0A1.5 1.5 0 0014 4.5v15a1.5 1.5 0 001.5 1.5h2a1.5 1.5 0 001.5-1.5v-15A1.5 1.5 0 0017.5 3h-2Z"></path>
</svg>
)
}
export function PlayIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M5 4.623V19.38a1.5 1.5 0 002.26 1.29L22 12 7.26 3.33A1.5 1.5 0 005 4.623Z" />
</svg>
)
}
export function PreviousIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M4 4a1 1 0 00-1 1v14a1 1 0 102 0V5a1 1 0 00-1-1Zm14.955.23L6 12.003l12.955 7.772A1.35 1.35 0 0021 18.617V5.387a1.35 1.35 0 00-2.045-1.157Z" />
</svg>
)
}
export function RepeatAllIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M21 10a1 1 0 011 1v4a5 5 0 01-5 5H5.414l1.293 1.293a1 1 0 11-1.414 1.414L1.586 19l3.707-3.707a1 1 0 111.414 1.414L5.414 18H17a3 3 0 003-3v-4a1 1 0 011-1Zm-3.707-8.707a1 1 0 011.414 0L22.414 5l-3.707 3.707a1 1 0 11-1.414-1.414L18.586 6H7a3 3 0 00-3 3v4a1 1 0 01-2 0V9a5 5 0 015-5h11.586l-1.293-1.293a1 1 0 010-1.414ZM12 10a2 2 0 110 4 2 2 0 010-4Z" />
</svg>
)
}
export function RepeatIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M17.293 1.293a1 1 0 000 1.415L18.586 4H7a5 5 0 00-5 5v4a1 1 0 102 0V9a3 3 0 013-3h11.586l-1.293 1.293a1 1 0 001.414 1.415L22.414 5l-3.707-3.707a1 1 0 00-1.414 0ZM21 10a1 1 0 00-1 1v4a3 3 0 01-3 3H5.414l1.293-1.292a1.001 1.001 0 00-1.414-1.415L1.586 19l3.707 3.707a1 1 0 101.414-1.413L5.414 20H17a5 5 0 005-5v-4a1 1 0 00-1-1Z"></path>
</svg>
)
}
export function RepeatOneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M17.293 1.293a1 1 0 000 1.415L18.586 4H7a5 5 0 00-5 5v4a1 1 0 102 0V9a3 3 0 013-3h11.586l-1.293 1.293a1 1 0 001.414 1.415L22.414 5l-3.707-3.707a1 1 0 00-1.414 0ZM13 15V8h-2.5a1 1 0 000 2h.5v5a1 1 0 002 0Zm8-5a1 1 0 00-1 1v4a3 3 0 01-3 3H5.414l1.293-1.292a1.001 1.001 0 00-1.414-1.415L1.586 19l3.707 3.707a1 1 0 101.414-1.413L5.414 20H17a5 5 0 005-5v-4a1 1 0 00-1-1Z" />
</svg>
)
}
export function ShuffleOffIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M16.293 1.293a1 1 0 00-.001 1.415L18.585 5H17.21a7 7 0 00-5.823 3.118L6.95 14.774A5 5 0 012.79 17H2a1 1 0 000 2h.79a7 7 0 005.822-3.117l4.438-6.656A5 5 0 0117.21 7h1.376l-2.293 2.293a1 1 0 001.414 1.414L22.414 6l-4.707-4.707a1 1 0 00-1.414 0ZM2.789 5H2a1 1 0 000 2h.79a5 5 0 014.159 2.227l.647.97 1.202-1.802-.185-.277A7 7 0 002.789 5Zm13.504 8.293a1 1 0 00-.001 1.414L18.585 17H17.21a5 5 0 01-4.16-2.226l-.648-.972-1.202 1.803.186.278A7 7 0 0017.21 19h1.376l-2.293 2.294-.068.076a1 1 0 001.406 1.406l.076-.07L22.414 18l-4.707-4.707a1 1 0 00-1.414 0Z" />
</svg>
)
}
export function ShuffleOneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M16.293 13.293a1 1 0 011.414 0L22.414 18l-4.707 4.707a1 1 0 01-1.414-1.413L18.586 19H17.21a7.001 7.001 0 01-5.824-3.117l-.186-.278 1.202-1.803.648.972A5.001 5.001 0 0017.21 17h1.375l-2.293-2.293a1 1 0 010-1.414Zm0-12a1 1 0 011.414 0L22.414 6l-4.707 4.707a1 1 0 01-1.414-1.414L18.586 7H17.21a5 5 0 00-4.16 2.227l-4.438 6.656A7 7 0 012.79 19H2a1 1 0 010-2h.79a5 5 0 004.16-2.226l4.437-6.656A7 7 0 0117.21 5h1.375l-2.293-2.292a1 1 0 010-1.415ZM3 10.001a2 2 0 110 4 2 2 0 010-4Zm-.21-5a7 7 0 015.823 3.117l.185.277-1.202 1.803-.647-.971A5 5 0 002.79 7H2a1 1 0 010-2h.79Z" />
</svg>
)
}
export function VolumeFullIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M11.485 2.143 3.913 6.687A6 6 0 001 11.832v.338a6 6 0 002.913 5.144l7.572 4.543A1 1 0 0013 21V3a1.001 1.001 0 00-1.515-.857Zm6.88 2.079a1 1 0 00-.001 1.414 9 9 0 010 12.728 1 1 0 001.414 1.414 11 11 0 000-15.556 1 1 0 00-1.413 0ZM4.941 8.402l.001-.002L11 4.767v14.466l-6.058-3.635A4 4 0 013 12.168v-.337a4 4 0 011.941-3.429ZM15.535 7.05a1 1 0 000 1.415 5 5 0 010 7.07 1 1 0 001.415 1.415 6.999 6.999 0 000-9.9 1 1 0 00-1.415 0Z"></path>
</svg>
)
}
export function VolumeMutedIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden="true"
fill="currentColor"
focusable="false"
height="24"
viewBox="0 0 24 24"
width="24"
{...props}
>
<path d="M11.485 2.143 3.913 6.687A6 6 0 001 11.832v.338a6 6 0 002.913 5.144l7.572 4.543A1 1 0 0013 21V3a1.001 1.001 0 00-1.515-.857ZM4.942 8.4 11 4.767v14.466l-6.058-3.634A4 4 0 013 12.169v-.338A4 4 0 014.942 8.4Zm16.351-.108L19 10.586l-2.293-2.293a1 1 0 10-1.414 1.414L17.586 12l-2.293 2.293a1 1 0 101.414 1.414L19 13.414l2.293 2.293a1 1 0 101.414-1.414L20.414 12l2.293-2.294a1 1 0 10-1.414-1.414Z" />
</svg>
)
}
registry/default/blocks/audio-player/hooks/use-playlist-asset.ts
"use client"
import type {
AudioPlayerAsset,
PlaybackUrls,
} from "@/registry/default/blocks/audio-player/player"
import type { UseAssetReturn } from "@/registry/default/hooks/use-asset"
import { useAsset } from "@/registry/default/hooks/use-asset"
export type { PlaybackUrls }
export type PlaylistAsset = AudioPlayerAsset
export interface UsePlaylistAssetReturn extends UseAssetReturn<PlaylistAsset> {
items: PlaylistAsset[]
}
export function usePlaylistAsset(): UsePlaylistAssetReturn {
const asset = useAsset<PlaylistAsset>()
const items = asset.orderedItems.map((item) => item.properties)
return {
...asset,
items,
}
}
registry/default/blocks/audio-player/components/button.tsx
import { Slot } from "@radix-ui/react-slot"
import * as React from "react"
import { cn } from "@/lib/utils"
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean
render?: React.ReactElement
size?: "default" | "large" | "sm" | "xl"
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ asChild = false, className, render, size = "large", ...props }, ref) => {
const Comp = render ? Slot : asChild ? Slot : "button"
const buttonProps =
Comp === "button" ? { type: props.type ?? "button", ...props } : props
return (
<Comp
className={cn(
`
inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full bg-transparent text-foreground
transition-[color,background-color,scale] duration-150 ease-out
hover:bg-muted
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring
active:scale-[0.96]
disabled:cursor-not-allowed disabled:opacity-50
`,
size === "default" &&
`
size-8
[&_svg]:size-5
`,
size === "large" &&
`
size-10
[&_svg]:size-6
`,
size === "xl" &&
`
size-12
[&_svg]:size-10
`,
size === "sm" &&
`
size-6
[&_svg]:size-4
`,
className
)}
ref={ref}
{...buttonProps}
>
{render
? React.cloneElement(render, undefined, props.children)
: props.children}
</Comp>
)
}
)
Button.displayName = "AudioPlayerButton"
export { Button }
registry/default/blocks/audio-player/styles.css
.limeplay {
--background: oklch(0.985 0 0);
--foreground: oklch(0.205 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.205 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.205 0 0);
--primary: oklch(0.577 0.245 27.325);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.94 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.94 0 0);
--muted-foreground: oklch(0.439 0 0);
--accent: oklch(0.94 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0 0 0 / 12%);
--input: oklch(0 0 0 / 14%);
--ring: oklch(0.577 0.245 27.325);
--chart-1: oklch(0.577 0.245 27.325);
--chart-2: oklch(0.704 0.191 22.216);
--chart-3: oklch(0.623 0.214 259.815);
--chart-4: oklch(0.809 0.105 251.813);
--chart-5: oklch(0.488 0.243 264.376);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.205 0 0);
--sidebar-primary: oklch(0.577 0.245 27.325);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.94 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0 0 0 / 12%);
--sidebar-ring: oklch(0.577 0.245 27.325);
--lp-timeline-track-height: 2px;
}
.dark .limeplay:not(.light),
.limeplay.dark {
--background: oklch(0.247 0 129.63);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.87 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--lp-timeline-track-height: 2px;
}