mirror of
https://github.com/koel/koel
synced 2025-01-09 19:28:44 +00:00
589 lines
14 KiB
Vue
589 lines
14 KiB
Vue
<template>
|
|
<div class="song-list-wrap main-scroll-wrap" :class="type"
|
|
ref="wrapper"
|
|
tabindex="1"
|
|
@keydown.delete.prevent.stop="handleDelete"
|
|
@keydown.enter.prevent.stop="handleEnter"
|
|
@keydown.a.prevent="handleA"
|
|
>
|
|
<table class="song-list-header">
|
|
<thead>
|
|
<tr>
|
|
<th @click="sort('song.track')" class="track-number">#
|
|
<i class="fa fa-angle-down" v-show="sortKey === 'song.track' && order > 0"/>
|
|
<i class="fa fa-angle-up" v-show="sortKey === 'song.track' && order < 0"/>
|
|
</th>
|
|
<th @click="sort('song.title')" class="title">Title
|
|
<i class="fa fa-angle-down" v-show="sortKey === 'song.title' && order > 0"/>
|
|
<i class="fa fa-angle-up" v-show="sortKey === 'song.title' && order < 0"/>
|
|
</th>
|
|
<th @click="sort(['song.album.artist.name', 'song.album.name', 'song.track'])" class="artist">Artist
|
|
<i class="fa fa-angle-down" v-show="sortingByArtist && order > 0"/>
|
|
<i class="fa fa-angle-up" v-show="sortingByArtist && order < 0"/>
|
|
</th>
|
|
<th @click="sort(['song.album.name', 'song.track'])" class="album">Album
|
|
<i class="fa fa-angle-down" v-show="sortingByAlbum && order > 0"/>
|
|
<i class="fa fa-angle-up" v-show="sortingByAlbum && order < 0"/>
|
|
</th>
|
|
<th @click="sort('song.length')" class="time">Time
|
|
<i class="fa fa-angle-down" v-show="sortKey === 'song.length' && order > 0"/>
|
|
<i class="fa fa-angle-up" v-show="sortKey === 'song.length' && order < 0"/>
|
|
</th>
|
|
<th class="play"></th>
|
|
</tr>
|
|
</thead>
|
|
</table>
|
|
|
|
<virtual-scroller
|
|
class="scroller"
|
|
content-tag="table"
|
|
:items="filteredItems"
|
|
item-height="35"
|
|
:renderers="renderers"
|
|
key-field="song"
|
|
/>
|
|
|
|
<song-menu ref="contextMenu" :songs="selectedSongs"/>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import isMobile from 'ismobilejs'
|
|
import { each } from 'lodash'
|
|
|
|
import { filterBy, orderBy, event, pluralize, $ } from '../../utils'
|
|
import { playlistStore, queueStore, songStore, favoriteStore } from '../../stores'
|
|
import { playback } from '../../services'
|
|
import router from '../../router'
|
|
import songItem from './song-item.vue'
|
|
import songMenu from './song-menu.vue'
|
|
|
|
export default {
|
|
name: 'song-list',
|
|
props: ['items', 'type', 'playlist', 'sortable'],
|
|
components: { songItem, songMenu },
|
|
|
|
data () {
|
|
return {
|
|
renderers: Object.freeze({
|
|
song: songItem
|
|
}),
|
|
lastSelectedRow: null,
|
|
q: '', // The filter query
|
|
sortKey: '',
|
|
order: 1,
|
|
sortingByAlbum: false,
|
|
sortingByArtist: false,
|
|
songRows: []
|
|
}
|
|
},
|
|
|
|
watch: {
|
|
/**
|
|
* Watch the items.
|
|
*/
|
|
items () {
|
|
if (this.sortable === false) {
|
|
this.sortKey = ''
|
|
}
|
|
|
|
// Update the song count and duration status on parent.
|
|
this.$parent.updateMeta({
|
|
songCount: this.items.length,
|
|
totalLength: songStore.getLength(this.items, true)
|
|
})
|
|
|
|
this.generateSongRows()
|
|
},
|
|
|
|
selectedSongs (val) {
|
|
this.$parent.setSelectedSongs(val)
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
filteredItems () {
|
|
return filterBy(
|
|
this.songRows,
|
|
this.q,
|
|
'song.title', 'song.album.name', 'song.artist.name'
|
|
)
|
|
},
|
|
|
|
/**
|
|
* Determine if the songs in the current list can be reordered by drag-and-dropping.
|
|
* @return {Boolean}
|
|
*/
|
|
allowSongReordering () {
|
|
return this.type === 'queue'
|
|
},
|
|
|
|
/**
|
|
* Songs that are currently selected (their rows are highlighted).
|
|
* @return {Array.<Object>}
|
|
*/
|
|
selectedSongs () {
|
|
return this.songRows.filter(row => row.selected).map(row => row.song)
|
|
}
|
|
},
|
|
|
|
methods: {
|
|
/**
|
|
* Generate an array of "song row" or "song wrapper" objects. 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 row"
|
|
* objects, with each object contain the song itself, and the "selected" flag. In order to
|
|
* comply with virtual-scroller, a "type" attribute also presents.
|
|
*/
|
|
generateSongRows () {
|
|
// Since this method re-generates the song wrappers, we need to keep track of the
|
|
// selected songs manually.
|
|
const selectedSongIds = this.selectedSongs.map(song => song.id)
|
|
|
|
this.songRows = this.items.map(song => {
|
|
return {
|
|
song,
|
|
selected: selectedSongIds.indexOf(song.id) > -1,
|
|
type: 'song'
|
|
}
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Handle sorting the song list.
|
|
*
|
|
* @param {String} key The sort key. Can be 'title', 'album', 'artist', or 'length'
|
|
*/
|
|
sort (key = null) {
|
|
if (this.sortable === false) {
|
|
return
|
|
}
|
|
|
|
if (key) {
|
|
this.sortKey = key
|
|
this.order *= -1
|
|
}
|
|
|
|
this.sortingByAlbum = Array.isArray(this.sortKey) && this.sortKey[0] === 'song.album.name'
|
|
this.sortingByArtist = Array.isArray(this.sortKey) && this.sortKey[0] === 'song.album.artist.name'
|
|
this.songRows = orderBy(this.songRows, this.sortKey, this.order)
|
|
},
|
|
|
|
/**
|
|
* Execute the corresponding reaction(s) when the user presses Delete.
|
|
*/
|
|
handleDelete () {
|
|
if (!this.selectedSongs.length) {
|
|
return
|
|
}
|
|
|
|
switch (this.type) {
|
|
case 'queue':
|
|
queueStore.unqueue(this.selectedSongs)
|
|
break
|
|
case 'favorites':
|
|
favoriteStore.unlike(this.selectedSongs)
|
|
break
|
|
case 'playlist':
|
|
playlistStore.removeSongs(this.playlist, this.selectedSongs)
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
|
|
this.clearSelection()
|
|
},
|
|
|
|
/**
|
|
* Execute the corresponding reaction(s) when the user presses Enter.
|
|
*
|
|
* @param {Event} event The keydown event.
|
|
*/
|
|
handleEnter (event) {
|
|
if (!this.selectedSongs.length) {
|
|
return
|
|
}
|
|
|
|
if (this.selectedSongs.length === 1) {
|
|
// Just play the song
|
|
playback.play(this.selectedSongs[0])
|
|
return
|
|
}
|
|
|
|
switch (this.type) {
|
|
case 'queue':
|
|
// Play the first song selected if we're in Queue screen.
|
|
playback.play(this.selectedSongs[0])
|
|
break
|
|
case 'favorites':
|
|
case 'playlist':
|
|
default:
|
|
//
|
|
// --------------------------------------------------------------------
|
|
// For other screens, follow this map:
|
|
//
|
|
// • Enter: Queue songs to bottom
|
|
// • Shift+Enter: Queues song to top
|
|
// • Cmd/Ctrl+Enter: Queues song to bottom and play the first selected song
|
|
// • Cmd/Ctrl+Shift+Enter: Queue songs to top and play the first queued song
|
|
//
|
|
// Also, if there's only one song selected, play it right away.
|
|
// --------------------------------------------------------------------
|
|
//
|
|
queueStore.queue(this.selectedSongs, false, event.shiftKey)
|
|
|
|
this.$nextTick(() => {
|
|
router.go('queue')
|
|
|
|
if (event.ctrlKey || event.metaKey || this.selectedSongs.length === 1) {
|
|
playback.play(this.selectedSongs[0])
|
|
}
|
|
})
|
|
|
|
break
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Capture A keydown event and select all if applicable.
|
|
*
|
|
* @param {Event} event The keydown event.
|
|
*/
|
|
handleA (event) {
|
|
if (!event.metaKey && !event.ctrlKey) {
|
|
return
|
|
}
|
|
|
|
this.selectAllRows()
|
|
},
|
|
|
|
/**
|
|
* Select all rows in the current list.
|
|
*/
|
|
selectAllRows () {
|
|
each(this.songRows, row => {
|
|
row.selected = true
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Handle the click event on a row to perform selection.
|
|
*
|
|
* @param {VueComponent} rowVm
|
|
* @param {Event} e
|
|
*/
|
|
rowClicked (rowVm, event) {
|
|
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
|
|
if (isMobile.any) {
|
|
this.toggleRow(rowVm)
|
|
return
|
|
}
|
|
|
|
if (event.ctrlKey || event.metaKey) {
|
|
this.toggleRow(rowVm)
|
|
}
|
|
|
|
if (event.button === 0) {
|
|
if (!(event.ctrlKey || event.metaKey || event.shiftKey)) {
|
|
this.clearSelection()
|
|
this.toggleRow(rowVm)
|
|
}
|
|
|
|
if (event.shiftKey && this.lastSelectedRow) {
|
|
this.selectRowsBetween(this.lastSelectedRow, rowVm)
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Toggle select/unslect a row.
|
|
*
|
|
* @param {VueComponent} rowVm The song-item component
|
|
*/
|
|
toggleRow (rowVm) {
|
|
rowVm.item.selected = !rowVm.item.selected
|
|
this.lastSelectedRow = rowVm
|
|
},
|
|
|
|
/**
|
|
* Select all rows between two rows.
|
|
*
|
|
* @param {VueComponent} firstRowVm The first row's component
|
|
* @param {VueComponent} secondRowVm The second row's component
|
|
*/
|
|
selectRowsBetween (firstRowVm, secondRowVm) {
|
|
const indexes = [this.songRows.indexOf(firstRowVm.item), this.songRows.indexOf(secondRowVm.item)]
|
|
indexes.sort((a, b) => a - b)
|
|
|
|
for (let i = indexes[0]; i <= indexes[1]; ++i) {
|
|
this.songRows[i].selected = true
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear the current selection on this song list.
|
|
*/
|
|
clearSelection () {
|
|
each(this.songRows, row => {
|
|
row.selected = false
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Enable dragging songs by capturing the dragstart event on a table row.
|
|
* Even though the event is triggered on one row only, we'll collect other
|
|
* selected rows, if any, as well.
|
|
*
|
|
* @param {VueComponent} The row's Vue component
|
|
* @param {Event} event The event
|
|
*/
|
|
dragStart (rowVm, event) {
|
|
// If the user is dragging an unselected row, clear the current selection.
|
|
if (!rowVm.item.selected) {
|
|
this.clearSelection()
|
|
rowVm.item.selected = true
|
|
}
|
|
|
|
this.$nextTick(() => {
|
|
const songIds = this.selectedSongs.map(song => song.id)
|
|
event.dataTransfer.setData('application/x-koel.text+plain', songIds)
|
|
event.dataTransfer.effectAllowed = 'move'
|
|
|
|
// Set a fancy drop image using our ghost element.
|
|
const ghost = document.getElementById('dragGhost')
|
|
ghost.innerText = `${pluralize(songIds.length, 'song')}`
|
|
event.dataTransfer.setDragImage(ghost, 0, 0)
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Add a "droppable" class and set the drop effect when other songs are dragged over a row.
|
|
*
|
|
* @param {Event} event The dragover event.
|
|
*/
|
|
allowDrop (event) {
|
|
if (!this.allowSongReordering) {
|
|
return
|
|
}
|
|
|
|
$.addClass(event.target.parentNode, 'droppable')
|
|
event.dataTransfer.dropEffect = 'move'
|
|
|
|
return false
|
|
},
|
|
|
|
/**
|
|
* Perform reordering songs upon dropping if the current song list is of type Queue.
|
|
*
|
|
* @param {VueComponent} rowVm The row's Vue Component
|
|
* @param {Event} event
|
|
*/
|
|
handleDrop (rowVm, event) {
|
|
if (
|
|
!this.allowSongReordering ||
|
|
!event.dataTransfer.getData('application/x-koel.text+plain') ||
|
|
!this.selectedSongs.length
|
|
) {
|
|
return this.removeDroppableState(event)
|
|
}
|
|
|
|
queueStore.move(this.selectedSongs, rowVm.song)
|
|
|
|
return this.removeDroppableState(event)
|
|
},
|
|
|
|
/**
|
|
* Remove the droppable state (and the styles) from a row.
|
|
*
|
|
* @param {Event} event
|
|
*/
|
|
removeDroppableState (event) {
|
|
$.removeClass(event.target.parentNode, 'droppable')
|
|
return false
|
|
},
|
|
|
|
/**
|
|
* Open the context menu.
|
|
*
|
|
* @param {VueComponent} rowVm The right-clicked row's component
|
|
* @param {Event} event
|
|
*/
|
|
openContextMenu (rowVm, event) {
|
|
// If the user is right-clicking an unselected row,
|
|
// clear the current selection and select it instead.
|
|
if (!rowVm.item.selected) {
|
|
this.clearSelection()
|
|
this.toggleRow(rowVm)
|
|
}
|
|
|
|
this.$nextTick(() => this.$refs.contextMenu.open(event.pageY, event.pageX))
|
|
}
|
|
},
|
|
|
|
created () {
|
|
event.on({
|
|
/**
|
|
* Listen to 'filter:changed' event to filter the current list.
|
|
*/
|
|
'filter:changed': q => {
|
|
this.q = q
|
|
}
|
|
})
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
@import "../../../sass/partials/_vars.scss";
|
|
@import "../../../sass/partials/_mixins.scss";
|
|
|
|
.song-list-wrap {
|
|
position: relative;
|
|
padding: 8px 24px;
|
|
|
|
.song-list-header {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 24px;
|
|
right: 24px;
|
|
padding: 0 24px;
|
|
background: #1b1b1b;
|
|
z-index: 1;
|
|
width: calc(100% - 48px);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
table-layout: fixed;
|
|
}
|
|
|
|
tr.droppable {
|
|
border-bottom-width: 3px;
|
|
border-bottom-color: $colorGreen;
|
|
}
|
|
|
|
td, th {
|
|
text-align: left;
|
|
padding: 8px;
|
|
vertical-align: middle;
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
|
|
&.time {
|
|
width: 72px;
|
|
text-align: right;
|
|
}
|
|
|
|
&.track-number {
|
|
width: 42px;
|
|
}
|
|
|
|
&.artist {
|
|
width: 23%;
|
|
}
|
|
|
|
&.album {
|
|
width: 27%;
|
|
}
|
|
|
|
&.play {
|
|
display: none;
|
|
|
|
html.touchevents & {
|
|
display: block;
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 4px;
|
|
}
|
|
}
|
|
}
|
|
|
|
th {
|
|
color: $color2ndText;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
|
|
i {
|
|
color: $colorHighlight;
|
|
font-size: 1.2rem;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Since the Queue screen doesn't allow sorting, we reset the cursor style.
|
|
*/
|
|
&.queue th {
|
|
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: 24px;
|
|
right: 24px;
|
|
}
|
|
|
|
.item {
|
|
margin-bottom: 0;
|
|
}
|
|
}
|
|
|
|
@media only screen and (max-width: 768px) {
|
|
table, tbody, tr {
|
|
display: block;
|
|
}
|
|
|
|
thead, tfoot {
|
|
display: none;
|
|
}
|
|
|
|
.scroller {
|
|
top: 0;
|
|
bottom: 24px;
|
|
|
|
.item-container {
|
|
left: 12px;
|
|
right: 12px;
|
|
}
|
|
}
|
|
|
|
tr {
|
|
padding: 8px 32px 8px 4px;
|
|
position: relative;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
color: $color2ndText;
|
|
width: 100%;
|
|
}
|
|
|
|
td {
|
|
display: inline;
|
|
padding: 0;
|
|
vertical-align: bottom;
|
|
color: $colorMainText;
|
|
|
|
&.album, &.time, &.track-number {
|
|
display: none;
|
|
}
|
|
|
|
&.artist {
|
|
color: $color2ndText;
|
|
font-size: .9rem;
|
|
padding: 0 4px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|