From 401149e209dcb04efbca220f2f7842df22a9a179 Mon Sep 17 00:00:00 2001
From: ale <ale@incal.net>
Date: Wed, 3 Feb 2021 09:41:15 +0000
Subject: [PATCH] Add an HTML audio player

---
 node/Makefile                 |   5 +-
 node/bindata.go               | 458 +++++++++++++++++++++++++++++++++-
 node/debug.go                 |   4 +-
 node/http.go                  |  38 +++
 node/static/css/player.css    | 166 ++++++++++++
 node/static/css/player.css.br | Bin 0 -> 695 bytes
 node/static/css/player.css.gz | Bin 0 -> 815 bytes
 node/static/js/player.js      |  39 +++
 node/static/js/player.js.br   | Bin 0 -> 370 bytes
 node/static/js/player.js.gz   | Bin 0 -> 502 bytes
 node/static/speaker.svg       |   7 +
 node/static/speaker.svg.br    | Bin 0 -> 278 bytes
 node/static/speaker.svg.gz    | Bin 0 -> 329 bytes
 node/templates/index.html     |   9 +-
 node/templates/player.html    |  29 +++
 15 files changed, 738 insertions(+), 17 deletions(-)
 create mode 100644 node/static/css/player.css
 create mode 100644 node/static/css/player.css.br
 create mode 100644 node/static/css/player.css.gz
 create mode 100644 node/static/js/player.js
 create mode 100644 node/static/js/player.js.br
 create mode 100644 node/static/js/player.js.gz
 create mode 100644 node/static/speaker.svg
 create mode 100644 node/static/speaker.svg.br
 create mode 100644 node/static/speaker.svg.gz
 create mode 100644 node/templates/player.html

diff --git a/node/Makefile b/node/Makefile
index ff93a88c..3985b0a4 100644
--- a/node/Makefile
+++ b/node/Makefile
@@ -1,10 +1,13 @@
 SOURCES = \
 	static/css/style.css \
+	static/css/player.css \
 	static/css/bootstrap.min.css \
 	static/js/bootstrap.bundle.min.js \
 	static/js/jquery-3.5.1.slim.min.js \
 	static/js/autoradio.js \
-	static/autoradio.svg
+	static/js/player.js \
+	static/autoradio.svg \
+	static/speaker.svg
 
 COMPRESSED = \
 	$(SOURCES:%=%.br) \
diff --git a/node/bindata.go b/node/bindata.go
index ca69fac2..d8763c25 100644
--- a/node/bindata.go
+++ b/node/bindata.go
@@ -6,6 +6,9 @@
 // static/css/bootstrap.min.css
 // static/css/bootstrap.min.css.br
 // static/css/bootstrap.min.css.gz
+// static/css/player.css
+// static/css/player.css.br
+// static/css/player.css.gz
 // static/css/style.css
 // static/css/style.css.br
 // static/css/style.css.gz
@@ -19,8 +22,15 @@
 // static/js/jquery-3.5.1.slim.min.js
 // static/js/jquery-3.5.1.slim.min.js.br
 // static/js/jquery-3.5.1.slim.min.js.gz
+// static/js/player.js
+// static/js/player.js.br
+// static/js/player.js.gz
 // static/radio52.png
+// static/speaker.svg
+// static/speaker.svg.br
+// static/speaker.svg.gz
 // templates/index.html
+// templates/player.html
 // DO NOT EDIT!
 
 package node
@@ -192,6 +202,223 @@ func staticCssBootstrapMinCssGz() (*asset, error) {
 	return a, nil
 }
 
