shadimage
lens-blocks
componentaudio-playersource: ok · hit

Lens Audio Player

An audio player component for playing audio attachments in Lens posts.

npx shadcn add "https://lensblocks.com/r/audio-player.json"
free

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

Dependencies

Bundle weight · light
iconslucide-react
ui-primitives@radix-ui/react-slider@1.3.6

Registry dependencies

avatarbuttonhttps://lensblocks.com/r/utils.json

Files3 · source cached 10/14/2026, 3:09:03 PM

registry/new-york/components/common/lens-audio-player.tsx
import ReactPlayer from "react-player";
import { MediaAudio } from "@lens-protocol/react";
import { getAudioExtension, parseUri } from "@/registry/new-york/lib/lens-utils";
import { SyntheticEvent, useRef, useState } from "react";
import { Button } from "@/registry/new-york/ui/button";
import { PauseIcon, PlayIcon, Volume2Icon, VolumeOffIcon } from "lucide-react";
import { MediaSeekSlider } from "@/registry/new-york/ui/media-seek-slider";
import { Duration } from "@/registry/new-york/ui/duration";
import { Skeleton } from "@/registry/new-york/ui/skeleton";

type Props = {
  audio: MediaAudio;
  postTitle?: string | null;
  preload?: "none" | "metadata" | "auto" | "";
  onError?: (e: any) => void;
  onCoverClick?: (imageUri: string) => void;
};

