Search code examples
javascriptmediasession

JavaScript MediaSession stops working when changing the 'src' attribute of HTMLAudioElement


I'm creating a music player web app, and I've got almost everything worked out, but I've run into a major problem.

First, here's the part that works. When music begins playing, MediaSession kicks in. This causes a notification to appear on the user's phone and let's them control playback with a Bluetooth audio device. The latter is especially important when driving, because if the user has paired their phone with their car, they can control playback with their car's audio controls instead of having to look down at their phone.

Now, here's where the problem comes in. In order to change songs, the src attribute of HTMLAudioElement gets changed, and it automatically begins loading the new song file from the server. If the new song starts playing right away, everything works fine, but if playback is paused or if an error is encountered while loading the song, then MediaSession stops working. The notification disappears and the action handlers no longer react to input from the Bluetooth device.

Here's a simplified excerpt of the relevant code from my Pinia store in Vue:

export const useMusicStore = defineStore('music', {

  state: () => ({
    player: new Audio(),
    queue: [],
    currentQueueId: null,
    isPaused: true,
  }),

  getters: {
    currentSong() {
      // ...
    },
  },

  actions: {

    initialize() {
      this.addEventListeners()
      this.setActionHandlers()
      this.loadLastSession()
    },

    addEventListeners() {
      this.player.addEventListener('play', () => this.updatePlaybackState('playing'))
      this.player.addEventListener('pause', () => this.updatePlaybackState('paused'))
      this.player.addEventListener('timeupdate', () => this.updatePositionState())
      this.player.addEventListener('loadedmetadata', () => this.updatePositionState())
      this.player.addEventListener('canplay', () => {
        if (!this.isPaused) {
          this.play()
        }
      })
      this.player.addEventListener('ended', () => {
        this.nextSong()
        this.play()
      })
      this.player.addEventListener('error', (el, err) => {
        console.error(err)
        Notify.create("Error encounted while attempting to play song.")
      })
    },


    setActionHandlers() {
      const actionHandlers = {
        play: () => this.play(),
        pause: () => this.pause(),
        stop: () => this.stop(),
        seekto: (details) => this.seek(details.seekTime),
        seekbackward: (details) => this.skipBack(details.seekOffset ?? 10),
        seekforward: (details) => this.skipForward(details.seekOffset ?? 10),
        previoustrack: () => this.skipBack(),
        nexttrack: () => this.skipForward(),
      }

      if ('mediaSession' in navigator) {
        for (const [action, handler] of Object.entries(actionHandlers)) {
          try {
            navigator.mediaSession.setActionHandler(action, handler)
          } catch {
            console.log(`Action '${action}' not supported.`)
          }
        }
      }
    },

    loadCurrentSong() {
      if (this.currentSong) {
        this.player.src = this.getSongUrl(this.currentSong)
      }
    },

  }
}

In the above code, you can see that when loadCurrentSong() runs, it changes the src attribute of the HTMLAudioElement, which automatically begins loading the song file. When enough of the file has been loaded, HTMLAudioElement fires a "canplay" event. This gets heard by the relevant event listener, which begins playing the song only if the user didn't have playback paused.

Is there a way of ensuring that MediaSession stays "alive" when switching songs? I'm open to using a library, but only if lets me handle the user interface (in Vue). I tried Howler.js, but I still ran into the exact same problem.


Solution

  • I fixed it! For some reason, MediaSession will shut down any time the src attribute changes, unless audio playback starts immediately afterward. I had to figure out some way to accommodate this "feature" while still preserving the ability for users to skip tracks in pause mode. I came up with a solution after reading this question and this question. I created a second audio element hardcoded with 10 seconds of silence, which I downloaded from this repository. At the end of the loadCurrentSong() function, after the src of the main audio element is changed, the silence plays and then immediately pauses. Since it happens synchronously, the user will never notice it, but it's enough to keep MediaSession alive. Here's the relevant bits of modified code:

    export const useMusicStore = defineStore('music', {
    
      state: () => ({
        player: new Audio(),
        silence: new Audio('10-seconds-of-silence.mp3'),
        // ...
      }),
    
      actions: {
    
        loadCurrentSong() {
          if (this.currentSong) {
            this.player.src = this.getSongUrl(this.currentSong)
            this.player.load()
            this.updateMetadata()
            this.updatePositionState()
            this.silence.play()
            this.silence.pause()
          }
        },
    
      }
    }