goldfire / howler.js

Javascript audio library for the modern web.
https://howlerjs.com
MIT License
24.09k stars 2.24k forks source link

Audio position resets when using (mobile) notification controls, Media Session API vs .play()/.pause(). #1262

Open bikubi opened 4 years ago

bikubi commented 4 years ago

Or, more generally, does Howler handle mobile OS audio controls?

On mobile devices, when you play HTML5 Audio, the OS let's you control it. (Android: notification drawer, per-app + headset remote control, somewhat globally; iOS: "Control Center", globally). My web app has regular (browser) control buttons, too. Both control schemes (let's call them "OS" and "browser") should work seamlessly & interchangeably, IMO. But, they only work as long as i stick to one scheme exclusively (pause/unpause by browser, pause/unpause by OS) - as soon as I mix (play by browser, pause by OS, unpause by browser), the audio position is reset to zero. As far as I can tell, this happens somewhere in play(), but I could not pin it down exactly.

I have made a Pen to reproduce the issue, with instructions. Tested on Android 9 / Chrome 78. Note that this does not occur on the Music Player example, where you have to essentially pause twice (play by browser, pause by OS, pause again by browser, then play by browser).

Background:

Any insight welcome!

bikubi commented 4 years ago

Update: mobile-like media controls will land soon in Chrome (you can enable chrome://flags/#global-media-controls, see also ars technica article). I just reproduced the issue on v81 on desktop.

bikubi commented 4 years ago

I found a workaround: The problem is, that Howler assumes it has exclusive control over the Audio node, so it relies on parameters on the sounds objects to manage state. When pause/play is triggered externally (e.g. via media control), the _paused property is not updated, and Howler doesn't take the node into consideration for re-use, and spawns a new one. So I listen for pause/play events on the Audio node, and correct the _paused property manually. Furthermore, I have to also set _seek to the node's currentTime. Here's a pen of an example implementation. Gist:

var howl = new Howl({
  html5: true,
  src: ['https://....mp3']
})
var node = howl._sounds[0]._node
node.onpause = function () {
  // find sound by node
  var s, sound
  for (s = 0; s < howl._sounds.length; s++) {
    if (howl._sounds[s]._node === this) {
      sound = howl._sounds[s]
    }
  }
  if (!sound) return
  sound._paused = true
  /* don't do the next thing if this is a live stream!
   * otherwise howler will fire an end event */
  sound._seek = this.currentTime
}.bind(node)

Please note that the examples of the older pen (linked above) might not work today-ish, the MP3 server is down.

I'd be happy to provide a PR, but I lack experience with Howler and its intricacies.

bikubi commented 4 years ago

FYI, to whom it may concern in the future, the workaround above allowed me to integrate Howler somewhat successfully with the Media Session API (setActionHandler with play, pause/stop, as well as seekforward/seekbackward.

bikubi commented 4 years ago

...and I've managed to make it work with pre-MediaSession (older browsers, iOS Control Center) controls AND live streams. The solution above apparently worked only with fixed-length files. Gist:

satyrius commented 2 years ago

Hello @bikubi 👋 Could you please share a full workaround example with us, while we all pray for #1530 to fix the problem eventually. It's not clear how you fixed it with the snippet above and setActionHandler

satyrius commented 2 years ago

After trials and errors this recipe worked https://github.com/goldfire/howler.js/issues/1175#issuecomment-639518966

bikubi commented 2 years ago

@satyrius if you're still interested i could throw a MWE together next week (on vacation r/n)

satyrius commented 2 years ago

@bikubi It would be great to look at your solution. Ofc no rush with it.

Some observation. I managed to make it work with setActionHandler on play/pause event. When they are triggered I explicitly call howl's play/pause. But this handlers stop working occasionally. Hard refresh of the page help the situation. And also iOS Notification Center play button is not working, but pause do.

Here's a snippet from my react component.

import React, { useState, useEffect } from 'react'
import { getStorage, ref, getDownloadURL } from 'firebase/storage'
import { Howl } from 'howler'

function Player({ file }) {
  const [position, setPosition] = useState(0)
  const [isLoaded, setLoaded] = useState(false)
  const [isPlaying, setPlaying] = useState(false)
  const [audio, setAudio] = useState()

  useEffect(() => {
    if (file)
      getDownloadURL(ref(getStorage(), file))
        .then((url) => {
          const howl = new Howl({
            src: [url],
            html5: true,
            onplay: () => {
              setPlaying(true)
              showCurrentPosition()
            },
            onpause: () => {
              setPlaying(false)
            },
          })

          const showCurrentPosition = () => {
            const sec = Math.floor(howl.seek())
            setPosition(sec)
            if (howl.playing()) {
              setTimeout(showCurrentPosition, 200)
            }
          }

          setAudio(howl)
          setLoaded(true)
        })
        .catch((error) => {
          setAudio(null)
          setLoaded(false)
        })
  }, [file])

  // Sync Media Session with Howl
  // https://github.com/goldfire/howler.js/issues/1175
  useEffect(() => {
    if (audio && navigator && navigator.mediaSession) {
      navigator.mediaSession.setActionHandler('play', () => audio.play())
      navigator.mediaSession.setActionHandler('pause', () => audio.pause())
    }
  }, [audio])

  // rendering...
}