diff --git a/platypush/backend/http/static/css/source/common/elements/switch.scss b/platypush/backend/http/static/css/source/common/elements/switch.scss
index 4870a90a6..6e871fdb7 100644
--- a/platypush/backend/http/static/css/source/common/elements/switch.scss
+++ b/platypush/backend/http/static/css/source/common/elements/switch.scss
@@ -12,7 +12,7 @@
display: inline-block;
text-align: center;
user-select: none;
- padding-top: 1rem;
+ padding: .5rem 0;
input[type=checkbox] {
display: none !important;
@@ -39,7 +39,7 @@
border-radius: 50%;
box-shadow: $switch-shadow-1;
display: block;
- margin: 0 auto;
+ margin: .5rem auto 0 auto;
font-size: 1.4em;
transition: all 350ms ease-in;
diff --git a/platypush/backend/http/static/css/source/common/vars.scss b/platypush/backend/http/static/css/source/common/vars.scss
index 7f6a1b3d5..cc14a3e40 100644
--- a/platypush/backend/http/static/css/source/common/vars.scss
+++ b/platypush/backend/http/static/css/source/common/vars.scss
@@ -3,9 +3,13 @@ $default-bg: white !default;
$default-bg-2: #f4f5f6 !default;
$default-bg-3: #f1f3f2 !default;
$default-bg-4: #edf0ee !default;
+$default-bg-5: #f8f8f8 !default;
$default-fg: black !default;
$default-fg-2: #333333 !default;
+$default-fg-3: #888888 !default;
$default-font-size: 1.5rem !default;
+$default-shadow: 2px 2px 2px #ccc !default;
+$default-hover-fg: #35b870 !default;
$default-font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif !default;
$default-border: 1px solid #e1e4e8 !default;
diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/music.snapcast/index.scss b/platypush/backend/http/static/css/source/webpanel/plugins/music.snapcast/index.scss
new file mode 100644
index 000000000..89500ede9
--- /dev/null
+++ b/platypush/backend/http/static/css/source/webpanel/plugins/music.snapcast/index.scss
@@ -0,0 +1,106 @@
+@import 'common/vars';
+@import 'common/mixins';
+@import 'common/layout';
+
+$host-border: $default-border-2;
+$host-shadow: $default-shadow;
+
+.music-snapcast-container {
+ .host {
+ width: 95%;
+ margin: 2rem auto;
+ border: $host-border;
+ border-radius: 1rem;
+ box-shadow: $host-shadow;
+
+ .head {
+ padding: 1rem .5rem;
+ background: $default-bg-4;
+ border-bottom: $host-border;
+ border-radius: 1rem 1rem 0 0;
+ display: flex;
+ align-items: center;
+
+ .name {
+ padding-left: .5rem;
+ text-transform: uppercase;
+
+ &:hover {
+ color: $default-hover-fg;
+ cursor: pointer;
+ }
+ }
+
+ button {
+ padding: 0;
+ border: 0;
+ &:hover { color: $default-hover-fg; }
+ }
+ }
+
+ .group {
+ .head {
+ background: $default-bg-5;
+ border-radius: 0;
+ }
+
+ .head,
+ .client {
+ padding: 0 1rem;
+ }
+
+ .client {
+ display: flex;
+ align-items: center;
+
+ &.offline { color: $default-fg-3; }
+ &:hover { background: $hover-bg; }
+
+ .name {
+ &:hover {
+ color: $default-hover-fg;
+ cursor: pointer;
+ }
+ }
+ }
+ }
+
+ .icon { margin-right: 1rem; }
+ }
+
+ .modal {
+ .info {
+ .row {
+ &:hover { background: $hover-bg; }
+ .value {
+ text-align: right;
+ }
+ }
+ }
+ }
+}
+
+@media #{map-get($widths, 's')} {
+ .music-snapcast-container {
+ .modal {
+ width: 80vw;
+ }
+ }
+}
+
+@media #{map-get($widths, 'm')} {
+ .music-snapcast-container {
+ .modal {
+ width: 70vw;
+ }
+ }
+}
+
+@media #{map-get($widths, 'l')} {
+ .music-snapcast-container {
+ .modal {
+ width: 65vw;
+ }
+ }
+}
+
diff --git a/platypush/backend/http/static/js/plugins/music.snapcast/client.js b/platypush/backend/http/static/js/plugins/music.snapcast/client.js
new file mode 100644
index 000000000..7c79eb291
--- /dev/null
+++ b/platypush/backend/http/static/js/plugins/music.snapcast/client.js
@@ -0,0 +1,37 @@
+Vue.component('music-snapcast-client', {
+ template: '#tmpl-music-snapcast-client',
+ props: {
+ config: { type: Object },
+ connected: { type: Boolean },
+ host: { type: Object },
+ id: { type: String },
+ groupId: { type: String },
+ lastSeen: { type: Object },
+ snapclient: { type: Object },
+ server: { type: Object },
+ bus: { type: Object },
+ },
+
+ methods: {
+ muteToggled: function(event) {
+ this.bus.$emit('client-mute-changed', {
+ id: this.id,
+ server: this.server,
+ value: !event.value,
+ });
+ },
+
+ volumeChanged: function(event) {
+ this.bus.$emit('client-volume-changed', {
+ id: this.id,
+ server: this.server,
+ value: parseInt(event.target.value),
+ });
+ },
+ },
+});
+
+Vue.component('music-snapcast-client-info', {
+ props: ['info'],
+});
+
diff --git a/platypush/backend/http/static/js/plugins/music.snapcast/group.js b/platypush/backend/http/static/js/plugins/music.snapcast/group.js
new file mode 100644
index 000000000..91f7d10a5
--- /dev/null
+++ b/platypush/backend/http/static/js/plugins/music.snapcast/group.js
@@ -0,0 +1,27 @@
+Vue.component('music-snapcast-group', {
+ template: '#tmpl-music-snapcast-group',
+ props: {
+ id: { type: String },
+ clients: { type: Object },
+ muted: { type: Boolean },
+ name: { type: String },
+ stream: { type: Object },
+ server: { type: Object },
+ bus: { type: Object },
+ },
+
+ methods: {
+ muteToggled: function(event) {
+ this.bus.$emit('group-mute-changed', {
+ id: this.id,
+ server: this.server,
+ value: !event.value,
+ });
+ },
+ },
+});
+
+Vue.component('music-snapcast-group-info', {
+ props: ['info'],
+});
+
diff --git a/platypush/backend/http/static/js/plugins/music.snapcast/host.js b/platypush/backend/http/static/js/plugins/music.snapcast/host.js
new file mode 100644
index 000000000..16b971039
--- /dev/null
+++ b/platypush/backend/http/static/js/plugins/music.snapcast/host.js
@@ -0,0 +1,20 @@
+Vue.component('music-snapcast-host', {
+ template: '#tmpl-music-snapcast-host',
+ props: {
+ groups: { type: Object },
+ server: { type: Object },
+ streams: { type: Object },
+ bus: { type: Object },
+ },
+
+ data: function() {
+ return {
+ collapsed: false,
+ };
+ },
+});
+
+Vue.component('music-snapcast-host-info', {
+ props: ['info'],
+});
+
diff --git a/platypush/backend/http/static/js/plugins/music.snapcast/index.js b/platypush/backend/http/static/js/plugins/music.snapcast/index.js
new file mode 100644
index 000000000..bec4eae48
--- /dev/null
+++ b/platypush/backend/http/static/js/plugins/music.snapcast/index.js
@@ -0,0 +1,178 @@
+Vue.component('music-snapcast', {
+ template: '#tmpl-music-snapcast',
+ props: ['config'],
+ data: function() {
+ return {
+ hosts: {},
+ modal: {
+ host: {
+ visible: false,
+ info: {},
+ },
+ group: {
+ visible: false,
+ info: {},
+ },
+ client: {
+ visible: false,
+ info: {},
+ },
+ },
+
+ bus: new Vue({}),
+ };
+ },
+
+ methods: {
+ refresh: async function() {
+ let hosts = await request('music.snapcast.get_backend_hosts');
+ let promises = Object.keys(hosts).map(
+ (host) => request('music.snapcast.status', {host: host, port: hosts[host]})
+ );
+
+ let statuses = await Promise.all(promises);
+ this.hosts = {};
+
+ for (const status of statuses) {
+ status.server.host.port = hosts[status.server.host.name];
+ var groups = {};
+
+ for (const group of status.groups) {
+ var clients = {};
+ for (const client of group.clients) {
+ clients[client.id] = client;
+ }
+
+ group.clients = clients;
+ groups[group.id] = group;
+ }
+
+ status.groups = groups;
+ var streams = {};
+
+ for (const stream of status.streams) {
+ streams[stream.id] = stream;
+ }
+
+ status.streams = streams;
+ Vue.set(this.hosts, status.server.host.name, status);
+ }
+ },
+
+ onClientUpdate: function(event) {
+ for (const groupId of Object.keys(this.hosts[event.host].groups)) {
+ if (event.client.id in this.hosts[event.host].groups[groupId].clients) {
+ this.hosts[event.host].groups[groupId].clients[event.client.id] = event.client;
+ }
+ }
+ },
+
+ onGroupStreamChange: function(event) {
+ this.hosts[event.host].groups[event.group].stream_id = event.stream;
+ },
+
+ onServerUpdate: function(event) {
+ this.refresh();
+ },
+
+ onStreamUpdate: function(event) {
+ this.streams[event.stream_id] = event.stream;
+ },
+
+ onClientVolumeChange: function(event) {
+ for (const groupId of Object.keys(this.hosts[event.host].groups)) {
+ if (event.client in this.hosts[event.host].groups[groupId].clients) {
+ if (event.volume != null) {
+ this.hosts[event.host].groups[groupId].clients[event.client].config.volume.percent = event.volume;
+ }
+
+ if (event.muted != null) {
+ this.hosts[event.host].groups[groupId].clients[event.client].config.volume.muted = event.muted;
+ }
+ }
+ }
+ },
+
+ onGroupMuteChange: function(event) {
+ this.hosts[event.host].groups[event.group].muted = event.muted;
+ },
+
+ modalShow: function(event) {
+ switch(event.type) {
+ case 'host':
+ this.modal[event.type].info = this.hosts[event.host];
+ break;
+ case 'group':
+ this.modal[event.type].info = this.hosts[event.host].groups[event.group];
+ break;
+ case 'client':
+ this.modal[event.type].info = this.hosts[event.host].groups[event.group].clients[event.client];
+ break;
+ }
+
+ this.modal[event.type].visible = true;
+ },
+
+ groupMute: async function(event) {
+ await request('music.snapcast.mute', {
+ group: event.id,
+ host: event.server.ip || event.server.name,
+ port: event.server.port,
+ mute: event.value,
+ });
+
+ this.hosts[event.server.name].groups[event.id].muted = event.value;
+ },
+
+ clientMute: async function(event) {
+ await request('music.snapcast.mute', {
+ client: event.id,
+ host: event.server.ip || event.server.name,
+ port: event.server.port,
+ mute: event.value,
+ });
+
+ for (const groupId of Object.keys(this.hosts[event.server.name].groups)) {
+ if (event.id in this.hosts[event.server.name].groups[groupId].clients) {
+ this.hosts[event.server.name].groups[groupId].clients[event.id].config.volume.muted = event.value;
+ }
+ }
+ },
+
+ clientSetVolume: async function(event) {
+ await request('music.snapcast.volume', {
+ client: event.id,
+ host: event.server.ip || event.server.name,
+ port: event.server.port,
+ volume: event.value,
+ });
+
+ for (const groupId of Object.keys(this.hosts[event.server.name].groups)) {
+ if (event.id in this.hosts[event.server.name].groups[groupId].clients) {
+ this.hosts[event.server.name].groups[groupId].clients[event.id].config.volume.percent = event.value;
+ }
+ }
+ },
+ },
+
+ created: function() {
+ this.refresh();
+
+ registerEventHandler(this.onClientUpdate,
+ 'platypush.message.event.music.snapcast.ClientConnectedEvent',
+ 'platypush.message.event.music.snapcast.ClientDisconnectedEvent',
+ 'platypush.message.event.music.snapcast.ClientNameChangeEvent');
+
+ registerEventHandler(this.onGroupStreamChange, 'platypush.message.event.music.snapcast.GroupStreamChangeEvent');
+ registerEventHandler(this.onServerUpdate, 'platypush.message.event.music.snapcast.ServerUpdateEvent');
+ registerEventHandler(this.onStreamUpdate, 'platypush.message.event.music.snapcast.StreamUpdateEvent');
+ registerEventHandler(this.onClientVolumeChange, 'platypush.message.event.music.snapcast.ClientVolumeChangeEvent');
+ registerEventHandler(this.onGroupMuteChange, 'platypush.message.event.music.snapcast.GroupMuteChangeEvent');
+
+ this.bus.$on('group-mute-changed', this.groupMute);
+ this.bus.$on('client-mute-changed', this.clientMute);
+ this.bus.$on('client-volume-changed', this.clientSetVolume);
+ this.bus.$on('modal-show', this.modalShow);
+ },
+});
+
diff --git a/platypush/backend/http/templates/plugins/music.snapcast/client.html b/platypush/backend/http/templates/plugins/music.snapcast/client.html
new file mode 100644
index 000000000..00392b624
--- /dev/null
+++ b/platypush/backend/http/templates/plugins/music.snapcast/client.html
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/platypush/backend/http/templates/plugins/music.snapcast/group.html b/platypush/backend/http/templates/plugins/music.snapcast/group.html
new file mode 100644
index 000000000..d189a8282
--- /dev/null
+++ b/platypush/backend/http/templates/plugins/music.snapcast/group.html
@@ -0,0 +1,33 @@
+{% include 'plugins/music.snapcast/client.html' %}
+
+
+
+
+
diff --git a/platypush/backend/http/templates/plugins/music.snapcast/host.html b/platypush/backend/http/templates/plugins/music.snapcast/host.html
new file mode 100644
index 000000000..b79cd4d44
--- /dev/null
+++ b/platypush/backend/http/templates/plugins/music.snapcast/host.html
@@ -0,0 +1,34 @@
+{% include 'plugins/music.snapcast/group.html' %}
+
+
+
+
+
diff --git a/platypush/backend/http/templates/plugins/music.snapcast/index.html b/platypush/backend/http/templates/plugins/music.snapcast/index.html
new file mode 100644
index 000000000..4d48531f3
--- /dev/null
+++ b/platypush/backend/http/templates/plugins/music.snapcast/index.html
@@ -0,0 +1,83 @@
+{% include 'plugins/music.snapcast/host.html' %}
+
+
+