diff --git a/front/src/components/Queue.vue b/front/src/components/Queue.vue
index 6887e9401..759f8a0f9 100644
--- a/front/src/components/Queue.vue
+++ b/front/src/components/Queue.vue
@@ -258,7 +258,7 @@ const reorderTracks = async (from: number, to: number) => {
>
diff --git a/front/src/components/audio/Player.vue b/front/src/components/audio/Player.vue
index bf3ff4a69..d4d6a991b 100644
--- a/front/src/components/audio/Player.vue
+++ b/front/src/components/audio/Player.vue
@@ -130,7 +130,7 @@ const { width: progressWidth } = useElementSize(progressBar)
>
({
+const audioCache = new LRUCache
>({
max: 20,
- disposeAfter (buffer: AudioBuffer, key: string) {
+ disposeAfter (source: IMediaElementAudioSourceNode, key: string) {
// In case we've disposed the current buffer from cache, add it back
- if (buffer === currentNode.value?.buffer) {
- bufferCache.set(key, buffer)
+ if (source === currentNode.value) {
+ audioCache.set(key, source)
}
}
})
-const loadTrackBuffer = async (track: Track, abortSignal?: AbortSignal) => {
- if (bufferCache.has(track.id)) {
- return bufferCache.get(track.id)
+const loadAudio = async (track: Track, abortSignal?: AbortSignal) => {
+ if (audioCache.has(track.id)) {
+ return audioCache.get(track.id)
}
const sources = await useTrackSources(track, abortSignal)
if (!sources.length) return null
- // TODO: Quality picker
- const response = await axios.get(sources[0].url, {
- responseType: 'arraybuffer'
- })
+ const audio = document.createElement('audio')
+ audio.preload = 'auto'
- const buffer = await context.decodeAudioData(response.data)
- bufferCache.set(track.id, buffer)
+ // @ts-expect-error Firefox doesn't yet support NetworkInformation
+ // without a `dom.netinfo.enabled` flag enabled
+ const type = navigator.connection?.effectiveType
+ const index = type === '2g' || type === '3g'
+ ? sources.length - 1
+ // TODO: Quality picker - get audio quality from store
+ : 0
- return buffer
+ audio.src = sources[index].url
+
+ const source = context.createMediaElementSource(audio)
+ source.addEventListener('ended', ended)
+
+ audioCache.set(track.id, source)
+ return source
}
const ended = () => {
@@ -60,31 +69,78 @@ const ended = () => {
}
}
-let globalAbortController: AbortController
-const playTrack = async (track: Track) => {
- // Abort previous play request
- globalAbortController?.abort()
- const abortController = globalAbortController = new AbortController()
-
- const buffer = await loadTrackBuffer(track, abortController.signal)
- if (abortController.signal.aborted) return false
- if (buffer === null) return null
-
- const source = new AudioBufferSourceNode(context, {
- buffer
- })
-
+const createMediaNode = async (track: Track) => {
+ // TODO (wvffle): Sync
+ const source = await preload(track)
+ if (!source) return null
source.connect(gainNode)
- source.addEventListener('ended', ended)
return source
}
-// Preload current track buffer
-const currentTrack = computed(() => store.state.queue.tracks[store.state.queue.currentIndex])
-if (currentTrack.value) {
- loadTrackBuffer(currentTrack.value)
+const preloadControllers = shallowReactive(new Map())
+const preload = (track: Track) => {
+ if (track && audioCache.has(track.id)) {
+ return audioCache.get(track.id)
+ }
+
+ const controller = new AbortController()
+ preloadControllers.set(track.id, controller)
+
+ const msg = `Preloading ${track.artist?.name ?? 'Unknown artist'} - ${track.title}`
+ logger.time(msg)
+
+ const promise = loadAudio(track, controller.signal).then(data => {
+ preloadControllers.delete(track.id)
+ logger.timeEnd(msg)
+ return data
+ })
+
+ return promise
}
+const preloads = computed>(() => {
+ const index = store.state.queue.currentIndex
+ const tracks = store.state.queue.tracks
+
+ const preloads = uniq([...Array(TO_PRELOAD).keys()].map(i => {
+ const preloadIndex = (index + i) % tracks.length
+ return tracks[preloadIndex]
+ }))
+
+ return preloads.length === 0
+ ? [tracks[index - 1]]
+ : preloads
+})
+
+// Preloading handler
+watchDebounced([
+ // on index change
+ () => store.state.queue.currentIndex,
+ // on new track
+ () => store.state.queue.tracks,
+ // on shuffle/unshuffle
+ () => store.state.queue.shuffleAbortController
+], async () => {
+ const shouldPreload = preloads.value
+
+ // Abort requests we no longer need
+ for (const [id, controller] of preloadControllers.entries()) {
+ if (!shouldPreload.some(track => track?.id === id)) {
+ controller.abort()
+ logger.info(`Aborted loading track ${id}`)
+ }
+ }
+
+ // Preload new reqests synchronously
+ for (const track of shouldPreload) {
+ if (track && !preloadControllers.has(track.id)) {
+ await preload(track)
+ }
+ }
+}, { immediate: true, debounce: 1000 })
+
+const currentTrack = computedEager(() => store.state.queue.tracks[store.state.queue.currentIndex])
+
//
// Audio gain
//
@@ -103,7 +159,7 @@ const toggleMute = () => store.state.player.volume === 0
//
// Audio playback
//
-const currentNode = shallowRef | null>(null)
+const currentNode = shallowRef | null>(null)
const playerState = reactive({
playing: false,
startedAt: 0,
@@ -130,8 +186,8 @@ const stop = () => {
}
const seek = (addTime: number) => {
- if (currentNode.value?.buffer) {
- progress.value = Math.max(0, Math.min(100, progress.value + (addTime / currentNode.value?.buffer?.duration) * 100))
+ if (currentNode.value) {
+ progress.value = Math.max(0, Math.min(100, progress.value + (addTime / duration.value) * 100))
}
}
@@ -172,12 +228,11 @@ const previous = async () => {
}
// Stop node, remove handlers and disconnect from gain node
-const stopNode = (node: IAudioBufferSourceNode | null) => {
+const stopNode = (node: IMediaElementAudioSourceNode | null) => {
pauseProgress()
if (node === null) return
node.removeEventListener('ended', ended)
- node.stop()
node.disconnect(gainNode)
}
@@ -188,12 +243,11 @@ watchDebounced([
() => playerState.playing,
currentTrack
], async () => {
-// watchEffect(async () => {
if (playerState.playing && currentTrack.value) {
stopNode(currentNode.value)
currentNode.value = null
- const source = await playTrack(currentTrack.value)
+ const source = await createMediaNode(currentTrack.value)
// Play request is aborted
if (source === false) return
@@ -206,16 +260,17 @@ watchDebounced([
// NOTE: We've now list reactivity tracking after the first await call
- if (playerState.pausedAt !== 0) {
- // Start from the paused moment
- source.start(0, playerState.pausedAt - playerState.startedAt)
- playerState.pausedAt = 0
- } else {
- // Start from the beginning
- source.start()
- playerState.startedAt = context.currentTime
- }
+ // if (playerState.pausedAt !== 0) {
+ // // Start from the paused moment
+ // source.start(0, playerState.pausedAt - playerState.startedAt)
+ // playerState.pausedAt = 0
+ // } else {
+ // // Start from the beginning
+ // source.start()
+ // playerState.startedAt = context.currentTime
+ // }
+ source.mediaElement.play()
currentNode.value = source
resumeProgress()
}
@@ -224,8 +279,7 @@ watchDebounced([
// Pause handler
watchEffect(() => {
if (!playerState.playing && currentTrack.value && currentNode.value) {
- playerState.pausedAt = context.currentTime
- currentNode.value.stop()
+ currentNode.value.mediaElement.pause()
pauseProgress()
}
})
@@ -233,61 +287,30 @@ watchEffect(() => {
// Looping handler
watchEffect(() => {
if (currentNode.value) {
- currentNode.value.loop = store.state.player.looping === LoopState.LOOP_CURRENT
+ currentNode.value.mediaElement.loop = store.state.player.looping === LoopState.LOOP_CURRENT
|| (store.state.player.looping === LoopState.LOOP_QUEUE && store.state.queue.tracks.length === 1)
}
})
-// Preloading handler
-watchDebounced([
- // on index change
- () => store.state.queue.currentIndex,
- // on new track
- () => store.state.queue.tracks,
- // on shuffle/unshuffle
- () => store.state.queue.shuffleAbortController
-], async () => {
- const index = store.state.queue.currentIndex
- const tracks = store.state.queue.tracks
-
- // Try to preload 1 previous track and TO_PRELOAD - 1 future tracks
- const preloads = uniq([-2, ...Array(TO_PRELOAD - 1).keys()].map(i => {
- const preloadIndex = (index + i + 1) % tracks.length
- return tracks[preloadIndex]
- })).filter(track => track && !bufferCache.has(track.id))
-
- if (!preloads.length) {
- return
- }
-
- await Promise.all(preloads.map(async track => {
- const msg = `Preloading ${track.artist?.name ?? 'Unknown artist'} - ${track.title}`
-
- logger.time(msg)
- await loadTrackBuffer(track)
- logger.timeEnd(msg)
- }))
-
- logger.debug(`Preloaded ${preloads.length} tracks`)
-}, { immediate: true, debounce: 1000 })
-
// Progress getter and setter
const time = ref(0)
+const duration = computedEager(() => currentNode.value?.mediaElement.duration ?? 0)
const progress = computed({
// Get progress
- get: () => currentNode.value?.buffer
- ? Math.min(time.value / currentNode.value.buffer.duration * 100, 100)
+ get: () => currentNode.value
+ ? Math.min(time.value / duration.value * 100, 100)
: 0,
// Seek to percent
set: async (percent: number) => {
// Initialize track if we haven't already
- if (!currentNode.value?.buffer) {
+ if (!currentNode.value) {
await play()
+ await nextTick()
progress.value = percent
return
}
- const time = percent / 100 * currentNode.value.buffer.duration
+ const time = percent / 100 * duration.value
pause()
playerState.startedAt = context.currentTime - time
playerState.pausedAt = context.currentTime
@@ -310,7 +333,7 @@ const { resume: resumeProgress, pause: pauseProgress } = useRafFn(() => {
const isListened = ref(false)
watchEffect(() => {
// When we are done but looping, reset startedAt
- if (progress.value === 100 && currentNode.value?.loop) {
+ if (progress.value === 100 && currentNode.value?.mediaElement.loop) {
playerState.startedAt = context.currentTime
}
@@ -333,7 +356,7 @@ watchEffect(() => {
// Exports
export default () => ({
// Audio loading
- loadTrackBuffer,
+ preload,
// Audio gain
toggleMute,
unmute,
@@ -348,7 +371,7 @@ export default () => ({
errored,
time,
progress,
- duration: computed(() => currentNode.value?.buffer?.duration ?? 0),
+ duration,
playing: computedEager(() => playerState.playing),
loading: computedEager(() => playerState.playing && currentTrack.value && !currentNode.value)
})