Go home

Audio Player

This is an audio player built with React that uses Waveformr to render audio waveforms as images and React Aria Components for accessibility. Check out this post for more details on the waveform progress technique.


import { useEffect, useRef, useState, useTransition } from "react";

// TODO: Inline this
import invariant from "tiny-invariant";
import { Slider, SliderThumb, SliderTrack } from "react-aria-components";

export function AudioPlayer() {
  const ref = useRef<HTMLAudioElement>(null);
  const [state, setState] = useState<"idle" | "loading" | "playing" | "paused">(
    "idle",
  );
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const lazyState = useLazyValue(state, 100);

  function togglePlay() {
    const audioElement = ref.current;
    invariant(audioElement, "Expected audio element to be defined");

    if (audioElement.paused) {
      audioElement.play();
    } else {
      audioElement.pause();
    }

    if (state === "idle") {
      setState("loading");
    }
  }

  // The metadata is sometimes cached by the browser and we don't get a
  // loadedmetadata event. Check once on mount to see if we have a duration
  useEffect(() => {
    const duration = ref.current?.duration;
    if (typeof duration === "number" && !Number.isNaN(duration)) {
      setDuration(duration);
    }
  }, []);

  return (
    <Container>
      <div className="flex flex-col gap-6 md:flex-row">
        <div className="hidden w-36 md:block lg:w-64">
          <Artwork />
        </div>
        <div className="flex flex-1 flex-col justify-between gap-4">
          <div className="flex items-center justify-between gap-4">
            <div className="flex items-center gap-4">
              <ArtistInfo />
            </div>
            <div className="flex items-center gap-4">
              <AudioTime currentTime={currentTime} duration={duration} />
              <IconButton
                aria-label={
                  lazyState === "loading"
                    ? "Loading"
                    : state === "playing"
                      ? "Pause"
                      : "Play"
                }
                onClick={togglePlay}
              >
                <TouchTarget>
                  {lazyState === "loading" ? (
                    <LoadingSpinner />
                  ) : state === "playing" ? (
                    <PauseIcon />
                  ) : (
                    <PlayIcon />
                  )}
                </TouchTarget>
              </IconButton>
            </div>
          </div>
          <div className="relative space-y-1">
            <Scrubber
              min={0}
              max={duration}
              value={currentTime}
              onPlay={togglePlay}
              onChange={(newTime) => {
                const audioElement = ref.current;
                invariant(audioElement, "Expected audio element to be defined");

                // Need to set both the state and the audio element's currentTime
                // here so that the scrubber doesn't jump back to the previous
                // position when the audio element's time updates.
                setCurrentTime(newTime);
                audioElement.currentTime = newTime;
              }}
            />
          </div>
        </div>
      </div>
      <audio
        src={trackUrl}
        // This tells the browser not to preload anything for this track
        preload="none"
        ref={ref}
        onPlaying={() => {
          setState("playing");
        }}
        onPause={() => {
          setState("paused");
        }}
        onLoadedMetadata={(event) => {
          setDuration(event.currentTarget.duration);
        }}
        onTimeUpdate={(event) => {
          setCurrentTime(event.currentTarget.currentTime);
        }}
      />
    </Container>
  );
}

function Container({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex flex-col rounded-lg bg-gray-900 p-4 shadow-2xl md:p-6">
      {children}
    </div>
  );
}

function PlayIcon() {
  return (
    <svg
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      width="1em"
      height="1em"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      className="lucide lucide-play-circle"
    >
      <circle cx="12" cy="12" r="10" />
      <polygon points="10 8 16 12 10 16 10 8" />
    </svg>
  );
}

function PauseIcon() {
  return (
    <svg
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
      width="1em"
      height="1em"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      className="lucide lucide-pause-circle"
    >
      <circle cx="12" cy="12" r="10" />
      <line x1="10" x2="10" y1="15" y2="9" />
      <line x1="14" x2="14" y1="15" y2="9" />
    </svg>
  );
}

