Added playlist editor in music.mpd web panel

This commit is contained in:
Fabio Manganiello 2019-06-07 23:07:36 +02:00
parent 1ad72a2695
commit b7a625097d
6 changed files with 464 additions and 37 deletions

View file

@ -76,6 +76,30 @@
} }
} }
%ctrl-button {
border: 0;
padding: 0 1.5rem;
&:disabled {
background: none;
}
&.enabled {
color: $button-enabled-color;
}
.fa-search {
color: $button-hover-color;
}
}
%move {
background: $move-mode-track-bg !important;
border-top: $move-mode-track-border;
border-bottom: $move-mode-track-border;
cursor: move;
}
.browser, .browser,
.search, .search,
.playlist { .playlist {
@ -97,25 +121,11 @@
} }
* > button { * > button {
border: 0; @extend %ctrl-button;
padding: 0 1.5rem;
&:disabled {
background: none;
}
&.enabled {
color: $button-enabled-color;
}
.fa-search {
color: $button-hover-color;
}
} }
button { button {
padding: 0 .75rem; padding: 0 .75rem;
} }
} }
@ -142,15 +152,14 @@
} }
&.move:hover { &.move:hover {
background: $move-mode-track-bg !important; @extend %move;
border-top: $move-mode-track-border;
border-bottom: $move-mode-track-border;
cursor: move;
} }
} }
} }
.playlist-add { .playlist-add,
.editor {
.editor-controls,
.playlist-add-controls { .playlist-add-controls {
background: $playlist-controls-bg; background: $playlist-controls-bg;
border-bottom: $playlist-controls-border; border-bottom: $playlist-controls-border;
@ -160,12 +169,35 @@
padding: .5rem; padding: .5rem;
} }
input[type=text] {
width: 100%;
}
button {
@extend %ctrl-button;
padding: 0 .75rem;
}
.editor-container,
.playlists-container { .playlists-container {
max-height: 70vh;
overflow: auto; overflow: auto;
margin: 0 -2rem; margin: 0 -2rem;
padding: 1rem; padding: 1rem;
} }
.playlists-container {
max-height: 70vh;
}
.editor-container {
max-height: 65vh;
.item {
&.move:hover {
@extend %move;
}
}
}
} }
.controls { .controls {
@ -355,7 +387,8 @@
} }
} }
#music-mpd-search-modal { #music-mpd-search-modal,
#music-mpd-playlist-edit {
.dropdown { .dropdown {
z-index: 503; z-index: 503;
} }
@ -367,6 +400,12 @@
} }
} }
#music-mpd-playlist-edit {
.modal {
min-width: 80rem;
}
}
@media #{map-get($widths, 's')} { @media #{map-get($widths, 's')} {
#music-mpd-info { #music-mpd-info {
.modal { .modal {

View file

@ -12,12 +12,14 @@ Vue.component('music-mpd', {
playlistFilter: '', playlistFilter: '',
browserFilter: '', browserFilter: '',
playlistAddFilter: '', playlistAddFilter: '',
editorFilter: '',
browserPath: [], browserPath: [],
browserItems: [], browserItems: [],
selectionMode: { selectionMode: {
playlist: false, playlist: false,
browser: false, browser: false,
editor: false,
}, },
moveMode: { moveMode: {
@ -28,12 +30,15 @@ Vue.component('music-mpd', {
infoItem: {}, infoItem: {},
modalVisible: { modalVisible: {
info: false, info: false,
editor: false,
playlistAdd: false, playlistAdd: false,
}, },
addToPlaylistItems: [], addToPlaylistItems: [],
selectedPlaylist: {},
selectedPlaylistItems: {}, selectedPlaylistItems: {},
selectedPlaylistAddItems: {}, selectedPlaylistAddItems: {},
selectedEditorItems: {},
selectedBrowserItems: {}, selectedBrowserItems: {},
syncTime: { syncTime: {
@ -213,7 +218,7 @@ Vue.component('music-mpd', {
icon: 'user', icon: 'user',
click: async function() { click: async function() {
await self.searchArtist(item); await self.searchArtist(item);
self.selectedPlaylistItems = {}; self.selectedBrowserItems = {};
} }
}); });
} }
@ -224,7 +229,7 @@ Vue.component('music-mpd', {
icon: 'compact-disc', icon: 'compact-disc',
click: async function() { click: async function() {
await self.searchAlbum(item); await self.searchAlbum(item);
self.selectedPlaylistItems = {}; self.selectedBrowserItems = {};
}, },
}); });
} }
@ -264,6 +269,13 @@ Vue.component('music-mpd', {
items.push({ items.push({
text: 'Edit', text: 'Edit',
icon: 'pen', icon: 'pen',
click: async function() {
const item = Object.values(self.selectedBrowserItems)[0];
self.selectedPlaylist.name = item.name;
await self.refreshSelectedPlaylist();
self.modalVisible.editor = true;
self.selectedBrowserItems = {};
},
}); });
} }
@ -297,6 +309,124 @@ Vue.component('music-mpd', {
return items; return items;
}, },
editorDropdownItems: function() {
var self = this;
var items = [];
if (Object.keys(this.selectedEditorItems).length === 1) {
const item = Object.values(this.selectedEditorItems)[0];
items.push(
{
text: 'Play',
icon: 'play',
click: async function() {
await self.add(item.file, position=0);
await self.playpos(0);
self.selectedEditorItems = {};
},
},
{
text: 'Replace and play',
icon: 'play',
click: async function() {
await self.clear();
await self.add(item.file, position=0);
await self.playpos(0);
self.selectedEditorItems = {};
},
}
);
if (item.artist && item.artist.length) {
items.push({
text: 'View artist',
icon: 'user',
click: async function() {
await self.searchArtist(item);
self.selectedEditorItems = {};
}
});
}
if (item.album && item.album.length) {
items.push({
text: 'View album',
icon: 'compact-disc',
click: async function() {
await self.searchAlbum(item);
self.selectedEditorItems = {};
},
});
}
}
items.push(
{
text: 'Add to queue',
icon: 'plus',
click: async function() {
const items = Object.values(self.selectedEditorItems);
const promises = items.map(item => self.add(item.file));
await Promise.all(promises);
self.selectedEditorItems = {};
},
},
{
text: 'Add to playlist',
icon: 'list',
click: async function() {
self.addToPlaylistItems = Object.keys(self.selectedEditorItems);
self.modalVisible.playlistAdd = true;
await self.listplaylists();
self.selectedEditorItems = {};
},
}
);
if (Object.keys(this.selectedEditorItems).length < this.selectedPlaylist.items.length) {
items.push({
text: 'Move',
icon: 'retweet',
click: function() {
self.moveMode.editor = true;
},
});
}
items.push(
{
text: 'Remove',
icon: 'trash',
click: async function() {
if (!confirm('Are you sure you want to remove the selected track' +
(Object.values(self.selectedEditorItems).length > 1 ? 's' : '') + ' from the playlist?')) {
return;
}
const items = Object.values(self.selectedEditorItems);
await self.playlistdelete(items.map(_ => _.pos));
self.selectedEditorItems = {};
},
}
);
if (Object.keys(this.selectedEditorItems).length === 1) {
const item = Object.values(self.selectedEditorItems)[0];
items.push({
text: 'View info',
icon: 'info',
click: async function() {
await self.info(item.file);
},
});
}
return items;
},
}, },
methods: { methods: {
@ -594,6 +724,14 @@ Vue.component('music-mpd', {
} }
}, },
playlistmove: async function(fromPos, toPos) {
if (!this.selectedPlaylist.name) {
return;
}
await request('music.mpd.playlistmove', {name: this.selectedPlaylist.name, from_pos: fromPos, to_pos: toPos});
},
swap: async function() { swap: async function() {
if (Object.keys(this.selectedPlaylistItems).length !== 2) { if (Object.keys(this.selectedPlaylistItems).length !== 2) {
return; return;
@ -669,6 +807,14 @@ Vue.component('music-mpd', {
} }
}, },
listplaylist: async function(name) {
return await request('music.mpd.listplaylist', {name: name});
},
listplaylistinfo: async function(name) {
return await request('music.mpd.listplaylistinfo', {name: name});
},
playlistadd: async function(items=[], playlists=[]) { playlistadd: async function(items=[], playlists=[]) {
if (!playlists.length) { if (!playlists.length) {
if (this.modalVisible.playlistAdd) { if (this.modalVisible.playlistAdd) {
@ -696,6 +842,50 @@ Vue.component('music-mpd', {
this.addToPlaylistItems = []; this.addToPlaylistItems = [];
}, },
playlistdelete: async function(items=[]) {
if (!items.length) {
items = Object.keys(this.selectedEditorItems);
}
if (!items.length || !this.selectedPlaylist.name) {
return;
}
await request('music.mpd.playlistdelete', {name: this.selectedPlaylist.name, pos: items});
await this.refreshSelectedPlaylist();
},
playlistclear: async function() {
if (!confirm('Are you sure that you want to clear this playlist? This operation is NOT REVERSIBLE')) {
return;
}
await request('music.mpd.playlistclear', {name: this.selectedPlaylist.name});
await this.refreshSelectedPlaylist();
},
rename: async function() {
if (!this.selectedPlaylist.name) {
return;
}
var newName = prompt('New name for the playlist', this.selectedPlaylist.name);
if (!newName.length) {
return;
}
await request('music.mpd.rename', {name: this.selectedPlaylist.name, new_name: newName});
await this.listplaylists();
for (var item of this.browserItems) {
if (item.type === 'playlist' && item.name === this.selectedPlaylist.name) {
item.name = newName;
}
}
this.selectedPlaylist.name = newName;
},
onNewPlayingTrack: async function(event) { onNewPlayingTrack: async function(event) {
var previousTrack = { var previousTrack = {
file: this.track.file, file: this.track.file,
@ -835,7 +1025,15 @@ Vue.component('music-mpd', {
return true; return true;
const filter = this.playlistFilter.split(' ').filter(_ => _.length > 0).map(_ => _.toLocaleLowerCase()).join(' '); const filter = this.playlistFilter.split(' ').filter(_ => _.length > 0).map(_ => _.toLocaleLowerCase()).join(' ');
return [track.artist || '', track.title || '', track.album || ''].join(' ').toLocaleLowerCase().indexOf() >= 0; return [track.artist || '', track.title || '', track.album || ''].join(' ').toLocaleLowerCase().indexOf(filter) >= 0;
},
matchesEditorFilter: function(track) {
if (this.editorFilter.length === 0)
return true;
const filter = this.editorFilter.split(' ').filter(_ => _.length > 0).map(_ => _.toLocaleLowerCase()).join(' ');
return [track.artist || '', track.title || '', track.album || ''].join(' ').toLocaleLowerCase().indexOf(filter) >= 0;
}, },
matchesBrowserFilter: function(item) { matchesBrowserFilter: function(item) {
@ -881,6 +1079,30 @@ Vue.component('music-mpd', {
} }
}, },
onEditorItemClick: async function(track) {
if (this.selectionMode.editor) {
if (track.pos in this.selectedEditorItems) {
Vue.delete(this.selectedEditorItems, track.pos);
} else {
Vue.set(this.selectedEditorItems, track.pos, track);
}
} else if (this.moveMode.editor) {
var fromPos = Object.values(this.selectedEditorItems).map(_ => _.pos);
var toPos = track.pos;
this.moveMode.editor = false;
const promises = fromPos.map((pos,i) => this.playlistmove(pos, toPos+i));
await Promise.all(promises);
await this.refreshSelectedPlaylist();
} else if (track.pos in this.selectedEditorItems) {
Vue.delete(this.selectedEditorItems, track.pos);
} else {
this.selectedEditorItems = {};
Vue.set(this.selectedEditorItems, track.pos, track);
openDropdown(this.$refs.editorDropdown.$el);
}
},
onBrowserItemClick: function(item) { onBrowserItemClick: function(item) {
if (item.type === 'directory' && item.name === '..') { if (item.type === 'directory' && item.name === '..') {
this.selectedBrowserItems = {}; this.selectedBrowserItems = {};
@ -940,6 +1162,14 @@ Vue.component('music-mpd', {
this.selectionMode.browser = !this.selectionMode.browser; this.selectionMode.browser = !this.selectionMode.browser;
}, },
toggleEditorSelectionMode: function() {
if (this.selectionMode.editor && Object.keys(this.selectedEditorItems).length) {
openDropdown(this.$refs.editorDropdown.$el);
}
this.selectionMode.editor = !this.selectionMode.editor;
},
browserSelectAll: function() { browserSelectAll: function() {
this.selectedBrowserItems = {}; this.selectedBrowserItems = {};
this.selectionMode.browser = true; this.selectionMode.browser = true;
@ -953,6 +1183,17 @@ Vue.component('music-mpd', {
openDropdown(this.$refs.browserDropdown.$el); openDropdown(this.$refs.browserDropdown.$el);
}, },
editorSelectAll: function() {
this.selectedEditorItems = {};
this.selectionMode.editor = true;
for (var item of this.selectedPlaylist.items) {
Vue.set(this.selectedEditorItems, item.pos, item);
}
openDropdown(this.$refs.editorDropdown.$el);
},
scrollToActiveTrack: function() { scrollToActiveTrack: function() {
if (this.$refs.activePlaylistTrack && this.$refs.activePlaylistTrack.length) { if (this.$refs.activePlaylistTrack && this.$refs.activePlaylistTrack.length) {
this.$refs.activePlaylistTrack[0].$el.scrollIntoView({behavior: 'smooth'}); this.$refs.activePlaylistTrack[0].$el.scrollIntoView({behavior: 'smooth'});
@ -979,7 +1220,17 @@ Vue.component('music-mpd', {
return; return;
} }
this.add(resource); await this.add(resource);
},
addToPlaylistEditorPrompt: async function() {
var resource = prompt('Path or URI of the resource to add');
if (!resource.length) {
return;
}
await request('music.mpd.playlistadd', {name: this.selectedPlaylist.name, uri: resource});
await this.refreshSelectedPlaylist();
}, },
savePlaylistPrompt: async function() { savePlaylistPrompt: async function() {
@ -994,6 +1245,18 @@ Vue.component('music-mpd', {
let items = await request('music.mpd.lsinfo', {uri: this.browserPath.join('/')}); let items = await request('music.mpd.lsinfo', {uri: this.browserPath.join('/')});
this._parseBrowserItems(items); this._parseBrowserItems(items);
}, },
refreshSelectedPlaylist: async function() {
if (!this.selectedPlaylist.name) {
return;
}
let items = (await this.listplaylistinfo(this.selectedPlaylist.name)).map((_, i) => {
return { ..._, pos: i }
});
Vue.set(this.selectedPlaylist, 'items', items);
},
}, },
created: function() { created: function() {

View file

@ -6,9 +6,7 @@
<script type="text/x-template" id="tmpl-music-mpd"> <script type="text/x-template" id="tmpl-music-mpd">
<div class="row music-mpd-container"> <div class="row music-mpd-container">
<music-mpd-search ref="search" <music-mpd-search ref="search" @info="info" :mpd="this">
@info="info"
:mpd="this">
</music-mpd-search> </music-mpd-search>
<modal id="music-mpd-info" title="Info" v-model="modalVisible.info" ref="modal"> <modal id="music-mpd-info" title="Info" v-model="modalVisible.info" ref="modal">
@ -33,9 +31,9 @@
<div class="col-8 value" v-text="infoItem.album"></div> <div class="col-8 value" v-text="infoItem.album"></div>
</div> </div>
<div class="row" v-if="infoItem.year"> <div class="row" v-if="infoItem.year || infoItem.date">
<div class="col-4 attr">Year</div> <div class="col-4 attr">Date</div>
<div class="col-8 value" v-text="infoItem.year"></div> <div class="col-8 value" v-text="infoItem.year || infoItem.date"></div>
</div> </div>
<div class="row" v-if="infoItem.disc"> <div class="row" v-if="infoItem.disc">
@ -85,6 +83,57 @@
</div> </div>
</modal> </modal>
<modal id="music-mpd-playlist-edit" title="Edit/view playlist" v-model="modalVisible.editor" ref="modalEditor">
<div class="editor">
<div class="editor-controls">
<div class="row">
<div class="col-7 filter-container">
<i class="fa fa-filter input-icon"></i>
<input type="text" class="with-icon" v-model="editorFilter">
</div>
<div class="col-5 pull-right">
<button title="Add item" @click="addToPlaylistEditorPrompt">
<i class="fa fa-plus"></i>
</button>
<button title="Rename playlist" @click="rename">
<i class="fa fa-edit"></i>
</button>
<button :class="{enabled: selectionMode.editor}"
:title="selectionMode.editor ? 'End selection' : 'Start selection'"
@click="toggleEditorSelectionMode">
<i class="fa fa-check"></i>
</button>
<button title="Select all"
@click="editorSelectAll">
<i class="fa fa-check-double"></i>
</button>
<button title="Clear playlist" @click="playlistclear">
<i class="fa fa-ban"></i>
</button>
</div>
</div>
</div>
<div class="editor-container">
<dropdown id="music-mpd-editor-dropdown"
v-if="selectedPlaylist.items"
ref="editorDropdown"
:items="editorDropdownItems">
</dropdown>
<music-mpd-playlist-item
v-for="item in selectedPlaylist.items"
v-if="matchesEditorFilter(item)"
:key="item.pos"
:track="item"
:selected="item.pos in selectedEditorItems"
:move="moveMode.editor"
@input="onEditorItemClick">
</music-mpd-playlist-item>
</div>
</div>
</modal>
<div class="row panels"> <div class="row panels">
<!-- Browser section --> <!-- Browser section -->
<div class="col-no-margin-l-3 col-no-margin-m-3 s-hidden panel browser"> <div class="col-no-margin-l-3 col-no-margin-m-3 s-hidden panel browser">

View file

@ -30,7 +30,8 @@
<div class="footer"> <div class="footer">
<div class="left col-6"> <div class="left col-6">
<button class="btn-default" v-if="results.length" @click="$event.preventDefault(); showResults = true" title="Show results"> <button class="btn-default" type="button" v-if="results.length"
@click="$event.preventDefault(); showResults = true" title="Show results">
<i class="fa fa-list"></i> <i class="fa fa-list"></i>
</button> </button>
</div> </div>

View file

@ -33,10 +33,6 @@ class MusicPlugin(Plugin):
def add(self, content): def add(self, content):
raise NotImplementedError() raise NotImplementedError()
@action
def playlistadd(self, playlist):
raise NotImplementedError()
@action @action
def clear(self): def clear(self):
raise NotImplementedError() raise NotImplementedError()

View file

@ -564,6 +564,26 @@ class MusicMpdPlugin(MusicPlugin):
return sorted(self._exec('listplaylists', return_status=False), return sorted(self._exec('listplaylists', return_status=False),
key=lambda p: p['playlist']) key=lambda p: p['playlist'])
@action
def listplaylist(self, name):
"""
List the items in the specified playlist (without metadata)
:param name: Name of the playlist
:type name: str
"""
return self._exec('listplaylist', name, return_status=False)
@action
def listplaylistinfo(self, name):
"""
List the items in the specified playlist (with metadata)
:param name: Name of the playlist
:type name: str
"""
return self._exec('listplaylistinfo', name, return_status=False)
@action @action
def playlistadd(self, name, uri): def playlistadd(self, name, uri):
""" """
@ -582,6 +602,65 @@ class MusicMpdPlugin(MusicPlugin):
for res in uri: for res in uri:
self._exec('playlistadd', name, res) self._exec('playlistadd', name, res)
@action
def playlistdelete(self, name, pos):
"""
Remove one or multiple tracks from a playlist.
:param name: Playlist name
:type name: str
:param pos: Position or list of positions to remove
:type pos: int or list[int]
"""
if isinstance(pos, str):
pos = int(pos)
if isinstance(pos, int):
pos = [pos]
for p in pos:
self._exec('playlistdelete', name, p)
@action
def playlistmove(self, name, from_pos, to_pos):
"""
Change the position of a track in the specified playlist
:param name: Playlist name
:type name: str
:param from_pos: Original track position
:type from_pos: int
:param to_pos: New track position
:type to_pos: int
"""
self._exec('playlistmove', name, from_pos, to_pos)
@action
def playlistclear(self, name):
"""
Clears all the elements from the specified playlist
:param name: Playlist name
:type name: str
"""
self._exec('playlistclear', name)
@action
def rename(self, name, new_name):
"""
Rename a playlist
:param name: Original playlist name
:type name: str
:param new_name: New playlist name
:type name: str
"""
self._exec('rename', name, new_name)
@action @action
def lsinfo(self, uri=None): def lsinfo(self, uri=None):
""" """