Safari audio.play() - NotAllowedError (DOM Exception 35)

I have an audio player on my project that gets executed after a click on a button, although safari is giving me hell with it.

NotAllowedError (DOM Exception 35): The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.

My set up is this, using Vue.js:

Component that is starting the action:

a.tile-panel__controls(@click="togglePlayStatus(loadedBeats, index)")

Store action:

togglePlayStatus({ state, commit }, { beats, index }) {
    commit('SET_PLAYLIST', beats)
    // basically on the very first click, show the player and set audioPaused to false
    if (state.playerIsShowing === false && state.beatPlaying == null) {
        commit('SHOW_PLAYER')
        commit('SET_CURRENT_BEAT_PLAYING', index)
        commit('TOGGLE_PLAY_STATUS')
    } else {
        // if the click is on the same beat, pause beat
        if (state.beatPlaying.id === beats[index].id) commit('TOGGLE_PLAY_STATUS')
        else {
            // set the new current beat playing
            commit('SET_CURRENT_BEAT_PLAYING', index)
            // and if there is no track playing we toggle status
            if (state.audioPaused === true) commit('TOGGLE_PLAY_STATUS')
        }
    }
},

Mutations

SHOW_PLAYER: state => { state.playerIsShowing = true },

SET_PLAYLIST: (state, payload) => { state.playlist = payload },

TOGGLE_PLAY_STATUS: (state, player) => { state.audioPaused = !state.audioPaused },

SET_CURRENT_BEAT_PLAYING: (state, index) => {
    state.beatPlayingIndex = index
    state.beatPlaying = state.playlist[state.beatPlayingIndex]
},

Player component

Template:

 - some template stuff that isn't relevant
audio(ref="player" autoplay="true" :src="`/s3/beat_play/${beatPlaying.id}/`" @timeupdate="updateTime" @loadeddata="loadTrack" @ended="NEXT" )

Computed:

...mapState('player', ['playerIsShowing', 'audioPaused', 'beatPlaying']),

Methods:

TOGGLE_PLAY_STATUS() {
    this.audioPaused ? this.$refs.player.play() : this.$refs.player.pause()
    this.$store.commit('player/TOGGLE_PLAY_STATUS')
},

updateTime() {
    this.currentPosition = this.$refs.player.currentTime
},

loadTrack() {
    try
        this.trackTotalDuration = this.$refs.player.duration
        this.$refs.player.play()
    } catch (err) {
        console.error(err)
    }
}

Watch:

audioPaused(value) {
    if (this.$refs.player) {
        if (value === true) this.$refs.player.pause()
        else this.$refs.player.play()
    }
}

You’re getting this error in Safari (at least) because you can’t programatically play audio. It must be initiated by the user, in that it must be initiated within an event handler such as click. I see you’re using a watcher to toggle the playback. This is likely the issue.

Some articles, such as https://webkit.org/blog/7734/auto-play-policy-changes-for-macos/, propose a UI fallback if autoplay isn’t supported. Another thing to keep in mind is if you use the same audio object, but change the source you can programatically play it, however you still need that first interaction for the user which essentially grants permission.

Indeed. The watcher runs asynchronously in the next call stack, so as far as the browser can tell, the previous stack cAused by the click is done and The watch code runs in a new stack not started from a user interaction. So it blocks playback

1 Like

Thanks for the reply @james-brndwgn and @LinusBorg

The problem is that I’m handling the play/pause through the store, since there are different components that can play audio, but i can’t do audio.play() from the store, hence the watch.

I actually found an interesting repo on github, tested the demo on mobile and safari and it works fine, I then forked it, npm run to test it in development and it still has the issue… so I’m not sure what the guy did to avoid that issue in production. :roll_eyes: :weary:
https://github.com/apoStyLEE/vueaudioplayer

Well, he got it working because he actually toggles play/pause synchronously here:

He then saves the play/pause state in the store, and that happens asynchonously, but that doesn’t matter.

That’s all fine until you want to change the play/pause state throguh the store, which again will be async.

Probably one of those few situations where I would actually use an event bus to get the toggle signal to the player directly.

I guess I have to check to set that up efficiently.

Still, I was concerned when I saw that by running his code I still get that annoying safari error… Seems to be working good only in his production demo… Any idea what it might be?

He’s actually also doing it async https://github.com/apoStyLEE/vueaudioplayer/blob/master/src/components/Player.vue#L20-L41

He has a watch on startStopTrack(){ return this.$store.state.startStopTrack; } that calls the playerChange() function

Well, there’s one thing to consider: Before Vue 2.5, Vue pushed async watcher updates inot the microtask queue instead of a new callstack,which means before 2.5, this would have worked as the microtask queue is still executed withín the same, first callstack.

So maybe his demo app has been build with Vue <=2.4.*, which is why it still works as expected, but doesn’t in your development environment using Vue >=2.5.*


See Release notes here:

Internals

