Skip to content

Instantly share code, notes, and snippets.

@stewart
Created August 17, 2016 01:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save stewart/21f78020d2665b226fa62f6f1544cd8b to your computer and use it in GitHub Desktop.
Save stewart/21f78020d2665b226fa62f6f1544cd8b to your computer and use it in GitHub Desktop.
<!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