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) { | ||||||
|  |       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() {
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user