function useLazyValue(value: any, delay: number) {
  const [state, setState] = useState(value);

  useEffect(() => {
    const timeout = setTimeout(() => {
      setState(value);
    }, delay);

    return () => {
      clearTimeout(timeout);
    };
  }, [value, delay]);

  return state;
}

function Scrubber(props: {
  value: number;
  max: number;
  min: number;
  onChange: (value: number) => void;
  onPlay: () => void;
}) {
  const { onChange, value, min, max, onPlay } = props;
  const percentPlayed = max === 0 ? 0 : (value / max) * 100;
  const [hint, setHint] = useState(0);
  const [, startTransition] = useTransition();

  const isLoaded = max > min;

  return (
    <div
      className="relative"
      style={
        {
          "--seek-hint": `${hint}%`,
          "--played": `${percentPlayed}%`,
        } as React.CSSProperties
      }
      onMouseLeave={() => {
        setHint(0);
      }}
      onMouseMove={(evt) => {
        if (!isLoaded) return;

        let rect = evt.currentTarget.getBoundingClientRect();

        startTransition(() => {
          setHint(((evt.clientX - rect.left) / rect.width) * 100);
        });
      }}
    >
      <div className="absolute left-0 top-0 h-full w-full">
        <WaveformImg
          name="played"
          stroke="linear-gradient(E75318,c62513)"
          clipStart="0%"
          clipEnd="var(--played)"
        />
      </div>
      <div
        className="absolute left-0 top-0 h-full w-full"
        style={{ opacity: hint === 0 ? 0 : 1 }}
      >
        <WaveformImg
          name="seek-hint"
          stroke="linear-gradient(EC7546, ED5645)"
          clipStart="min(var(--played), var(--seek-hint))"
          clipEnd="max(var(--played), var(--seek-hint))"
        />
      </div>
      {isLoaded ? (
        <Slider
          className="absolute left-0 top-0 h-full w-full"
          formatOptions={{
            style: "unit",
            unit: "second",
          }}
          minValue={min}
          maxValue={max}
          value={value}
          aria-label="Seek"
          step={1}
          onChange={(val) => {
            onChange(val);
          }}
        >
          <SliderTrack className="relative h-full bg-transparent">
            <SliderThumb
              className="h-full w-[2px] rounded bg-blue-500 opacity-0 data-[focus-visible]:opacity-100 data-[focus-visible]:ring-blue-600"
              style={{
                top: "50%",
              }}
            />
          </SliderTrack>
        </Slider>
      ) : (
        // If we're not loaded yet, this is just a big button to start playing
        <button
          className="absolute left-0 top-0 flex h-full w-full focus:outline-none [&+div]:hover:opacity-50 [&+div]:focus-visible:opacity-50 [&+div]:active:opacity-[40]"
          onClick={onPlay}
        />
      )}
      <div className="pointer-events-none transition-opacity">
        <WaveformImg
          name="base"
          stroke="c5c1bd"
          clipStart="max(var(--played), var(--seek-hint))"
          clipEnd="100%"
        />
      </div>

      <div
        className="pointer-events-none absolute left-0 top-0 flex h-full w-[2px] items-center justify-between bg-blue-600"
        style={{
          left: "var(--seek-hint)",
          opacity: hint === 0 ? 0 : 1,
        }}
      />
    </div>
  );
}

function AudioTime(props: { currentTime: number; duration: number }) {
  return (
    <div
      className="flex justify-between gap-1 text-xs text-gray-100 opacity-100 aria-hidden:opacity-0 md:text-sm"
      aria-hidden={props.duration === 0}
    >
      <span>{formatTimecode(props.currentTime)}</span>
      <span>/</span>
      <span>{formatTimecode(props.duration)}</span>
    </div>
  );
}

function Artwork() {
  return (
    <div className="flex items-start">
      <img
        alt="Artist image of The Air on Earth"
        src="/images/taoe.jpg"
        className="not-prose aspect-square w-full rounded"
      />
    </div>
  );
}

