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 { 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) {
|
||||||
state.duration = value
|
if (isFinite(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
|
||||||
|
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
|
audio.setVolume(storedMuteState ? 0.0 : storedVolume)
|
||||||
if (
|
const url = store.getters['player/track']?.url
|
||||||
store.state.player.scrobbled === false &&
|
if (url) {
|
||||||
store.state.player.duration > 30 &&
|
audio.changeTrack(url, { paused: true })
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user