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

457 lines
12 KiB
Vue
Raw Normal View History

2022-04-15 14:24:30 +00:00
<template>
<div
ref="wrapper"
class="song-list-wrap"
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"
>
2022-06-10 10:47:46 +00:00
<div :class="config.sortable ? 'sortable' : 'unsortable'" class="song-list-header">
<span
2022-06-10 10:47:46 +00:00
v-if="config.columns.includes('track')"
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
#
2022-10-25 18:25:58 +00:00
<icon v-if="sortField === 'track' && sortOrder === 'asc'" :icon="faCaretDown" class="text-highlight"/>
<icon v-if="sortField === 'track' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
2022-04-24 18:44:48 +00:00
</span>
<span
2022-06-10 10:47:46 +00:00
v-if="config.columns.includes('title')"
class="title"
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
2022-10-25 18:25:58 +00:00
<icon v-if="sortField === 'title' && sortOrder === 'asc'" :icon="faCaretDown" class="text-highlight"/>
<icon v-if="sortField === 'title' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
2022-04-24 18:44:48 +00:00
</span>
2022-04-15 17:00:08 +00:00
<span
2022-06-10 10:47:46 +00:00
v-if="config.columns.includes('artist')"
class="artist"
data-testid="header-artist"
2022-07-21 07:54:36 +00:00
role="button"
title="Sort by artist"
2022-06-10 10:47:46 +00:00
@click="sort('artist_name')"
2022-04-15 17:00:08 +00:00
>
2022-04-24 18:44:48 +00:00
Artist
2022-10-25 18:25:58 +00:00
<icon v-if="sortField === 'artist_name' && sortOrder === 'asc'" :icon="faCaretDown" class="text-highlight"/>
<icon v-if="sortField === 'artist_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
2022-04-24 18:44:48 +00:00
</span>
2022-04-15 17:00:08 +00:00
<span
2022-06-10 10:47:46 +00:00
v-if="config.columns.includes('album')"
class="album"
data-testid="header-album"
2022-07-21 07:54:36 +00:00
role="button"
title="Sort by album"
2022-06-10 10:47:46 +00:00
@click="sort('album_name')"
2022-04-15 17:00:08 +00:00
>
2022-04-24 18:44:48 +00:00
Album
2022-10-25 18:25:58 +00:00
<icon v-if="sortField === 'album_name' && sortOrder === 'asc'" :icon="faCaretDown" class="text-highlight"/>
<icon v-if="sortField === 'album_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
2022-04-24 18:44:48 +00:00
</span>
<span
2022-06-10 10:47:46 +00:00
v-if="config.columns.includes('length')"
class="time"
data-testid="header-length"
2022-07-21 07:54:36 +00:00
role="button"
title="Sort by song duration"
2022-06-10 10:47:46 +00:00
@click="sort('length')"
>
2022-10-25 18:25:58 +00:00
Time
<icon v-if="sortField === 'length' && sortOrder === 'asc'" :icon="faCaretDown" class="text-highlight"/>
<icon v-if="sortField === 'length' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight"/>
2022-04-24 18:44:48 +00:00
</span>
2022-04-15 17:00:08 +00:00
<span class="favorite"></span>
</div>
2022-07-16 09:52:39 +00:00
<VirtualScroller
v-slot="{ item }"
:item-height="64"
2022-07-16 09:52:39 +00:00
:items="songRows"
@scroll="onScroll"
@scrolled-to-end="$emit('scrolled-to-end')"
>
<SongListItem
:key="item.song.id"
2022-06-10 10:47:46 +00:00
:columns="config.columns"
:item="item"
draggable="true"
@click="rowClicked(item, $event)"
2022-10-10 07:00:02 +00:00
@dragleave="onDragLeave"
@dragstart="onDragStart(item, $event)"
@dragenter.prevent="onDragEnter"
@dragover.prevent
2022-10-10 07:00:02 +00:00
@drop.prevent="onDrop(item, $event)"
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>
2022-07-21 07:54:36 +00:00
import { findIndex } from 'lodash'
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'
import { eventBus, requireInjection } from '@/utils'
2022-10-10 07:00:02 +00:00
import { useDraggable, useDroppable } from '@/composables'
2022-07-05 15:09:20 +00:00
import {
ScreenNameKey,
2022-07-05 15:09:20 +00:00
SelectedSongsKey,
SongListConfigKey,
SongListSortFieldKey,
SongListSortOrderKey,
SongsKey
} from '@/symbols'
2022-04-15 14:24:30 +00:00
import VirtualScroller from '@/components/ui/VirtualScroller.vue'
import SongListItem from '@/components/song/SongListItem.vue'
2022-04-15 14:24:30 +00:00
const { startDragging } = useDraggable('songs')
2022-10-10 07:00:02 +00:00
const { getDroppedData, acceptsDrop } = useDroppable(['songs'])
2022-07-16 09:52:39 +00:00
const emit = defineEmits(['press:enter', 'press:delete', 'reorder', 'sort', 'scroll-breakpoint', 'scrolled-to-end'])
2022-04-15 14:24:30 +00:00
2022-10-10 07:00:02 +00:00
const [items] = requireInjection<[Ref<Song[]>]>(SongsKey)
const [screen] = requireInjection<[ScreenName]>(ScreenNameKey)
2022-10-10 07:00:02 +00:00
const [selectedSongs, setSelectedSongs] = requireInjection<[Ref<Song[]>, Closure]>(SelectedSongsKey)
const [sortField, setSortField] = requireInjection<[Ref<SongListSortField>, Closure]>(SongListSortFieldKey)
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
const [injectedConfig] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
2022-04-15 14:24:30 +00:00
2022-04-15 17:00:08 +00:00
const lastSelectedRow = ref<SongRow>()
const sortFields = ref<SongListSortField[]>([])
const songRows = ref<SongRow[]>([])
2022-04-15 14:24:30 +00:00
const allowReordering = screen === 'Queue'
2022-07-20 08:00:02 +00:00
watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true })
2022-04-15 14:24:30 +00:00
2022-06-10 10:47:46 +00:00
const config = computed((): SongListConfig => {
2022-04-15 17:00:08 +00:00
return Object.assign({
sortable: true,
columns: ['track', 'thumbnail', 'title', 'artist', 'album', 'length']
2022-07-20 08:00:02 +00:00
}, injectedConfig)
2022-04-15 17:00:08 +00:00
})
2022-04-15 14:24:30 +00:00
2022-07-16 09:52:39 +00:00
let lastScrollTop = 0
const onScroll = e => {
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
/**
* Since song objects themselves are shared by all song lists, we can't use them directly to
* determine their selection status (selected/unselected). Therefore, for each song list, we
* maintain an array of "song rows," each containing the song itself and the "selected" flag.
2022-04-15 17:00:08 +00:00
*/
const generateSongRows = () => {
2022-04-15 17:00:08 +00:00
// Since this method re-generates the song wrappers, we need to keep track of the
// selected songs manually.
const selectedSongIds = selectedSongs.value.map(song => song.id)
return items.value.map(song => ({
song,
selected: selectedSongIds.includes(song.id)
}))
}
2022-04-15 14:24:30 +00:00
2022-06-10 10:47:46 +00:00
const sort = (field: SongListSortField) => {
2022-04-15 17:00:08 +00:00
// there are certain circumstances where sorting is simply disallowed, e.g. in Queue
2022-06-10 10:47:46 +00:00
if (!config.value.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 = () => {
2022-06-10 10:47:46 +00:00
config.value.sortable || (sortFields.value = [])
songRows.value = generateSongRows()
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.
*/
const selectAllRows = () => songRows.value.forEach(row => (row.selected = true))
const clearSelection = () => songRows.value.forEach(row => (row.selected = false))
2022-04-15 17:00:08 +00:00
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
const getAllSongsWithSort = () => songRows.value.map(row => row.song)
2022-04-15 14:24:30 +00:00
const rowClicked = (row: SongRow, 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
const toggleRow = (row: SongRow) => {
row.selected = !row.selected
lastSelectedRow.value = row
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
const selectRowsBetween = (first: SongRow, second: SongRow) => {
const firstIndex = Math.max(0, findIndex(songRows.value, row => row.song.id === first.song.id))
const secondIndex = Math.max(0, findIndex(songRows.value, row => row.song.id === second.song.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) {
songRows.value[i].selected = true
2022-04-15 17:00:08 +00:00
}
}
2022-04-15 14:24:30 +00:00
2022-10-10 07:00:02 +00:00
const onDragStart = (row: SongRow, 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
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
startDragging(event, selectedSongs.value)
2022-04-15 17:00:08 +00:00
}
2022-04-15 14:24:30 +00:00
2022-10-10 07:00:02 +00:00
const onDragEnter = (event: DragEvent) => {
if (!allowReordering) return
2022-07-29 12:12:55 +00:00
2022-10-10 07:00:02 +00:00
if (acceptsDrop(event)) {
(event.target as Element).parentElement?.classList.add('droppable')
event.dataTransfer!.dropEffect = 'move'
}
2022-04-15 17:00:08 +00:00
return false
}
2022-10-10 07:00:02 +00:00
const onDrop = (item: SongRow, event: DragEvent) => {
if (!allowReordering || !getDroppedData(event) || !selectedSongs.value.length) {
return onDragLeave(event)
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
emit('reorder', item.song)
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 Element).parentElement?.classList.remove('droppable')
2022-04-15 17:00:08 +00:00
return false
}
const openContextMenu = async (row: SongRow, event: MouseEvent) => {
if (!row.selected) {
2022-04-15 17:00:08 +00:00
clearSelection()
toggleRow(row)
// awaiting a next tick so that the selected songs are collected properly
await nextTick()
2022-04-15 17:00:08 +00:00
}
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, selectedSongs.value)
}
defineExpose({
2022-10-10 07:00:02 +00:00
getAllSongsWithSort
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>
<style lang="scss">
.song-list-wrap {
position: relative;
2022-04-27 20:52:37 +00:00
display: flex;
flex-direction: column;
overflow: scroll;
2022-04-15 14:24:30 +00:00
2022-10-13 15:18:47 +00:00
@media screen and (max-width: 768px) {
padding: 0 12px;
}
2022-04-15 14:24:30 +00:00
.song-list-header {
background: var(--color-bg-secondary);
z-index: 1;
2022-04-15 17:00:08 +00:00
display: flex;
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
div.droppable {
2022-04-15 14:24:30 +00:00
border-bottom-width: 3px;
border-bottom-color: var(--color-green);
}
2022-08-01 08:58:25 +00:00
.song-list-header > span, .song-item > span {
2022-04-15 14:24:30 +00:00
text-align: left;
padding: 8px;
vertical-align: middle;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
&.time {
flex-basis: 64px;
overflow: visible;
2022-04-15 14:24:30 +00:00
}
&.track-number {
2022-04-15 17:00:08 +00:00
flex-basis: 66px;
2022-04-15 14:24:30 +00:00
padding-left: 24px;
}
&.artist {
flex-basis: 20%;
2022-04-15 14:24:30 +00:00
}
&.album {
2022-04-15 17:00:08 +00:00
flex-basis: 27%;
2022-04-15 14:24:30 +00:00
}
&.favorite {
2022-04-15 17:00:08 +00:00
flex-basis: 36px;
2022-04-15 14:24:30 +00:00
}
&.play {
display: none;
@media (hover: none) {
display: block;
}
}
2022-04-15 17:00:08 +00:00
&.title {
flex: 1;
}
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
.song-list-header {
2022-04-15 14:24:30 +00:00
color: var(--color-text-secondary);
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
2022-04-24 18:44:48 +00:00
i:not(.duration-header) {
2022-04-15 14:24:30 +00:00
color: var(--color-highlight);
font-size: 1.2rem;
}
}
2022-04-15 17:00:08 +00:00
.unsortable span {
2022-04-15 14:24:30 +00:00
cursor: default;
}
.scroller {
overflow: auto;
position: absolute;
top: 35px;
left: 0;
bottom: 0;
right: 0;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
.item-container {
position: absolute;
left: 0;
right: 0;
2022-07-16 09:52:39 +00:00
min-height: 200%;
2022-04-15 14:24:30 +00:00
}
.item {
margin-bottom: 0;
}
}
@media only screen and (max-width: 768px) {
2022-04-15 17:00:08 +00:00
.song-list-header {
2022-04-15 14:24:30 +00:00
display: none;
}
.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;
color: var(--color-text-secondary);
2022-07-16 09:52:39 +00:00
width: 200%;
2022-04-15 14:24:30 +00:00
}
2022-04-15 17:00:08 +00:00
.song-item span {
2022-04-15 14:24:30 +00:00
display: none;
padding: 0;
vertical-align: bottom;
color: var(--color-text-primary);
&.thumbnail {
display: block;
padding-right: 12px;
}
2022-04-15 14:24:30 +00:00
&.artist, &.title {
display: inline;
}
&.artist {
color: var(--color-text-secondary);
font-size: 0.9rem;
padding: 0 4px;
}
}
}
}
</style>