Search code examples
javascripthtmliosaudioevent-handling

HTML5 audio element: Visual elements of button to play audio do not update upon click but wait until audio starts to play (mobile iOS issue)


Background

I have a button that plays the audio connected to a nearby audio element when a click event is detected. The button element has a nested img element whose src I change to point to various .svg files depending on on whether audio is

  1. playing (pause icon)
  2. paused (play icon)
  3. about to play but hasn't started playing yet (rotating buffering icon)

Problem

The switch to the pause or play icon happens with no problems at all, but the switch to the buffering icon happens so late it isn't really useful (i.e., user clicks the play button, user waits for however long, and the buffering icon flashes for a few ms right before the audio starts playing).

On desktop, this is all very snappy and the buffering icon wouldn't even be needed, but on mobile (iOS) there can be a one to four second delay from when the user clicks the play button and the audio actually starts playing (which is why I want a buffering icon for that wait time).

Code

HTML structure

There are a whole slew of these li elements. The real focus here is on the button.playOrPauseBtn element:

<li>
    <audio
        id="TR-21"
        class="dialogueAudio"
        src="/static/audio/en/es/end/tr-21.mp3"
        preload="auto"
    >
    </audio>
    <div>
        <button type="button" class="svgBtn playOrPauseBtn" >
            <img class="playIcon" src="/static/svg/play.svg" height="22px" width="22px" />
        </button>
    </div>
</li>

javascript

The focus here would be the event listener at the bottom, which I would expect to immediately change the UI of the play/pause button to my buffer icon right when the button is clicked, but on mobile (iOS; haven't tested android), it actually waits and then only flashes the buffer icon right before the audio starts playing (1 to 4 second wait, depending on audio).

If it seems weird that everything is separated into lots of mini functions, it's because these are extremely simplified versions of the actual functions (I'm trying to only focus on the problem at hand). They do a lot more with the UI, such as changing title and alt text for various elements. Let me know if you think it would be helpful to see the JS code and HTML markup in their original, unsimplified form.

/* ----------------------------------------------
------------------- FUNCTIONS -------------------
---------------------------------------------- */

const changeAudioControlBtn = function(btn, svg, disabled=false, spinny=false) {
  // get other elements
  let btnImg = btn.querySelector("img");
  // change UI of btn
  btn.disabled = disabled;
  // change UI of img
  btnImg.src = svg;
  if (spinny === false) {
    btnImg.classList.remove("spinny");
  } else {
    btnImg.classList.add("spinny");
  }
}

/* Function to pause all audios */
const pauseAll = async function() {
  let allYeAudios = document.querySelectorAll("audio");
  allYeAudios.forEach((item, i) => {
    item.pause();
  });
};

/* Function for playing or pausing audio associated with play/pause button */
const playOrPause = async function(soundByte) {
  if (soundByte.paused == true) {
    // pause other audios before playing
    await pauseAll();
    soundByte.play();
  } else {
    soundByte.pause();
  }
};

/* Function to change play/pause button icon to play or pause symbol */
const changePlayOrPauseGraphics = function() {
  let playPauseBtn = this.parentElement.querySelector(".playOrPauseBtn");
  if (this.paused === true) {
    // set properties for function to change UI of play/pause btn
    let btn = playPauseBtn;
    let svg = "/static/svg/play.svg";
    changeAudioControlBtn(btn, svg);
  } else {
    // set properties for function to change UI of play/pause btn
    let btn = playPauseBtn;
    let svg = "/static/svg/pause.svg";
    changeAudioControlBtn(btn, svg);
  }
};

/* ----------------------------------------------
---------------- EVENT LISTENERS ----------------
---------------------------------------------- */

const allDialogueAudios = document.querySelectorAll(".dialogueAudio");

// Change visuals if playing
allDialogueAudios.forEach((item, i) => {
  item.addEventListener("playing", changePlayOrPauseGraphics);
});

// change visuals when audio pauses too
allDialogueAudios.forEach((item, i) => {
  item.addEventListener("pause", changePlayOrPauseGraphics);
});

const allPlayButtons = document.querySelectorAll(".playOrPauseBtn");

