Implemented support for modals and music.mpd search and item info

This commit is contained in:
Fabio Manganiello 2019-06-06 02:08:36 +02:00
parent 611a137ff6
commit 7df0cec14e
18 changed files with 692 additions and 156 deletions

View file

@ -8,6 +8,8 @@
}
input[type=text] {
border-radius: 5rem;
&:hover {
border: $border-hover;
}

View file

@ -1,36 +1,40 @@
.modal {
display: none;
.modal-container {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 999;
background-color: rgba(10,10,10,0.85);
}
z-index: var(--z-index);
background: rgba(10,10,10,0.9);
.modal-container {
margin: 5% auto auto auto;
width: 70%;
background: white;
border-radius: 10px;
}
.modal {
--width: auto;
--height: auto;
width: var(--width);
height: var(--height);
.modal-header {
border-bottom: 1px solid #ccc;
margin: 0.5rem auto;
padding: 0.5rem;
div:first-child { border-radius: 1rem 1rem 0 0; }
div:last-child { border-radius: 0 0 1rem 1rem; }
.header {
border-bottom: $modal-header-border;
padding: .5rem;
text-align: center;
background-color: $modal-bg;
border-radius: 10px 10px 0 0;
background: $modal-header-bg;
text-transform: uppercase;
letter-spacing: .1rem;
line-height: 38px;
line-height: 3.8rem;
}
.modal-body {
.body {
max-height: 75vh;
overflow: auto;
padding: 2.5rem 2rem 1.5rem 2rem;
background: $modal-body-bg;
}
}
}

View file

@ -23,8 +23,6 @@ $nav-bg: #e8e8e8 !default;
$nav-fg: $default-link-fg;
$nav-date-time-shadow: 2px 2px 2px #ccc !default;
$modal-bg: #f0f0f0 !default;
//// Animations defaults
$transition-duration: .5s !default;
$fade-transition-duration: .5s !default;
@ -88,5 +86,10 @@ $header-bottom: $default-bottom;
//// Dropdown element
$dropdown-bg: rgba(241,243,242,0.9) !default;
$dropdown-shadow: 1px 1px 1px #bbb;
$dropdown-shadow: 1px 1px 1px #bbb !default;
//// Modal element
$modal-header-bg: #f0f0f0 !default;
$modal-header-border: 1px solid #ccc !default;
$modal-body-bg: white !default;

View file

@ -49,13 +49,14 @@
}
}
.panels {
display: flex;
.spacer {
height: 5rem;
}
.panels {
display: flex;
}
.browser, .playlist {
overflow: auto;
}
@ -76,11 +77,13 @@
}
.browser,
.search,
.playlist {
position: relative; // For the dropdown menu
padding: .5rem 1rem 6rem 1rem;
.browser-controls,
.results-controls,
.playlist-controls {
position: fixed;
height: 5rem;
@ -91,7 +94,6 @@
input[type=text] {
width: 100%;
border-radius: 5rem;
}
* > button {
@ -141,7 +143,6 @@
}
}
}
}
.controls {
@extend .vertical-center;
@ -237,12 +238,114 @@
margin-left: 1.5rem;
}
}
.search {
--width: 90vw;
position: relative;
padding: 0;
form {
margin-bottom: 0;
.row {
padding: .5rem;
}
.footer {
padding-top: 1.5rem;
margin-top: 1.5rem;
border-top: $search-modal-footer-border;
.left {
display: flex;
justify-content: left;
}
}
button, input[type=submit] {
border-radius: 5rem;
}
}
.dropdown {
z-index: 503;
}
.results-controls {
position: fixed;
padding: 0;
margin: -2.45rem auto 0 -2rem;
border-bottom: $default-border-2;
width: var(--width);
height: 3em;
display: flex;
align-items: center;
z-index: 502;
}
.results {
padding-top: 2.7rem;
}
form, .results {
position: relative;
}
}
}
.dropdown {
width: 20rem;
}
#music-mpd-info {
.modal {
.body {
.row {
margin: .5rem;
padding: .5rem;
border-bottom: $info-modal-row-border;
&:hover {
border-radius: 1rem;
background: $hover-bg;
}
.attr {
color: $info-modal-attr-color;
}
.value {
text-align: right;
}
}
}
}
}
@media #{map-get($widths, 's')} {
#music-mpd-info {
.modal {
width: 80vw;
}
}
}
@media #{map-get($widths, 'm')} {
#music-mpd-info {
.modal {
width: 70vw;
}
}
}
@media #{map-get($widths, 'l')} {
#music-mpd-info {
.modal {
width: 45vw;
}
}
}
@keyframes active-track {
0% { background: $active-track-bg-1; }
50% { background: $active-track-bg-2; }
@ -260,3 +363,4 @@
50% { background: $active-track-bg-2; }
100% { background: $active-track-bg-1; }
}

