Lens Audio Player

PreviousNext

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

Docs
lens-blockscomponent

Preview

Loading preview…
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>
  );
};

Installation

npx shadcn@latest add @lens-blocks/audio-player

Usage

import { AudioPlayer } from "@/components/audio-player"
<AudioPlayer />