[rhythmbox] new web remote control plugin



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>
+                               &#183;
+                               <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]