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
This commit is contained in:
parent
465b6918e4
commit
97e7049333
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)"
|
||||
>
|
||||
<i
|
||||
class="repeat icon"
|
||||
@click.prevent.stop="$store.commit('player/toggleLooping')"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<i
|
||||
class="repeat icon"
|
||||
@click.prevent.stop="$store.commit('player/toggleLooping')"
|
||||
>
|
||||
<i class="repeat icon">
|
||||
<span class="ui circular tiny vibrant label">∞</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>
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 forceShuffle = useThrottleFn(() => {
|
||||
isShuffling.value = true
|
||||
|
||||
useTimeoutFn(async () => {
|
||||
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()
|
||||
})
|
||||
|
||||
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,
|
||||
export default () => ({
|
||||
currentIndex,
|
||||
currentTrack,
|
||||
hasNext,
|
||||
hasPrevious,
|
||||
isEmpty,
|
||||
isShuffling,
|
||||
|
||||
removeTrack,
|
||||
clear,
|
||||
next,
|
||||
previous,
|
||||
|
||||
tracks,
|
||||
reorder,
|
||||
|
||||
isEmpty,
|
||||
shuffle,
|
||||
forceShuffle,
|
||||
|
||||
unshuffle,
|
||||
isShuffling,
|
||||
isShuffled,
|
||||
endsIn,
|
||||
focused
|
||||
}
|
||||
}
|
||||
clear,
|
||||
removeTrack,
|
||||
reorder
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -2,17 +2,27 @@ 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 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 => {
|
||||
const canPlay = audio.canPlayType(upload.mimetype)
|
||||
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
|
@ -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) {
|
||||
|
|
|
@ -4,6 +4,7 @@ import * as Sentry from '@sentry/vue'
|
|||
import { BrowserTracing } from '@sentry/tracing'
|
||||
|
||||
export const install: InitModule = ({ app, router }) => {
|
||||
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.`)
|
||||
|
@ -14,7 +15,6 @@ export const install: InitModule = ({ app, router }) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (import.meta.env.VUE_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
app,
|
||||
dsn: import.meta.env.VUE_SENTRY_DSN,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,35 +128,15 @@ 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
|
||||
shuffleAbortController: state.queue.shuffleAbortController && null,
|
||||
tracks: state.queue.tracks.map(trackReducer),
|
||||
unshuffled: state.queue.unshuffled.map(trackReducer)
|
||||
}
|
||||
}
|
||||
return data
|
||||
})
|
||||
}
|
||||
},
|
||||
rehydrated: async (store) => {
|
||||
if (store.state.queue.shuffleAbortController === null) {
|
||||
await store.dispatch('queue/unshuffle', true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
color: var(--player-color);
|
||||
background: var(--player-background);
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
border-radius: 0;
|
||||
padding: 0em;
|
||||
position: fixed;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -169,6 +169,7 @@ export interface Cover {
|
|||
urls: {
|
||||
original: string
|
||||
medium_square_crop: string
|
||||
large_square_crop: string
|
||||
}
|
||||
}
|
||||
|
||||
|
|
110
front/yarn.lock
110
front/yarn.lock
|
@ -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==
|
||||
|
|
Loading…
Reference in New Issue