add new audio controller

This commit is contained in:
Thomas Amland 2021-02-15 18:46:37 +01:00
parent 1d49e741b0
commit fd71ce5d15
2 changed files with 221 additions and 56 deletions

163
src/player/audio.ts Normal file
View File

@ -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<void>((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))
}

View File

@ -1,17 +1,14 @@
import { Store, Module } from 'vuex' import { Store, Module } from 'vuex'
import { shuffle, trackListEquals } from '@/shared/utils' import { shuffle, trackListEquals } from '@/shared/utils'
import { API } from '@/shared/api' import { API } from '@/shared/api'
import { AudioController } from '@/player/audio'
const audio = new Audio()
const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]') const storedQueue = JSON.parse(localStorage.getItem('queue') || '[]')
const storedQueueIndex = parseInt(localStorage.getItem('queueIndex') || '-1') 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 storedVolume = parseFloat(localStorage.getItem('player.volume') || '1.0')
const storedMuteState = localStorage.getItem('player.mute') === 'true' const storedMuteState = localStorage.getItem('player.mute') === 'true'
audio.volume = storedMuteState ? 0.0 : storedVolume
const mediaSession: MediaSession | undefined = navigator.mediaSession const mediaSession: MediaSession | undefined = navigator.mediaSession
const audio = new AudioController()
interface State { interface State {
queue: any[]; queue: any[];
@ -86,8 +83,9 @@ export const playerModule: Module<State, any> = {
persistQueue(state) persistQueue(state)
state.scrobbled = false state.scrobbled = false
const track = state.queue[index] const track = state.queue[index]
audio.src = track.url
state.duration = track.duration state.duration = track.duration
const next = (index + 1) % state.queue.length
audio.setBuffer(state.queue[next].url)
document.title = `${track.title}${track.artist}` document.title = `${track.title}${track.artist}`
if (mediaSession) { if (mediaSession) {
mediaSession.metadata = new MediaMetadata({ mediaSession.metadata = new MediaMetadata({
@ -124,7 +122,9 @@ export const playerModule: Module<State, any> = {
state.currentTime = value state.currentTime = value
}, },
setDuration(state, value: any) { setDuration(state, value: any) {
if (isFinite(value)) {
state.duration = value state.duration = value
}
}, },
setScrobbled(state) { setScrobbled(state) {
state.scrobbled = true state.scrobbled = true
@ -137,11 +137,11 @@ export const playerModule: Module<State, any> = {
}, },
actions: { actions: {
async playTrackList({ commit, state }, { tracks, index }) { async playTrackList({ commit, state, getters }, { tracks, index }) {
if (trackListEquals(state.queue, tracks)) { if (trackListEquals(state.queue, tracks)) {
commit('setQueueIndex', index) commit('setQueueIndex', index)
commit('setPlaying') commit('setPlaying')
await audio.play() await audio.changeTrack(getters.track.url)
return return
} }
tracks = [...tracks] tracks = [...tracks]
@ -156,45 +156,38 @@ export const playerModule: Module<State, any> = {
commit('setQueueIndex', index) commit('setQueueIndex', index)
} }
commit('setPlaying') commit('setPlaying')
await audio.play() await audio.changeTrack(getters.track.url)
}, },
async resume({ commit }) { async resume({ commit }) {
commit('setPlaying') commit('setPlaying')
await audio.play() await audio.resume()
}, },
async pause({ commit }) { async pause({ commit }) {
audio.pause() audio.pause()
commit('setPaused') commit('setPaused')
}, },
async playPause({ state, dispatch }) { async playPause({ state, dispatch }) {
if (state.isPlaying) { return state.isPlaying ? dispatch('pause') : dispatch('resume')
return dispatch('pause')
}
return dispatch('resume')
}, },
async next({ commit, state, getters, dispatch }) { async next({ commit, state, getters }) {
if (!state.repeat && !getters.hasNext) {
return dispatch('resetQueue')
}
commit('setQueueIndex', state.queueIndex + 1) commit('setQueueIndex', state.queueIndex + 1)
commit('setPlaying') commit('setPlaying')
await audio.play() await audio.changeTrack(getters.track.url)
}, },
async previous({ commit, state }) { async previous({ commit, state, getters }) {
commit('setQueueIndex', commit('setQueueIndex', audio.currentTime() > 3 ? state.queueIndex : state.queueIndex - 1)
audio.currentTime > 3 ? state.queueIndex : state.queueIndex - 1)
commit('setPlaying') commit('setPlaying')
await audio.play() await audio.changeTrack(getters.track.url)
}, },
seek({ state }, value) { seek({ state }, value) {
if (isFinite(state.duration)) { if (isFinite(state.duration)) {
audio.currentTime = state.duration * value audio.seek(state.duration * value)
} }
}, },
resetQueue({ commit }) { async resetQueue({ commit, getters }) {
audio.pause()
commit('setQueueIndex', 0) commit('setQueueIndex', 0)
commit('setPaused') commit('setPaused')
await audio.changeTrack(getters.track.url, { paused: true })
}, },
toggleRepeat({ commit, state }) { toggleRepeat({ commit, state }) {
commit('setRepeat', !state.repeat) commit('setRepeat', !state.repeat)
@ -204,7 +197,7 @@ export const playerModule: Module<State, any> = {
}, },
toggleMute({ commit, state }) { toggleMute({ commit, state }) {
commit('setMute', !state.mute) commit('setMute', !state.mute)
audio.volume = state.mute ? 0.0 : state.volume audio.setVolume(state.mute ? 0.0 : state.volume)
}, },
addToQueue({ state, commit }, tracks) { addToQueue({ state, commit }, tracks) {
commit('addToQueue', state.shuffle ? shuffle([...tracks]) : tracks) commit('addToQueue', state.shuffle ? shuffle([...tracks]) : tracks)
@ -213,7 +206,7 @@ export const playerModule: Module<State, any> = {
commit('setNextInQueue', state.shuffle ? shuffle([...tracks]) : tracks) commit('setNextInQueue', state.shuffle ? shuffle([...tracks]) : tracks)
}, },
setVolume({ commit }, value) { setVolume({ commit }, value) {
audio.volume = value audio.setVolume(value)
commit('setVolume', value) commit('setVolume', value)
}, },
}, },
@ -247,31 +240,40 @@ export const playerModule: Module<State, any> = {
} }
export function setupAudio(store: Store<any>, api: API) { export function setupAudio(store: Store<any>, api: API) {
audio.ontimeupdate = () => { audio.setEventHandlers({
store.commit('player/setCurrentTime', audio.currentTime) onTimeUpdate: (value: number) => {
store.commit('player/setCurrentTime', value)
// Scrobble // Scrobble
if ( if (
store.state.player.scrobbled === false && store.state.player.scrobbled === false &&
store.state.player.duration > 30 && store.state.player.duration > 30 &&
audio.currentTime / store.state.player.duration > 0.7 audio.currentTime() / store.state.player.duration > 0.7
) { ) {
const id = store.getters['player/trackId'] const id = store.getters['player/trackId']
store.commit('player/setScrobbled') store.commit('player/setScrobbled')
api.scrobble(id) api.scrobble(id)
} }
} },
audio.ondurationchange = () => { onDurationChange: (value: number) => {
if (isFinite(audio.duration)) { store.commit('player/setDuration', value)
store.commit('player/setDuration', audio.duration) },
} onError: (error: any) => {
}
audio.onerror = () => {
store.commit('player/setPaused') store.commit('player/setPaused')
store.commit('setError', audio.error) 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')
} }
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) { if (mediaSession) {
@ -292,16 +294,16 @@ export function setupAudio(store: Store<any>, api: API) {
}) })
mediaSession.setActionHandler('seekto', (details) => { mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime) { if (details.seekTime) {
audio.currentTime = details.seekTime audio.seek(details.seekTime)
} }
}) })
mediaSession.setActionHandler('seekforward', (details) => { mediaSession.setActionHandler('seekforward', (details) => {
const offset = details.seekOffset || 10 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) => { mediaSession.setActionHandler('seekbackward', (details) => {
const offset = details.seekOffset || 10 const offset = details.seekOffset || 10
audio.currentTime = Math.max(audio.currentTime - offset, 0) audio.seek(Math.max(audio.currentTime() - offset, 0))
}) })
// FIXME // FIXME
// function updatePositionState() { // function updatePositionState() {