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:
wvffle 2022-07-24 20:51:13 +00:00 committed by Georg Krause
parent 53d9015e17
commit c87bf7e6b8
17 changed files with 594 additions and 242 deletions

View File

@ -18,27 +18,26 @@
"postinstall": "yarn run fix-fomantic-css"
},
"dependencies": {
"@vue/runtime-core": "^3.2.37",
"@vue/runtime-core": "3.2.37",
"@vueuse/core": "8.9.4",
"@vueuse/integrations": "8.9.4",
"axios": "0.27.2",
"axios-auth-refresh": "3.3.1",
"diff": "5.1.0",
"dompurify": "^2.3.8",
"dompurify": "2.3.8",
"focus-trap": "6.9.4",
"fomantic-ui-css": "2.8.8",
"howler": "2.2.3",
"js-logger": "1.6.1",
"lodash-es": "4.17.21",
"moment": "2.29.4",
"pinia": "^2.0.13",
"qs": "6.11.0",
"register-service-worker": "1.7.2",
"sanitize-html": "2.7.1",
"sass": "1.53.0",
"showdown": "2.1.0",
"text-clipper": "2.2.0",
"transliteration": "^2.3.5",
"transliteration": "2.3.5",
"vue": "3.2.37",
"vue-gettext": "2.1.12",
"vue-plyr": "7.0.0",
@ -47,34 +46,35 @@
"vue-upload-component": "3.1.2",
"vue3-gettext": "2.3.0",
"vue3-lazyload": "0.3.5",
"vue3-virtual-scroll-list": "0.2.0",
"vuedraggable": "4.1.0",
"vuex": "4.0.2",
"vuex-persistedstate": "4.1.0",
"vuex-router-sync": "5.0.0"
},
"devDependencies": {
"@types/dompurify": "^2.3.3",
"@types/howler": "^2.2.7",
"@types/dompurify": "2.3.3",
"@types/howler": "2.2.7",
"@types/jest": "28.1.6",
"@types/jquery": "3.5.14",
"@types/lodash-es": "4.17.6",
"@types/qs": "6.9.7",
"@types/semantic-ui": "^2.2.7",
"@types/showdown": "^2.0.0",
"@types/semantic-ui": "2.2.7",
"@types/showdown": "2.0.0",
"@typescript-eslint/eslint-plugin": "5.30.7",
"@vitejs/plugin-vue": "3.0.1",
"@vue/compiler-sfc": "3.2.37",
"@vue/eslint-config-standard": "7.0.0",
"@vue/eslint-config-typescript": "11.0.0",
"@vue/test-utils": "2.0.2",
"@vue/tsconfig": "^0.1.3",
"@vue/tsconfig": "0.1.3",
"chai": "4.3.6",
"easygettext": "2.17.0",
"eslint": "8.20.0",
"eslint-config-standard": "17.0.0",
"eslint-plugin-html": "7.0.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-promise": "6.0.0",
"eslint-plugin-vue": "9.2.0",

View File

@ -104,7 +104,7 @@ if (store.state.auth.authenticated) {
<template v-if="Component">
<keep-alive :max="1">
<Suspense>
<component :is="Component" v-show="!store.state.ui.queueFocused" />
<component :is="Component" />
<template #fallback>
<!-- TODO (wvffle): Add loader -->
Loading...

View File

@ -1,40 +1,27 @@
<script setup lang="ts">
import type { Track, QueueItemSource } from '~/types'
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 time from '~/utils/time'
import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import Draggable from 'vuedraggable'
import { whenever, watchDebounced } from '@vueuse/core'
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 VirtualList from '~/components/vui/list/VirtualList.vue'
import QueueItem from '~/components/QueueItem.vue'
const queueModal = ref()
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 { activate, deactivate } = useFocusTrap(queueModal, { allowOutsideClick: true, preventScroll: true })
const { $pgettext } = useGettext()
const scrollLock = useScrollLock(document.body)
const store = useStore()
const {
playing,
@ -55,16 +42,15 @@ const {
hasNext,
isEmpty: emptyQueue,
tracks,
reorder: reorderTracks,
reorder,
endsIn: timeLeft,
currentIndex,
removeTrack,
clear,
next,
previous
} = useQueue()
const reorder = (event: { oldIndex: number, newIndex: number }) => reorderTracks(event.oldIndex, event.newIndex)
const labels = computed(() => ({
queue: $pgettext('*/*/*', 'Queue'),
duration: $pgettext('*/*/*', 'Duration'),
@ -73,13 +59,42 @@ const labels = computed(() => ({
previous: $pgettext('*/*/*', 'Previous track'),
next: $pgettext('*/*/*', 'Next track'),
pause: $pgettext('*/*/*', 'Pause'),
play: $pgettext('*/*/*', 'Play'),
remove: $pgettext('*/*/*', 'Remove'),
selectTrack: $pgettext('*/*/*', 'Select track')
play: $pgettext('*/*/*', 'Play')
}))
watchDebounced(() => store.state.ui.queueFocused, scrollToCurrent, { debounce: 400 })
whenever(currentTrack, scrollToCurrent, { immediate: true })
watchEffect(async () => {
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(
() => tracks.value.length === 0,
@ -96,19 +111,49 @@ const touchProgress = (event: MouseEvent) => {
currentTime.value = time
}
const play = (index: number) => {
store.dispatch('queue/currentIndex', index)
const play = (index: unknown) => {
store.dispatch('queue/currentIndex', index as number)
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>
<template>
<section
ref="queueModal"
class="main with-background component-queue"
:aria-label="labels.queue"
>
<div id="queue-grid">
<div
id="queue-grid"
:class="store.state.ui.queueFocused && `show-${store.state.ui.queueFocused}`"
>
<div
id="player"
class="ui basic segment"
@ -296,7 +341,7 @@ const play = (index: number) => {
</div>
</template>
</div>
<div>
<div id="queue">
<div class="ui basic clearing segment">
<h2 class="ui header">
<div class="content">
@ -336,107 +381,54 @@ const play = (index: number) => {
</div>
</h2>
</div>
<div>
<table class="ui compact very basic fixed single line selectable unstackable table">
<draggable
v-model="tracks"
handle=".handle"
item-key="id"
tag="tbody"
@update="reorder"
<virtual-list
:list="queueItems"
:component="QueueItem"
:size="50"
:item-class="(index: number) => currentIndex === index ? 'active': ''"
data-key="id"
@play="play"
@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 }">
<tr
:key="track.id"
:class="['queue-item', {'active': index === currentIndex}]"
>
<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>
<translate translate-context="*/Player/Button.Label/Short, Verb">
Stop radio
</translate>
</button>
</div>
</div>
</div>

View File

@ -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>

View File

@ -191,7 +191,7 @@ const touchProgress = (event: MouseEvent) => {
</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">
<img
v-if="currentTrack.cover && currentTrack.cover.urls.original"
@ -370,7 +370,7 @@ const touchProgress = (event: MouseEvent) => {
</translate>
</button>
<button
class="position circular control button tablet-and-below"
class="position circular control button desktop-and-below"
@click.stop="switchTab"
>
<i class="stream icon" />
@ -398,21 +398,21 @@ const touchProgress = (event: MouseEvent) => {
</button>
<button
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"
>
<i class="large up angle icon" />
</button>
<button
v-if="$store.state.ui.queueFocused === 'queue'"
class="circular control button tablet-and-below"
class="circular control button desktop-and-below"
@click.stop="switchTab"
>
<i class="large down angle icon" />
</button>
</div>
<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)"
>
<i class="x icon" />

View File

@ -21,7 +21,9 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['approved', 'deny', 'update:show', 'show', 'hide'])
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)

View File

@ -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>

View File

@ -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>

View File

@ -3,7 +3,7 @@ import type { ContentFilter } from '~/store/moderation'
import { useStore } from '~/store'
import { useGettext } from 'vue3-gettext'
import { computed, ref } from 'vue'
import { computed, markRaw, ref } from 'vue'
import axios from 'axios'
import usePlayer from '~/composables/audio/usePlayer'
import useQueue from '~/composables/audio/useQueue'
@ -129,7 +129,7 @@ export default (props: PlayOptionsProps) => {
// TODO (wvffle): It was behind 250ms timeout, why?
isLoading.value = false
return tracks.filter(track => track.uploads?.length)
return tracks.filter(track => track.uploads?.length).map(markRaw)
}
const el = useCurrentElement()
@ -137,7 +137,8 @@ export default (props: PlayOptionsProps) => {
jQuery(el.value).find('.ui.dropdown').dropdown('hide')
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) => {

View File

@ -100,7 +100,7 @@ const currentTime = computed({
return
}
if (!currentSound.value.getSource() || time === currentSound.value.seek()) {
if (!currentSound.value?.getSource() || time === currentSound.value.seek()) {
return
}

View File

@ -28,20 +28,13 @@ const focused = computed(() => store.state.ui.queueFocused === 'queue')
//
// Track list
//
const tracksChangeBuffer = ref<Track[] | null>(null)
const tracks = computed<Track[]>({
get: () => store.state.queue.tracks,
set: (value) => (tracksChangeBuffer.value = value)
})
const tracks = computed<Track[]>(() => store.state.queue.tracks)
const reorder = (oldIndex: number, newIndex: number) => {
store.commit('queue/reorder', {
tracks: tracksChangeBuffer.value ?? tracks.value,
oldIndex,
newIndex
})
tracksChangeBuffer.value = null
}
//

View File

@ -38,21 +38,23 @@ const store: Module<State, RootState> = {
tracks (state, value) {
state.tracks = value
},
insert (state, { track, index }) {
state.tracks.splice(index, 0, track)
},
reorder (state, { tracks, oldIndex, newIndex }) {
reorder (state, { oldIndex, newIndex }) {
// called when the user uses drag / drop to reorder
// tracks in queue
state.tracks = tracks
const [track] = state.tracks.splice(oldIndex, 1)
state.tracks.splice(newIndex, 0, track)
if (oldIndex === state.currentIndex) {
state.currentIndex = newIndex
return
}
if (oldIndex < state.currentIndex && newIndex >= state.currentIndex) {
// item before was moved after
state.currentIndex -= 1
}
if (oldIndex > state.currentIndex && newIndex <= state.currentIndex) {
// item after was moved before
state.currentIndex += 1
@ -72,41 +74,32 @@ const store: Module<State, RootState> = {
isEmpty: state => state.tracks.length === 0
},
actions: {
append ({ commit, state }, { track, index }) {
index = index || state.tracks.length
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 })
}
append ({ dispatch, state }, { track, index = state.tracks.length }) {
return dispatch('appendMany', { tracks: [track], index })
},
appendMany ({ state, dispatch }, { tracks, index, callback }) {
logger.info('Appending many tracks to the queue', tracks.map((track: Track) => track.title))
let shouldPlay = false
appendMany ({ state, dispatch }, { tracks, index = state.tracks.length }) {
logger.info(
'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
shouldPlay = true
} else {
index = index ?? state.tracks.length
}
const total = tracks.length
tracks.forEach((track: Track, i: number) => {
const promise = dispatch('append', { track, index })
index += 1
if (index >= state.tracks.length) {
// we simply push to the end
state.tracks.push(...tracks)
} else {
// we insert the track at given position
state.tracks.splice(index, 0, ...tracks)
}
if (callback && i + 1 === total) {
promise.then(callback)
}
if (shouldPlay && promise && i + 1 === total) {
promise.then(() => dispatch('next'))
}
})
if (shouldPlay) {
return dispatch('next')
}
},
cleanTrack ({ state, dispatch, commit }, index) {

View File

@ -235,8 +235,23 @@
display: grid;
grid-template-columns: 37.5% 62.5%;
#queue {
position: relative;
}
@include media("<desktop") {
grid-template-columns: 1fr 0;
&.show-player {
#queue {
display: none;
}
}
&.show-queue {
#player {
display: none;
}
}
}
#player {
@ -246,6 +261,11 @@
justify-content: center;
text-align: center;
.ui.header {
width: 100%;
max-width: 90%;
}
.cover-container {
width: 50vh;
max-width: 90%;
@ -282,10 +302,43 @@
grid-template-rows: auto 1fr;
> :nth-child(2) {
overflow-y: hidden;
}
.virtual-list {
height: 100%;
overflow-y: auto;
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;
}

View File

@ -18,6 +18,19 @@ export interface InitModuleContext {
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
export type Theme = 'auto' | 'light' | 'dark'

View File

@ -21,8 +21,8 @@ export default {
? `${hours}:${pad(min)}:${pad(sec)}`
: `${min}:${pad(sec)}`
},
durationFormatted (v: string) {
const duration = parseInt(v)
durationFormatted (v: string | number) {
const duration = typeof v === 'number' ? v : parseInt(v)
return this.parse(duration % 1 !== 0 ? 0 : Math.round(duration))
}
}

View File

@ -1363,10 +1363,10 @@
dependencies:
"@babel/types" "^7.3.0"
"@types/dompurify@^2.3.3":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.4.tgz#94e997e30338ea24d4c8d08beca91ce4dd17a1b4"
integrity sha512-EXzDatIb5EspL2eb/xPGmaC8pePcTHrkDCONjeisusLFrVfl38Pjea/R0YJGu3k9ZQadSvMqW0WXPI2hEo2Ajg==
"@types/dompurify@2.3.3":
version "2.3.3"
resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.3.3.tgz#c24c92f698f77ed9cc9d9fa7888f90cf2bfaa23f"
integrity sha512-nnVQSgRVuZ/843oAfhA25eRSNzUFcBPk/LOiw5gm8mD9/X7CNcbRkQu/OsjCewO8+VIYfPxUnXvPEVGenw14+w==
dependencies:
"@types/trusted-types" "*"
@ -1390,7 +1390,7 @@
dependencies:
"@types/node" "*"
"@types/howler@^2.2.7":
"@types/howler@2.2.7":
version "2.2.7"
resolved "https://registry.yarnpkg.com/@types/howler/-/howler-2.2.7.tgz#5acfbed57f9e1d99b8dabe1b824729e1c1ea1fae"
integrity sha512-PEZldwZqJJw1PWRTpupyC7ajVTZA8aHd8nB/Y0n6zRZi5u8ktYDntsHj13ltEiBRqWwF06pASxBEvCTxniG8eA==
@ -1632,7 +1632,7 @@
dependencies:
"@types/jquery" "*"
"@types/semantic-ui@^2.2.7":
"@types/semantic-ui@2.2.7":
version "2.2.7"
resolved "https://registry.yarnpkg.com/@types/semantic-ui/-/semantic-ui-2.2.7.tgz#4ae4242004aac11a21133d4f338e868b92605270"
integrity sha512-Uj6rby2GnuVyO7pj8vgUFsv5eaxb0ktpfasYcB/vXnSAeJ4cRjIOvxka+EoPjw3tPCY4/WlxRss8hsh7kRWzQg==
@ -1659,7 +1659,7 @@
"@types/semantic-ui-transition" "*"
"@types/semantic-ui-visibility" "*"
"@types/showdown@^2.0.0":
"@types/showdown@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.0.tgz#3e800eca8573848cac4e5555f4377ba3a0e7b1f2"
integrity sha512-70xBJoLv+oXjB5PhtA8vo7erjLDp9/qqI63SRHm4REKrwuPOLs8HhXwlZJBJaB4kC18cCZ1UUZ6Fb/PLFW4TCA==
@ -1989,7 +1989,7 @@
"@vue/compiler-dom" "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"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.2.1.tgz#6f2948ff002ec46df01420dfeff91de16c5b4092"
integrity sha512-OEgAMeQXvCoJ+1x8WyQuVZzFo0wcyCmUR3baRVLmKBo1LmYZWMlRiXlux5jd0fqVJu6PfDbOrZItVqUEzLobeQ==
@ -2041,7 +2041,7 @@
dependencies:
"@vue/shared" "3.2.37"
"@vue/reactivity@3.2.38", "@vue/reactivity@^3.2.37":
"@vue/reactivity@^3.2.37":
version "3.2.38"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.38.tgz#d576fdcea98eefb96a1f1ad456e289263e87292e"
integrity sha512-6L4myYcH9HG2M25co7/BSo0skKFHpAN8PhkNPM4xRVkyGl1K5M3Jx4rp5bsYhvYze2K4+l+pioN4e6ZwFLUVtw==
@ -2056,14 +2056,6 @@
"@vue/reactivity" "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":
version "3.2.37"
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"
integrity sha512-E2P4oXSaWDqTZNbmKZFVLrNN/siVN78YkEqs7pHryWerrlZR9bBFLWdJwRoguX45Ru6HxIflzKl4vQvwRMwm5g==
"@vue/tsconfig@^0.1.3":
"@vue/tsconfig@0.1.3":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.1.3.tgz#4a61dbd29783d01ddab504276dcf0c2b6988654f"
integrity sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==
@ -3113,10 +3105,10 @@ domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3:
dependencies:
domelementtype "^2.3.0"
dompurify@^2.3.8:
version "2.4.0"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.0.tgz#c9c88390f024c2823332615c9e20a453cf3825dd"
integrity sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==
dompurify@2.3.8:
version "2.3.8"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.3.8.tgz#224fe9ae57d7ebd9a1ae1ac18c1c1ca3f532226f"
integrity sha512-eVhaWoVibIzqdGYjwsBWodIQIaXFSB+cKDf4cfxLMsK0xiud6SE+/WCVx/Xw/UwQsa4cS3T2eITcdtmTg2UKcw==
domutils@^2.5.2:
version "2.8.0"
@ -3484,18 +3476,18 @@ eslint-plugin-import@2.26.0:
resolve "^1.22.0"
tsconfig-paths "^3.14.1"
eslint-plugin-n@^15.2.4:
version "15.2.5"
resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.2.5.tgz#aa7ff8d45bb8bf2df8ea3b7d3774ae570cb794b8"
integrity sha512-8+BYsqiyZfpu6NXmdLOXVUfk8IocpCjpd8nMRRH0A9ulrcemhb2VI9RSJMEy5udx++A/YcVPD11zT8hpFq368g==
eslint-plugin-n@15.2.4:
version "15.2.4"
resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-15.2.4.tgz#d62021a0821ae650701ed459756aaf478a9b6056"
integrity sha512-tjnVMv2fiXYMnuiIFI8QMtyUFI42SckEEWvi8h68SWGWshfqO6SSCASy24dGMGAiy7NUk6DZt90DM0iNUsmQ5w==
dependencies:
builtins "^5.0.1"
eslint-plugin-es "^4.1.0"
eslint-utils "^3.0.0"
ignore "^5.1.1"
is-core-module "^2.10.0"
is-core-module "^2.9.0"
minimatch "^3.1.2"
resolve "^1.22.1"
resolve "^1.10.1"
semver "^7.3.7"
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"
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"
resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed"
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"
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:
version "3.0.2"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-3.0.2.tgz#7e6f85413fd9161ab4e12b539b06010d85954bb9"
@ -6222,7 +6206,7 @@ tr46@^1.0.1:
dependencies:
punycode "^2.1.0"
transliteration@^2.3.5:
transliteration@2.3.5:
version "2.3.5"
resolved "https://registry.yarnpkg.com/transliteration/-/transliteration-2.3.5.tgz#8f92309575f69e4a8a525dab4ff705ebcf961c45"
integrity sha512-HAGI4Lq4Q9dZ3Utu2phaWgtm3vB6PkLUFqWAScg/UW+1eZ/Tg6Exo4oC0/3VUol/w4BlefLhUUSVBr/9/ZGQOw==
@ -6549,6 +6533,11 @@ vue3-lazyload@0.3.5:
dependencies:
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:
version "3.2.37"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e"

View File

@ -1,4 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1