music.mpd panel migration - WIP

This commit is contained in:
Fabio Manganiello 2020-12-26 15:03:12 +01:00
parent bc3e0b8634
commit b4fc734a15
66 changed files with 1642 additions and 128 deletions

View file

@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-24ff873d.5ef32028.css" rel="prefetch"><link href="/static/css/chunk-35b45d59.bea59a51.css" rel="prefetch"><link href="/static/css/chunk-45939517.1062df39.css" rel="prefetch"><link href="/static/css/chunk-4bbbb9a3.431b3300.css" rel="prefetch"><link href="/static/css/chunk-53360c78.2281ab32.css" rel="prefetch"><link href="/static/css/chunk-62a3d08e.495b15c5.css" rel="prefetch"><link href="/static/css/chunk-6fa461b6.1fb05517.css" rel="prefetch"><link href="/static/css/chunk-e8078048.6c400707.css" rel="prefetch"><link href="/static/js/chunk-24ff873d.f955ad3b.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.8d32d3ba.js" rel="prefetch"><link href="/static/js/chunk-35b45d59.d541d43f.js" rel="prefetch"><link href="/static/js/chunk-45939517.38162e50.js" rel="prefetch"><link href="/static/js/chunk-4bbbb9a3.6f0e4975.js" rel="prefetch"><link href="/static/js/chunk-53360c78.54e2e626.js" rel="prefetch"><link href="/static/js/chunk-62a3d08e.8fc4fd3a.js" rel="prefetch"><link href="/static/js/chunk-6fa461b6.d6fffa0d.js" rel="prefetch"><link href="/static/js/chunk-e8078048.e668de5f.js" rel="prefetch"><link href="/static/css/app.5408c936.css" rel="preload" as="style"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="preload" as="style"><link href="/static/js/app.41ee3441.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.1eac7b43.js" rel="preload" as="script"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="stylesheet"><link href="/static/css/app.5408c936.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.1eac7b43.js"></script><script src="/static/js/app.41ee3441.js"></script></body></html> <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>platypush</title><link href="/static/css/chunk-24ff873d.64d9bc0b.css" rel="prefetch"><link href="/static/css/chunk-35b45d59.0c4a18da.css" rel="prefetch"><link href="/static/css/chunk-37df3df6.fe6f1cbc.css" rel="prefetch"><link href="/static/css/chunk-3d60f62e.2026dd4f.css" rel="prefetch"><link href="/static/css/chunk-45939517.e4a1ddf3.css" rel="prefetch"><link href="/static/css/chunk-4bbbb9a3.3108d379.css" rel="prefetch"><link href="/static/css/chunk-53360c78.c486a396.css" rel="prefetch"><link href="/static/css/chunk-545459d0.009b6a70.css" rel="prefetch"><link href="/static/css/chunk-62a3d08e.6cb54f10.css" rel="prefetch"><link href="/static/css/chunk-d8561e02.b52f89a0.css" rel="prefetch"><link href="/static/css/chunk-e8078048.c6785c78.css" rel="prefetch"><link href="/static/js/chunk-24ff873d.f955ad3b.js" rel="prefetch"><link href="/static/js/chunk-2d2091df.b017f6d4.js" rel="prefetch"><link href="/static/js/chunk-2d21da1a.867fde19.js" rel="prefetch"><link href="/static/js/chunk-35b45d59.d541d43f.js" rel="prefetch"><link href="/static/js/chunk-37df3df6.8441b420.js" rel="prefetch"><link href="/static/js/chunk-3d60f62e.8cc48f2d.js" rel="prefetch"><link href="/static/js/chunk-45939517.38162e50.js" rel="prefetch"><link href="/static/js/chunk-4bbbb9a3.6f0e4975.js" rel="prefetch"><link href="/static/js/chunk-53360c78.54e2e626.js" rel="prefetch"><link href="/static/js/chunk-545459d0.aa1e42a3.js" rel="prefetch"><link href="/static/js/chunk-62a3d08e.8fc4fd3a.js" rel="prefetch"><link href="/static/js/chunk-d8561e02.78e44394.js" rel="prefetch"><link href="/static/js/chunk-e8078048.e668de5f.js" rel="prefetch"><link href="/static/css/app.e87398b0.css" rel="preload" as="style"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="preload" as="style"><link href="/static/js/app.4ae7a178.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.63de75fe.js" rel="preload" as="script"><link href="/static/css/chunk-vendors.5dad8b00.css" rel="stylesheet"><link href="/static/css/app.e87398b0.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.63de75fe.js"></script><script src="/static/js/app.4ae7a178.js"></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-545459d0"],{8285:function(e,n,u){"use strict";var t=u("7a23"),o=Object(t["J"])("data-v-12a0983b"),i=o((function(e,n,u,o,i,a){return Object(t["r"])(),Object(t["e"])("label",null,[Object(t["h"])("input",{class:"slider",type:"range",min:u.range[0],max:u.range[1],value:u.value,disabled:u.disabled,onChange:n[1]||(n[1]=function(n){return e.$emit("input",n)}),onMouseup:n[2]||(n[2]=function(n){return e.$emit("mouseup",n)}),onInput:n[3]||(n[3]=function(n){return e.$emit("input",n)}),onMousedown:n[4]||(n[4]=function(n){return e.$emit("mousedown",n)}),onTouch:n[5]||(n[5]=function(n){return e.$emit("input",n)}),onTouchstart:n[6]||(n[6]=function(n){return e.$emit("mousedown",n)}),onTouchend:n[7]||(n[7]=function(n){return e.$emit("mouseup",n)})},null,40,["min","max","value","disabled"])])})),a=(u("a9e3"),{name:"Slider",emits:["input","mouseup","mousedown"],props:{value:{type:Number},disabled:{type:Boolean,default:!1},range:{type:Array,default:function(){return[0,100]}}}});u("ee52");a.render=i,a.__scopeId="data-v-12a0983b";n["a"]=a},e1773:function(e,n,u){},ee52:function(e,n,u){"use strict";u("e1773")}}]);
//# sourceMappingURL=chunk-545459d0.aa1e42a3.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/components/elements/Slider.vue","webpack:///./src/components/elements/Slider.vue?7dba","webpack:///./src/components/elements/Slider.vue?5806"],"names":["class","type","min","range","max","value","disabled","$emit","$event","name","emits","props","Number","Boolean","default","Array","render","__scopeId"],"mappings":"uNACE,eAKQ,cAJN,eAGqF,SAH9EA,MAAM,SAASC,KAAK,QAASC,IAAK,EAAAC,MAAK,GAAMC,IAAK,EAAAD,MAAK,GAAME,MAAO,EAAAA,MAAQC,SAAU,EAAAA,SACrF,SAAM,+BAAE,EAAAC,MAAK,QAAUC,KAAU,UAAO,+BAAE,EAAAD,MAAK,UAAYC,KAAU,QAAK,+BAAE,EAAAD,MAAK,QAAUC,KAC3F,YAAS,+BAAE,EAAAD,MAAK,YAAcC,KAAU,QAAK,+BAAE,EAAAD,MAAK,QAAUC,KAC9D,aAAU,+BAAE,EAAAD,MAAK,YAAcC,KAAU,WAAQ,+BAAE,EAAAD,MAAK,UAAYC,M,+CAKjE,G,UAAA,CACbC,KAAM,SACNC,MAAO,CAAC,QAAS,UAAW,aAC5BC,MAAO,CACLN,MAAO,CACLJ,KAAMW,QAGRN,SAAU,CACRL,KAAMY,QACNC,SAAS,GAGXX,MAAO,CACLF,KAAMc,MACND,QAAS,iBAAM,CAAC,EAAG,U,UCpBzB,EAAOE,OAAS,EAChB,EAAOC,UAAY,kBAEJ,U,0DCRf","file":"static/js/chunk-545459d0.aa1e42a3.js","sourcesContent":["<template>\n <label>\n <input class=\"slider\" type=\"range\" :min=\"range[0]\" :max=\"range[1]\" :value=\"value\" :disabled=\"disabled\"\n @change=\"$emit('input', $event)\" @mouseup=\"$emit('mouseup', $event)\" @input=\"$emit('input', $event)\"\n @mousedown=\"$emit('mousedown', $event)\" @touch=\"$emit('input', $event)\"\n @touchstart=\"$emit('mousedown', $event)\" @touchend=\"$emit('mouseup', $event)\">\n </label>\n</template>\n\n<script>\nexport default {\n name: \"Slider\",\n emits: ['input', 'mouseup', 'mousedown'],\n props: {\n value: {\n type: Number,\n },\n\n disabled: {\n type: Boolean,\n default: false,\n },\n\n range: {\n type: Array,\n default: () => [0, 100],\n },\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.slider {\n @include appearance(none);\n @include transition(opacity .2s);\n width: 100%;\n height: 1em;\n border-radius: 0.33em;\n background: $slider-bg;\n outline: none;\n\n @mixin slider-thumb {\n @include appearance(none);\n width: 1.5em;\n height: 1.5em;\n border-radius: 50%;\n border: 0;\n background: $slider-thumb-bg;\n cursor: pointer;\n }\n\n &::-webkit-slider-thumb { @include slider-thumb; }\n &::-moz-range-thumb { @include slider-thumb; }\n &::-moz-range-track { @include appearance(none); }\n\n &::-webkit-progress-value,\n &::-moz-range-progress {\n background: $slider-progress-bg;\n height: 1em;\n }\n\n &[disabled] {\n &::-webkit-progress-value,\n &::-moz-range-progress {\n background: none;\n }\n\n &::-webkit-slider-thumb,\n &::-moz-range-thumb {\n display: none;\n width: 0;\n }\n }\n}\n</style>","import { render } from \"./Slider.vue?vue&type=template&id=12a0983b&scoped=true&bindings={\\\"value\\\":\\\"props\\\",\\\"disabled\\\":\\\"props\\\",\\\"range\\\":\\\"props\\\"}\"\nimport script from \"./Slider.vue?vue&type=script&lang=js\"\nexport * from \"./Slider.vue?vue&type=script&lang=js\"\n\nimport \"./Slider.vue?vue&type=style&index=0&id=12a0983b&lang=scss&scoped=true\"\nscript.render = render\nscript.__scopeId = \"data-v-12a0983b\"\n\nexport default script","export * from \"-!../../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--8-oneOf-1-0!../../../node_modules/css-loader/dist/cjs.js??ref--8-oneOf-1-1!../../../node_modules/vue-loader-v16/dist/stylePostLoader.js!../../../node_modules/postcss-loader/src/index.js??ref--8-oneOf-1-2!../../../node_modules/sass-loader/dist/cjs.js??ref--8-oneOf-1-3!../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../node_modules/vue-loader-v16/dist/index.js??ref--0-1!./Slider.vue?vue&type=style&index=0&id=12a0983b&lang=scss&scoped=true\""],"sourceRoot":""}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-d8561e02"],{"49c1":function(e,t,n){},"9e1b":function(e,t,n){"use strict";n("49c1")},dabe:function(e,t,n){"use strict";n.r(t);var i=n("7a23"),c=Object(i["J"])("data-v-3565b88b");Object(i["u"])("data-v-3565b88b");var o={class:"plugin"};Object(i["s"])();var a=c((function(e,t,n,c,a,r){var s=Object(i["z"])("Loading");return Object(i["r"])(),Object(i["e"])("div",o,[a.loading?(Object(i["r"])(),Object(i["e"])(s,{key:0})):a.component?(Object(i["r"])(),Object(i["e"])(Object(i["A"])(a.component),{key:1,config:a.config},null,8,["config"])):Object(i["f"])("",!0)])})),r=(n("a15b"),n("d81d"),n("fb6a"),n("d3b7"),n("ac1f"),n("1276"),n("96cf"),n("1da1")),s=n("3e54"),u=n("3a5e"),p={name:"Plugin",components:{Loading:u["a"]},mixins:[s["a"]],props:{pluginName:{type:String,required:!0}},data:function(){return{loading:!1,component:null,config:{}}},computed:{componentName:function(){return this.pluginName.split(".").map((function(e){return e[0].toUpperCase()+e.slice(1)})).join("")}},methods:{refresh:function(){var e=Object(r["a"])(regeneratorRuntime.mark((function e(){var t,c=this;return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:return this.loading=!0,e.prev=1,this.component=Object(i["i"])((function(){return n("0f0c")("./".concat(c.componentName,"/Index"))})),this.$options.components[this.componentName]=this.component,e.next=6,this.request("config.get_plugins");case 6:if(e.t2=t=e.sent,e.t1=null===e.t2,e.t1){e.next=10;break}e.t1=void 0===t;case 10:if(!e.t1){e.next=14;break}e.t3=void 0,e.next=15;break;case 14:e.t3=t[this.pluginName];case 15:if(e.t0=e.t3,e.t0){e.next=18;break}e.t0={};case 18:this.config=e.t0;case 19:return e.prev=19,this.loading=!1,e.finish(19);case 22:case"end":return e.stop()}}),e,this,[[1,,19,22]])})));function t(){return e.apply(this,arguments)}return t}()},mounted:function(){this.refresh()}};n("9e1b");p.render=a,p.__scopeId="data-v-3565b88b";t["default"]=p}}]);
//# sourceMappingURL=chunk-d8561e02.78e44394.js.map