export const LensAudioPlayer = (props: Props) => {
  const { audio, postTitle, preload = "metadata", onError } = props;

  // ReactPlayer looks at the file extension in the source URI to determine how to play the file,
  // so we need to append the extension to the url for instances where the audio url does not have an extension
  // e.g., when using Grove urls like https://api.grove.storage/<key>
  // or when using a proxy server that does not preserve the original file name
  const audioUri = audio ? parseUri(audio.item) + "?extension=." + getAudioExtension(audio.type) : null;

  const playerRef = useRef<HTMLVideoElement | null>(null);

  const initialState = {
    playing: false,
    volume: 1,
    muted: false,
    played: 0,
    loaded: 0,
    duration: 0,
    seeking: false,
    loadedSeconds: 0,
    playedSeconds: 0,
  };

  type PlayerState = typeof initialState;

  const [state, setState] = useState<PlayerState>(initialState);
  const [showTimeRemaining, setShowTimeRemaining] = useState(true);
  const [hasPlayed, setHasPlayed] = useState(false);

  if (!audio || !audioUri) {
    return null;
  }

  if (!ReactPlayer.canPlay?.(audioUri)) {
    return <audio controls src={audioUri} />;
  }

  const handlePlayPause = () => {
    setState(prevState => ({ ...prevState, playing: !prevState.playing }));
  };

  const handleToggleMuted = () => {
    setState(prevState => ({ ...prevState, muted: !prevState.muted }));
  };

  const handlePlay = () => {
    setState(prevState => ({ ...prevState, playing: true }));
    setHasPlayed(true);
  };

  const handlePause = () => {
    setState(prevState => ({ ...prevState, playing: false }));
  };

  const handleSeekMouseDown = () => {
    setState(prevState => ({ ...prevState, seeking: true }));
  };

  const handleSeekChange = (event: SyntheticEvent<HTMLInputElement>) => {
    const inputTarget = event.target as HTMLInputElement;
    setState(prevState => ({ ...prevState, played: Number.parseFloat(inputTarget.value) }));
  };

  const onValueChange = (values: Number[]) => {
    setState(prevState => ({ ...prevState, seeking: false }));
    if (playerRef.current) {
      playerRef.current.currentTime = values[0].valueOf() * playerRef.current.duration;
    }
  };

  const handleProgress = () => {
    const player = playerRef.current;
    // We only want to update time slider if we are not currently seeking
    if (!player || state.seeking || !player.buffered?.length) return;

    setState(prevState => ({
      ...prevState,
      loadedSeconds: player.buffered?.end(player.buffered?.length - 1),
      loaded: player.buffered?.end(player.buffered?.length - 1) / player.duration,
    }));
  };

  const handleTimeUpdate = () => {
    const player = playerRef.current;
    // We only want to update time slider if we are not currently seeking
    if (!player || state.seeking) return;

    if (!player.duration) return;

    setState(prevState => ({
      ...prevState,
      playedSeconds: player.currentTime,
      played: player.currentTime / player.duration,
    }));
  };

  const handleEnded = () => {
    setState(prevState => ({ ...prevState, playing: false }));
  };

  const handleDurationChange = () => {
    const player = playerRef.current;
    if (!player) return;

    setState(prevState => ({ ...prevState, duration: player.duration }));
  };

  const { playing, playedSeconds, volume, muted, played, loaded, duration } = state;

  return (
    <div
      className="w-full flex border rounded-xl mt-1 bg-card text-card-foreground overflow-hidden"
      onClick={event => event.stopPropagation()}
    >
      <div className="w-full h-24 flex items-center">
        {audio.cover && (
          <img
            src={parseUri(audio.cover)!!}
            alt="Cover image"
            className="aspect-square object-cover h-full rounded-l-xl flex-1 cursor-pointer hover:opacity-90"
            width={192}
            height={192}
            loading={"lazy"}
            onClick={() => props.onCoverClick?.(parseUri(audio.cover)!!)}
          />
        )}

        <div className="w-full min-w-0 flex flex-col h-full px-2 justify-center">
          <div className="w-full min-w-0 flex flex-col pl-1 gap-1 md:gap-0">
            {(audio.title || postTitle) && (
              <div className="text-sm md:text-lg font-semibold truncate mt-1">{audio.title ?? postTitle}</div>
            )}
            {audio.artist && <div className="text-sm md:text-base opacity-80 truncate -mt-1">{audio.artist}</div>}
          </div>
          <ReactPlayer
            ref={playerRef}
            src={audioUri}
            controls={false}
            preload={preload}
            playing={playing}
            volume={volume}
            muted={muted}
            onPlay={handlePlay}
            onPause={handlePause}
            onEnded={handleEnded}
            onError={onError}
            onTimeUpdate={handleTimeUpdate}
            onProgress={handleProgress}
            onDurationChange={handleDurationChange}
          />
          <div className="w-full flex gap-1 items-center">
            <Button
              variant="ghost"
              size="icon"
              className="-ms-2 flex-none rounded-full"
              onClick={handlePlayPause}
              disabled={!loaded}
            >
              {playing ? <PauseIcon fill="var(--primary)" /> : <PlayIcon fill="var(--primary)" />}
            </Button>
            <MediaSeekSlider
              min={0}
              max={0.999999}
              step={0.01}
              value={[played]}
              onValueChange={onValueChange}
              onMouseDown={handleSeekMouseDown}
              onChange={handleSeekChange}
              className="flex-grow"
              disabled={!loaded}
            />
            <Button
              variant="ghost"
              size="sm"
              onClick={() => setShowTimeRemaining(!showTimeRemaining)}
              className="flex-none rounded-full"
              disabled={!loaded}
            >
              <Duration
                seconds={!hasPlayed ? duration : showTimeRemaining ? duration - playedSeconds : playedSeconds}
                isCountdown={hasPlayed && showTimeRemaining}
                className="flex-none text-sm"
              />
            </Button>
            <Button
              variant="ghost"
              size="icon"
              className="flex-none rounded-full"
              onClick={handleToggleMuted}
              disabled={!loaded}
            >
              {muted ? <VolumeOffIcon fill="var(--primary)" /> : <Volume2Icon fill="var(--primary)" />}
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
};

export const LensAudioPlayerSkeleton = () => {
  return (
    <div className="w-full flex border rounded-xl mt-1">
      <div className="w-full h-24 flex items-center">
        <Skeleton className="aspect-square h-full rounded-l-xl rounded-r-none flex-1" />
        <div className="w-full flex flex-col h-full px-2 justify-center gap-3">
          <div className="w-full flex flex-col pl-1 gap-2">
            <Skeleton className="h-4 w-32" />
            <Skeleton className="h-3 w-24" />
          </div>
          <div className="w-full flex gap-2 items-center px-1">
            <Skeleton className="h-5 w-5 rounded-full" />
            <Skeleton className="h-3 flex-grow rounded-full" />
            <Skeleton className="h-4 w-9 rounded-full" />
            <Skeleton className="h-5 w-5 rounded-full ml-2" />
          </div>
        </div>
      </div>
    </div>
  );
};
registry/new-york/ui/media-seek-slider.tsx
"use client";

import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";

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

function MediaSeekSlider({
  className,
  defaultValue,
  value,
  min = 0,
  max = 100,
  ...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
  const _values = React.useMemo(
    () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
    [value, defaultValue, min, max],
  );

  return (
    <SliderPrimitive.Root
      data-slot="slider"
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      className={cn(
        "relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col group",
        className,
      )}
      {...props}
    >
      <SliderPrimitive.Track
        data-slot="slider-track"
        className={cn(
          "bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
        )}
      >
        <SliderPrimitive.Range
          data-slot="slider-range"
          className={cn("bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full")}
        />
      </SliderPrimitive.Track>
      {Array.from({ length: _values.length }, (_, index) => (
        <SliderPrimitive.Thumb
          data-slot="slider-thumb"
          key={index}
          className="border-primary bg-foreground ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 invisible group-hover:visible"
        />
      ))}
    </SliderPrimitive.Root>
  );
}

export { MediaSeekSlider };
registry/new-york/ui/duration.tsx
export const Duration = ({
  className,
  seconds,
  isCountdown,
}: {
  className?: string;
  seconds: number;
  isCountdown?: boolean;
}) => (
  <time dateTime={`P${Math.round(seconds)}S`} className={className}>
    {format(seconds, isCountdown)}
  </time>
);

function format(seconds: number, isCountdown?: boolean) {
  const date = new Date(seconds * 1000);
  const hh = date.getUTCHours();
  const mm = date.getUTCMinutes();
  const ss = pad(date.getUTCSeconds());
  if (hh) {
    return `${isCountdown ? "-" : ""}${hh}:${pad(mm)}:${ss}`;
  }
  return `${isCountdown ? "-" : ""}${mm}:${ss}`;
}

function pad(string: string | number) {
  return `0${string}`.slice(-2);
}

More from lens-blocks

Browse all components in this library →