add new audio controller
This commit is contained in:
parent
1d49e741b0
commit
fd71ce5d15
163
src/player/audio.ts
Normal file
163
src/player/audio.ts
Normal 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))
|
||||
}
|
@ -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() {
|
||||
|
Loading…
x
Reference in New Issue
Block a user