+var _staticCssPlayerCss = []byte(`#player {
+    display: block;
+    position: absolute;
+    width: 375px;
+}
+
+.button {
+    display: block;
+    width: 0;
+    height: 0;
+    border-top: 35px solid transparent;
+    border-bottom: 35px solid transparent;
+    border-left: 60px solid orangered;
+    margin: 70px auto;
+    position: relative;
+    z-index: 1;
+    transition: all .4;
+    -webkit-transition: all .4;
+    -moz-transition: all .4;
+    left: 10px;
+}
+
+.button:before {
+    content: '';
+    position: absolute;
+    top: -75px;
+    left: -115px;
+    bottom: -75px;
+    right: -35px;
+    border-radius: 50%;
+    border: 15px solid orangered;
+    z-index: 2;
+    transition: all .4s;
+    -webkit-transition: all .4;
+    -moz-transition: all .4;
+    transition: transform .3s;
+}
+
+.loading:before {
+    animation: pulse .5s ease-in infinite;
+}
+
+@keyframes pulse {
+    0% { box-shadow: 0px 0px 0px red; }
+    50% { box-shadow: 0px 0px 55px red; }
+}
+
+.button:after {
+    content:'';
+    opacity:0;
+    transition: opacity .4s;
+}
+
+.button:hover:before,
+.button.play:before {
+    transform: scale(1.2);
+    -webkit-transform: scale(1.2);
+    -moz-transform: scale(1.2);
+}
+
+.button.pause:after {
+    content: '';
+    opacity: 1;
+    width: 50px;
+    height: 70px;
+    background: orangered;
+    position:absolute;
+    right: 1px;
+    top: -35px;
+    border-left: 25px solid orangered;
+    box-shadow: inset 25px 0 0 0 #fff;
+}
+
+
+/** VOLUME SLIDER **/
+
+input[type=range] {
+  height: 58px;
+  -webkit-appearance: none;
+  margin: 5px;
+  width: 80%;
+  max-width: 260px;
+  background-color: transparent;
+}
+input[type=range]:focus {
+  outline: none;
+}
+input[type=range]::-webkit-slider-runnable-track {
+  width: 100%;
+  height: 6px;
+  cursor: pointer;
+  animate: 0.2s;
+  background: #FF0000;
+  border-radius: 3px;
+  border: 0px solid #F27B7F;
+}
+input[type=range]::-webkit-slider-thumb {
+  box-shadow: 0px 0px 0px #A6A6A6;
+  border: 2px solid #968994;
+  height: 50px;
+  width: 17px;
+  border-radius: 2px;
+  background: black;
+  cursor: pointer;
+  -webkit-appearance: none;
+  margin-top: -23px;
+}
+input[type=range]:focus::-webkit-slider-runnable-track {
+  background: #FF0000;
+}
+input[type=range]::-moz-range-track {
+  width: 100%;
+  height: 6px;
+  cursor: pointer;
+  animate: 0.2s;
+  box-shadow: 0px 0px 0px #A6A6A6;
+  background: #FF0000;
+  border-radius: 3px;
+  border: 0px solid #F27B7F;
+}
+input[type=range]::-moz-range-thumb {
+  box-shadow: 0px 0px 0px #A6A6A6;
+  border: 2px solid #968994;
+  height: 50px;
+  width: 17px;
+  border-radius: 2px;
+  background: black;
+  cursor: pointer;
+}
+input[type=range]::-ms-track {
+  width: 100%;
+  height: 6px;
+  cursor: pointer;
+  animate: 0.2s;
+  background: transparent;
+  border-color: transparent;
+  color: transparent;
+}
+input[type=range]::-ms-fill-lower {
+  background: #FF0000;
+  border: 0px solid #F27B7F;
+  border-radius: 6px;
+  box-shadow: 0px 0px 0px #A6A6A6;
+}
+input[type=range]::-ms-fill-upper {
+  background: #FF0000;
+  border: 0px solid #F27B7F;
+  border-radius: 6px;
+  box-shadow: 0px 0px 0px #A6A6A6;
+}
+input[type=range]::-ms-thumb {
+  margin-top: 1px;
+  box-shadow: 0px 0px 0px #A6A6A6;
+  border: 2px solid #968994;
+  height: 50px;
+  width: 17px;
+  border-radius: 2px;
+  background: black;
+  cursor: pointer;
+}
+input[type=range]:focus::-ms-fill-lower {
+  background: #FF0000;
+}
+input[type=range]:focus::-ms-fill-upper {
+  background: #FF0000;
+}
+`)
+
+func staticCssPlayerCssBytes() ([]byte, error) {
+	return _staticCssPlayerCss, nil
+}
+
+func staticCssPlayerCss() (*asset, error) {
+	bytes, err := staticCssPlayerCssBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/css/player.css", size: 3355, mode: os.FileMode(420), modTime: time.Unix(1612343832, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _staticCssPlayerCssBr = []byte("\xc1\xd0h\x00b1`\xb7)7\xc2\u2b6e\x90\xa6\x9cm\u0154\xfb\u0600tc\xd8W\xaa\xcd\xe0\x84\vK6\x88\x02L\u07e5\xb5\xa5\x8a\xd9\xce\xdf\xd6\xd0RH\xaf:DA7\u05a2\u0655`\xea&\xe100\xdd\xd8\xe0.\u05d2\xd9\xf8\x8e\xefx\x94\xb5U\xbe\x93\xcd\xfe\xce\u0726\x00\xa0\x00%g\x93K\x99\xdd\tW\xf2\xbe\xda\xfd\xbc\xaa\xd0]\x86\u04f9\x06\xca\"6\xd1\xdfXg\x1f\x1b}3\x1a\x8d\x1a\x01\xa8\xafK(A\xc7\x1ei%Pz>X\xa0\xecoLvd\xc4y-,\x1a\xdb\x14<\xa3\xad\xe7U\xd8x\u029aZl\xe1s\xf0\xee#\xb6\x98h\xb1O\x0eq4\xd1r\x8d0\xcbW\xc2t\x92N\x82\xcdM\xb9R#\x97\x16s\x1b!\xbfj\xc4\x1a\xf5\xff|P\xe3}u\xfb\x99i\xac\x8b\xa4\xe4\xaeA\x8dB&\xa3\f\xb3\x1e\xb0,a\x01W\x19Vv\xf33 %\f\xf9)\xaaa\xceQ\x0f\x9a\x96\xbf\x89\x1f\xe5wB\x96\x98\x97$#i\xf1X0\xadM\xea\xaai,\x86\x14\x17'Y\x11\x13\xd1p\x1cc|\x03\x10\x18n\xef\xac(\x1e\xb1\xa1{.\xb7\xc2\x1a\xe5\x12\xe7\xc3q\v\v_\xc6o\xfb7T\x17\x97{\xc4\xdcWp\xafX\xb6\x8eD\xf7\x87\x8a\x05\x03\xfe\xd3\xf3YUG,k,\x84\r6\xee\x1a\xa5F\xa6u\xbbw\xe0H\xc7B`\xaah\x10|\x1dR\u07fd\x8333R\xc9\x12b\x1d\xe3O\x1e\x9d\u040c\xb5x|\x95h\xeb\xdf\x00:t\xc1\xc9\xfe\x9db\x1aO5\xbeIz\x0f\x88Cr\xde\xe3\xc0D\xb0p\xa9\xa0\xc7\xd3\t-g9!S\xa9\xd4\u0485\xb4GYR\xae+9\x8ao\xb5Y\xec!)P\xe4/\x82\x1b\xff'p\xd5\xff\x16\x9f\x9d\xa0\t\"?\xcd\u05c4\x03\xde\xecE1\x85\b\xac\xb5\\b\xbf\xe5\xcd\x19\xef\x9c?\x96\v\x03\u0309Y\x93\xae\x98\x17\x0f\xbd\x006\xa4ln\xf9\xae\x01H\xefy\u01b1b\x85\x16\xc0\rC!\"\xe0\xaf\xde-|u\xfc_\x82\u071b&21\xe4\xc4\u01fehB\xa2b\x13l\xec\x91\xeb\x95\x19\xbae%\xdd\xd9\xea\xd6\x18\t\x06\x81dp\xddr\x99\x14B\x14\xb8\xfc\x14\xeb&i\xd5#F?az\xc19\u0705\x04\xc8\x1f\x00\xf3X\xc6>>\xdd\xef^\\Z\x13\xd5\xc0\xb3]\xcf3\xb6dK\x05q\x82\x1d\x80\x13\xffR%\x89\x85\xd8\xed\xe5R\xb8\x9a\x18P\xf2*\xa5\xe3J:X\x84~\x01\xf8\xb9\tn\x01\u03a6\x89\x06\x04\xca\"\u0355\x84\b\xcbx\n\x12\x8f\x8c?If\xe9\xca\xc8p\xe2w9w\x18\xc1\u38ac\xae\x0f0\xe4\u0471t1@\xa2\x1eV\x96\xdaA\xf2\vB\xea\xbf\xf0\xaeGj)\x8d?\x92\x93\u032a\xa9\xe0\x97\x0eG\xfc\xe1\x8dt\xc0;")
+
+func staticCssPlayerCssBrBytes() ([]byte, error) {
+	return _staticCssPlayerCssBr, nil
+}
+
+func staticCssPlayerCssBr() (*asset, error) {
+	bytes, err := staticCssPlayerCssBrBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/css/player.css.br", size: 695, mode: os.FileMode(420), modTime: time.Unix(1612343832, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _staticCssPlayerCssGz = []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x02\x03\xd5T\xf7\x96\xb3*\x10\xff?O\xc1\xed\xd5|\xea~i\xee\xed\xbd\xf7\xdeQ\u01c4\x13\x02\x1e\xc0\xadg\xdf\xfd\"\xb3\xb8\x92\r9\xb7\x97\xada\x1cg~e\x98\xc7ZN/A\x91\xeb\t\xb1_5\xd3\xfd\xb9 %\x97\xd5\xf6\xd4\xc5Z\xa9\x99aR\x14\x84\x96Z\xf2\xce\x00\xc6\xcfYm6\x059Y\xcc\u068b\xd3\xc9\xcdd2-;c\xa4\x88\x97\xf2\xaf\xa4x\xda\x00[o\xccp,\xa5\xaaA%F\xb6\xb6\xa8\xadIl3V\x13\xa3\xa8\xd0-U L\x90WJ\xdbl\xf7\x9bR94\xb6\xcd<\x1d\x12\xa5M\\\x83\x82\x1a\xd3vT\xad\x99\xe5\xb7\xe83hg\xe4>q\x05\x9c\x1av\x06\x18\xbfJ\x98\xa8\xe1\xa2 \x19\x9e][/\x11\xe7d\xfa\x10\xe3\xc99\x94[f\x92\xe8\U000ddf0a>D\xccY\x1aj[\x94\xd0H\x05\xb7\x12WR\x18\x106\xed\xa9\xa7\x8e[\xe54M\u0428\xbb\xe2I\x96\r\x11T3HR\xe8N\xd2\v\x1c\u0229h\xcd:]\x90Y\xfa\xc48n\xd1\xceb\n\x0f\x92\xe51\xc9\xf4_\xa0\xd9(\x8e\x9f\xadV;2=\xd1(!\x97\x16\xb8X\x87\x1aR\xc1v\x14_i;\xae\x81Lg\x9a\x00\xd5`\x11\x13&\x1a&\x98\x01\xf7\xfe+[\xb8l\x14\u0741v\x99\xbe@\xfa\x04\xb9\xb6\n\\$zCky^\x10k\xd9\xf0\xdb\v@n\\\xde,\x9a8\x9b\r\x99c\xabic@\xed9\ud356-\xad\x98\xb9,\xd2{\xc4\xfd#\x94tTn#\xcf@\xddr\u007f\xdeG\xa7\xee\x8a\x06\x82\f\xc2\x15DW\x94\xc3\xd3\xd94\u007f&4'\x9a\x82\xfe\u011e#\x18lK;\r\a\x19\x92}\x8a$\vv\xc7\xcc*\x16\xae\x8f\xc5\x10)i\xb5]+\u0649\xba\x18\r`x3\x82\x8b\xe1g<\xf3\x15\x8cl\x83\x89\x0f\x16H\x1e\x1b\xef\xc0U&4\x18\xccM\xdd\xf7cM\xd38\xf2\x93\a\xcf>K\xbe\xfa\xf8\x83/?|\x93|\xfe\xc1\xbbo\xbc\xf9\x19y\xf6\xd9\a\x93\t\x13mg\xbe7\x97-\xbc\xe8\xca\xfe\xe8$\xf1\xf4fK\x04\xe3\u0167m\v\xd4\xe6UP\x10!\x85#\xe2\x17\x18\xe2\xf6R-\xd3'\xf0\xe1Er\x1b\xc9\xe7\xa8\xd5H\xa9\xa4\x92\\\xaa\"\u071d7\xf71\x15\x8d\xac:\xed\x90\xc9\xcep&|\xfb\x83\u0245G\xab9s+\xa3\x13\x82\x96\x1c\x12\xa3l\u7f8a\a\x99\xa5\x88\xd2\u04dd#\xc0\xaaS\xba\x87\xd5J&\f\xa8>\x847\u0576M\xa7\xb9\x0eI\x14\u4c77\xdeJ\xed\x97\v\x87[\xea\x04\v\xfa\x1d\x95z\x0f\xed+\xf9\xe2\xb5\xc5[\xbf\x8d\x80\xd9t\xbb\xd2\xe1\x8e]\xf5\xc7^\x9d\xf7\xdf\xe3^\xf9]\xaf\xd5|\xb9Z=\x1c\x13\xf5\x83\xecuX\xe01@\xefJ\xecS-\xb9=\x1c\xd0\xe8\xb7\rI\x82#\x9e;Y\xa2>\xff\x06\x03\x0f\xc9\x1f\xd1\xd2-\x05w\xf8k\xfd?\xee\xc4\xdf<\"!\xad\xff\xfexDI\xe8\xbf\xedR\x06;\xc5c?\xb8p\b9\x10\x8d\x03n\x18\xe7\t\x97\u7822\x93x\xdc\xcf}!\x91\xe1op\xef8\xa4\xaem\xffC\x90\xfcP\x86W?\u00f2\xff\xb39\u016d\xf4\x9b\u034f\x97\xf8Mfa\x89_\x01\xe01$V\x1b\r\x00\x00")
+
+func staticCssPlayerCssGzBytes() ([]byte, error) {
+	return _staticCssPlayerCssGz, nil
+}
+
+func staticCssPlayerCssGz() (*asset, error) {
+	bytes, err := staticCssPlayerCssGzBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/css/player.css.gz", size: 815, mode: os.FileMode(420), modTime: time.Unix(1612344943, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
 var _staticCssStyleCss = []byte(`/* Space out content a bit */
 body {
   padding-top: 80px;
@@ -442,7 +669,7 @@ func staticJsJquery351SlimMinJs() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "static/js/jquery-3.5.1.slim.min.js", size: 72380, mode: os.FileMode(420), modTime: time.Unix(1588633359, 0)}
+	info := bindataFileInfo{name: "static/js/jquery-3.5.1.slim.min.js", size: 72380, mode: os.FileMode(420), modTime: time.Unix(1612307980, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -459,7 +686,7 @@ func staticJsJquery351SlimMinJsBr() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "static/js/jquery-3.5.1.slim.min.js.br", size: 22370, mode: os.FileMode(420), modTime: time.Unix(1588633359, 0)}
+	info := bindataFileInfo{name: "static/js/jquery-3.5.1.slim.min.js.br", size: 22370, mode: os.FileMode(420), modTime: time.Unix(1612307980, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -476,7 +703,97 @@ func staticJsJquery351SlimMinJsGz() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "static/js/jquery-3.5.1.slim.min.js.gz", size: 24600, mode: os.FileMode(420), modTime: time.Unix(1588633359, 0)}
+	info := bindataFileInfo{name: "static/js/jquery-3.5.1.slim.min.js.gz", size: 24600, mode: os.FileMode(420), modTime: time.Unix(1612307980, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _staticJsPlayerJs = []byte(`var spplayer = {};
+
+spplayer.init = function(p) {
+    let status = "pause";
+    const player = document.createElement("audio");
+    const el_button = p.getElementsByClassName("button")[0];
+    const el_volume = p.getElementsByClassName("volume")[0];
+
+    // Create a source with parameters extracted from the target
+    // element's attributes.
+    let source = document.createElement("source");
+    source.type = p.getAttribute('stream-type');
+    source.src = p.getAttribute('stream-src');
+    player.appendChild(source);
+
+    // Enough of the audio has loaded to allow playback to begin.
+    player.addEventListener("canplaythrough", function () {
+        el_button.classList.remove("loading");
+    });
+
+    el_button.addEventListener("click", function () {
+        if (status === "play") {
+            player.load();
+        } else {
+            player.play();
+        }
+        status = status === "play" ? "pause" : "play";
+        el_button.classList.toggle("pause");
+    });
+
+    changeVolume = function (v) {
+        player.volume = el_volume.value/100;
+    };
+
+    el_volume.addEventListener("mousemove", changeVolume);
+    el_volume.addEventListener("change", changeVolume);
+};
+
+spplayer.init(document.getElementById("player"));
+`)
+
+func staticJsPlayerJsBytes() ([]byte, error) {
+	return _staticJsPlayerJs, nil
+}
+
+func staticJsPlayerJs() (*asset, error) {
+	bytes, err := staticJsPlayerJsBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/js/player.js", size: 1240, mode: os.FileMode(420), modTime: time.Unix(1612344201, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _staticJsPlayerJsBr = []byte("\xb1\xb8&\x00a\x1c\xa6\x1b{\xb9\xc4]\xd4fx\u0272\xb9\x94\x9dL\x89-\x1bW\x8f\xbcW\xf5\xf9\x06Q\xe8\xb1{\xb4\xcd^\xc8\xe9\xe4\x0f\x98\xa8\x96\x80j\x9c\xb4\xb8P\xb7\x80\xb7#zp\x12h;\x94\x1b\xb9\x81\x00\x1aWo\x86\xa6\xb7\xdfM\xf0\xfbv\xfc\x02`\x1f\x14\b\xfc\x89\x02\xef\u64c1Y\x00t\xd7)\n$\xbdp](\x9b\xf6\xaa\xd1A\x1b\r\xfb\xe5\xc7/\n\xf7\x95k\xf9\xed\u3684\u008ct\xc2'\x03k\xa7\x96[\x1c\x0e\xa3\xe8-\x93\x87\r~&\x19K\x91\xe7\xbf\xdcJ\x9cO\x16USvI\u0152\x1f\x9bG,E\x84\xbdZ\x83`x\xce\x18\xad\xb0\x00\xc6\xee\xdfx\xa3i\x85\x8a\x19\x00$X8_\xca\xdb\r\xd6\xf2v=9\xc7\x1e\x04D\xa4\xe3\x18\x85\xde\u015a\u03ed\xd0[O\u030d\xe0\x13\xb4`B!9\x95X\x18\x91\xb1\x90\x8b\xe3\xfd\xde{v\xcdy2\a\xf5+}\xe97\xcaFb\xa1\x11f\v\x90\xe0?1\xb4\xc5\xe8W\x06k\xff\x05\x1747\u0398\xb0\xde\x18B!Q\u047c\xdfP\xc8\x18a\xacZ\x81\x00-{\x02sI77p\xd8goe-\xb9\xe3\xccy\x8c*\x9fFH\x8f\u035eY\x86\xaaF\xcb\xc6s\\P\xbe\xb7\xea\u0530\x9a\xa0\xf5\xa1Md\xc9 \xc9&\xfc\xb8\x10\xa7(\x89c\xd2<\x10\x187s\xe3Y\x05x\xabN_\x02\x92\xce;6a\xb8\xac\x9c\x8bR&\xfb\x9c\x91\xe0\xeet\x9c,")
+
+func staticJsPlayerJsBrBytes() ([]byte, error) {
+	return _staticJsPlayerJsBr, nil
+}
+
+func staticJsPlayerJsBr() (*asset, error) {
+	bytes, err := staticJsPlayerJsBrBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/js/player.js.br", size: 370, mode: os.FileMode(420), modTime: time.Unix(1612344201, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _staticJsPlayerJsGz = []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x02\x03\x85T\u0152\xe3<\x10\xbe\xe7)\xba\x82\xf2\xff\xef\x06\x8e\xcb\xccp[FE\xee\u062a\x91%\x97\xd4\x1e\x9ew_\x93\x14\x06\xb3\xfd\x81\x9ar\xca-\xb8<W\xfc\x02-<\x80\xab\x9b{\x9d\x8e\u007f\x1eK-\xa9|\xb9(\xb4 i4\xcb#\xb8\xea@\xf9SH\xe0\x88S\xe1\xca\xcf\u075c\x17\x0e\xbb\xf7\xea/\xc2hG\x10\xf4b#\x8a\f5\x8d\x85EN\xf8Ba\xf5\u013a\xbc\x88\xa5\xe9F\xab\x1cT\u007f\xe6\x05\x91\xd1%-\x1f'H-\xd8=\xbdx\xa6\xb8s\x1fy\x86\xac\xdb@\xba\u044f\xe9\xaf\r\xf2\xa9Q\xa5\xd5Ar\x03i\xc95{2\x81g\xf5\u0280\x833\x85\x15\bg\x92R\u0239-\x19\x84\xd6\x01\x9e\x93\xe5\x820\x86\x855\x19P\x8a@\u0716\x16^\x00\x1b\xab\x91\x03Nde\xb9Btc\x9f&\xaf\xba?\x15\r\xc0\xe7\xa2y\x1a\xd3E\x1eBy\xe2U\xd9\xc8Q\xc9\xcdnW_G\xeb\x04g\xc5~|\xf9\xd1\xc3\xdb\xca\xf2<G\x1d?K\xa5\x8aY\xa3\x10-3\xf2B\x9b\"I\xc1,\xea`\xebRA\xca\x1d(\xc3c\x8c\x81\fp\xa5\xccY\xad5\xe7\xe2\xa4z3\xc7D\xea\xf1\x9aE\x1c\xbf8EM\xef\xa5#\xd4hYWp]}\xa3\xd4V\xfa\xdd[\xa1\xb1\x80\xf9\u01aa~\xa1\x11\u01a2*]\xc5\x1f[\xcc\xcc)\xb2n\xb5\x04\xa9\x13\x9f\xad\x1b\xbf\xea\xc0\xd9e\xab\xa48\xd9\xeb&\x17\xc0|+?(\x9b\xb9Zaw\x15\xb0\x12R\xe5\xceZ\xeb\u06be\xf4u\xb8\x1b[]\u05b0\xe1.\f\u0396-<\xf2\xc3\x04w\xdbW\xf7\x0e\xa6\x85L\x92(d\ri+)\"\xe5:\xc1\xcf~.\x96\xf1\x9f\x86\xf8\x96\xeb\r\xe3\x13Fi|\xcaU\x81\x93\xd9t\xda\uadb2K\xc0v\xae3S\xb8\xbaTe\xbeW\xed\xa3{G\xa9\r|\x9b\xb7\xf5\x8f\xc4\xc2$-\xe7\xfc\xe9\u015b\x98u\x1bP7*Y\xff\x00\x92\xc7V\xf8\xd8\x04\x00\x00")
+
+func staticJsPlayerJsGzBytes() ([]byte, error) {
+	return _staticJsPlayerJsGz, nil
+}
+
+func staticJsPlayerJsGz() (*asset, error) {
+	bytes, err := staticJsPlayerJsGzBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/js/player.js.gz", size: 502, mode: os.FileMode(420), modTime: time.Unix(1612344943, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -498,6 +815,64 @@ func staticRadio52Png() (*asset, error) {
 	return a, nil
 }
 
+var _staticSpeakerSvg = []byte(`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" version="1.0"  width="500" height="500" viewBox="0 0 75 75">
+<path d="M39.389,13.769 L22.235,28.606 L6,28.606 L6,47.699 L21.989,47.699 L39.389,62.75 L39.389,13.769z"
+style="stroke:#FF4500;stroke-width:5;stroke-linejoin:round;fill:#FF4500;"
+/>
+<path d="M48,27.6a19.5,19.5 0 0 1 0,21.4M55.1,20.5a30,30 0 0 1 0,35.6M61.6,14a38.8,38.8 0 0 1 0,48.6" style="fill:none;stroke:#FF4500;stroke-width:5;stroke-linecap:round"/>
+</svg>
+`)
+
+func staticSpeakerSvgBytes() ([]byte, error) {
+	return _staticSpeakerSvg, nil
+}
+
+func staticSpeakerSvg() (*asset, error) {
+	bytes, err := staticSpeakerSvgBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/speaker.svg", size: 524, mode: os.FileMode(420), modTime: time.Unix(1612341006, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _staticSpeakerSvgBr = []byte("\xa1X\x10\x00\xe08\xc8\u0351\xd7\x14#\xe1\xf0\x16\xf4\xe7~\v\x89\xd0w\xa3\xbe\xd39f\x89_\x02\"\x1a\n!\xf2\u007f7\xc1\xee}\xe3s\\\xa39,\x8a\x86\xc8\x04:-\xc28\xcf5\u04f9\x12\x9e\x85\x84\x01%\xb5^\xe9\x13\a\x1d$h\x191\xf7T\"\xab\xd8\xfdE\xf6\x04G\x97\x9bn\x9c\xccp\xbe\xd8\xee72\xfe\xe3\xbd\xf9\xbb\xfc\xa2\x1d\u0449\x02\xb3\x8fS\u02e8\xccLTg\x96;\xf7+?0(:A\xd0\u0583\u02f7.\u0630Ia0\xf6N$\xec\xfdq\u066e\\A\x97\v\x88\xc3\x14\x8b\x99\xaa\xa2\xba\x00\x9a1r4\xd3x\x06>\x99B6\t\x96\\\xa68\x13\x88\x1a\x85\xc6X\xde\xfek\xafR\xbc=\x9e\xf7\xcba\u0568O&>073wa\x14\u020a\x12\xad\u0606\xa9\x99\xa6}\u02ab\xae\xb6F\xe0\x9f}\x06M\x18gR0\xc0\x811R~\x16\u00e0\x82\xbe\n\x01\x05\x941\xcc\x1c\x83c\x82\xb9\v\x18\xab(\t\a\xf13\x971\x83\xec\xe0\xb3\f[\x14\x04\xf5\xd5uD\xe8\x17\xb3k\xb1\x161\xd1W\xda\x01")
+
+func staticSpeakerSvgBrBytes() ([]byte, error) {
+	return _staticSpeakerSvgBr, nil
+}
+
+func staticSpeakerSvgBr() (*asset, error) {
+	bytes, err := staticSpeakerSvgBrBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/speaker.svg.br", size: 278, mode: os.FileMode(420), modTime: time.Unix(1612341006, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _staticSpeakerSvgGz = []byte("\x1f\x8b\b\x00\x00\x00\x00\x00\x02\x03\x8c\x91E\x96\xe30\x10@\xd7Cw\u042b\xd9*e\x81%\xf3@\xd3*\xd9u\x1f\xc0a\x94b\b\x9e\xbe\xe5p\xb2j[T\xfc\xebU\xfaw\xbb\x98\x93\xf5\xa0\xac&\xd6d\xc0\x91\x01\x19\x98\x9e\xedO\xcc(\x83\x8f\xf7\xb7V\b\xa4\xaas\xd3\xcf\xe7\xd6\f20\x16\xfe\xfe\xf9\xf5=\xad\xd6#\xe2BM\x95\xc1\xb8\xae\x97\xb1\xe7m6\x1b\xdcH\xb4\xe5\xc8\x13\x8c1\xcfy\xc0Cf\xb2\x99\xf4\xebq\x06\x8a9a<\x98\x8c\xc6\xf5IXO\x06\x9b'\xbb\u0340\x11F\x02\xe5\x164U\x96y=&\xfd\f:2B\x19F\x94K\ftD\xdaB\xa0\x90\x8a\x8a\x105\u04e4\xado^~\x80:j\\8Fat\x11O\t\xb4@\x97\xbd}\x97n\x0f\xbf\xbeW\xf5n\ueeab\xea\xd2\xce\x06\xf1\xef\xb77\xdfa%G\xb1u\xa0\x8e\xd5Y\x9cO\xcc`j'&.\xed\xca\xf4\x93\xe1d>\xbfD\xb8T\xde\x1d\xb8\x1fR\xe1\x10r\x1e\xa1\xa2\xcdA\x98\xfb9a\xd4\x01\xfa\x1d\xa5\x90S\xc1P\xe5\x92Q\xc9.F\xa9Pw4GM\xb9\x9f\xcb\x10C\xda\x1c\x17\xb3\xef\xda\x05r\x82>\x00\x187\x9d\xe4\xcb\xf8\xbd|y\xa4\x87\x03m3\xab\xcf\xd9q\x01\x00id\x0f\x85\f\x02\x00\x00")
+
+func staticSpeakerSvgGzBytes() ([]byte, error) {
+	return _staticSpeakerSvgGz, nil
+}
+
+func staticSpeakerSvgGz() (*asset, error) {
+	bytes, err := staticSpeakerSvgGzBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "static/speaker.svg.gz", size: 329, mode: os.FileMode(420), modTime: time.Unix(1612344943, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
 var _templatesIndexHtml = []byte(`<!DOCTYPE html>
 <html lang="en">
   <head>
@@ -525,26 +900,25 @@ var _templatesIndexHtml = []byte(`<!DOCTYPE html>
         <div class="col-lg-6">
           <h4>Streams</h4>
           <ul>
-            {{$domain := .Domain}}
             {{range $m := .Mounts}}
             <li>
-              <a href="http://{{$domain}}{{$m.Mount.Path}}"
+              <a href="/player/{{$m.Mount.Path}}"
                  {{if $m.Mount.RelayUrl}}
                  data-toggle="tooltip" data-delay="300" title="relay of {{$m.Mount.RelayUrl}}"
                  {{else if $m.IcecastMount.GetDescription}}
                  data-toggle="tooltip" data-delay="300" title="{{$m.IcecastMount.GetDescription}}"
                  {{end}}
                  >{{$m.Mount.Path}}</a>
-              <a href="http://{{$domain}}{{$m.Mount.Path}}.m3u">(m3u)</a>
+              <a href="/{{$m.Mount.Path}}.m3u">(m3u)</a>
               <span class="badge badge-secondary">{{$m.Listeners}}</span>
               {{if $m.TransMounts}}
               <ul>
                 {{range $tm := $m.TransMounts}}
                 <li>
-                  <a href="http://{{$domain}}{{$tm.Mount.Path}}"
+                  <a href="/player/{{$tm.Mount.Path}}"
                      data-toggle="tooltip" data-delay="300" title="{{$tm.Mount.TranscodeParams.String}}"
                      >{{$tm.Mount.Path}}</a>
-                  <a href="http://{{$domain}}{{$tm.Mount.Path}}.m3u">(m3u)</a>
+                  <a href="/{{$tm.Mount.Path}}.m3u">(m3u)</a>
                   <span class="badge badge-secondary">{{$tm.Listeners}}</span>
                 </li>
                 {{end}}
@@ -602,7 +976,53 @@ func templatesIndexHtml() (*asset, error) {
 		return nil, err
 	}
 
-	info := bindataFileInfo{name: "templates/index.html", size: 3037, mode: os.FileMode(420), modTime: time.Unix(1612307759, 0)}
+	info := bindataFileInfo{name: "templates/index.html", size: 2948, mode: os.FileMode(420), modTime: time.Unix(1612345109, 0)}
+	a := &asset{bytes: bytes, info: info}
+	return a, nil
+}
+
+var _templatesPlayerHtml = []byte(`<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>{{.Domain}}</title>
+    <link rel="stylesheet" href="/static/css/bootstrap.min.css">
+    <link rel="stylesheet" href="/static/css/style.css">
+    <link rel="stylesheet" href="/static/css/player.css">
+    <link rel="shortcut icon" href="/static/radio52.png">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+  </head>
+  <body>
+
+    <div class="container">
+      <div class="page-header">
+        <h1>{{.Name}}</h1>
+      </div>
+
+      <div id="player" stream-type="{{.Type}}" stream-src="{{.URL}}">
+        <a href="#" title="Listen" class="button loading"></a>
+        <div class="volumebar">
+          <img src="/static/speaker.svg" width="60" height="60" alt="volume">
+          <input class="volume" type="range" min="0" max="100" value="100" >
+        </div>
+      </div>
+    </div>
+
+    <script type="text/javascript" src="/static/js/player.js"></script>
+  </body>
+</html>
+`)
+
+func templatesPlayerHtmlBytes() ([]byte, error) {
+	return _templatesPlayerHtml, nil
+}
+
+func templatesPlayerHtml() (*asset, error) {
+	bytes, err := templatesPlayerHtmlBytes()
+	if err != nil {
+		return nil, err
+	}
+
+	info := bindataFileInfo{name: "templates/player.html", size: 955, mode: os.FileMode(420), modTime: time.Unix(1612344550, 0)}
 	a := &asset{bytes: bytes, info: info}
 	return a, nil
 }
@@ -665,6 +1085,9 @@ var _bindata = map[string]func() (*asset, error){
 	"static/css/bootstrap.min.css": staticCssBootstrapMinCss,
 	"static/css/bootstrap.min.css.br": staticCssBootstrapMinCssBr,
 	"static/css/bootstrap.min.css.gz": staticCssBootstrapMinCssGz,
+	"static/css/player.css": staticCssPlayerCss,
+	"static/css/player.css.br": staticCssPlayerCssBr,
+	"static/css/player.css.gz": staticCssPlayerCssGz,
 	"static/css/style.css": staticCssStyleCss,
 	"static/css/style.css.br": staticCssStyleCssBr,
 	"static/css/style.css.gz": staticCssStyleCssGz,
@@ -678,8 +1101,15 @@ var _bindata = map[string]func() (*asset, error){
 	"static/js/jquery-3.5.1.slim.min.js": staticJsJquery351SlimMinJs,
 	"static/js/jquery-3.5.1.slim.min.js.br": staticJsJquery351SlimMinJsBr,
 	"static/js/jquery-3.5.1.slim.min.js.gz": staticJsJquery351SlimMinJsGz,
+	"static/js/player.js": staticJsPlayerJs,
+	"static/js/player.js.br": staticJsPlayerJsBr,
+	"static/js/player.js.gz": staticJsPlayerJsGz,
 	"static/radio52.png": staticRadio52Png,
+	"static/speaker.svg": staticSpeakerSvg,
+	"static/speaker.svg.br": staticSpeakerSvgBr,
+	"static/speaker.svg.gz": staticSpeakerSvgGz,
 	"templates/index.html": templatesIndexHtml,
+	"templates/player.html": templatesPlayerHtml,
 }
 
 // AssetDir returns the file names below a certain
@@ -730,6 +1160,9 @@ var _bintree = &bintree{nil, map[string]*bintree{
 			"bootstrap.min.css": &bintree{staticCssBootstrapMinCss, map[string]*bintree{}},
 			"bootstrap.min.css.br": &bintree{staticCssBootstrapMinCssBr, map[string]*bintree{}},
 			"bootstrap.min.css.gz": &bintree{staticCssBootstrapMinCssGz, map[string]*bintree{}},
+			"player.css": &bintree{staticCssPlayerCss, map[string]*bintree{}},
+			"player.css.br": &bintree{staticCssPlayerCssBr, map[string]*bintree{}},
+			"player.css.gz": &bintree{staticCssPlayerCssGz, map[string]*bintree{}},
 			"style.css": &bintree{staticCssStyleCss, map[string]*bintree{}},
 			"style.css.br": &bintree{staticCssStyleCssBr, map[string]*bintree{}},
 			"style.css.gz": &bintree{staticCssStyleCssGz, map[string]*bintree{}},
@@ -745,11 +1178,18 @@ var _bintree = &bintree{nil, map[string]*bintree{
 			"jquery-3.5.1.slim.min.js": &bintree{staticJsJquery351SlimMinJs, map[string]*bintree{}},
 			"jquery-3.5.1.slim.min.js.br": &bintree{staticJsJquery351SlimMinJsBr, map[string]*bintree{}},
 			"jquery-3.5.1.slim.min.js.gz": &bintree{staticJsJquery351SlimMinJsGz, map[string]*bintree{}},
+			"player.js": &bintree{staticJsPlayerJs, map[string]*bintree{}},
+			"player.js.br": &bintree{staticJsPlayerJsBr, map[string]*bintree{}},
+			"player.js.gz": &bintree{staticJsPlayerJsGz, map[string]*bintree{}},
 		}},
 		"radio52.png": &bintree{staticRadio52Png, map[string]*bintree{}},
+		"speaker.svg": &bintree{staticSpeakerSvg, map[string]*bintree{}},
+		"speaker.svg.br": &bintree{staticSpeakerSvgBr, map[string]*bintree{}},
+		"speaker.svg.gz": &bintree{staticSpeakerSvgGz, map[string]*bintree{}},
 	}},
 	"templates": &bintree{nil, map[string]*bintree{
 		"index.html": &bintree{templatesIndexHtml, map[string]*bintree{}},
+		"player.html": &bintree{templatesPlayerHtml, map[string]*bintree{}},
 	}},
 }}
 
diff --git a/node/debug.go b/node/debug.go
index 0caf6803..fe59f70c 100644
--- a/node/debug.go
+++ b/node/debug.go
@@ -145,14 +145,14 @@ func (s *statusPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	sort.Sort(statusList(statuses))
 
 	ms := mountsToStatus(s.n.mounts.GetMounts(), nodes, exemplary)
-	ctx := struct {
+	vars := struct {
 		Domain string
 		Nodes  []*pb.Status
 		Mounts []*mountStatus
 	}{s.domain, statuses, ms}
 
 	var buf bytes.Buffer
-	if err := tpl.ExecuteTemplate(&buf, "index.html", ctx); err != nil {
+	if err := tpl.ExecuteTemplate(&buf, "index.html", vars); err != nil {
 		log.Printf("error rendering status page: %v", err)
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
diff --git a/node/http.go b/node/http.go
index abac15cf..53d36972 100644
--- a/node/http.go
+++ b/node/http.go
@@ -4,6 +4,7 @@ package node
 //go:generate go-bindata --nocompress --pkg node static/... templates/...
 
 import (
+	"bytes"
 	"context"
 	"crypto/tls"
 	"flag"
@@ -14,6 +15,7 @@ import (
 	"net/http"
 	"net/http/httputil"
 	"net/url"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"time"
@@ -111,6 +113,12 @@ func newHTTPHandler(n *Node, icecastPort int, domain string) http.Handler {
 	// statusHandler serves the home status page.
 	statusHandler := gziphandler.GzipHandler(newStatusPageHandler(n, domain))
 
+	// playerHandler serves the HTML audio player.
+	playerHandler := gziphandler.GzipHandler(withMount(n, func(m *pb.Mount, w http.ResponseWriter, r *http.Request) {
+		servePlayer(m, w, r, domain)
+	}))
+	mux.Handle("/player/", http.StripPrefix("/player", playerHandler))
+
 	streamPrefixSlash := autoradio.IcecastMountPrefix + "/"
 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 		switch {
@@ -196,6 +204,36 @@ func serveRedirect(lb *loadBalancer, mount *pb.Mount, w http.ResponseWriter, r *
 	sendRedirect(w, r, targetURL.String())
 }
 
+func servePlayer(m *pb.Mount, w http.ResponseWriter, r *http.Request, domain string) {
+	// Build the stream URL using the incoming request.
+	streamURL := url.URL{
+		Scheme: schemeFromRequest(r),
+		Host:   r.Host,
+		Path:   m.Path,
+	}
+
+	// Make up the audio MIME type from the stream path.
+	mimeType := "audio/" + strings.TrimPrefix(filepath.Ext(m.Path), ".")
+
+	vars := struct {
+		Domain string
+		Name   string
+		Type   string
+		URL    string
+	}{domain, m.Path, mimeType, streamURL.String()}
+
+	var buf bytes.Buffer
+	if err := tpl.ExecuteTemplate(&buf, "player.html", vars); err != nil {
+		log.Printf("error rendering player page: %v", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
+	addDefaultHeaders(w)
+	w.Write(buf.Bytes()) //nolint
+}
+
 // Serve a M3U response. This simply points back at the stream
 // redirect handler by dropping the .m3u suffix in the request URL.
 func sendM3U(w http.ResponseWriter, r *http.Request) {
diff --git a/node/static/css/player.css b/node/static/css/player.css
new file mode 100644
index 00000000..0551110a
--- /dev/null
+++ b/node/static/css/player.css
@@ -0,0 +1,166 @@
+#player {
+    display: block;
+    position: absolute;
+    width: 375px;
+}
+
+.button {
+    display: block;
+    width: 0;
+    height: 0;
+    border-top: 35px solid transparent;
+    border-bottom: 35px solid transparent;
+    border-left: 60px solid orangered;
+    margin: 70px auto;
+    position: relative;
+    z-index: 1;
+    transition: all .4;
+    -webkit-transition: all .4;
+    -moz-transition: all .4;
+    left: 10px;
+}
+
+.button:before {
+    content: '';
+    position: absolute;
+    top: -75px;
+    left: -115px;
+    bottom: -75px;
+    right: -35px;
+    border-radius: 50%;
+    border: 15px solid orangered;
+    z-index: 2;
+    transition: all .4s;
+    -webkit-transition: all .4;
+    -moz-transition: all .4;
+    transition: transform .3s;
+}
+
+.loading:before {
+    animation: pulse .5s ease-in infinite;
+}
+
+@keyframes pulse {
+    0% { box-shadow: 0px 0px 0px red; }
+    50% { box-shadow: 0px 0px 55px red; }
+}
+
+.button:after {
+    content:'';
+    opacity:0;
+    transition: opacity .4s;
+}
+
+.button:hover:before,
+.button.play:before {
+    transform: scale(1.2);
+    -webkit-transform: scale(1.2);
+    -moz-transform: scale(1.2);
+}
+
+.button.pause:after {
+    content: '';
+    opacity: 1;
+    width: 50px;
+    height: 70px;
+    background: orangered;
+    position:absolute;
+    right: 1px;
+    top: -35px;
+    border-left: 25px solid orangered;
+    box-shadow: inset 25px 0 0 0 #fff;
+}
+
+
+/** VOLUME SLIDER **/
+
+input[type=range] {
+  height: 58px;
+  -webkit-appearance: none;
+  margin: 5px;
+  width: 80%;
+  max-width: 260px;
+  background-color: transparent;
+}
+input[type=range]:focus {
+  outline: none;
+}
+input[type=range]::-webkit-slider-runnable-track {
+  width: 100%;
+  height: 6px;
+  cursor: pointer;
+  animate: 0.2s;
+  background: #FF0000;
+  border-radius: 3px;
+  border: 0px solid #F27B7F;
+}
+input[type=range]::-webkit-slider-thumb {
+  box-shadow: 0px 0px 0px #A6A6A6;
+  border: 2px solid #968994;
+  height: 50px;
+  width: 17px;
+  border-radius: 2px;
+  background: black;
+  cursor: pointer;
+  -webkit-appearance: none;
+  margin-top: -23px;
+}
+input[type=range]:focus::-webkit-slider-runnable-track {
+  background: #FF0000;
+}
+input[type=range]::-moz-range-track {
+  width: 100%;
+  height: 6px;
+  cursor: pointer;
+  animate: 0.2s;
+  box-shadow: 0px 0px 0px #A6A6A6;
+  background: #FF0000;
+  border-radius: 3px;
+  border: 0px solid #F27B7F;
+}
+input[type=range]::-moz-range-thumb {
+  box-shadow: 0px 0px 0px #A6A6A6;
+  border: 2px solid #968994;
+  height: 50px;
+  width: 17px;
+  border-radius: 2px;
+  background: black;
+  cursor: pointer;
+}
+input[type=range]::-ms-track {
+  width: 100%;
+  height: 6px;
+  cursor: pointer;
+  animate: 0.2s;
+  background: transparent;
+  border-color: transparent;
+  color: transparent;
+}
+input[type=range]::-ms-fill-lower {
+  background: #FF0000;
+  border: 0px solid #F27B7F;
+  border-radius: 6px;
+  box-shadow: 0px 0px 0px #A6A6A6;
+}
+input[type=range]::-ms-fill-upper {
+  background: #FF0000;
+  border: 0px solid #F27B7F;
+  border-radius: 6px;
+  box-shadow: 0px 0px 0px #A6A6A6;
+}
+input[type=range]::-ms-thumb {
+  margin-top: 1px;
+  box-shadow: 0px 0px 0px #A6A6A6;
+  border: 2px solid #968994;
+  height: 50px;
+  width: 17px;
+  border-radius: 2px;
+  background: black;
+  cursor: pointer;
+}
+input[type=range]:focus::-ms-fill-lower {
+  background: #FF0000;
+}
+input[type=range]:focus::-ms-fill-upper {
+  background: #FF0000;
+}
diff --git a/node/static/css/player.css.br b/node/static/css/player.css.br
new file mode 100644
index 0000000000000000000000000000000000000000..b087a847e27c2250341aac5f49858b5b8008386d
GIT binary patch
literal 695
zcmX?jA%h{wFk!o<`JqQ^*G*V9C->---!~dcl5d2sI{To7+uN*z$>;vktxLOZp1XhT
zLXgLLD;G!eYm079O?ajD(7@pCjR$(yC*Azf_r7Av*3f;E&;C1iXBoo+2G#V*-ZSrV
zhJV_3>+hab7h>Bk?_@irWOng>M7q3mt+7<E6yu8Z-Wra_<uX+Rs_Y^byvg?|OF2@h
zt0Q$=#Afl@=b<+$PR)wSd0716o$|IB85{li3QaB+^%|TGKU6Zwuj#Ds&LHLKV#U&m
z`?HQnef?h(@VK`0_sq;S-AkUVbL@3eTg<arZi7xDW4L5k*=J(~Ri2-ks}j!z^3R&K
zzf=Bcxzn^6(^Zr+KSmg=^?kJ}Q>RTtTs=}y_+o)fat*V9MBe)~8gd&KR_kp)B=uD2
z`Qbut?)YQ*zs*C$r&k}j6JD@BVq2ff_x3JU=6{zzM~1rVWb3r>n!S@+>b9(OcliU4
z<4y^yG6ZU5gYNHbHZ~4ADU>Ap*k5k$g`TYyHB&QQ-)FEYIe7Bl+$1S~(|w**{2k6k
z_Z}Z`*-)@@!STzSy6KjR!7Hy^YTe=<8MIE@vMYaU<QqlJfG7G*(*M;9uKpLBKX(DA
zlKt81EzI}cxEi){tl1iqwEyW@$@g>Yr*Si%>5QDbZiYDjUIw!zIe9<VF?zhOJhm~Z
zRqOz-v!c?2_4jmZO8>++-I=XsWccLB@qHOii;{$M-b{QwRdQFV>fM{Ku1RpRHKr8Y
zEt)CfB(md=$ZNICtIBTniB$(J@3gX<kZ1TDam>!{?)$iyDB-IIHpiYf-j?FcTG%Ap
zApAc_wX^lc+owT0W=RBm(pvi1%POL!j`7D%&OF9*%R1RuPAQ$8+QM<Vf=j5s$KEsT
z<*5?|kIF5}B@RAbv}PT@!IO&{OAH+r$%Re3<@kx)>DB%Z>)f+6d+jGpKC^1&gXw(k
Le;)Rh9IyreUPMxs

literal 0
HcmV?d00001

diff --git a/node/static/css/player.css.gz b/node/static/css/player.css.gz
new file mode 100644
index 0000000000000000000000000000000000000000..271e19a5f90adf1d4fb62271b8b45617ca1cde4f
GIT binary patch
literal 815
zcmb2|=3oE;Cg!Ul-=}TX68LZLfAH<qnpbt1@80hHelPHNi!hVifwk%P|0-?XF^Si5
z`}DPmGU;`xGmc03={rt*&ABn&^zz@G3aZnue!FrsYURwtAd!}7QJ>B+96KMGYsP9B
zdFEF4<&$SqjC8G&mvl`3w0=p&Er#g)GX*+JS2?P>ZQHhDt!IvLnDF8i7j#1vd?ti@
z-Ol+wJIK;x+gTe~p{2et%}r95%R<)9e7gOGaYp(RyF%7EQe|us`@LqYc(_PGa$an-
zzToqeUjmO$T26^hoAl!I+&x`(E@vX8vZAM4@L=686doiYVD@(9^1kS&CcYO|?0)1q
zjaM+l*dyqL-z9FD6Du>$TE$L?{B^`4@8Y>6F8)bR4NrcFUvN{SZ~odaYa`o>8j91!
zc0{yG1syCCiO;lN*XWZzi{Wa5ps<>h+6>0;|Folb<cQpDEStH{;TOx!ZCo)bRnFO!
zaRS*lKiuYa$cwqBGs|K&@66=YnaK`sE#LM|(_K4br_QD7BY%~rl!W9dpLe)hRDaKX
z`MWN*CZ0p0F5y!SU%qKluV$h?tLfj7dCC$w-qyVAl9Ot?RJhBIpV1Oq*MC$%GjnUK
z_RY>+61)i&#!cN1(``PB_T)5o@{4)MNxwf3s%9c_#`o^`WZ%mcpC+@PxAWfj>qoP`
zea+;We+PHx@A)ZN`RykAWKQAS^nK>jb@#kD_3wqs$1iE#ERT;a&*i@IEHpqsu~qqz
zxWuDxLAsaKKRk3Tl0Nx-#Z`}$OD4svQo1wO{JWuO?;5MO6HcBhP^<afe6rPjwPmsP
zV&SG~GD3@Up6}~opSiT-(&f3z2UZ2|YQO5G#<Rk%#r*0v-{m{`JVl;t+<wpN?R4(;
z=vOS<zhtLY@xRsn`Dp9W=>5G7H%oS3de|5H^?2Oe@An_a{fu3IwPt6Ot&H&3<~OAg
z4-6gNd%X$P-DxXx;l*}d&nD%0){%?s{<AUjpOn9NNtd<!NA_R)cSr8qC@HS}|F6Qu
z^TqzRL2TAXlkImDa99fTGUrJ==bZk0!J^3(ch1);PJEbO@ZMs{y4?TH6aECWh1(zA
f^nbJE(Y0S@pY5Oi!#6FlGoJB*p-PxEF9QPrJ;RH{

literal 0
HcmV?d00001

diff --git a/node/static/js/player.js b/node/static/js/player.js
new file mode 100644
index 00000000..e7a1015a
--- /dev/null
+++ b/node/static/js/player.js
@@ -0,0 +1,39 @@
+var spplayer = {};
+
+spplayer.init = function(p) {
+    let status = "pause";
+    const player = document.createElement("audio");
+    const el_button = p.getElementsByClassName("button")[0];
+    const el_volume = p.getElementsByClassName("volume")[0];
+
+    // Create a source with parameters extracted from the target
+    // element's attributes.
+    let source = document.createElement("source");
+    source.type = p.getAttribute('stream-type');
+    source.src = p.getAttribute('stream-src');
+    player.appendChild(source);
+
+    // Enough of the audio has loaded to allow playback to begin.
+    player.addEventListener("canplaythrough", function () {
+        el_button.classList.remove("loading");
+    });
+
+    el_button.addEventListener("click", function () {
+        if (status === "play") {
+            player.load();
+        } else {
+            player.play();
+        }
+        status = status === "play" ? "pause" : "play";
+        el_button.classList.toggle("pause");
+    });
+
+    changeVolume = function (v) {
+        player.volume = el_volume.value/100;
+    };
+
+    el_volume.addEventListener("mousemove", changeVolume);
+    el_volume.addEventListener("change", changeVolume);
+};
+
+spplayer.init(document.getElementById("player"));
diff --git a/node/static/js/player.js.br b/node/static/js/player.js.br
new file mode 100644
index 0000000000000000000000000000000000000000..8a59ff471f7e9330971a311d76d5ee3b9fef10ee
GIT binary patch
literal 370
zcmdnELyaL(W|?&L&Lgo`(kf1F+Bs#ePp7VQc>kX8uRqxWUu>-2ayIV7%P0IZR!nQi
znzLm`!1jji%2fqI8P-#zcQ!Ieh3B^|+kW5o!|$>`ObPNL9Dh2Q-anh%7|Bp_U6V^?
zZ$Yfa>~E_sI!g2YetKM=>-*H~pKl+}YB|(Xa!8#yd-=3z8NS6YbSJm-)~QK)Pkg@r
zj@KN2vC!Z$&!dy%XS?gTw(N~+PN+C1v33K)v3K_?7H770NiwKJSj3;Y&3o-rnXTn<
zITn{Ck0n~~9i4T4?S*LnGrbRlw<I_zT276Sn7DC5_v63!s>{w+8nJ)Xu6=2K$}MT3
zU>f&?2lj?rj=l(I%l^+QZeo6J#)f+mPKtpS_uLOSA(6Nys*yprnyJ{++`QmMdVZ?z
z&c|med$i`edGw#17umMT?ewwYn1FrTUtQTSYr)rrz9}aaPOAOcA+TJdGx?H@fP{JR
h<4D$u)qe3zlg?S2CGJ==r#ndP_ne6j-j&SJ0RUvWv|<1N

literal 0
HcmV?d00001

diff --git a/node/static/js/player.js.gz b/node/static/js/player.js.gz
new file mode 100644
index 0000000000000000000000000000000000000000..f470918478aa6f0b04723cf4ac1ddb5db1450d58
GIT binary patch
literal 502
zcmb2|=3oE;Cg#?Vqmv%n2<&^VxvS~Z|MzTtr_U5byScu*v1+2~^eb}n%Ht=Cux<X^
zIIHNC?hc#qKTNtd4XbBY&+V(1^VVHivr}V>LZ-=S<sGjaeg=Cy=m>sz>ipd~;(WWm
zztTUH;V$qcNx6&1w71pO?}t<3*CV_3bS!=8wpu2n{u%4Ui>kWv>K?i`Z1+|y+fgiE
z*|z4k!>)_{FW2*aS{b<Xs$&sz=1J3PqsH{Fry81#TSYn2Ck36@sVmuXjd9-Or%eWJ
zt)`LzT@H7qiN!HUub#;4my){EsU&&6+WKAgqPz#5FRGNf>?-F}`6%?}iNKR*^TOlZ
zU$dkit*iNY@$l_apKRP^?Y)<FMJ^W5HU8u@TgmgF&Z~siL5`<nH4Z2DH1ZTIJrlXs
z^!cMDRmR6%UVWV9Dzo3h)%X2U<trJH;RUg^i?4+Jx*NT4W5ds5tLhdC9$TapdsII9
z%o)v1d0Sal3eR6Fz5msLo2jdpSloQACVrqs+g@Y#&Zxw4(G5aDPtQfYzP0a5=?>}W
z*lQae>hYYLrfc)*FiZLE@b7%fT74#Ir0`~HYbrgpI(WWLZ`#NCZ9k^H=6x*emRWNu
zv~lvyl3TB~ZSp=)w$3<s$F7jnec^8xSG%v|t(m?3YyXi$D!R}AygWL4MyYgwxmM(V
OhDpc6e%xSTU;qGHh4+2{

literal 0
HcmV?d00001

diff --git a/node/static/speaker.svg b/node/static/speaker.svg
new file mode 100644
index 00000000..7ef57b08
--- /dev/null
+++ b/node/static/speaker.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" version="1.0"  width="500" height="500" viewBox="0 0 75 75">
+<path d="M39.389,13.769 L22.235,28.606 L6,28.606 L6,47.699 L21.989,47.699 L39.389,62.75 L39.389,13.769z"
+style="stroke:#FF4500;stroke-width:5;stroke-linejoin:round;fill:#FF4500;"
+/>
+<path d="M48,27.6a19.5,19.5 0 0 1 0,21.4M55.1,20.5a30,30 0 0 1 0,35.6M61.6,14a38.8,38.8 0 0 1 0,48.6" style="fill:none;stroke:#FF4500;stroke-width:5;stroke-linecap:round"/>
+</svg>
diff --git a/node/static/speaker.svg.br b/node/static/speaker.svg.br
new file mode 100644
index 0000000000000000000000000000000000000000..34863f793c5af6632d3a026af4ffb05566160c9b
GIT binary patch
literal 278
zcmZ1&A;9p!;>6jB*F}^ceh~ZeypFr`Liyr-mo3ve<C&DCxD-Ftn;(2v`?xq}v87H|
z+X)sc-9r}VO)u{hn%COGsJb=or7*j!N`|E2_YkGkH~zYQV{xB8J8#aJf_*pMnH&9k
zy!YqsKZ|59b~0`54?ex(j891VH0$r$_68bOju);qpWd!_V}oa+!8bpZH-8Inu8VP;
z&fRfXq<iM7MY|Yg85Wsbu3)p9>14(^EoPa8aEDauv50&BveyUgv7Pt*bmG+&{%Upx
z=EmiTA}6|p*4}7aIdfU<snzSYxjmR)%jPSQ9%OK!(J-h^?C^r7eO!#JQw-0@G$%Lh
n<d#^i!O8y7c)DTpn+KbDqD5G~UM+QbA-*|#qnP2v@LP-kBglhk

literal 0
HcmV?d00001

diff --git a/node/static/speaker.svg.gz b/node/static/speaker.svg.gz
new file mode 100644
index 0000000000000000000000000000000000000000..5e4ec9e7eb815ab51da53d8f06f16b486df82cdd
GIT binary patch
literal 329
zcmb2|=3oE;Cgz@ruG1bH2sm7KF2At)rdDdB>Su?`S~pAO4<t@W;+VJZX~Cwf=#W3J
zLw}X;o-z6Bf;DQ_QVvXHl$<f|t^XN~=Kk;7!#I|#D!zRF`86IR1F?VqetxxGdrkR~
zlke1nJJwC#_&nE4`i{qzrzeDa49`~{a8BDa^ULc(wl2#=n;AXFzIsOZv&~lDeYQc+
zjp?bFN$9jnTeZJDRz^;eZd1H@N?dL^Ett^NC184a$=dw5x(2JP7z?+=5<!2?Ee_N7
z)=tmk-@h;XYu>wEt6yC@$M*64cJup*s_q+07xZ0?oa28cAxm9N@9n8ClOLwo?RVJ`
za!+=Le9%LIBDsZ&&N|NcZJBt5@t5q<3Bd;go=yrpspqybpxngWcjx@m0?xN&#5cdc
n#ah&4#~@)o_sQuWduu9}v@_=#uRedXkdYxXg};@DiGcwCcYd8N

literal 0
HcmV?d00001

diff --git a/node/templates/index.html b/node/templates/index.html
index bb1a1b7d..1a470fcf 100644
--- a/node/templates/index.html
+++ b/node/templates/index.html
@@ -25,26 +25,25 @@
         <div class="col-lg-6">
           <h4>Streams</h4>
           <ul>
-            {{$domain := .Domain}}
             {{range $m := .Mounts}}
             <li>
-              <a href="http://{{$domain}}{{$m.Mount.Path}}"
+              <a href="/player/{{$m.Mount.Path}}"
                  {{if $m.Mount.RelayUrl}}
                  data-toggle="tooltip" data-delay="300" title="relay of {{$m.Mount.RelayUrl}}"
                  {{else if $m.IcecastMount.GetDescription}}
                  data-toggle="tooltip" data-delay="300" title="{{$m.IcecastMount.GetDescription}}"
                  {{end}}
                  >{{$m.Mount.Path}}</a>
-              <a href="http://{{$domain}}{{$m.Mount.Path}}.m3u">(m3u)</a>
+              <a href="/{{$m.Mount.Path}}.m3u">(m3u)</a>
               <span class="badge badge-secondary">{{$m.Listeners}}</span>
               {{if $m.TransMounts}}
               <ul>
                 {{range $tm := $m.TransMounts}}
                 <li>
-                  <a href="http://{{$domain}}{{$tm.Mount.Path}}"
+                  <a href="/player/{{$tm.Mount.Path}}"
                      data-toggle="tooltip" data-delay="300" title="{{$tm.Mount.TranscodeParams.String}}"
                      >{{$tm.Mount.Path}}</a>
-                  <a href="http://{{$domain}}{{$tm.Mount.Path}}.m3u">(m3u)</a>
+                  <a href="/{{$tm.Mount.Path}}.m3u">(m3u)</a>
                   <span class="badge badge-secondary">{{$tm.Listeners}}</span>
                 </li>
                 {{end}}
diff --git a/node/templates/player.html b/node/templates/player.html
new file mode 100644
index 00000000..e2aa446b
--- /dev/null
+++ b/node/templates/player.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>{{.Domain}}</title>
+    <link rel="stylesheet" href="/static/css/bootstrap.min.css">
+    <link rel="stylesheet" href="/static/css/style.css">
+    <link rel="stylesheet" href="/static/css/player.css">
+    <link rel="shortcut icon" href="/static/radio52.png">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+  </head>
+  <body>
+
+    <div class="container">
+      <div class="page-header">
+        <h1>{{.Name}}</h1>
+      </div>
+
+      <div id="player" stream-type="{{.Type}}" stream-src="{{.URL}}">
+        <a href="#" title="Listen" class="button loading"></a>
+        <div class="volumebar">
+          <img src="/static/speaker.svg" width="60" height="60" alt="volume">
+          <input class="volume" type="range" min="0" max="100" value="100" >
+        </div>
+      </div>
+    </div>
+
+    <script type="text/javascript" src="/static/js/player.js"></script>
+  </body>
+</html>
-- 
GitLab