Rewrite player logic

This commit will bring:
- Gapless play! (Fix #739)
- Chunked queue shuffling - we play first track after first 50 queue items are shuffled, then we shuffle chunks of 50 queue items with each new animation frame.
- We can now restore original queue order after shuffling! (Part of #1506)
- Preloading whole tracks into LRU cache (Should fix #1812)
- Preloading multiple tracks at once
environments/review-front-deve-otr6gc/deployments/13419
wvffle 2022-07-28 23:45:53 +00:00 zatwierdzone przez Georg Krause
rodzic 465b6918e4
commit 97e7049333
27 zmienionych plików z 839 dodań i 929 usunięć

Wyświetl plik

@ -69,12 +69,12 @@ http {
text/x-component
text/x-cross-domain-policy;
add_header Content-Security-Policy "default-src 'self' 'unsafe-eval'; connect-src https: 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:";
add_header Content-Security-Policy "connect-src https: wss: 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header X-Frame-Options "SAMEORIGIN" always;
location /front/ {
add_header Content-Security-Policy "default-src 'self' 'unsafe-eval'; connect-src https: 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:";
add_header Content-Security-Policy "connect-src https: wss: 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; media-src 'self' data:";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Service-Worker-Allowed "/";
# uncomment the following line and comment the proxy-pass one

Wyświetl plik

@ -35,6 +35,7 @@
"howler": "2.2.3",
"js-logger": "1.6.1",
"lodash-es": "4.17.21",
"lru-cache": "^7.13.1",
"mavon-editor": "^3.0.0-beta",
"moment": "2.29.4",
"qs": "6.11.0",
@ -42,6 +43,7 @@
"sanitize-html": "2.7.1",
"sass": "1.54.0",
"showdown": "2.1.0",
"standardized-audio-context": "^25.3.29",
"text-clipper": "2.2.0",
"tiptap-markdown": "^0.5.0",
"transliteration": "2.3.5",

Wyświetl plik

@ -11,7 +11,7 @@ import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import { whenever, watchDebounced, useCurrentElement, useScrollLock } from '@vueuse/core'
import { useGettext } from 'vue3-gettext'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
import VirtualList from '~/components/vui/list/VirtualList.vue'
import QueueItem from '~/components/QueueItem.vue'
@ -24,33 +24,33 @@ const scrollLock = useScrollLock(document.body)
const store = useStore()
const {
playing,
loading: isLoadingAudio,
errored,
duration,
durationFormatted,
currentTimeFormatted,
progress,
bufferProgress,
currentTime,
pause,
resume
} = usePlayer()
const {
currentTrack,
hasNext,
isEmpty: emptyQueue,
tracks,
reorder,
endsIn: timeLeft,
currentIndex,
removeTrack,
clear,
next,
previous
clear
} = useQueue()
const currentIndex = computed(() => store.state.queue.currentIndex)
const currentTrack = computed(() => store.state.queue.tracks[currentIndex.value])
const hasNext = computed(() => store.getters['queue/hasNext'])
const durationFormatted = computed(() => time.parse(Math.floor(duration.value)))
const currentTimeFormatted = computed(() => time.parse(Math.floor(currentTime.value)))
const {
play,
pause,
next,
previous,
playing,
errored,
progress,
duration,
time: currentTime,
loading: isLoadingAudio
} = useWebAudioPlayer()
const labels = computed(() => ({
queue: $pgettext('*/*/*', 'Queue'),
duration: $pgettext('*/*/*', 'Duration'),
@ -105,13 +105,12 @@ router.beforeEach(() => store.commit('ui/queueFocused', null))
const progressBar = ref()
const touchProgress = (event: MouseEvent) => {
const time = ((event.clientX - (event.target as Element).getBoundingClientRect().left) / progressBar.value.offsetWidth) * duration.value
currentTime.value = time
const percent = (event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth
progress.value = percent * 100
}
const play = (index: unknown) => {
store.dispatch('queue/currentIndex', index as number)
resume()
const playIndex = (index: number) => {
store.state.queue.currentIndex = index
}
const getCover = (track: Track) => {
@ -255,12 +254,8 @@ const reorderTracks = async (from: number, to: number) => {
<div
ref="progressBar"
:class="['ui', 'small', 'vibrant', {'indicating': isLoadingAudio}, 'progress']"
@click="touchProgress"
@click.stop.prevent="touchProgress"
>
<div
class="buffer bar"
:style="{ 'transform': `translateX(${bufferProgress - 100}%)` }"
/>
<div
class="position bar"
:style="{ 'transform': `translateX(${progress - 100}%)` }"
@ -313,7 +308,7 @@ const reorderTracks = async (from: number, to: number) => {
:title="labels.play"
:aria-label="labels.play"
class="control"
@click.prevent.stop="resume"
@click.prevent.stop="play"
>
<i :class="['ui', 'play', {'disabled': !currentTrack}, 'icon']" />
</span>
@ -395,7 +390,7 @@ const reorderTracks = async (from: number, to: number) => {
:index="index"
:source="item"
:class="[...classList, currentIndex === index && 'active']"
@play="play"
@play="playIndex"
@remove="removeTrack"
/>
</template>

Wyświetl plik

@ -4,7 +4,7 @@ import type { Cover, Track } from '~/types'
import PlayButton from '~/components/audio/PlayButton.vue'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
import { computed } from 'vue'
interface Props {
@ -16,7 +16,7 @@ interface Props {
const props = defineProps<Props>()
const { currentTrack } = useQueue()
const { playing } = usePlayer()
const { playing } = useWebAudioPlayer()
const cover = computed(() => props.entry.cover ?? null)
const duration = computed(() => props.entry.uploads.find(upload => upload.duration)?.duration ?? null)

Wyświetl plik

@ -155,7 +155,7 @@ const openMenu = () => {
data-ref="enqueue"
:disabled="!playable"
:title="labels.addToQueue"
@click.stop.prevent="enqueue"
@click.stop.prevent="enqueue()"
>
<i class="plus icon" /><translate translate-context="*/Queue/Dropdown/Button/Label/Short">Add to queue</translate>
</button>

Wyświetl plik

@ -1,5 +1,7 @@
<script setup lang="ts">
// TODO (wvffle): Move most of this stufff to usePlayer
import { LoopState } from '~/store/player'
import time from '~/utils/time'
import { useStore } from '~/store'
import VolumeControl from './VolumeControl.vue'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
@ -7,9 +9,9 @@ import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import { computed, ref } from 'vue'
import { useGettext } from 'vue3-gettext'
import { useMouse, useWindowSize } from '@vueuse/core'
import { useMouse, useElementSize } from '@vueuse/core'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
const store = useStore()
const { $pgettext } = useGettext()
@ -19,40 +21,41 @@ const toggleMobilePlayer = () => {
}
const {
isShuffling,
shuffle,
previous,
isEmpty: queueIsEmpty,
currentIndex,
currentTrack,
hasNext,
hasPrevious,
currentTrack,
currentIndex,
tracks,
next
isEmpty: queueIsEmpty,
isShuffling,
isShuffled,
unshuffle,
shuffle,
clear
} = useQueue()
const {
playing,
loading: isLoadingAudio,
looping,
currentTime,
progress,
durationFormatted,
currentTimeFormatted,
bufferProgress,
duration,
toggleMute,
play,
pause,
seek,
togglePlayback,
resume,
pause
} = usePlayer()
next,
previous,
playing,
progress,
duration,
time: currentTime,
loading: isLoadingAudio
} = useWebAudioPlayer()
const durationFormatted = computed(() => time.parse(Math.floor(duration.value)))
const currentTimeFormatted = computed(() => time.parse(Math.floor(currentTime.value)))
// Key binds
onKeyboardShortcut('e', toggleMobilePlayer)
onKeyboardShortcut('p', togglePlayback)
onKeyboardShortcut('p', () => playing.value ? pause() : play())
onKeyboardShortcut('s', shuffle)
onKeyboardShortcut('q', () => store.dispatch('queue/clean'))
onKeyboardShortcut('q', () => clear)
onKeyboardShortcut('m', () => toggleMute)
onKeyboardShortcut('l', () => store.commit('player/toggleLooping'))
onKeyboardShortcut('f', () => store.dispatch('favorites/toggle', currentTrack.value?.id))
@ -86,22 +89,19 @@ const labels = computed(() => ({
addArtistContentFilter: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Hide content from this artist…')
}))
const setCurrentTime = (time: number) => {
currentTime.value = time
}
const switchTab = () => {
store.commit('ui/queueFocused', store.state.ui.queueFocused === 'player' ? 'queue' : 'player')
}
const progressBar = ref()
const touchProgress = (event: MouseEvent) => {
const time = ((event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth) * duration.value
currentTime.value = time
const percent = (event.clientX - ((event.target as Element).closest('.progress')?.getBoundingClientRect().left ?? 0)) / progressBar.value.offsetWidth
progress.value = percent * 100
}
// TODO (wvffle): Use createSharedComposable
const { x } = useMouse()
const { width: screenWidth } = useWindowSize()
const { width: progressWidth } = useElementSize(progressBar)
</script>
<template>
@ -128,17 +128,13 @@ const { width: screenWidth } = useWindowSize()
:class="['ui', 'top attached', 'small', 'inverted', {'indicating': isLoadingAudio}, 'progress']"
@click.prevent.stop="touchProgress"
>
<div
class="buffer bar"
:style="{ 'transform': `translateX(${bufferProgress - 100}%)` }"
/>
<div
class="position bar"
:style="{ 'transform': `translateX(${progress - 100}%)` }"
/>
<div
class="seek bar"
:style="{ 'transform': `translateX(${x / screenWidth * 100 - 100}%)` }"
:style="{ 'transform': `translateX(${x / progressWidth * 100 - 100}%)` }"
/>
</div>
<div class="controls-row">
@ -257,7 +253,7 @@ const { width: screenWidth } = useWindowSize()
:aria-label="labels.previous"
:disabled="!hasPrevious"
class="circular button control tablet-and-up"
@click.prevent.stop="$store.dispatch('queue/previous')"
@click.prevent.stop="previous"
>
<i :class="['ui', 'large', {'disabled': !hasPrevious}, 'backward step', 'icon']" />
</button>
@ -266,7 +262,7 @@ const { width: screenWidth } = useWindowSize()
:title="labels.play"
:aria-label="labels.play"
class="circular button control"
@click.prevent.stop="resume"
@click.prevent.stop="play"
>
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
</button>
@ -284,7 +280,7 @@ const { width: screenWidth } = useWindowSize()
:aria-label="labels.next"
:disabled="!hasNext"
class="circular button control"
@click.prevent.stop="$store.dispatch('queue/next')"
@click.prevent.stop="next"
>
<i :class="['ui', 'large', {'disabled': !hasNext}, 'forward step', 'icon']" />
</button>
@ -295,7 +291,7 @@ const { width: screenWidth } = useWindowSize()
<template v-if="!isLoadingAudio">
<span
class="start"
@click.stop.prevent="setCurrentTime(0)"
@click.stop.prevent="progress = 0"
>
{{ currentTimeFormatted }}
</span>
@ -308,57 +304,53 @@ const { width: screenWidth } = useWindowSize()
<div class="group">
<volume-control class="expandable" />
<button
v-if="looping === 0"
v-if="$store.state.player.looping === LoopState.NO_LOOP"
class="circular control button"
:title="labels.loopingDisabled"
:aria-label="labels.loopingDisabled"
:disabled="!currentTrack"
@click.prevent.stop="$store.commit('player/looping', 1)"
@click.prevent.stop="$store.commit('player/toggleLooping')"
>
<i :class="['ui', {'disabled': !currentTrack}, 'step', 'repeat', 'icon']" />
</button>
<button
v-if="looping === 1"
v-if="$store.state.player.looping === LoopState.LOOP_CURRENT"
class="looping circular control button"
:title="labels.loopingSingle"
:aria-label="labels.loopingSingle"
:disabled="!currentTrack"
class="looping circular control button"
@click.prevent.stop="$store.commit('player/looping', 2)"
@click.prevent.stop="$store.commit('player/toggleLooping')"
>
<i
class="repeat icon"
>
<i class="repeat icon">
<span class="ui circular tiny vibrant label">1</span>
</i>
</button>
<button
v-if="looping === 2"
v-if="$store.state.player.looping === LoopState.LOOP_QUEUE"
class="looping circular control button"
:title="labels.loopingWhole"
:aria-label="labels.loopingWhole"
:disabled="!currentTrack"
@click.prevent.stop="$store.commit('player/looping', 0)"
@click.prevent.stop="$store.commit('player/toggleLooping')"
>
<i
class="repeat icon"
>
<i class="repeat icon">
<span class="ui circular tiny vibrant label">&infin;</span>
</i>
</button>
<button
class="circular control button"
:disabled="queueIsEmpty || null"
class="circular control button shuffling"
:disabled="queueIsEmpty"
:title="labels.shuffle"
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
@click.prevent.stop="() => isShuffled ? unshuffle() : shuffle()"
>
<div
v-if="isShuffling"
class="ui inline shuffling inverted tiny active loader"
class="ui inline inverted tiny active loader"
/>
<i
v-else
:class="['ui', 'random', {'disabled': queueIsEmpty}, 'icon']"
:class="['ui', 'random', {disabled: queueIsEmpty, vibrant: isShuffled}, 'icon']"
/>
</button>
</div>

Wyświetl plik

@ -1,21 +1,21 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useStore } from '~/store'
import { useGettext } from 'vue3-gettext'
import { useStore } from '~/store'
import { useTimeoutFn } from '@vueuse/core'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
const { mute, unmute } = useWebAudioPlayer()
const store = useStore()
const { volume, mute, unmute } = usePlayer()
const sliderVolume = computed({
get: () => store.state.player.volume * 100,
set: (value) => store.commit('player/volume', value / 100)
})
const expanded = ref(false)
const volumeSteps = 100
const sliderVolume = computed({
get: () => volume.value * volumeSteps,
set: (value) => store.commit('player/volume', value / volumeSteps)
})
const { $pgettext } = useGettext()
const labels = computed(() => ({
unmute: $pgettext('Sidebar/Player/Icon.Tooltip/Verb', 'Unmute'),

Wyświetl plik

@ -9,7 +9,7 @@ import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue'
import usePlayOptions from '~/composables/audio/usePlayOptions'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
interface Props extends PlayOptionsProps {
track: Track
@ -39,7 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
const showTrackModal = ref(false)
const { currentTrack } = useQueue()
const { playing } = usePlayer()
const { playing } = useWebAudioPlayer()
const { activateTrack } = usePlayOptions(props)
const { $pgettext } = useGettext()

Wyświetl plik

@ -9,7 +9,7 @@ import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue'
import usePlayOptions from '~/composables/audio/usePlayOptions'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
interface Props extends PlayOptionsProps {
track: Track
@ -39,7 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
const showTrackModal = ref(false)
const { currentTrack } = useQueue()
const { playing } = usePlayer()
const { playing } = useWebAudioPlayer()
const { activateTrack } = usePlayOptions(props)
const { $pgettext } = useGettext()

Wyświetl plik

@ -8,7 +8,7 @@ import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import PlayButton from '~/components/audio/PlayButton.vue'
import usePlayOptions from '~/composables/audio/usePlayOptions'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
import { computed } from 'vue'
interface Props extends PlayOptionsProps {
@ -44,7 +44,7 @@ const props = withDefaults(defineProps<Props>(), {
displayActions: true
})
const { playing, loading } = usePlayer()
const { playing, loading } = useWebAudioPlayer()
const { currentTrack } = useQueue()
const { activateTrack } = usePlayOptions(props)

Wyświetl plik

@ -5,7 +5,7 @@ import { useStore } from '~/store'
import { useGettext } from 'vue3-gettext'
import { computed, markRaw, ref } from 'vue'
import axios from 'axios'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
import useQueue from '~/composables/audio/useQueue'
import { useCurrentElement } from '@vueuse/core'
import jQuery from 'jquery'
@ -26,8 +26,8 @@ export default (props: PlayOptionsProps) => {
// TODO (wvffle): Test if we can defineProps in composable
const store = useStore()
const { resume, pause, playing } = usePlayer()
const { currentTrack } = useQueue()
const { play, pause, next, playing } = useWebAudioPlayer()
const { currentTrack, clear } = useQueue()
const playable = computed(() => {
if (props.isPlayable) {
@ -133,32 +133,25 @@ export default (props: PlayOptionsProps) => {
}
const el = useCurrentElement()
const enqueue = async () => {
const enqueue = async (skip = false, index?: number) => {
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
const tracks = await getPlayableTracks()
await store.dispatch('queue/appendMany', { tracks })
addMessage(tracks)
}
const enqueueNext = async (next = false) => {
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
const tracks = await getPlayableTracks()
const wasEmpty = store.state.queue.tracks.length === 0
await store.dispatch('queue/appendMany', { tracks, index: store.state.queue.currentIndex + 1 })
if (next && !wasEmpty) {
await store.dispatch('queue/next')
resume()
await store.dispatch('queue/appendMany', { tracks, index })
if (skip && !wasEmpty) {
await next()
}
addMessage(tracks)
}
const enqueueNext = async (skip?: boolean) => enqueue(skip, store.state.queue.currentIndex + 1)
const replacePlay = async () => {
store.dispatch('queue/clean')
await clear()
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
@ -173,7 +166,7 @@ export default (props: PlayOptionsProps) => {
store.dispatch('queue/currentIndex', 0)
}
resume()
play()
addMessage(tracks)
}
@ -184,7 +177,7 @@ export default (props: PlayOptionsProps) => {
return pause()
}
return resume()
return play()
}
replacePlay()

Wyświetl plik

@ -1,265 +0,0 @@
import type { Track } from '~/types'
import { computed, watchEffect, ref, watch } from 'vue'
import { Howler } from 'howler'
import { useRafFn, useTimeoutFn } from '@vueuse/core'
import useQueue from '~/composables/audio/useQueue'
import useSound from '~/composables/audio/useSound'
import toLinearVolumeScale from '~/composables/audio/toLinearVolumeScale'
import store from '~/store'
import axios from 'axios'
const PRELOAD_DELAY = 15
const { currentSound, loadSound, onSoundProgress } = useSound()
const { isShuffling, currentTrack, currentIndex } = useQueue()
const looping = computed(() => store.state.player.looping)
const playing = computed(() => store.state.player.playing)
const loading = computed(() => store.state.player.isLoadingAudio)
const errored = computed(() => store.state.player.errored)
const focused = computed(() => store.state.ui.queueFocused === 'player')
// Cache sound if we have currentTrack available
if (currentTrack.value) {
loadSound(currentTrack.value)
}
// Playing
const playTrack = async (track: Track, oldTrack?: Track) => {
const oldSound = currentSound.value
// TODO (wvffle): Move oldTrack to watcher
if (oldSound && track !== oldTrack) {
oldSound.stop()
}
if (!track) {
return
}
if (!isShuffling.value) {
if (!track.uploads.length) {
// we don't have any information for this track, we need to fetch it
track = await axios.get(`tracks/${track.id}/`)
.then(response => response.data, () => null)
}
if (track === null) {
store.commit('player/isLoadingAudio', false)
store.dispatch('player/trackErrored')
return
}
currentSound.value = loadSound(track)
if (playing.value) {
currentSound.value.play()
store.commit('player/playing', true)
} else {
store.commit('player/isLoadingAudio', false)
}
store.commit('player/errored', false)
store.dispatch('player/updateProgress', 0)
}
}
const { start: loadTrack, stop: cancelLoading } = useTimeoutFn((track, oldTrack) => {
playTrack(track as Track, oldTrack as Track)
}, 100, { immediate: false }) as {
start: (a: Track, b: Track) => void
stop: () => void
}
watch(currentTrack, (track, oldTrack) => {
cancelLoading()
currentSound.value?.pause()
store.commit('player/isLoadingAudio', true)
loadTrack(track, oldTrack)
})
// Volume
const volume = computed({
get: () => store.state.player.volume,
set: (value) => store.commit('player/volume', value)
})
watchEffect(() => Howler.volume(toLinearVolumeScale(volume.value)))
const mute = () => store.dispatch('player/mute')
const unmute = () => store.dispatch('player/unmute')
const toggleMute = () => store.dispatch('player/toggleMute')
// Time and duration
const duration = computed(() => store.state.player.duration)
const currentTime = computed({
get: () => store.state.player.currentTime,
set: (time) => {
if (time < 0 || time > duration.value) {
return
}
if (!currentSound.value?.getSource() || time === currentSound.value.seek()) {
return
}
currentSound.value.seek(time)
// Update progress immediately to ensure updated UI
progress.value = time
}
})
const durationFormatted = computed(() => store.getters['player/durationFormatted'])
const currentTimeFormatted = computed(() => store.getters['player/currentTimeFormatted'])
// Progress
const progress = computed({
get: () => store.getters['player/progress'],
set: (time) => {
if (currentSound.value?.state() === 'loaded') {
store.state.player.currentTime = time
const duration = currentSound.value.duration()
currentSound.value.triggerSoundProgress(time, duration)
}
}
})
const bufferProgress = computed(() => store.state.player.bufferProgress)
onSoundProgress(({ node, time, duration }) => {
const toPreload = store.state.queue.tracks[currentIndex.value + 1]
if (!nextTrackPreloaded.value && toPreload && (time > PRELOAD_DELAY || duration - time < 30)) {
loadSound(toPreload)
nextTrackPreloaded.value = true
}
if (time > duration / 2) {
if (!isListeningSubmitted.value) {
store.dispatch('player/trackListened', currentTrack.value)
isListeningSubmitted.value = true
}
}
// from https://github.com/goldfire/howler.js/issues/752#issuecomment-372083163
const { buffered, currentTime } = node
let range = 0
try {
while (buffered.start(range) >= currentTime || currentTime >= buffered.end(range)) {
range += 1
}
} catch (IndexSizeError) {
return
}
let loadPercentage
const start = buffered.start(range)
const end = buffered.end(range)
if (range === 0) {
// easy case, no user-seek
const loadStartPercentage = start / node.duration
const loadEndPercentage = end / node.duration
loadPercentage = loadEndPercentage - loadStartPercentage
} else {
const loaded = end - start
const remainingToLoad = node.duration - start
// user seeked a specific position in the audio, our progress must be
// computed based on the remaining portion of the track
loadPercentage = loaded / remainingToLoad
}
if (loadPercentage * 100 === bufferProgress.value) {
return
}
store.commit('player/bufferProgress', loadPercentage * 100)
})
const observeProgress = ref(false)
useRafFn(() => {
if (observeProgress.value && currentSound.value?.state() === 'loaded') {
progress.value = currentSound.value.seek()
}
})
watch(playing, async (isPlaying) => {
if (currentSound.value) {
if (isPlaying) {
currentSound.value.play()
} else {
currentSound.value.pause()
}
} else {
await playTrack(currentTrack.value)
}
observeProgress.value = isPlaying
})
const isListeningSubmitted = ref(false)
const nextTrackPreloaded = ref(false)
watch(currentTrack, () => (nextTrackPreloaded.value = false))
// Controls
const pause = () => store.dispatch('player/pausePlayback')
const resume = () => store.dispatch('player/resumePlayback')
const { next } = useQueue()
const seek = (step: number) => {
// seek right
if (step > 0) {
if (currentTime.value + step < duration.value) {
store.dispatch('player/updateProgress', (currentTime.value + step))
} else {
next()
}
return
}
// seek left
const position = Math.max(currentTime.value + step, 0)
store.dispatch('player/updateProgress', position)
}
const togglePlayback = () => {
if (playing.value) return pause()
return resume()
}
export default () => {
return {
looping,
playing,
loading,
errored,
focused,
isListeningSubmitted,
playTrack,
volume,
mute,
unmute,
toggleMute,
duration,
currentTime,
durationFormatted,
currentTimeFormatted,
progress,
bufferProgress,
pause,
resume,
seek,
togglePlayback
}
}

Wyświetl plik

@ -1,35 +1,19 @@
import type { Track } from '~/types'
import { useTimeoutFn, useThrottleFn, useTimeAgo, useNow, whenever } from '@vueuse/core'
import { Howler } from 'howler'
import { useTimeAgo, useNow } from '@vueuse/core'
import { gettext } from '~/init/locale'
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { sum } from 'lodash-es'
import store from '~/store'
const { $pgettext } = gettext
const currentTrack = computed(() => store.getters['queue/currentTrack'])
const currentIndex = computed(() => store.state.queue.currentIndex)
const currentTrack = computed(() => store.state.queue.tracks[currentIndex.value])
const hasNext = computed(() => store.getters['queue/hasNext'])
const hasPrevious = computed(() => store.getters['queue/hasPrevious'])
const isEmpty = computed(() => store.getters['queue/isEmpty'])
whenever(isEmpty, () => Howler.unload())
const removeTrack = (index: number) => store.dispatch('queue/cleanTrack', index)
const tracks = computed(() => store.state.queue.tracks)
const isShuffling = computed(() => !!store.state.queue.shuffleAbortController)
const isShuffled = computed(() => !!store.state.queue.unshuffled.length)
const isEmpty = computed(() => tracks.value.length === 0)
const clear = () => store.dispatch('queue/clean')
const next = () => store.dispatch('queue/next')
const previous = () => store.dispatch('queue/previous')
const focused = computed(() => store.state.ui.queueFocused === 'queue')
//
// Track list
//
const tracks = computed<Track[]>(() => store.state.queue.tracks)
const removeTrack = (index: number) => store.dispatch('queue/cleanTrack', index)
const reorder = (oldIndex: number, newIndex: number) => {
store.commit('queue/reorder', {
oldIndex,
@ -39,30 +23,22 @@ const reorder = (oldIndex: number, newIndex: number) => {
//
// Shuffle
//
const isShuffling = ref(false)
const { $pgettext } = gettext
const shuffle = async () => {
await store.dispatch('queue/shuffle')
store.commit('ui/addMessage', {
content: $pgettext('Content/Queue/Message', 'Queue shuffled!'),
date: new Date()
})
}
const forceShuffle = useThrottleFn(() => {
isShuffling.value = true
useTimeoutFn(async () => {
await store.dispatch('queue/shuffle')
store.commit('ui/addMessage', {
content: $pgettext('Content/Queue/Message', 'Queue shuffled!'),
date: new Date()
})
isShuffling.value = false
}, 100)
})
const shuffle = useThrottleFn(() => {
if (isShuffling.value || isEmpty.value) {
return
}
return forceShuffle()
}, 101, false)
const unshuffle = async () => {
await store.dispatch('queue/unshuffle')
store.commit('ui/addMessage', {
content: $pgettext('Content/Queue/Message', 'Queue order restored!'),
date: new Date()
})
}
//
// Time left
@ -80,27 +56,19 @@ const endsIn = useTimeAgo(computed(() => {
return date
}))
export default () => {
return {
currentTrack,
currentIndex,
hasNext,
hasPrevious,
isEmpty,
isShuffling,
removeTrack,
clear,
next,
previous,
tracks,
reorder,
shuffle,
forceShuffle,
endsIn,
focused
}
}
export default () => ({
currentIndex,
currentTrack,
hasNext,
hasPrevious,
tracks,
isEmpty,
shuffle,
unshuffle,
isShuffling,
isShuffled,
endsIn,
clear,
removeTrack,
reorder
})

Wyświetl plik

@ -1,160 +0,0 @@
import type { Track } from '~/types'
import { ref, computed } from 'vue'
import { Howl } from 'howler'
import useTrackSources from '~/composables/audio/useTrackSources'
import useSoundCache from '~/composables/audio/useSoundCache'
import usePlayer from '~/composables/audio/usePlayer'
import store from '~/store'
import { createEventHook, useThrottleFn } from '@vueuse/core'
interface Sound {
id?: number
howl: Howl
stop: () => void
play: () => void
pause: () => void
state: () => 'unloaded' | 'loading' | 'loaded'
seek: (time?: number) => number
duration: () => number
getSource: () => boolean
triggerSoundProgress: (time: number, duration: number) => void
}
const soundCache = useSoundCache()
const currentTrack = computed(() => store.getters['queue/currentTrack'])
const looping = computed(() => store.state.player.looping)
const currentSound = ref()
const soundId = ref()
const soundProgress = createEventHook<{ node: HTMLAudioElement, time: number, duration: number }>()
const createSound = (howl: Howl): Sound => ({
howl,
play () {
this.id = howl.play(this.id)
},
stop () {
howl.stop(this.id)
this.id = undefined
},
pause () {
howl.pause(this.id)
},
state: () => howl.state(),
seek: (time?: number) => howl.seek(time),
duration: () => howl.duration(),
getSource: () => (howl as any)._sounds[0],
triggerSoundProgress: useThrottleFn((time: number, duration: number) => {
const node = (howl as any)._sounds[0]?._node
if (node) {
soundProgress.trigger({ node, time, duration })
}
}, 1000)
})
const loadSound = (track: Track): Sound => {
const cached = soundCache.get(track.id)
if (cached) {
return createSound(cached.howl)
}
const sources = useTrackSources(track)
const howl = new Howl({
src: sources.map((source) => source.url),
format: sources.map((source) => source.type),
autoplay: false,
loop: false,
html5: true,
preload: true,
onend () {
const onlyTrack = store.state.queue.tracks.length === 1
if (looping.value === 1 || (onlyTrack && looping.value === 2)) {
currentSound.value.seek(0)
store.dispatch('player/updateProgress', 0)
soundId.value = currentSound.value.play(soundId.value)
} else {
store.dispatch('player/trackEnded', currentTrack.value)
}
},
onunlock () {
if (store.state.player.playing && currentSound.value) {
soundId.value = currentSound.value.play(soundId.value)
}
},
onload () {
const node = (howl as any)._sounds[0]._node as HTMLAudioElement
node.addEventListener('progress', () => {
if (howl !== currentSound.value) {
return
}
currentSound.value._triggerSoundProgress()
})
},
onplay () {
const [otherId] = (this as any)._getSoundIds()
const [currentId] = (currentSound.value?.howl as any)._getSoundIds() ?? []
if (otherId !== currentId) {
return (this as any).stop()
}
const time = currentSound.value.seek()
const duration = currentSound.value.duration()
if (time <= duration / 2) {
const { isListeningSubmitted } = usePlayer()
isListeningSubmitted.value = false
}
store.commit('player/isLoadingAudio', false)
store.commit('player/resetErrorCount')
store.commit('player/errored', false)
store.commit('player/duration', howl.duration())
},
onplayerror (soundId, error) {
console.error('play error', soundId, error)
},
onloaderror (soundId, error) {
soundCache.delete(track.id)
howl.unload()
const [otherId] = (this as any)._getSoundIds()
const [currentId] = (currentSound.value?.howl as any)._getSoundIds() ?? []
if (otherId !== currentId) {
console.error('load error', soundId, error)
return
}
console.error('Error while playing:', soundId, error)
store.commit('player/isLoadingAudio', false)
store.dispatch('player/trackErrored')
}
})
soundCache.set(track.id, {
id: track.id,
date: new Date(),
howl
})
return createSound(howl)
}
export default () => {
return {
loadSound,
currentSound,
onSoundProgress: soundProgress.on
}
}

Wyświetl plik

@ -1,38 +0,0 @@
import type { Howl } from 'howler'
import { sortBy } from 'lodash-es'
import { reactive, watchEffect, ref } from 'vue'
const MAX_PRELOADED = 3
export interface CachedSound {
id: string
date: Date
howl: Howl
}
const soundCache = reactive(new Map<string, CachedSound>())
const cleaningCache = ref(false)
watchEffect(() => {
const toRemove = soundCache.size - MAX_PRELOADED
if (toRemove > 0 && !cleaningCache.value) {
cleaningCache.value = true
const excess = sortBy([...soundCache.values()], [(cached: CachedSound) => cached.date])
.slice(0, toRemove)
for (const cached of excess) {
console.log('Removing cached element:', cached)
soundCache.delete(cached.id)
cached.howl.unload()
}
cleaningCache.value = false
}
})
export default () => {
return soundCache
}

Wyświetl plik

@ -2,16 +2,26 @@ import type { Track } from '~/types'
import store from '~/store'
import updateQueryString from '~/composables/updateQueryString'
import axios from 'axios'
export interface TrackSource {
url: string
type: string
}
export default (trackData: Track): TrackSource[] => {
const audio = document.createElement('audio')
const audio = document.createElement('audio')
const allowed = ['probably', 'maybe']
const allowed = ['probably', 'maybe']
export default async (trackData: Track, abortSignal?: AbortSignal): Promise<TrackSource[]> => {
if (trackData.uploads.length === 0) {
trackData = await axios.get(`tracks/${trackData.id}/`, { signal: abortSignal })
.then(response => response.data)
.catch(() => null)
}
if (!trackData) {
return []
}
const sources = trackData.uploads
.filter(upload => {
@ -35,6 +45,8 @@ export default (trackData: Track): TrackSource[] => {
)
})
// TODO: Quality picker - sort sources by quality
const token = store.state.auth.scopedTokens.listen
if (store.state.auth.authenticated && token !== null) {
// we need to send the token directly in url

Wyświetl plik

@ -0,0 +1,354 @@
import type { IAudioBufferSourceNode, IAudioContext } from 'standardized-audio-context'
import type { Track } from '~/types'
import { AudioContext, AudioBufferSourceNode } from 'standardized-audio-context'
import { ref, reactive, computed, watchEffect, nextTick, shallowRef } from 'vue'
import { useRafFn, watchDebounced, computedEager } from '@vueuse/core'
import { uniq } from 'lodash-es'
import LRUCache from 'lru-cache'
import store from '~/store'
import axios from 'axios'
import useTrackSources from './useTrackSources'
import { LoopState } from '~/store/player'
import useLogger from '../useLogger'
import toLinearVolumeScale from './toLinearVolumeScale'
const TO_PRELOAD = 5
const context = new AudioContext()
const logger = useLogger()
//
// Audio loading
//
// Maximum of 20 song buffers can be cached
const bufferCache = new LRUCache<string, AudioBuffer>({
max: 20,
disposeAfter (buffer: AudioBuffer, key: string) {
// In case we've disposed the current buffer from cache, add it back
if (buffer === currentNode.value?.buffer) {
bufferCache.set(key, buffer)
}
}
})
const loadTrackBuffer = async (track: Track, abortSignal?: AbortSignal) => {
if (bufferCache.has(track.id)) {
return bufferCache.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 buffer = await context.decodeAudioData(response.data)
bufferCache.set(track.id, buffer)
return buffer
}
const ended = () => {
// Since pause() also emits ended event, we need to check if we're playing currently
if (playerState.playing) {
next()
}
}
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
})
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)
}
//
// Audio gain
//
const gainNode = context.createGain()
gainNode.connect(context.destination)
watchEffect(() => (gainNode.gain.value = toLinearVolumeScale(store.state.player.volume)))
const unmute = () => store.dispatch('player/unmute')
const mute = () => store.dispatch('player/mute')
const toggleMute = () => store.state.player.volume === 0
? unmute()
: mute()
//
// Audio playback
//
const currentNode = shallowRef<IAudioBufferSourceNode<IAudioContext> | null>(null)
const playerState = reactive({
playing: false,
startedAt: 0,
pausedAt: 0
})
const play = () => {
if (context.state === 'suspended') context.resume()
playerState.playing = true
}
const pause = () => {
playerState.playing = false
}
const stop = () => {
if (currentNode.value) {
progress.value = 0
stopNode(currentNode.value)
currentNode.value = null
playerState.playing = false
playerState.pausedAt = 0
}
}
const seek = (addTime: number) => {
if (currentNode.value?.buffer) {
progress.value = Math.max(0, Math.min(100, progress.value + (addTime / currentNode.value?.buffer?.duration) * 100))
}
}
const isLastTrack = computedEager(() => store.state.queue.currentIndex + 1 >= store.state.queue.tracks.length)
const willLoopQueue = computedEager(() => store.state.player.looping === LoopState.LOOP_QUEUE && isLastTrack.value)
const next = async () => {
// Looping queue
if (willLoopQueue.value) {
return store.dispatch('queue/currentIndex', 0)
}
// Pause if last
if (isLastTrack.value) {
progress.value = 0
// We need to wait for the first debounce tick
await nextTick()
// We need to wait for the play() to run after seeking to the beginning
await nextTick()
return pause()
}
// Play next track
if (playerState.pausedAt === 0) {
stop()
await store.dispatch('queue/currentIndex', store.state.queue.currentIndex + 1)
play()
}
}
const previous = async () => {
if (store.state.queue.currentIndex > 0 && time.value < 3) {
await store.dispatch('queue/currentIndex', store.state.queue.currentIndex - 1)
} else {
progress.value = 0
}
}
// Stop node, remove handlers and disconnect from gain node
const stopNode = (node: IAudioBufferSourceNode<IAudioContext> | null) => {
pauseProgress()
if (node === null) return
node.removeEventListener('ended', ended)
node.stop()
node.disconnect(gainNode)
}
const errored = ref(false)
// Play handler
watchDebounced([
() => playerState.playing,
currentTrack
], async () => {
// watchEffect(async () => {
if (playerState.playing && currentTrack.value) {
stopNode(currentNode.value)
currentNode.value = null
const source = await playTrack(currentTrack.value)
// Play request is aborted
if (source === false) return
// Play request errored
if (source === null) {
errored.value = true
return
}
// 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
}
currentNode.value = source
resumeProgress()
}
}, { debounce: 0 })
// Pause handler
watchEffect(() => {
if (!playerState.playing && currentTrack.value && currentNode.value) {
playerState.pausedAt = context.currentTime
currentNode.value.stop()
pauseProgress()
}
})
// Looping handler
watchEffect(() => {
if (currentNode.value) {
currentNode.value.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 progress = computed({
// Get progress
get: () => currentNode.value?.buffer
? Math.min(time.value / currentNode.value.buffer.duration * 100, 100)
: 0,
// Seek to percent
set: async (percent: number) => {
// Initialize track if we haven't already
if (!currentNode.value?.buffer) {
await play()
progress.value = percent
return
}
const time = percent / 100 * currentNode.value.buffer.duration
pause()
playerState.startedAt = context.currentTime - time
playerState.pausedAt = context.currentTime
await nextTick()
play()
}
})
// Progress animation loop
const { resume: resumeProgress, pause: pauseProgress } = useRafFn(() => {
if (playerState.playing) {
time.value = context.currentTime - playerState.startedAt
return
}
time.value = 0
}, { immediate: false })
// Animation fix for looped tracks and track listened handler
const isListened = ref(false)
watchEffect(() => {
// When we are done but looping, reset startedAt
if (progress.value === 100 && currentNode.value?.loop) {
playerState.startedAt = context.currentTime
}
// If unathenticated, do not track track listenings
if (!store.state.auth.authenticated) return
// When we are half-way through, send track listened
if (progress.value > 50 && !isListened.value) {
isListened.value = true
return axios.post('history/listenings/', { track: currentTrack.value.id })
.catch((error) => logger.error('Could not record track in history', error))
}
// When we are before half-way through, reset listened state
if (currentNode.value && progress.value <= 50 && isListened.value) {
isListened.value = false
}
})
// Exports
export default () => ({
// Audio loading
loadTrackBuffer,
// Audio gain
toggleMute,
unmute,
mute,
// Audio playback
play,
pause,
stop,
seek,
next,
previous,
errored,
time,
progress,
duration: computed(() => currentNode.value?.buffer?.duration ?? 0),
playing: computedEager(() => playerState.playing),
loading: computedEager(() => playerState.playing && currentTrack.value && !currentNode.value)
})

Wyświetl plik

@ -2,15 +2,15 @@ import type { InitModule } from '~/types'
import { whenever } from '@vueuse/core'
import useQueue from '~/composables/audio/useQueue'
import usePlayer from '~/composables/audio/usePlayer'
import useWebAudioPlayer from '~/composables/audio/useWebAudioPlayer'
export const install: InitModule = ({ app }) => {
const { currentTrack, next, previous } = useQueue()
const { resume, pause, seek } = usePlayer()
const { currentTrack } = useQueue()
const { play, pause, seek, next, previous } = useWebAudioPlayer()
// Add controls for notification drawer
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', resume)
navigator.mediaSession.setActionHandler('play', play)
navigator.mediaSession.setActionHandler('pause', pause)
navigator.mediaSession.setActionHandler('seekforward', () => seek(5))
navigator.mediaSession.setActionHandler('seekbackward', () => seek(-5))
@ -25,7 +25,8 @@ export const install: InitModule = ({ app }) => {
const metadata: MediaMetadataInit = {
title,
artist: artist.name
// TODO (wvffle): translate
artist: artist?.name ?? 'Unknown artist'
}
if (album?.cover) {

Wyświetl plik

@ -4,17 +4,17 @@ import * as Sentry from '@sentry/vue'
import { BrowserTracing } from '@sentry/tracing'
export const install: InitModule = ({ app, router }) => {
if (import.meta.env.DEV) {
if (!document.cookie.split(';').map(cookie => cookie.split('=')[0].trim()).includes('sentry_dev')) {
alert(`This instance uses ${new URL(import.meta.env.VUE_SENTRY_DSN).hostname} to collect information about crashes and stack traces.\n\nPlease unlock the domain in your adblock to allow us debug the branch.\n\nIf you do not want to share the data with us, please delete \`x-test-server\` cookie.`)
const expires = new Date()
expires.setTime(expires.getTime() + (100 * 24 * 60 * 60 * 1000))
document.cookie = `sentry_dev=1;expires=${expires.toUTCString()}`
}
}
if (import.meta.env.VUE_SENTRY_DSN) {
if (import.meta.env.DEV) {
if (!document.cookie.split(';').map(cookie => cookie.split('=')[0].trim()).includes('sentry_dev')) {
alert(`This instance uses ${new URL(import.meta.env.VUE_SENTRY_DSN).hostname} to collect information about crashes and stack traces.\n\nPlease unlock the domain in your adblock to allow us debug the branch.\n\nIf you do not want to share the data with us, please delete \`x-test-server\` cookie.`)
const expires = new Date()
expires.setTime(expires.getTime() + (100 * 24 * 60 * 60 * 1000))
document.cookie = `sentry_dev=1;expires=${expires.toUTCString()}`
}
}
Sentry.init({
app,
dsn: import.meta.env.VUE_SENTRY_DSN,

Wyświetl plik

@ -56,3 +56,4 @@ Promise.all(modules).finally(() => {
// TODO (wvffle): Replace `from '(../)+` with `from '~/`
// TODO (wvffle): Fix props not being available in template in IntelliJ Idea
// TODO (wvffle): Use navigation guards
// TODO (wvffle): Use computedEager whenever there is a cheap operation that can be executed eagerly

Wyświetl plik

@ -1,3 +1,4 @@
import type { Track } from '~/types'
import type { InjectionKey } from 'vue'
import type { State as FavoritesState } from './favorites'
import type { State as ChannelsState } from './channels'
@ -39,8 +40,39 @@ export interface RootState {
player: PlayerState
}
// we keep only valuable fields to make the cache lighter and avoid
// cyclic value serialization errors
const trackReducer = (track: Track) => {
const artist = track.artist
? {
id: track.artist.id,
mbid: track.artist.mbid,
name: track.artist.name
}
: {}
return {
id: track.id,
title: track.title,
mbid: track.mbid,
uploads: track.uploads,
listen_url: track.listen_url,
artist,
album: track.album
? {
id: track.album.id,
title: track.album.title,
mbid: track.album.mbid,
cover: track.album.cover,
artist
}
: {}
}
}
export const key: InjectionKey<Store<RootState>> = Symbol('vuex state injection key')
export default createStore<RootState>({
// TODO (wvffle): Use strict mode
modules: {
ui,
auth,
@ -96,36 +128,16 @@ export default createStore<RootState>({
return {
queue: {
currentIndex: state.queue.currentIndex,
tracks: state.queue.tracks.map((track: any) => {
// we keep only valuable fields to make the cache lighter and avoid
// cyclic value serialization errors
const artist = {
id: track.artist.id,
mbid: track.artist.mbid,
name: track.artist.name
}
const data = {
id: track.id,
title: track.title,
mbid: track.mbid,
uploads: track.uploads,
listen_url: track.listen_url,
artist,
album: {}
}
if (track.album) {
data.album = {
id: track.album.id,
title: track.album.title,
mbid: track.album.mbid,
cover: track.album.cover,
artist
}
}
return data
})
shuffleAbortController: state.queue.shuffleAbortController && null,
tracks: state.queue.tracks.map(trackReducer),
unshuffled: state.queue.unshuffled.map(trackReducer)
}
}
},
rehydrated: async (store) => {
if (store.state.queue.shuffleAbortController === null) {
await store.dispatch('queue/unshuffle', true)
}
}
})
]

Wyświetl plik

@ -1,9 +1,13 @@
import type { Module } from 'vuex'
import type { RootState } from '~/store/index'
import axios from 'axios'
import time from '~/utils/time'
import useLogger from '~/composables/useLogger'
export enum LoopState {
NO_LOOP,
LOOP_CURRENT,
LOOP_QUEUE
}
export interface State {
maxConsecutiveErrors: number
@ -11,16 +15,13 @@ export interface State {
playing: boolean
isLoadingAudio: boolean
volume: number
tempVolume: number
lastVolume: number
duration: number
currentTime: number
errored: boolean
bufferProgress: number
looping: 0 | 1 | 2 // 0 -> no, 1 -> on track, 2 -> on queue
looping: LoopState
}
const logger = useLogger()
const store: Module<State, RootState> = {
namespaced: true,
state: {
@ -29,35 +30,25 @@ const store: Module<State, RootState> = {
playing: false,
isLoadingAudio: false,
volume: 1,
tempVolume: 0.5,
lastVolume: 0.5,
duration: 0,
currentTime: 0,
errored: false,
bufferProgress: 0,
looping: 0
looping: LoopState.NO_LOOP
},
mutations: {
reset (state) {
state.errorCount = 0
state.playing = false
},
volume (state, value) {
value = parseFloat(value)
value = Math.min(value, 1)
value = Math.max(value, 0)
state.volume = value
volume (state, value: number) {
state.volume = Math.min(Math.max(value, 0), 1)
},
tempVolume (state, value) {
value = parseFloat(value)
value = Math.min(value, 1)
value = Math.max(value, 0)
state.tempVolume = value
lastVolume (state, value: number) {
state.lastVolume = Math.min(Math.max(value, 0), 1)
},
incrementVolume (state, value) {
value = parseFloat(state.volume + value)
value = Math.min(value, 1)
value = Math.max(value, 0)
state.volume = value
state.volume = Math.min(Math.max(value, 0), 1)
},
incrementErrorCount (state) {
state.errorCount += 1
@ -74,20 +65,23 @@ const store: Module<State, RootState> = {
currentTime (state, value) {
state.currentTime = value
},
looping (state, value) {
looping (state, value: LoopState) {
state.looping = value
},
playing (state, value) {
state.playing = value
},
bufferProgress (state, value) {
state.bufferProgress = value
},
toggleLooping (state) {
if (state.looping > 1) {
state.looping = 0
} else {
state.looping += 1
switch (state.looping) {
case LoopState.NO_LOOP:
state.looping = LoopState.LOOP_CURRENT
break
case LoopState.LOOP_CURRENT:
state.looping = LoopState.LOOP_QUEUE
break
case LoopState.LOOP_QUEUE:
state.looping = LoopState.NO_LOOP
break
}
},
isLoadingAudio (state, value) {
@ -109,83 +103,15 @@ const store: Module<State, RootState> = {
incrementVolume ({ commit, state }, value) {
commit('volume', state.volume + value)
},
stop ({ commit }) {
commit('errored', false)
commit('resetErrorCount')
},
togglePlayback ({ commit, state, dispatch }) {
commit('playing', !state.playing)
if (state.errored && state.errorCount < state.maxConsecutiveErrors) {
setTimeout(() => {
if (state.playing) {
dispatch('queue/next', null, { root: true })
}
}, 3000)
}
},
async resumePlayback ({ commit, state, dispatch }) {
commit('playing', true)
if (state.errored && state.errorCount < state.maxConsecutiveErrors) {
await new Promise(resolve => setTimeout(resolve, 3000))
if (state.playing) {
return dispatch('queue/next', null, { root: true })
}
}
},
pausePlayback ({ commit }) {
commit('playing', false)
},
toggleMute ({ commit, state }) {
if (state.volume > 0) {
commit('tempVolume', state.volume)
commit('volume', 0)
} else {
commit('volume', state.tempVolume)
}
},
trackListened ({ rootState }, track) {
if (!rootState.auth.authenticated) {
return
}
return axios.post('history/listenings/', { track: track.id }).catch((error) => {
logger.error('Could not record track in history', error)
})
},
trackEnded ({ commit, dispatch, rootState }) {
const queueState = rootState.queue
if (queueState.currentIndex === queueState.tracks.length - 1) {
// we've reached last track of queue, trigger a reload
// from radio if any
dispatch('radios/populateQueue', null, { root: true })
}
dispatch('queue/next', null, { root: true })
if (queueState.ended) {
// Reset playback
commit('playing', false)
dispatch('updateProgress', 0)
}
},
trackErrored ({ commit, dispatch, state }) {
commit('errored', true)
commit('incrementErrorCount')
if (state.errorCount < state.maxConsecutiveErrors) {
setTimeout(() => {
if (state.playing) {
dispatch('queue/next', null, { root: true })
}
}, 3000)
}
},
updateProgress ({ commit }, t) {
commit('currentTime', t)
},
mute ({ commit, state }) {
commit('tempVolume', state.volume)
commit('lastVolume', state.volume)
commit('volume', 0)
},
unmute ({ commit, state }) {
commit('volume', state.tempVolume)
commit('volume', state.lastVolume)
}
}
}

Wyświetl plik

@ -2,38 +2,99 @@ import type { Module } from 'vuex'
import type { RootState } from '~/store/index'
import type { Track } from '~/types'
import { shuffle } from 'lodash-es'
import { shuffle, chunk } from 'lodash-es'
import useLogger from '~/composables/useLogger'
const CHUNK_SIZE = 50
export interface State {
tracks: Track[]
unshuffled: Track[]
// NOTE: It's null when we are rehydrated from local storage
// and we were mid-shuffling before
shuffleAbortController?: AbortController | null
currentIndex: number
ended: boolean
}
const logger = useLogger()
// Load useWebAudioPlayer dynamically to avoid vuex not initialized issues
const useWebAudioPlayer = async () => {
const { default: useWebAudioPlayer } = await import('~/composables/audio/useWebAudioPlayer')
return useWebAudioPlayer()
}
interface DeferredAppendOptions {
signal?: AbortSignal
mapChunk?: (chunk: Track[]) => Track[]
mapChunks?: (chunk: Track[][]) => Track[][]
afterChunk?: (i: number, chunk: Track[]) => Promise<void> | void
}
const deferredAppend = async (from: Track[], to: Track[], options: DeferredAppendOptions = {}) => {
const {
mapChunk = (i) => i,
mapChunks = (i) => i,
afterChunk = () => {}
} = options
const chunks = mapChunks(chunk(from, CHUNK_SIZE))
const firstChunk = mapChunk(chunks[0])
if (!firstChunk) return
to.push(...firstChunk)
await afterChunk(0, firstChunk)
for (let i = 1; i < chunks.length; i++) {
// Break if we have aborted
if (options.signal?.aborted) {
break
}
const chunk = mapChunk(chunks[i])
await new Promise<void>(resolve => {
requestAnimationFrame(async () => {
// Break before modyfing the array, if we have aborted
if (options.signal?.aborted) {
return resolve()
}
to.push(...chunk)
await afterChunk(0, chunk)
return resolve()
})
})
}
}
const store: Module<State, RootState> = {
namespaced: true,
state: {
tracks: [],
currentIndex: -1,
ended: true
unshuffled: [],
currentIndex: -1
},
mutations: {
reset (state) {
state.tracks.length = 0
state.unshuffled.length = 0
state.currentIndex = -1
state.ended = true
},
currentIndex (state, value) {
state.currentIndex = value
},
ended (state, value) {
state.ended = value
splice (state, { start, size, items = [] }) {
state.tracks.splice(start, size, ...items)
},
splice (state, { start, size }) {
state.tracks.splice(start, size)
push (state, { items = [], to = state.tracks }) {
to.push(...items)
},
clean (state, array = state.tracks) {
array.length = 0
},
controller (state, controller: AbortController | undefined) {
state.shuffleAbortController = controller
},
tracks (state, value) {
state.tracks = value
@ -78,7 +139,7 @@ const store: Module<State, RootState> = {
return dispatch('appendMany', { tracks: [track], index })
},
appendMany ({ state, dispatch }, { tracks, index = state.tracks.length }) {
async appendMany ({ commit, state }, { tracks, index = state.tracks.length }) {
logger.info(
'Enqueueing tracks',
tracks.map((track: Track) => [track.artist?.name, track.title].join(' - '))
@ -92,31 +153,37 @@ const store: Module<State, RootState> = {
if (index >= state.tracks.length) {
// we simply push to the end
state.tracks.push(...tracks)
commit('push', { items: tracks })
} else {
// we insert the track at given position
state.tracks.splice(index, 0, ...tracks)
commit('splice', { start: index, size: 0, items: tracks })
}
// If the queue is shuffled, push back to the original queue
if (state.unshuffled.length) {
commit('push', { items: tracks, to: state.unshuffled })
}
if (shouldPlay) {
return dispatch('next')
const { play } = await useWebAudioPlayer()
return play()
}
},
cleanTrack ({ state, dispatch, commit }, index) {
// are we removing current playin track
const current = index === state.currentIndex
async cleanTrack ({ state, dispatch, commit }, index) {
const { stop, play } = await useWebAudioPlayer()
if (current) {
dispatch('player/stop', null, { root: true })
}
// are we removing currently playing track
const current = index === state.currentIndex
if (current) stop()
commit('splice', { start: index, size: 1 })
if (index < state.currentIndex) {
commit('currentIndex', state.currentIndex - 1)
} else if (index > 0 && index === state.tracks.length && current) {
// kind of a edge case: if you delete the last track of the queue
// while it's playing we set current index to the previous one to
// If you delete the last track of the queue while it's
// playing we set current index to the previous one to
// avoid the queue tab from being stuck because the player
// disappeared cf #1092
commit('currentIndex', state.tracks.length - 1)
@ -125,57 +192,87 @@ const store: Module<State, RootState> = {
commit('currentIndex', index)
}
if (state.tracks.length > state.currentIndex) {
play()
}
if (state.currentIndex + 1 === state.tracks.length) {
dispatch('radios/populateQueue', null, { root: true })
}
},
previous ({ state, dispatch, rootState }) {
if (state.currentIndex > 0 && rootState.player.currentTime < 3) {
dispatch('currentIndex', state.currentIndex - 1)
} else {
dispatch('currentIndex', state.currentIndex)
}
},
next ({ state, dispatch, commit, rootState }) {
if (rootState.player.looping === 2 && state.currentIndex >= state.tracks.length - 1) {
logger.info('Going back to the beginning of the queue')
return dispatch('currentIndex', 0)
} else {
if (state.currentIndex < state.tracks.length - 1) {
logger.debug('Playing next track')
return dispatch('currentIndex', state.currentIndex + 1)
} else {
commit('ended', true)
}
}
},
last ({ state, dispatch }) {
return dispatch('currentIndex', state.tracks.length - 1)
},
currentIndex ({ commit, state, rootState, dispatch }, index) {
commit('ended', false)
commit('player/currentTime', 0, { root: true })
commit('currentIndex', index)
if (state.tracks.length - index <= 2 && rootState.radios.running) {
return dispatch('radios/populateQueue', null, { root: true })
}
},
clean ({ dispatch, commit, state }) {
dispatch('radios/stop', null, { root: true })
dispatch('player/stop', null, { root: true })
state.tracks.length = 0
dispatch('currentIndex', -1)
// so we replay automatically on next track append
commit('ended', true)
},
async shuffle ({ dispatch, state }) {
const shuffled = shuffle(state.tracks)
state.tracks.length = 0
await dispatch('appendMany', { tracks: shuffled })
await dispatch('currentIndex', 0)
last ({ state, dispatch }) {
return dispatch('currentIndex', state.tracks.length - 1)
},
async currentIndex ({ commit, state, rootState, dispatch }, index) {
commit('currentIndex', index)
// TODO (wvffle): Move to useRadio
if (index === state.tracks.length - 1 && rootState.radios.running) {
return dispatch('radios/populateQueue', null, { root: true })
}
},
async clean ({ dispatch, state }) {
const { stop } = await useWebAudioPlayer()
stop()
await dispatch('radios/stop', null, { root: true })
state.tracks.length = 0
state.unshuffled.length = 0
state.shuffleAbortController = undefined
await dispatch('currentIndex', -1)
},
async shuffle ({ commit, dispatch, state }) {
const { play, stop } = await useWebAudioPlayer()
stop()
logger.time('Shuffling')
const abortController = new AbortController()
commit('controller', abortController)
// This should be rather quick, as it doesn't re-render the UI
commit('clean', state.unshuffled)
commit('push', { items: state.tracks, to: state.unshuffled })
commit('clean')
await deferredAppend(state.unshuffled, state.tracks, {
signal: abortController.signal,
mapChunk: shuffle,
mapChunks: shuffle,
async afterChunk (i) {
if (i === 0) {
await dispatch('currentIndex', 0)
play()
}
}
})
commit('controller', undefined)
logger.timeEnd('Shuffling')
},
async unshuffle ({ commit, dispatch, state }, rehydration = false) {
const { play, stop } = await useWebAudioPlayer()
stop()
state.shuffleAbortController?.abort()
const abortController = new AbortController()
commit('controller', abortController)
commit('clean')
await deferredAppend(state.unshuffled, state.tracks, {
signal: abortController.signal,
async afterChunk (i) {
if (i === 0 && !rehydration) {
await dispatch('currentIndex', 0)
play()
}
}
})
commit('clean', state.unshuffled)
commit('controller', undefined)
}
}
}

Wyświetl plik

@ -21,7 +21,6 @@
color: var(--player-color);
background: var(--player-background);
width: 100%;
width: 100vw;
border-radius: 0;
padding: 0em;
position: fixed;
@ -204,7 +203,7 @@
}
}
}
.shuffling.loader.inline {
.shuffling .loader.inline {
margin: 0;
}
.control.circular.button {

Wyświetl plik

@ -4,7 +4,6 @@
align-items: center;
position: relative;
overflow: visible;
top: 3px;
input {
max-width: 5.5em;
height: 4px;
@ -14,13 +13,12 @@
background-color: #1B1C1D;
position: absolute;
left: -4em;
top: -7em;
top: calc(-7em + 5px);
transform: rotate(-90deg);
display: flex;
align-items: center;
height: 2.5em;
padding: 0 0.5em;
box-shadow: 1px 1px 3px rgba(125, 125, 125, 0.5);
}
input {
max-width: 8.5em;

Wyświetl plik

@ -169,6 +169,7 @@ export interface Cover {
urls: {
original: string
medium_square_crop: string
large_square_crop: string
}
}

Wyświetl plik

@ -908,7 +908,7 @@
"@babel/types" "^7.4.4"
esutils "^2.0.2"
"@babel/runtime@^7.11.2", "@babel/runtime@^7.8.4":
"@babel/runtime@^7.11.2", "@babel/runtime@^7.18.9", "@babel/runtime@^7.8.4":
version "7.19.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
@ -1313,67 +1313,67 @@
estree-walker "^1.0.1"
picomatch "^2.2.2"
"@sentry/browser@7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.7.0.tgz#7810ee98d4969bd0367e29ac0af6c5779db7e6c4"
integrity sha512-oyzpWcsjVZTaf14zAL89Ng1DUHlbjN+V8pl8dR9Y9anphbzL5BK9p0fSK4kPIrO4GukK+XrKnLJDPuE/o7WR3g==
"@sentry/browser@7.12.1":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.12.1.tgz#2be6fa5c2529a2a75abac4d00aca786362302a1a"
integrity sha512-pgyL65CrGFLe8sKcEG8KXAuVTE8zkAsyTlv/AuME06cSdxzO/memPK/r3BI6EM7WupIdga+V5tQUldeT1kgHNA==
dependencies:
"@sentry/core" "7.7.0"
"@sentry/types" "7.7.0"
"@sentry/utils" "7.7.0"
"@sentry/core" "7.12.1"
"@sentry/types" "7.12.1"
"@sentry/utils" "7.12.1"
tslib "^1.9.3"
"@sentry/core@7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.7.0.tgz#1a2d477897552d179380f7c54c7d81a4e98ea29a"
integrity sha512-Z15ACiuiFINFcK4gbMrnejLn4AVjKBPJOWKrrmpIe8mh+Y9diOuswt5mMUABs+jhpZvqht3PBLLGBL0WMsYMYA==
"@sentry/core@7.12.1":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.12.1.tgz#a22f1c530ed528a699ed204c36eb5fc8d308103d"
integrity sha512-DFHbzHFjukhlkRZ5xzfebx0IBzblW43kmfnalBBq7xEMscUvnhsYnlvL9Y20tuPZ/PrTcq4JAHbFluAvw6M0QQ==
dependencies:
"@sentry/hub" "7.7.0"
"@sentry/types" "7.7.0"
"@sentry/utils" "7.7.0"
"@sentry/hub" "7.12.1"
"@sentry/types" "7.12.1"
"@sentry/utils" "7.12.1"
tslib "^1.9.3"
"@sentry/hub@7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.7.0.tgz#9ad3471cf5ecaf1a9d3a3a04ca2515ffec9e2c09"
integrity sha512-6gydK234+a0nKhBRDdIJ7Dp42CaiW2juTiHegUVDq+482balVzbZyEAmESCmuzKJhx5BhlCElVxs/cci1NjMpg==
"@sentry/hub@7.12.1":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.12.1.tgz#dffad40cd2b8f44df2d5f20a89df87879cbbf1c3"
integrity sha512-KLVnVqXf+CRmXNy9/T8K2/js7QvOQ94xtgP5KnWJbu2rl+JhxnIGiBRF51lPXFIatt7zWwB9qNdMS8lVsvLMGQ==
dependencies:
"@sentry/types" "7.7.0"
"@sentry/utils" "7.7.0"
"@sentry/types" "7.12.1"
"@sentry/utils" "7.12.1"
tslib "^1.9.3"
"@sentry/tracing@^7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.7.0.tgz#67324b755a28e115289755e44a0b8b467a63d0b2"
integrity sha512-HNmvTwemuc21q/K6HXsSp9njkne6N1JQ71TB+QGqYU5VtxsVgYSUhhYqV6WcHz7LK4Hj6TvNFoeu69/rO0ysgw==
version "7.12.1"
resolved "https://registry.yarnpkg.com/@sentry/tracing/-/tracing-7.12.1.tgz#9f92985f152054ac90b6ec83a33c44e8084a008e"
integrity sha512-WnweIt//IqkEkJSjA8DtnIeCdItYIqJSxNQ6qK+r546/ufxRYFBck2fbmM0oKZJVg2evbwhadrBTIUzYkqNj4A==
dependencies:
"@sentry/hub" "7.7.0"
"@sentry/types" "7.7.0"
"@sentry/utils" "7.7.0"
"@sentry/hub" "7.12.1"
"@sentry/types" "7.12.1"
"@sentry/utils" "7.12.1"
tslib "^1.9.3"
"@sentry/types@7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.7.0.tgz#dd6bd3d119d7efea0e85dbaa4b17de1c22b63c7a"
integrity sha512-4x8O7uerSGLnYC10krHl9t8h7xXHn5FextqKYbTCXCnx2hC8D+9lz8wcbQAFo0d97wiUYqI8opmEgFVGx7c5hQ==
"@sentry/types@7.12.1":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.12.1.tgz#eff76d938f9effc62a2ec76cd5c3f04de37f5c15"
integrity sha512-VGZs39SZgMcCGv7H0VyFy1LEFGsnFZH590JUopmz6nG63EpeYQ2xzhIoPNAiLKbyUvBEwukn+faCg3u3MGqhgQ==
"@sentry/utils@7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.7.0.tgz#013e3097c4268a76de578494c7df999635fb0ad4"
integrity sha512-fD+ROSFpeJlK7bEvUT2LOW7QqgjBpXJwVISKZ0P2fuzclRC3KoB2pbZgBM4PXMMTiSzRGWhvfRRjBiBvQJBBJQ==
"@sentry/utils@7.12.1":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.12.1.tgz#fcf80fdc332d0bd288e21b13efc7a2f0d604f75a"
integrity sha512-Dh8B13pC0u8uLM/zf+oZngyg808c6BDEO94F7H+h3IciCVVd92A0cOQwLGAEdf8srnJgpZJNAlSC8lFDhbFHzQ==
dependencies:
"@sentry/types" "7.7.0"
"@sentry/types" "7.12.1"
tslib "^1.9.3"
"@sentry/vue@^7.7.0":
version "7.7.0"
resolved "https://registry.yarnpkg.com/@sentry/vue/-/vue-7.7.0.tgz#7462d3957250a08f77972dc55d624d4c688e33b9"
integrity sha512-0gtUJ5ngdEYS2CnlOW76U6sMs5RoALpfhk7QMqPn7nGCMHP2uthwi8/T1HMKjg5JTZqLcfssf059fg3ZnhpGYQ==
version "7.12.1"
resolved "https://registry.yarnpkg.com/@sentry/vue/-/vue-7.12.1.tgz#cb8a93384be40e3389333547fbe443f8a2615fa4"
integrity sha512-p8Z1CrjVgHBK+Udb/X+bl5MTs3faGMMwZlcTtcMG0ZIY54V1GkvAsGBn3EFoe0yGCv6UFiuS90CxTfh0XtZavg==
dependencies:
"@sentry/browser" "7.7.0"
"@sentry/core" "7.7.0"
"@sentry/types" "7.7.0"
"@sentry/utils" "7.7.0"
"@sentry/browser" "7.12.1"
"@sentry/core" "7.12.1"
"@sentry/types" "7.12.1"
"@sentry/utils" "7.12.1"
tslib "^1.9.3"
"@sinclair/typebox@^0.24.1":
@ -2635,6 +2635,14 @@ atob@^2.1.2:
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
automation-events@^4.0.20:
version "4.0.20"
resolved "https://registry.yarnpkg.com/automation-events/-/automation-events-4.0.20.tgz#a103d322db98b9999d04b44a3cf276539a88cc37"
integrity sha512-ALTOLrB4vTyXOsLPia8OKM1qZKtlsGC+3VDe3jcCGUpvgKTbqlzvaJU3HqDhU6jlVEMMBqT01XZv3K/3Z5g29w==
dependencies:
"@babel/runtime" "^7.18.9"
tslib "^2.4.0"
axios-auth-refresh@3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/axios-auth-refresh/-/axios-auth-refresh-3.3.3.tgz#f8c2fd0ca3adf89168dfb0caff10f076499ea482"
@ -5494,6 +5502,11 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
lru-cache@^7.13.1:
version "7.14.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.0.tgz#21be64954a4680e303a09e9468f880b98a0b3c7f"
integrity sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ==
magic-string@^0.25.0, magic-string@^0.25.7:
version "0.25.9"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
@ -6644,6 +6657,15 @@ stack-utils@^2.0.3:
dependencies:
escape-string-regexp "^2.0.0"
standardized-audio-context@^25.3.29:
version "25.3.29"
resolved "https://registry.yarnpkg.com/standardized-audio-context/-/standardized-audio-context-25.3.29.tgz#4f1948a3903323bb831b8c7129bed9320e500be5"
integrity sha512-5RqrvuaphiR3W2t8nd8PRBhQKXTTf0gHu8I0BukH3A11C9ZHQQmeE9WywBe68TcYBj9DCm42db8OWTw1PluLUA==
dependencies:
"@babel/runtime" "^7.18.9"
automation-events "^4.0.20"
tslib "^2.4.0"
string-length@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
@ -6943,7 +6965,7 @@ tslib@^1.8.1, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.3.1:
tslib@^2.3.1, tslib@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==