Queue component enhancements
This commit provides following queue component enhancements: - Use a virtual list to render queue items. We now render ~25-30 items at once compared to `queue.length` items. (Fix #1471) - Faster queue opening and a smoother open animation. (Fix #1471) - Faster song enqueueing: When enqueueing a playlist or an album, we modify the state only once compared to `tracks.length` modifications before. - When opening the queue, current track is now already pre-scrolled and centered. This resolves an issue with big queues, where the track list was slowly scrolling to a random position. - Dragging a track onto the edge of the track list will now scroll the container. You reorder a track from the very top to the very bottom even when you have 3000 tracks in the queue! - Ability to use scroll wheel while reordering track items - Track reordering on mobile devices! Currently dragging to edge is disabled - Responsivity fixes - Allow click outside modal (Fix #1581)
This commit is contained in:
parent
53d9015e17
commit
c87bf7e6b8
|
@ -18,27 +18,26 @@
|
||||||
"postinstall": "yarn run fix-fomantic-css"
|
"postinstall": "yarn run fix-fomantic-css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/runtime-core": "^3.2.37",
|
"@vue/runtime-core": "3.2.37",
|
||||||
"@vueuse/core": "8.9.4",
|
"@vueuse/core": "8.9.4",
|
||||||
"@vueuse/integrations": "8.9.4",
|
"@vueuse/integrations": "8.9.4",
|
||||||
"axios": "0.27.2",
|
"axios": "0.27.2",
|
||||||
"axios-auth-refresh": "3.3.1",
|
"axios-auth-refresh": "3.3.1",
|
||||||
"diff": "5.1.0",
|
"diff": "5.1.0",
|
||||||
"dompurify": "^2.3.8",
|
"dompurify": "2.3.8",
|
||||||
"focus-trap": "6.9.4",
|
"focus-trap": "6.9.4",
|
||||||
"fomantic-ui-css": "2.8.8",
|
"fomantic-ui-css": "2.8.8",
|
||||||
"howler": "2.2.3",
|
"howler": "2.2.3",
|
||||||
"js-logger": "1.6.1",
|
"js-logger": "1.6.1",
|
||||||
"lodash-es": "4.17.21",
|
"lodash-es": "4.17.21",
|
||||||
"moment": "2.29.4",
|
"moment": "2.29.4",
|
||||||
"pinia": "^2.0.13",
|
|
||||||
"qs": "6.11.0",
|
"qs": "6.11.0",
|
||||||
"register-service-worker": "1.7.2",
|
"register-service-worker": "1.7.2",
|
||||||
"sanitize-html": "2.7.1",
|
"sanitize-html": "2.7.1",
|
||||||
"sass": "1.53.0",
|
"sass": "1.53.0",
|
||||||
"showdown": "2.1.0",
|
"showdown": "2.1.0",
|
||||||
"text-clipper": "2.2.0",
|
"text-clipper": "2.2.0",
|
||||||
"transliteration": "^2.3.5",
|
"transliteration": "2.3.5",
|
||||||
"vue": "3.2.37",
|
"vue": "3.2.37",
|
||||||
"vue-gettext": "2.1.12",
|
"vue-gettext": "2.1.12",
|
||||||
"vue-plyr": "7.0.0",
|
"vue-plyr": "7.0.0",
|
||||||
|
@ -47,34 +46,35 @@
|
||||||
"vue-upload-component": "3.1.2",
|
"vue-upload-component": "3.1.2",
|
||||||
"vue3-gettext": "2.3.0",
|
"vue3-gettext": "2.3.0",
|
||||||
"vue3-lazyload": "0.3.5",
|
"vue3-lazyload": "0.3.5",
|
||||||
|
"vue3-virtual-scroll-list": "0.2.0",
|
||||||
"vuedraggable": "4.1.0",
|
"vuedraggable": "4.1.0",
|
||||||
"vuex": "4.0.2",
|
"vuex": "4.0.2",
|
||||||
"vuex-persistedstate": "4.1.0",
|
"vuex-persistedstate": "4.1.0",
|
||||||
"vuex-router-sync": "5.0.0"
|
"vuex-router-sync": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dompurify": "^2.3.3",
|
"@types/dompurify": "2.3.3",
|
||||||
"@types/howler": "^2.2.7",
|
"@types/howler": "2.2.7",
|
||||||
"@types/jest": "28.1.6",
|
"@types/jest": "28.1.6",
|
||||||
"@types/jquery": "3.5.14",
|
"@types/jquery": "3.5.14",
|
||||||
"@types/lodash-es": "4.17.6",
|
"@types/lodash-es": "4.17.6",
|
||||||
"@types/qs": "6.9.7",
|
"@types/qs": "6.9.7",
|
||||||
"@types/semantic-ui": "^2.2.7",
|
"@types/semantic-ui": "2.2.7",
|
||||||
"@types/showdown": "^2.0.0",
|
"@types/showdown": "2.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "5.30.7",
|
"@typescript-eslint/eslint-plugin": "5.30.7",
|
||||||
"@vitejs/plugin-vue": "3.0.1",
|
"@vitejs/plugin-vue": "3.0.1",
|
||||||
"@vue/compiler-sfc": "3.2.37",
|
"@vue/compiler-sfc": "3.2.37",
|
||||||
"@vue/eslint-config-standard": "7.0.0",
|
"@vue/eslint-config-standard": "7.0.0",
|
||||||
"@vue/eslint-config-typescript": "11.0.0",
|
"@vue/eslint-config-typescript": "11.0.0",
|
||||||
"@vue/test-utils": "2.0.2",
|
"@vue/test-utils": "2.0.2",
|
||||||
"@vue/tsconfig": "^0.1.3",
|
"@vue/tsconfig": "0.1.3",
|
||||||
"chai": "4.3.6",
|
"chai": "4.3.6",
|
||||||
"easygettext": "2.17.0",
|
"easygettext": "2.17.0",
|
||||||
"eslint": "8.20.0",
|
"eslint": "8.20.0",
|
||||||
"eslint-config-standard": "17.0.0",
|
"eslint-config-standard": "17.0.0",
|
||||||
"eslint-plugin-html": "7.0.0",
|
"eslint-plugin-html": "7.0.0",
|
||||||
"eslint-plugin-import": "2.26.0",
|
"eslint-plugin-import": "2.26.0",
|
||||||
"eslint-plugin-n": "^15.2.4",
|
"eslint-plugin-n": "15.2.4",
|
||||||
"eslint-plugin-node": "11.1.0",
|
"eslint-plugin-node": "11.1.0",
|
||||||
"eslint-plugin-promise": "6.0.0",
|
"eslint-plugin-promise": "6.0.0",
|
||||||
"eslint-plugin-vue": "9.2.0",
|
"eslint-plugin-vue": "9.2.0",
|
||||||
|
|
|
@ -104,7 +104,7 @@ if (store.state.auth.authenticated) {
|
||||||
<template v-if="Component">
|
<template v-if="Component">
|
||||||
<keep-alive :max="1">
|
<keep-alive :max="1">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<component :is="Component" v-show="!store.state.ui.queueFocused" />
|
<component :is="Component" />
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<!-- TODO (wvffle): Add loader -->
|
<!-- TODO (wvffle): Add loader -->
|
||||||
Loading...
|
Loading...
|
||||||
|
|
|
@ -1,40 +1,27 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Track, QueueItemSource } from '~/types'
|
||||||
|
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { nextTick, ref, computed, onBeforeMount, onUnmounted } from 'vue'
|
import { nextTick, ref, computed, watchEffect, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import time from '~/utils/time'
|
import time from '~/utils/time'
|
||||||
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
|
||||||
import Draggable from 'vuedraggable'
|
import { whenever, watchDebounced, useCurrentElement, useScrollLock } from '@vueuse/core'
|
||||||
import { whenever, watchDebounced } from '@vueuse/core'
|
|
||||||
import { useGettext } from 'vue3-gettext'
|
import { useGettext } from 'vue3-gettext'
|
||||||
import useQueue from '~/composables/audio/useQueue'
|
import useQueue from '~/composables/audio/useQueue'
|
||||||
import usePlayer from '~/composables/audio/usePlayer'
|
import usePlayer from '~/composables/audio/usePlayer'
|
||||||
|
|
||||||
|
import VirtualList from '~/components/vui/list/VirtualList.vue'
|
||||||
|
import QueueItem from '~/components/QueueItem.vue'
|
||||||
|
|
||||||
const queueModal = ref()
|
const queueModal = ref()
|
||||||
|
const { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
|
||||||
let savedScroll = 0
|
|
||||||
onBeforeMount(() => (savedScroll = window.scrollY))
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.body.parentElement?.setAttribute('style', 'scroll-behavior: auto')
|
|
||||||
window.scrollTo({ top: savedScroll, behavior: undefined })
|
|
||||||
document.body.parentElement?.removeAttribute('style')
|
|
||||||
})
|
|
||||||
|
|
||||||
const { activate } = useFocusTrap(queueModal, { allowOutsideClick: true })
|
|
||||||
activate()
|
|
||||||
|
|
||||||
const store = useStore()
|
|
||||||
const currentIndex = computed(() => store.state.queue.currentIndex)
|
|
||||||
|
|
||||||
const scrollToCurrent = async () => {
|
|
||||||
await nextTick()
|
|
||||||
const item = queueModal.value?.querySelector('.queue-item.active')
|
|
||||||
item?.scrollIntoView({ behavior: store.state.ui.queueFocused ? 'smooth' : 'auto' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { $pgettext } = useGettext()
|
const { $pgettext } = useGettext()
|
||||||
|
const scrollLock = useScrollLock(document.body)
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
playing,
|
playing,
|
||||||
|
@ -55,16 +42,15 @@ const {
|
||||||
hasNext,
|
hasNext,
|
||||||
isEmpty: emptyQueue,
|
isEmpty: emptyQueue,
|
||||||
tracks,
|
tracks,
|
||||||
reorder: reorderTracks,
|
reorder,
|
||||||
endsIn: timeLeft,
|
endsIn: timeLeft,
|
||||||
|
currentIndex,
|
||||||
removeTrack,
|
removeTrack,
|
||||||
clear,
|
clear,
|
||||||
next,
|
next,
|
||||||
previous
|
previous
|
||||||
} = useQueue()
|
} = useQueue()
|
||||||
|
|
||||||
const reorder = (event: { oldIndex: number, newIndex: number }) => reorderTracks(event.oldIndex, event.newIndex)
|
|
||||||
|
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
queue: $pgettext('*/*/*', 'Queue'),
|
queue: $pgettext('*/*/*', 'Queue'),
|
||||||
duration: $pgettext('*/*/*', 'Duration'),
|
duration: $pgettext('*/*/*', 'Duration'),
|
||||||
|
@ -73,13 +59,42 @@ const labels = computed(() => ({
|
||||||
previous: $pgettext('*/*/*', 'Previous track'),
|
previous: $pgettext('*/*/*', 'Previous track'),
|
||||||
next: $pgettext('*/*/*', 'Next track'),
|
next: $pgettext('*/*/*', 'Next track'),
|
||||||
pause: $pgettext('*/*/*', 'Pause'),
|
pause: $pgettext('*/*/*', 'Pause'),
|
||||||
play: $pgettext('*/*/*', 'Play'),
|
play: $pgettext('*/*/*', 'Play')
|
||||||
remove: $pgettext('*/*/*', 'Remove'),
|
|
||||||
selectTrack: $pgettext('*/*/*', 'Select track')
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
watchDebounced(() => store.state.ui.queueFocused, scrollToCurrent, { debounce: 400 })
|
watchEffect(async () => {
|
||||||
whenever(currentTrack, scrollToCurrent, { immediate: true })
|
scrollLock.value = !!store.state.ui.queueFocused
|
||||||
|
if (store.state.ui.queueFocused) {
|
||||||
|
await nextTick()
|
||||||
|
activate()
|
||||||
|
} else {
|
||||||
|
deactivate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = ref()
|
||||||
|
const el = useCurrentElement()
|
||||||
|
const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => {
|
||||||
|
const item = el.value?.querySelector('.queue-item.active')
|
||||||
|
item?.scrollIntoView({
|
||||||
|
behavior,
|
||||||
|
block: 'center'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watchDebounced(currentTrack, () => scrollToCurrent(), { debounce: 100 })
|
||||||
|
whenever(
|
||||||
|
() => store.state.ui.queueFocused,
|
||||||
|
() => {
|
||||||
|
list.value?.scrollToIndex(currentIndex.value)
|
||||||
|
setTimeout(() => scrollToCurrent('auto'), 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
list.value?.scrollToIndex(currentIndex.value)
|
||||||
|
})
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => tracks.value.length === 0,
|
() => tracks.value.length === 0,
|
||||||
|
@ -96,19 +111,49 @@ const touchProgress = (event: MouseEvent) => {
|
||||||
currentTime.value = time
|
currentTime.value = time
|
||||||
}
|
}
|
||||||
|
|
||||||
const play = (index: number) => {
|
const play = (index: unknown) => {
|
||||||
store.dispatch('queue/currentIndex', index)
|
store.dispatch('queue/currentIndex', index as number)
|
||||||
resume()
|
resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCover = (track: Track) => {
|
||||||
|
return store.getters['instance/absoluteUrl'](
|
||||||
|
track.cover?.urls.medium_square_crop
|
||||||
|
?? track.album?.cover?.urls.medium_square_crop
|
||||||
|
?? new URL('../assets/audio/default-cover.png', import.meta.url).href
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueItems = computed(() => tracks.value.map((track, index) => ({
|
||||||
|
id: `${index}-${track.id}`,
|
||||||
|
track,
|
||||||
|
coverUrl: getCover(track),
|
||||||
|
labels: {
|
||||||
|
remove: $pgettext('*/*/*', 'Remove'),
|
||||||
|
selectTrack: $pgettext('*/*/*', 'Select track')
|
||||||
|
},
|
||||||
|
duration: time.durationFormatted(track.uploads[0].duration ?? 0) ?? ''
|
||||||
|
}) as QueueItemSource))
|
||||||
|
|
||||||
|
const reorderTracks = async (from: number, to: number) => {
|
||||||
|
reorder(from, to)
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
if (to === currentIndex.value) {
|
||||||
|
scrollToCurrent()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
ref="queueModal"
|
|
||||||
class="main with-background component-queue"
|
class="main with-background component-queue"
|
||||||
:aria-label="labels.queue"
|
:aria-label="labels.queue"
|
||||||
>
|
>
|
||||||
<div id="queue-grid">
|
<div
|
||||||
|
id="queue-grid"
|
||||||
|
:class="store.state.ui.queueFocused && `show-${store.state.ui.queueFocused}`"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
id="player"
|
id="player"
|
||||||
class="ui basic segment"
|
class="ui basic segment"
|
||||||
|
@ -296,7 +341,7 @@ const play = (index: number) => {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div id="queue">
|
||||||
<div class="ui basic clearing segment">
|
<div class="ui basic clearing segment">
|
||||||
<h2 class="ui header">
|
<h2 class="ui header">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
@ -336,107 +381,54 @@ const play = (index: number) => {
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<virtual-list
|
||||||
<table class="ui compact very basic fixed single line selectable unstackable table">
|
:list="queueItems"
|
||||||
<draggable
|
:component="QueueItem"
|
||||||
v-model="tracks"
|
:size="50"
|
||||||
handle=".handle"
|
:item-class="(index: number) => currentIndex === index ? 'active': ''"
|
||||||
item-key="id"
|
data-key="id"
|
||||||
tag="tbody"
|
@play="play"
|
||||||
@update="reorder"
|
@remove="removeTrack"
|
||||||
|
@reorder="reorderTracks"
|
||||||
|
/>
|
||||||
|
<!-- <virtual-list
|
||||||
|
ref="list"
|
||||||
|
wrap-class="queue-sortable-container"
|
||||||
|
item-class="queue-sortable-item"
|
||||||
|
:data-key="'id'"
|
||||||
|
:data-sources="queueItems"
|
||||||
|
:data-component="QueueItem"
|
||||||
|
:estimate-size="50"
|
||||||
|
:extra-props="{
|
||||||
|
onPlay: play,
|
||||||
|
onRemove: removeTrack,
|
||||||
|
itemClass: (index: number) => currentIndex === index ? 'active': ''
|
||||||
|
}"
|
||||||
|
@reorder="log"
|
||||||
|
/> -->
|
||||||
|
<div
|
||||||
|
v-if="$store.state.radios.running"
|
||||||
|
class="ui info message"
|
||||||
|
>
|
||||||
|
<div class="content">
|
||||||
|
<h3 class="header">
|
||||||
|
<i class="feed icon" /> <translate translate-context="Sidebar/Player/Title">
|
||||||
|
You have a radio playing
|
||||||
|
</translate>
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
<translate translate-context="Sidebar/Player/Paragraph">
|
||||||
|
New tracks will be appended here automatically.
|
||||||
|
</translate>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="ui basic primary button"
|
||||||
|
@click="$store.dispatch('radios/stop')"
|
||||||
>
|
>
|
||||||
<template #item="{ element: track, index }">
|
<translate translate-context="*/Player/Button.Label/Short, Verb">
|
||||||
<tr
|
Stop radio
|
||||||
:key="track.id"
|
</translate>
|
||||||
:class="['queue-item', {'active': index === currentIndex}]"
|
</button>
|
||||||
>
|
|
||||||
<td class="handle">
|
|
||||||
<i class="grip lines icon" />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
class="image-cell"
|
|
||||||
@click="play(index)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="track.cover && track.cover.urls.original"
|
|
||||||
class="ui mini image"
|
|
||||||
alt=""
|
|
||||||
:src="$store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-else-if="track.album && track.album.cover && track.album.cover.urls.original"
|
|
||||||
class="ui mini image"
|
|
||||||
alt=""
|
|
||||||
:src="$store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-else
|
|
||||||
class="ui mini image"
|
|
||||||
alt=""
|
|
||||||
src="../assets/audio/default-cover.png"
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
colspan="3"
|
|
||||||
@click="play(index)"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="title reset ellipsis"
|
|
||||||
:title="track.title"
|
|
||||||
:aria-label="labels.selectTrack"
|
|
||||||
>
|
|
||||||
<strong>{{ track.title }}</strong><br>
|
|
||||||
<span>
|
|
||||||
{{ track.artist.name }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td class="duration-cell">
|
|
||||||
<template v-if="track.uploads.length > 0">
|
|
||||||
{{ time.durationFormatted(track.uploads[0].duration) }}
|
|
||||||
</template>
|
|
||||||
</td>
|
|
||||||
<td class="controls">
|
|
||||||
<template v-if="$store.getters['favorites/isFavorite'](track.id)">
|
|
||||||
<i class="pink heart icon" />
|
|
||||||
</template>
|
|
||||||
<button
|
|
||||||
:aria-label="labels.remove"
|
|
||||||
:title="labels.remove"
|
|
||||||
:class="['ui', 'really', 'tiny', 'basic', 'circular', 'icon', 'button']"
|
|
||||||
@click.stop="removeTrack(index)"
|
|
||||||
>
|
|
||||||
<i class="x icon" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
</table>
|
|
||||||
<div
|
|
||||||
v-if="$store.state.radios.running"
|
|
||||||
class="ui info message"
|
|
||||||
>
|
|
||||||
<div class="content">
|
|
||||||
<h3 class="header">
|
|
||||||
<i class="feed icon" /> <translate translate-context="Sidebar/Player/Title">
|
|
||||||
You have a radio playing
|
|
||||||
</translate>
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
<translate translate-context="Sidebar/Player/Paragraph">
|
|
||||||
New tracks will be appended here automatically.
|
|
||||||
</translate>
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
class="ui basic primary button"
|
|
||||||
@click="$store.dispatch('radios/stop')"
|
|
||||||
>
|
|
||||||
<translate translate-context="*/Player/Button.Label/Short, Verb">
|
|
||||||
Stop radio
|
|
||||||
</translate>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,69 @@
|
||||||
|
<script setup functional lang="ts">
|
||||||
|
import type { QueueItemSource } from '~/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
source: QueueItemSource
|
||||||
|
index: number
|
||||||
|
itemClass: (index: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'play', index: number): void
|
||||||
|
(e: 'remove', index: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
defineEmits<Emits>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="itemClass(index)"
|
||||||
|
class="queue-item"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="handle">
|
||||||
|
<i class="grip lines icon" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="image-cell"
|
||||||
|
@click="$emit('play', index)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
class="ui mini image"
|
||||||
|
alt=""
|
||||||
|
:src="source.coverUrl"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div @click="$emit('play', index)">
|
||||||
|
<button
|
||||||
|
class="title reset ellipsis"
|
||||||
|
:title="source.track.title"
|
||||||
|
:aria-label="source.labels.selectTrack"
|
||||||
|
>
|
||||||
|
<strong>{{ source.track.title }}</strong><br>
|
||||||
|
<span>
|
||||||
|
{{ source.track.artist.name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="duration-cell">
|
||||||
|
<template v-if="source.track.uploads.length > 0">
|
||||||
|
{{ source.duration }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<template v-if="$store.getters['favorites/isFavorite'](source.track.id)">
|
||||||
|
<i class="pink heart icon" />
|
||||||
|
</template>
|
||||||
|
<button
|
||||||
|
:aria-label="source.labels.remove"
|
||||||
|
:title="source.labels.remove"
|
||||||
|
class="ui really tiny basic circular icon button"
|
||||||
|
@click.stop="$emit('remove', index)"
|
||||||
|
>
|
||||||
|
<i class="x icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -191,7 +191,7 @@ const touchProgress = (event: MouseEvent) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls track-controls queue-not-focused tablet-and-below">
|
<div class="controls track-controls queue-not-focused desktop-and-below">
|
||||||
<div class="ui tiny image">
|
<div class="ui tiny image">
|
||||||
<img
|
<img
|
||||||
v-if="currentTrack.cover && currentTrack.cover.urls.original"
|
v-if="currentTrack.cover && currentTrack.cover.urls.original"
|
||||||
|
@ -370,7 +370,7 @@ const touchProgress = (event: MouseEvent) => {
|
||||||
</translate>
|
</translate>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="position circular control button tablet-and-below"
|
class="position circular control button desktop-and-below"
|
||||||
@click.stop="switchTab"
|
@click.stop="switchTab"
|
||||||
>
|
>
|
||||||
<i class="stream icon" />
|
<i class="stream icon" />
|
||||||
|
@ -398,21 +398,21 @@ const touchProgress = (event: MouseEvent) => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="$store.state.ui.queueFocused === 'player'"
|
v-if="$store.state.ui.queueFocused === 'player'"
|
||||||
class="circular control button close-control tablet-and-below"
|
class="circular control button close-control desktop-and-below"
|
||||||
@click.stop="switchTab"
|
@click.stop="switchTab"
|
||||||
>
|
>
|
||||||
<i class="large up angle icon" />
|
<i class="large up angle icon" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="$store.state.ui.queueFocused === 'queue'"
|
v-if="$store.state.ui.queueFocused === 'queue'"
|
||||||
class="circular control button tablet-and-below"
|
class="circular control button desktop-and-below"
|
||||||
@click.stop="switchTab"
|
@click.stop="switchTab"
|
||||||
>
|
>
|
||||||
<i class="large down angle icon" />
|
<i class="large down angle icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="circular control button close-control tablet-and-below"
|
class="circular control button close-control desktop-and-below"
|
||||||
@click.stop="$store.commit('ui/queueFocused', null)"
|
@click.stop="$store.commit('ui/queueFocused', null)"
|
||||||
>
|
>
|
||||||
<i class="x icon" />
|
<i class="x icon" />
|
||||||
|
|
|
@ -21,7 +21,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
const emit = defineEmits(['approved', 'deny', 'update:show', 'show', 'hide'])
|
const emit = defineEmits(['approved', 'deny', 'update:show', 'show', 'hide'])
|
||||||
|
|
||||||
const modal = ref()
|
const modal = ref()
|
||||||
const { activate, deactivate, pause, unpause } = useFocusTrap(modal)
|
const { activate, deactivate, pause, unpause } = useFocusTrap(modal, {
|
||||||
|
allowOutsideClick: true
|
||||||
|
})
|
||||||
|
|
||||||
const show = useVModel(props, 'show', emit)
|
const show = useVModel(props, 'show', emit)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
component: unknown
|
||||||
|
index: number
|
||||||
|
dragClassHandler: (index: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Component
|
||||||
|
:is="component"
|
||||||
|
:class="dragClassHandler(index)"
|
||||||
|
:index="index"
|
||||||
|
:data-index="index"
|
||||||
|
/>
|
||||||
|
</template>
|
|
@ -0,0 +1,233 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MaybeElementRef, MaybeElement } from '@vueuse/core'
|
||||||
|
|
||||||
|
import { useMouse, useCurrentElement, useResizeObserver, useRafFn, useElementByPoint } from '@vueuse/core'
|
||||||
|
import { ref, watchEffect, reactive } from 'vue'
|
||||||
|
|
||||||
|
import VirtualItem from './VirtualItem.vue'
|
||||||
|
// @ts-expect-error no typings
|
||||||
|
import VirtualList from 'vue3-virtual-scroll-list'
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'reorder', from: number, to: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
list: object[]
|
||||||
|
size: number
|
||||||
|
dataKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const ghostContainer = ref()
|
||||||
|
const hoveredIndex = ref()
|
||||||
|
const draggedItem = ref()
|
||||||
|
const position = ref('after')
|
||||||
|
|
||||||
|
const getIndex = (element: HTMLElement) => +(element?.getAttribute('data-index') ?? 0)
|
||||||
|
|
||||||
|
const isTouch = ref(false)
|
||||||
|
const onMousedown = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const element = event.target as HTMLElement
|
||||||
|
const dragItem = element.closest('.drag-item')?.children[0] as HTMLElement
|
||||||
|
if (!dragItem || !element.classList.contains('handle')) return
|
||||||
|
|
||||||
|
// Touch devices stop emitting touch events while container is scrolled
|
||||||
|
isTouch.value = event instanceof TouchEvent
|
||||||
|
|
||||||
|
const ghost = dragItem.cloneNode(true) as HTMLElement
|
||||||
|
ghost.classList.add('drag-ghost')
|
||||||
|
ghostContainer.value.appendChild(ghost)
|
||||||
|
|
||||||
|
const index = getIndex(dragItem)
|
||||||
|
document.body.classList.add('dragging')
|
||||||
|
hoveredIndex.value = index
|
||||||
|
draggedItem.value = {
|
||||||
|
item: props.list[index],
|
||||||
|
ghost,
|
||||||
|
index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch and mobile devices support
|
||||||
|
const onTouchmove = (event: TouchEvent) => {
|
||||||
|
if (draggedItem.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('touchcancel', (event: TouchEvent) => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
|
||||||
|
const reorder = (event: MouseEvent | TouchEvent) => {
|
||||||
|
const element = event.target as HTMLElement
|
||||||
|
const dragItem = element.closest('.drag-item')?.children[0]
|
||||||
|
|
||||||
|
if (dragItem && draggedItem.value) {
|
||||||
|
const from = draggedItem.value.index
|
||||||
|
let to = hoveredIndex.value
|
||||||
|
|
||||||
|
if (from === to) return cleanup()
|
||||||
|
to -= +(position.value === 'before')
|
||||||
|
to += +(from > to)
|
||||||
|
|
||||||
|
if (from === to) return cleanup()
|
||||||
|
emit('reorder', from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', reorder)
|
||||||
|
document.addEventListener('touchend', reorder)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
document.body.classList.remove('dragging')
|
||||||
|
draggedItem.value?.ghost?.remove()
|
||||||
|
draggedItem.value = undefined
|
||||||
|
hoveredIndex.value = undefined
|
||||||
|
scrollDirection.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragClassHandler = (index: number) => draggedItem.value && hoveredIndex.value === index
|
||||||
|
? `drop-${position.value}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const scrollDirection = ref()
|
||||||
|
const containerSize = reactive({ bottom: 0, top: 0 })
|
||||||
|
const { x, y: screenY } = useMouse()
|
||||||
|
const { element: hoveredElement } = useElementByPoint({ x, y: screenY })
|
||||||
|
|
||||||
|
// Find current index and position on both desktop and mobile devices
|
||||||
|
watchEffect(() => {
|
||||||
|
const dragItem = (hoveredElement.value as HTMLElement)?.closest('.drag-item')?.children[0] as HTMLElement
|
||||||
|
if (!dragItem) return
|
||||||
|
|
||||||
|
hoveredIndex.value = getIndex(dragItem)
|
||||||
|
const { y } = dragItem.getBoundingClientRect()
|
||||||
|
position.value = screenY.value - y < props.size / 2 ? 'before' : 'after'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Automatically scroll when on the edge
|
||||||
|
watchEffect(() => {
|
||||||
|
const { top, bottom } = containerSize
|
||||||
|
const y = Math.min(bottom, Math.max(top, screenY.value))
|
||||||
|
|
||||||
|
if (draggedItem.value) {
|
||||||
|
ghostContainer.value.style.top = `${y}px`
|
||||||
|
|
||||||
|
scrollDirection.value = y === top
|
||||||
|
? 'up'
|
||||||
|
: y === bottom
|
||||||
|
? 'down'
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollDirection.value = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const el = useCurrentElement()
|
||||||
|
useResizeObserver(el as unknown as MaybeElementRef<MaybeElement>, ([entry]) => {
|
||||||
|
const height = entry.borderBoxSize?.[0]?.blockSize ?? 0
|
||||||
|
|
||||||
|
if (height !== 0) {
|
||||||
|
containerSize.top = (entry.target as HTMLElement).offsetTop
|
||||||
|
containerSize.bottom = height + containerSize.top
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let lastDate = +new Date()
|
||||||
|
useRafFn(() => {
|
||||||
|
const now = +new Date()
|
||||||
|
const delta = now - lastDate
|
||||||
|
const direction = scrollDirection.value
|
||||||
|
|
||||||
|
if (direction && el.value?.children[0] && !isTouch.value) {
|
||||||
|
el.value.children[0].scrollTop += 200 / delta * (direction === 'up' ? -1 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDate = now
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
cleanup
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<virtual-list
|
||||||
|
class="virtual-list"
|
||||||
|
wrap-class="drag-container"
|
||||||
|
item-class="drag-item"
|
||||||
|
:data-key="dataKey"
|
||||||
|
:data-sources="list"
|
||||||
|
:data-component="VirtualItem"
|
||||||
|
:estimate-size="size"
|
||||||
|
:extra-props="{
|
||||||
|
dragClassHandler,
|
||||||
|
...$attrs
|
||||||
|
}"
|
||||||
|
@mousedown="onMousedown"
|
||||||
|
@touchstart="onMousedown"
|
||||||
|
@touchmove="onTouchmove"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="ghostContainer"
|
||||||
|
class="ghost-container"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.drag-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
user-select: none;
|
||||||
|
cursor: grab !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-before {
|
||||||
|
box-shadow: 0 -1px 0 var(--vibrant-color),
|
||||||
|
inset 0 1px 0 var(--vibrant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-after {
|
||||||
|
box-shadow: 0 1px 0 var(--vibrant-color),
|
||||||
|
inset 0 -1px 0 var(--vibrant-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-ghost {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-container {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1002;
|
||||||
|
width: 100%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-light .ghost-container {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -3,7 +3,7 @@ import type { ContentFilter } from '~/store/moderation'
|
||||||
|
|
||||||
import { useStore } from '~/store'
|
import { useStore } from '~/store'
|
||||||
import { useGettext } from 'vue3-gettext'
|
import { useGettext } from 'vue3-gettext'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, markRaw, ref } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import usePlayer from '~/composables/audio/usePlayer'
|
import usePlayer from '~/composables/audio/usePlayer'
|
||||||
import useQueue from '~/composables/audio/useQueue'
|
import useQueue from '~/composables/audio/useQueue'
|
||||||
|
@ -129,7 +129,7 @@ export default (props: PlayOptionsProps) => {
|
||||||
// TODO (wvffle): It was behind 250ms timeout, why?
|
// TODO (wvffle): It was behind 250ms timeout, why?
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
||||||
return tracks.filter(track => track.uploads?.length)
|
return tracks.filter(track => track.uploads?.length).map(markRaw)
|
||||||
}
|
}
|
||||||
|
|
||||||
const el = useCurrentElement()
|
const el = useCurrentElement()
|
||||||
|
@ -137,7 +137,8 @@ export default (props: PlayOptionsProps) => {
|
||||||
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
|
||||||
|
|
||||||
const tracks = await getPlayableTracks()
|
const tracks = await getPlayableTracks()
|
||||||
store.dispatch('queue/appendMany', { tracks }).then(() => addMessage(tracks))
|
await store.dispatch('queue/appendMany', { tracks })
|
||||||
|
addMessage(tracks)
|
||||||
}
|
}
|
||||||
|
|
||||||
const enqueueNext = async (next = false) => {
|
const enqueueNext = async (next = false) => {
|
||||||
|
|
|
@ -100,7 +100,7 @@ const currentTime = computed({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSound.value.getSource() || time === currentSound.value.seek()) {
|
if (!currentSound.value?.getSource() || time === currentSound.value.seek()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,20 +28,13 @@ const focused = computed(() => store.state.ui.queueFocused === 'queue')
|
||||||
//
|
//
|
||||||
// Track list
|
// Track list
|
||||||
//
|
//
|
||||||
const tracksChangeBuffer = ref<Track[] | null>(null)
|
const tracks = computed<Track[]>(() => store.state.queue.tracks)
|
||||||
const tracks = computed<Track[]>({
|
|
||||||
get: () => store.state.queue.tracks,
|
|
||||||
set: (value) => (tracksChangeBuffer.value = value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reorder = (oldIndex: number, newIndex: number) => {
|
const reorder = (oldIndex: number, newIndex: number) => {
|
||||||
store.commit('queue/reorder', {
|
store.commit('queue/reorder', {
|
||||||
tracks: tracksChangeBuffer.value ?? tracks.value,
|
|
||||||
oldIndex,
|
oldIndex,
|
||||||
newIndex
|
newIndex
|
||||||
})
|
})
|
||||||
|
|
||||||
tracksChangeBuffer.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
|
@ -38,21 +38,23 @@ const store: Module<State, RootState> = {
|
||||||
tracks (state, value) {
|
tracks (state, value) {
|
||||||
state.tracks = value
|
state.tracks = value
|
||||||
},
|
},
|
||||||
insert (state, { track, index }) {
|
reorder (state, { oldIndex, newIndex }) {
|
||||||
state.tracks.splice(index, 0, track)
|
|
||||||
},
|
|
||||||
reorder (state, { tracks, oldIndex, newIndex }) {
|
|
||||||
// called when the user uses drag / drop to reorder
|
// called when the user uses drag / drop to reorder
|
||||||
// tracks in queue
|
// tracks in queue
|
||||||
state.tracks = tracks
|
|
||||||
|
const [track] = state.tracks.splice(oldIndex, 1)
|
||||||
|
state.tracks.splice(newIndex, 0, track)
|
||||||
|
|
||||||
if (oldIndex === state.currentIndex) {
|
if (oldIndex === state.currentIndex) {
|
||||||
state.currentIndex = newIndex
|
state.currentIndex = newIndex
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) {
|
if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) {
|
||||||
// item before was moved after
|
// item before was moved after
|
||||||
state.currentIndex -= 1
|
state.currentIndex -= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) {
|
if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) {
|
||||||
// item after was moved before
|
// item after was moved before
|
||||||
state.currentIndex += 1
|
state.currentIndex += 1
|
||||||
|
@ -72,41 +74,32 @@ const store: Module<State, RootState> = {
|
||||||
isEmpty: state => state.tracks.length === 0
|
isEmpty: state => state.tracks.length === 0
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
append ({ commit, state }, { track, index }) {
|
append ({ dispatch, state }, { track, index = state.tracks.length }) {
|
||||||
index = index || state.tracks.length
|
return dispatch('appendMany', { tracks: [track], index })
|
||||||
if (index > state.tracks.length - 1) {
|
|
||||||
// we simply push to the end
|
|
||||||
commit('insert', { track, index: state.tracks.length })
|
|
||||||
} else {
|
|
||||||
// we insert the track at given position
|
|
||||||
commit('insert', { track, index })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
appendMany ({ state, dispatch }, { tracks, index, callback }) {
|
appendMany ({ state, dispatch }, { tracks, index = state.tracks.length }) {
|
||||||
logger.info('Appending many tracks to the queue', tracks.map((track: Track) => track.title))
|
logger.info(
|
||||||
let shouldPlay = false
|
'Enqueueing tracks',
|
||||||
|
tracks.map((track: Track) => [track.artist?.name, track.title].join(' - '))
|
||||||
|
)
|
||||||
|
|
||||||
if (state.tracks.length === 0) {
|
const shouldPlay = state.tracks.length === 0
|
||||||
|
if (shouldPlay) {
|
||||||
index = 0
|
index = 0
|
||||||
shouldPlay = true
|
|
||||||
} else {
|
|
||||||
index = index ?? state.tracks.length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = tracks.length
|
if (index >= state.tracks.length) {
|
||||||
tracks.forEach((track: Track, i: number) => {
|
// we simply push to the end
|
||||||
const promise = dispatch('append', { track, index })
|
state.tracks.push(...tracks)
|
||||||
index += 1
|
} else {
|
||||||
|
// we insert the track at given position
|
||||||
|
state.tracks.splice(index, 0, ...tracks)
|
||||||
|
}
|
||||||
|
|
||||||
if (callback && i + 1 === total) {
|
if (shouldPlay) {
|
||||||
promise.then(callback)
|
return dispatch('next')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldPlay && promise && i + 1 === total) {
|
|
||||||
promise.then(() => dispatch('next'))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
cleanTrack ({ state, dispatch, commit }, index) {
|
cleanTrack ({ state, dispatch, commit }, index) {
|
||||||
|
|
|
@ -235,8 +235,23 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 37.5% 62.5%;
|
grid-template-columns: 37.5% 62.5%;
|
||||||
|
|
||||||
|
#queue {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
@include media("<desktop") {
|
@include media("<desktop") {
|
||||||
grid-template-columns: 1fr 0;
|
grid-template-columns: 1fr 0;
|
||||||
|
&.show-player {
|
||||||
|
#queue {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show-queue {
|
||||||
|
#player {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#player {
|
#player {
|
||||||
|
@ -246,6 +261,11 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
.ui.header {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-container {
|
.cover-container {
|
||||||
width: 50vh;
|
width: 50vh;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
|
@ -282,10 +302,43 @@
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto 1fr;
|
||||||
|
|
||||||
> :nth-child(2) {
|
> :nth-child(2) {
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtual-list {
|
||||||
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.queue-item {
|
||||||
|
height: 50px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 10% auto 1fr 10% auto;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.875rem;
|
||||||
|
|
||||||
|
.handle > .grip {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Taken from semantic ui
|
||||||
|
&.active {
|
||||||
|
background: #E0E0E0;
|
||||||
|
color: #000000de;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-container:not(.dragging) .queue-item:hover {
|
||||||
|
background: rgba(0,0,0,.05);
|
||||||
|
color: #000000f2;
|
||||||
}
|
}
|
|
@ -18,6 +18,19 @@ export interface InitModuleContext {
|
||||||
|
|
||||||
export type InitModule = (ctx: InitModuleContext) => void | Promise<void>
|
export type InitModule = (ctx: InitModuleContext) => void | Promise<void>
|
||||||
|
|
||||||
|
export interface QueueItemSource {
|
||||||
|
id: string
|
||||||
|
track: Track
|
||||||
|
duration: string
|
||||||
|
coverUrl: string
|
||||||
|
|
||||||
|
// TODO (wvffle): Maybe use <translate> component to avoid passing the labels
|
||||||
|
labels: {
|
||||||
|
remove: string
|
||||||
|
selectTrack: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Theme stuff
|
// Theme stuff
|
||||||
export type Theme = 'auto' | 'light' | 'dark'
|
export type Theme = 'auto' | 'light' | 'dark'
|
||||||
|
|
||||||
|
|
|
@ -21,8 +21,8 @@ export default {
|
||||||
? `${hours}:${pad(min)}:${pad(sec)}`
|
? `${hours}:${pad(min)}:${pad(sec)}`
|
||||||
: `${min}:${pad(sec)}`
|
: `${min}:${pad(sec)}`
|
||||||
},
|
},
|
||||||
durationFormatted (v: string) {
|
durationFormatted (v: string | number) {
|
||||||
const duration = parseInt(v)
|
const duration = typeof v === 'number' ? v : parseInt(v)
|
||||||
return this.parse(duration % 1 !== 0 ? 0 : Math.round(duration))
|
return this.parse(duration % 1 !== 0 ? 0 : Math.round(duration))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1363,10 +1363,10 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/types" "^7.3.0"
|
"@babel/types" "^7.3.0"
|
||||||
|
|
||||||
"@types/dompurify@^2.3.3":
|
"@types/dompurify@2.3.3":
|
||||||
version "2.3.4"
|
version "2.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.4.tgz#94e997e30338ea24d4c8d08beca91ce4dd17a1b4"
|
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.3.tgz#c24c92f698f77ed9cc9d9fa7888f90cf2bfaa23f"
|
||||||
integrity sha512-EXzDatIb5EspL2eb/xPGmaC8pePcTHrkDCONjeisusLFrVfl38Pjea/R0YJGu3k9ZQadSvMqW0WXPI2hEo2Ajg==
|
integrity sha512-nnVQSgRVuZ/843oAfhA25eRSNzUFcBPk/LOiw5gm8mD9/X7CNcbRkQu/OsjCewO8+VIYfPxUnXvPEVGenw14+w==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/trusted-types" "*"
|
"@types/trusted-types" "*"
|
||||||
|
|
||||||
|
@ -1390,7 +1390,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/howler@^2.2.7":
|
"@types/howler@2.2.7":
|
||||||
version "2.2.7"
|
version "2.2.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.7.tgz#5acfbed57f9e1d99b8dabe1b824729e1c1ea1fae"
|
resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.7.tgz#5acfbed57f9e1d99b8dabe1b824729e1c1ea1fae"
|
||||||
integrity sha512-PEZldwZqJJw1PWRTpupyC7ajVTZA8aHd8nB/Y0n6zRZi5u8ktYDntsHj13ltEiBRqWwF06pASxBEvCTxniG8eA==
|
integrity sha512-PEZldwZqJJw1PWRTpupyC7ajVTZA8aHd8nB/Y0n6zRZi5u8ktYDntsHj13ltEiBRqWwF06pASxBEvCTxniG8eA==
|
||||||
|
@ -1632,7 +1632,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/jquery" "*"
|
"@types/jquery" "*"
|
||||||
|
|
||||||
"@types/semantic-ui@^2.2.7":
|
"@types/semantic-ui@2.2.7":
|
||||||
version "2.2.7"
|
version "2.2.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/semantic-ui/-/semantic-ui-2.2.7.tgz#4ae4242004aac11a21133d4f338e868b92605270"
|
resolved "https://registry.yarnpkg.com/@types/semantic-ui/-/semantic-ui-2.2.7.tgz#4ae4242004aac11a21133d4f338e868b92605270"
|
||||||
integrity sha512-Uj6rby2GnuVyO7pj8vgUFsv5eaxb0ktpfasYcB/vXnSAeJ4cRjIOvxka+EoPjw3tPCY4/WlxRss8hsh7kRWzQg==
|
integrity sha512-Uj6rby2GnuVyO7pj8vgUFsv5eaxb0ktpfasYcB/vXnSAeJ4cRjIOvxka+EoPjw3tPCY4/WlxRss8hsh7kRWzQg==
|
||||||
|
@ -1659,7 +1659,7 @@
|
||||||
"@types/semantic-ui-transition" "*"
|
"@types/semantic-ui-transition" "*"
|
||||||
"@types/semantic-ui-visibility" "*"
|
"@types/semantic-ui-visibility" "*"
|
||||||
|
|
||||||
"@types/showdown@^2.0.0":
|
"@types/showdown@2.0.0":
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.0.tgz#3e800eca8573848cac4e5555f4377ba3a0e7b1f2"
|
resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.0.tgz#3e800eca8573848cac4e5555f4377ba3a0e7b1f2"
|
||||||
integrity sha512-70xBJoLv+oXjB5PhtA8vo7erjLDp9/qqI63SRHm4REKrwuPOLs8HhXwlZJBJaB4kC18cCZ1UUZ6Fb/PLFW4TCA==
|
integrity sha512-70xBJoLv+oXjB5PhtA8vo7erjLDp9/qqI63SRHm4REKrwuPOLs8HhXwlZJBJaB4kC18cCZ1UUZ6Fb/PLFW4TCA==
|
||||||
|
@ -1989,7 +1989,7 @@
|
||||||
"@vue/compiler-dom" "3.2.38"
|
"@vue/compiler-dom" "3.2.38"
|
||||||
"@vue/shared" "3.2.38"
|
"@vue/shared" "3.2.38"
|
||||||
|
|
||||||
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.1.4", "@vue/devtools-api@^6.2.1":
|
"@vue/devtools-api@^6.0.0-beta.11", "@vue/devtools-api@^6.1.4":
|
||||||
version "6.2.1"
|
version "6.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.2.1.tgz#6f2948ff002ec46df01420dfeff91de16c5b4092"
|
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.2.1.tgz#6f2948ff002ec46df01420dfeff91de16c5b4092"
|
||||||
integrity sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==
|
integrity sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==
|
||||||
|
@ -2041,7 +2041,7 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@vue/shared" "3.2.37"
|
"@vue/shared" "3.2.37"
|
||||||
|
|
||||||
"@vue/reactivity@3.2.38", "@vue/reactivity@^3.2.37":
|
"@vue/reactivity@^3.2.37":
|
||||||
version "3.2.38"
|
version "3.2.38"
|
||||||
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.38.tgz#d576fdcea98eefb96a1f1ad456e289263e87292e"
|
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.38.tgz#d576fdcea98eefb96a1f1ad456e289263e87292e"
|
||||||
integrity sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw==
|
integrity sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw==
|
||||||
|
@ -2056,14 +2056,6 @@
|
||||||
"@vue/reactivity" "3.2.37"
|
"@vue/reactivity" "3.2.37"
|
||||||
"@vue/shared" "3.2.37"
|
"@vue/shared" "3.2.37"
|
||||||
|
|
||||||
"@vue/runtime-core@^3.2.37":
|
|
||||||
version "3.2.38"
|
|
||||||
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.38.tgz#d19cf591c210713f80e6a94ffbfef307c27aea06"
|
|
||||||
integrity sha512-kk0qiSiXUU/IKxZw31824rxmFzrLr3TL6ZcbrxWTKivadoKupdlzbQM4SlGo4MU6Zzrqv4fzyUasTU1jDoEnzg==
|
|
||||||
dependencies:
|
|
||||||
"@vue/reactivity" "3.2.38"
|
|
||||||
"@vue/shared" "3.2.38"
|
|
||||||
|
|
||||||
"@vue/runtime-dom@3.2.37":
|
"@vue/runtime-dom@3.2.37":
|
||||||
version "3.2.37"
|
version "3.2.37"
|
||||||
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz#002bdc8228fa63949317756fb1e92cdd3f9f4bbd"
|
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.37.tgz#002bdc8228fa63949317756fb1e92cdd3f9f4bbd"
|
||||||
|
@ -2096,7 +2088,7 @@
|
||||||
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.2.tgz#0b5edd683366153d5bc5a91edc62f292118710eb"
|
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.2.tgz#0b5edd683366153d5bc5a91edc62f292118710eb"
|
||||||
integrity sha512-E2P4oXSaWDqTZNbmKZFVLrNN/siVN78YkEqs7pHryWerrlZR9bBFLWdJwRoguX45Ru6HxIflzKl4vQvwRMwm5g==
|
integrity sha512-E2P4oXSaWDqTZNbmKZFVLrNN/siVN78YkEqs7pHryWerrlZR9bBFLWdJwRoguX45Ru6HxIflzKl4vQvwRMwm5g==
|
||||||
|
|
||||||
"@vue/tsconfig@^0.1.3":
|
"@vue/tsconfig@0.1.3":
|
||||||
version "0.1.3"
|
version "0.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.1.3.tgz#4a61dbd29783d01ddab504276dcf0c2b6988654f"
|
resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.1.3.tgz#4a61dbd29783d01ddab504276dcf0c2b6988654f"
|
||||||
integrity sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==
|
integrity sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==
|
||||||
|
@ -3113,10 +3105,10 @@ domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype "^2.3.0"
|
domelementtype "^2.3.0"
|
||||||
|
|
||||||
dompurify@^2.3.8:
|
dompurify@2.3.8:
|
||||||
version "2.4.0"
|
version "2.3.8"
|
||||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd"
|
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.8.tgz#224fe9ae57d7ebd9a1ae1ac18c1c1ca3f532226f"
|
||||||
integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==
|
integrity sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw==
|
||||||
|
|
||||||
domutils@^2.5.2:
|
domutils@^2.5.2:
|
||||||
version "2.8.0"
|
version "2.8.0"
|
||||||
|
@ -3484,18 +3476,18 @@ eslint-plugin-import@2.26.0:
|
||||||
resolve "^1.22.0"
|
resolve "^1.22.0"
|
||||||
tsconfig-paths "^3.14.1"
|
tsconfig-paths "^3.14.1"
|
||||||
|
|
||||||
eslint-plugin-n@^15.2.4:
|
eslint-plugin-n@15.2.4:
|
||||||
version "15.2.5"
|
version "15.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.2.5.tgz#aa7ff8d45bb8bf2df8ea3b7d3774ae570cb794b8"
|
resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.2.4.tgz#d62021a0821ae650701ed459756aaf478a9b6056"
|
||||||
integrity sha512-8+BYsqiyZfpu6NXmdLOXVUfk8IocpCjpd8nMRRH0A9ulrcemhb2VI9RSJMEy5udx++A/YcVPD11zT8hpFq368g==
|
integrity sha512-tjnVMv2fiXYMnuiIFI8QMtyUFI42SckEEWvi8h68SWGWshfqO6SSCASy24dGMGAiy7NUk6DZt90DM0iNUsmQ5w==
|
||||||
dependencies:
|
dependencies:
|
||||||
builtins "^5.0.1"
|
builtins "^5.0.1"
|
||||||
eslint-plugin-es "^4.1.0"
|
eslint-plugin-es "^4.1.0"
|
||||||
eslint-utils "^3.0.0"
|
eslint-utils "^3.0.0"
|
||||||
ignore "^5.1.1"
|
ignore "^5.1.1"
|
||||||
is-core-module "^2.10.0"
|
is-core-module "^2.9.0"
|
||||||
minimatch "^3.1.2"
|
minimatch "^3.1.2"
|
||||||
resolve "^1.22.1"
|
resolve "^1.10.1"
|
||||||
semver "^7.3.7"
|
semver "^7.3.7"
|
||||||
|
|
||||||
eslint-plugin-node@11.1.0:
|
eslint-plugin-node@11.1.0:
|
||||||
|
@ -4220,7 +4212,7 @@ is-callable@^1.1.4, is-callable@^1.2.4:
|
||||||
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
|
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
|
||||||
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
|
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
|
||||||
|
|
||||||
is-core-module@^2.10.0, is-core-module@^2.7.0, is-core-module@^2.8.1, is-core-module@^2.9.0:
|
is-core-module@^2.7.0, is-core-module@^2.8.1, is-core-module@^2.9.0:
|
||||||
version "2.10.0"
|
version "2.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
|
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
|
||||||
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
|
integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==
|
||||||
|
@ -5371,14 +5363,6 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.2.3, picomatc
|
||||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||||
|
|
||||||
pinia@^2.0.13:
|
|
||||||
version "2.0.21"
|
|
||||||
resolved "https://registry.yarnpkg.com/pinia/-/pinia-2.0.21.tgz#2a6599ad3736fa71866f4b053ffb0073cd482270"
|
|
||||||
integrity sha512-6ol04PtL29O0Z6JHI47O3JUSoyOJ7Og0rstXrHVMZSP4zAldsQBXJCNF0i/H7m8vp/Hjd/CSmuPl7C5QAwpeWQ==
|
|
||||||
dependencies:
|
|
||||||
"@vue/devtools-api" "^6.2.1"
|
|
||||||
vue-demi "*"
|
|
||||||
|
|
||||||
pirates@^3.0.2:
|
pirates@^3.0.2:
|
||||||
version "3.0.2"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/pirates/-/pirates-3.0.2.tgz#7e6f85413fd9161ab4e12b539b06010d85954bb9"
|
resolved "https://registry.yarnpkg.com/pirates/-/pirates-3.0.2.tgz#7e6f85413fd9161ab4e12b539b06010d85954bb9"
|
||||||
|
@ -6222,7 +6206,7 @@ tr46@^1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode "^2.1.0"
|
punycode "^2.1.0"
|
||||||
|
|
||||||
transliteration@^2.3.5:
|
transliteration@2.3.5:
|
||||||
version "2.3.5"
|
version "2.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/transliteration/-/transliteration-2.3.5.tgz#8f92309575f69e4a8a525dab4ff705ebcf961c45"
|
resolved "https://registry.yarnpkg.com/transliteration/-/transliteration-2.3.5.tgz#8f92309575f69e4a8a525dab4ff705ebcf961c45"
|
||||||
integrity sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==
|
integrity sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==
|
||||||
|
@ -6549,6 +6533,11 @@ vue3-lazyload@0.3.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
vue-demi "^0.12.5"
|
vue-demi "^0.12.5"
|
||||||
|
|
||||||
|
vue3-virtual-scroll-list@0.2.0:
|
||||||
|
version "0.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/vue3-virtual-scroll-list/-/vue3-virtual-scroll-list-0.2.0.tgz#72810f831a66b7b3a4c82924ff655213943450f4"
|
||||||
|
integrity sha512-BPReKBDAVvJ+VgBAHK0qLLhZpOkl/bgB3q+8Do5moc9KUJKFDf8o0/tjAUKaim5ihj254IuJGFyZCUl4aWW/0w==
|
||||||
|
|
||||||
vue@3.2.37:
|
vue@3.2.37:
|
||||||
version "3.2.37"
|
version "3.2.37"
|
||||||
resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e"
|
resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e"
|
||||||
|
|
Loading…
Reference in New Issue