[rhythmbox] new web remote control plugin
- From: Jonathan Matthew <jmatthew src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [rhythmbox] new web remote control plugin
- Date: Mon, 9 May 2016 12:39:48 +0000 (UTC)
commit 445186d6577efecf7a3e7f4a8ecdd58347d0c800
Author: Jonathan Matthew <jonathan d14n org>
Date: Mon May 9 22:30:44 2016 +1000
new web remote control plugin
This provides a simple interface to control playback, and soon will
be able to stream the playing track to the client.
configure.ac | 1 +
data/org.gnome.rhythmbox.gschema.xml | 9 +
plugins/Makefile.am | 1 +
plugins/webremote/Makefile.am | 17 +
plugins/webremote/css/grids-responsive-min.css | 7 +
plugins/webremote/css/pure-min.css | 11 +
plugins/webremote/css/webremote.css | 72 +++
plugins/webremote/js/webremote.js | 399 ++++++++++++++
plugins/webremote/siphash.py | 261 ++++++++++
plugins/webremote/webremote-config.ui | 88 ++++
plugins/webremote/webremote.html | 82 +++
plugins/webremote/webremote.plugin.in | 10 +
plugins/webremote/webremote.py | 655 ++++++++++++++++++++++++
13 files changed, 1613 insertions(+), 0 deletions(-)
---
diff --git a/configure.ac b/configure.ac
index f1776eb..e67156a 100644
--- a/configure.ac
+++ b/configure.ac
@@ -805,6 +805,7 @@ plugins/notification/Makefile
plugins/visualizer/Makefile
plugins/grilo/Makefile
plugins/soundcloud/Makefile
+plugins/webremote/Makefile
sample-plugins/Makefile
sample-plugins/sample/Makefile
sample-plugins/sample-python/Makefile
diff --git a/data/org.gnome.rhythmbox.gschema.xml b/data/org.gnome.rhythmbox.gschema.xml
index 2d4df10..c529e7a 100644
--- a/data/org.gnome.rhythmbox.gschema.xml
+++ b/data/org.gnome.rhythmbox.gschema.xml
@@ -436,4 +436,13 @@
<schema id="org.gnome.rhythmbox.plugins.grilo" path="/org/gnome/rhythmbox/plugins/grilo/">
<child name="source" schema="org.gnome.rhythmbox.source"/>
</schema>
+
+ <schema id="org.gnome.rhythmbox.plugins.webremote" path="/org/gnome/rhythmbox/plugins/webremote/">
+ <key name="listen-port" type="q">
+ <default>0</default>
+ <summary>Listening port to use for the web remote control service</summary>
+ <description>Listening port to use for the web remote control service</description>
+ </key>
+
+ </schema>
</schemalist>
diff --git a/plugins/Makefile.am b/plugins/Makefile.am
index 3ae8a7a..efdb1c5 100644
--- a/plugins/Makefile.am
+++ b/plugins/Makefile.am
@@ -22,6 +22,7 @@ SUBDIRS += \
replaygain \
sendto \
soundcloud \
+ webremote \
rb
SUBDIRS += context
diff --git a/plugins/webremote/Makefile.am b/plugins/webremote/Makefile.am
new file mode 100644
index 0000000..d538ee5
--- /dev/null
+++ b/plugins/webremote/Makefile.am
@@ -0,0 +1,17 @@
+plugindir = $(PLUGINDIR)/webremote
+plugindatadir = $(PLUGINDATADIR)/webremote
+plugin_PYTHON = siphash.py webremote.py
+
+plugin_in_files = webremote.plugin.in
+%.plugin: %.plugin.in $(INTLTOOL_MERGE) $(wildcard $(top_srcdir)/po/*po) ; $(INTLTOOL_MERGE)
$(top_srcdir)/po $< $@ -d -u -c $(top_builddir)/po/.intltool-merge-cache
+
+plugin_DATA = $(plugin_in_files:.plugin.in=.plugin)
+
+gtkbuilderdir = $(plugindatadir)
+gtkbuilder_DATA = \
+ webremote-config.ui
+
+EXTRA_DIST = $(plugin_in_files) $(gtkbuilder_DATA)
+
+CLEANFILES = $(plugin_DATA)
+DISTCLEANFILES = $(plugin_DATA)
diff --git a/plugins/webremote/css/grids-responsive-min.css b/plugins/webremote/css/grids-responsive-min.css
new file mode 100644
index 0000000..7bcfd32
--- /dev/null
+++ b/plugins/webremote/css/grids-responsive-min.css
@@ -0,0 +1,7 @@
+/*!
+Pure v0.5.0
+Copyright 2014 Yahoo! Inc. All rights reserved.
+Licensed under the BSD License.
+https://github.com/yui/pure/blob/master/LICENSE.md
+*/
+ media screen and
(min-width:35.5em){.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-1-2,.pure-u-sm-1-3,.pure-u-sm-2-3,.pure-u-sm-1-4,.pure-u-sm-3-4,.pure-u-sm-1-5,.pure-u-sm-2-5,.pure-u-sm-3-5,.pure-u-sm-4-5,.pure-u-sm-5-5,.pure-u-sm-1-6,.pure-u-sm-5-6,.pure-u-sm-1-8,.pure-u-sm-3-8,.pure-u-sm-5-8,.pure-u-sm-7-8,.pure-u-sm-1-12,.pure-u-sm-5-12,.pure-u-sm-7-12,.pure-u-sm-11-12,.pure-u-sm-1-24,.pure-u-sm-2-24,.pure-u-sm-3-24,.pure-u-sm-4-24,.pure-u-sm-5-24,.pure-u-sm-6-24,.pure-u-sm-7-24,.pure-u-sm-8-24,.pure-u-sm-9-24,.pure-u-sm-10-24,.pure-u-sm-11-24,.pure-u-sm-12-24,.pure-u-sm-13-24,.pure-u-sm-14-24,.pure-u-sm-15-24,.pure-u-sm-16-24,.pure-u-sm-17-24,.pure-u-sm-18-24,.pure-u-sm-19-24,.pure-u-sm-20-24,.pure-u-sm-21-24,.pure-u-sm-22-24,.pure-u-sm-23-24,.pure-u-sm-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-sm-1-24{width:4.1667%;*width:4.1357%}.pure-u-sm-1-12,.pure-u-sm-2-24{width:8.3333%;*wid
th:8.3023%}.pure-u-sm-1-8,.pure-u-sm-3-24{width:12.5%;*width:12.469%}.pure-u-sm-1-6,.pure-u-sm-4-24{width:16.6667%;*width:16.6357%}.pure-u-sm-1-5{width:20%;*width:19.969%}.pure-u-sm-5-24{width:20.8333%;*width:20.8023%}.pure-u-sm-1-4,.pure-u-sm-6-24{width:25%;*width:24.969%}.pure-u-sm-7-24{width:29.1667%;*width:29.1357%}.pure-u-sm-1-3,.pure-u-sm-8-24{width:33.3333%;*width:33.3023%}.pure-u-sm-3-8,.pure-u-sm-9-24{width:37.5%;*width:37.469%}.pure-u-sm-2-5{width:40%;*width:39.969%}.pure-u-sm-5-12,.pure-u-sm-10-24{width:41.6667%;*width:41.6357%}.pure-u-sm-11-24{width:45.8333%;*width:45.8023%}.pure-u-sm-1-2,.pure-u-sm-12-24{width:50%;*width:49.969%}.pure-u-sm-13-24{width:54.1667%;*width:54.1357%}.pure-u-sm-7-12,.pure-u-sm-14-24{width:58.3333%;*width:58.3023%}.pure-u-sm-3-5{width:60%;*width:59.969%}.pure-u-sm-5-8,.pure-u-sm-15-24{width:62.5%;*width:62.469%}.pure-u-sm-2-3,.pure-u-sm-16-24{width:66.6667%;*width:66.6357%}.pure-u-sm-17-24{width:70.8333%;*width:70.8023%}.pure-u-sm-3-4,.p
ure-u-sm-18-24{width:75%;*width:74.969%}.pure-u-sm-19-24{width:79.1667%;*width:79.1357%}.pure-u-sm-4-5{width:80%;*width:79.969%}.pure-u-sm-5-6,.pure-u-sm-20-24{width:83.3333%;*width:83.3023%}.pure-u-sm-7-8,.pure-u-sm-21-24{width:87.5%;*width:87.469%}.pure-u-sm-11-12,.pure-u-sm-22-24{width:91.6667%;*width:91.6357%}.pure-u-sm-23-24{width:95.8333%;*width:95.8023%}.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-5-5,.pure-u-sm-24-24{width:100%}}
media screen and
(min-width:48em){.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-1-2,.pure-u-md-1-3,.pure-u-md-2-3,.pure-u-md-1-4,.pure-u-md-3-4,.pure-u-md-1-5,.pure-u-md-2-5,.pure-u-md-3-5,.pure-u-md-4-5,.pure-u-md-5-5,.pure-u-md-1-6,.pure-u-md-5-6,.pure-u-md-1-8,.pure-u-md-3-8,.pure-u-md-5-8,.pure-u-md-7-8,.pure-u-md-1-12,.pure-u-md-5-12,.pure-u-md-7-12,.pure-u-md-11-12,.pure-u-md-1-24,.pure-u-md-2-24,.pure-u-md-3-24,.pure-u-md-4-24,.pure-u-md-5-24,.pure-u-md-6-24,.pure-u-md-7-24,.pure-u-md-8-24,.pure-u-md-9-24,.pure-u-md-10-24,.pure-u-md-11-24,.pure-u-
md-12-24,.pure-u-md-13-24,.pure-u-md-14-24,.pure-u-md-15-24,.pure-u-md-16-24,.pure-u-md-17-24,.pure-u-md-18-24,.pure-u-md-19-24,.pure-u-md-20-24,.pure-u-md-21-24,.pure-u-md-22-24,.pure-u-md-23-24,.pure-u-md-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-md-1-24{width:4.1667%;*width:4.1357%}.pure-u-md-1-12,.pure-u-md-2-24{width:8.3333%;*width:8.3023%}.pure-u-md-1-8,.pure-u-md-3-24{width:12.5%;*width:12.469%}.pure-u-md-1-6,.pure-u-md-4-24{width:16.6667%;*width:16.6357%}.pure-u-md-1-5{width:20%;*width:19.969%}.pure-u-md-5-24{width:20.8333%;*width:20.8023%}.pure-u-md-1-4,.pure-u-md-6-24{width:25%;*width:24.969%}.pure-u-md-7-24{width:29.1667%;*width:29.1357%}.pure-u-md-1-3,.pure-u-md-8-24{width:33.3333%;*width:33.3023%}.pure-u-md-3-8,.pure-u-md-9-24{width:37.5%;*width:37.469%}.pure-u-md-2-5{width:40%;*width:39.969%}.pure-u-md-5-12,.pure-u-md-10-24{width:41.6667%;*width:41.6357%}.pure-u-md-
11-24{width:45.8333%;*width:45.8023%}.pure-u-md-1-2,.pure-u-md-12-24{width:50%;*width:49.969%}.pure-u-md-13-24{width:54.1667%;*width:54.1357%}.pure-u-md-7-12,.pure-u-md-14-24{width:58.3333%;*width:58.3023%}.pure-u-md-3-5{width:60%;*width:59.969%}.pure-u-md-5-8,.pure-u-md-15-24{width:62.5%;*width:62.469%}.pure-u-md-2-3,.pure-u-md-16-24{width:66.6667%;*width:66.6357%}.pure-u-md-17-24{width:70.8333%;*width:70.8023%}.pure-u-md-3-4,.pure-u-md-18-24{width:75%;*width:74.969%}.pure-u-md-19-24{width:79.1667%;*width:79.1357%}.pure-u-md-4-5{width:80%;*width:79.969%}.pure-u-md-5-6,.pure-u-md-20-24{width:83.3333%;*width:83.3023%}.pure-u-md-7-8,.pure-u-md-21-24{width:87.5%;*width:87.469%}.pure-u-md-11-12,.pure-u-md-22-24{width:91.6667%;*width:91.6357%}.pure-u-md-23-24{width:95.8333%;*width:95.8023%}.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-5-5,.pure-u-md-24-24{width:100%}}
media screen and
(min-width:64em){.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-1-2,.pure-u-lg-1-3,.pure-u-lg-2-3,.pure-u-lg-1-
4,.pure-u-lg-3-4,.pure-u-lg-1-5,.pure-u-lg-2-5,.pure-u-lg-3-5,.pure-u-lg-4-5,.pure-u-lg-5-5,.pure-u-lg-1-6,.pure-u-lg-5-6,.pure-u-lg-1-8,.pure-u-lg-3-8,.pure-u-lg-5-8,.pure-u-lg-7-8,.pure-u-lg-1-12,.pure-u-lg-5-12,.pure-u-lg-7-12,.pure-u-lg-11-12,.pure-u-lg-1-24,.pure-u-lg-2-24,.pure-u-lg-3-24,.pure-u-lg-4-24,.pure-u-lg-5-24,.pure-u-lg-6-24,.pure-u-lg-7-24,.pure-u-lg-8-24,.pure-u-lg-9-24,.pure-u-lg-10-24,.pure-u-lg-11-24,.pure-u-lg-12-24,.pure-u-lg-13-24,.pure-u-lg-14-24,.pure-u-lg-15-24,.pure-u-lg-16-24,.pure-u-lg-17-24,.pure-u-lg-18-24,.pure-u-lg-19-24,.pure-u-lg-20-24,.pure-u-lg-21-24,.pure-u-lg-22-24,.pure-u-lg-23-24,.pure-u-lg-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-lg-1-24{width:4.1667%;*width:4.1357%}.pure-u-lg-1-12,.pure-u-lg-2-24{width:8.3333%;*width:8.3023%}.pure-u-lg-1-8,.pure-u-lg-3-24{width:12.5%;*width:12.469%}.pure-u-lg-1-6,.pure-u-lg-4-24{width:16.6667%;*width:1
6.6357%}.pure-u-lg-1-5{width:20%;*width:19.969%}.pure-u-lg-5-24{width:20.8333%;*width:20.8023%}.pure-u-lg-1-4,.pure-u-lg-6-24{width:25%;*width:24.969%}.pure-u-lg-7-24{width:29.1667%;*width:29.1357%}.pure-u-lg-1-3,.pure-u-lg-8-24{width:33.3333%;*width:33.3023%}.pure-u-lg-3-8,.pure-u-lg-9-24{width:37.5%;*width:37.469%}.pure-u-lg-2-5{width:40%;*width:39.969%}.pure-u-lg-5-12,.pure-u-lg-10-24{width:41.6667%;*width:41.6357%}.pure-u-lg-11-24{width:45.8333%;*width:45.8023%}.pure-u-lg-1-2,.pure-u-lg-12-24{width:50%;*width:49.969%}.pure-u-lg-13-24{width:54.1667%;*width:54.1357%}.pure-u-lg-7-12,.pure-u-lg-14-24{width:58.3333%;*width:58.3023%}.pure-u-lg-3-5{width:60%;*width:59.969%}.pure-u-lg-5-8,.pure-u-lg-15-24{width:62.5%;*width:62.469%}.pure-u-lg-2-3,.pure-u-lg-16-24{width:66.6667%;*width:66.6357%}.pure-u-lg-17-24{width:70.8333%;*width:70.8023%}.pure-u-lg-3-4,.pure-u-lg-18-24{width:75%;*width:74.969%}.pure-u-lg-19-24{width:79.1667%;*width:79.1357%}.pure-u-lg-4-5{width:80%;*width:79.
969%}.pure-u-lg-5-6,.pure-u-lg-20-24{width:83.3333%;*width:83.3023%}.pure-u-lg-7-8,.pure-u-lg-21-24{width:87.5%;*width:87.469%}.pure-u-lg-11-12,.pure-u-lg-22-24{width:91.6667%;*width:91.6357%}.pure-u-lg-23-24{width:95.8333%;*width:95.8023%}.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-5-5,.pure-u-lg-24-24{width:100%}}
media screen and
(min-width:80em){.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-1-2,.pure-u-xl-1-3,.pure-u-xl-2-3,.pure-u-xl-1-4,.pure-u-xl-3-4,.pure-u-xl-1-5,.pure-u-xl-2-5,.pure-u-xl-3-5,.pure-u-xl-4-5,.pure-u-xl-5-5,.pure-u-xl-1-6,.pure-u-xl-5-6,.pure-u-xl-1-8,.pure-u-xl-3-8,.pure-u-xl-5-8,.pure-u-xl-7-8,.pure-u-xl-1-12,.pure-u-xl-5-12,.pure-u-xl-7-12,.pure-u-xl-11-12,.pure-u-xl-1-24,.pure-u-xl-2-24,.pure-u-xl-3-24,.pure-u-xl-4-24,.pure-u-xl-5-24,.pure-u-xl-6-24,.pure-u-xl-7-24,.pure-u-xl-8-24,.pure-u-xl-9-24,.pure-u-xl-10-24,.pure-u-xl-11-24,.pure-u-xl-12-24,.pure-u-xl-13-24,.pure-u-xl-14-24,.pure-u-xl-15-24,.pure-u-xl-16-24,.pure-u-xl-17-24,.pure-u-xl-18-24,.pure-u-xl-1
9-24,.pure-u-xl-20-24,.pure-u-xl-21-24,.pure-u-xl-22-24,.pure-u-xl-23-24,.pure-u-xl-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xl-1-24{width:4.1667%;*width:4.1357%}.pure-u-xl-1-12,.pure-u-xl-2-24{width:8.3333%;*width:8.3023%}.pure-u-xl-1-8,.pure-u-xl-3-24{width:12.5%;*width:12.469%}.pure-u-xl-1-6,.pure-u-xl-4-24{width:16.6667%;*width:16.6357%}.pure-u-xl-1-5{width:20%;*width:19.969%}.pure-u-xl-5-24{width:20.8333%;*width:20.8023%}.pure-u-xl-1-4,.pure-u-xl-6-24{width:25%;*width:24.969%}.pure-u-xl-7-24{width:29.1667%;*width:29.1357%}.pure-u-xl-1-3,.pure-u-xl-8-24{width:33.3333%;*width:33.3023%}.pure-u-xl-3-8,.pure-u-xl-9-24{width:37.5%;*width:37.469%}.pure-u-xl-2-5{width:40%;*width:39.969%}.pure-u-xl-5-12,.pure-u-xl-10-24{width:41.6667%;*width:41.6357%}.pure-u-xl-11-24{width:45.8333%;*width:45.8023%}.pure-u-xl-1-2,.pure-u-xl-12-24{width:50%;*width:49.969%}.pure-u-xl-13-24{width:54.166
7%;*width:54.1357%}.pure-u-xl-7-12,.pure-u-xl-14-24{width:58.3333%;*width:58.3023%}.pure-u-xl-3-5{width:60%;*width:59.969%}.pure-u-xl-5-8,.pure-u-xl-15-24{width:62.5%;*width:62.469%}.pure-u-xl-2-3,.pure-u-xl-16-24{width:66.6667%;*width:66.6357%}.pure-u-xl-17-24{width:70.8333%;*width:70.8023%}.pure-u-xl-3-4,.pure-u-xl-18-24{width:75%;*width:74.969%}.pure-u-xl-19-24{width:79.1667%;*width:79.1357%}.pure-u-xl-4-5{width:80%;*width:79.969%}.pure-u-xl-5-6,.pure-u-xl-20-24{width:83.3333%;*width:83.3023%}.pure-u-xl-7-8,.pure-u-xl-21-24{width:87.5%;*width:87.469%}.pure-u-xl-11-12,.pure-u-xl-22-24{width:91.6667%;*width:91.6357%}.pure-u-xl-23-24{width:95.8333%;*width:95.8023%}.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-5-5,.pure-u-xl-24-24{width:100%}}
\ No newline at end of file
diff --git a/plugins/webremote/css/pure-min.css b/plugins/webremote/css/pure-min.css
new file mode 100644
index 0000000..14497d9
--- /dev/null
+++ b/plugins/webremote/css/pure-min.css
@@ -0,0 +1,11 @@
+/*!
+Pure v0.5.0
+Copyright 2014 Yahoo! Inc. All rights reserved.
+Licensed under the BSD License.
+https://github.com/yui/pure/blob/master/LICENSE.md
+*/
+/*!
+normalize.css v1.1.3 | MIT License | git.io/normalize
+Copyright (c) Nicolas Gallagher and Jonathan Neal
+*/
+/*! normalize.css v1.1.3 | MIT License | git.io/normalize
*/article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}audio:not([controls]){display:none;height:0}[hidden]{display:none}html{font-size:100%;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}html,button,input,select,textarea{font-family:sans-serif}body{margin:0}a:focus{outline:thin
dotted}a:active,a:hover{outline:0}h1{font-size:2em;margin:.67em 0}h2{font-size:1.5em;margin:.83em
0}h3{font-size:1.17em;margin:1em 0}h4{font-size:1em;margin:1.33em 0}h5{font-size:.83em;margin:1.67em
0}h6{font-size:.67em;margin:2.33em 0}abbr[title]{border-bottom:1px
dotted}b,strong{font-weight:700}blockquote{margin:1em
40px}dfn{font-style:italic}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}mark{background:#ff0;color:#000}p,pre{margin:1em
0}code,kbd,pre,samp{font-family:monospace,serif;_font-family:'courier ne
w',monospace;font-size:1em}pre{white-space:pre;white-space:pre-wrap;word-wrap:break-word}q{quotes:none}q:before,q:after{content:'';content:none}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,menu,ol,ul{margin:1em
0}dd{margin:0 0 0 40px}menu,ol,ul{padding:0 0 0 40px}nav ul,nav
ol{list-style:none;list-style-image:none}img{border:0;-ms-interpolation-mode:bicubic}svg:not(:root){overflow:hidden}figure{margin:0}form{margin:0}fieldset{border:1px
solid silver;margin:0 2px;padding:.35em .625em
.75em}legend{border:0;padding:0;white-space:normal;*margin-left:-7px}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,select{text-transform:none}button,html
input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;*overflow:visible}button[disabled],html
input[disabled]{cursor:defaul
t}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0;*height:13px;*width:13px}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}textarea{overflow:auto;vertical-align:top}table{border-collapse:collapse;border-spacing:0}[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em;text-rendering:optimizespeed;font-family:FreeSans,Arimo,"Droid
Sans",Helvetica,Arial,sans-serif;display:-webkit-flex;-webkit-flex-flow:row
wrap;display:-ms-flexbox;-ms-flex-flow:row wrap}.opera-only
:-o-prefocus,.pure-g{word-spacing:-.43em}.pure-u{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:
normal;vertical-align:top;text-rendering:auto}.pure-g [class
*="pure-u"]{font-family:sans-serif}.pure-u-1,.pure-u-1-1,.pure-u-1-2,.pure-u-1-3,.pure-u-2-3,.pure-u-1-4,.pure-u-3-4,.pure-u-1-5,.pure-u-2-5,.pure-u-3-5,.pure-u-4-5,.pure-u-5-5,.pure-u-1-6,.pure-u-5-6,.pure-u-1-8,.pure-u-3-8,.pure-u-5-8,.pure-u-7-8,.pure-u-1-12,.pure-u-5-12,.pure-u-7-12,.pure-u-11-12,.pure-u-1-24,.pure-u-2-24,.pure-u-3-24,.pure-u-4-24,.pure-u-5-24,.pure-u-6-24,.pure-u-7-24,.pure-u-8-24,.pure-u-9-24,.pure-u-10-24,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%;*width:4.1357%}.pure-u-1-12,.pure-u-2-24{width:8.3333%;*width:8.3023%}.pure-u-1-8,.pure-u-3-24{width:12.5%;*width:12.469%}.pure-u-1-6,.pure-u-4-24{
width:16.6667%;*width:16.6357%}.pure-u-1-5{width:20%;*width:19.969%}.pure-u-5-24{width:20.8333%;*width:20.8023%}.pure-u-1-4,.pure-u-6-24{width:25%;*width:24.969%}.pure-u-7-24{width:29.1667%;*width:29.1357%}.pure-u-1-3,.pure-u-8-24{width:33.3333%;*width:33.3023%}.pure-u-3-8,.pure-u-9-24{width:37.5%;*width:37.469%}.pure-u-2-5{width:40%;*width:39.969%}.pure-u-5-12,.pure-u-10-24{width:41.6667%;*width:41.6357%}.pure-u-11-24{width:45.8333%;*width:45.8023%}.pure-u-1-2,.pure-u-12-24{width:50%;*width:49.969%}.pure-u-13-24{width:54.1667%;*width:54.1357%}.pure-u-7-12,.pure-u-14-24{width:58.3333%;*width:58.3023%}.pure-u-3-5{width:60%;*width:59.969%}.pure-u-5-8,.pure-u-15-24{width:62.5%;*width:62.469%}.pure-u-2-3,.pure-u-16-24{width:66.6667%;*width:66.6357%}.pure-u-17-24{width:70.8333%;*width:70.8023%}.pure-u-3-4,.pure-u-18-24{width:75%;*width:74.969%}.pure-u-19-24{width:79.1667%;*width:79.1357%}.pure-u-4-5{width:80%;*width:79.969%}.pure-u-5-6,.pure-u-20-24{width:83.3333%;*width:83.3023%
}.pure-u-7-8,.pure-u-21-24{width:87.5%;*width:87.469%}.pure-u-11-12,.pure-u-22-24{width:91.6667%;*width:91.6357%}.pure-u-23-24{width:95.8333%;*width:95.8023%}.pure-u-1,.pure-u-1-1,.pure-u-5-5,.pure-u-24-24{width:100%}.pure-button{display:inline-block;*display:inline;zoom:1;line-height:normal;white-space:nowrap;vertical-align:baseline;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button{font-family:inherit;font-size:100%;*font-size:90%;*overflow:visible;padding:.5em
1em;color:#444;color:rgba(0,0,0,.8);*color:#444;border:1px solid #999;border:0
rgba(0,0,0,0);background-color:#E6E6E6;text-decoration:none;border-radius:2px}.pure-button-hover,.pure-button:hover,.pure-button:focus{filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000',
endColorstr='#1a000000', GradientType=0);background-image:-webkit-gradient(li
near,0 0,0
100%,from(transparent),color-stop(40%,rgba(0,0,0,.05)),to(rgba(0,0,0,.1)));background-image:-webkit-linear-gradient(transparent,rgba(0,0,0,.05)
40%,rgba(0,0,0,.1));background-image:-moz-linear-gradient(top,rgba(0,0,0,.05)
0,rgba(0,0,0,.1));background-image:-o-linear-gradient(transparent,rgba(0,0,0,.05)
40%,rgba(0,0,0,.1));background-image:linear-gradient(transparent,rgba(0,0,0,.05)
40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{box-shadow:0 0 0 1px
rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2)
inset}.pure-button[disabled],.pure-button-disabled,.pure-button-disabled:hover,.pure-button-disabled:focus,.pure-button-disabled:active{border:0;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);filter:alpha(opacity=40);-khtml-opacity:.4;-moz-opacity:.4;opacity:.4;cursor:not-allowed;box-shadow:none}.pure-button-hidden{display:none}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-prima
ry,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff}.pure-form
input[type=text],.pure-form input[type=password],.pure-form input[type=email],.pure-form
input[type=url],.pure-form input[type=date],.pure-form input[type=month],.pure-form
input[type=time],.pure-form input[type=datetime],.pure-form input[type=datetime-local],.pure-form
input[type=week],.pure-form input[type=number],.pure-form input[type=search],.pure-form
input[type=tel],.pure-form input[type=color],.pure-form select,.pure-form textarea{padding:.5em
.6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px
#ddd;border-radius:4px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-form
input:not([type]){padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px
#ddd;border-radius:4px;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.pure-form
input[type
=color]{padding:.2em .5em}.pure-form input[type=text]:focus,.pure-form input[type=password]:focus,.pure-form
input[type=email]:focus,.pure-form input[type=url]:focus,.pure-form input[type=date]:focus,.pure-form
input[type=month]:focus,.pure-form input[type=time]:focus,.pure-form input[type=datetime]:focus,.pure-form
input[type=datetime-local]:focus,.pure-form input[type=week]:focus,.pure-form
input[type=number]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form
input[type=color]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;outline:thin dotted
\9;border-color:#129FEA}.pure-form input:not([type]):focus{outline:0;outline:thin dotted
\9;border-color:#129FEA}.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus,.pure-form
input[type=checkbox]:focus{outline:thin dotted #333;outline:1px auto #129FEA}.pure-form
.pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=text][disabled]
,.pure-form input[type=password][disabled],.pure-form input[type=email][disabled],.pure-form
input[type=url][disabled],.pure-form input[type=date][disabled],.pure-form
input[type=month][disabled],.pure-form input[type=time][disabled],.pure-form
input[type=datetime][disabled],.pure-form input[type=datetime-local][disabled],.pure-form
input[type=week][disabled],.pure-form input[type=number][disabled],.pure-form
input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form
input[type=color][disabled],.pure-form select[disabled],.pure-form
textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form
input:not([type])[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form
input[readonly],.pure-form select[readonly],.pure-form
textarea[readonly]{background:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form
textarea:focus:invalid,.pure-form select:focus:invalid{color:#b94a48;border-color:#ee5
f5b}.pure-form input:focus:invalid:focus,.pure-form textarea:focus:invalid:focus,.pure-form
select:focus:invalid:focus{border-color:#e9322d}.pure-form input[type=file]:focus:invalid:focus,.pure-form
input[type=radio]:focus:invalid:focus,.pure-form
input[type=checkbox]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{border:1px solid
#ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0
.2em}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form
legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;color:#333;border-bottom:1px solid
#e5e5e5}.pure-form-stacked input[type=text],.pure-form-stacked input[type=password],.pure-form-stacked
input[type=email],.pure-form-stacked input[type=url],.pure-form-stacked input[type=date],.pure-form-stacked
input[type=month],.pure-form-stacked input[type=time],.pure-form-stacked
input[type=datetime],.pure-form-stacked input[type=datetime-local],.pure-form-stacke
d input[type=week],.pure-form-stacked input[type=number],.pure-form-stacked
input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=color],.pure-form-stacked
select,.pure-form-stacked label,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-stacked
input:not([type]){display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned
textarea,.pure-form-aligned select,.pure-form-aligned
.pure-help-inline,.pure-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.pure-form-aligned
textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned
.pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em
0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 10em}.pure-form input.pure-input-rounded,.pure-form
.pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bott
om:10px}.pure-form .pure-group
input{display:block;padding:10px;margin:0;border-radius:0;position:relative;top:-1px}.pure-form .pure-group
input:focus{z-index:2}.pure-form .pure-group input:first-child{top:1px;border-radius:4px 4px 0 0}.pure-form
.pure-group input:last-child{top:-2px;border-radius:0 0 4px 4px}.pure-form .pure-group button{margin:.35em
0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-2-3{width:66%}.pure-form
.pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form
.pure-input-1-4{width:25%}.pure-form
.pure-help-inline,.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:.875em}.pure-form-message{display:block;color:#666;font-size:
875em} media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form
input:not([type]),.pure-form input[type=text],.pure-form input[type=password],.pure-form
input[type=email],.pure-form input[type=url],.pure-form
input[type=date],.pure-form input[type=month],.pure-form input[type=time],.pure-form
input[type=datetime],.pure-form input[type=datetime-local],.pure-form input[type=week],.pure-form
input[type=number],.pure-form input[type=search],.pure-form input[type=tel],.pure-form
input[type=color],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group
input[type=text],.pure-group input[type=password],.pure-group input[type=email],.pure-group
input[type=url],.pure-group input[type=date],.pure-group input[type=month],.pure-group
input[type=time],.pure-group input[type=datetime],.pure-group input[type=datetime-local],.pure-group
input[type=week],.pure-group input[type=number],.pure-group input[type=search],.pure-group
input[type=tel],.pure-group input[type=color]{margin-bottom:0}.pure-form-aligned .pure-control-group
label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned
.pure-controls{margin:1.5em 0 0}.pure-form .pure-he
lp-inline,.pure-form-message-inline,.pure-form-message{display:block;font-size:.75em;padding:.2em 0
.8em}}.pure-menu
ul{position:absolute;visibility:hidden}.pure-menu.pure-menu-open{visibility:visible;z-index:2;width:100%}.pure-menu
ul{left:-10000px;list-style:none;margin:0;padding:0;top:-10000px;z-index:1}.pure-menu>ul{position:relative}.pure-menu-open>ul{left:0;top:0;visibility:visible}.pure-menu-open>ul:focus{outline:0}.pure-menu
li{position:relative}.pure-menu a,.pure-menu
.pure-menu-heading{display:block;color:inherit;line-height:1.5em;padding:5px
20px;text-decoration:none;white-space:nowrap}.pure-menu.pure-menu-horizontal>.pure-menu-heading{display:inline-block;*display:inline;zoom:1;margin:0;vertical-align:middle}.pure-menu.pure-menu-horizontal>ul{display:inline-block;*display:inline;zoom:1;vertical-align:middle}.pure-menu
li a{padding:5px
20px}.pure-menu-can-have-children>.pure-menu-label:after{content:'\25B8';float:right;font-family:'Lucida
Grande','Lucida Sans Unic
ode','DejaVu
Sans',sans-serif;margin-right:-20px;margin-top:-1px}.pure-menu-can-have-children>.pure-menu-label{padding-right:30px}.pure-menu-separator{background-color:#dfdfdf;display:block;height:1px;font-size:0;margin:7px
2px;overflow:hidden}.pure-menu-hidden{display:none}.pure-menu-fixed{position:fixed;top:0;left:0;width:100%}.pure-menu-horizontal
li{display:inline-block;*display:inline;zoom:1;vertical-align:middle}.pure-menu-horizontal li
li{display:block}.pure-menu-horizontal>.pure-menu-children>.pure-menu-can-have-children>.pure-menu-label:after{content:"\25BE"}.pure-menu-horizontal>.pure-menu-children>.pure-menu-can-have-children>.pure-menu-label{padding-right:30px}.pure-menu-horizontal
li.pure-menu-separator{height:50%;width:1px;margin:0 7px}.pure-menu-horizontal li
li.pure-menu-separator{height:1px;width:auto;margin:7px
2px}.pure-menu.pure-menu-open,.pure-menu.pure-menu-horizontal li
.pure-menu-children{background:#fff;border:1px solid #b7b7b7}.pure-menu.pure-menu-h
orizontal,.pure-menu.pure-menu-horizontal .pure-menu-heading{border:0}.pure-menu a{border:1px solid
transparent;border-left:0;border-right:0}.pure-menu a,.pure-menu
.pure-menu-can-have-children>li:after{color:#777}.pure-menu
.pure-menu-can-have-children>li:hover:after{color:#fff}.pure-menu
.pure-menu-open{background:#dedede}.pure-menu li a:hover,.pure-menu li a:focus{background:#eee}.pure-menu
li.pure-menu-disabled a:hover,.pure-menu li.pure-menu-disabled
a:focus{background:#fff;color:#bfbfbf}.pure-menu
.pure-menu-disabled>a{background-image:none;border-color:transparent;cursor:default}.pure-menu
.pure-menu-disabled>a,.pure-menu
.pure-menu-can-have-children.pure-menu-disabled>a:after{color:#bfbfbf}.pure-menu
.pure-menu-heading{color:#565d64;text-transform:uppercase;font-size:90%;margin-top:.5em;border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#dfdfdf}.pure-menu
.pure-menu-selected a{color:#000}.pure-menu.pure-menu-open.pure-menu-fixed{border:0;border-bot
tom:1px solid
#b7b7b7}.pure-paginator{letter-spacing:-.31em;*letter-spacing:normal;*word-spacing:-.43em;text-rendering:optimizespeed;list-style:none;margin:0;padding:0}.opera-only
:-o-prefocus,.pure-paginator{word-spacing:-.43em}.pure-paginator
li{display:inline-block;*display:inline;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-paginator
.pure-button{border-radius:0;padding:.8em 1.4em;vertical-align:top;height:1.1em}.pure-paginator
.pure-button:focus,.pure-paginator .pure-button:active{outline-style:none}.pure-paginator
.prev,.pure-paginator .next{color:#C0C1C3;text-shadow:0 -1px 0 rgba(0,0,0,.45)}.pure-paginator
.prev{border-radius:2px 0 0 2px}.pure-paginator .next{border-radius:0 2px 2px 0} media
(max-width:480px){.pure-menu-horizontal{width:100%}.pure-menu-children li{display:block;border-bottom:1px
solid #000}}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid
#cbcbcb}.pure-table captio
n{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table
th{border-left:1px solid #cbcbcb;border-width:0 0 0
1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table td:first-child,.pure-table
th:first-child{border-left-width:0}.pure-table
thead{background:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table
td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped
tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid
#cbcbcb}.pure-table-bordered tbody>tr:last-child td,.pure-table-horizontal tbody>tr:last-child
td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0
1px;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child td{border-bottom-width:0}
\ No newline at end of file
diff --git a/plugins/webremote/css/webremote.css b/plugins/webremote/css/webremote.css
new file mode 100644
index 0000000..6b6b03c
--- /dev/null
+++ b/plugins/webremote/css/webremote.css
@@ -0,0 +1,72 @@
+body {
+ background-color: white;
+}
+div.wrapper {
+ max-width: 960px;
+ margin: 0px auto;
+}
+
+div#playerbuttons {
+ border-color: black;
+ border-style: solid;
+ border-width: 0.1rem 0 0.1rem 0;
+}
+div.playerbutton {
+ text-align: center;
+ margin: 1rem; display: block;
+}
+
+div#status, div#trackbits, div#trackposition {
+ font-family: "Cantarell";
+}
+div#connection {
+ float: right;
+}
+div#stream {
+ float: left;
+}
+
+div#trackimage {
+ height: 50%;
+ max-width: 100%;
+ margin: 10px auto;
+}
+img.albumart {
+ height: 100%;
+ width: auto;
+ margin: 10px auto;
+ display: block;
+ border: 0.1rem solid black;
+}
+img.noalbumart {
+ height: 100%;
+ width: auto;
+ margin: 10px auto;
+ display: block;
+ border: none;
+}
+
+#connectform {
+ text-align: center;
+ font-size: x-large;
+ padding: 10px;
+}
+
+#trackbits {
+ text-align: center;
+}
+#tracktitle {
+ font-size: x-large;
+}
+#trackalbum {
+ font-style: italic;
+}
+
+#seekbar {
+ margin: 0 0 1rem 0;
+}
+#seekbar-range {
+ display: block;
+ margin: 0px auto;
+ width: 100%;
+}
diff --git a/plugins/webremote/js/webremote.js b/plugins/webremote/js/webremote.js
new file mode 100644
index 0000000..f0b447e
--- /dev/null
+++ b/plugins/webremote/js/webremote.js
@@ -0,0 +1,399 @@
+
+var timer = null;
+var lasttime = null;
+var seeking = false;
+var lastposition = 0.0;
+
+var streaming = false;
+var needsync = false;
+var audiotag = null;
+var s = null;
+
+var accesskey = '';
+
+var sign = function(path) {
+ sh = new SipHash();
+ ts = new Date().getTime();
+ message = path + "\n" + ts;
+ return "sig=" + sh.hash_hex(sh.string_to_key(accesskey), message) + "&ts=" + ts;
+};
+
+var pad = function(str, len, chr, left) {
+ var padding = (str.length >= len) ? '' : new Array(1 + len - str.length >>> 0).join(chr);
+ return left ? str + padding : padding + str;
+};
+var timestr = function(s) {
+ var h = Math.floor(s / (60*60));
+ var m = Math.floor((s % (60*60)) / 60);
+ var sec = Math.floor(s % 60);
+ if (h >= 1) {
+ return pad(h.toString(), 2, '0', false) + ":" +
+ pad(m.toString(), 2, '0', false) + ":" +
+ pad(sec.toString(), 2, '0', false);
+ } else {
+ return pad(m.toString(), 2, '0', false) + ":" +
+ pad(sec.toString(), 2, '0', false);
+ }
+};
+
+var tick = function() {
+ now = new Date();
+ var elapsed = (now.getTime() - lasttime.getTime());
+ lasttime = now;
+
+ if (seeking === false) {
+ lastposition = lastposition + elapsed;
+ document.getElementById("seekbar-range").value = lastposition;
+ document.getElementById("trackposition").textContent = timestr(lastposition/1000);
+ }
+
+ if (streaming && needsync) {
+ audioTimeSync();
+ }
+};
+
+var audioCanSeekTo = function(seektime) {
+ for (i = 0; i < audiotag.seekable.length; i++) {
+ if (audiotag.seekable.start(i) > seektime) {
+ return false;
+ }
+ if (audiotag.seekable.end(i) > seektime) {
+ return true;
+ }
+ }
+ return false;
+};
+
+var audioTimeSync = function() {
+ if (needsync) {
+ if (audioCanSeekTo(lastposition/1000)) {
+ // probably check seekable ranges and stuff?
+ audiotag.currentTime = lastposition/1000;
+
+ if (timer != null) {
+ audiotag.play();
+ }
+ needsync = false;
+ }
+ }
+};
+
+var createAudioTag = function() {
+ audiotag = document.createElement("audio");
+ // plugin should give us direct stream urls if they exist
+ // and should tell us if the thing can't be streamed (cdda etc.) too
+ path = "/entry/current/stream";
+ audiotag.setAttribute("src", path + "?" + sign(path));
+ audiotag.setAttribute("preload", "auto");
+
+ audiotag.addEventListener('canplay', audioTimeSync);
+ needsync = true;
+
+ c = document.getElementById("stream-container");
+ while (c.firstChild) {
+ c.removeChild(c.firstChild);
+ }
+ c.appendChild(audiotag);
+};
+
+var replaceImage = function(element, imgclass, imgsrc) {
+ img = document.createElement("img");
+ img.setAttribute("class", imgclass);
+
+ img.setAttribute("src", imgsrc);
+
+ e = document.getElementById(element);
+ while (e.firstChild) {
+ e.removeChild(e.firstChild);
+ }
+ e.appendChild(img);
+};
+
+var connectionState = function(connected) {
+ document.getElementById("connectform").hidden = connected;
+ document.getElementById("trackbits").hidden = !connected;
+ document.getElementById("seekbar").hidden = !connected;
+ pb = document.getElementById("playerbuttons");
+ if (connected) {
+ pb.classList.remove("pure-button-disabled");
+ } else {
+ pb.classList.add("pure-button-disabled");
+ }
+}
+
+
+var connect = function() {
+ accesskey = document.getElementById("accesskey").value;
+
+ loc = document.location.hostname + ":" + document.location.port;
+ s = new WebSocket("ws://" + loc + "/ws/player:" + sign("/ws/player"));
+ s.onopen = function(event) {
+ connectionState(true);
+ msg = { "action": "status" };
+ s.send(JSON.stringify(msg));
+ };
+
+ s.onclose = function(event) {
+ document.getElementById("connectionhostname").textContent = "";
+ window.clearInterval(timer);
+ timer = null;
+ lasttime = null;
+
+ connectionState(false);
+ };
+ s.onmessage = function(event) {
+ m = JSON.parse(event.data);
+ if ("shutdown" in m) {
+ if (streaming) {
+ toggleStreaming();
+ }
+ }
+ if ("hostname" in m) {
+ document.getElementById("connection-hostname").textContent = m['hostname'];
+ }
+ if ("id" in m) {
+ lastposition = 0;
+ document.getElementById("seekbar-range").value = 0;
+ document.getElementById("trackposition").textContent = timestr(0);
+
+ if (streaming) {
+ createAudioTag();
+ }
+ }
+ if ("title" in m) {
+ document.title = m.title;
+ document.getElementById("tracktitle").textContent = m.title;
+ }
+ if ("artist" in m) {
+ document.getElementById("trackartist").textContent = m.artist;
+ }
+ if ("album" in m) {
+ document.getElementById("trackalbum").textContent = m.album;
+ }
+ if ("duration" in m) {
+ range = document.getElementById("seekbar-range");
+ range.max = m.duration*1000;
+ range.min = 0;
+ document.getElementById('trackduration').textContent = timestr(m.duration);
+ }
+ if ("playing" in m) {
+ if (m.playing) {
+ iconname = "media-playback-pause-symbolic";
+ if (timer == null) {
+ timer = window.setInterval(tick, 250);
+ lasttime = new Date();
+ }
+ if (streaming) {
+ audiotag.play();
+ }
+ } else {
+ iconname = "media-playback-start-symbolic";
+ window.clearInterval(timer);
+ timer = null;
+ lasttime = null;
+ if (streaming) {
+ audiotag.pause();
+ }
+ }
+
+ replaceImage("playpause", "", "/icon/" + iconname + "/48");
+ }
+ if ("position" in m) {
+ lastposition = m.position;
+ document.getElementById("seekbar-range").value = m.position;
+ document.getElementById('trackposition').textContent = timestr(m.position / 1000);
+ seeking = false;
+
+ if (streaming) {
+ needsync = true;
+ audioTimeSync();
+ }
+ }
+ if ("albumart" in m) {
+ if (m.albumart != null) {
+ path = "/art/" + m.albumart;
+ imgsrc = path + "?" + sign(path);
+ imgclass = "albumart";
+ } else {
+ imgsrc = "/icon/rhythmbox-symbolic/128";
+ imgclass = "noalbumart";
+ }
+ replaceImage("trackimage", imgclass, imgsrc);
+ }
+ };
+
+ return false;
+};
+
+var sendevent = function() {
+ s.send(JSON.stringify({ 'action': this.id }));
+};
+
+var seekinput = function() {
+ if (seeking === false) {
+ time = this.value / 1000;
+ s.send(JSON.stringify({ 'action': 'seek', 'time': time}));
+ seeking = true;
+ }
+};
+
+var toggleStreaming = function() {
+ if (streaming) {
+ streaming = false;
+ audiotag.pause();
+ c = document.getElementById("stream-container");
+ c.removeChild(audiotag);
+ audiotag = null;
+ } else {
+ streaming = true;
+ createAudioTag();
+ }
+};
+
+window.onload = function() {
+ document.getElementById("previous").addEventListener('click', sendevent);
+ document.getElementById("playpause").addEventListener('click', sendevent);
+ document.getElementById("next").addEventListener('click', sendevent);
+
+ document.getElementById("seekbar-range").addEventListener('input', seekinput);
+
+ // document.getElementById("stream-check").addEventListener('click', toggleStreaming);
+
+ document.getElementById("connectform").onsubmit = connect;
+
+ connectionState(false);
+}
+
+// siphash implementation from https://github.com/jedisct1/siphash-js
+
+function SipHash() {
+ function _add(a, b) {
+ var rl = a.l + b.l,
+ a2 = { h: a.h + b.h + (rl / 2 >>> 31) >>> 0,
+ l: rl >>> 0 };
+ a.h = a2.h; a.l = a2.l;
+ }
+
+ function _xor(a, b) {
+ a.h ^= b.h; a.h >>>= 0;
+ a.l ^= b.l; a.l >>>= 0;
+ }
+
+ function _rotl(a, n) {
+ var a2 = {
+ h: a.h << n | a.l >>> (32 - n),
+ l: a.l << n | a.h >>> (32 - n)
+ };
+ a.h = a2.h; a.l = a2.l;
+ }
+
+ function _rotl32(a) {
+ var al = a.l;
+ a.l = a.h; a.h = al;
+ }
+
+ function _compress(v0, v1, v2, v3) {
+ _add(v0, v1);
+ _add(v2, v3);
+ _rotl(v1, 13);
+ _rotl(v3, 16);
+ _xor(v1, v0);
+ _xor(v3, v2);
+ _rotl32(v0);
+ _add(v2, v1);
+ _add(v0, v3);
+ _rotl(v1, 17);
+ _rotl(v3, 21);
+ _xor(v1, v2);
+ _xor(v3, v0);
+ _rotl32(v2);
+ }
+
+ function _get_int(a, offset) {
+ return a.charCodeAt(offset + 3) << 24 |
+ a.charCodeAt(offset + 2) << 16 |
+ a.charCodeAt(offset + 1) << 8 |
+ a.charCodeAt(offset);
+ }
+
+ function hash(key, m) {
+ var k0 = { h: key[1] >>> 0, l: key[0] >>> 0 },
+ k1 = { h: key[3] >>> 0, l: key[2] >>> 0 },
+ v0 = { h: k0.h, l: k0.l }, v2 = k0,
+ v1 = { h: k1.h, l: k1.l }, v3 = k1,
+ mi, mp = 0, ml = m.length, ml7 = ml - 7,
+ buf = new Uint8Array(new ArrayBuffer(8));
+
+ _xor(v0, { h: 0x736f6d65, l: 0x70736575 });
+ _xor(v1, { h: 0x646f7261, l: 0x6e646f6d });
+ _xor(v2, { h: 0x6c796765, l: 0x6e657261 });
+ _xor(v3, { h: 0x74656462, l: 0x79746573 });
+ while (mp < ml7) {
+ mi = { h: _get_int(m, mp + 4), l: _get_int(m, mp) };
+ _xor(v3, mi);
+ _compress(v0, v1, v2, v3);
+ _compress(v0, v1, v2, v3);
+ _xor(v0, mi);
+ mp += 8;
+ }
+ buf[7] = ml;
+ var ic = 0;
+ while (mp < ml) {
+ buf[ic++] = m.charCodeAt(mp++);
+ }
+ while (ic < 7) {
+ buf[ic++] = 0;
+ }
+ mi = { h: buf[7] << 24 | buf[6] << 16 | buf[5] << 8 | buf[4],
+ l: buf[3] << 24 | buf[2] << 16 | buf[1] << 8 | buf[0] };
+ _xor(v3, mi);
+ _compress(v0, v1, v2, v3);
+ _compress(v0, v1, v2, v3);
+ _xor(v0, mi);
+ _xor(v2, { h: 0, l: 0xff });
+ _compress(v0, v1, v2, v3);
+ _compress(v0, v1, v2, v3);
+ _compress(v0, v1, v2, v3);
+ _compress(v0, v1, v2, v3);
+
+ var h = v0;
+ _xor(h, v1);
+ _xor(h, v2);
+ _xor(h, v3);
+
+ return h;
+ }
+
+ function string_to_key(a) {
+ var k = [0, 0, 0, 0];
+ var i = 0, ki = 0;
+
+ pa = a + "\0\0\0\0";
+ while (i < a.length) {
+ k[ki] = (k[ki] + _get_int(pa, i)) % 0xffffffff;
+ i += 4;
+ ki = (ki + 1) % 4;
+ }
+
+ return k;
+ }
+
+ function hash_hex(key, m) {
+ var r = hash(key, m);
+ return ("0000000" + r.h.toString(16)).substr(-8) +
+ ("0000000" + r.l.toString(16)).substr(-8);
+ }
+
+ function hash_uint(key, m) {
+ var r = hash(key, m);
+ return (r.h & 0x1fffff) * 0x100000000 + r.l;
+ }
+
+ return {
+ string_to_key: string_to_key,
+ hash: hash,
+ hash_hex: hash_hex,
+ hash_uint: hash_uint
+ };
+};
+
diff --git a/plugins/webremote/siphash.py b/plugins/webremote/siphash.py
new file mode 100644
index 0000000..5f7cea6
--- /dev/null
+++ b/plugins/webremote/siphash.py
@@ -0,0 +1,261 @@
+r'''
+<MIT License>
+Copyright (c) 2013 Marek Majkowski <marek popcount org>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+</MIT License>
+
+
+SipHash-2-4 implementation, following the 'hashlib' API:
+
+>>> key = b'0123456789ABCDEF'
+>>> SipHash_2_4(key, b'a').hexdigest()
+b'864c339cb0dc0fac'
+>>> SipHash_2_4(key, b'a').digest()
+b'\x86L3\x9c\xb0\xdc\x0f\xac'
+>>> SipHash_2_4(key, b'a').hash()
+12398370950267227270
+>>> SipHash_2_4(key).update(b'a').hash()
+12398370950267227270
+
+>>> key = b'\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f'
+>>> SipHash_2_4(key, b'').hash()
+8246050544436514353
+>>> SipHash_2_4(key, b'').hexdigest()
+b'310e0edd47db6f72'
+
+'''
+import struct
+import binascii
+
+def _doublesipround(v, m):
+ '''
+ Internal helper. Xors 'm' to 'v3', runs two rounds of siphash on
+ vector 'v' and xors 'm' to 'v0'.
+
+ >>> _doublesipround((1,2,3,4),0)
+ (9263201270060220426, 2307743542053503000, 5255419393243893904, 10208987565802066018)
+ >>> _doublesipround((1,2,3,4),0xff)
+ (11557575153743626750, 2307780510495171325, 7519994316568162407, 5442382437785464174)
+ >>> _doublesipround((0,0,0,0),0)
+ (0, 0, 0, 0)
+ >>> _doublesipround((0,0,0,0),0xff)
+ (2368684213854535680, 36416423977725, 2305811110491594975, 15626573430810475768)
+ '''
+ a, b, c, d = v
+ d ^= m
+
+ e = (a + b) & 0xffffffffffffffff
+ i = (((b & 0x7ffffffffffff) << 13) | (b >> 51)) ^ e
+ f = c + d
+ j = ((((d) << 16) | (d >> 48)) ^ f ) & 0xffffffffffffffff
+ h = (f + i) & 0xffffffffffffffff
+
+ k = ((e << 32) | (e >> 32)) + j
+ l = (((i & 0x7fffffffffff) << 17) | (i >> 47)) ^ h
+ o = (((j << 21) | (j >> 43)) ^ k) & 0xffffffffffffffff
+
+ p = (k + l) & 0xffffffffffffffff
+ q = (((l & 0x7ffffffffffff) << 13) | (l >> 51)) ^ p
+ r = ((h << 32) | (h >> 32)) + o
+ s = (((o << 16) | (o >> 48)) ^ r) & 0xffffffffffffffff
+ t = (r + q) & 0xffffffffffffffff
+ u = (((p << 32) | (p >> 32)) + s) & 0xffffffffffffffff
+
+ return (u ^ m,
+ (((q & 0x7fffffffffff) << 17) | (q >> 47)) ^ t,
+ ((t & 0xffffffff) << 32) | (t >> 32),
+ (((s & 0x7ffffffffff) << 21) | (s >> 43)) ^ u)
+
+
+_zeroes = b'\x00\x00\x00\x00\x00\x00\x00\x00'
+_oneQ = struct.Struct('<Q')
+_twoQ = struct.Struct('<QQ')
+_oneQout = struct.Struct(">Q")
+
+
+class SipHash_2_4(object):
+ r'''
+ >>> SipHash_2_4(b'0123456789ABCDEF', b'a').hash()
+ 12398370950267227270
+ >>> SipHash_2_4(b'0123456789ABCDEF', b'').hash()
+ 3627314469837380007
+ >>> SipHash_2_4(b'FEDCBA9876543210', b'').hash()
+ 2007056766899708634
+ >>> SipHash_2_4(b'FEDCBA9876543210').update(b'').update(b'').hash()
+ 2007056766899708634
+ >>> SipHash_2_4(b'FEDCBA9876543210', b'a').hash()
+ 6581475155582014123
+ >>> SipHash_2_4(b'FEDCBA9876543210').update(b'a').hash()
+ 6581475155582014123
+ >>> SipHash_2_4(b'FEDCBA9876543210').update(b'a').update(b'').hash()
+ 6581475155582014123
+ >>> SipHash_2_4(b'FEDCBA9876543210').update(b'').update(b'a').hash()
+ 6581475155582014123
+
+ >>> a = SipHash_2_4(b'FEDCBA9876543210').update(b'a')
+ >>> a.hash()
+ 6581475155582014123
+ >>> b = a.copy()
+ >>> a.hash(), b.hash()
+ (6581475155582014123, 6581475155582014123)
+ >>> a.update(b'a') and None
+ >>> a.hash(), b.hash()
+ (3258273892680892829, 6581475155582014123)
+ '''
+ digest_size = 16
+ block_size = 64
+
+ s = b''
+ b = 0
+
+ def __init__(self, secret, s=b''):
+ k0 = (secret[1] << 32) + secret[0]
+ k1 = (secret[3] << 32) + secret[2]
+ self.v = (0x736f6d6570736575 ^ k0,
+ 0x646f72616e646f6d ^ k1,
+ 0x6c7967656e657261 ^ k0,
+ 0x7465646279746573 ^ k1)
+ self.update(s)
+
+ def update(self, s):
+ s = self.s + s
+ lim = (len(s)//8)*8
+ v = self.v
+ off = 0
+
+ for off in range(0, lim, 8):
+ m, = _oneQ.unpack_from(s, off)
+
+ # print 'v0 %016x' % v[0]
+ # print 'v1 %016x' % v[1]
+ # print 'v2 %016x' % v[2]
+ # print 'v3 %016x' % v[3]
+ # print 'compress %016x' % m
+
+ v = _doublesipround(v, m)
+ self.v = v
+ self.b += lim
+ self.s = s[lim:]
+ return self
+
+ def hash(self):
+ l = len(self.s)
+ assert l < 8
+
+ b = (((self.b + l) & 0xff) << 56)
+ b |= _oneQ.unpack_from(self.s+_zeroes)[0]
+ v = self.v
+
+ # print 'v0 %016x' % v[0]
+ # print 'v1 %016x' % v[1]
+ # print 'v2 %016x' % v[2]
+ # print 'v3 %016x' % v[3]
+ # print 'padding %016x' % b
+
+ v = _doublesipround(v, b)
+
+ # print 'v0 %016x' % v0
+ # print 'v1 %016x' % v1
+ # print 'v2 %016x' % v2
+ # print 'v3 %016x' % v3
+
+ v = list(v)
+ v[2] ^= 0xff
+ v = _doublesipround(_doublesipround(v, 0), 0)
+ return v[0] ^ v[1] ^ v[2] ^ v[3]
+
+ def digest(self):
+ return _oneQout.pack(self.hash())
+
+ def hexdigest(self):
+ return binascii.hexlify(self.digest())
+
+ def copy(self):
+ n = SipHash_2_4(_zeroes * 2)
+ n.v, n.s, n.b = self.v, self.s, self.b
+ return n
+
+
+siphash24 = SipHash_2_4
+SipHash24 = SipHash_2_4
+
+
+if __name__ == "__main__":
+ # Test vectors as per spec
+ vectors = [c.encode('utf-8') for c in [
+ "310e0edd47db6f72", "fd67dc93c539f874", "5a4fa9d909806c0d", "2d7efbd796666785",
+ "b7877127e09427cf", "8da699cd64557618", "cee3fe586e46c9cb", "37d1018bf50002ab",
+ "6224939a79f5f593", "b0e4a90bdf82009e", "f3b9dd94c5bb5d7a", "a7ad6b22462fb3f4",
+ "fbe50e86bc8f1e75", "903d84c02756ea14", "eef27a8e90ca23f7", "e545be4961ca29a1",
+ "db9bc2577fcc2a3f", "9447be2cf5e99a69", "9cd38d96f0b3c14b", "bd6179a71dc96dbb",
+ "98eea21af25cd6be", "c7673b2eb0cbf2d0", "883ea3e395675393", "c8ce5ccd8c030ca8",
+ "94af49f6c650adb8", "eab8858ade92e1bc", "f315bb5bb835d817", "adcf6b0763612e2f",
+ "a5c91da7acaa4dde", "716595876650a2a6", "28ef495c53a387ad", "42c341d8fa92d832",
+ "ce7cf2722f512771", "e37859f94623f3a7", "381205bb1ab0e012", "ae97a10fd434e015",
+ "b4a31508beff4d31", "81396229f0907902", "4d0cf49ee5d4dcca", "5c73336a76d8bf9a",
+ "d0a704536ba93e0e", "925958fcd6420cad", "a915c29bc8067318", "952b79f3bc0aa6d4",
+ "f21df2e41d4535f9", "87577519048f53a9", "10a56cf5dfcd9adb", "eb75095ccd986cd0",
+ "51a9cb9ecba312e6", "96afadfc2ce666c7", "72fe52975a4364ee", "5a1645b276d592a1",
+ "b274cb8ebf87870a", "6f9bb4203de7b381", "eaecb2a30b22a87f", "9924a43cc1315724",
+ "bd838d3aafbf8db7", "0b1a2a3265d51aea", "135079a3231ce660", "932b2846e4d70666",
+ "e1915f5cb1eca46c", "f325965ca16d629f", "575ff28e60381be5", "724506eb4c328a95",
+ ]]
+
+ key = ''.join(chr(i) for i in range(16)).encode('utf-8')
+ plaintext = ''.join(chr(i) for i in range(64)).encode('utf-8')
+ for i in range(64):
+ assert SipHash_2_4(key, plaintext[:i]).hexdigest() == vectors[i], \
+ 'failed on test no %i' % i
+
+ # Internal doctests
+ #
+ # To maintain compatibility with both python 2.x and 3.x in tests
+ # we need to do a trick. Python 2.x doesn't like b'' notation,
+ # Python 3.x doesn't have 2222L long integers notation. To
+ # overcome that we'll pipe both results as well as the intended
+ # doctest output through an `eval` function before comparison. To
+ # do it we need to monkeypatch the OutputChecker:
+ import doctest
+ EVAL_FLAG = doctest.register_optionflag("EVAL")
+ OrigOutputChecker = doctest.OutputChecker
+
+ def relaxed_eval(s):
+ if s.strip():
+ return eval(s)
+ else:
+ return None
+
+ class MyOutputChecker:
+ def __init__(self):
+ self.orig = OrigOutputChecker()
+
+ def check_output(self, want, got, optionflags):
+ if optionflags & EVAL_FLAG:
+ return relaxed_eval(got) == relaxed_eval(want)
+ else:
+ return self.orig.check_output(want, got, optionflags)
+
+ def output_difference(self, example, got, optionflags):
+ return self.orig.output_difference(example, got, optionflags)
+
+ doctest.OutputChecker = MyOutputChecker
+ # Monkey patching done. Go for doctests:
+
+ if doctest.testmod(optionflags=EVAL_FLAG)[0] == 0: print("all tests ok")
diff --git a/plugins/webremote/webremote-config.ui b/plugins/webremote/webremote-config.ui
new file mode 100644
index 0000000..98796b1
--- /dev/null
+++ b/plugins/webremote/webremote-config.ui
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.19.0 -->
+<interface>
+ <requires lib="gtk+" version="3.12"/>
+ <object class="GtkGrid" id="webremote-config">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="margin_left">12</property>
+ <property name="margin_right">12</property>
+ <property name="margin_top">12</property>
+ <property name="margin_bottom">12</property>
+ <property name="row_spacing">6</property>
+ <property name="column_spacing">6</property>
+ <child>
+ <object class="GtkLabel" id="label1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Web remote control preferences</property>
+ <property name="ellipsize">end</property>
+ <property name="xalign">0</property>
+ <attributes>
+ <attribute name="weight" value="bold"/>
+ </attributes>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">0</property>
+ <property name="width">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Listening port:</property>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Access key:</property>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="launch-link">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="use_markup">True</property>
+ </object>
+ <packing>
+ <property name="left_attach">0</property>
+ <property name="top_attach">3</property>
+ <property name="width">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkEntry" id="accesskey">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="top_attach">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="portnumber">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">0</property>
+ <property name="xalign">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="top_attach">1</property>
+ </packing>
+ </child>
+ </object>
+</interface>
diff --git a/plugins/webremote/webremote.html b/plugins/webremote/webremote.html
new file mode 100644
index 0000000..3e4f944
--- /dev/null
+++ b/plugins/webremote/webremote.html
@@ -0,0 +1,82 @@
+<html>
+<head>
+ <meta charset="utf-8" />
+ <link rel="stylesheet" href="/css/pure-min.css" />
+ <link rel="stylesheet" href="/css/grids-responsive-min.css" />
+ <link rel="stylesheet" href="/css/webremote.css" />
+ <script type="text/javascript" src="/js/webremote.js"></script>
+</head>
+<body>
+<div class="pure-g webremote">
+ <div class="wrapper">
+
+ <div class="pure-u-1-1">
+ <div id="trackimage">
+ <img class="noalbumart" src="/icon/rhythmbox-symbolic/128">
+ </div>
+ </div>
+
+ <div class="pure-u-1-1">
+ <div id="connectform">
+ <form class="pure-form" id="connectform">
+ <input type="password" id="accesskey" placeholder="Enter access key"/>
+ <button type="submit" class="pure-button" id="connectbutton">
+ <img src="/icon/go-next-symbolic/24">
+ </button>
+ </form>
+ </div>
+ <div id="trackbits">
+ <div id="tracktitle"></div>
+ <span id="trackartist"></span>
+ ·
+ <span id="trackalbum"></span>
+ <div>
+ <span id="trackposition">00:00</span>
+ /
+ <span id="trackduration">00:00</span>
+ </div>
+ </div>
+ </div>
+
+ <div class="pure-u-1-1" id="seekbar">
+ <div class="pure-g">
+ <div class="pure-u-1-4"></div>
+ <div class="pure-u-1-2">
+ <input type="range" id="seekbar-range"></input>
+ </div>
+ <div class="pure-u-1-4"></div>
+ </div>
+ </div>
+
+ <div class="pure-u-1-1" id="playerbuttons">
+ <div class="pure-g">
+ <div class="pure-u-1-3">
+ <div class="pure-button playerbutton" id="previous">
+ <img src="/icon/media-skip-backward-symbolic/48">
+ </div>
+ </div>
+ <div class="pure-u-1-3">
+ <div class="pure-button playerbutton" id="playpause">
+ <img src="/icon/media-playback-start-symbolic/48">
+ </div>
+ </div>
+ <div class="pure-u-1-3">
+ <div class="pure-button playerbutton" id="next">
+ <img src="/icon/media-skip-forward-symbolic/48">
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="pure-u-1-1">
+ <!-- not quite ready -->
+ <!-- <div id="stream"><input type="checkbox" id="stream-check">stream</div> -->
+ <div id="connection">
+ <div id="connectionhostname"></div>
+ </div>
+ </div>
+ </div>
+</div>
+<div id="stream-container"/>
+</body>
+</html>
diff --git a/plugins/webremote/webremote.plugin.in b/plugins/webremote/webremote.plugin.in
new file mode 100644
index 0000000..9a3d967
--- /dev/null
+++ b/plugins/webremote/webremote.plugin.in
@@ -0,0 +1,10 @@
+[Plugin]
+Loader=python3
+Module=webremote
+IAge=2
+_Name=Web remote control
+_Description=Control Rhythmbox from a web browser
+Authors=Jonathan Matthew <jonathan d14n org>
+Copyright=Copyright © 2016 Jonathan Matthew
+Website=http://www.rhythmbox.org/
+Depends=rb
diff --git a/plugins/webremote/webremote.py b/plugins/webremote/webremote.py
new file mode 100644
index 0000000..e029563
--- /dev/null
+++ b/plugins/webremote/webremote.py
@@ -0,0 +1,655 @@
+# -*- Mode: python; coding: utf-8; tab-width: 8; indent-tabs-mode: t; -*-
+#
+# Copyright (C) 2016 Jonathan Matthew
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2, or (at your option)
+# any later version.
+#
+# The Rhythmbox authors hereby grant permission for non-GPL compatible
+# GStreamer plugins to be used and distributed together with GStreamer
+# and Rhythmbox. This permission is above and beyond the permissions granted
+# by the GPL license by which Rhythmbox is covered. If you modify this code
+# you may extend this exception to your version of the code, but you are not
+# obligated to do so. If you do not wish to do so, delete this exception
+# statement from your version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import gi
+gi.require_version('Soup', '2.4')
+from gi.repository import GLib, GObject, Gio, Peas, PeasGtk, Soup, Gtk
+from gi.repository import RB
+import rb
+
+import sys
+import os.path
+import json
+import re
+import time
+import struct
+
+import siphash
+
+import gettext
+gettext.install('rhythmbox', RB.locale_dir())
+
+if rb.rbconfig.libsecret_enabled:
+ gi.require_version('Secret', '1')
+ try:
+ from gi.repository import Secret
+ SECRET_SCHEMA = Secret.Schema.new("org.gnome.rhythmbox.plugins.webremote",
+ Secret.SchemaFlags.NONE,
+ {'id': Secret.SchemaAttributeType.STRING})
+ except ImportError as e:
+ SECRET_SCHEMA = None
+else:
+ SECRET_SCHEMA = None
+
+
+def get_access_key(id='default'):
+ if SECRET_SCHEMA is None:
+ return ''
+
+ try:
+ svc = Secret.Service.get_sync(Secret.ServiceFlags.OPEN_SESSION, None)
+ items = svc.search_sync(SECRET_SCHEMA,
+ {'id': id},
+ Secret.SearchFlags.LOAD_SECRETS,
+ None)
+ if items:
+ return items[0].get_secret().get().decode('utf-8')
+
+ return ''
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+ return ''
+
+def store_access_key(key, id='default'):
+ if SECRET_SCHEMA is None:
+ return
+
+ try:
+ Secret.password_store_sync(SECRET_SCHEMA,
+ {'id': id},
+ Secret.COLLECTION_DEFAULT,
+ "Rhythmbox web remote access key",
+ key,
+ None)
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+
+
+def get_host_name():
+ try:
+ p = Gio.DBusProxy.new_for_bus_sync(Gio.BusType.SYSTEM,
+ 0,
+ None,
+ 'org.freedesktop.Avahi',
+ '/',
+ 'org.freedesktop.Avahi.Server')
+ return p.GetHostNameFqdn()
+ except Exception as e:
+ # ignore
+ import socket
+ return socket.gethostname()
+
+
+
+class ClientSession(object):
+
+ def __init__(self, plugin, connection, client, connid):
+ print("new connection attached")
+ self.connid = connid
+ self.conn = connection
+ self.conn.connect("message", self.message_cb)
+ self.conn.connect("closed", self.closed_cb)
+ self.client = client
+ self.plugin = plugin
+ self.actions = {
+ 'status': self.plugin.client_status,
+ 'next': self.plugin.client_next,
+ 'previous': self.plugin.client_previous,
+ 'playpause': self.plugin.client_playpause,
+ 'seek': self.plugin.client_seek
+ }
+
+ def message_cb(self, conn, msgtype, message):
+ if msgtype != Soup.WebsocketDataType.TEXT:
+ print("binary message received?")
+ return
+
+ d = message.get_data().decode("utf-8")
+ print("message received: %s" % d)
+ try:
+ m = json.loads(d)
+ action = m.get('action')
+ print("doing %s" % action)
+ if action in self.actions:
+ r = self.actions[action](m)
+ else:
+ r = {'result': 'what'}
+
+ print("responding %s" % str(r))
+ self.conn.send_text(json.dumps(r))
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+
+ def closed_cb(self, conn):
+ self.plugin.player_websocket_closed(self, self.connid)
+
+ def dispatch(self, message):
+ self.conn.send_text(message)
+
+ def disconnect(self):
+ self.conn.close()
+
+class TrackStreamer(object):
+ def __init__(self, server, message, track, content_type):
+ self.server = server
+ self.message = message
+ self.message.connect("wrote-chunk", self.wrote_chunk)
+ self.trackfile = Gio.File.new_for_uri(track)
+ self.content_type = content_type
+ self.stream = None
+ self.offset = 0
+ self.done = False
+ print("streaming " + track)
+
+ def wrote_chunk(self, msg):
+ if not self.done:
+ self.server.pause_message(self.message)
+
+ def open(self):
+ print("opening")
+ self.trackfile.read_async(GLib.PRIORITY_DEFAULT, None, self.opened)
+ self.server.pause_message(self.message)
+
+ def opened(self, obj, result):
+ try:
+ print("track opened")
+ headers = self.message.props.response_headers
+ headers.set_content_type(self.content_type)
+ headers.set_encoding(Soup.Encoding.CHUNKED)
+
+ body = self.message.props.response_body
+ body.set_accumulate(False)
+
+ self.stream = self.trackfile.read_finish(result)
+ self.message.set_status(200)
+ self.read_more()
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+ self.message.set_status(500)
+ self.server.unpause_message(self.message)
+
+ def read_more(self):
+ self.stream.read_bytes_async(65536, GLib.PRIORITY_DEFAULT, None, self.read_done)
+
+ def read_done(self, obj, result):
+ body = self.message.props.response_body
+ try:
+ b = self.stream.read_bytes_finish(result)
+ if b.get_size() == 0:
+ self.done = True
+ body.complete()
+ else:
+ self.offset = self.offset + b.get_size()
+ body.append(b.get_data()) # uh..
+ self.read_more()
+ self.server.unpause_message(self.message)
+
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+ if (self.offset == 0):
+ self.message.set_status(500)
+ else:
+ body.complete()
+ self.server.unpause_message(self.message)
+
+
+
+class WebRemotePlugin(GObject.Object, Peas.Activatable):
+ __gtype_name = 'WebRemotePlugin'
+ object = GObject.property(type=GObject.GObject)
+
+ signature_max_age = 60
+ # we don't really need a huge replay memory.
+ # clients should only make requests every couple of minutes,
+ # and signatures are only valid for (currently) up to two minutes.
+ replay_memory = 20
+
+ string_props = {
+ RB.RhythmDBPropType.TITLE: 'title',
+ RB.RhythmDBPropType.ARTIST: 'artist',
+ RB.RhythmDBPropType.ALBUM: 'album',
+ RB.RhythmDBPropType.ALBUM_ARTIST: 'album-artist',
+ RB.RhythmDBPropType.GENRE: 'genre',
+ RB.RhythmDBPropType.COMPOSER: 'composer',
+ RB.RhythmDBPropType.TITLE: 'title',
+ }
+ ulong_props = {
+ RB.RhythmDBPropType.ENTRY_ID: 'id',
+ RB.RhythmDBPropType.YEAR: 'year',
+ #RB.RhythmDBPropType.BEATS_PER_MINUTE: 'bpm',
+ RB.RhythmDBPropType.BITRATE: 'bitrate',
+ RB.RhythmDBPropType.DURATION: 'duration',
+ RB.RhythmDBPropType.TRACK_NUMBER: 'track-number',
+ RB.RhythmDBPropType.TRACK_TOTAL: 'track-total',
+ RB.RhythmDBPropType.DISC_NUMBER: 'disc-number',
+ RB.RhythmDBPropType.DISC_TOTAL: 'disc-total'
+ }
+
+ def __init__(self):
+ GObject.Object.__init__(self)
+ self.settings = Gio.Settings("org.gnome.rhythmbox.plugins.webremote")
+ self.settings.connect("changed", self.settings_changed_cb)
+ self.server = None
+ self.next_connid = 0
+ self.connections = {}
+ self.replay = []
+ self.access_key = None
+
+ self.listen_reset = False
+
+ def get_sign_key(self, id):
+ # some day there will be multiple keys
+ a = get_access_key(id)
+
+ ea = a.encode()
+ pa = (a + 4 * '\0').encode()
+
+ k = [0, 0, 0, 0]
+ i = 0
+ ki = 0
+ uint = struct.Struct("<I")
+ while i < len(ea):
+ k[ki] = (k[ki] + uint.unpack(pa[i:i+4])[0]) % 0xffffffff
+ i = i + 4
+ ki = (ki + 1) % 4
+
+ return k
+
+ def check_http_signature(self, path, query):
+ try:
+ qargs = dict([b.split("=") for b in query.split("&")])
+ ts = qargs['ts']
+ sig = qargs['sig']
+ keyid = qargs.get("k", "default") # not used yet
+
+ if sig in self.replay:
+ print("replayed signature " + sig + " in request for " + path)
+ return False
+
+ its = int(ts) / 1000
+ now = time.time()
+ max = self.signature_max_age
+ print("request timestamp: " + ts + ", min: " + str(now - max) + ", max: " + str(now +
max))
+ if (its < (now - max)) or (its > (now + max)):
+ return False
+
+ message = (path + "\n" + ts).encode()
+ check = siphash.SipHash_2_4(self.get_sign_key(keyid), message).hexdigest()
+ print("request signature: " + sig + ", expecting: " + check.decode())
+ if check == sig.encode():
+ self.replay.insert(0, sig)
+ self.replay = self.replay[:self.replay_memory]
+ return True
+
+ return False
+
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+ return False
+
+ def check_http_msg_signature(self, msg):
+ u = msg.get_uri()
+ return self.check_http_signature(u.get_path(), u.get_query())
+
+ def player_websocket_cb(self, server, conn, path, client):
+ (upath, query) = path.split(":", 1)
+ if self.check_http_signature(upath, query) is False:
+ conn.close(403, "whatever")
+ return
+
+ cs = ClientSession(self, conn, client, self.next_connid)
+ self.connections[self.next_connid] = cs
+ self.next_connid = self.next_connid + 1
+
+ def player_websocket_closed(self, connection, connid):
+ self.connections.pop(connid)
+
+ def dispatch(self, message):
+ m = json.dumps(message)
+ for c in self.connections.values():
+ c.dispatch(m)
+
+ def album_art_filename(self, filename):
+ if filename is None:
+ return None
+
+ if filename.startswith(self.artcache) is False:
+ return None
+
+ return os.path.normpath(filename)
+
+
+ def entry_details(self, entry):
+ m = {}
+ if entry is not None:
+ for (p, k) in self.string_props.items():
+ m[k] = entry.get_string(p)
+ for (p, k) in self.ulong_props.items():
+ m[k] = entry.get_ulong(p)
+
+ key = entry.create_ext_db_key(RB.RhythmDBPropType.ALBUM)
+ (filename, lkey) = self.art_store.lookup(key)
+ m['albumart'] = self.album_art_filename(filename)
+ return m
+
+ def set_playing_position(self, update):
+ try:
+ (r, pos) = self.shell_player.get_playing_time()
+ update['position'] = pos * 1000
+ except Exception as e:
+ pass
+
+ def client_status(self, message):
+ entry = self.shell_player.get_playing_entry()
+ if entry:
+ m = self.entry_details(entry)
+ self.set_playing_position(m)
+ p = self.shell_player.get_playing()
+ m['playing'] = p[1]
+ else:
+ m = { 'playing': False, 'id': 0 }
+
+ m['hostname'] = GLib.get_host_name()
+ return m
+
+ def client_next(self, message):
+ try:
+ self.shell_player.do_next()
+ return {'result': 'ok'}
+ except Exception as e:
+ return {'result': str(e) }
+
+ def client_previous(self, message):
+ try:
+ self.shell_player.do_previous()
+ return {'result': 'ok'}
+ except Exception as e:
+ return {'result': str(e) }
+
+ def client_playpause(self, message):
+ try:
+ self.shell_player.playpause(True)
+ return {'result': 'ok'}
+ except Exception as e:
+ return {'result': str(e) }
+
+ def client_seek(self, message):
+ try:
+ self.shell_player.set_playing_time(message['time'])
+ return {'result': 'ok'}
+ except Exception as e:
+ return {'result': str(e) }
+
+
+ def playing_song_changed_cb(self, player, entry):
+ self.elapsed = 0
+ self.dispatch(self.entry_details(entry))
+
+ def playing_changed_cb(self, player, playing):
+ u = { 'playing': playing }
+ self.set_playing_position(u)
+ self.dispatch(u)
+
+ def playing_song_property_changed_cb(self, player, uri, prop, oldvalue, newvalue):
+ if prop in self.string_props:
+ self.dispatch({ self.string_props[prop]: newvalue })
+ if prop in self.ulong_props:
+ self.dispatch({ self.ulong_props[prop]: newvalue })
+
+ def elapsed_nano_changed_cb(self, player, elapsed):
+ if abs(elapsed - self.elapsed) > 1000000000:
+ self.dispatch({'position': elapsed/1000000}) # ms
+ self.elapsed = elapsed
+
+ def art_added_cb(self, store, key, filename, data):
+ entry = self.shell_player.get_playing_entry()
+ if entry is not None and self.db.entry_matches_ext_db_key(entry, key):
+ self.dispatch({'albumart': self.album_art_filename(filename)})
+
+ def send_file_response(self, msg, filename, content_type):
+ try:
+ fp = open(filename, 'rb')
+ d = fp.read()
+
+ if callable(content_type):
+ content_type = content_type(d)
+
+ msg.set_response(content_type, Soup.MemoryUse.COPY, d)
+ msg.set_status(200)
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+ msg.set_status(500)
+
+ def image_content_type(self, data):
+ # superhacky
+ if data[1:4] == b'PNG':
+ return "image/png"
+ elif data[0:5] == b'<?xml':
+ return "image/svg+xml"
+ else:
+ return "image/jpeg"
+
+ def http_track_cb(self, server, msg, path, query, client):
+
+ if self.check_http_msg_signature(msg) is False:
+ msg.set_status(403)
+ return
+
+ entry = self.shell_player.get_playing_entry()
+ if entry is None:
+ msg.set_status(404)
+ return
+
+ mt = entry.get_string(RB.RhythmDBPropType.MEDIA_TYPE)
+ ct = RB.gst_media_type_to_mime_type(mt)
+ s = TrackStreamer(server, msg, entry.get_playback_uri(), ct)
+ try:
+ s.open()
+ except Exception as e:
+ sys.excepthook(*sys.exc_info())
+ msg.set_status(500)
+
+
+ def http_art_cb(self, server, msg, path, query, client):
+ if self.check_http_msg_signature(msg) is False:
+ msg.set_status(403)
+ return
+
+ if msg.method != "GET":
+ msg.set_status(404)
+ return
+
+ if re.match("/art/[a-zA-Z0-9/]+", path) is None:
+ msg.set_status(404)
+ return
+
+ artpath = os.path.join(self.artcache, path[len("/art/"):])
+ if not os.path.exists(artpath):
+ msg.set_status(404)
+ return
+
+ self.send_file_response(msg, artpath, self.image_content_type)
+
+ def http_icon_cb(self, server, msg, path, query, client):
+ if msg.method != "GET":
+ msg.set_status(404)
+ return
+
+ if re.match("/icon/[a-z-]+/[0-9]+", path) is None:
+ msg.set_status(404)
+ return
+
+ bits = path.split("/")
+ iconname = bits[2]
+ iconsize = int(bits[3])
+ icon = Gtk.IconTheme.get_default().lookup_icon(iconname, iconsize,
Gtk.IconLookupFlags.FORCE_SVG)
+ if icon is None:
+ msg.set_status(404)
+ return
+
+ self.send_file_response(msg, icon.get_filename(), self.image_content_type)
+
+ def serve_static(self, msg, path, subdir, content_type):
+
+ if subdir == '':
+ ssubdir = '/'
+ else:
+ ssubdir = "/" + subdir + "/"
+ if not path.startswith(ssubdir):
+ msg.set_status(403)
+ return
+
+ relpath = path[len(ssubdir):]
+
+ if msg.method != "GET" or relpath.find("/") != -1:
+ msg.set_status(403)
+ return
+
+ f = rb.find_plugin_file(self, os.path.join(subdir, relpath))
+ if f is None:
+ msg.set_status(403)
+ return
+
+ self.send_file_response(msg, f, content_type)
+
+
+ def http_static_css_cb(self, server, msg, path, query, client):
+ self.serve_static(msg, path, "css", "text/css")
+
+ def http_static_js_cb(self, server, msg, path, query, client):
+ self.serve_static(msg, path, "js", "text/javascript")
+
+ def http_root_cb(self, server, msg, path, query, client):
+ self.serve_static(msg, '/webremote.html', '', 'text/html')
+
+ def settings_changed_cb(self, settings, key):
+ if key == 'listen-port':
+ if self.http_server is not None and self.listen_reset is False:
+ self.http_server.disconnect()
+ self.http_listen()
+
+ def http_listen(self):
+ print("relistening")
+ port = self.settings['listen-port']
+ if port != 0:
+ try:
+ self.http_server.listen_all(port, 0)
+ except Exception as e:
+ port = 0
+
+ if port == 0:
+ print("trying again")
+ self.listen_reset = True
+ self.http_server.listen_all(0, 0)
+ # remember the port number for convenience
+ uris = self.http_server.get_uris()
+ if len(uris) > 0:
+ self.settings['listen-port'] = uris[0].get_port()
+
+ self.listen_reset = False
+
+
+ def do_activate(self):
+ shell = self.object
+ self.db = shell.props.db
+
+ self.shell_player = shell.props.shell_player
+ self.shell_player.connect("playing-song-changed", self.playing_song_changed_cb)
+ self.shell_player.connect("playing-song-property-changed",
self.playing_song_property_changed_cb)
+ self.shell_player.connect("playing-changed", self.playing_changed_cb)
+ self.shell_player.connect("elapsed-nano-changed", self.elapsed_nano_changed_cb)
+ self.playing_song_changed_cb(self.shell_player, self.shell_player.get_playing_entry())
+
+ self.http_server = Soup.Server()
+ self.http_server.add_handler(path="/art/", callback=self.http_art_cb)
+ self.http_server.add_handler(path="/icon/", callback=self.http_icon_cb)
+ self.http_server.add_handler(path="/entry/current/stream", callback=self.http_track_cb)
+ self.http_server.add_handler(path="/css/", callback=self.http_static_css_cb)
+ self.http_server.add_handler(path="/js/", callback=self.http_static_js_cb)
+ self.http_server.add_websocket_handler("/ws/player", None, None, self.player_websocket_cb)
+ self.http_server.add_handler(path="/", callback=self.http_root_cb)
+
+ self.http_listen()
+ self.http_server.run_async()
+
+ self.artcache = os.path.join(RB.user_cache_dir(), "album-art", "")
+ self.art_store = RB.ExtDB(name="album-art")
+ self.art_store.connect("added", self.art_added_cb)
+
+
+ def do_deactivate(self):
+ self.dispatch({'shutdown': True })
+ self.server = None
+ self.connections = {}
+
+class WebRemoteConfig(GObject.Object, PeasGtk.Configurable):
+ __gtype_name__ = 'WebRemoteConfig'
+ object = GObject.property(type=GObject.Object)
+
+ def accesskey_focus_out_cb(self, widget, event):
+ k = widget.get_text()
+ if k != self.access_key:
+ print("changing access key to %s" % k)
+ self.access_key = k
+ store_access_key(k)
+
+ def update_port(self):
+ hostname = get_host_name()
+ port = self.settings['listen-port']
+ url = 'http://%s:%d/' % (hostname, port)
+ label = _('Launch web remote control')
+ self.launch_link.set_markup('<a href="%s">%s</a>' % (url, label))
+
+ self.portnumber.set_text("%d" % port)
+
+ def settings_changed_cb(self, settings, key):
+ if key == 'listen-port':
+ self.update_port()
+
+ def do_create_configure_widget(self):
+ self.settings = Gio.Settings("org.gnome.rhythmbox.plugins.webremote")
+ self.settings.connect("changed", self.settings_changed_cb)
+
+ ui_file = rb.find_plugin_file(self, "webremote-config.ui")
+ self.builder = Gtk.Builder()
+ self.builder.add_from_file(ui_file)
+
+ content = self.builder.get_object("webremote-config")
+
+ self.portnumber = self.builder.get_object("portnumber")
+ self.launch_link = self.builder.get_object("launch-link")
+ self.update_port()
+
+ self.key_entry = self.builder.get_object("accesskey")
+ self.access_key = get_access_key()
+ if self.access_key:
+ self.key_entry.set_text(self.access_key)
+ self.key_entry.connect("focus-out-event", self.accesskey_focus_out_cb)
+
+ return content
+
+
+GObject.type_register(WebRemoteConfig)
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]