View file

@ -0,0 +1 @@
{"version":3,"sources":["webpack:///./src/components/widgets/Plugin/Index.vue?bbf0","webpack:///./src/components/widgets/Plugin/Index.vue","webpack:///./src/components/widgets/Plugin/Index.vue?fc8a"],"names":["class","loading","component","config","name","components","Loading","mixins","Utils","props","pluginName","type","String","required","data","computed","componentName","this","split","map","t","toUpperCase","slice","join","methods","refresh","$options","request","mounted","render","__scopeId"],"mappings":"2IAAA,W,sICCOA,MAAM,U,wGAAX,eAGM,MAHN,EAGM,CAFW,EAAAC,S,iBAAf,eAA0B,YAC6B,EAAAC,W,iBAAvD,eAAoE,eAApD,EAAAA,WAAS,C,MAAGC,OAAQ,EAAAA,Q,2JASzB,GACbC,KAAM,SACNC,WAAY,CAACC,UAAA,MACbC,OAAQ,CAACC,EAAA,MACTC,MAAO,CAELC,WAAY,CACVC,KAAMC,OACNC,UAAU,IAIdC,KAZa,WAaX,MAAO,CACLb,SAAS,EACTC,UAAW,KACXC,OAAQ,KAIZY,SAAU,CACRC,cADQ,WAEN,OAAOC,KAAKP,WAAWQ,MAAM,KAAKC,KAAI,SAACC,GAAD,OAAOA,EAAE,GAAGC,cAAgBD,EAAEE,MAAM,MAAIC,KAAK,MAIvFC,QAAS,CACPC,QAAS,WAAF,8CAAE,kHACPR,KAAKhB,SAAU,EADR,SAILgB,KAAKf,UAAY,gBAAqB,kBAAM,UAAO,YAAuB,EAAKc,cAAnC,cAC5CC,KAAKS,SAASrB,WAAWY,KAAKD,eAAiBC,KAAKf,UAL/C,SAMgBe,KAAKU,QAAQ,sBAN7B,0JAMS,EAA6CV,KAAKP,YAN3D,gDAM0E,GAN1E,QAMLO,KAAKd,OANA,8BAQLc,KAAKhB,SAAU,EARV,2EAAF,qDAAE,IAaX2B,QAAS,WACPX,KAAKQ,Y,UChDT,EAAOI,OAAS,EAChB,EAAOC,UAAY,kBAEJ","file":"static/js/chunk-d8561e02.78e44394.js","sourcesContent":["export * from \"-!../../../../node_modules/mini-css-extract-plugin/dist/loader.js??ref--8-oneOf-1-0!../../../../node_modules/css-loader/dist/cjs.js??ref--8-oneOf-1-1!../../../../node_modules/vue-loader-v16/dist/stylePostLoader.js!../../../../node_modules/postcss-loader/src/index.js??ref--8-oneOf-1-2!../../../../node_modules/sass-loader/dist/cjs.js??ref--8-oneOf-1-3!../../../../node_modules/cache-loader/dist/cjs.js??ref--0-0!../../../../node_modules/vue-loader-v16/dist/index.js??ref--0-1!./Index.vue?vue&type=style&index=0&id=3565b88b&lang=scss&scoped=true\"","<template>\n <div class=\"plugin\">\n <Loading v-if=\"loading\" />\n <component :is=\"component\" :config=\"config\" v-else-if=\"component\" />\n </div>\n</template>\n\n<script>\nimport Utils from \"@/Utils\";\nimport Loading from \"@/components/Loading\";\nimport {defineAsyncComponent} from \"vue\";\n\nexport default {\n name: \"Plugin\",\n components: {Loading},\n mixins: [Utils],\n props: {\n // Name of the plugin view to be loaded\n pluginName: {\n type: String,\n required: true,\n },\n },\n\n data() {\n return {\n loading: false,\n component: null,\n config: {},\n }\n },\n\n computed: {\n componentName() {\n return this.pluginName.split('.').map((t) => t[0].toUpperCase() + t.slice(1)).join('')\n },\n },\n\n methods: {\n refresh: async function() {\n this.loading = true\n\n try {\n this.component = defineAsyncComponent(() => import(`@/components/panels/${this.componentName}/Index`))\n this.$options.components[this.componentName] = this.component\n this.config = (await this.request('config.get_plugins'))?.[this.pluginName] || {}\n } finally {\n this.loading = false\n }\n },\n },\n\n mounted: function() {\n this.refresh()\n },\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.plugin {\n margin: -1em 0 0 -1em !important;\n padding: 0;\n width: calc(100% + 2em);\n height: calc(100% + 2em);\n}\n</style>\n","import { render } from \"./Index.vue?vue&type=template&id=3565b88b&scoped=true&bindings={\\\"pluginName\\\":\\\"props\\\",\\\"loading\\\":\\\"data\\\",\\\"component\\\":\\\"data\\\",\\\"config\\\":\\\"data\\\",\\\"componentName\\\":\\\"options\\\",\\\"refresh\\\":\\\"options\\\"}\"\nimport script from \"./Index.vue?vue&type=script&lang=js\"\nexport * from \"./Index.vue?vue&type=script&lang=js\"\n\nimport \"./Index.vue?vue&type=style&index=0&id=3565b88b&lang=scss&scoped=true\"\nscript.render = render\nscript.__scopeId = \"data-v-3565b88b\"\n\nexport default script"],"sourceRoot":""}

