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 · lighticonslucide-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);
}