shadimage
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 · medium
icons@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;
}

More from limeplay

Browse all components in this library →