View file

@ -26,3 +26,8 @@ $active-track-bg-2: #9cdfb0;
$move-mode-track-border: 3px dotted rgb(216,156,136);
$move-mode-track-bg: rgba(216,156,136,0.3);
$search-modal-footer-border: 1px solid #ccc;
$info-modal-row-border: 1px solid #ddd;
$info-modal-attr-color: #777;

View file

@ -0,0 +1,15 @@
var parseElement = function(element) {
if (element instanceof Object) {
if (element.$el) {
element = element.$el;
}
} else if (element instanceof String || typeof(element) === 'string') {
element = document.getElementById(element);
} else {
console.error('Got unexpected type ' + typeof(element) + ' for DOM element');
return;
}
return element;
};

View file

@ -29,21 +29,6 @@ Vue.component('dropdown', {
var openedDropdown;
var _parseElement = function(element) {
if (element instanceof Object) {
if (element.$el) {
element = element.$el;
}
} else if (element instanceof String || typeof(element) === 'string') {
element = document.getElementById(element);
} else {
console.error('Got unexpected type ' + typeof(element) + ' for dropdown element');
return;
}
return element;
};
var clickHndl = function(event) {
if (!openedDropdown) {
return;
@ -53,7 +38,7 @@ var clickHndl = function(event) {
while (element) {
if (element == openedDropdown) {
return; // TODO dropdown click
return;
}
element = element.parentElement;
@ -78,7 +63,7 @@ function closeDropdown() {
}
function openDropdown(element) {
element = _parseElement(element);
element = parseElement(element);
if (!element) {
console.error('Invalid dropdown element');
return;

View file

@ -0,0 +1,84 @@
Vue.component('modal', {
template: '#tmpl-modal',
props: {
id: {
type: String,
},
title: {
type: String,
},
width: {
type: [Number, String],
},
height: {
type: [Number, String],
},
// Modal visibility value
value: {
type: Boolean,
default: false,
},
timeout: {
type: [Number, String],
},
level: {
type: Number,
default: 1,
},
},
data: function() {
return {
timeoutId: undefined,
prevValue: this.value,
};
},
computed: {
zIndex: function() {
return 500 + this.level;
},
},
methods: {
modalClicked: function(event) {
// Close any opened dropdowns before stopping the click propagation
const dropdowns = this.$el.querySelectorAll('.dropdown:not(.hidden)');
for (const dropdown of dropdowns) {
closeDropdown(dropdown);
}
event.stopPropagation();
},
modalClose: function() {
event.stopPropagation();
this.$emit('input', false);
},
},
updated: function() {
if (this.value != this.prevValue) {
this.$emit((this.value ? 'open' : 'close'), this);
this.prevValue = this.value;
}
if (this.value && this.timeout && !this.timeoutId) {
var handler = (self) => {
return () => {
self.modalClose();
self.timeoutId = undefined;
};
};
this.timeoutId = setTimeout(handler(this), 0+this.timeout);
}
},
});

View file

@ -1,6 +1,7 @@
Vue.component('music-mpd', {
template: '#tmpl-music-mpd',
props: ['config'],
mixins: [utils],
data: function() {
return {
track: {},
@ -22,6 +23,11 @@ Vue.component('music-mpd', {
editor: false,
},
infoItem: {},
modalVisible: {
info: false,
},
selectedPlaylistItems: {},
selectedBrowserItems: {},
@ -78,6 +84,10 @@ Vue.component('music-mpd', {
items.push({
text: 'View track info',
icon: 'info',
click: async function() {
self.infoItem = Object.values(self.selectedPlaylistItems)[0];
self.modalVisible.info = true;
},
});
}
@ -103,7 +113,7 @@ Vue.component('music-mpd', {
if (Object.keys(this.selectedBrowserItems).length === 1) {
items.push(
{
text: 'Add and play',
text: 'Play',
icon: 'play',
click: async function() {
const item = Object.values(self.selectedBrowserItems)[0];
@ -340,28 +350,6 @@ Vue.component('music-mpd', {
}
},
convertTime: function(time) {
time = parseFloat(time); // Normalize strings
var t = {};
t.h = '' + parseInt(time/3600);
t.m = '' + parseInt(time/60 - t.h*60);
t.s = '' + parseInt(time - t.m*60);
for (var attr of ['m','s']) {
if (parseInt(t[attr]) < 10) {
t[attr] = '0' + t[attr];
}
}
var ret = [];
if (parseInt(t.h)) {
ret.push(t.h);
}
ret.push(t.m, t.s);
return ret.join(':');
},
previous: async function() {
await request('music.mpd.previous');
let track = await request('music.mpd.currentsong');
@ -697,18 +685,16 @@ Vue.component('music-mpd', {
if (this.playlistFilter.length === 0)
return true;
return [track.artist || '', track.title || '', track.album || '']
.join(' ').toLocaleLowerCase().indexOf(
this.playlistFilter.split(' ').filter(_ => _.length > 0).map(_ => _.toLocaleLowerCase()).join(' ')
) >= 0;
const filter = this.playlistFilter.split(' ').filter(_ => _.length > 0).map(_ => _.toLocaleLowerCase()).join(' ');
return [track.artist || '', track.title || '', track.album || ''].join(' ').toLocaleLowerCase().indexOf() >= 0;
},
matchesBrowserFilter: function(item) {
if (this.browserFilter.length === 0)
return true;
return item.name.toLocaleLowerCase().indexOf(
this.browserFilter.toLocaleLowerCase().split(' ').filter(_ => _.length > 0).join(' ')) >= 0;
const filter = this.browserFilter.toLocaleLowerCase().split(' ').filter(_ => _.length > 0).join(' ');
return item.name.toLocaleLowerCase().indexOf(filter) >= 0;
},
onPlaylistItemClick: async function(track) {

View file

@ -1,5 +1,6 @@
Vue.component('music-mpd-playlist-item', {
template: '#tmpl-music-mpd-playlist-item',
mixins: [utils],
props: {
track: {
type: Object,

View file

@ -0,0 +1,161 @@
Vue.component('music-mpd-search', {
template: '#tmpl-music-mpd-search',
props: ['mpd'],
data: function() {
return {
visible: false,
showResults: false,
results: false,
filter: '',
selectionMode: false,
selectedItems: {},
query: {
any: '',
artist: '',
title: '',
album: '',
},
};
},
computed: {
dropdownItems: function() {
var self = this;
var items = [];
if (Object.keys(this.selectedItems).length === 1) {
items.push(
{
text: 'Play',
icon: 'play',
click: async function() {
const item = Object.values(self.selectedItems)[0];
await self.mpd.add(item.file, position=0);
await self.mpd.playpos(0);
self.selectedItems = {};
},
},
{
text: 'Replace and play',
icon: 'play',
click: async function() {
await self.mpd.clear();
const item = Object.values(self.selectedItems)[0];
await self.mpd.add(item.file, position=0);
await self.mpd.playpos(0);
self.selectedItems = {};
},
}
);
}
items.push(
{
text: 'Add to queue',
icon: 'plus',
click: async function() {
const items = Object.values(self.selectedItems);
const promises = items.map(item => self.mpd.add(item.file));
await Promise.all(promises);
self.selectedItems = {};
},
},
);
if (Object.keys(this.selectedItems).length === 1) {
items.push({
text: 'View info',
icon: 'info',
});
}
return items;
},
},
methods: {
search: async function() {
const filter = Object.keys(this.query).reduce((items, key) => {
if (this.query[key].length) {
items.push(key, this.query[key]);
}
return items;
}, []);
var results = await request('music.mpd.search', {filter: filter});
this.results = results.sort((a,b) => {
const tokenize = (t) => {
return ''.concat(t.artist || '', '-', t.album || '', '-', t.disc || '', '-', t.track || '', t.title || '').toLocaleLowerCase();
};
return tokenize(a).localeCompare(tokenize(b));
});
this.showResults = true;
},
matchesFilter: function(item) {
if (this.filter.length === 0)
return true;
const filter = this.filter.split(' ').filter(_ => _.length > 0).map(_ => _.toLocaleLowerCase()).join(' ');
return [item.file || '', item.artist || '', item.title || '', item.album || ''].join(' ').toLocaleLowerCase().indexOf(filter) >= 0;
},
onItemClick: function(item) {
if (this.selectionMode) {
if (item.file in this.selectedItems) {
Vue.delete(this.selectedItems, item.file);
} else {
Vue.set(this.selectedItems, item.file, item);
}
} else if (item.file in this.selectedItems) {
Vue.delete(this.selectedItems, item.file);
} else {
this.selectedItems = {};
Vue.set(this.selectedItems, item.file, item);
openDropdown(this.$refs.dropdown.$el);
}
},
toggleSelectionMode: function() {
if (this.selectionMode && Object.keys(this.selectedItems).length) {
openDropdown(this.$refs.dropdown.$el);
}
this.selectionMode = !this.selectionMode;
},
selectAll: function() {
this.selectedItems = {};
this.selectionMode = true;
for (var item of this.results) {
this.selectedItems[item.id] = item;
}
openDropdown(this.$refs.dropdown.$el);
},
},
});
Vue.component('music-mpd-search-item', {
template: '#tmpl-music-mpd-search-item',
mixins: [utils],
props: {
item: {
type: Object,
default: {},
},
selected: {
type: Boolean,
default: false,
},
},
});

View file

@ -0,0 +1,26 @@
var utils = {
methods: {
convertTime: function(time) {
time = parseFloat(time); // Normalize strings
var t = {};
t.h = '' + parseInt(time/3600);
t.m = '' + parseInt(time/60 - t.h*60);
t.s = '' + parseInt(time - t.m*60);
for (var attr of ['m','s']) {
if (parseInt(t[attr]) < 10) {
t[attr] = '0' + t[attr];
}
}
var ret = [];
if (parseInt(t.h)) {
ret.push(t.h);
}
ret.push(t.m, t.s);
return ret.join(':');
},
},
};

View file

@ -1,4 +1,6 @@
{% include 'elements/common.html' %}
{% include 'elements/switch.html' %}
{% include 'elements/range-slider.html' %}
{% include 'elements/dropdown.html' %}
{% include 'elements/modal.html' %}

View file

@ -0,0 +1,2 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/elements/common.js') }}"></script>

View file

@ -0,0 +1,13 @@
<script type="text/x-template" id="tmpl-modal">
<div class="modal-container fade-in" :id="id" :class="{hidden: !value}" :style="{'--z-index': zIndex}" @click="modalClose">
<div class="modal" :style="{'--width':width, '--height':height}">
<div class="header" v-text="title" v-if="title" @click="modalClicked"></div>
<div class="body" @click="modalClicked">
<slot></slot>
</div>
</div>
</div>
</script>
<script type="application/javascript" src="{{ url_for('static', filename='js/elements/modal.js') }}"></script>

View file

@ -1,8 +1,52 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/music.mpd/utils.js') }}"></script>
{% include 'plugins/music.mpd/browser.html' %}
{% include 'plugins/music.mpd/playlist.html' %}
{% include 'plugins/music.mpd/search.html' %}
<script type="text/x-template" id="tmpl-music-mpd">
<div class="row music-mpd-container">
<music-mpd-search ref="search" :mpd="this"></music-mpd-search>
<modal id="music-mpd-info" title="Info" v-model="modalVisible.info" ref="modal">
<div class="info-container">
<div class="row">
<div class="col-4 attr">File</div>
<div class="col-8 value" v-text="infoItem.file"></div>
</div>
<div class="row" v-if="infoItem.artist">
<div class="col-4 attr">Artist</div>
<div class="col-8 value" v-text="infoItem.artist"></div>
</div>
<div class="row" v-if="infoItem.title">
<div class="col-4 attr">Title</div>
<div class="col-8 value" v-text="infoItem.title"></div>
</div>
<div class="row" v-if="infoItem.album">
<div class="col-4 attr">Album</div>
<div class="col-8 value" v-text="infoItem.album"></div>
</div>
<div class="row" v-if="infoItem.disc">
<div class="col-4 attr">Disc</div>
<div class="col-8 value" v-text="infoItem.disc"></div>
</div>
<div class="row" v-if="infoItem.track">
<div class="col-4 attr">Track</div>
<div class="col-8 value" v-text="infoItem.track"></div>
</div>
<div class="row" v-if="infoItem.time && infoItem.time > 0">
<div class="col-4 attr">Time</div>
<div class="col-8 value" v-text="convertTime(infoItem.time)"></div>
</div>
</div>
</modal>
<div class="row panels">
<!-- Browser section -->
<div class="col-no-margin-l-3 col-no-margin-m-3 s-hidden panel browser">
@ -65,17 +109,13 @@
<!-- Playlist section -->
<div class="col-s-12 col-no-margin-m-9 col-no-margin-l-9 panel playlist">
<div class="row empty" v-if="playlist.length === 0">
<i class="fa fa-list"></i>
</div>
<div class="col-s-12 col-m-9 col-l-9 playlist-controls" v-else>
<div class="col-s-12 col-m-9 col-l-9 playlist-controls">
<div class="col-7 filter-container">
<i class="fa fa-filter input-icon"></i>
<input type="text" class="with-icon" v-model="playlistFilter">
</div>
<div class="col-5 buttons pull-right">
<button title="Search">
<button title="Search" @click="$refs.search.visible = true">
<i class="fa fa-search"></i>
</button>
<button title="Add item" @click="addToPlaylistPrompt">
@ -105,6 +145,10 @@
</div>
</div>
<div class="row empty" v-if="playlist.length === 0">
<i class="fa fa-list"></i>
</div>
<div class="spacer"></div>
<dropdown id="music-mpd-playlist-dropdown"

View file

@ -4,9 +4,11 @@
<div class="row item playlist-item"
:class="{selected: selected, active: active, move: move}"
@click="$emit('input', track)">
<div class="col-5 artist" v-text="track.artist"></div>
<div class="col-5 title" v-text="track.title"></div>
<div class="col-2 pull-right duration" v-text="$parent.convertTime(track.time)"></div>
<div class="col-5 artist" v-text="track.artist" v-if="track.artist"></div>
<div class="col-5 artist empty" v-else>[No Artist]</div>
<div class="col-5 title" v-text="track.title || track.file"></div>
<div class="col-2 pull-right duration" v-text="convertTime(track.time)"></div>
</div>
</script>

View file

@ -0,0 +1,97 @@
<script type="application/javascript" src="{{ url_for('static', filename='js/plugins/music.mpd/search.js') }}"></script>
<script type="text/x-template" id="tmpl-music-mpd-search">
<modal id="music-mpd-search-modal" title="Search" v-model="visible"
:width="showResults ? '90vw' : 'initial'" ref="modal"
@open="$refs.form.querySelector('input[type=text]:first-child').focus()">
<div class="search">
<form ref="form" @submit.prevent="search" :class="{hidden: showResults}">
<div class="row">
<input type="text" v-model.lazy.trim="query.any" placeholder="Free-text search">
</div>
<div class="row">
<input type="text" v-model.lazy.trim="query.artist" placeholder="Artist">
</div>
<div class="row">
<input type="text" v-model.lazy.trim="query.title" placeholder="Title">
</div>
<div class="row">
<input type="text" v-model.lazy.trim="query.album" placeholder="Album">
</div>
<div class="footer">
<div class="left col-6">
<button class="btn-default" v-if="results.length" @click="showResults = true" title="Show results">
<i class="fa fa-list"></i>
</button>
</div>
<div class="pull-right" :class="{'col-6': results.length > 0, 'col-12': results.length == 0}">
<button class="btn-primary" type="submit">
<i class="fa fa-search"></i>
</button>
</div>
</div>
</form>
<div class="results-controls" :class="{hidden: !showResults}">
<div class="col-6">
<i class="fa fa-filter input-icon"></i>
<input type="text" class="with-icon" v-model="filter">
</div>
<div class="col-6 pull-right">
<button :class="{enabled: selectionMode}"
:title="selectionMode ? 'End selection' : 'Start selection'"
v-if="results.length > 0"
@click="toggleSelectionMode">
<i class="fa fa-check"></i>
</button>
<button title="Select all"
v-if="results.length > 0"
@click="selectAll">
<i class="fa fa-check-double"></i>
</button>
<button class="btn-default" @click="showResults = false" title="Show search form">
<i class="fa fa-search"></i>
</button>
</div>
</div>
<dropdown id="music-mpd-search-dropdown"
v-if="results.length > 0"
ref="dropdown"
:items="dropdownItems">
</dropdown>
<div class="results" :class="{hidden: !showResults}">
<div class="no-results" v-if="results.length === 0">No results</div>
<div v-else>
<music-mpd-search-item
v-for="item in results"
v-if="matchesFilter(item)"
:key="item.file"
:item="item"
:selected="item.file in selectedItems"
@input="onItemClick">
</music-mpd-search-item>
</div>
</div>
</div>
</modal>
</script>
<script type="text/x-template" id="tmpl-music-mpd-search-item">
<div class="row item search-item"
:class="{selected: selected}"
@click="$emit('input', item)">
<div class="col-3 artist" v-text="item.artist" v-if="item.artist"></div>
<div class="col-3 artist empty" v-else>[No Artist]</div>
<div class="col-4 title" v-text="item.title || item.file"></div>
<div class="col-3 album" v-text="item.album" v-if="item.album"></div>
<div class="col-3 album empty" v-else>-</div>
<div class="col-2 pull-right duration" v-text="item.time && item.time != 0 ? convertTime(item.time) : '-:--'"></div>
</div>
</script>