function WaveformImg(props: {
  name: string;
  clipStart: string;
  clipEnd: string;
  stroke: string;
  className?: string;
}) {
  const { name, clipStart, clipEnd, stroke } = props;
  const clipPath = `polygon(${clipStart} 0%, ${clipEnd} 0%, ${clipEnd} 100%, ${clipStart} 100%)`;

  return (
    <img
      alt=""
      src={waveformUrl({ stroke })}
      className={`not-prose ${name}`}
      style={{
        clipPath,
      }}
    />
  );
}

function IconButton(props: React.ComponentPropsWithRef<"button">) {
  return (
    <button
      {...props}
      className={`relative rounded-full text-3xl text-orange-500 hover:text-orange-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 active:text-orange-300 md:text-4xl`}
    />
  );
}

// Thanks: https://github.com/SamHerbert/SVG-Loaders
function LoadingSpinner() {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="1em"
      height="1em"
      aria-hidden="true"
      viewBox="0 0 38 38"
    >
      <defs>
        <linearGradient id="a" x1="8.042%" x2="65.682%" y1="0%" y2="23.865%">
          <stop offset="0%" stopColor="#fff" stopOpacity="0"></stop>
          <stop offset="63.146%" stopColor="#fff" stopOpacity="0.631"></stop>
          <stop offset="100%" stopColor="#fff"></stop>
        </linearGradient>
      </defs>
      <g fill="none" fillRule="evenodd" transform="translate(1 1)">
        <path stroke="url(#a)" strokeWidth="2" d="M36 18c0-9.94-8.06-18-18-18">
          <animateTransform
            attributeName="transform"
            dur="0.9s"
            from="0 18 18"
            repeatCount="indefinite"
            to="360 18 18"
            type="rotate"
          ></animateTransform>
        </path>
        <circle cx="36" cy="18" r="1" fill="#fff">
          <animateTransform
            attributeName="transform"
            dur="0.9s"
            from="0 18 18"
            repeatCount="indefinite"
            to="360 18 18"
            type="rotate"
          ></animateTransform>
        </circle>
      </g>
    </svg>
  );
}

function ArtistInfo() {
  return (
    <div className="flex flex-col">
      <a
        target="_blank"
        rel="noopener noreferrer"
        className="block text-xs leading-none text-gray-300 no-underline hover:underline md:text-sm"
        href="https://open.spotify.com/artist/4beU4ZRfDapoH3orvpJthM"
      >
        The Air on Earth
      </a>
      <a
        target="_blank"
        rel="noopener noreferrer"
        className="block text-base font-medium leading-none text-gray-50 no-underline hover:underline md:text-2xl md:leading-none lg:text-4xl lg:leading-none"
        href="https://open.spotify.com/track/5fqgN15DVKhH7TjUkvjVQD"
      >
        Rest
      </a>
    </div>
  );
}

function formatTimecode(seconds: number) {
  const min = String(Math.floor(seconds / 60) % 60).padStart(2, "0");
  const sec = String(Math.floor(seconds) % 60).padStart(2, "0");

  return `${min}:${sec}`;
}

function TouchTarget({ children }: { children: React.ReactNode }) {
  return (
    <>
      {children}
      <span
        aria-hidden="true"
        className="[amedia(pointer:fine)]:hidden absolute left-1/2 top-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2"
      />
    </>
  );
}

const trackUrl =
  "https://res.cloudinary.com/dhhjogfy6//video/upload/q_auto/v1575831765/audio/rest.mp3";

function waveformUrl(params: { stroke: string }) {
  const url = new URL("https://api.waveformr.com/render");
  url.searchParams.set("url", trackUrl);
  url.searchParams.set("type", "bars");
  Object.entries(params).forEach(([key, value]) => {
    url.searchParams.set(key, value);
  });

  return url.toString();
}