This commit is contained in:
Ondrej Zara 2019-03-22 15:35:04 +01:00
parent ecbb7e3d97
commit 76802bc630
18 changed files with 248 additions and 63 deletions

View file

@ -5,3 +5,6 @@ all: $(APP)
$(APP): css/* $(APP): css/*
$(LESS) css/app.less > $@ $(LESS) css/app.less > $@
watch: all
while inotifywait -e MODIFY -r css js ; do make $^ ; done

View file

@ -24,13 +24,19 @@ nav ul li {
nav ul li:hover { nav ul li:hover {
background-color: red; background-color: red;
} }
nav ul li.active {
background-color: green;
}
#player:not([data-state=play]) .pause { #player:not([data-state=play]) .pause {
display: none; display: none;
} }
#player[data-state=play] .play { #player[data-state=play] .play {
display: none; display: none;
} }
#player:not(.random) .random, #player:not([data-flags~=random]) .random,
#player:not(.repeat) .repeat { #player:not([data-flags~=repeat]) .repeat {
opacity: 0.5; opacity: 0.5;
} }
#queue .current {
font-weight: bold;
}

View file

@ -8,3 +8,4 @@ body {
@import "main.less"; @import "main.less";
@import "nav.less"; @import "nav.less";
@import "player.less"; @import "player.less";
@import "queue.less";

View file

@ -11,5 +11,9 @@ nav ul {
line-height: 40px; line-height: 40px;
&:hover { background-color:red;} &:hover { background-color:red;}
&.active {
background-color: green;
}
} }
} }

View file

@ -2,5 +2,6 @@
&:not([data-state=play]) .pause { display: none; } &:not([data-state=play]) .pause { display: none; }
&[data-state=play] .play { display: none; } &[data-state=play] .play { display: none; }
&:not(.random) .random, &:not(.repeat) .repeat { opacity: 0.5; }
&:not([data-flags~=random]) .random, &:not([data-flags~=repeat]) .repeat { opacity: 0.5; }
} }

3
app/css/queue.less Normal file
View file

@ -0,0 +1,3 @@
#queue {
.current { font-weight: bold; }
}

View file

@ -23,25 +23,18 @@
</section> </section>
</header> </header>
<main> <main>
main<br/> <section id="queue"></section>
main<br/> <section id="playlists"></section>
main<br/> <section id="library"></section>
main<br/> <section id="misc"></section>
main<br/>
main<br/>
main<br/>
main<br/>
main<br/>
main<br/>
main<br/>
</main> </main>
<footer> <footer>
<nav> <nav>
<ul> <ul>
<li>Q</li> <li data-for="queue">Q</li>
<li>Playlists</li> <li data-for="playlists">Playlists</li>
<li>Library</li> <li data-for="library">Library</li>
<li>Misc</li> <li data-for="misc">Misc</li>
</ul> </ul>
</nav> </nav>
</footer> </footer>

View file

@ -1,11 +1,37 @@
import * as mpd from "./mpd.js"; import * as nav from "./nav.js";
import * as mpd from "./lib/mpd.js";
import * as player from "./player.js"; import * as player from "./player.js";
import * as art from "./art.js";
import * as queue from "./queue.js";
const components = { queue };
export function activate(what) {
for (let id in components) {
let node = document.querySelector(`#${id}`);
if (what == id) {
node.style.display = "";
components[id].activate();
} else {
node.style.display = "none";
}
}
nav.active(what);
}
async function init() { async function init() {
nav.init(document.querySelector("nav"));
for (let id in components) {
let node = document.querySelector(`#${id}`);
components[id].init(node);
}
await mpd.init(); await mpd.init();
player.init(); player.init(document.querySelector("#player"));
activate("queue");
window.mpd = mpd; window.mpd = mpd;
} }
init(); init();

View file

@ -1,5 +1,6 @@
import * as mpd from "./mpd.js"; import * as mpd from "./mpd.js";
import * as parser from "./parser.js"; import * as parser from "./parser.js";
import * as html from "./html.js";
let cache = {}; let cache = {};
const SIZE = 64; const SIZE = 64;
@ -29,17 +30,15 @@ async function getImageData(songUrl) {
async function bytesToImage(bytes) { async function bytesToImage(bytes) {
let blob = new Blob([bytes]); let blob = new Blob([bytes]);
let image = document.createElement("img"); let src = URL.createObjectURL(blob);
image.src = URL.createObjectURL(blob); let image = html.node("img", {src});
return new Promise(resolve => { return new Promise(resolve => {
image.onload = () => resolve(image); image.onload = () => resolve(image);
}); });
} }
function resize(image) { function resize(image) {
let canvas = document.createElement("canvas"); let canvas = html.node("canvas", {width:SIZE, height:SIZE});
canvas.width = SIZE;
canvas.height = SIZE;
let ctx = canvas.getContext("2d"); let ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, SIZE, SIZE); ctx.drawImage(image, 0, 0, SIZE, SIZE);
return canvas; return canvas;

6
app/js/lib/format.js Normal file
View file

@ -0,0 +1,6 @@
export function time(sec) {
sec = Math.round(sec);
let m = Math.floor(sec / 60);
let s = sec % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}

29
app/js/lib/html.js Normal file
View file

@ -0,0 +1,29 @@
export function node(name, attrs, content, parent) {
let n = document.createElement(name);
Object.assign(n, attrs);
if (attrs && attrs.title) { n.setAttribute("aria-label", attrs.title); }
content && text(content, n);
parent && parent.appendChild(n);
return n;
}
export function button(attrs, content, parent) {
return node("button", attrs, content, parent);
}
export function clear(node) {
while (node.firstChild) { node.firstChild.parentNode.removeChild(node.firstChild); }
return node;
}
export function text(txt, parent) {
let n = document.createTextNode(txt);
parent && parent.appendChild(n);
return n;
}
export function fragment() {
return document.createDocumentFragment();
}

View file

@ -57,6 +57,11 @@ export async function status() {
return parser.linesToStruct(lines); return parser.linesToStruct(lines);
} }
export async function listQueue() {
let lines = await command("playlistinfo");
return parser.songList(lines);
}
export async function init() { export async function init() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {

29
app/js/lib/parser.js Normal file
View file

@ -0,0 +1,29 @@
export function linesToStruct(lines) {
let result = {};
lines.forEach(line => {
let cindex = line.indexOf(":");
if (cindex == -1) { throw new Error(`Malformed line "${line}"`); }
result[line.substring(0, cindex)] = line.substring(cindex+2);
});
return result;
}
export function songList(lines) {
let songs = [];
let batch = [];
while (lines.length) {
let line = lines[0];
if (line.startsWith("file:") && batch.length) {
let song = linesToStruct(batch);
songs.push(song);
}
batch.push(lines.shift());
}
if (batch.length) {
let song = linesToStruct(batch);
songs.push(song);
}
return songs;
}

17
app/js/lib/pubsub.js Normal file
View file

@ -0,0 +1,17 @@
let storage = new Map();
export function publish(message, publisher, data) {
console.log(message, publisher, data);
if (!storage.has(message)) { return; }
storage.get(message).forEach(listener => listener(message, publisher, data));
}
export function subscribe(message, listener) {
if (!storage.has(message)) { storage.set(message, new Set()); }
storage.get(message).add(listener);
}
export function unsubscribe(message, listener) {
if (!storage.has(message)) { storage.set(message, new Set()); }
storage.get(message).remove(listener);
}

16
app/js/nav.js Normal file
View file

@ -0,0 +1,16 @@
import * as app from "./app.js";
let tabs = [];
export function init(node) {
tabs = Array.from(node.querySelectorAll("[data-for]"));
tabs.forEach(tab => {
tab.addEventListener("click", e => app.activate(tab.dataset.for));
});
}
export function active(id) {
tabs.forEach(tab => {
tab.classList.toggle("active", tab.dataset.for == id);
});
}

View file

@ -1,10 +0,0 @@
export function linesToStruct(lines) {
let result = {};
lines.forEach(line => {
let cindex = line.indexOf(":");
if (cindex == -1) { throw new Error(`Malformed line "${line}"`); }
result[line.substring(0, cindex)] = line.substring(cindex+2);
});
return result;
}

View file

@ -1,5 +1,8 @@
import * as mpd from "./mpd.js"; import * as mpd from "./lib/mpd.js";
import * as art from "./art.js"; import * as art from "./lib/art.js";
import * as html from "./lib/html.js";
import * as format from "./lib/format.js";
import * as pubsub from "./lib/pubsub.js";
const DELAY = 2000; const DELAY = 2000;
const DOM = {}; const DOM = {};
@ -8,48 +11,38 @@ let current = {};
let node; let node;
let idleTimeout = null; let idleTimeout = null;
function formatTime(sec) { function sync(data) {
sec = Math.round(sec); DOM.elapsed.textContent = format.time(Number(data["elapsed"] || 0)); // changed time
let m = Math.floor(sec / 60);
let s = sec % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function update(data) {
DOM.elapsed.textContent = formatTime(Number(data["elapsed"] || 0)); // changed time
if (data["file"] != current["file"]) { // changed song if (data["file"] != current["file"]) { // changed song
DOM.duration.textContent = formatTime(Number(data["duration"] || 0)); DOM.duration.textContent = format.time(Number(data["duration"] || 0));
DOM.title.textContent = data["Title"] || ""; DOM.title.textContent = data["Title"] || "";
DOM.album.textContent = data["Album"] || ""; DOM.album.textContent = data["Album"] || "";
DOM.artist.textContent = data["Artist"] || ""; DOM.artist.textContent = data["Artist"] || "";
pubsub.publish("song-change", null, data);
} }
if (data["Artist"] != current["Artist"] || data["Album"] != current["Album"]) { // changed album (art) if (data["Artist"] != current["Artist"] || data["Album"] != current["Album"]) { // changed album (art)
DOM.art.innerHTML = ""; DOM.art.innerHTML = "";
art.get(data["Artist"], data["Album"], data["file"]).then(src => { art.get(data["Artist"], data["Album"], data["file"]).then(src => {
if (!src) { return; } if (!src) { return; }
let image = document.createElement("img"); let image = html.node("img", {src});
image.src = src;
DOM.art.appendChild(image); DOM.art.appendChild(image);
}); });
} }
node.classList.toggle("random", data["random"] == "1"); let flags = [];
node.classList.toggle("repeat", data["repeat"] == "1"); if (data["random"] == "1") { flags.push("random"); }
if (data["repeat"] == "1") { flags.push("repeat"); }
node.dataset.flags = flags.join(" ");
node.dataset.state = data["state"]; node.dataset.state = data["state"];
current = data; current = data;
} }
async function sync() {
let data = await mpd.status();
update(data);
idle();
}
function idle() { function idle() {
idleTimeout = setTimeout(sync, DELAY); idleTimeout = setTimeout(update, DELAY);
} }
function clearIdle() { function clearIdle() {
@ -60,12 +53,19 @@ function clearIdle() {
async function command(cmd) { async function command(cmd) {
clearIdle(); clearIdle();
let data = await mpd.commandAndStatus(cmd); let data = await mpd.commandAndStatus(cmd);
update(data); sync(data);
idle(); idle();
} }
export function init() { export async function update() {
node = document.querySelector("#player"); clearIdle();
let data = await mpd.status();
sync(data);
idle();
}
export function init(n) {
node = n;
let all = node.querySelectorAll("[class]"); let all = node.querySelectorAll("[class]");
Array.from(all).forEach(node => DOM[node.className] = node); Array.from(all).forEach(node => DOM[node.className] = node);
@ -77,5 +77,5 @@ export function init() {
DOM.random.addEventListener("click", e => command(`random ${current["random"] == "1" ? "0" : "1"}`)); DOM.random.addEventListener("click", e => command(`random ${current["random"] == "1" ? "0" : "1"}`));
DOM.repeat.addEventListener("click", e => command(`repeat ${current["repeat"] == "1" ? "0" : "1"}`)); DOM.repeat.addEventListener("click", e => command(`repeat ${current["repeat"] == "1" ? "0" : "1"}`));
sync(); update();
} }

57
app/js/queue.js Normal file
View file

@ -0,0 +1,57 @@
import * as mpd from "./lib/mpd.js";
import * as html from "./lib/html.js";
import * as player from "./player.js";
import * as pubsub from "./lib/pubsub.js";
let node;
let currentId;
function updateCurrent() {
let all = Array.from(node.querySelectorAll("[data-song-id]"));
all.forEach(node => {
node.classList.toggle("current", node.dataset.songId == currentId);
});
}
async function playSong(id) {
await mpd.command(`playid ${id}`);
player.update();
}
function buildSong(song) {
let id = Number(song["Id"]);
let node = html.node("li");
node.dataset.songId = id;
node.textContent = song["file"];
let play = html.button({}, "▶", node);
play.addEventListener("click", e => playSong(id));
return node;
}
function buildSongs(songs) {
html.clear(node);
let ul = html.node("ul");
songs.map(buildSong).forEach(li => ul.appendChild(li));
node.appendChild(ul);
updateCurrent();
}
function onSongChange(message, publisher, data) {
currentId = data["Id"];
updateCurrent();
}
export async function activate() {
let songs = await mpd.listQueue();
buildSongs(songs);
}
export function init(n) {
node = n;
pubsub.subscribe("song-change", onSongChange);
}