We have changed the implementation of Vue.nextTick to fix a few bugs (related to #6566, #6690). The change involves using a macro task instead of a micro task to defer DOM updates when inside a DOM event handler attached via v-on. This means any Vue updates triggered by state changes inside v-on handlers will be now deferred using a macro task. This may lead to changes in behavior when dealing with native DOM events.

For more details regarding micro/macro tasks, see this blog post.

1 Like

Hi again @LinusBorg ,
I re structured most of the code, originally it was still giving me errors in the console now it seems like it’s all working fine, except one thing.

When I change track, the src updates but the audio stops, I than have to pause it and play it again for it to work.
Maybe I’m doing something wrong with the audio.load(). I really can’t figure it out.

Here’s the code, maybe you can spot the issue right away.

Template

audio(preload="none" ref="player" @timeupdate="updateCurrentPosition" @loadeddata="setTrackTotDuration" @ended="playNext")
	source(v-if="beatPlayingSrc" :src="beatPlayingSrc" type="audio/mp3")

Script

data: () => ({
    currentPosition: 0,
    trackTotalDuration: 0,
    dragging: false
}),
computed: {
    ...mapState('player', ['playerIsShowing', 'audioPaused']),
    ...mapGetters('player', ['beatPlaying']),
    beat() { return this.beatPlaying },
    beatPlayingSrc() { if (this.beat) return `https://clickandrap.com/s3/beat_play/${this.beat.id}/` }
},
methods: {
    ...mapMutations('player', ['TOGGLE_PLAY_STATUS']),
    ...mapActions('player', ['playNext', 'playPrevious']),
    updateCurrentPosition() { this.currentPosition = this.$refs.player.currentTime },
    setTrackTotDuration() { this.trackTotalDuration = this.$refs.player.duration }
},
created() {
    Vue.playerBus.$on(EVENT_AUDIO_TOGGLE, audioPaused => {
        if (this.$refs.player) {
            if (audioPaused === true) this.$refs.player.pause()
            else this.$refs.player.play()
        }
    })
    Vue.playerBus.$on(EVENT_LOAD_TRACK, audioPaused => {
        if (this.$refs.player) this.$refs.player.load()
    })
}

Store

state: {
    playerIsShowing: false,
    playlist: [],
    audioPaused: true,
    beatPlayingIndex: null
},
getters: {
    beatPlaying: state => state.playlist[state.beatPlayingIndex]
},
mutations: {
    SHOW_PLAYER: state => { state.playerIsShowing = true },
    SET_PLAYLIST: (state, payload) => { state.playlist = payload },
    SET_CURRENT_BEAT_PLAYING: (state, index) => {
        state.beatPlayingIndex = index
        Vue.playerBus.$emit(EVENT_LOAD_TRACK, state.beatPlayingIndex)
    },
    TOGGLE_PLAY_STATUS: state => {
        state.audioPaused = !state.audioPaused
        Vue.playerBus.$emit(EVENT_AUDIO_TOGGLE, state.audioPaused)
    }
},
actions: {
    togglePlayStatus({ state, getters, commit }, { beats, index }) {
        commit('SET_PLAYLIST', beats)
        commit('SHOW_PLAYER')
        // if the beat clicked is the same as the currently playing beat, set to true
        const sameBeatPlaying = getters['beatPlaying'] && getters['beatPlaying'].id === beats[index].id
        commit('SET_CURRENT_BEAT_PLAYING', index)
        if (sameBeatPlaying || state.audioPaused) commit('TOGGLE_PLAY_STATUS')
    },

    playNext({ commit, state }) {
        if (state.audioPaused) commit('TOGGLE_PLAY_STATUS')
        let index
        // if the current beat playing is the ltas in the array, we jump back the first one
        if (state.beatPlayingIndex === state.playlist.length - 1) index = 0
        // otherwise we go to the next one
        else index = state.beatPlayingIndex + 1
        commit('SET_CURRENT_BEAT_PLAYING', index)
    },

    playPrevious({ commit, state }) {
        // if the audio is stopped, play
        if (state.audioPaused) commit('TOGGLE_PLAY_STATUS')
        // set the new track to play
        let index
        // if the current beat playing is the first in the array, we jump to the last one
        if (state.beatPlayingIndex === 0) index = state.playlist.length - 1
        // otherwise we go to the previous one
        else index = state.beatPlayingIndex - 1
        commit('SET_CURRENT_BEAT_PLAYING', index)
    }
}

Managed to make it work but it’s very ugly:

Script

created() {
    Vue.playerBus.$on(EVENT_LOAD_TRACK, () => {
        if (this.$refs.player) {
            setTimeout(() => {
                this.$refs.player.load()
                this.$refs.player.play()
            }, 0)
        }
    })
    Vue.playerBus.$on(EVENT_AUDIO_TOGGLE, audioPaused => {
        if (this.$refs.player) {
            if (audioPaused === true) this.$refs.player.pause()
            else this.$refs.player.play()
        }
    })

Store Action

    togglePlayStatus({ state, getters, commit }, { beats, index }) {
        commit('SET_PLAYLIST', beats)
        commit('SHOW_PLAYER')
        // if the beat clicked is the same as the currently playing beat, set sameBeatPlaying to true
        const sameBeatPlaying = getters['beatPlaying'] && getters['beatPlaying'].id === beats[index].id
        commit('SET_CURRENT_BEAT_PLAYING', index)
        if (!sameBeatPlaying) Vue.playerBus.$emit(EVENT_LOAD_TRACK)
        if (sameBeatPlaying || state.audioPaused) commit('TOGGLE_PLAY_STATUS')
    },

As you can see I must use the timeout otherwise it doesn’t play the right track.
If I don’t use timeout it basically loads/used the track that was loaded previous to the new click

1 Like