koel/resources/assets/js/components/shared/song-list.vue

571 lines
19 KiB
Vue
Raw Normal View History

2015-12-13 04:42:28 +00:00
<template>
<div class="song-list-wrap main-scroll-wrap {{ type }}"
v-el:wrapper
tabindex="1"
@scroll="scrolling"
2015-12-13 04:42:28 +00:00
@keydown.delete.prevent.stop="handleDelete"
@keydown.enter.prevent.stop="handleEnter"
@keydown.a.prevent="handleA"
>
<table v-show="items.length">
<thead>
<tr>
2016-03-20 14:05:42 +00:00
<th>#</th>
2015-12-13 04:42:28 +00:00
<th @click="sort('title')">Title
<i class="fa fa-angle-down" v-show="sortKey === 'title' && order > 0"></i>
<i class="fa fa-angle-up" v-show="sortKey === 'title' && order < 0"></i>
</th>
<th @click="sort(['album.artist.name', 'album.name', 'track'])">Artist
2015-12-13 04:42:28 +00:00
<i class="fa fa-angle-down" v-show="sortKey === 'album.artist.name' && order > 0"></i>
<i class="fa fa-angle-up" v-show="sortKey === 'album.artist.name' && order < 0"></i>
</th>
<th @click="sort(['album.name', 'track'])">Album
2015-12-13 04:42:28 +00:00
<i class="fa fa-angle-down" v-show="sortKey === 'album.name' && order > 0"></i>
<i class="fa fa-angle-up" v-show="sortKey === 'album.name' && order < 0"></i>
</th>
<th @click="sort('fmtLength')" class="time">Time
<i class="fa fa-angle-down" v-show="sortKey === 'fmtLength' && order > 0"></i>
<i class="fa fa-angle-up" v-show="sortKey === 'fmtLength' && order < 0"></i>
</th>
2016-01-07 04:22:02 +00:00
<th class="play"></th>
2015-12-13 04:42:28 +00:00
</tr>
</thead>
<tbody>
<tr
v-for="item in items
| caseInsensitiveOrderBy sortKey order
2016-02-13 16:59:11 +00:00
| filterSongBy q
2015-12-13 04:42:28 +00:00
| limitBy numOfItems"
is="song-item"
data-track="{{ item.track }}"
data-song-id="{{ item.id }}"
track-by="id"
2016-02-08 14:33:07 +00:00
:song="item"
v-ref:rows
@click="rowClick(item.id, $event)"
2015-12-13 04:42:28 +00:00
draggable="true"
@dragstart="dragStart(item.id, $event)"
2016-01-17 10:10:49 +00:00
@dragleave="removeDroppableState"
@dragover.prevent="allowDrop(item.id, $event)"
@drop.stop.prevent="handleDrop(item.id, $event)"
2016-03-05 09:01:12 +00:00
@contextmenu.prevent="openContextMenu(item.id, $event)"
2015-12-13 04:42:28 +00:00
>
</tr>
</tbody>
</table>
2016-03-05 09:01:12 +00:00
<song-menu v-ref:context-menu :songs="selectedSongs"></song-menu>
</div>
2015-12-13 04:42:28 +00:00
</template>
<script>
import _ from 'lodash';
import isMobile from 'ismobilejs';
import $ from 'jquery';
2015-12-13 04:42:28 +00:00
import songItem from './song-item.vue';
2016-03-05 09:01:12 +00:00
import songMenu from './song-menu.vue';
2015-12-13 04:42:28 +00:00
import infiniteScroll from '../../mixins/infinite-scroll';
import playlistStore from '../../stores/playlist';
import queueStore from '../../stores/queue';
import songStore from '../../stores/song';
import favoriteStore from '../../stores/favorite';
import playback from '../../services/playback';
export default {
props: ['items', 'type', 'playlist', 'selectedSongs', 'sortable'],
2015-12-13 04:42:28 +00:00
mixins: [infiniteScroll],
2016-03-05 09:01:12 +00:00
components: { songItem, songMenu },
2015-12-13 04:42:28 +00:00
data() {
return {
lastSelectedRow: null,
q: '', // The filter query
sortKey: this.type === 'top-songs' ? 'playCount' : ['album.artist.name', 'album.name', 'track'],
2016-02-08 14:33:07 +00:00
order: this.type === 'top-songs' ? -1 : 1,
componentCache: {},
2015-12-13 04:42:28 +00:00
};
},
watch: {
/**
2016-01-14 08:02:59 +00:00
* Watch the items.
2015-12-13 04:42:28 +00:00
*/
items() {
if (this.sortable === false) {
2015-12-13 04:42:28 +00:00
this.sortKey = '';
}
2016-01-14 08:02:59 +00:00
// Dispatch this event for the parent to update the song count and duration status.
this.$dispatch('songlist:changed', {
2016-01-14 08:02:59 +00:00
songCount: this.items.length,
totalLength: songStore.getLength(this.items, true),
});
2015-12-13 04:42:28 +00:00
},
},
methods: {
/**
* Handle sorting the song list.
*
* @param {String} key The sort key. Can be 'title', 'album', 'artist', or 'fmtLength'
2015-12-13 04:42:28 +00:00
*/
sort(key) {
if (this.sortable === false) {
2015-12-13 04:42:28 +00:00
return;
}
this.sortKey = key;
this.order = 0 - this.order;
},
/**
* Execute the corresponding reaction(s) when the user presses Delete.
*/
handleDelete() {
2016-03-16 03:51:07 +00:00
let songs = this.selectedSongs;
2015-12-13 04:42:28 +00:00
if (!songs.length) {
return;
}
switch (this.type) {
case 'queue':
queueStore.unqueue(songs);
break;
case 'favorites':
favoriteStore.unlike(songs);
break;
case 'playlist':
2016-03-13 17:00:32 +00:00
playlistStore.removeSongs(this.playlist, songs);
2015-12-13 04:42:28 +00:00
break;
default:
2015-12-13 04:42:28 +00:00
break;
}
this.clearSelection();
},
/**
* Execute the corresponding reaction(s) when the user presses Enter.
2016-01-07 09:03:38 +00:00
*
* @param {Object} e The keydown event.
2015-12-13 04:42:28 +00:00
*/
handleEnter(e) {
2016-03-16 03:51:07 +00:00
let songs = this.selectedSongs;
2015-12-13 04:42:28 +00:00
if (!songs.length) {
return;
}
if (songs.length === 1) {
// Just play the song
playback.play(songs[0]);
2015-12-13 04:42:28 +00:00
return;
}
switch (this.type) {
case 'queue':
// Play the first song selected if we're in Queue screen.
playback.play(songs[0]);
break;
case 'favorites':
case 'playlist':
default:
//
2015-12-13 04:42:28 +00:00
// --------------------------------------------------------------------
// For other screens, follow this map:
//
2015-12-13 04:42:28 +00:00
// • 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
//
2015-12-13 04:42:28 +00:00
// Also, if there's only one song selected, play it right away.
// --------------------------------------------------------------------
//
2015-12-13 04:42:28 +00:00
if (e.shiftKey) {
queueStore.queue(songs, false);
} else {
queueStore.queue(songs, false, false);
}
2016-02-09 04:57:08 +00:00
this.$nextTick(() => {
2015-12-13 04:42:28 +00:00
this.$root.loadMainView('queue');
2016-03-13 17:00:32 +00:00
if (e.ctrlKey || e.metaKey || songs.length === 1) {
2015-12-13 04:42:28 +00:00
playback.play(songs[0]);
}
});
break;
}
this.clearSelection();
},
/**
* Get the song-item component that's associated with a song ID.
*
* @param {String} id The song ID.
*
* @return {Object} The Vue compoenent
*/
getComponentBySongId(id) {
// A Vue component can be removed (as a result of filter for example), so we check for its $el as well.
if (!this.componentCache[id] || !this.componentCache[id].$el) {
this.componentCache[id] = _.find(this.$refs.rows, { song: { id } });
}
return this.componentCache[id];
},
2015-12-13 04:42:28 +00:00
/**
* Capture A keydown event and select all if applicable.
2016-01-07 09:03:38 +00:00
*
* @param {Object} e The keydown event.
2015-12-13 04:42:28 +00:00
*/
handleA(e) {
if (!e.metaKey && !e.ctrlKey) {
return;
}
_.invoke(this.$refs.rows, 'select');
2015-12-14 04:25:42 +00:00
this.gatherSelected();
2015-12-13 04:42:28 +00:00
},
/**
2016-01-06 16:41:59 +00:00
* Gather all selected songs.
*
* @return {Array.<Object>} An array of Song objects
2015-12-13 04:42:28 +00:00
*/
2015-12-14 04:25:42 +00:00
gatherSelected() {
2016-03-16 03:51:07 +00:00
let selectedRows = _.where(this.$refs.rows, { selected: true });
let ids = _.map(selectedRows, row => row.song.id);
2015-12-13 04:42:28 +00:00
2015-12-14 04:25:42 +00:00
this.selectedSongs = songStore.byIds(ids);
2015-12-13 04:42:28 +00:00
},
/**
* -----------------------------------------------------------
* The next four methods are to deal with selection.
*
2015-12-13 04:42:28 +00:00
* Credits: http://stackoverflow.com/a/17966381/794641 by andyb
* -----------------------------------------------------------
*/
/**
* Handle the click event on a row to perform selection.
*
* @param {String} songId
* @param {Object} e
*/
rowClick(songId, e) {
2016-03-16 03:51:07 +00:00
let row = this.getComponentBySongId(songId);
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
2015-12-13 04:42:28 +00:00
if (isMobile.any) {
2016-01-07 04:22:02 +00:00
this.toggleRow(row);
this.gatherSelected();
2015-12-13 04:42:28 +00:00
return;
}
if (e.ctrlKey || e.metaKey) {
this.toggleRow(row);
}
if (e.button === 0) {
if (!e.ctrlKey && !e.metaKey && !e.shiftKey) {
this.clearSelection();
this.toggleRow(row);
}
if (e.shiftKey && this.lastSelectedRow && this.lastSelectedRow.$el) {
this.selectRowsBetweenIndexes([this.lastSelectedRow.$el.rowIndex, row.$el.rowIndex]);
2015-12-13 04:42:28 +00:00
}
}
2015-12-14 04:25:42 +00:00
this.gatherSelected();
2015-12-13 04:42:28 +00:00
},
2016-01-07 09:03:38 +00:00
/**
* Toggle select/unslect a row.
*
* @param {Object} row The song-item component
2016-01-07 09:03:38 +00:00
*/
2015-12-13 04:42:28 +00:00
toggleRow(row) {
row.toggleSelectedState();
2015-12-13 04:42:28 +00:00
this.lastSelectedRow = row;
},
selectRowsBetweenIndexes(indexes) {
indexes.sort((a, b) => a - b);
2016-03-16 03:51:07 +00:00
let rows = $(this.$els.wrapper).find('tbody tr');
2015-12-13 04:42:28 +00:00
2016-03-16 03:51:07 +00:00
for (let i = indexes[0]; i <= indexes[1]; ++i) {
2016-03-13 17:00:32 +00:00
this.getComponentBySongId($(rows[i - 1]).data('song-id')).select();
2015-12-13 04:42:28 +00:00
}
},
/**
* Clear the current selection on this song list.
*/
2015-12-13 04:42:28 +00:00
clearSelection() {
_.invoke(this.$refs.rows, 'deselect');
this.gatherSelected();
2015-12-13 04:42:28 +00:00
},
/**
* 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.
2016-01-07 09:03:38 +00:00
*
* @param {Object} e The event.
2015-12-13 04:42:28 +00:00
*/
dragStart(songId, e) {
2016-03-05 09:01:12 +00:00
// If the user is dragging an unselected row, clear the current selection.
2016-03-16 03:51:07 +00:00
let currentRow = this.getComponentBySongId(songId);
2016-03-05 09:01:12 +00:00
if (!currentRow.selected) {
this.clearSelection();
currentRow.select();
this.gatherSelected();
}
2016-03-05 09:01:12 +00:00
this.$nextTick(() => {
// We can opt for something like application/x-koel.text+plain here to sound fancy,
// but forget it.
2016-03-16 03:51:07 +00:00
let songIds = _.pluck(this.selectedSongs, 'id');
2016-03-05 09:01:12 +00:00
e.dataTransfer.setData('text/plain', songIds);
e.dataTransfer.effectAllowed = 'move';
2015-12-13 04:42:28 +00:00
2016-03-05 09:01:12 +00:00
// Set a fancy drop image using our ghost element.
2016-03-16 03:51:07 +00:00
let $ghost = $('#dragGhost').text(`${songIds.length} song${songIds.length === 1 ? '' : 's'}`);
2016-03-05 09:01:12 +00:00
e.dataTransfer.setDragImage($ghost[0], 0, 0);
});
},
2015-12-13 04:42:28 +00:00
/**
* Add a "droppable" class and set the drop effect when other songs are dragged over a row.
*
* @param {String} songId
* @param {Object} e The dragover event.
*/
allowDrop(songId, e) {
if (this.type !== 'queue') {
return;
}
$(e.target).parents('tr').addClass('droppable');
e.dataTransfer.dropEffect = 'move';
return false;
},
/**
* Perform reordering songs upon dropping if the current song list is of type Queue.
*
* @param {String} songId
* @param {Object} e
*/
handleDrop(songId, e) {
if (this.type !== 'queue') {
return;
}
if (!e.dataTransfer.getData('text/plain')) {
return false;
}
2016-03-16 03:51:07 +00:00
let songs = this.selectedSongs;
if (!songs.length) {
return false;
}
queueStore.move(songs, songStore.byId(songId));
return false;
},
/**
* Remove the droppable state (and the styles) from a row.
*
* @param {Object} e
*/
2016-01-17 10:10:49 +00:00
removeDroppableState(e) {
return $(e.target).parents('tr').removeClass('droppable');
},
2016-03-05 09:01:12 +00:00
openContextMenu(songId, e) {
// If the user is right-click an unselected row, clear the current selection and select it instead.
2016-03-16 03:51:07 +00:00
let currentRow = this.getComponentBySongId(songId);
2016-03-05 09:01:12 +00:00
if (!currentRow.selected) {
this.clearSelection();
currentRow.select();
this.gatherSelected();
}
this.$nextTick(() => {
this.$refs.contextMenu.open(e.pageY, e.pageX);
});
},
2015-12-13 04:42:28 +00:00
},
events: {
/**
2016-01-07 09:03:38 +00:00
* Listen to song:played event to do some logic.
*
2016-01-06 16:41:59 +00:00
* @param {Object} song The current playing song.
2015-12-13 04:42:28 +00:00
*/
2016-01-07 09:03:38 +00:00
'song:played': function (song) {
2015-12-13 04:42:28 +00:00
// If the song is at the end of the current displayed items, load more.
if (this.type === 'queue' && this.items.indexOf(song) >= this.numOfItems) {
this.displayMore();
}
// Scroll the item into view if it's lost into oblivion.
if (this.type === 'queue') {
2016-03-16 03:51:07 +00:00
let $wrapper = $(this.$els.wrapper);
let $row = $wrapper.find(`.song-item[data-song-id="${song.id}"]`);
2015-12-13 04:42:28 +00:00
if (!$row.length) {
return;
}
2016-03-13 17:00:32 +00:00
if ($wrapper[0].getBoundingClientRect().top + $wrapper[0].getBoundingClientRect().height <
$row[0].getBoundingClientRect().top) {
2015-12-13 04:42:28 +00:00
$wrapper.scrollTop($wrapper.scrollTop() + $row.position().top);
}
}
return true;
},
/**
* Listen to 'filter:changed' event to filter the current list.
*/
'filter:changed': function (q) {
this.q = q;
},
/**
* Clears the current list's selection if the user has switched to another view.
*/
'main-content-view:load': function () {
this.clearSelection();
},
2016-01-06 16:41:59 +00:00
/**
2016-01-07 01:11:16 +00:00
* Listens to the 'song:selection-changed' dispatched from a child song-item
2016-01-06 16:41:59 +00:00
* to collect the selected songs.
*/
'song:selection-changed': function () {
this.gatherSelected();
},
2016-01-07 01:11:16 +00:00
/**
* Listen to 'song:selection-clear' (often broadcasted from the direct parent)
* to clear the selected songs.
*/
'song:selection-clear': function () {
this.clearSelection();
2016-03-14 17:16:38 +00:00
},
2015-12-13 04:42:28 +00:00
},
};
</script>
<style lang="sass">
2016-03-13 17:00:32 +00:00
@import "../../../sass/partials/_vars.scss";
@import "../../../sass/partials/_mixins.scss";
2015-12-13 04:42:28 +00:00
.song-list-wrap {
position: relative;
table {
width: 100%;
}
tr.droppable {
border-bottom-width: 3px;
border-bottom-color: $colorGreen;
}
2015-12-13 04:42:28 +00:00
td, th {
text-align: left;
padding: 8px;
vertical-align: middle;
&.time {
width: 72px;
text-align: right;
}
2016-01-06 16:41:59 +00:00
2016-01-07 04:22:02 +00:00
&.play {
2016-01-06 16:41:59 +00:00
display: none;
2016-01-07 01:39:38 +00:00
html.touchevents & {
display: block;
position: absolute;
top: 8px;
2016-01-07 04:22:02 +00:00
right: 4px;
2016-01-07 01:39:38 +00:00
}
2016-01-06 16:41:59 +00:00
}
2015-12-13 04:42:28 +00:00
}
th {
color: $color2ndText;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
i {
color: $colorHighlight;
font-size: 120%;
}
}
/**
* Since the Queue screen doesn't allow sorting, we reset the cursor style.
*/
&.queue th {
cursor: default;
}
@media only screen and (max-width: 768px) {
2015-12-13 04:42:28 +00:00
table, tbody, tr {
display: block;
}
thead, tfoot {
display: none;
}
tr {
2016-01-06 16:41:59 +00:00
padding: 8px 32px 8px 4px;
position: relative;
2015-12-13 04:42:28 +00:00
}
td {
display: inline;
padding: 0;
vertical-align: bottom;
2016-03-20 14:23:01 +00:00
&.album, &.time, &.track-number {
2015-12-13 04:42:28 +00:00
display: none;
}
&.artist {
opacity: .5;
font-size: 90%;
padding: 0 4px;
}
}
}
}
</style>