View file

@ -1,21 +1,27 @@
# webapp # Platypush web app
The UI for Platypush is built with Vue 3. The production-ready files are distributed with the package under `platypush/backend/http/dist`. If you want to change/debug/redistribute some parts of the UI you'll need `npm` installed. The directory of this file is the root of the web app project and all the following commands should be typed from here.
## Project setup ## Project setup
``` ```
npm install npm install
``` ```
### Compiles and hot-reloads for development ### Compilation and hot-reload for development
``` ```
npm run serve npm run serve
``` ```
### Compiles and minifies for production ### Compiles and minifies for production
``` ```
npm run build npm run build
``` ```
### Lints and fixes files ### Lints and fixes files
``` ```
npm run lint npm run lint
``` ```

View file

@ -2,6 +2,9 @@
"icons": { "icons": {
"light.hue": { "light.hue": {
"class": "fas fa-lightbulb" "class": "fas fa-lightbulb"
},
"music.mpd": {
"class": "fas fa-music"
} }
} }
} }

View file

@ -0,0 +1,468 @@
<template>
<div class="extension fade-in" :class="{hidden: !expanded}">
<div class="row">
<div class="col-3">
</div>
<div class="col-6">
<div class="buttons">
<button @click="$emit('previous')" title="Play previous track" v-if="buttons.previous">
<i class="icon fa fa-step-backward"></i>
</button>
<button @click="$emit('stop')" v-if="buttons.stop && status.state !== 'stop'" title="Stop playback">
<i class="icon fa fa-stop"></i>
</button>
<button @click="$emit('next')" title="Play next track" v-if="buttons.next">
<i class="icon fa fa-step-forward"></i>
</button>
</div>
</div>
<div class="col-3">
</div>
</div>
<div class="row">
<div class="col-9 volume-container">
<div class="col-1">
<button :disabled="status.muted == null" @click="$emit(status.muted ? 'unmute' : 'mute')">
<i class="icon fa fa-volume-up"></i>
</button>
</div>
<div class="col-11 volume-slider">
<Slider :value="status.volume" :range="volumeRange" :disabled="status.volume == null"
@mouseup="$emit('set-volume', $event.target.value)" />
</div>
</div>
<div class="col-3 list-controls">
<button @click="$emit('consume', !status.consume)" :class="{enabled: status.consume}"
title="Toggle consume mode" v-if="buttons.consume">
<i class="icon fa fa-utensils"></i>
</button>
<button @click="$emit('random', !status.random)" :class="{enabled: status.random}"
title="Toggle shuffle" v-if="buttons.random">
<i class="icon fa fa-random"></i>
</button>
<button @click="$emit('repeat', !status.repeat)" :class="{enabled: status.repeat}"
title="Toggle repeat" v-if="buttons.repeat">
<i class="icon fa fa-redo"></i>
</button>
</div>
</div>
<div class="row">
<div class="col-s-2 col-m-1 time">
<span class="elapsed-time"
v-text="elapsed != null && status.state !== 'stop' ? convertTime(elapsed) : '-:--'"></span>
</div>
<div class="col-s-8 col-m-10">
<Slider :value="elapsed" :range="[0, duration]" :disabled="!duration || status.state === 'stop'"
@mouseup="$emit('seek', $event.target.value)" />
</div>
<div class="col-s-2 col-m-1 time">
<span class="total-time"
v-text="duration && status.state !== 'stop' ? convertTime(duration) : '-:--'"></span>
</div>
</div>
</div>
<div class="controls">
<div class="playback-controls mobile tablet col-2">
<button @click="$emit(status.state === 'play' ? 'pause' : 'play')"
:title="status.state === 'play' ? 'Pause' : 'Play'">
<i class="icon play-pause fa fa-pause" v-if="status.state === 'play'"></i>
<i class="icon play-pause fa fa-play" v-else></i>
</button>
</div>
<div class="track-container col-s-8 col-m-8 col-l-3">
<div class="track-info" v-if="track && status?.state !== 'stop'">
<div class="title">
<a href="#" v-text="track.title" @click="$emit('search', {album: track.album})" v-if="track.album"></a>
<span v-text="track.title" v-else></span>
</div>
<div class="artist" v-if="track.artist">
<a href="#" v-text="track.artist" @click="$emit('search', {artist: track.artist})"></a>
</div>
</div>
</div>
<div class="playback-controls desktop col-6">
<div class="row buttons">
<button @click="$emit('previous')" title="Play previous track" v-if="buttons.previous">
<i class="icon fa fa-step-backward"></i>
</button>
<button @click="$emit(status.state === 'play' ? 'pause' : 'play')"
:title="status.state === 'play' ? 'Pause' : 'Play'">
<i class="icon play-pause fa fa-pause" v-if="status.state === 'play'"></i>
<i class="icon play-pause fa fa-play" v-else></i>
</button>
<button @click="$emit('stop')" v-if="buttons.stop && status.state !== 'stop'" title="Stop playback">
<i class="icon fa fa-stop"></i>
</button>
<button @click="$emit('next')" title="Play next track" v-if="buttons.next">
<i class="icon fa fa-step-forward"></i>
</button>
</div>
<div class="row">
<div class="col-1 time">
<span class="elapsed-time"
v-text="elapsed != null && status.state !== 'stop' ? convertTime(elapsed) : '-:--'"></span>
</div>
<div class="col-10">
<Slider :value="elapsed" :range="[0, duration]" :disabled="!duration || status.state === 'stop'"
@mouseup="$emit('seek', $event.target.value)" />
</div>
<div class="col-1 time">
<span class="total-time"
v-text="duration && status.state !== 'stop' ? convertTime(duration) : '-:--'"></span>
</div>
</div>
</div>
<div class="col-2 pull-right mobile tablet right-buttons">
<button @click="expanded = !expanded" :title="expanded ? 'Show more controls' : 'Hide extra controls'">
<i class="fas" :class="[`fa-chevron-${expanded ? 'down' : 'up'}`]" />
</button>
</div>
<div class="col-3 pull-right desktop">
<div class="row list-controls">
<button @click="$emit('consume')" :class="{enabled: status.consume}" title="Toggle consume mode" v-if="buttons.consume">
<i class="icon fa fa-utensils"></i>
</button>
<button @click="$emit('random')" :class="{enabled: status.random}" title="Toggle shuffle" v-if="buttons.random">
<i class="icon fa fa-random"></i>
</button>
<button @click="$emit('repeat')" :class="{enabled: status.repeat}" title="Toggle repeat" v-if="buttons.repeat">
<i class="icon fa fa-redo"></i>
</button>
</div>
<div class="row volume-container">
<div class="col-2">
<button :disabled="status.muted == null" @click="$emit(status.muted ? 'unmute' : 'mute')">
<i class="icon fa fa-volume-up"></i>
</button>
</div>
<div class="col-10">
<Slider :value="status.volume" :range="volumeRange" :disabled="status.volume == null"
@mouseup="$emit('set-volume', $event.target.value)" />
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "@/Utils"
import MediaUtils from "@/components/Media/Utils";
import Slider from "@/components/elements/Slider";
export default {
name: "Controls",
components: {Slider},
mixins: [Utils, MediaUtils],
emits: ['search', 'previous', 'next', 'play', 'pause', 'stop', 'seek', 'consume', 'random', 'repeat',
'set-volume', 'mute', 'unmute'],
props: {
track: {
type: Object,
},
status: {
type: Object,
default: () => {},
},
// Enabled playback buttons
buttons: {
type: Object,
default: () => {
return {
previous: true,
next: true,
stop: true,
consume: true,
random: true,
repeat: true,
}
},
},
// Volume range
volumeRange: {
type: Array,
default: () => [0, 100],
}
},
data() {
return {
expanded: false,
lastSync: 0,
elapsed: this.status?.elapsed,
}
},
computed: {
duration() {
return this.status?.duration != null ? this.status.duration : this.track?.duration
},
},
methods: {
getTime() {
return (new Date()).getTime() / 1000
}
},
mounted() {
const self = this
this.$watch(() => self.track, (track) => {
if (!track || self.status?.state !== 'play')
self.lastSync = this.getTime()
})
this.$watch(() => self.status, () => {
self.lastSync = this.getTime()
})
setInterval(() => {
if (self.status?.state === 'play')
self.elapsed = (self.status?.elapsed || 0) + Math.round(this.getTime() - self.lastSync)
}, 1000)
},
}
</script>
<style lang="scss" scoped>
@import 'vars.scss';
button {
border: 0;
&:hover {
border: 0;
.icon {
color: $default-hover-fg;
}
}
&.enabled {
color: $selected-fg;
}
}
.extension {
box-shadow: $border-shadow-bottom;
flex-direction: column;
display: none;
overflow: hidden;
@include until($desktop) {
display: flex;
}
.row {
display: flex;
}
.buttons {
justify-content: center;
margin: 0;
}
.volume-container,
.list-controls {
display: flex;
align-items: center;
button {
padding: 0 .25em;
}
}
.list-controls {
margin-top: -.5em;
flex-flow: row-reverse;
}
.time {
&:first-child {
margin-left: .25em;
}
&:last-child {
margin-right: .25em;
}
}
.volume-slider {
margin-left: 2.25em;
}
}
.controls {
width: 100%;
height: $media-ctrl-panel-height;
display: flex;
padding: 1em .5em;
overflow: hidden;
.row {
width: 100%;
display: flex;
}
.track-container {
display: flex;
flex-direction: column;
justify-content: center;
margin-left: 0;
@include until($tablet) {
align-items: center;
}
a {
color: initial;
text-decoration: none;
&:hover {
color: $default-hover-fg;
}
}
.artist, .title {
overflow: hidden;
text-overflow: ellipsis;
}
.artist {
opacity: 0.6;
letter-spacing: .04em;
}
.title {
font-weight: normal;
font-size: 1em;
letter-spacing: .05em;
margin-bottom: .25em;
}
}
.playback-controls {
&.mobile {
display: none;
@include until($tablet) {
display: flex !important;
align-items: center;
}
}
&.tablet {
display: none;
@media screen and (min-width: $tablet) and (max-width: $desktop - 1) {
display: flex !important;
align-items: center;
}
}
.row {
justify-content: center;
}
.buttons {
height: 50%;
align-items: center;
}
button {
padding: 0;
margin: 0 .75em;
.play-pause {
color: $play-btn-fg;
font-size: 1.75em;
&:hover {
color: $default-hover-fg-2;
}
}
}
}
.list-controls {
height: 50%;
opacity: 0.7;
display: flex;
align-items: center;
margin-bottom: 1em;
flex-flow: row-reverse;
}
.mobile.right-buttons {
@include until ($desktop) {
display: flex;
align-items: center;
justify-content: right;
}
}
.pull-right {
button {
padding: 0;
}
.volume-container {
button {
background: none;
}
}
}
.seek-slider {
width: 75%;
}
.volume-slider {
width: 75%;
margin-right: 1rem;
}
}
.time {
font-size: .7em;
position: relative;
}
.elapsed-time {
text-align: right;
float: right;
}
.mobile {
@include from($tablet) {
display: none;
}
}
.tablet {
@media screen and (max-width: $tablet), screen and (min-width: $desktop - 1) {
display: none;
}
}
.desktop {
@include until($desktop) {
display: none;
}
}
</style>

