refactor to idle wip

This commit is contained in:
Ondřej Žára 2020-05-05 23:03:36 +02:00
parent 147e6fd711
commit 2b33e39169
9 changed files with 473 additions and 347 deletions

View file

@ -238,20 +238,21 @@ function pathContents(lines) {
let ws; let ws;
let commandQueue = []; let commandQueue = [];
let current; let current;
let canTerminateIdle = false;
function onMessage(e) { function onMessage(e) {
if (current) { if (!current) { return; }
let lines = JSON.parse(e.data);
let last = lines.pop(); let lines = JSON.parse(e.data);
if (last.startsWith("OK")) { let last = lines.pop();
current.resolve(lines); if (last.startsWith("OK")) {
} else { current.resolve(lines);
console.warn(last); } else {
current.reject(last); console.warn(last);
} current.reject(last);
current = null;
} }
processQueue(); current = null;
setTimeout(processQueue, 0); // after other potential commands are enqueued
} }
function onError(e) { function onError(e) {
@ -267,25 +268,30 @@ function onClose(e) {
} }
function processQueue() { function processQueue() {
if (current || commandQueue.length == 0) { return; } if (commandQueue.length == 0) {
current = commandQueue.shift(); if (!current) { idle(); } // nothing to do
ws.send(current.cmd); } else if (current) { // stuff waiting in queue but there is a command under way
if (canTerminateIdle) {
ws.send("noidle");
canTerminateIdle = false;
}
} else { // advance to next command
current = commandQueue.shift();
ws.send(current.cmd);
}
} }
function serializeFilter(filter, operator = "==") { async function idle() {
let tokens = ["("]; let promise = command("idle stored_playlist playlist player options");
Object.entries(filter).forEach(([key, value], index) => { canTerminateIdle = true;
index && tokens.push(" AND "); let lines = await promise;
tokens.push(`(${key} ${operator} "${escape(value)}")`); canTerminateIdle = false;
}); let changed = linesToStruct(lines).changed || [];
tokens.push(")"); changed = [].concat(changed);
if (changed.length > 0) {
let filterStr = tokens.join(""); // FIXME not on window
return `"${escape(filterStr)}"`; window.dispatchEvent(new CustomEvent("idle-change", {detail:changed}));
} }
function escape(str) {
return str.replace(/(['"\\])/g, "\\$1");
} }
async function command(cmd) { async function command(cmd) {
@ -297,18 +303,14 @@ async function command(cmd) {
}); });
} }
async function commandAndStatus(cmd) { async function status() {
let lines = await command([cmd, "status", "currentsong"]); let lines = await command("status");
let status = linesToStruct(lines); return linesToStruct(lines);
if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
return status;
} }
async function status() { async function currentSong() {
let lines = await command(["status", "currentsong"]); let lines = await command("currentsong");
let status = linesToStruct(lines); return linesToStruct(lines);
if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
return status;
} }
async function listQueue() { async function listQueue() {
@ -354,7 +356,6 @@ async function searchSongs(filter) {
let tokens = ["search", serializeFilter(filter, "contains")]; let tokens = ["search", serializeFilter(filter, "contains")];
let lines = await command(tokens.join(" ")); let lines = await command(tokens.join(" "));
return songList(lines); return songList(lines);
} }
async function albumArt(songUrl) { async function albumArt(songUrl) {
@ -375,11 +376,31 @@ async function albumArt(songUrl) {
return null; return null;
} }
function serializeFilter(filter, operator = "==") {
let tokens = ["("];
Object.entries(filter).forEach(([key, value], index) => {
index && tokens.push(" AND ");
tokens.push(`(${key} ${operator} "${escape(value)}")`);
});
tokens.push(")");
let filterStr = tokens.join("");
return `"${escape(filterStr)}"`;
}
function escape(str) {
return str.replace(/(['"\\])/g, "\\$1");
}
async function init() { async function init() {
let response = await fetch("/ticket", {method:"POST"}); let response = await fetch("/ticket", {method:"POST"});
let ticket = (await response.json()).ticket; let ticket = (await response.json()).ticket;
return new Promise((resolve, reject) => { let resolve, reject;
let promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
try { try {
let url = new URL(location.href); let url = new URL(location.href);
url.protocol = "ws"; url.protocol = "ws";
@ -387,21 +408,21 @@ async function init() {
url.searchParams.set("ticket", ticket); url.searchParams.set("ticket", ticket);
ws = new WebSocket(url.href); ws = new WebSocket(url.href);
} catch (e) { reject(e); } } catch (e) { reject(e); }
current = {resolve, reject};
ws.addEventListener("error", onError); ws.addEventListener("error", onError);
ws.addEventListener("message", onMessage); ws.addEventListener("message", onMessage);
ws.addEventListener("close", onClose); ws.addEventListener("close", onClose);
}); });
current = {resolve, reject, promise};
return Promise;
} }
var mpd = /*#__PURE__*/Object.freeze({ var mpd = /*#__PURE__*/Object.freeze({
__proto__: null, __proto__: null,
serializeFilter: serializeFilter,
escape: escape,
command: command, command: command,
commandAndStatus: commandAndStatus,
status: status, status: status,
currentSong: currentSong,
listQueue: listQueue, listQueue: listQueue,
listPlaylists: listPlaylists, listPlaylists: listPlaylists,
listPath: listPath, listPath: listPath,
@ -409,6 +430,8 @@ var mpd = /*#__PURE__*/Object.freeze({
listSongs: listSongs, listSongs: listSongs,
searchSongs: searchSongs, searchSongs: searchSongs,
albumArt: albumArt, albumArt: albumArt,
serializeFilter: serializeFilter,
escape: escape,
init: init init: init
}); });
@ -416,22 +439,23 @@ function command$1(cmd) {
console.warn(`mpd-mock does not know "${cmd}"`); console.warn(`mpd-mock does not know "${cmd}"`);
} }
function commandAndStatus$1(cmd) {
command$1(cmd);
return status$1();
}
function status$1() { function status$1() {
return { return {
volume: 50, volume: 50,
elapsed: 10, elapsed: 10,
duration: 70,
state: "play"
}
}
function currentSong$1() {
return {
duration: 70, duration: 70,
file: "name.mp3", file: "name.mp3",
Title: "Title of song", Title: "Title of song",
Artist: "Artist of song", Artist: "Artist of song",
Album: "Album of song", Album: "Album of song",
Track: "6", Track: "6",
state: "play",
Id: 2 Id: 2
} }
} }
@ -439,7 +463,7 @@ function status$1() {
function listQueue$1() { function listQueue$1() {
return [ return [
{Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30, file:"a.mp3"}, {Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30, file:"a.mp3"},
status$1(), currentSong$1(),
{Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230, file:"c.mp3"}, {Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230, file:"c.mp3"},
]; ];
} }
@ -491,8 +515,8 @@ function init$1() {}
var mpdMock = /*#__PURE__*/Object.freeze({ var mpdMock = /*#__PURE__*/Object.freeze({
__proto__: null, __proto__: null,
command: command$1, command: command$1,
commandAndStatus: commandAndStatus$1,
status: status$1, status: status$1,
currentSong: currentSong$1,
listQueue: listQueue$1, listQueue: listQueue$1,
listPlaylists: listPlaylists$1, listPlaylists: listPlaylists$1,
listPath: listPath$1, listPath: listPath$1,
@ -822,7 +846,7 @@ class Menu extends Component {
} }
_onComponentChange(component) { _onComponentChange(component) {
this._tabs.forEach( tab => { this._tabs.forEach(tab => {
tab.classList.toggle("active", tab.dataset.for == component); tab.classList.toggle("active", tab.dataset.for == component);
}); });
} }
@ -916,141 +940,177 @@ function fileName(file) {
return file.split("/").pop(); return file.split("/").pop();
} }
const DELAY = 1000; const ELAPSED_PERIOD = 500;
class Player extends Component { class Player extends Component {
constructor() { constructor() {
super(); super();
this._current = {}; this._current = {
song: {},
elapsed: 0,
at: 0,
volume: 0
};
this._toggledVolume = 0; this._toggledVolume = 0;
this._idleTimeout = null;
this._dom = this._initDOM(); const DOM = {};
const all = this.querySelectorAll("[class]");
[...all].forEach(node => DOM[node.className] = node);
DOM.progress = DOM.timeline.querySelector("x-range");
DOM.progress.step = "0.1"; // FIXME
DOM.volume = DOM.volume.querySelector("x-range");
this._dom = DOM;
} }
async update() { handleEvent(e) {
this._clearIdle(); switch (e.type) {
const data = await this._mpd.status(); case "idle-change":
this._sync(data); let hasOptions = e.detail.includes("options");
this._idle(); let hasPlayer = e.detail.includes("player");
(hasOptions || hasPlayer) && this._updateStatus();
hasPlayer && this._updateCurrent();
break;
}
} }
_onAppLoad() { _onAppLoad() {
this.update(); this._addEvents();
this._updateStatus();
this._updateCurrent();
window.addEventListener("idle-change", this);
setInterval(() => this._updateElapsed(), ELAPSED_PERIOD);
} }
_initDOM() { async _updateStatus() {
const DOM = {}; const data = await this._mpd.status();
const all = this.querySelectorAll("[class]");
Array.from(all).forEach(node => DOM[node.className] = node);
DOM.progress = DOM.timeline.querySelector("x-range");
DOM.volume = DOM.volume.querySelector("x-range");
DOM.play.addEventListener("click", _ => this._command("play"));
DOM.pause.addEventListener("click", _ => this._command("pause 1"));
DOM.prev.addEventListener("click", _ => this._command("previous"));
DOM.next.addEventListener("click", _ => this._command("next"));
DOM.random.addEventListener("click", _ => this._command(`random ${this._current["random"] == "1" ? "0" : "1"}`));
DOM.repeat.addEventListener("click", _ => this._command(`repeat ${this._current["repeat"] == "1" ? "0" : "1"}`));
DOM.volume.addEventListener("input", e => this._command(`setvol ${e.target.valueAsNumber}`));
DOM.progress.addEventListener("input", e => this._command(`seekcur ${e.target.valueAsNumber}`));
DOM.mute.addEventListener("click", _ => this._command(`setvol ${this._toggledVolume}`));
return DOM;
}
async _command(cmd) {
this._clearIdle();
const data = await this._mpd.commandAndStatus(cmd);
this._sync(data);
this._idle();
}
_idle() {
this._idleTimeout = setTimeout(() => this.update(), DELAY);
}
_clearIdle() {
this._idleTimeout && clearTimeout(this._idleTimeout);
this._idleTimeout = null;
}
_sync(data) {
const DOM = this._dom; const DOM = this._dom;
if ("volume" in data) {
data["volume"] = Number(data["volume"]);
DOM.mute.disabled = false; this._updateFlags(data);
DOM.volume.disabled = false; this._updateVolume(data);
DOM.volume.value = data["volume"];
if (data["volume"] == 0 && this._current["volume"] > 0) { // muted if ("duration" in data) { // play/pause
this._toggledVolume = this._current["volume"]; let duration = Number(data["duration"]);
clear(DOM.mute); DOM.duration.textContent = time(duration);
DOM.mute.appendChild(icon("volume-off")); DOM.progress.max = duration;
} DOM.progress.disabled = false;
} else { // no song at all
if (data["volume"] > 0 && this._current["volume"] == 0) { // restored DOM.progress.value = 0;
this._toggledVolume = 0; DOM.progress.disabled = true;
clear(DOM.mute);
DOM.mute.appendChild(icon("volume-high"));
}
} else {
DOM.mute.disabled = true;
DOM.volume.disabled = true;
DOM.volume.value = 50;
} }
// changed time // rebase the time sync
let elapsed = Number(data["elapsed"] || 0); this._current.elapsed = Number(data["elapsed"] || 0);
DOM.progress.value = elapsed; this._current.at = performance.now();
DOM.elapsed.textContent = time(elapsed); }
if (data["file"] != this._current["file"]) { // changed song async _updateCurrent() {
if (data["file"]) { // playing at all? const data = await this._mpd.currentSong();
let duration = Number(data["duration"]); const DOM = this._dom;
DOM.duration.textContent = time(duration);
DOM.progress.max = duration; if (data["file"] != this._current.song["file"]) { // changed song
DOM.progress.disabled = false; if (data["file"]) { // is there a song at all?
DOM.title.textContent = data["Title"] || fileName(data["file"]); DOM.title.textContent = data["Title"] || fileName(data["file"]);
DOM.subtitle.textContent = subtitle(data, {duration:false}); DOM.subtitle.textContent = subtitle(data, {duration:false});
} else { } else {
DOM.title.textContent = ""; DOM.title.textContent = "";
DOM.subtitle.textContent = ""; DOM.subtitle.textContent = "";
DOM.progress.value = 0;
DOM.progress.disabled = true;
} }
this._dispatchSongChange(data); this._dispatchSongChange(data);
} }
this._app.style.setProperty("--progress", DOM.progress.value/DOM.progress.max);
let artistNew = data["AlbumArtist"] || data["Artist"]; let artistNew = data["AlbumArtist"] || data["Artist"];
let artistOld = this._current["AlbumArtist"] || this._current["Artist"]; let artistOld = this._current.song["AlbumArtist"] || this._current.song["Artist"];
let albumNew = data["Album"];
let albumOld = this._current.song["Album"];
if (artistNew != artistOld || data["Album"] != this._current["Album"]) { // changed album (art) Object.assign(this._current.song, data);
if (artistNew != artistOld || albumNew != albumOld) { // changed album (art)
clear(DOM.art); clear(DOM.art);
get(this._mpd, artistNew, data["Album"], data["file"]).then(src => { let src = await get(this._mpd, artistNew, data["Album"], data["file"]);
if (src) { if (src) {
node("img", {src}, "", DOM.art); node("img", {src}, "", DOM.art);
} else { } else {
icon("music", DOM.art); icon("music", DOM.art);
} }
}); }
}
_updateElapsed() {
const DOM = this._dom;
let elapsed = 0;
if (this._current.song["file"]) {
elapsed = this._current.elapsed;
if (this.dataset.state == "play") { elapsed += (performance.now() - this._current.at)/1000; }
} }
DOM.progress.value = elapsed;
DOM.elapsed.textContent = time(elapsed);
this._app.style.setProperty("--progress", DOM.progress.value/DOM.progress.max);
}
_updateFlags(data) {
let flags = []; let flags = [];
if (data["random"] == "1") { flags.push("random"); } if (data["random"] == "1") { flags.push("random"); }
if (data["repeat"] == "1") { flags.push("repeat"); } if (data["repeat"] == "1") { flags.push("repeat"); }
this.dataset.flags = flags.join(" "); this.dataset.flags = flags.join(" ");
this.dataset.state = data["state"]; this.dataset.state = data["state"];
}
this._current = data; _updateVolume(data) {
const DOM = this._dom;
if ("volume" in data) {
let volume = Number(data["volume"]);
DOM.mute.disabled = false;
DOM.volume.disabled = false;
DOM.volume.value = volume;
if (volume == 0 && this._current.volume > 0) { // muted
this._toggledVolume = this._current.volume;
clear(DOM.mute);
DOM.mute.appendChild(icon("volume-off"));
}
if (volume > 0 && this._current.volume == 0) { // restored
this._toggledVolume = 0;
clear(DOM.mute);
DOM.mute.appendChild(icon("volume-high"));
}
this._current.volume = volume;
} else {
DOM.mute.disabled = true;
DOM.volume.disabled = true;
DOM.volume.value = 50;
}
}
_addEvents() {
const DOM = this._dom;
DOM.play.addEventListener("click", _ => this._app.mpd.command("play"));
DOM.pause.addEventListener("click", _ => this._app.mpd.command("pause 1"));
DOM.prev.addEventListener("click", _ => this._app.mpd.command("previous"));
DOM.next.addEventListener("click", _ => this._app.mpd.command("next"));
DOM.random.addEventListener("click", _ => {
let isRandom = this.dataset.flags.split(" ").includes("random");
this._app.mpd.command(`random ${isRandom ? "0" : "1"}`);
});
DOM.repeat.addEventListener("click", _ => {
let isRepeat = this.dataset.flags.split(" ").includes("repeat");
this._app.mpd.command(`repeat ${isRepeat ? "0" : "1"}`);
});
DOM.volume.addEventListener("input", e => this._app.mpd.command(`setvol ${e.target.valueAsNumber}`));
DOM.progress.addEventListener("input", e => this._app.mpd.command(`seekcur ${e.target.valueAsNumber}`));
DOM.mute.addEventListener("click", _ => this._app.mpd.command(`setvol ${this._toggledVolume}`));
} }
_dispatchSongChange(detail) { _dispatchSongChange(detail) {
@ -1151,22 +1211,22 @@ class Queue extends Component {
this._updateCurrent(); this._updateCurrent();
break; break;
case "queue-change": case "idle-change":
this._sync(); e.detail.includes("playlist") && this._sync();
break; break;
} }
} }
_onAppLoad() { _onAppLoad() {
window.addEventListener("idle-change", this);
this._app.addEventListener("song-change", this); this._app.addEventListener("song-change", this);
this._app.addEventListener("queue-change", this);
this._sync(); this._sync();
} }
_onComponentChange(c, isThis) { _onComponentChange(c, isThis) {
this.hidden = !isThis; this.hidden = !isThis;
isThis && this._sync();
} }
async _sync() { async _sync() {
@ -1204,19 +1264,17 @@ class Queue extends Component {
sel.addCommandAll(); sel.addCommandAll();
sel.addCommand(async items => { sel.addCommand(items => {
const commands = generateMoveCommands(items, -1, Array.from(this.children)); const commands = generateMoveCommands(items, -1, Array.from(this.children));
await this._mpd.command(commands); this._mpd.command(commands);
this._sync();
}, {label:"Up", icon:"arrow-up-bold"}); }, {label:"Up", icon:"arrow-up-bold"});
sel.addCommand(async items => { sel.addCommand(items => {
const commands = generateMoveCommands(items, +1, Array.from(this.children)); const commands = generateMoveCommands(items, +1, Array.from(this.children));
await this._mpd.command(commands.reverse()); // move last first this._mpd.command(commands.reverse()); // move last first
this._sync();
}, {label:"Down", icon:"arrow-down-bold"}); }, {label:"Down", icon:"arrow-down-bold"});
sel.addCommand(async items => { sel.addCommand(items => {
let name = prompt("Save selected songs as a playlist?", "name"); let name = prompt("Save selected songs as a playlist?", "name");
if (name === null) { return; } if (name === null) { return; }
@ -1225,16 +1283,14 @@ class Queue extends Component {
return `playlistadd "${name}" "${escape(item.file)}"`; return `playlistadd "${name}" "${escape(item.file)}"`;
}); });
await this._mpd.command(commands); // FIXME notify? this._mpd.command(commands); // FIXME notify?
}, {label:"Save", icon:"content-save"}); }, {label:"Save", icon:"content-save"});
sel.addCommand(async items => { sel.addCommand(async items => {
if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; } if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; }
const commands = items.map(item => `deleteid ${item.songId}`); const commands = items.map(item => `deleteid ${item.songId}`);
await this._mpd.command(commands); this._mpd.command(commands);
this._sync();
}, {label:"Remove", icon:"delete"}); }, {label:"Remove", icon:"delete"});
sel.addCommandCancel(); sel.addCommandCancel();
@ -1263,9 +1319,21 @@ class Playlists extends Component {
this._initCommands(); this._initCommands();
} }
handleEvent(e) {
switch (e.type) {
case "idle-change":
e.detail.includes("stored_playlist") && this._sync();
break;
}
}
_onAppLoad() {
window.addEventListener("idle-change", this);
this._sync();
}
_onComponentChange(c, isThis) { _onComponentChange(c, isThis) {
this.hidden = !isThis; this.hidden = !isThis;
if (isThis) { this._sync(); }
} }
async _sync() { async _sync() {
@ -1287,15 +1355,13 @@ class Playlists extends Component {
const name = item.name; const name = item.name;
const commands = ["clear", `load "${escape(name)}"`, "play"]; const commands = ["clear", `load "${escape(name)}"`, "play"];
await this._mpd.command(commands); await this._mpd.command(commands);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Play", icon:"play"}); }, {label:"Play", icon:"play"});
sel.addCommand(async item => { sel.addCommand(async item => {
const name = item.name; const name = item.name;
await this._mpd.command(`load "${escape(name)}"`); await this._mpd.command(`load "${escape(name)}"`);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Enqueue", icon:"plus"}); }, {label:"Enqueue", icon:"plus"});
sel.addCommand(async item => { sel.addCommand(async item => {
@ -1303,7 +1369,6 @@ class Playlists extends Component {
if (!confirm(`Really delete playlist '${name}'?`)) { return; } if (!confirm(`Really delete playlist '${name}'?`)) { return; }
await this._mpd.command(`rm "${escape(name)}"`); await this._mpd.command(`rm "${escape(name)}"`);
this._sync();
}, {label:"Delete", icon:"delete"}); }, {label:"Delete", icon:"delete"});
sel.addCommandCancel(); sel.addCommandCancel();
@ -1819,15 +1884,13 @@ class Library extends Component {
sel.addCommand(async items => { sel.addCommand(async items => {
const commands = ["clear",...items.map(createEnqueueCommand), "play"]; const commands = ["clear",...items.map(createEnqueueCommand), "play"];
await this._mpd.command(commands); await this._mpd.command(commands);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Play", icon:"play"}); }, {label:"Play", icon:"play"});
sel.addCommand(async items => { sel.addCommand(async items => {
const commands = items.map(createEnqueueCommand); const commands = items.map(createEnqueueCommand);
await this._mpd.command(commands); await this._mpd.command(commands);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Enqueue", icon:"plus"}); }, {label:"Enqueue", icon:"plus"});
sel.addCommandCancel(); sel.addCommandCancel();

View file

@ -223,15 +223,13 @@ class Library extends Component {
sel.addCommand(async items => { sel.addCommand(async items => {
const commands = ["clear",...items.map(createEnqueueCommand), "play"]; const commands = ["clear",...items.map(createEnqueueCommand), "play"];
await this._mpd.command(commands); await this._mpd.command(commands);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Play", icon:"play"}); }, {label:"Play", icon:"play"});
sel.addCommand(async items => { sel.addCommand(async items => {
const commands = items.map(createEnqueueCommand); const commands = items.map(createEnqueueCommand);
await this._mpd.command(commands); await this._mpd.command(commands);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Enqueue", icon:"plus"}); }, {label:"Enqueue", icon:"plus"});
sel.addCommandCancel(); sel.addCommandCancel();

View file

@ -15,7 +15,7 @@ class Menu extends Component {
} }
_onComponentChange(component) { _onComponentChange(component) {
this._tabs.forEach( tab => { this._tabs.forEach(tab => {
tab.classList.toggle("active", tab.dataset.for == component); tab.classList.toggle("active", tab.dataset.for == component);
}); });
} }

View file

@ -4,141 +4,177 @@ import * as format from "../format.js";
import Component from "../component.js"; import Component from "../component.js";
const DELAY = 1000; const ELAPSED_PERIOD = 500;
class Player extends Component { class Player extends Component {
constructor() { constructor() {
super(); super();
this._current = {}; this._current = {
song: {},
elapsed: 0,
at: 0,
volume: 0
};
this._toggledVolume = 0; this._toggledVolume = 0;
this._idleTimeout = null;
this._dom = this._initDOM(); const DOM = {};
const all = this.querySelectorAll("[class]");
[...all].forEach(node => DOM[node.className] = node);
DOM.progress = DOM.timeline.querySelector("x-range");
DOM.progress.step = "0.1"; // FIXME
DOM.volume = DOM.volume.querySelector("x-range");
this._dom = DOM;
} }
async update() { handleEvent(e) {
this._clearIdle(); switch (e.type) {
const data = await this._mpd.status(); case "idle-change":
this._sync(data); let hasOptions = e.detail.includes("options");
this._idle(); let hasPlayer = e.detail.includes("player");
(hasOptions || hasPlayer) && this._updateStatus();
hasPlayer && this._updateCurrent();
break;
}
} }
_onAppLoad() { _onAppLoad() {
this.update(); this._addEvents();
this._updateStatus();
this._updateCurrent();
window.addEventListener("idle-change", this);
setInterval(() => this._updateElapsed(), ELAPSED_PERIOD);
} }
_initDOM() { async _updateStatus() {
const DOM = {}; const data = await this._mpd.status();
const all = this.querySelectorAll("[class]");
Array.from(all).forEach(node => DOM[node.className] = node);
DOM.progress = DOM.timeline.querySelector("x-range");
DOM.volume = DOM.volume.querySelector("x-range");
DOM.play.addEventListener("click", _ => this._command("play"));
DOM.pause.addEventListener("click", _ => this._command("pause 1"));
DOM.prev.addEventListener("click", _ => this._command("previous"));
DOM.next.addEventListener("click", _ => this._command("next"));
DOM.random.addEventListener("click", _ => this._command(`random ${this._current["random"] == "1" ? "0" : "1"}`));
DOM.repeat.addEventListener("click", _ => this._command(`repeat ${this._current["repeat"] == "1" ? "0" : "1"}`));
DOM.volume.addEventListener("input", e => this._command(`setvol ${e.target.valueAsNumber}`));
DOM.progress.addEventListener("input", e => this._command(`seekcur ${e.target.valueAsNumber}`));
DOM.mute.addEventListener("click", _ => this._command(`setvol ${this._toggledVolume}`));
return DOM;
}
async _command(cmd) {
this._clearIdle();
const data = await this._mpd.commandAndStatus(cmd);
this._sync(data);
this._idle();
}
_idle() {
this._idleTimeout = setTimeout(() => this.update(), DELAY);
}
_clearIdle() {
this._idleTimeout && clearTimeout(this._idleTimeout);
this._idleTimeout = null;
}
_sync(data) {
const DOM = this._dom; const DOM = this._dom;
if ("volume" in data) {
data["volume"] = Number(data["volume"]);
DOM.mute.disabled = false; this._updateFlags(data);
DOM.volume.disabled = false; this._updateVolume(data);
DOM.volume.value = data["volume"];
if (data["volume"] == 0 && this._current["volume"] > 0) { // muted if ("duration" in data) { // play/pause
this._toggledVolume = this._current["volume"]; let duration = Number(data["duration"]);
html.clear(DOM.mute); DOM.duration.textContent = format.time(duration);
DOM.mute.appendChild(html.icon("volume-off")); DOM.progress.max = duration;
} DOM.progress.disabled = false;
} else { // no song at all
if (data["volume"] > 0 && this._current["volume"] == 0) { // restored DOM.progress.value = 0;
this._toggledVolume = 0; DOM.progress.disabled = true;
html.clear(DOM.mute);
DOM.mute.appendChild(html.icon("volume-high"));
}
} else {
DOM.mute.disabled = true;
DOM.volume.disabled = true;
DOM.volume.value = 50;
} }
// changed time // rebase the time sync
let elapsed = Number(data["elapsed"] || 0); this._current.elapsed = Number(data["elapsed"] || 0);
DOM.progress.value = elapsed; this._current.at = performance.now();
DOM.elapsed.textContent = format.time(elapsed); }
if (data["file"] != this._current["file"]) { // changed song async _updateCurrent() {
if (data["file"]) { // playing at all? const data = await this._mpd.currentSong();
let duration = Number(data["duration"]); const DOM = this._dom;
DOM.duration.textContent = format.time(duration);
DOM.progress.max = duration; if (data["file"] != this._current.song["file"]) { // changed song
DOM.progress.disabled = false; if (data["file"]) { // is there a song at all?
DOM.title.textContent = data["Title"] || format.fileName(data["file"]); DOM.title.textContent = data["Title"] || format.fileName(data["file"]);
DOM.subtitle.textContent = format.subtitle(data, {duration:false}); DOM.subtitle.textContent = format.subtitle(data, {duration:false});
} else { } else {
DOM.title.textContent = ""; DOM.title.textContent = "";
DOM.subtitle.textContent = ""; DOM.subtitle.textContent = "";
DOM.progress.value = 0;
DOM.progress.disabled = true;
} }
this._dispatchSongChange(data); this._dispatchSongChange(data);
} }
this._app.style.setProperty("--progress", DOM.progress.value/DOM.progress.max);
let artistNew = data["AlbumArtist"] || data["Artist"]; let artistNew = data["AlbumArtist"] || data["Artist"];
let artistOld = this._current["AlbumArtist"] || this._current["Artist"]; let artistOld = this._current.song["AlbumArtist"] || this._current.song["Artist"];
let albumNew = data["Album"];
let albumOld = this._current.song["Album"];
if (artistNew != artistOld || data["Album"] != this._current["Album"]) { // changed album (art) Object.assign(this._current.song, data);
if (artistNew != artistOld || albumNew != albumOld) { // changed album (art)
html.clear(DOM.art); html.clear(DOM.art);
art.get(this._mpd, artistNew, data["Album"], data["file"]).then(src => { let src = await art.get(this._mpd, artistNew, data["Album"], data["file"]);
if (src) { if (src) {
html.node("img", {src}, "", DOM.art); html.node("img", {src}, "", DOM.art);
} else { } else {
html.icon("music", DOM.art); html.icon("music", DOM.art);
} }
}); }
}
_updateElapsed() {
const DOM = this._dom;
let elapsed = 0;
if (this._current.song["file"]) {
elapsed = this._current.elapsed;
if (this.dataset.state == "play") { elapsed += (performance.now() - this._current.at)/1000; }
} }
DOM.progress.value = elapsed;
DOM.elapsed.textContent = format.time(elapsed);
this._app.style.setProperty("--progress", DOM.progress.value/DOM.progress.max);
}
_updateFlags(data) {
let flags = []; let flags = [];
if (data["random"] == "1") { flags.push("random"); } if (data["random"] == "1") { flags.push("random"); }
if (data["repeat"] == "1") { flags.push("repeat"); } if (data["repeat"] == "1") { flags.push("repeat"); }
this.dataset.flags = flags.join(" "); this.dataset.flags = flags.join(" ");
this.dataset.state = data["state"]; this.dataset.state = data["state"];
}
this._current = data; _updateVolume(data) {
const DOM = this._dom;
if ("volume" in data) {
let volume = Number(data["volume"]);
DOM.mute.disabled = false;
DOM.volume.disabled = false;
DOM.volume.value = volume;
if (volume == 0 && this._current.volume > 0) { // muted
this._toggledVolume = this._current.volume;
html.clear(DOM.mute);
DOM.mute.appendChild(html.icon("volume-off"));
}
if (volume > 0 && this._current.volume == 0) { // restored
this._toggledVolume = 0;
html.clear(DOM.mute);
DOM.mute.appendChild(html.icon("volume-high"));
}
this._current.volume = volume;
} else {
DOM.mute.disabled = true;
DOM.volume.disabled = true;
DOM.volume.value = 50;
}
}
_addEvents() {
const DOM = this._dom;
DOM.play.addEventListener("click", _ => this._app.mpd.command("play"));
DOM.pause.addEventListener("click", _ => this._app.mpd.command("pause 1"));
DOM.prev.addEventListener("click", _ => this._app.mpd.command("previous"));
DOM.next.addEventListener("click", _ => this._app.mpd.command("next"));
DOM.random.addEventListener("click", _ => {
let isRandom = this.dataset.flags.split(" ").includes("random");
this._app.mpd.command(`random ${isRandom ? "0" : "1"}`);
});
DOM.repeat.addEventListener("click", _ => {
let isRepeat = this.dataset.flags.split(" ").includes("repeat");
this._app.mpd.command(`repeat ${isRepeat ? "0" : "1"}`);
});
DOM.volume.addEventListener("input", e => this._app.mpd.command(`setvol ${e.target.valueAsNumber}`));
DOM.progress.addEventListener("input", e => this._app.mpd.command(`seekcur ${e.target.valueAsNumber}`));
DOM.mute.addEventListener("click", _ => this._app.mpd.command(`setvol ${this._toggledVolume}`));
} }
_dispatchSongChange(detail) { _dispatchSongChange(detail) {

View file

@ -9,9 +9,21 @@ class Playlists extends Component {
this._initCommands(); this._initCommands();
} }
handleEvent(e) {
switch (e.type) {
case "idle-change":
e.detail.includes("stored_playlist") && this._sync();
break;
}
}
_onAppLoad() {
window.addEventListener("idle-change", this);
this._sync();
}
_onComponentChange(c, isThis) { _onComponentChange(c, isThis) {
this.hidden = !isThis; this.hidden = !isThis;
if (isThis) { this._sync(); }
} }
async _sync() { async _sync() {
@ -33,15 +45,13 @@ class Playlists extends Component {
const name = item.name; const name = item.name;
const commands = ["clear", `load "${escape(name)}"`, "play"]; const commands = ["clear", `load "${escape(name)}"`, "play"];
await this._mpd.command(commands); await this._mpd.command(commands);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Play", icon:"play"}); }, {label:"Play", icon:"play"});
sel.addCommand(async item => { sel.addCommand(async item => {
const name = item.name; const name = item.name;
await this._mpd.command(`load "${escape(name)}"`); await this._mpd.command(`load "${escape(name)}"`);
this.selection.clear(); this.selection.clear(); // fixme notification?
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
}, {label:"Enqueue", icon:"plus"}); }, {label:"Enqueue", icon:"plus"});
sel.addCommand(async item => { sel.addCommand(async item => {
@ -49,7 +59,6 @@ class Playlists extends Component {
if (!confirm(`Really delete playlist '${name}'?`)) { return; } if (!confirm(`Really delete playlist '${name}'?`)) { return; }
await this._mpd.command(`rm "${escape(name)}"`); await this._mpd.command(`rm "${escape(name)}"`);
this._sync();
}, {label:"Delete", icon:"delete"}); }, {label:"Delete", icon:"delete"});
sel.addCommandCancel(); sel.addCommandCancel();

View file

@ -30,22 +30,22 @@ class Queue extends Component {
this._updateCurrent(); this._updateCurrent();
break; break;
case "queue-change": case "idle-change":
this._sync(); e.detail.includes("playlist") && this._sync();
break; break;
} }
} }
_onAppLoad() { _onAppLoad() {
window.addEventListener("idle-change", this);
this._app.addEventListener("song-change", this); this._app.addEventListener("song-change", this);
this._app.addEventListener("queue-change", this);
this._sync(); this._sync();
} }
_onComponentChange(c, isThis) { _onComponentChange(c, isThis) {
this.hidden = !isThis; this.hidden = !isThis;
isThis && this._sync();
} }
async _sync() { async _sync() {
@ -83,19 +83,17 @@ class Queue extends Component {
sel.addCommandAll(); sel.addCommandAll();
sel.addCommand(async items => { sel.addCommand(items => {
const commands = generateMoveCommands(items, -1, Array.from(this.children)); const commands = generateMoveCommands(items, -1, Array.from(this.children));
await this._mpd.command(commands); this._mpd.command(commands);
this._sync();
}, {label:"Up", icon:"arrow-up-bold"}); }, {label:"Up", icon:"arrow-up-bold"});
sel.addCommand(async items => { sel.addCommand(items => {
const commands = generateMoveCommands(items, +1, Array.from(this.children)); const commands = generateMoveCommands(items, +1, Array.from(this.children));
await this._mpd.command(commands.reverse()); // move last first this._mpd.command(commands.reverse()); // move last first
this._sync();
}, {label:"Down", icon:"arrow-down-bold"}); }, {label:"Down", icon:"arrow-down-bold"});
sel.addCommand(async items => { sel.addCommand(items => {
let name = prompt("Save selected songs as a playlist?", "name"); let name = prompt("Save selected songs as a playlist?", "name");
if (name === null) { return; } if (name === null) { return; }
@ -104,16 +102,14 @@ class Queue extends Component {
return `playlistadd "${name}" "${escape(item.file)}"`; return `playlistadd "${name}" "${escape(item.file)}"`;
}); });
await this._mpd.command(commands); // FIXME notify? this._mpd.command(commands); // FIXME notify?
}, {label:"Save", icon:"content-save"}); }, {label:"Save", icon:"content-save"});
sel.addCommand(async items => { sel.addCommand(async items => {
if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; } if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; }
const commands = items.map(item => `deleteid ${item.songId}`); const commands = items.map(item => `deleteid ${item.songId}`);
await this._mpd.command(commands); this._mpd.command(commands);
this._sync();
}, {label:"Remove", icon:"delete"}); }, {label:"Remove", icon:"delete"});
sel.addCommandCancel(); sel.addCommandCancel();

View file

@ -2,22 +2,23 @@ export function command(cmd) {
console.warn(`mpd-mock does not know "${cmd}"`); console.warn(`mpd-mock does not know "${cmd}"`);
} }
export function commandAndStatus(cmd) {
command(cmd);
return status();
}
export function status() { export function status() {
return { return {
volume: 50, volume: 50,
elapsed: 10, elapsed: 10,
duration: 70,
state: "play"
}
}
export function currentSong() {
return {
duration: 70, duration: 70,
file: "name.mp3", file: "name.mp3",
Title: "Title of song", Title: "Title of song",
Artist: "Artist of song", Artist: "Artist of song",
Album: "Album of song", Album: "Album of song",
Track: "6", Track: "6",
state: "play",
Id: 2 Id: 2
} }
} }
@ -25,7 +26,7 @@ export function status() {
export function listQueue() { export function listQueue() {
return [ return [
{Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30, file:"a.mp3"}, {Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30, file:"a.mp3"},
status(), currentSong(),
{Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230, file:"c.mp3"}, {Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230, file:"c.mp3"},
]; ];
} }

View file

@ -3,20 +3,21 @@ import * as parser from "./parser.js";
let ws; let ws;
let commandQueue = []; let commandQueue = [];
let current; let current;
let canTerminateIdle = false;
function onMessage(e) { function onMessage(e) {
if (current) { if (!current) { return; }
let lines = JSON.parse(e.data);
let last = lines.pop(); let lines = JSON.parse(e.data);
if (last.startsWith("OK")) { let last = lines.pop();
current.resolve(lines); if (last.startsWith("OK")) {
} else { current.resolve(lines);
console.warn(last); } else {
current.reject(last); console.warn(last);
} current.reject(last);
current = null;
} }
processQueue(); current = null;
setTimeout(processQueue, 0); // after other potential commands are enqueued
} }
function onError(e) { function onError(e) {
@ -32,25 +33,30 @@ function onClose(e) {
} }
function processQueue() { function processQueue() {
if (current || commandQueue.length == 0) { return; } if (commandQueue.length == 0) {
current = commandQueue.shift(); if (!current) { idle(); } // nothing to do
ws.send(current.cmd); } else if (current) { // stuff waiting in queue but there is a command under way
if (canTerminateIdle) {
ws.send("noidle");
canTerminateIdle = false;
}
} else { // advance to next command
current = commandQueue.shift();
ws.send(current.cmd);
}
} }
export function serializeFilter(filter, operator = "==") { async function idle() {
let tokens = ["("]; let promise = command("idle stored_playlist playlist player options");
Object.entries(filter).forEach(([key, value], index) => { canTerminateIdle = true;
index && tokens.push(" AND "); let lines = await promise;
tokens.push(`(${key} ${operator} "${escape(value)}")`); canTerminateIdle = false;
}); let changed = parser.linesToStruct(lines).changed || [];
tokens.push(")"); changed = [].concat(changed);
if (changed.length > 0) {
let filterStr = tokens.join(""); // FIXME not on window
return `"${escape(filterStr)}"`; window.dispatchEvent(new CustomEvent("idle-change", {detail:changed}));
} }
export function escape(str) {
return str.replace(/(['"\\])/g, "\\$1");
} }
export async function command(cmd) { export async function command(cmd) {
@ -62,18 +68,14 @@ export async function command(cmd) {
}); });
} }
export async function commandAndStatus(cmd) { export async function status() {
let lines = await command([cmd, "status", "currentsong"]); let lines = await command("status");
let status = parser.linesToStruct(lines); return parser.linesToStruct(lines);
if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
return status;
} }
export async function status() { export async function currentSong() {
let lines = await command(["status", "currentsong"]); let lines = await command("currentsong");
let status = parser.linesToStruct(lines); return parser.linesToStruct(lines);
if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
return status;
} }
export async function listQueue() { export async function listQueue() {
@ -119,7 +121,6 @@ export async function searchSongs(filter) {
let tokens = ["search", serializeFilter(filter, "contains")]; let tokens = ["search", serializeFilter(filter, "contains")];
let lines = await command(tokens.join(" ")); let lines = await command(tokens.join(" "));
return parser.songList(lines); return parser.songList(lines);
} }
export async function albumArt(songUrl) { export async function albumArt(songUrl) {
@ -140,11 +141,31 @@ export async function albumArt(songUrl) {
return null; return null;
} }
export function serializeFilter(filter, operator = "==") {
let tokens = ["("];
Object.entries(filter).forEach(([key, value], index) => {
index && tokens.push(" AND ");
tokens.push(`(${key} ${operator} "${escape(value)}")`);
});
tokens.push(")");
let filterStr = tokens.join("");
return `"${escape(filterStr)}"`;
}
export function escape(str) {
return str.replace(/(['"\\])/g, "\\$1");
}
export async function init() { export async function init() {
let response = await fetch("/ticket", {method:"POST"}); let response = await fetch("/ticket", {method:"POST"});
let ticket = (await response.json()).ticket; let ticket = (await response.json()).ticket;
return new Promise((resolve, reject) => { let resolve, reject;
let promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
try { try {
let url = new URL(location.href); let url = new URL(location.href);
url.protocol = "ws"; url.protocol = "ws";
@ -152,10 +173,12 @@ export async function init() {
url.searchParams.set("ticket", ticket); url.searchParams.set("ticket", ticket);
ws = new WebSocket(url.href); ws = new WebSocket(url.href);
} catch (e) { reject(e); } } catch (e) { reject(e); }
current = {resolve, reject};
ws.addEventListener("error", onError); ws.addEventListener("error", onError);
ws.addEventListener("message", onMessage); ws.addEventListener("message", onMessage);
ws.addEventListener("close", onClose); ws.addEventListener("close", onClose);
}); });
current = {resolve, reject, promise};
return Promise;
} }

View file

@ -6,7 +6,7 @@
"dependencies": { "dependencies": {
"custom-range": "^1.0.3", "custom-range": "^1.0.3",
"node-static": "^0.7.11", "node-static": "^0.7.11",
"ws2mpd": "^2.1.0" "ws2mpd": "^2.2.0"
}, },
"devDependencies": { "devDependencies": {
"less": "^3.9.0", "less": "^3.9.0",