// Play buttons
allPlayButtons.forEach((item, i) => {
  item.addEventListener("click", () => {
    // set properties for UI function (to change btn to spinny buffer icon)
    let btn = item;
    let svg = "/static/svg/loady_spinner.svg";
    let disabled = true;
    let spinny = true;
    changeAudioControlBtn(btn, title, svg, alt, disabled, spinny);
    let soundByte = item.parentElement.parentElement.querySelector(".dialogueAudio");
    playOrPause(soundByte);
  });
});

What I've tried

I used to have the code that calls the changeAudioControlBtn() function in order to change the UI of the playOrPauseBtn to a buffer icon inside the playOrPause() function, and the event listener handler for the play/pause buttons was much simpler (it just called the playOrPause() function, but I thought moving the code for the buffer icon change to the event listener handler would be more immediate. But that didn't have any effect.

Other details

The issue does not seem to be Safari related because it does not happen on Safari desktop, and the same issue happens on my iOS device on Chrome, Safari, and Firefox browsers.


Solution

  • The core of this issue is that the audio is not loaded on iOS unless you call the load() method, and therefore the canplay and canplaythrough etc. are not fired. Either you can call load() on all audio objects on the page when the DOM is loaded, or you can call load() each time the user click a button for playing the audio. In the following I went for the latter, because it is the hard road, and if you have a lot of audio objects, I guess it gives less network traffic.

    The initial look of the buttons is the play icon. The audio elements can either have the preload attribute set to none or auto. As I see it, this only effects non-iOS browsers.

    In playOrPause() I test if the audio object has the custom property loaded set to true, if not it must be because this is the first time the button has been clicked and the canplay has not been fired (in the case of iOS). With the manuallyloaded property I indicate that load() was called "manually". If loaded is not set, the original condition for paused/not paused will run.

    In changePlayOrPauseGraphics() I made a switch statement for the different events, changing the class name of the button between play, pause and spinny. For the pause, canplay and ended events the button should show a play icon, and at the same time there is a condition for manuallyloaded. If manuallyloaded is true this indicate that load() was called and that the button has been clicked for the first time, and therefore is should go ahead an play the audio (and delete the manuallyloaded property). Else if the event is a canplay the loaded property should just be set without playing the audio.

    The only situation where you will see the spinny icon is when the event emptied is fired, and this will only happen if load() is called. So, either in the iOS-browser case, or if the user in other browsers hits the button before the initial canplay event (either because the attribute preload is none or because the user is on a slow connection and has a fast hand).

    A comment on the event listeners

    For both click events and all the audio related event I add the event listener to a parent element (here audiolist01). All events are going up and down the DOM tree anyway. The reason that I often use a parent element is that 1) I don't have to loop over N number of elements, 2) if anything is added to the DOM dynamically, I don't have to add event listeners for that particular element. The only downside is that, for general events like click I have to test what element was clicked, like you can see in the click event callback. For the audio events, I know that these can only come from an audio object. Notice, btw that I have to use the useCapture parameter on addEventListener() for the audio -- I can not explain why that is needed, but somehow the events are only present in the bubbling phases (addEventListener: useCapture).

    const audiolist01 = document.getElementById('audiolist01');
    
    /* ----------------------------------------------
    ------------------- FUNCTIONS -------------------
    ---------------------------------------------- */
    
    /* Function for any click event on the audiolist */
    const audiolistClickHandler = function(e) {
      switch (e.target.name) {
        case 'playOrPauseBtn':
          playOrPause(e);
          break;
      }
    };
    
    /* Function for playing or pausing audio associated with play/pause button */
    const playOrPause = async function(e) {
      let soundByte = e.target.closest('li').querySelector('audio');
    
      if (!soundByte.loaded) {
        soundByte.load();
        soundByte.manuallyloaded = true;
      } else {
        if (soundByte.paused) {
          // pause other audios before playing
          await pauseAll();
          soundByte.play();
        } else {
          soundByte.pause();
        }
      }
    };
    
    /* Function to change play/pause button icon to play or pause symbol */
    const changePlayOrPauseGraphics = function(e) {
      let playPauseBtn = e.target.closest('li').querySelector(".playOrPauseBtn");
      playPauseBtn.disabled = false;
      switch (e.type) {
        case 'playing':
          playPauseBtn.classList.replace('play', 'pause');
          playPauseBtn.classList.replace('spinny', 'pause');
          break;
        case 'pause':
        case 'canplay':
        case 'ended':
          if (e.target.manuallyloaded) {
            delete e.target.manuallyloaded;
            e.target.play();
            e.target.loaded = true;
          } else if(e.type == 'canplay'){
            e.target.loaded = true;
          }
          playPauseBtn.classList.replace('pause', 'play');
          playPauseBtn.classList.replace('spinny', 'play');
          break;
        default:
          // effectively only the emptied event
          playPauseBtn.disabled = true;
          playPauseBtn.classList.replace('play', 'spinny');
          playPauseBtn.classList.replace('pause', 'spinny');
          break;
      }
    };
    
    /* Function to pause all audios */
    const pauseAll = async function() {
      let allYeAudios = document.querySelectorAll("audio");
      allYeAudios.forEach((item, i) => {
        item.pause();
      });
    };
    
    /* ----------------------------------------------
    ---------------- EVENT LISTENERS ----------------
    ---------------------------------------------- */
    
    audiolist01.addEventListener('click', audiolistClickHandler);
    
    // Change visuals if playing
    audiolist01.addEventListener("playing", changePlayOrPauseGraphics, true);
    
    // change visuals when audio pauses too
    audiolist01.addEventListener("pause", changePlayOrPauseGraphics, true);
    
    audiolist01.addEventListener("canplay", changePlayOrPauseGraphics, true);
    audiolist01.addEventListener("emptied", changePlayOrPauseGraphics, true);
    audiolist01.addEventListener("ended", changePlayOrPauseGraphics, true);
    ul.audiolist {
      list-style: none;
      margin: 0;
      padding: 0;
    }
    
    button.svgBtn {
      padding: 0;
      border: none;
      background-color: transparent;
      width: 22px;
      height: 22px;
    }
    
    button.svgBtn.play {
      background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICA8Y2lyY2xlIGZpbGw9Im9yYW5nZSIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+CiAgPHBhdGggc3Ryb2tlPSJmaWxsIiBkPSJNIDMwIDE1IHYgNzAgbCA1MCAtMzUgeiIvPgo8L3N2Zz4KCg==');
    }
    
    button.svgBtn.pause {
      background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICA8Y2lyY2xlIGZpbGw9Im9yYW5nZSIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+CiAgPHBhdGggc3Ryb2tlPSJibGFjayIgZD0iTSAzMCAyMiB2IDU2IG0gNDAgMCB2IC01NiIgc3Ryb2tlLXdpZHRoPSIxOCIvPgo8L3N2Zz4KCg==');
    }
    
    button.svgBtn.spinny {
      background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj4KICA8Y2lyY2xlIGZpbGw9Im9yYW5nZSIgY3g9IjUwIiBjeT0iNTAiIHI9IjUwIi8+CiAgPGNpcmNsZSBmaWxsPSJub25lIiBzdHJva2U9ImJsYWNrIiBzdHJva2Utd2lkdGg9IjE1IiBjeD0iNTAiIGN5PSI1MCIgcj0iMzUiIHBhdGhMZW5ndGg9IjEwMCIgc3Ryb2tlLWRhc2hhcnJheT0iNSA1IDEwIDUgMjAgNSAzMCAxMDAiLz4KPC9zdmc+Cg==');
      animation: 1s infinite rotate linear;
    }
    
    @keyframes rotate {
      from {
        transform: rotate(0deg);
      }
      to {
        transform: rotate(360deg);
      }
    }
    <ul id="audiolist01" class="audiolist">
      <li>
        <audio id="TR-21" class="dialogueAudio" src="https://cdn.pixabay.com/audio/2023/08/24/audio_8d3bddfb3b.mp3" preload="auto">
        </audio>
        <div>
          <button type="button" name="playOrPauseBtn" class="svgBtn playOrPauseBtn play"></button>
        </div>
      </li>
      <li>
        <audio id="TR-22" class="dialogueAudio" src="https://cdn.pixabay.com/audio/2023/08/17/audio_64ceae974a.mp3" preload="none">
        </audio>
        <div>
          <button type="button" name="playOrPauseBtn" class="svgBtn playOrPauseBtn play"></button>
        </div>
      </li>
    </ul>