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 { 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<State, any> = {
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, any> = {
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<State, any> = {
},
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<State, any> = {
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<State, any> = {
},
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<State, any> = {
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<State, any> = {
}
export function setupAudio(store: Store<any>, 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<any>, 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() {