From fd71ce5d156a97fc9fcbe9df307f0b9d87df2cc6 Mon Sep 17 00:00:00 2001 From: Thomas Amland Date: Mon, 15 Feb 2021 18:46:37 +0100 Subject: [PATCH] add new audio controller --- src/player/audio.ts | 163 ++++++++++++++++++++++++++++++++++++++++++++ src/player/store.ts | 114 ++++++++++++++++--------------- 2 files changed, 221 insertions(+), 56 deletions(-) create mode 100644 src/player/audio.ts diff --git a/src/player/audio.ts b/src/player/audio.ts new file mode 100644 index 0000000..b200194 --- /dev/null +++ b/src/player/audio.ts @@ -0,0 +1,163 @@ +export class AudioController { + private audio = new Audio() + private handle = -1 + private ended = false + private volume = 1.0 + private fadeDuration = 200 + private endCutoff = 200 + private eventHandlers = null as any + private buffer = new Audio() + + currentTime() { + return this.audio.currentTime + } + + duration() { + return this.audio.duration + } + + setEventHandlers(handlers: any) { + this.eventHandlers = handlers + } + + setBuffer(url: string) { + this.buffer.src = url + } + + setVolume(value: number) { + this.cancelFade() + this.volume = value + this.audio.volume = value + } + + async pause() { + await this.fadeOut() + this.audio.pause() + } + + async resume() { + this.audio.volume = 0.0 + await this.audio.play() + this.fadeIn() + } + + async seek(value: number) { + await this.fadeOut(this.fadeDuration / 2.0) + this.audio.volume = 0.0 + this.audio.currentTime = value + await this.fadeIn(this.fadeDuration / 2.0) + } + + async changeTrack(url: string, options: { paused?: boolean } = {}) { + if (this.audio) { + this.cancelFade() + endPlayback(this.audio, this.fadeDuration) + } + this.audio = new Audio(url) + this.audio.ondurationchange = () => { + this.eventHandlers?.onDurationChange(this.audio.duration) + } + this.audio.onerror = () => { + this.eventHandlers?.onError(this.audio.error) + } + this.audio.onended = () => { + if (!this.ended) { + this.ended = true + this.eventHandlers?.onEnded() + } + } + this.audio.ontimeupdate = () => { + this.eventHandlers?.onTimeUpdate(this.audio.currentTime) + const left = (this.audio.duration - this.audio.currentTime) * 1000 + if (!this.ended && left <= this.endCutoff + this.fadeDuration) { + console.info(`AudioController: ending. time left: ${left}`) + this.ended = true + this.eventHandlers?.onEnded() + } + } + this.ended = false + this.eventHandlers?.onDurationChange(this.audio.duration) + this.eventHandlers?.onTimeUpdate(this.audio.currentTime) + this.audio.volume = 0.0 + + if (options.paused !== true) { + await this.audio.play() + this.fadeIn() + } + } + + private cancelFade() { + clearTimeout(this.handle) + } + + private fadeIn(duration: number = this.fadeDuration) { + this.fadeFromTo(0.0, this.volume, duration).then() + } + + private fadeOut(duration: number = this.fadeDuration) { + return this.fadeFromTo(this.volume, 0.0, duration) + } + + private fadeFromTo(from: number, to: number, duration: number) { + console.info(`AudioController: start fade (${from}, ${to}, ${duration})`) + const startTime = Date.now() + const step = (to - from) / duration + if (duration <= 0.0) { + this.audio.volume = to + } + clearTimeout(this.handle) + return new Promise((resolve) => { + const run = () => { + if (this.audio.volume === to) { + console.info( + 'AudioController: fade result. ' + + `duration: ${duration}ms, actual: ${Date.now() - startTime}ms, ` + + `volume: ${this.audio.volume}`) + resolve() + return + } + const elapsed = Date.now() - startTime + this.audio.volume = clamp(0.0, this.volume, from + (elapsed * step)) + this.handle = setTimeout(run, 10) + } + run() + }) + } +} + +function endPlayback(audio: HTMLAudioElement, duration: number) { + async function fade(audio: HTMLAudioElement, from: number, to: number, duration: number) { + if (duration <= 0.0) { + audio.volume = to + return audio + } + const startTime = Date.now() + const step = (to - from) / duration + while (audio.volume !== to) { + const elapsed = Date.now() - startTime + audio.volume = clamp(0.0, 1.0, from + (elapsed * step)) + await sleep(10) + } + return audio + } + console.info(`AudioController: ending payback for ${audio}`) + audio.ontimeupdate = null + audio.ondurationchange = null + audio.onerror = null + audio.onended = null + const startTime = Date.now() + fade(audio, audio.volume, 0.0, duration) + .catch((err) => console.warn('Error during fade out: ' + err.stack)) + .finally(() => { + audio.pause() + console.info(`AudioController: ending payback done. actual ${Date.now() - startTime}ms`) + }) +} + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function clamp(min: number, max: number, value: number) { + return Math.max(min, Math.min(value, max)) +} diff --git a/src/player/store.ts b/src/player/store.ts index bf589b1..0e035d2 100644 --- a/src/player/store.ts +++ b/src/player/store.ts @@ -1,17 +1,14 @@ import { Store, Module } from 'vuex' import { shuffle, trackListEquals } from '@/shared/utils' import { API } from '@/shared/api' +import { AudioController } from '@/player/audio' -const audio = new Audio() const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]') const storedQueueIndex = parseInt(localStorage.getItem('queueIndex') || '-1') -if (storedQueueIndex > -1 && storedQueueIndex < storedQueue.length) { - audio.src = storedQueue[storedQueueIndex].url -} const storedVolume = parseFloat(localStorage.getItem('player.volume') || '1.0') const storedMuteState = localStorage.getItem('player.mute') === 'true' -audio.volume = storedMuteState ? 0.0 : storedVolume const mediaSession: MediaSession | undefined = navigator.mediaSession +const audio = new AudioController() interface State { queue: any[]; @@ -86,8 +83,9 @@ export const playerModule: Module = { persistQueue(state) state.scrobbled = false const track = state.queue[index] - audio.src = track.url state.duration = track.duration + const next = (index + 1) % state.queue.length + audio.setBuffer(state.queue[next].url) document.title = `${track.title} • ${track.artist}` if (mediaSession) { mediaSession.metadata = new MediaMetadata({ @@ -124,7 +122,9 @@ export const playerModule: Module = { state.currentTime = value }, setDuration(state, value: any) { - state.duration = value + if (isFinite(value)) { + state.duration = value + } }, setScrobbled(state) { state.scrobbled = true @@ -137,11 +137,11 @@ export const playerModule: Module = { }, actions: { - async playTrackList({ commit, state }, { tracks, index }) { + async playTrackList({ commit, state, getters }, { tracks, index }) { if (trackListEquals(state.queue, tracks)) { commit('setQueueIndex', index) commit('setPlaying') - await audio.play() + await audio.changeTrack(getters.track.url) return } tracks = [...tracks] @@ -156,45 +156,38 @@ export const playerModule: Module = { commit('setQueueIndex', index) } commit('setPlaying') - await audio.play() + await audio.changeTrack(getters.track.url) }, async resume({ commit }) { commit('setPlaying') - await audio.play() + await audio.resume() }, async pause({ commit }) { audio.pause() commit('setPaused') }, async playPause({ state, dispatch }) { - if (state.isPlaying) { - return dispatch('pause') - } - return dispatch('resume') + return state.isPlaying ? dispatch('pause') : dispatch('resume') }, - async next({ commit, state, getters, dispatch }) { - if (!state.repeat && !getters.hasNext) { - return dispatch('resetQueue') - } + async next({ commit, state, getters }) { commit('setQueueIndex', state.queueIndex + 1) commit('setPlaying') - await audio.play() + await audio.changeTrack(getters.track.url) }, - async previous({ commit, state }) { - commit('setQueueIndex', - audio.currentTime > 3 ? state.queueIndex : state.queueIndex - 1) + async previous({ commit, state, getters }) { + commit('setQueueIndex', audio.currentTime() > 3 ? state.queueIndex : state.queueIndex - 1) commit('setPlaying') - await audio.play() + await audio.changeTrack(getters.track.url) }, seek({ state }, value) { if (isFinite(state.duration)) { - audio.currentTime = state.duration * value + audio.seek(state.duration * value) } }, - resetQueue({ commit }) { - audio.pause() + async resetQueue({ commit, getters }) { commit('setQueueIndex', 0) commit('setPaused') + await audio.changeTrack(getters.track.url, { paused: true }) }, toggleRepeat({ commit, state }) { commit('setRepeat', !state.repeat) @@ -204,7 +197,7 @@ export const playerModule: Module = { }, toggleMute({ commit, state }) { commit('setMute', !state.mute) - audio.volume = state.mute ? 0.0 : state.volume + audio.setVolume(state.mute ? 0.0 : state.volume) }, addToQueue({ state, commit }, tracks) { commit('addToQueue', state.shuffle ? shuffle([...tracks]) : tracks) @@ -213,7 +206,7 @@ export const playerModule: Module = { commit('setNextInQueue', state.shuffle ? shuffle([...tracks]) : tracks) }, setVolume({ commit }, value) { - audio.volume = value + audio.setVolume(value) commit('setVolume', value) }, }, @@ -247,31 +240,40 @@ export const playerModule: Module = { } export function setupAudio(store: Store, api: API) { - audio.ontimeupdate = () => { - store.commit('player/setCurrentTime', audio.currentTime) + audio.setEventHandlers({ + onTimeUpdate: (value: number) => { + store.commit('player/setCurrentTime', value) + // Scrobble + if ( + store.state.player.scrobbled === false && + store.state.player.duration > 30 && + audio.currentTime() / store.state.player.duration > 0.7 + ) { + const id = store.getters['player/trackId'] + store.commit('player/setScrobbled') + api.scrobble(id) + } + }, + onDurationChange: (value: number) => { + store.commit('player/setDuration', value) + }, + onError: (error: any) => { + store.commit('player/setPaused') + store.commit('setError', error) + }, + onEnded: () => { + if (store.getters['player/hasNext'] || store.state.player.repeat) { + return store.dispatch('player/next') + } else { + return store.dispatch('player/resetQueue') + } + }, + }) - // Scrobble - if ( - store.state.player.scrobbled === false && - store.state.player.duration > 30 && - audio.currentTime / store.state.player.duration > 0.7 - ) { - const id = store.getters['player/trackId'] - store.commit('player/setScrobbled') - api.scrobble(id) - } - } - audio.ondurationchange = () => { - if (isFinite(audio.duration)) { - store.commit('player/setDuration', audio.duration) - } - } - audio.onerror = () => { - store.commit('player/setPaused') - store.commit('setError', audio.error) - } - audio.onended = () => { - store.dispatch('player/next') + audio.setVolume(storedMuteState ? 0.0 : storedVolume) + const url = store.getters['player/track']?.url + if (url) { + audio.changeTrack(url, { paused: true }) } if (mediaSession) { @@ -292,16 +294,16 @@ export function setupAudio(store: Store, api: API) { }) mediaSession.setActionHandler('seekto', (details) => { if (details.seekTime) { - audio.currentTime = details.seekTime + audio.seek(details.seekTime) } }) mediaSession.setActionHandler('seekforward', (details) => { const offset = details.seekOffset || 10 - audio.currentTime = Math.min(audio.currentTime + offset, audio.duration) + audio.seek(Math.min(audio.currentTime() + offset, audio.duration())) }) mediaSession.setActionHandler('seekbackward', (details) => { const offset = details.seekOffset || 10 - audio.currentTime = Math.max(audio.currentTime - offset, 0) + audio.seek(Math.max(audio.currentTime() - offset, 0)) }) // FIXME // function updatePositionState() {