mirror of
https://github.com/jellyfin/jellyfin-vue.git
synced 2024-10-07 03:23:37 +00:00
refactor: reimplement SortableJS for queue
This commit is contained in:
parent
db84249c5e
commit
ca0781c6d5
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 />
|
||||
|
@ -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
13
package-lock.json
generated
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user