refactor: reimplement SortableJS for queue

This commit is contained in:
Fernando Fernández 2023-03-21 11:46:35 +00:00
parent db84249c5e
commit ca0781c6d5
5 changed files with 91 additions and 148 deletions

View File

@ -40,6 +40,7 @@
"hls.js": "1.3.1",
"jassub": "1.5.1",
"lodash-es": "4.17.21",
"sortablejs": "1.15.0",
"swiper": "9.0.3",
"uuid": "9.0.0",
"vue": "3.2.39",
@ -52,6 +53,7 @@
"@intlify/unplugin-vue-i18n": "0.8.1",
"@types/dompurify": "2.3.3",
"@types/lodash-es": "4.17.6",
"@types/sortablejs": "1.15.1",
"@types/uuid": "9.0.0",
"@typescript-eslint/eslint-plugin": "5.42.0",
"@typescript-eslint/parser": "5.42.0",

View File

@ -4,6 +4,7 @@
:close-on-content-click="false"
:persistent="!closeOnClick"
:transition="'slide-y-transition'"
:width="listWidth"
location="top">
<template #activator="{ props: menu }">
<tooltip-button
@ -27,7 +28,6 @@
<v-icon v-else :icon="modeIcon" />
</v-avatar>
</template>
<template #subtitle>
{{ getTotalEndsAtTime(playbackManager.queue).value }} -
{{
@ -36,22 +36,11 @@
})
}}
</template>
<template #append>
<like-button
v-if="playbackManager.initiator && playbackManager.currentItem"
:item="playbackManager.currentItem" />
<item-menu
v-if="playbackManager.initiator && playbackManager.currentItem"
:item="playbackManager.currentItem" />
</template>
</v-list-item>
</v-list>
<v-divider />
<v-list class="overflow">
<!-- We set an special property to destroy the element so it doesn't take resources while it's not being used.
This is especially useful for really huge queues. -->
<draggable-queue v-if="menuModel || !destroy" class="ml-4" />
<v-list class="queue-area">
<draggable-queue />
</v-list>
<v-spacer />
<v-card-actions>
@ -70,37 +59,34 @@
<i-mdi-content-save />
</v-icon>
</tooltip-button>
<v-spacer />
</v-card-actions>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTimeoutFn } from '@vueuse/core';
import IMdiShuffle from 'virtual:icons/mdi/shuffle';
import IMdiPlaylistMusic from 'virtual:icons/mdi/playlist-music';
import { playbackManagerStore } from '@/store';
import { InitMode } from '@/store/playbackManager';
import { getTotalEndsAtTime } from '@/utils/time';
withDefaults(
const props = withDefaults(
defineProps<{
closeOnClick?: boolean;
size?: number;
}>(),
{ closeOnClick: false }
{ closeOnClick: false, size: 40 }
);
const playbackManager = playbackManagerStore();
const { t } = useI18n();
const menuModel = ref(false);
const destroy = ref(false);
const timeout = useTimeoutFn(() => {
destroy.value = true;
}, 500);
const listWidth = computed(() => `${props.size}vw`);
// const listHeight = computed(() => `${props.size}vh`);
const sourceText = computed(() => {
/**
@ -143,21 +129,16 @@ const modeIcon = computed(() =>
? IMdiShuffle
: IMdiPlaylistMusic
);
watch(menuModel, (val) => {
if (!val) {
timeout.start();
} else {
timeout.stop();
destroy.value = false;
}
});
</script>
<style lang="scss" scoped>
.overflow {
overflow-y: scroll;
overflow-x: hidden;
/**
For some reason, v-bind doesn't work with this, so we must manually update this
if we ever want to change the size
TODO: Investigate why
*/
.queue-area {
min-height: 40vh;
max-height: 40vh;
}

View File

@ -8,8 +8,8 @@
<virtual-grid
v-else-if="!loading && items.length > 0 && !noVirtual"
:items="items"
:buffer-multiplier="4"
:throttle-scroll="300"
:buffer-multiplier="2"
:throttle-scroll="175"
:class="useResponsiveClasses('card-grid-container')">
<template #default="{ item, style }">
<card :style="style" :item="item" margin text overlay link />

View File

@ -1,108 +1,78 @@
<template>
<v-list-group class="list-group">
<draggable
v-model="queue"
v-bind="dragOptions"
class="list-draggable user-select-none">
<template v-for="(item, index) in queue" :key="`${item.Id}-${index}`">
<v-hover v-slot="{ isHovering, props: hoverProps }" ref="listItems">
<v-list-item v-bind="hoverProps" @click="onClick(index)">
<v-list-item-action
v-if="!isHovering"
class="list-group-item d-flex justify-center d-flex transition"
:class="{ 'text-primary font-weight-bold': isPlaying(index) }">
{{ index + 1 }}
</v-list-item-action>
<v-list-item-action v-else class="justify-center d-flex">
<span id="draggable-queue">
<template v-for="(item, index) of playbackManager.queue" :key="item.Id">
<v-hover v-slot="{ isHovering, props: hoverProps }">
<v-list-item
v-bind="hoverProps"
:title="item.Name ?? ''"
:subtitle="getArtists(item)"
class="grab-cursor"
:class="{ 'text-primary font-weight-bold': isPlaying(index) }"
@click="playbackManager.currentItemIndex = index">
<template #prepend>
<v-list-item-action :key="index" start>
<v-icon>
<i-mdi-drag-horizontal />
<template v-if="!isHovering">
{{ index + 1 }}
</template>
<i-mdi-drag-horizontal v-else />
</v-icon>
</v-list-item-action>
<v-avatar class="list-group-item">
<v-avatar>
<blurhash-image :item="item" />
</v-avatar>
<v-list-item-title
:class="{
'text-primary font-weight-bold': isPlaying(index)
}"
class="text-truncate ml-2 list-group-item transition">
{{ item.Name }}
</v-list-item-title>
<v-list-item-subtitle
v-if="getArtists(item)"
class="ml-2 list-group-item transition"
:class="{
'text-primary font-weight-bold': isPlaying(index)
}">
{{ getArtists(item) }}
</v-list-item-subtitle>
<v-list-item-action v-hide="isPlaying(index)">
<like-button :item="item" />
</v-list-item-action>
<v-list-item-action class="mr-2">
<item-menu :item="item" queue />
</v-list-item-action>
</v-list-item>
</v-hover>
</template>
</draggable>
</v-list-group>
</template>
<template #append>
<like-button v-hide="isPlaying(index)" :item="item" />
<item-menu v-hide="isPlaying(index)" :item="item" queue />
</template>
</v-list-item>
</v-hover>
</template>
</span>
</template>
<script setup lang="ts">
import Sortable from 'sortablejs';
import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client';
import { computed, onMounted, ref } from 'vue';
import { onMounted, onBeforeUnmount } from 'vue';
import { VHover } from 'vuetify/lib/components/VHover/index';
import { playbackManagerStore } from '@/store';
const dragOptions = {
animation: 500,
delay: 0,
group: false,
dragoverBubble: true,
ghostClass: 'ghost'
};
const listItems = ref<InstanceType<typeof VHover>[] | undefined>(undefined);
let sortable: Sortable | undefined;
const playbackManager = playbackManagerStore();
const queue = computed({
get() {
return playbackManager.queue;
},
set(newValue: BaseItemDto[]) {
playbackManager.setNewQueue(
newValue.map((item) => item.Id).filter((id): id is string => !!id)
onMounted(() => {
const target = document.querySelector('#draggable-queue');
if (!target || !(target instanceof HTMLElement)) {
throw new Error(
'The expected DOM tree for the sortable queue has been changed'
);
}
sortable = new Sortable(target, {
animation: 500,
delay: 0,
dragoverBubble: true,
onUpdate(e): void {
const oldIndex = e.oldIndex;
if (typeof oldIndex === 'number') {
const item = playbackManager.queue[oldIndex];
if (item && item.Id && typeof e.newIndex === 'number') {
playbackManager.changeItemPosition(item.Id, e.newIndex);
}
}
}
});
});
onMounted(() => {
const reference = listItems.value;
const currentItemId = playbackManager.currentItem?.Id || '';
if (reference) {
const element = reference.find(
(v) => v.$.vnode.key === `${currentItemId}-${reference.indexOf(v)}`
);
if (element && element.$el) {
/**
* As the queue opening has a transition effect, el.$el.scrollIntoView() doesn't work directly,
* as the parent DOM node is not fully rendered while the transition is taking place
* (so scrollIntoView() doesn't know exactly what to scroll).
*
* The browser always give full priority to the DOM manipulation and painting process,
* while the setTimeout runs with lower priority. This assures us that the view will be scrolled to
* the currently playing element as soon as all the DOM operations and transitions are over.
*/
window.setTimeout(() => {
element.$el.scrollIntoView();
});
}
onBeforeUnmount(() => {
if (sortable) {
sortable.destroy();
sortable = undefined;
}
});
@ -114,38 +84,15 @@ function isPlaying(index: number): boolean {
}
/**
* Gets the artists of the current item
* Gets the artists of the item
*/
function getArtists(item: BaseItemDto): string | undefined {
return item.Artists ? item.Artists.join(', ') : undefined;
}
/**
* Click handler for list items
*/
function onClick(index: number): void {
playbackManager.currentItemIndex = index;
}
</script>
<style lang="scss" scoped>
.transition {
transition: all 0.15s ease-in;
}
.list-group {
margin: 0 !important;
}
.ghost {
opacity: 0;
}
.list-draggable {
min-height: 20px;
}
.list-group-item {
.grab-cursor {
cursor: grab;
}
</style>

13
package-lock.json generated
View File

@ -35,6 +35,7 @@
"hls.js": "1.3.1",
"jassub": "1.5.1",
"lodash-es": "4.17.21",
"sortablejs": "1.15.0",
"swiper": "9.0.3",
"uuid": "9.0.0",
"vue": "3.2.39",
@ -47,6 +48,7 @@
"@intlify/unplugin-vue-i18n": "0.8.1",
"@types/dompurify": "2.3.3",
"@types/lodash-es": "4.17.6",
"@types/sortablejs": "1.15.1",
"@types/uuid": "9.0.0",
"@typescript-eslint/eslint-plugin": "5.42.0",
"@typescript-eslint/parser": "5.42.0",
@ -3569,6 +3571,12 @@
"integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==",
"dev": true
},
"node_modules/@types/sortablejs": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.1.tgz",
"integrity": "sha512-g/JwBNToh6oCTAwNS8UGVmjO7NLDKsejVhvE4x1eWiPTC3uCuNsa/TD4ssvX3du+MLiM+SHPNDuijp8y76JzLQ==",
"dev": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
@ -13809,6 +13817,11 @@
"node": ">=0.10.0"
}
},
"node_modules/sortablejs": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz",
"integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w=="
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",