Created
August 17, 2016 01:46
-
-
Save stewart/21f78020d2665b226fa62f6f1544cd8b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<title> CMUS Remote </title> | |
<link href="https://fonts.googleapis.com/css?family=Cormorant+Garamond" rel="stylesheet"> | |
<style> | |
/* normalize.css */ | |
html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block}audio:not([controls]){display:none;height:0}progress{vertical-align:baseline}template,[hidden]{display:none}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}dfn{font-style:italic}h1{font-size:2em;margin:0.67em 0}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}img{border-style:none}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-family:monospace, monospace;font-size:1em}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}button,input,select,textarea{font:inherit;margin:0}optgroup{font-weight:bold}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-input-placeholder{color:inherit;opacity:0.54}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit} | |
html, body, .container { | |
width: 100%; | |
height: 100%; | |
} | |
body { | |
background-color: #FEFFF6; | |
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif; | |
} | |
.container { | |
display: flex; | |
flex-direction: column; | |
justify-content: space-between; | |
} | |
#info, #controls-and-progress { | |
padding: 2em 3em; | |
} | |
#info h1, #info h2 { | |
margin: 0; | |
} | |
#info .title { | |
font-family: "Cormorant Garamond", "Garamond", "Palatino", "Times New Roman", serif; | |
font-weight: bold; | |
font-size: 70px; | |
line-height: 1.2; | |
color: #222; | |
text-overflow: ellipsis; | |
white-space: nowrap; | |
overflow: hidden; | |
} | |
#info .artist, #info .album { | |
font-size: 18px; | |
color: #444; | |
} | |
#info .album { | |
font-weight: normal; | |
} | |
#controls { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
} | |
#controls a { | |
text-decoration: none; | |
display: inline-block; | |
} | |
#media-controls a:hover { | |
background: #dedede; | |
} | |
#media-controls a:active { | |
background: #bbb; | |
} | |
#setting-controls a { | |
padding: 0.25em 1em; | |
font-size: 1.25em; | |
color: #aaa; | |
border: 2px solid #aaa; | |
} | |
#setting-controls a:first-child { | |
margin-right: 1.5em; | |
} | |
#setting-controls a.active, #setting-controls a:active { | |
color: #444; | |
border-color: #444; | |
} | |
.progress-wrapper { | |
width: 100%; | |
position: relative; | |
border-right: 2px solid #aaa; | |
border-left: 2px solid #aaa; | |
margin-top: 30px; | |
height: 20px; | |
cursor: pointer; | |
} | |
.progress-indicator, .progress-indicator .progress { | |
width: 100%; | |
height: 2px; | |
background-color: #aaa; | |
position: absolute; | |
top: 50%; | |
transform: translateY(-50%); | |
} | |
.progress-indicator .progress { | |
width: auto; | |
height: 5px; | |
background-color: #666; | |
} | |
.progress-wrapper small { | |
position: absolute; | |
top: -10px; | |
left: 50%; | |
transform: translateX(-50%); | |
font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif; | |
font-size: 12px; | |
color: #444; | |
} | |
svg { | |
height: 50px; | |
} | |
@media only screen and (max-width: 570px) { | |
#info, #controls-and-progress { | |
padding: 1em; | |
} | |
#info .title { | |
font-size: 2.5em; | |
} | |
#info .artist, #info .album { | |
font-size: 0.9em; | |
color: #444; | |
} | |
#controls { | |
flex-direction: column; | |
justify-content: space-around; | |
height: 55vh; | |
} | |
#media-controls, #setting-controls { | |
width: 100%; | |
display: flex; | |
justify-content: space-around; | |
} | |
svg { | |
height: 75px; | |
} | |
} | |
</style> | |
<meta name="viewport" content="width=device-width" /> | |
</head> | |
<body> | |
<div class="container"> | |
<div id="info"> | |
<h1 class="title" data-attr="title"></h1> | |
<h2 class="artist" data-attr="artist"></h1> | |
<h2 class="album" data-attr="album"></h1> | |
</div> | |
<div id="controls-and-progress"> | |
<div id="controls"> | |
<div id="media-controls"> | |
<a href="" data-command="player-prev"> | |
<svg viewBox="0 0 154 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |
<g stroke="#444" stroke-width="3" fill="none"> | |
<line x1="20" y1="15" x2="20" y2="85" /> | |
<polygon points="75,15, 75,85 21,50" /> | |
<polygon points="132,15, 132,85 76,50" /> | |
</g> | |
</svg> | |
</a> | |
<!-- is dynamically set based on playback state --> | |
<a href="" data-attr="playpause" data-command="player-pause"></a> | |
<a href="" data-command="player-next"> | |
<svg viewBox="0 0 154 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |
<g stroke="#444" stroke-width="3" fill="none"> | |
<polygon points="20,15, 20,85 75,50" /> | |
<polygon points="77,15, 77,85 132,50" /> | |
<line x1="134" y1="15" x2="134" y2="85" /> | |
</g> | |
</svg> | |
</a> | |
</div> | |
<div id="setting-controls"> | |
<a href="" data-attr="repeat" data-command="toggle repeat"> | |
repeat | |
</a> | |
<a href="" data-attr="shuffle" data-command="toggle shuffle"> | |
shuffle | |
</a> | |
</div> | |
</div> | |
<div class="progress-wrapper"> | |
<small data-attr="progress"></small> | |
<div class="progress-indicator"> | |
<div class="progress" data-attr="progress-indicator"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
// --- constants -------------------------------------------------------- | |
var slice = Array.prototype.slice, | |
isArray = Array.isArray; | |
// --- deep merge ------------------------------------------------------- | |
// https://github.com/KyleAMathews/deepmerge | |
// Copyright (c) 2012 Nicholas Fisher - MIT License | |
function merge(target, src) { | |
var array = Array.isArray(src); | |
var dest = array && [] || {}; | |
if (array) { | |
target = target || []; | |
dest = dest.concat(target); | |
src.forEach(function(e, i) { | |
if (typeof dest[i] === 'undefined') { | |
dest[i] = e; | |
} else if (typeof e === 'object') { | |
dest[i] = merge(target[i], e); | |
} else { | |
if (target.indexOf(e) === -1) { | |
dest.push(e); | |
} | |
} | |
}); | |
} else { | |
if (target && typeof target === 'object') { | |
Object.keys(target).forEach(function (key) { | |
dest[key] = target[key]; | |
}) | |
} | |
Object.keys(src).forEach(function (key) { | |
if (typeof src[key] !== 'object' || !src[key]) { | |
dest[key] = src[key]; | |
} | |
else { | |
if (!target[key]) { | |
dest[key] = src[key]; | |
} else { | |
dest[key] = merge(target[key], src[key]); | |
} | |
} | |
}); | |
} | |
return dest; | |
} | |
// --- event emitter ---------------------------------------------------- | |
var events = (function() { | |
var subscribers = {}; | |
function on(event, callback) { | |
if (subscribers[event] == null) { | |
subscribers[event] = []; | |
} | |
subscribers[event].push(callback); | |
} | |
function emit() { | |
var args = slice.apply(arguments), | |
event = args.shift(); | |
if (subscribers[event].length) { | |
subscribers[event].forEach(function(callback) { | |
callback.apply(null, args); | |
}); | |
} | |
} | |
return { | |
on: on, | |
emit: emit, | |
} | |
})(); | |
// --- store implementation --------------------------------------------- | |
var store = (function() { | |
var state = { tags: {}, settings: {} }; | |
function dispatch(action) { | |
state = reducer(state, action); | |
events.emit("store.update"); | |
} | |
function getState() { | |
return state; | |
} | |
return { dispatch: dispatch, getState: getState }; | |
})(); | |
// --- reducer ---------------------------------------------------------- | |
function reducer(state, action) { | |
switch (action.type) { | |
case "patch": | |
return merge(Object.assign({}, state), action.data); | |
} | |
return state; | |
} | |
// --- socket ----------------------------------------------------------- | |
var socket = function(url) { | |
var ws = null, | |
timedOut = false, | |
closed = false; | |
var reconnectDecay = 1.5, | |
reconnectInterval = 1000, | |
maxReconnectInterval = 30000, | |
timeoutInterval = 2000; | |
var reconnectAttempts = 0; | |
function open(reconnectAttempt) { | |
ws = new WebSocket(url); | |
if (!reconnectAttempt) { | |
reconnectAttempts = 0; | |
} | |
var local = ws; | |
var timeout = setTimeout(function() { | |
timedOut = true; | |
local.close(); | |
timedOut = false; | |
}, timeoutInterval); | |
ws.onopen = function(event) { | |
clearTimeout(timeout); | |
reconnectAttempts = 0; | |
reconnectAttempt = false; | |
} | |
ws.onclose = function(event) { | |
clearTimeout(timeout); | |
ws = null; | |
// manually closed, don't try to reconnect | |
if (closed) { | |
return; | |
} | |
var timeout = reconnectInterval * Math.pow(reconnectDecay, reconnectAttempts); | |
if (timeout > maxReconnectInterval) { | |
timeout = maxReconnectInterval; | |
} | |
setTimeout(function() { | |
reconnectAttempts++; | |
open(true); | |
}, timeout); | |
} | |
ws.onmessage = function(event) { | |
events.emit("ws.message", event); | |
} | |
ws.onerror = function(event) { | |
events.emit("ws.error", event); | |
} | |
} | |
function send(msg) { | |
if (ws) { | |
return ws.send(msg); | |
} | |
} | |
function close() { | |
if (ws) { | |
closed = true; | |
ws.close(); | |
} | |
} | |
return { | |
open: open, | |
send: send, | |
close: close, | |
} | |
}; | |
// --- renderer ---------------------------------------------------------- | |
var render = (function() { | |
var title = document.querySelector("[data-attr=title]"), | |
artist = document.querySelector("[data-attr=artist]"), | |
album = document.querySelector("[data-attr=album]"), | |
progress = document.querySelector("[data-attr=progress]"), | |
progressIndicator = document.querySelector("[data-attr=progress-indicator]"), | |
repeat = document.querySelector("[data-attr=repeat]"), | |
shuffle = document.querySelector("[data-attr=shuffle]"), | |
playpause = document.querySelector("[data-attr=playpause]"); | |
var play = ` | |
<svg viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |
<g stroke="#444" stroke-width="3" fill="none"> | |
<polygon points="25,15, 25,85 80,50" /> | |
</g> | |
</svg> | |
`; | |
var pause = ` | |
<svg viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |
<g stroke="#444" stroke-width="3" fill="none"> | |
<line x1="30" y1="15" x2="30" y2="85" /> | |
<line x1="70" y1="15" x2="70" y2="85" /> | |
</g> | |
</svg> | |
`; | |
// time(1, 60) -> "00:01 / 01:00" | |
function time(p, d) { | |
function pad(s) { return ("00" + s).slice(-2); } | |
function format(t) { | |
return `${pad(Math.floor(t / 60))}:${pad(t % 60)}`; | |
} | |
return `${format(p)} / ${format(d)}` | |
} | |
function draw() { | |
var state = store.getState(), | |
tags = state.tags; | |
var width = (state.position / state.duration) * 100; | |
window.requestAnimationFrame(function() { | |
if (state.playing) { | |
playpause.innerHTML = pause; | |
} else { | |
playpause.innerHTML = play; | |
} | |
title.innerHTML = tags.title; | |
artist.innerHTML = tags.artist; | |
album.innerHTML = tags.album; | |
progress.innerHTML = time(state.position || 0, state.duration); | |
progressIndicator.setAttribute("style", `width: ${width}%;`); | |
if (state.settings.repeat === "true") { | |
repeat.classList.add("active"); | |
} else { | |
repeat.classList.remove("active"); | |
} | |
if (state.settings.shuffle === "true") { | |
shuffle.classList.add("active"); | |
} else { | |
shuffle.classList.remove("active"); | |
} | |
}); | |
} | |
return draw; | |
})(); | |
// --- commander -------------------------------------------------------- | |
(function() { | |
var commands = document.querySelectorAll("[data-command]") | |
Array.prototype.forEach.call(commands, function(el) { | |
var command = el.attributes["data-command"].value; | |
el.addEventListener("click", function(e) { | |
e.preventDefault(); | |
events.emit("command", command); | |
}) | |
}); | |
})(); | |
// --- scrubber---------------------------------------------------------- | |
(function() { | |
var progress = document.querySelector(".progress-wrapper"); | |
progress.addEventListener("click", function(e) { | |
var width = progress.offsetWidth, | |
click = e.offsetX; | |
var state = store.getState(); | |
var position; | |
if (state.duration) { | |
position = Math.floor(click * (state.duration / width)); | |
events.emit("command", "seek " + position); | |
} | |
}); | |
})(); | |
// --- main ------------------------------------------------------------- | |
(function() { | |
var ws = socket("ws://" + window.location.host + "/ws"); | |
ws.open(); | |
events.on("store.update", render); | |
events.on("ws.message", function(data) { | |
var json = JSON.parse(event.data); | |
if (json.error) { | |
console.error(json.error); | |
} else { | |
store.dispatch({ type: "patch", data: json }); | |
} | |
}) | |
events.on("command", function(cmd) { | |
var data = JSON.stringify({ command: cmd }); | |
ws.send(data); | |
}) | |
})(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment