DEV Community

CK
CK

Posted on

Having trouble with PWA <Audio /> and S3?

Appended info at the end of the article.

Hi all,

I'm writing a PWA which requires audio playback from S3 with presigned URLs. The presigned URLs are important because the bucket cannot be public.

I wrote a little audio player, and on the web on desktop, it works every time. However, on PWA on iOS (and maybe Android, I have yet to test this), the audio playback is slow and works about half of the time. I'm curious if any of you have experimented with this, and whether you have a solution for it.

Here is the full component fwiw:

import { useState, useRef, useEffect } from "react";
import {
  ArrowUturnLeftIcon,
  ArrowUturnRightIcon,
  PlayIcon,
  PauseIcon,
} from "@heroicons/react/24/outline";

import { api } from "../../utils/api";
import Spinner from "../ui/Spinner";
const skipTime = 15;

const buttonStyles = `
  w-6 items-center
`;





const AudioPlayer = ({
  src,
  recordingId,
  duration
}: {
  src: string;
  recordingId: string;
  duration: number;
}) => {
  const audioRef = useRef<HTMLAudioElement>(null);
  const seekRef = useRef<HTMLInputElement>(null);
  const [_duration, setDuration] = useState(duration);
  const [currentTime, setCurrentTime] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [lastTime, setLastTime] = useState(0)
  const [loaded, setLoaded] = useState(false);
  const updateLT = api.recording.updateListenTime.useMutation();
  const [audioUrl, setAudioUrl] = useState<string | null>(null);

  const downloadAudio = async () => {
    const response = await fetch(src);
    const blob = await response.blob();
    const objectUrl = URL.createObjectURL(blob);
    setAudioUrl(objectUrl);
    if (seekRef.current) {
      seekRef.current.value = "0"
    }
  }

  useEffect(() => {
    downloadAudio().then(() => {
      setLoaded(true)
      return;
    }).catch(() => {
      console.error("Could not load audio")
    })

    return () => {
      if (audioUrl) {
        URL.revokeObjectURL(audioUrl);
      }
    };
  }, [])

  return (
    <>
      {
        audioUrl &&
        <>
          <audio
            controls
            ref={audioRef}
            onDurationChange={(e) => {
              const target = e.target as HTMLAudioElement;
              const dur = target.duration;
              if (dur !== Infinity) {
                setDuration(dur)
              }
            }}
            onTimeUpdate={(e) => {
              const target = e.target as HTMLAudioElement;
              if (seekRef && seekRef.current) {
                seekRef.current.value = (target.currentTime * 1000).toString();
              }
              setCurrentTime(target.currentTime);
              target.currentTime.toString();
              if (target.currentTime - lastTime > 2) {
                setLastTime(target.currentTime);
                updateLT.mutate({
                  recordingId,
                  progress: target.currentTime,
                  duration: _duration,
                });
              }
            }}
          >
            <source src={src} type="audio/mp3" ></source>
            Your browser does not support the audio!
          </audio>
          <div
            className={`
          align-center
          rounded-md
          flex
          w-full flex-col
          justify-center
          bg-gray-200 text-gray-900
      `}
          >
            <div className={`flex h-8 w-full items-center justify-center pt-2`}>
              <span className="mx-2 w-8">
                {Math.floor(currentTime / 60).toFixed()}:
                {String((currentTime % 60).toFixed()).padStart(2, "0")}
              </span>
              <input
                type="range"
                min={0}
                max={_duration * 1000}
                ref={seekRef}
                className="h-0.5 cursor-pointer appearance-none rounded bg-white"
                onChange={(e) => {
                  const target = e.target as HTMLInputElement;
                  if (audioRef && audioRef.current) {
                    audioRef.current.currentTime = Number(target.value) / 1000;
                  }
                }}
              />
              <span className="mx-2 w-8">
                {_duration ? Math.floor(_duration / 60).toFixed() : 0}:
                {_duration
                  ? String((_duration % 60).toFixed()).padStart(2, "0")
                  : "00"}
              </span>
            </div>
            <div
              className={`
        flex w-full justify-center gap-4 pb-4
        `}
            >
              <button
                // disabled={audioContext!.tracks!.length === 0}
                className={`${buttonStyles}`}
                onClick={() => {
                  if (audioRef && audioRef.current) {
                    audioRef.current.currentTime = Math.max(
                      audioRef.current.currentTime - skipTime,
                      0
                    );
                  }
                }}
              >
                <ArrowUturnLeftIcon />
              </button>
              <button
                // disabled={audioContext!.tracks!.length === 0}
                className={`${buttonStyles}`}
                onClick={() => {
                  if (audioRef && audioRef.current) {
                    audioRef.current.volume = 1
                    if (playing) {
                      audioRef.current.pause();
                    } else {
                      audioRef.current.play().catch((e) => console.error(e));
                    }
                  }
                  setPlaying(!playing);
                }}
              >
                {loaded ? <>{playing ? <PauseIcon /> : <PlayIcon />}</> : <Spinner />}

              </button>
              <button
                // disabled={audioContext!.tracks!.length === 0}
                className={`${buttonStyles}`}
                onClick={() => {
                  if (audioRef && audioRef.current) {
                    audioRef.current.currentTime = Math.min(
                      audioRef.current.currentTime + skipTime,
                      _duration
                    );
                  }
                }}
              >
                <ArrowUturnRightIcon />
              </button>
            </div>
          </div>
        </>

      }
    </>

  );
};

export default AudioPlayer;
Enter fullscreen mode Exit fullscreen mode

I thought if I downloaded the full file as a blob then it might work, but I'm still getting slow/no playback on iOS. This is the same behavior I got when I tried to stream directly from the S3 presigned URL.

Let me know if you have any suggestions.

Thanks

Update:

According to this article from Prototyp, PWA audio support from Apple is intentionally unreliable because of Apple's desire to maintain a monopoly on the App store (eg: forcing Spotify to use an official app instead of a PWA).

I am going to look into alternatives for audio playback, potentially a native app.

Sentry mobile image

Mobile Vitals: A first step to Faster Apps

Slow startup times, UI hangs, and frozen frames frustrate users—but they’re also fixable. Mobile Vitals help you measure and understand these performance issues so you can optimize your app’s speed and responsiveness. Learn how to use them to reduce friction and improve user experience.

Read the guide

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs