koel/resources/assets/js/components/song/SongList.vue

502 lines
15 KiB
Vue
Raw Normal View History

2022-04-15 14:24:30 +00:00
<template>
<div
ref="wrapper"
2024-04-04 22:20:42 +00:00
class="song-list-wrap relative flex flex-col flex-1 overflow-auto py-0 px-3 md:p-0"
data-testid="song-list"
2022-04-15 14:24:30 +00:00
tabindex="0"
@keydown.delete.prevent.stop="handleDelete"
@keydown.enter.prevent.stop="handleEnter"
@keydown.a.prevent="handleA"
>
2024-04-04 22:20:42 +00:00
<div
:class="config.sortable ? 'sortable' : 'unsortable'"
class="song-list-header flex z-[2] bg-k-bg-secondary"
>
<span
class="track-number"
data-testid="header-track-number"
2022-07-21 07:54:36 +00:00
role="button"
title="Sort by track number"
2022-06-10 10:47:46 +00:00
@click="sort('track')"
>
2022-04-24 18:44:48 +00:00
#
<template v-if="config.sortable">
2024-04-04 22:20:42 +00:00
<Icon v-if="sortField === 'track' && sortOrder === 'asc'" :icon="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
</template>
2022-04-24 18:44:48 +00:00
</span>
<span
class="title-artist"
data-testid="header-title"
2022-07-21 07:54:36 +00:00
role="button"
title="Sort by title"
2022-06-10 10:47:46 +00:00
@click="sort('title')"
>
2022-04-24 18:44:48 +00:00
Title
<template v-if="config.sortable">
2024-04-04 22:20:42 +00:00
<Icon v-if="sortField === 'title' && sortOrder === 'asc'" :icon="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
</template>
2022-04-24 18:44:48 +00:00
</span>
2022-04-15 17:00:08 +00:00
<span
class="album"
data-testid="header-album"
2022-07-21 07:54:36 +00:00
role="button"
2024-05-19 05:49:42 +00:00
:title="`Sort by ${contentType === 'episodes' ? 'podcast' : (contentType === 'songs' ? 'album' : 'album/podcast')}`"
@click="sort(contentType === 'episodes' ? 'podcast_title' : (contentType === 'songs' ? 'album_name' : ['album_name', 'podcast_title']))"
2022-04-15 17:00:08 +00:00
>
2024-05-19 05:49:42 +00:00
<template v-if="contentType === 'episodes'">Podcast</template>
<template v-else-if="contentType === 'songs'">Album</template>
<template v-else>Album <span class="opacity-50">/</span> Podcast</template>
<span v-if="config.sortable" class="ml-2">
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'asc'" :icon="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortingByAlbumOrPodcast && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
</span>
2022-04-24 18:44:48 +00:00
</span>
2024-01-18 11:13:05 +00:00
<template v-if="config.collaborative">
<span class="collaborator">User</span>
<span class="added-at">Added</span>
</template>
<span
class="time"
data-testid="header-length"
2022-07-21 07:54:36 +00:00
role="button"
2024-05-19 05:49:42 +00:00
title="Sort by duration"
2022-06-10 10:47:46 +00:00
@click="sort('length')"
>
2022-10-25 18:25:58 +00:00
Time
<template v-if="config.sortable">
2024-04-04 22:20:42 +00:00
<Icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretDown" class="text-k-highlight" />
<Icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretUp" class="text-k-highlight" />
</template>
2022-04-24 18:44:48 +00:00
</span>
<span class="extra">
<SongListSorter
v-if="config.sortable"
:field="sortField"
2024-06-03 11:42:21 +00:00
:has-custom-order-sort="config.hasCustomOrderSort"
:order="sortOrder"
2024-05-19 05:49:42 +00:00
:content-type="contentType"
@sort="sort"
/>
</span>
2022-04-15 17:00:08 +00:00
</div>
2022-07-16 09:52:39 +00:00
<VirtualScroller
2024-05-19 05:49:42 +00:00
v-slot="{ item }: { item: PlayableRow }"
:item-height="64"
2024-05-19 05:49:42 +00:00
:items="filteredRows"
2022-07-16 09:52:39 +00:00
@scroll="onScroll"
@scrolled-to-end="$emit('scrolled-to-end')"
>
<SongListItem
2024-05-19 05:49:42 +00:00
:key="item.playable.id"
:item="item"
draggable="true"
2024-01-18 11:13:05 +00:00
@click="onClick(item, $event)"
2022-10-10 07:00:02 +00:00
@dragleave="onDragLeave"
@dragstart="onDragStart(item, $event)"
2024-05-19 05:49:42 +00:00
@play="onPlay(item.playable)"
@dragover.prevent="onDragOver"
2022-10-10 07:00:02 +00:00
@drop.prevent="onDrop(item, $event)"
@dragend.prevent="onDragEnd"
2022-04-30 11:55:54 +00:00
@contextmenu.prevent="openContextMenu(item, $event)"
/>
2022-04-27 20:52:37 +00:00
</VirtualScroller>
2022-04-15 14:24:30 +00:00
</div>
</template>
2022-04-15 17:00:08 +00:00
<script lang="ts" setup>
2024-05-19 05:49:42 +00:00
import Fuse from 'fuse.js'
import { findIndex, findLastIndex, throttle } from 'lodash'
2022-07-21 07:54:36 +00:00
import isMobile from 'ismobilejs'
2022-10-25 18:25:58 +00:00
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
import { computed, nextTick, onMounted, Ref, ref, watch } from 'vue'
2024-05-19 05:49:42 +00:00
import { arrayify, eventBus, getPlayableCollectionContentType, requireInjection } from '@/utils'
import { preferenceStore as preferences, queueStore } from '@/stores'
2022-10-10 07:00:02 +00:00
import { useDraggable, useDroppable } from '@/composables'
2024-03-25 22:59:38 +00:00
import { playbackService } from '@/services'
import {
2024-05-19 05:49:42 +00:00
PlayableListConfigKey,
PlayableListContextKey,
PlayableListSortFieldKey,
2024-06-03 07:16:29 +00:00
PlayablesKey,
SelectedPlayablesKey,
SongListFilterKeywordsKey,
2024-05-19 05:49:42 +00:00
SongListSortOrderKey
} from '@/symbols'
2022-04-15 14:24:30 +00:00
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
import SongListItem from '@/components/song/SongListItem.vue'
import SongListSorter from '@/components/song/SongListSorter.vue'
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
const { startDragging } = useDraggable('playables')
const { getDroppedData, acceptsDrop } = useDroppable(['playables'])
const emit = defineEmits<{
(e: 'press:enter', event: KeyboardEvent): void,
(e: 'press:delete'): void,
2024-05-19 05:49:42 +00:00
(e: 'reorder', song: Playable, type: MoveType): void,
(e: 'sort', field: MaybeArray<PlayableListSortField>, order: SortOrder): void,
(e: 'scroll-breakpoint', direction: 'up' | 'down'): void,
(e: 'scrolled-to-end'): void,
}>()
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
const [items] = requireInjection<[Ref<Playable[]>]>(PlayablesKey)
const [selectedPlayables, setSelectedPlayables] = requireInjection<[Ref<Playable[]>, Closure]>(SelectedPlayablesKey)
const [sortField, setSortField] = requireInjection<[Ref<MaybeArray<PlayableListSortField>>, Closure]>(PlayableListSortFieldKey)
2022-10-10 07:00:02 +00:00
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
2024-05-19 05:49:42 +00:00
const [config] = requireInjection<[Partial<PlayableListConfig>]>(PlayableListConfigKey, [{}])
const [context] = requireInjection<[PlayableListContext]>(PlayableListContextKey)
const filterKeywords = requireInjection(SongListFilterKeywordsKey, ref(''))
2024-05-19 05:49:42 +00:00
let fuse: Fuse<PlayableRow> | null = null
const wrapper = ref<HTMLElement>()
2024-05-19 05:49:42 +00:00
const lastSelectedRow = ref<PlayableRow>()
const sortFields = ref<PlayableListSortField[]>([])
const rows = ref<PlayableRow[]>([])
2022-04-15 14:24:30 +00:00
2024-03-25 22:59:38 +00:00
const shouldTriggerContinuousPlayback = computed(() => {
2024-05-19 05:49:42 +00:00
return preferences.continuous_playback
2024-03-25 22:59:38 +00:00
&& typeof context.type !== 'undefined'
2024-05-19 05:49:42 +00:00
&& ['Playlist', 'Album', 'Artist', 'Genre', 'Favorites'].includes(context.type)
})
const contentType = computed(() => getPlayableCollectionContentType(rows.value.map(({ playable }) => playable)))
const sortingByAlbumOrPodcast = computed(() => {
const sortFields = arrayify(sortField.value)
return sortFields[0] === 'album_name' || sortFields[0] === 'podcast_title'
2024-03-25 22:59:38 +00:00
})
2024-01-24 22:39:47 +00:00
watch(
2024-05-19 05:49:42 +00:00
rows,
() => setSelectedPlayables(rows.value.filter(({ selected }) => selected).map(({ playable }) => playable)),
2024-01-24 22:39:47 +00:00
{ deep: true }
)
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
watch(rows, () => {
fuse = new Fuse(rows.value, {
keys: [
'playable.title',
'playable.artist_name',
'playable.album_name',
'playable.podcast_title',
'playable.podcast_author',
'playable.episode_description'
]
})
}, { immediate: true })
const filteredRows = computed<PlayableRow[]>(() => {
const keywords = filterKeywords.value.trim()
if (!keywords) {
2024-05-19 05:49:42 +00:00
return rows.value
}
2024-05-19 05:49:42 +00:00
return fuse?.search(keywords).map(result => result.item) || []
})
2022-07-16 09:52:39 +00:00
let lastScrollTop = 0
const onScroll = (e: Event) => {
2022-07-16 09:52:39 +00:00
const scroller = e.target as HTMLElement
if (scroller.scrollTop > 512 && lastScrollTop < 512) {
emit('scroll-breakpoint', 'down')
} else if (scroller.scrollTop < 512 && lastScrollTop > 512) {
emit('scroll-breakpoint', 'up')
}
lastScrollTop = scroller.scrollTop
}
2022-04-15 17:00:08 +00:00
/**
2024-05-19 05:49:42 +00:00
* Since playable objects themselves are shared by all lists, we can't use them directly to
* determine their selection status (selected/unselected). Therefore, for each list, we
* maintain an array of "playable rows," each containing the playable itself and the "selected" flag.
2022-04-15 17:00:08 +00:00
*/
2024-05-19 05:49:42 +00:00
const generateRows = () => {
// Since this method re-generates the playable wrappers, we need to keep track of the
// selected playable manually.
const selectedIds = selectedPlayables.value.map(playable => playable.id)
return items.value.map<PlayableRow>(playable => ({
playable,
selected: selectedIds.includes(playable.id)
2022-04-15 17:00:08 +00:00
}))
}
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
const sort = (field: MaybeArray<PlayableListSortField>) => {
2022-04-15 17:00:08 +00:00
// there are certain circumstances where sorting is simply disallowed, e.g. in Queue
if (!config.sortable) {
2022-04-15 17:00:08 +00:00
return
}
2022-04-15 14:24:30 +00:00
2022-07-20 08:00:02 +00:00
setSortField(field)
setSortOrder(sortOrder.value === 'asc' ? 'desc' : 'asc')
2022-04-15 14:24:30 +00:00
2022-06-10 10:47:46 +00:00
emit('sort', field, sortOrder.value)
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
const render = () => {
config.sortable || (sortFields.value = [])
2024-05-19 05:49:42 +00:00
rows.value = generateRows()
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2022-04-28 16:04:52 +00:00
watch(items, () => render(), { deep: true })
2022-04-15 14:24:30 +00:00
const handleDelete = () => {
emit('press:delete')
2022-04-15 17:00:08 +00:00
clearSelection()
}
2022-04-15 14:24:30 +00:00
const handleEnter = (event: KeyboardEvent) => {
emit('press:enter', event)
clearSelection()
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
/**
* Select all (filtered) rows in the current list.
*/
2024-05-19 05:49:42 +00:00
const selectAllRows = () => rows.value.forEach(row => (row.selected = true))
const clearSelection = () => rows.value.forEach(row => (row.selected = false))
2022-04-15 17:00:08 +00:00
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
2024-05-19 05:49:42 +00:00
const getAllPlayablesWithSort = () => rows.value.map(row => row.playable)
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
const onClick = (row: PlayableRow, event: MouseEvent) => {
2022-04-15 17:00:08 +00:00
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
if (isMobile.any) {
toggleRow(row)
2022-04-15 17:00:08 +00:00
return
}
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
if (event.ctrlKey || event.metaKey) {
toggleRow(row)
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
if (event.button === 0) {
if (!(event.ctrlKey || event.metaKey || event.shiftKey)) {
clearSelection()
toggleRow(row)
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
if (event.shiftKey && lastSelectedRow.value) {
selectRowsBetween(lastSelectedRow.value, row)
2022-04-15 17:00:08 +00:00
}
}
}
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
const toggleRow = (row: PlayableRow) => {
row.selected = !row.selected
lastSelectedRow.value = row
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
const selectRowsBetween = (first: PlayableRow, second: PlayableRow) => {
const firstIndex = Math.max(0, findIndex(rows.value, row => row.playable.id === first.playable.id))
const secondIndex = Math.max(0, findIndex(rows.value, row => row.playable.id === second.playable.id))
const indexes = [firstIndex, secondIndex]
2022-04-15 17:00:08 +00:00
indexes.sort((a, b) => a - b)
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
for (let i = indexes[0]; i <= indexes[1]; ++i) {
2024-05-19 05:49:42 +00:00
rows.value[i].selected = true
2022-04-15 17:00:08 +00:00
}
}
2022-04-15 14:24:30 +00:00
2024-05-19 05:49:42 +00:00
const onDragStart = async (row: PlayableRow, event: DragEvent) => {
2022-04-15 17:00:08 +00:00
// If the user is dragging an unselected row, clear the current selection.
if (!row.selected) {
2022-04-15 17:00:08 +00:00
clearSelection()
row.selected = true
2024-01-18 11:13:05 +00:00
await nextTick()
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
// Add "dragging" class to the wrapper so that we can disable pointer events on child elements.
// This prevents dragleave events from firing when the user drags the mouse over the child elements.
2022-12-02 16:17:37 +00:00
wrapper.value?.classList.add('dragging')
2024-05-19 05:49:42 +00:00
startDragging(event, selectedPlayables.value)
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
const onDragOver = throttle((event: DragEvent) => {
if (!config.reorderable) return
if (acceptsDrop(event)) {
const target = event.target as HTMLElement
const rect = target.getBoundingClientRect()
const midPoint = rect.top + rect.height / 2
target.classList.remove('dragover-top', 'dragover-bottom')
target.classList.add('droppable', event.clientY < midPoint ? 'dragover-top' : 'dragover-bottom')
}
return false
}, 50)
2024-05-19 05:49:42 +00:00
const onDrop = (row: PlayableRow, event: DragEvent) => {
if (!config.reorderable || !getDroppedData(event) || !selectedPlayables.value.length) {
2022-12-02 16:17:37 +00:00
wrapper.value?.classList.remove('dragging')
2022-10-10 07:00:02 +00:00
return onDragLeave(event)
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
2022-12-02 16:17:37 +00:00
wrapper.value?.classList.remove('dragging')
if (!rowInSelectedRange(row)) {
2024-05-19 05:49:42 +00:00
emit(
'reorder',
row.playable,
(event.target as HTMLElement).classList.contains('dragover-bottom') ? 'after' : 'before'
)
}
2022-10-10 07:00:02 +00:00
return onDragLeave(event)
2022-04-15 17:00:08 +00:00
}
2022-10-10 07:00:02 +00:00
const onDragLeave = (event: DragEvent) => {
(event.target as HTMLElement).closest('.song-item')?.classList.remove('droppable', 'dragover-top', 'dragover-bottom')
2022-04-15 17:00:08 +00:00
return false
}
2022-12-02 16:17:37 +00:00
const onDragEnd = () => wrapper.value?.classList.remove('dragging')
2024-05-19 05:49:42 +00:00
const rowInSelectedRange = (row: PlayableRow) => {
if (!row.selected) return false
2024-05-19 05:49:42 +00:00
const index = findIndex(rows.value, ({ playable }) => playable.id === row.playable.id)
const firstSelectedIndex = Math.max(0, findIndex(rows.value, ({ selected }) => selected))
const lastSelectedIndex = Math.max(0, findLastIndex(rows.value, ({ selected }) => selected))
if (index < firstSelectedIndex || index > lastSelectedIndex) return false
for (let i = firstSelectedIndex; i <= lastSelectedIndex; ++i) {
2024-05-19 05:49:42 +00:00
if (!rows.value[i].selected) return false
}
return true
}
2024-05-19 05:49:42 +00:00
const openContextMenu = async (row: PlayableRow, event: MouseEvent) => {
if (!row.selected) {
2022-04-15 17:00:08 +00:00
clearSelection()
toggleRow(row)
2024-05-19 05:49:42 +00:00
// awaiting a next tick so that the selected items are collected properly
await nextTick()
2022-04-15 17:00:08 +00:00
}
2024-05-19 05:49:42 +00:00
eventBus.emit('PLAYABLE_CONTEXT_MENU_REQUESTED', event, selectedPlayables.value)
2022-04-15 17:00:08 +00:00
}
2024-06-07 12:53:24 +00:00
const onPlay = async (playable: Playable) => {
2024-03-25 22:59:38 +00:00
if (shouldTriggerContinuousPlayback.value) {
2024-05-19 05:49:42 +00:00
queueStore.replaceQueueWith(getAllPlayablesWithSort())
2024-03-25 22:59:38 +00:00
}
2024-06-07 12:53:24 +00:00
await playbackService.play(playable)
2024-03-25 22:59:38 +00:00
}
2022-04-15 17:00:08 +00:00
defineExpose({
2024-05-19 05:49:42 +00:00
getAllPlayablesWithSort
2022-04-15 14:24:30 +00:00
})
2022-04-28 14:46:38 +00:00
onMounted(() => render())
2022-04-15 14:24:30 +00:00
</script>
2024-04-04 20:13:35 +00:00
<style lang="postcss">
2022-04-15 14:24:30 +00:00
.song-list-wrap {
2024-04-04 22:20:42 +00:00
.virtual-scroller {
@apply flex-1;
2022-04-15 14:24:30 +00:00
}
&.dragging .song-item * {
2024-04-04 22:20:42 +00:00
@apply pointer-events-none;
2022-04-15 14:24:30 +00:00
}
2022-08-01 08:58:25 +00:00
.song-list-header > span, .song-item > span {
2024-04-04 22:20:42 +00:00
@apply text-left p-2 align-middle text-ellipsis overflow-hidden whitespace-nowrap;
2022-04-15 14:24:30 +00:00
&.time {
2024-05-19 05:49:42 +00:00
@apply basis-20 overflow-visible;
2022-04-15 14:24:30 +00:00
}
&.track-number {
2024-04-04 22:20:42 +00:00
@apply basis-16 pl-6;
2022-04-15 14:24:30 +00:00
}
&.album {
2024-04-04 22:20:42 +00:00
@apply basis-[27%];
2022-04-15 14:24:30 +00:00
}
2024-01-18 11:13:05 +00:00
&.collaborator {
2024-04-04 22:20:42 +00:00
@apply basis-[72px] text-center;
2024-01-18 11:13:05 +00:00
}
&.added-at {
2024-04-04 22:20:42 +00:00
@apply basis-36 text-left;
2024-01-18 11:13:05 +00:00
}
&.extra {
2024-04-04 22:20:42 +00:00
@apply basis-12 text-center;
2022-04-15 14:24:30 +00:00
}
&.play {
2024-04-04 22:20:42 +00:00
@apply hidden no-hover:block;
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
&.title-artist {
2024-04-04 22:20:42 +00:00
@apply flex-1;
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
.song-list-header {
2024-04-04 22:20:42 +00:00
@apply tracking-widest uppercase cursor-pointer text-k-text-secondary;
2022-04-15 14:24:30 +00:00
.extra {
2024-04-04 22:20:42 +00:00
@apply px-0;
2022-04-15 14:24:30 +00:00
}
}
2022-04-15 17:00:08 +00:00
.unsortable span {
2024-04-04 22:20:42 +00:00
@apply cursor-default;
2022-04-15 14:24:30 +00:00
}
@media only screen and (max-width: 768px) {
.scroller {
top: 0;
.item-container {
left: 12px;
right: 12px;
2022-07-16 09:52:39 +00:00
width: calc(200vw - 24px);
2022-04-15 14:24:30 +00:00
}
}
2022-04-15 17:00:08 +00:00
.song-item {
2022-07-29 12:12:55 +00:00
padding: 8px 12px;
2022-04-15 14:24:30 +00:00
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
2022-07-16 09:52:39 +00:00
width: 200%;
2022-04-15 14:24:30 +00:00
}
2024-01-18 11:13:05 +00:00
.song-item :is(.track-number, .album, .time, .added-at),
.song-list-header :is(.track-number, .album, .time, .added-at) {
2022-04-15 14:24:30 +00:00
display: none;
}
.song-item span {
2022-04-15 14:24:30 +00:00
padding: 0;
vertical-align: bottom;
&.thumbnail {
display: block;
padding-right: 12px;
}
2022-04-15 14:24:30 +00:00
}
}
}
</style>