View file

@ -0,0 +1,28 @@
<script>
export default {
name: "Utils",
methods: {
convertTime(time) {
time = parseFloat(time); // Normalize strings
const t = {}
t.h = '' + parseInt(time/3600)
t.m = '' + parseInt(time/60 - t.h*60)
t.s = '' + parseInt(time - (t.h*3600 + t.m*60))
for (const attr of ['m','s']) {
if (parseInt(t[attr]) < 10) {
t[attr] = '0' + t[attr]
}
}
const ret = []
if (parseInt(t.h)) {
ret.push(t.h)
}
ret.push(t.m, t.s)
return ret.join(':')
},
},
}
</script>

View file

@ -0,0 +1,64 @@
<template>
<div class="media-container">
<div class="view-container">
<slot />
</div>
<div class="controls-container">
<Controls :status="status" :track="track" @play="$emit('play', $event)" @pause="$emit('pause', $event)"
@stop="$emit('stop')" @previous="$emit('previous')" @next="$emit('next')" @seek="$emit('seek', $event)"
@set-volume="$emit('set-volume', $event)" @consume="$emit('consume', $event)"
@repeat="$emit('repeat', $event)" @random="$emit('random', $event)" />
</div>
</div>
</template>
<script>
import Controls from "@/components/Media/Controls";
export default {
name: "View",
components: {Controls},
emits: ['play', 'pause', 'stop', 'next', 'previous', 'set-volume', 'seek', 'consume', 'random', 'repeat'],
props: {
pluginName: {
type: String,
required: true,
},
status: {
type: Object,
default: () => {},
},
track: {
type: Object,
},
},
}
</script>
<style lang="scss" scoped>
@import 'vars.scss';
.media-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
.view-container {
height: calc(100% - #{$media-ctrl-panel-height});
overflow: auto;
}
.controls-container {
width: 100%;
position: absolute;
bottom: 0;
border-top: $default-border-2;
background: $default-bg-2;
box-shadow: $border-shadow-top;
}
}
</style>

View file

@ -0,0 +1 @@
$media-ctrl-panel-height: 5.5em;

View file

@ -5,7 +5,8 @@
<span class="hostname" v-if="hostname" v-text="hostname" /> <span class="hostname" v-if="hostname" v-text="hostname" />
</div> </div>
<li v-for="name in Object.keys(panels)" :key="name" class="entry" :class="{selected: name === selectedPanel}" <ul>
<li v-for="name in Object.keys(panels).sort()" :key="name" class="entry" :class="{selected: name === selectedPanel}"
:title="name" @click="onItemClick(name)"> :title="name" @click="onItemClick(name)">
<a :href="`/#${name}`"> <a :href="`/#${name}`">
<span class="icon"> <span class="icon">
@ -15,6 +16,7 @@
<span class="name" v-if="!collapsed">{{ displayName(name) }}</span> <span class="name" v-if="!collapsed">{{ displayName(name) }}</span>
</a> </a>
</li> </li>
</ul>
</nav> </nav>
</template> </template>
@ -95,7 +97,7 @@ nav {
background: $nav-bg; background: $nav-bg;
color: $nav-fg; color: $nav-fg;
box-shadow: $nav-box-shadow-main; box-shadow: $nav-box-shadow-main;
margin-right: 4px; margin-right: 2px;
} }
li { li {
@ -153,11 +155,14 @@ nav {
} }
&.collapsed { &.collapsed {
display: flex;
flex-direction: column;
@media screen and (min-width: $tablet) { @media screen and (min-width: $tablet) {
width: 2.5em; width: 2.5em;
min-width: unset; min-width: unset;
max-width: unset; max-width: unset;
background: initial; background: $nav-collapsed-bg;
color: $nav-collapsed-fg; color: $nav-collapsed-fg;
box-shadow: $nav-box-shadow-collapsed; box-shadow: $nav-box-shadow-collapsed;
@ -180,11 +185,14 @@ nav {
.toggler { .toggler {
text-align: center; text-align: center;
@media screen and (min-width: $tablet) {
margin-bottom: 3em;
}
} }
ul {
display: flex;
flex-direction: column;
justify-content: center;
flex-grow: 1;
li { li {
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
@ -213,5 +221,6 @@ nav {
} }
} }
} }
}
} }
</style> </style>

View file

@ -0,0 +1,114 @@
<template>
<div class="dropdown-container" ref="container">
<button :title="title" ref="button" @click.stop="toggle">
<i class="icon" :class="iconClass" v-if="iconClass" />
<span class="text" v-text="text" v-if="text" />
</button>
<div class="dropdown fade-in" :id="id" :class="{hidden: !visible}" ref="dropdown">
<slot />
</div>
</div>
</template>
<script>
export default {
name: "Dropdown",
emits: ['click'],
props: {
id: {
type: String,
},
items: {
type: Array,
default: () => [],
},
iconClass: {
type: String,
},
text: {
type: String,
},
title: {
type: String,
},
},
data() {
return {
visible: false,
}
},
methods: {
documentClickHndl(event) {
if (!this.visible)
return
let element = event.target
while (element) {
if (element === this.$refs.dropdown.element) {
return
}
element = element.parentElement
}
this.close()
},
close() {
this.visible = false
document.removeEventListener('click', this.documentClickHndl)
},
open() {
document.addEventListener('click', this.documentClickHndl)
this.visible = true
setTimeout(() => {
const element = this.$refs.dropdown
element.style.left = 0
element.style.top = parseFloat(getComputedStyle(this.$refs.button).height) + 'px'
const maxOffset = 45
const maxLeft = window.innerWidth - maxOffset
const left = this.$refs.container.offsetLeft + element.offsetLeft
const width = element.clientWidth
if (left + width >= maxLeft) {
element.style.left = -(parseFloat(getComputedStyle(this.$refs.button).width) + maxOffset) + 'px'
}
}, 10)
},
toggle() {
this.visible ? this.close() : this.open()
},
},
}
</script>
<style lang="scss" scoped>
.dropdown-container {
position: relative;
display: flex;
flex-direction: column;
.dropdown {
position: absolute;
width: max-content;
background: $dropdown-bg;
border-radius: .25em;
border: $default-border-3;
box-shadow: $dropdown-shadow;
display: flex;
flex-direction: column;
z-index: 1;
}
}
</style>

View file

@ -0,0 +1,57 @@
<template>
<div class="row item" @click="clicked">
<div class="col-1 icon">
<i :class="iconClass" v-if="iconClass" />
</div>
<div class="col-11 text" v-text="text" />
</div>
</template>
<script>
export default {
name: "DropdownItem",
props: {
iconClass: {
type: String,
},
text: {
type: String,
},
disabled: {
type: Boolean,
default: false,
},
},
methods: {
clicked(event) {
this.$parent.$emit('click', event)
this.$parent.visible = false
}
}
}
</script>
<style lang="scss" scoped>
.item {
display: flex;
padding: .5em .25em;
cursor: pointer;
align-items: center;
&:hover {
background: $hover-bg;
}
&.disabled {
color: $dropdown-disabled-color;
cursor: initial;
}
.icon {
margin: 0 .5rem;
}
}
</style>

View file

@ -129,7 +129,6 @@ export default {
} }
&[disabled] { &[disabled] {
opacity: 0.3;
@mixin no-thumb { @mixin no-thumb {
display: none; display: none;
width: 0; width: 0;

View file

@ -60,8 +60,6 @@ export default {
} }
&[disabled] { &[disabled] {
opacity: 0.3;
&::-webkit-progress-value, &::-webkit-progress-value,
&::-moz-range-progress { &::-moz-range-progress {
background: none; background: none;

View file

@ -0,0 +1,23 @@
<template>
<div class="header">
<slot />
</div>
</template>
<script>
export default {
name: "Header"
}
</script>
<style lang="scss" scoped>
@import 'vars.scss';
.header {
width: 100%;
height: $music-header-height;
background: $menu-panel-bg;
padding: .5em;
box-shadow: $border-shadow-bottom;
}
</style>

View file

@ -0,0 +1,145 @@
<template>
<Loading v-if="loading" />
<MediaView :plugin-name="pluginName" :status="status" :track="track" @play="$emit('play', $event)"
@pause="$emit('pause')" @stop="$emit('stop')" @previous="$emit('previous')" @next="$emit('next')"
@set-volume="$emit('set-volume', $event)" @seek="$emit('seek', $event)" @consume="$emit('consume', $event)"
@repeat="$emit('repeat', $event)" @random="$emit('random', $event)" v-else>
<main>
<div class="nav-container">
<Nav :selected-view="selectedView" @input="selectedView = $event" />
</div>
<div class="view-container">
<Playlist :tracks="tracks" :status="status" :loading="loading" v-if="selectedView === 'playing'"
@play="$emit('play', $event)" @clear="$emit('clear')" />
</div>
</main>
</MediaView>
</template>
<script>
import MediaView from "@/components/Media/View";
import Nav from "@/components/panels/Music/Nav";
import Playlist from "@/components/panels/Music/Playlist";
import Utils from "@/Utils";
export default {
name: "Music",
emits: ['play', 'pause', 'stop', 'clear', 'previous', 'next', 'set-volume', 'seek', 'consume', 'repeat', 'random',
'status-update', 'playlist-update', 'new-playing-track'],
mixins: [Utils],
components: {Nav, MediaView, Playlist},
props: {
pluginName: {
type: String,
required: true,
},
loading: {
type: Boolean,
default: false,
},
config: {
type: Object,
default: () => {},
},
tracks: {
type: Array,
default: () => [],
},
status: {
type: Object,
default: () => {},
},
},
data() {
return {
selectedView: 'playing',
}
},
computed: {
track() {
if (this.status?.playingPos == null)
return null
return this.tracks[this.status.playingPos]
}
},
methods: {
async onStatusEvent(event) {
if (event.plugin_name !== this.pluginName)
return
this.$emit('status-update', event)
},
async onPlaylistEvent(event) {
if (event.plugin_name !== this.pluginName)
return
this.$emit('playlist-update', event)
},
async onNewPlayingTrack(event) {
if (event.plugin_name !== this.pluginName)
return
this.notify({
title: event.track?.artist,
text: event.track?.title,
iconClass: 'fa fa-play',
})
this.$emit('new-playing-track', event)
},
},
mounted() {
this.subscribe(this.onStatusEvent, 'on-status-update',
'platypush.message.event.music.MusicPlayEvent',
'platypush.message.event.music.MusicPauseEvent',
'platypush.message.event.music.MusicStopEvent',
'platypush.message.event.music.SeekChangeEvent',
'platypush.message.event.music.VolumeChangeEvent',
'platypush.message.event.music.MuteChangeEvent',
'platypush.message.event.music.PlaybackRepeatModeChangeEvent',
'platypush.message.event.music.PlaybackRandomModeChangeEvent',
'platypush.message.event.music.PlaybackConsumeModeChangeEvent',
'platypush.message.event.music.PlaybackSingleModeChangeEvent',
)
this.subscribe(this.onPlaylistEvent, 'on-playlist-update',
'platypush.message.event.music.PlaylistChangeEvent')
this.subscribe(this.onPlaylistEvent, 'on-new-playing-track',
'platypush.message.event.music.NewPlayingTrackEvent')
},
unmounted() {
this.unsubscribe('on-status-update')
this.unsubscribe('on-playlist-update')
},
}
</script>
<style lang="scss" scoped>
main {
height: 100%;
background: $background-color;
display: flex;
flex-direction: row-reverse;
.view-container {
display: flex;
flex-grow: 1;
overflow: auto;
}
}
</style>

View file

@ -0,0 +1,94 @@
<template>
<nav>
<li v-for="(view, name) in views" :key="name" :title="view.displayName"
:class="{selected: name === selectedView}" @click="$emit('input', name)">
<i :class="view.iconClass" />
</li>
</nav>
</template>
<script>
export default {
name: "Nav",
emits: ['input'],
props: {
selectedView: {
type: String,
},
collapsed: {
type: Boolean,
default: false,
},
views: {
type: Object,
default: () => {
return {
playing: {
iconClass: 'fas fa-play',
displayName: 'Now Playing',
},
search: {
iconClass: 'fas fa-search',
displayName: 'Search',
},
playlists: {
iconClass: 'fas fa-list-ul',
displayName: 'Playlists',
},
library: {
iconClass: 'fas fa-compact-disc',
displayName: 'Library',
},
}
}
},
},
}
</script>
<style lang="scss" scoped>
@import 'vars.scss';
nav {
width: $music-nav-width;
height: 100%;
background: $nav-collapsed-bg;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
box-shadow: 2.5px 0 4.5px 2px $nav-collapsed-fg;
margin-left: 2.5px;
overflow: hidden;
li {
display: flex;
align-items: center;
font-size: 1.2em;
cursor: pointer;
list-style: none;
padding: .6em;
opacity: 0.7;
&.selected,
&:hover {
border-radius: 1.2em;
margin: 0 0.2em;
}
&:hover {
background: $nav-entry-collapsed-hover-bg;
}
&.selected {
background: $nav-entry-collapsed-selected-bg;
}
}
}
</style>

View file

@ -0,0 +1,206 @@
<template>
<Loading v-if="loading" />
<div class="playlist fade-in" v-else>
<div class="header-container">
<MusicHeader>
<div class="col-8 filter">
<label>
<input type="search" placeholder="Filter">
</label>
</div>
<div class="col-4 buttons">
<button title="Add item" @click="$refs.addToPlaylistModal.visible = true">
<i class="fa fa-plus"></i>
</button>
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
<DropdownItem text="Save as playlist" icon-class="fa fa-save" :disabled="!tracks?.length"
@click="$refs.savePlaylistModal.visible = true" />
<DropdownItem text="Swap tracks" icon-class="fa fa-retweet" :disabled="tracks?.length !== 2 || !selectionMode" />
<DropdownItem :text="selectionMode ? 'End selection' : 'Start selection'" icon-class="far fa-check-square"
:disabled="!tracks?.length" @click="selectionMode = !selectionMode" />
<DropdownItem :text="selectedTracks?.length === tracks?.length ? 'Unselect all' : 'Select all'"
icon-class="fa fa-check-double" :disabled="!tracks?.length"
@click="selectedTracks = [...Array(tracks.length).keys()]" />
<DropdownItem text="Clear playlist" icon-class="fa fa-ban" :disabled="!tracks?.length" @click="$emit('clear')" />
</Dropdown>
</div>
</MusicHeader>
</div>
<div class="body">
<div class="no-content" v-if="!tracks?.length">
No tracks are loaded
</div>
<div class="row track" v-for="(track, i) in tracks" :key="i" @dblclick="$emit('play', {pos: i})">
<div class="col-10">
<div class="title" v-text="track.title || '[No Title]'" />
<div class="artist" v-text="track.artist || '[No Artist]'" />
<div class="album" v-text="track.album" v-if="track.album" />
</div>
<div class="col-2 right-side">
<span class="duration" v-text="convertTime(track.time)" />
<span class="actions">
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h">
<DropdownItem text="Play" icon-class="fa fa-play" @click="$emit('play', {pos: i})" />
<DropdownItem text="Add to playlist" icon-class="fa fa-list-ul" />
</Dropdown>
</span>
</div>
</div>
</div>
</div>
<Modal :visible="false" ref="addToPlaylistModal">
</Modal>
<Modal :visible="false" ref="savePlaylistModal">
</Modal>
</template>
<script>
import Modal from "@/components/Modal";
import MusicHeader from "@/components/panels/Music/Header";
import MediaUtils from "@/components/Media/Utils";
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
export default {
name: "Playlist",
mixins: [MediaUtils],
components: {DropdownItem, Dropdown, Modal, MusicHeader},
emits: ['play', 'clear', 'add-to-playlist'],
props: {
tracks: {
type: Array,
default: () => [],
},
loading: {
type: Boolean,
default: false,
},
status: {
type: Object,
default: () => {},
}
},
data() {
return {
selectionMode: false,
selectedTracks: [],
}
},
}
</script>
<style lang="scss" scoped>
@import 'vars.scss';
.playlist {
width: 100%;
display: flex;
flex-direction: column;
.header-container {
button {
border: 0;
background: none;
}
.filter {
input {
width: 100%;
}
}
.buttons {
display: flex;
justify-content: right;
}
}
.body {
height: calc(100% - #{$music-header-height});
overflow: auto;
}
.no-content {
height: 100%;
}
.track {
display: flex;
justify-content: center;
padding: .75em .25em .25em .25em;
box-shadow: 0 2.5px 2px -1px $default-shadow-color;
cursor: pointer;
&:hover {
background: $hover-bg;
}
.title {
font-size: 1em;
font-weight: normal;
margin: 0;
}
.artist, .album {
display: inline-flex;
opacity: 0.7;
font-size: .9em;
}
.artist {
margin-right: .25em;
}
.album {
@include until($tablet) {
display: none;
}
&::before {
content: "\2022";
margin-right: .25em;
}
}
.right-side {
display: flex;
justify-content: right;
}
.duration,
.actions {
display: inline-flex;
align-items: center;
}
.duration {
font-size: .85em;
opacity: .7;
}
.actions {
::v-deep button {
opacity: .7;
}
}
}
}
::v-deep button {
background: none;
padding: .5em .75em;
border: 0;
}
</style>

View file

@ -0,0 +1,2 @@
$music-header-height: 3.3em;
$music-nav-width: 2.8em;

View file

@ -0,0 +1,167 @@
<template>
<Loading v-if="loading" />
<MusicPlugin plugin-name="music.mpd" :loading="loading" :config="config" :tracks="tracks" :status="status"
@play="play" @pause="pause" @stop="stop" @previous="previous" @next="next" @clear="clear"
@set-volume="setVolume" @seek="seek" @consume="consume" @random="random" @repeat="repeat"
@status-update="refreshStatus(true)" @playlist-update="refreshTracks(true)"
@new-playing-track="refreshStatus(true)" />
</template>
<script>
import MusicPlugin from "@/components/panels/Music/Index";
import Utils from "@/Utils";
import Loading from "@/components/Loading";
export default {
name: "MusicMpd",
components: {Loading, MusicPlugin},
mixins: [Utils],
props: {
config: {
type: Object,
default: () => {},
},
},
data() {
return {
loading: false,
tracks: [],
status: {},
}
},
methods: {
async refreshTracks(background) {
if (!background)
this.loading = true
try {
this.tracks = await this.request('music.mpd.playlistinfo')
} finally {
this.loading = false
}
},
async refreshStatus(background) {
if (!background)
this.loading = true
try {
this.status = Object.entries(await this.request('music.mpd.status')).reduce((obj, [k, v]) => {
switch (k) {
case 'bitrate':
case 'volume':
obj[k] = parseInt(v)
break
case 'consume':
case 'random':
case 'repeat':
case 'single':
obj[k] = !!parseInt(v)
break
case 'song':
obj['playingPos'] = parseInt(v)
break
case 'time':
[obj['elapsed'], obj['duration']] = v.split(':').map(t => parseInt(t))
break
case 'elapsed':
break
default:
obj[k] = v
break
}
return obj
}, {})
} finally {
this.loading = false
}
},
async refresh(background) {
if (!background)
this.loading = true
try {
await Promise.all([this.refreshTracks(background), this.refreshStatus(background)])
} finally {
this.loading = false
}
},
async play(event) {
if (event?.pos != null) {
await this.request('music.mpd.play_pos', {pos: event.pos})
} else {
await this.request('music.mpd.play')
}
await this.refreshStatus(true)
},
async pause() {
await this.request('music.mpd.pause')
await this.refreshStatus(true)
},
async stop() {
await this.request('music.mpd.stop')
await this.refreshStatus(true)
},
async previous() {
await this.request('music.mpd.previous')
await this.refreshStatus(true)
},
async next() {
await this.request('music.mpd.next')
await this.refreshStatus(true)
},
async clear() {
await this.request('music.mpd.clear')
await Promise.all([this.refreshStatus(true), this.refreshTracks(true)])
},
async setVolume(volume) {
if (volume === this.status.volume)
return
await this.request('music.mpd.set_volume', {volume: volume})
await this.refreshStatus(true)
},
async seek(pos) {
await this.request('music.mpd.seek', {position: pos})
await this.refreshStatus(true)
},
async repeat(value) {
await this.request('music.mpd.repeat', {value: value})
await this.refreshStatus(true)
},
async random(value) {
await this.request('music.mpd.random', {value: value})
await this.refreshStatus(true)
},
async consume(value) {
await this.request('music.mpd.consume', {value: value})
await this.refreshStatus(true)
},
},
mounted() {
this.refresh()
},
}
</script>

View file

@ -0,0 +1,7 @@
@import "themes/light.scss";
@import "mixins.scss";
@import "layout.scss";
@import "components.scss";
@import "inputs.scss";
@import "animations.scss";

View file

@ -0,0 +1,33 @@
.input-icon {
position: absolute;
min-width: .3em;
padding: .1em;
color: $input-icon-fg;
}
input[type=text],
input[type=password],
input[type=search],
input[type=number] {
border: $default-border-2;
border-radius: .5em;
padding: .25em;
&:hover {
border: $input-icon-hover-border;
}
&:focus {
border: $input-icon-focus-border;
}
&.with-icon {
padding-left: .3em;
}
}
input[type=search],
input[type=text] {
border-radius: 1em;
padding: .25em .5em;
}

View file

@ -1,7 +1,9 @@
$widths: ( $widths: (
s: '(max-width: 720px)', s: (max-width: $tablet),
m: '(max-width: 1024px) and (min-width: 720px)', m: (min-width: $tablet),
l: '(min-width: 1024px)', l: (min-width: $desktop),
xl: (min-width: $widescreen),
xxl: (min-width: $fullhd),
); );
@for $i from 1 through 12 { @for $i from 1 through 12 {
@ -102,3 +104,10 @@ $widths: (
.hidden { .hidden {
display: none !important; display: none !important;
} }
.no-content {
display: flex;
font-size: 1.5em;
align-items: center;
justify-content: center;
}

View file

@ -39,7 +39,11 @@ $default-border-2: 1px solid $border-color-2 !default;
$default-border-3: 1px solid $border-color-3 !default; $default-border-3: 1px solid $border-color-3 !default;
//// Shadows //// Shadows
$border-shadow-bottom: 0 3px 2px -1px #c0c0c0; $default-shadow-color: #c0c0c0 !default;
$border-shadow-top: 0 -2.5px 4px 0 $default-shadow-color;
$border-shadow-bottom: 0 3px 2px -1px $default-shadow-color;
$border-shadow-left: -2.5px 0 4px 0 $default-shadow-color;
$border-shadow-right: 2.5px 0 4px 0 $default-shadow-color;
//// Modals //// Modals
$modal-header-bg: #e0e0e0 !default; $modal-header-bg: #e0e0e0 !default;
@ -75,6 +79,7 @@ $nav-entry-collapsed-hover-bg: rgba(160, 245, 178, 0.60) !default;
$nav-box-shadow-main: 1px 0 2px #002626; $nav-box-shadow-main: 1px 0 2px #002626;
$nav-box-shadow-entry: 0 0 1px 1px #103824 !default; $nav-box-shadow-entry: 0 0 1px 1px #103824 !default;
$nav-box-shadow-collapsed: 1px 0 2px 1px #bbb !default; $nav-box-shadow-collapsed: 1px 0 2px 1px #bbb !default;
$nav-collapsed-bg: white;
$nav-collapsed-fg: #5e5e5e; $nav-collapsed-fg: #5e5e5e;
/// Panel/menu components /// Panel/menu components
@ -101,3 +106,16 @@ $slider-thumb-bg: rgba(0,215,80,1.0) !default;
$slider-thumb-disabled-bg: rgba(0,215,80,0.3) !default; $slider-thumb-disabled-bg: rgba(0,215,80,0.3) !default;
$slider-hover-on-hover-bg: #d2d2d2 !default; $slider-hover-on-hover-bg: #d2d2d2 !default;
$slider-progress-bg: rgba(0,215,80,0.2) !default; $slider-progress-bg: rgba(0,215,80,0.2) !default;
//// Input element
$input-icon-fg: #888;
$input-icon-focus-border: 1px solid rgba(127, 216, 95, 0.83);
$input-icon-hover-border: 1px solid rgba(159, 180, 152, 0.83);
//// Media elements
$play-btn-fg: #27ee5e !default;
//// Dropdown element
$dropdown-bg: rgb(241, 243, 242) !default;
$dropdown-disabled-color: #999 !default;
$dropdown-shadow: 1px 1px 1px #bbb !default;

View file

@ -0,0 +1,8 @@
export default {
getMousePos() {
return [
window.event.clientX,
window.event.clientY,
]
}
}

View file

@ -5,6 +5,14 @@ export default {
isMobile() { isMobile() {
return window.matchMedia("only screen and (max-width: 760px)").matches return window.matchMedia("only screen and (max-width: 760px)").matches
}, },
isTablet() {
return !this.isMobile() && window.matchMedia("only screen and (max-width: 960px)").matches
},
isDesktop() {
return window.matchMedia("only screen and (min-width: 1152px)").matches
},
}, },
} }
</script> </script>

View file

@ -65,7 +65,6 @@ body {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
margin: 0; margin: 0;
overflow-x: hidden;
} }
.login-container { .login-container {

View file

@ -5,7 +5,7 @@
@select="selectedPanel = $event" v-else /> @select="selectedPanel = $event" v-else />
<div class="canvas"> <div class="canvas">
<div class="panel" v-for="(panel, name) in components" :key="name"> <div class="panel" :class="{hidden: name !== selectedPanel}" v-for="(panel, name) in components" :key="name">
<component :is="panel.component" :config="panel.config" :plugin-name="name" v-if="name === selectedPanel" /> <component :is="panel.component" :config="panel.config" :plugin-name="name" v-if="name === selectedPanel" />
</div> </div>
</div> </div>

View file

@ -7,11 +7,7 @@ module.exports = {
additionalData: ` additionalData: `
@import '~bulma'; @import '~bulma';
@import '~w3css/w3.css'; @import '~w3css/w3.css';
@import "@/style/mixins.scss"; @import "@/style/common.scss";
@import "@/style/themes/light.scss";
@import "@/style/layout.scss";
@import "@/style/components.scss";
@import "@/style/animations.scss";
` `
} }
} }

View file

@ -131,19 +131,19 @@ class MusicMopidyBackend(Backend):
track = self._parse_track(track) track = self._parse_track(track)
if not track: if not track:
return return
self.bus.post(MusicPauseEvent(status=status, track=track)) self.bus.post(MusicPauseEvent(status=status, track=track, plugin_name='music.mpd'))
elif event == 'track_playback_resumed': elif event == 'track_playback_resumed':
status['state'] = 'play' status['state'] = 'play'
track = self._parse_track(track) track = self._parse_track(track)
if not track: if not track:
return return
self.bus.post(MusicPlayEvent(status=status, track=track)) self.bus.post(MusicPlayEvent(status=status, track=track, plugin_name='music.mpd'))
elif event == 'track_playback_ended' or ( elif event == 'track_playback_ended' or (
event == 'playback_state_changed' event == 'playback_state_changed'
and msg.get('new_state') == 'stopped'): and msg.get('new_state') == 'stopped'):
status['state'] = 'stop' status['state'] = 'stop'
track = self._parse_track(track) track = self._parse_track(track)
self.bus.post(MusicStopEvent(status=status, track=track)) self.bus.post(MusicStopEvent(status=status, track=track, plugin_name='music.mpd'))
elif event == 'track_playback_started': elif event == 'track_playback_started':
track = self._parse_track(track) track = self._parse_track(track)
if not track: if not track:
@ -152,7 +152,7 @@ class MusicMopidyBackend(Backend):
status['state'] = 'play' status['state'] = 'play'
status['position'] = 0.0 status['position'] = 0.0
status['time'] = track.get('time') status['time'] = track.get('time')
self.bus.post(NewPlayingTrackEvent(status=status, track=track)) self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd'))
elif event == 'stream_title_changed': elif event == 'stream_title_changed':
m = re.match('^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', '')) m = re.match('^\s*(.+?)\s+-\s+(.*)\s*$', msg.get('title', ''))
if not m: if not m:
@ -162,35 +162,35 @@ class MusicMopidyBackend(Backend):
track['title'] = m.group(2) track['title'] = m.group(2)
status['state'] = 'play' status['state'] = 'play'
status['position'] = 0.0 status['position'] = 0.0
self.bus.post(NewPlayingTrackEvent(status=status, track=track)) self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd'))
elif event == 'volume_changed': elif event == 'volume_changed':
status['volume'] = msg.get('volume') status['volume'] = msg.get('volume')
self.bus.post(VolumeChangeEvent(volume=status['volume'], self.bus.post(VolumeChangeEvent(volume=status['volume'], status=status, track=track,
status=status, track=track)) plugin_name='music.mpd'))
elif event == 'mute_changed': elif event == 'mute_changed':
status['mute'] = msg.get('mute') status['mute'] = msg.get('mute')
self.bus.post(MuteChangeEvent(mute=status['mute'], self.bus.post(MuteChangeEvent(mute=status['mute'], status=status, track=track,
status=status, track=track)) plugin_name='music.mpd'))
elif event == 'seeked': elif event == 'seeked':
status['position'] = msg.get('time_position')/1000 status['position'] = msg.get('time_position')/1000
self.bus.post(SeekChangeEvent(position=status['position'], self.bus.post(SeekChangeEvent(position=status['position'], status=status, track=track,
status=status, track=track)) plugin_name='music.mpd'))
elif event == 'tracklist_changed': elif event == 'tracklist_changed':
tracklist = [self._parse_track(t, pos=i) tracklist = [self._parse_track(t, pos=i)
for i, t in enumerate(self._communicate({ for i, t in enumerate(self._communicate({
'method': 'core.tracklist.get_tl_tracks'}))] 'method': 'core.tracklist.get_tl_tracks'}))]
self.bus.post(PlaylistChangeEvent(changes=tracklist)) self.bus.post(PlaylistChangeEvent(changes=tracklist, plugin_name='music.mpd'))
elif event == 'options_changed': elif event == 'options_changed':
new_status = self._get_tracklist_status() new_status = self._get_tracklist_status()
if new_status['random'] != self._latest_status.get('random'): if new_status['random'] != self._latest_status.get('random'):
self.bus.post(PlaybackRandomModeChangeEvent(state=new_status['random'])) self.bus.post(PlaybackRandomModeChangeEvent(state=new_status['random'], plugin_name='music.mpd'))
if new_status['repeat'] != self._latest_status['repeat']: if new_status['repeat'] != self._latest_status['repeat']:
self.bus.post(PlaybackRepeatModeChangeEvent(state=new_status['repeat'])) self.bus.post(PlaybackRepeatModeChangeEvent(state=new_status['repeat'], plugin_name='music.mpd'))
if new_status['single'] != self._latest_status['single']: if new_status['single'] != self._latest_status['single']:
self.bus.post(PlaybackSingleModeChangeEvent(state=new_status['single'])) self.bus.post(PlaybackSingleModeChangeEvent(state=new_status['single'], plugin_name='music.mpd'))
if new_status['consume'] != self._latest_status['consume']: if new_status['consume'] != self._latest_status['consume']:
self.bus.post(PlaybackConsumeModeChangeEvent(state=new_status['consume'])) self.bus.post(PlaybackConsumeModeChangeEvent(state=new_status['consume'], plugin_name='music.mpd'))
self._latest_status = new_status self._latest_status = new_status

View file

@ -81,11 +81,11 @@ class MusicMpdBackend(Backend):
if state != last_state: if state != last_state:
if state == 'stop': if state == 'stop':
self.bus.post(MusicStopEvent(status=status, track=track)) self.bus.post(MusicStopEvent(status=status, track=track, plugin_name='music.mpd'))
elif state == 'pause': elif state == 'pause':
self.bus.post(MusicPauseEvent(status=status, track=track)) self.bus.post(MusicPauseEvent(status=status, track=track, plugin_name='music.mpd'))
elif state == 'play': elif state == 'play':
self.bus.post(MusicPlayEvent(status=status, track=track)) self.bus.post(MusicPlayEvent(status=status, track=track, plugin_name='music.mpd'))
if playlist != last_playlist: if playlist != last_playlist:
if last_playlist: if last_playlist:
@ -93,31 +93,31 @@ class MusicMpdBackend(Backend):
# PlaylistChangeEvent temporarily disabled # PlaylistChangeEvent temporarily disabled
# changes = plugin.plchanges(last_playlist).output # changes = plugin.plchanges(last_playlist).output
# self.bus.post(PlaylistChangeEvent(changes=changes)) # self.bus.post(PlaylistChangeEvent(changes=changes))
self.bus.post(PlaylistChangeEvent()) self.bus.post(PlaylistChangeEvent(plugin_name='music.mpd'))
last_playlist = playlist last_playlist = playlist
if state == 'play' and track != last_track: if state == 'play' and track != last_track:
self.bus.post(NewPlayingTrackEvent(status=status, track=track)) self.bus.post(NewPlayingTrackEvent(status=status, track=track, plugin_name='music.mpd'))
if last_status.get('volume', None) != status['volume']: if last_status.get('volume', None) != status['volume']:
self.bus.post(VolumeChangeEvent( self.bus.post(VolumeChangeEvent(volume=int(status['volume']), status=status, track=track,
volume=int(status['volume']), status=status, track=track)) plugin_name='music.mpd'))
if last_status.get('random', None) != status['random']: if last_status.get('random', None) != status['random']:
self.bus.post(PlaybackRandomModeChangeEvent( self.bus.post(PlaybackRandomModeChangeEvent(state=bool(int(status['random'])), status=status,
state=bool(int(status['random'])), status=status, track=track)) track=track, plugin_name='music.mpd'))
if last_status.get('repeat', None) != status['repeat']: if last_status.get('repeat', None) != status['repeat']:
self.bus.post(PlaybackRepeatModeChangeEvent( self.bus.post(PlaybackRepeatModeChangeEvent(state=bool(int(status['repeat'])), status=status,
state=bool(int(status['repeat'])), status=status, track=track)) track=track, plugin_name='music.mpd'))
if last_status.get('consume', None) != status['consume']: if last_status.get('consume', None) != status['consume']:
self.bus.post(PlaybackConsumeModeChangeEvent( self.bus.post(PlaybackConsumeModeChangeEvent(state=bool(int(status['consume'])), status=status,
state=bool(int(status['consume'])), status=status, track=track)) track=track, plugin_name='music.mpd'))
if last_status.get('single', None) != status['single']: if last_status.get('single', None) != status['single']:
self.bus.post(PlaybackSingleModeChangeEvent( self.bus.post(PlaybackSingleModeChangeEvent(state=bool(int(status['single'])), status=status,
state=bool(int(status['single'])), status=status, track=track)) track=track, plugin_name='music.mpd'))
last_status = status last_status = status
last_state = state last_state = state

View file

@ -4,8 +4,8 @@ from platypush.message.event import Event
class MusicEvent(Event): class MusicEvent(Event):
""" Base class for music events """ """ Base class for music events """
def __init__(self, status, track, *args, **kwargs): def __init__(self, status, track, plugin_name=None, *args, **kwargs):
super().__init__(*args, status=status, track=track, **kwargs) super().__init__(*args, status=status, track=track, plugin_name=plugin_name, **kwargs)
class MusicPlayEvent(MusicEvent): class MusicPlayEvent(MusicEvent):

View file

@ -3,7 +3,7 @@ from platypush.plugins import Plugin, action
class MusicPlugin(Plugin): class MusicPlugin(Plugin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(**kwargs)
@action @action
def play(self): def play(self):
@ -26,7 +26,11 @@ class MusicPlugin(Plugin):
raise NotImplementedError() raise NotImplementedError()
@action @action
def setvol(self, vol): def set_volume(self, volume):
raise NotImplementedError()
@action
def seek(self, position):
raise NotImplementedError() raise NotImplementedError()
@action @action

View file

@ -181,12 +181,22 @@ class MusicMpdPlugin(MusicPlugin):
@action @action
def setvol(self, vol): def setvol(self, vol):
""" """
Set the volume Set the volume (DEPRECATED, use :meth:`.set_volume` instead).
:param vol: Volume value (range: 0-100) :param vol: Volume value (range: 0-100)
:type vol: int :type vol: int
""" """
return self._exec('setvol', str(vol)) return self.set_volume(vol)
@action
def set_volume(self, volume):
"""
Set the volume.
:param volume: Volume value (range: 0-100)
:type volume: int
"""
return self._exec('setvol', str(volume))
@action @action
def volup(self, delta=10): def volup(self, delta=10):
@ -407,13 +417,24 @@ class MusicMpdPlugin(MusicPlugin):
@action @action
def seekcur(self, value): def seekcur(self, value):
""" """
Seek to the specified position Seek to the specified position (DEPRECATED, use :meth:`.seek` instead).
:param value: Seek position in seconds, or delta string (e.g. '+15' or '-15') to indicate a seek relative to the current position :param value: Seek position in seconds, or delta string (e.g. '+15' or '-15') to indicate a seek relative to the current position
:type value: int :type value: int
""" """
return self._exec('seekcur', value) return self.seek(value)
@action
def seek(self, position):
"""
Seek to the specified position
:param position: Seek position in seconds, or delta string (e.g. '+15' or '-15') to indicate a seek relative to the current position
:type position: int
"""
return self._exec('seekcur', position)
@action @action
def forward(self): def forward(self):