objectified the mpd module, app now reconnects
This commit is contained in:
parent
0b96e4da31
commit
9c66c7c1c1
4 changed files with 405 additions and 460 deletions
486
app/cyp.js
486
app/cyp.js
|
@ -237,145 +237,170 @@ function pathContents(lines) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ws, app;
|
class MPD {
|
||||||
let commandQueue = [];
|
static async connect() {
|
||||||
let current;
|
let response = await fetch("/ticket", {method:"POST"});
|
||||||
let canTerminateIdle = false;
|
let ticket = (await response.json()).ticket;
|
||||||
|
|
||||||
function onError(e) {
|
let ws = new WebSocket(createURL(ticket).href);
|
||||||
console.error(e);
|
|
||||||
current && current.reject(e);
|
|
||||||
ws = null; // fixme
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClose(e) {
|
return new Promise((resolve, reject) => {
|
||||||
console.warn(e);
|
let mpd;
|
||||||
current && current.reject(e);
|
let initialCommand = {resolve: () => resolve(mpd), reject};
|
||||||
ws = null; // fixme
|
mpd = new this(ws, initialCommand);
|
||||||
}
|
});
|
||||||
|
|
||||||
function onMessage(e) {
|
|
||||||
if (!current) { return; }
|
|
||||||
|
|
||||||
let lines = JSON.parse(e.data);
|
|
||||||
let last = lines.pop();
|
|
||||||
if (last.startsWith("OK")) {
|
|
||||||
current.resolve(lines);
|
|
||||||
} else {
|
|
||||||
console.warn(last);
|
|
||||||
current.reject(last);
|
|
||||||
}
|
}
|
||||||
current = null;
|
|
||||||
|
|
||||||
if (commandQueue.length > 0) {
|
constructor(/** @type WebSocket */ ws, initialCommand) {
|
||||||
advanceQueue();
|
this._ws = ws;
|
||||||
} else {
|
this._queue = [];
|
||||||
setTimeout(idle, 0); // only after resolution callbacks
|
this._current = initialCommand;
|
||||||
|
this._canTerminateIdle = false;
|
||||||
|
|
||||||
|
ws.addEventListener("message", e => this._onMessage(e));
|
||||||
|
ws.addEventListener("close", e => this._onClose(e));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function advanceQueue(){
|
onClose(_e) {}
|
||||||
current = commandQueue.shift();
|
onChange(_changed) {}
|
||||||
ws.send(current.cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function idle() {
|
command(cmd) {
|
||||||
if (current) { return; }
|
if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); }
|
||||||
|
|
||||||
canTerminateIdle = true;
|
return new Promise((resolve, reject) => {
|
||||||
let lines = await command("idle stored_playlist playlist player options mixer");
|
this._queue.push({cmd, resolve, reject});
|
||||||
canTerminateIdle = false;
|
|
||||||
let changed = linesToStruct(lines).changed || [];
|
|
||||||
changed = [].concat(changed);
|
|
||||||
(changed.length > 0) && app.dispatchEvent(new CustomEvent("idle-change", {detail:changed}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function command(cmd) {
|
if (!this._current) {
|
||||||
if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); }
|
this._advanceQueue();
|
||||||
|
} else if (this._canTerminateIdle) {
|
||||||
|
this._ws.send("noidle");
|
||||||
|
this._canTerminateIdle = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
async status() {
|
||||||
commandQueue.push({cmd, resolve, reject});
|
let lines = await this.command("status");
|
||||||
|
return linesToStruct(lines);
|
||||||
|
}
|
||||||
|
|
||||||
if (!current) {
|
async currentSong() {
|
||||||
advanceQueue();
|
let lines = await this.command("currentsong");
|
||||||
} else if (canTerminateIdle) {
|
return linesToStruct(lines);
|
||||||
ws.send("noidle");
|
}
|
||||||
canTerminateIdle = false;
|
|
||||||
|
async listQueue() {
|
||||||
|
let lines = await this.command("playlistinfo");
|
||||||
|
return songList(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPlaylists() {
|
||||||
|
let lines = await this.command("listplaylists");
|
||||||
|
let parsed = linesToStruct(lines);
|
||||||
|
|
||||||
|
let list = parsed["playlist"];
|
||||||
|
if (!list) { return []; }
|
||||||
|
return (list instanceof Array ? list : [list]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPath(path) {
|
||||||
|
let lines = await this.command(`lsinfo "${escape(path)}"`);
|
||||||
|
return pathContents(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTags(tag, filter = {}) {
|
||||||
|
let tokens = ["list", tag];
|
||||||
|
if (Object.keys(filter).length) {
|
||||||
|
tokens.push(serializeFilter(filter));
|
||||||
|
|
||||||
|
let fakeGroup = Object.keys(filter)[0]; // FIXME hack for MPD < 0.21.6
|
||||||
|
tokens.push("group", fakeGroup);
|
||||||
}
|
}
|
||||||
});
|
let lines = await this.command(tokens.join(" "));
|
||||||
}
|
let parsed = linesToStruct(lines);
|
||||||
|
return [].concat(tag in parsed ? parsed[tag] : []);
|
||||||
async function status() {
|
|
||||||
let lines = await command("status");
|
|
||||||
return linesToStruct(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function currentSong() {
|
|
||||||
let lines = await command("currentsong");
|
|
||||||
return linesToStruct(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listQueue() {
|
|
||||||
let lines = await command("playlistinfo");
|
|
||||||
return songList(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listPlaylists() {
|
|
||||||
let lines = await command("listplaylists");
|
|
||||||
let parsed = linesToStruct(lines);
|
|
||||||
|
|
||||||
let list = parsed["playlist"];
|
|
||||||
if (!list) { return []; }
|
|
||||||
return (list instanceof Array ? list : [list]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listPath(path) {
|
|
||||||
let lines = await command(`lsinfo "${escape(path)}"`);
|
|
||||||
return pathContents(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listTags(tag, filter = {}) {
|
|
||||||
let tokens = ["list", tag];
|
|
||||||
if (Object.keys(filter).length) {
|
|
||||||
tokens.push(serializeFilter(filter));
|
|
||||||
|
|
||||||
let fakeGroup = Object.keys(filter)[0]; // FIXME hack for MPD < 0.21.6
|
|
||||||
tokens.push("group", fakeGroup);
|
|
||||||
}
|
}
|
||||||
let lines = await command(tokens.join(" "));
|
|
||||||
let parsed = linesToStruct(lines);
|
|
||||||
return [].concat(tag in parsed ? parsed[tag] : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listSongs(filter, window = null) {
|
async listSongs(filter, window = null) {
|
||||||
let tokens = ["find", serializeFilter(filter)];
|
let tokens = ["find", serializeFilter(filter)];
|
||||||
window && tokens.push("window", window.join(":"));
|
window && tokens.push("window", window.join(":"));
|
||||||
let lines = await command(tokens.join(" "));
|
let lines = await this.command(tokens.join(" "));
|
||||||
return songList(lines);
|
return songList(lines);
|
||||||
}
|
|
||||||
|
|
||||||
async function searchSongs(filter) {
|
|
||||||
let tokens = ["search", serializeFilter(filter, "contains")];
|
|
||||||
let lines = await command(tokens.join(" "));
|
|
||||||
return songList(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function albumArt(songUrl) {
|
|
||||||
let data = [];
|
|
||||||
let offset = 0;
|
|
||||||
let params = ["albumart", `"${escape(songUrl)}"`, offset];
|
|
||||||
|
|
||||||
while (1) {
|
|
||||||
params[2] = offset;
|
|
||||||
try {
|
|
||||||
let lines = await command(params.join(" "));
|
|
||||||
data = data.concat(lines[2]);
|
|
||||||
let metadata = linesToStruct(lines.slice(0, 2));
|
|
||||||
if (data.length >= Number(metadata["size"])) { return data; }
|
|
||||||
offset += Number(metadata["binary"]);
|
|
||||||
} catch (e) { return null; }
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
async searchSongs(filter) {
|
||||||
|
let tokens = ["search", serializeFilter(filter, "contains")];
|
||||||
|
let lines = await this.command(tokens.join(" "));
|
||||||
|
return songList(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
async albumArt(songUrl) {
|
||||||
|
let data = [];
|
||||||
|
let offset = 0;
|
||||||
|
let params = ["albumart", `"${escape(songUrl)}"`, offset];
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
params[2] = offset;
|
||||||
|
try {
|
||||||
|
let lines = await this.command(params.join(" "));
|
||||||
|
data = data.concat(lines[2]);
|
||||||
|
let metadata = linesToStruct(lines.slice(0, 2));
|
||||||
|
if (data.length >= Number(metadata["size"])) { return data; }
|
||||||
|
offset += Number(metadata["binary"]);
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
escape(...args) { return escape(...args); }
|
||||||
|
|
||||||
|
_onMessage(e) {
|
||||||
|
if (!this._current) { return; }
|
||||||
|
|
||||||
|
let lines = JSON.parse(e.data);
|
||||||
|
let last = lines.pop();
|
||||||
|
if (last.startsWith("OK")) {
|
||||||
|
this._current.resolve(lines);
|
||||||
|
} else {
|
||||||
|
console.warn(last);
|
||||||
|
this._current.reject(last);
|
||||||
|
}
|
||||||
|
this._current = null;
|
||||||
|
|
||||||
|
if (this._queue.length > 0) {
|
||||||
|
this._advanceQueue();
|
||||||
|
} else {
|
||||||
|
setTimeout(() => this._idle(), 0); // only after resolution callbacks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClose(e) {
|
||||||
|
console.warn(e);
|
||||||
|
this._current && this._current.reject(e);
|
||||||
|
this._ws = null;
|
||||||
|
this.onClose(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
_advanceQueue() {
|
||||||
|
this._current = this._queue.shift();
|
||||||
|
this._ws.send(this._current.cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _idle() {
|
||||||
|
if (this._current) { return; }
|
||||||
|
|
||||||
|
this._canTerminateIdle = true;
|
||||||
|
let lines = await this.command("idle stored_playlist playlist player options mixer");
|
||||||
|
this._canTerminateIdle = false;
|
||||||
|
let changed = linesToStruct(lines).changed || [];
|
||||||
|
changed = [].concat(changed);
|
||||||
|
(changed.length > 0) && this.onChange(changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function escape(str) {
|
||||||
|
return str.replace(/(['"\\])/g, "\\$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeFilter(filter, operator = "==") {
|
function serializeFilter(filter, operator = "==") {
|
||||||
|
@ -390,141 +415,14 @@ function serializeFilter(filter, operator = "==") {
|
||||||
return `"${escape(filterStr)}"`;
|
return `"${escape(filterStr)}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escape(str) {
|
function createURL(ticket) {
|
||||||
return str.replace(/(['"\\])/g, "\\$1");
|
let url = new URL(location.href);
|
||||||
|
url.protocol = "ws";
|
||||||
|
url.hash = "";
|
||||||
|
url.searchParams.set("ticket", ticket);
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function init(a) {
|
|
||||||
app = a;
|
|
||||||
let response = await fetch("/ticket", {method:"POST"});
|
|
||||||
let ticket = (await response.json()).ticket;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
current = {resolve, reject};
|
|
||||||
|
|
||||||
try {
|
|
||||||
let url = new URL(location.href);
|
|
||||||
url.protocol = "ws";
|
|
||||||
url.hash = "";
|
|
||||||
url.searchParams.set("ticket", ticket);
|
|
||||||
ws = new WebSocket(url.href);
|
|
||||||
} catch (e) { reject(e); }
|
|
||||||
|
|
||||||
ws.addEventListener("error", onError);
|
|
||||||
ws.addEventListener("message", onMessage);
|
|
||||||
ws.addEventListener("close", onClose);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var mpd = /*#__PURE__*/Object.freeze({
|
|
||||||
__proto__: null,
|
|
||||||
command: command,
|
|
||||||
status: status,
|
|
||||||
currentSong: currentSong,
|
|
||||||
listQueue: listQueue,
|
|
||||||
listPlaylists: listPlaylists,
|
|
||||||
listPath: listPath,
|
|
||||||
listTags: listTags,
|
|
||||||
listSongs: listSongs,
|
|
||||||
searchSongs: searchSongs,
|
|
||||||
albumArt: albumArt,
|
|
||||||
serializeFilter: serializeFilter,
|
|
||||||
escape: escape,
|
|
||||||
init: init
|
|
||||||
});
|
|
||||||
|
|
||||||
function command$1(cmd) {
|
|
||||||
console.warn(`mpd-mock does not know "${cmd}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function status$1() {
|
|
||||||
return {
|
|
||||||
volume: 50,
|
|
||||||
elapsed: 10,
|
|
||||||
duration: 70,
|
|
||||||
state: "play"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentSong$1() {
|
|
||||||
return {
|
|
||||||
duration: 70,
|
|
||||||
file: "name.mp3",
|
|
||||||
Title: "Title of song",
|
|
||||||
Artist: "Artist of song",
|
|
||||||
Album: "Album of song",
|
|
||||||
Track: "6",
|
|
||||||
Id: 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listQueue$1() {
|
|
||||||
return [
|
|
||||||
{Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30, file:"a.mp3"},
|
|
||||||
currentSong$1(),
|
|
||||||
{Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230, file:"c.mp3"},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function listPlaylists$1() {
|
|
||||||
return [
|
|
||||||
"Playlist 1",
|
|
||||||
"Playlist 2",
|
|
||||||
"Playlist 3"
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function listPath$1(path) {
|
|
||||||
return {
|
|
||||||
"directory": [
|
|
||||||
{"directory": "Dir 1"},
|
|
||||||
{"directory": "Dir 2"},
|
|
||||||
{"directory": "Dir 3"}
|
|
||||||
],
|
|
||||||
"file": [
|
|
||||||
{"file": "File 1"},
|
|
||||||
{"file": "File 2"},
|
|
||||||
{"file": "File 3"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listTags$1(tag, filter = null) {
|
|
||||||
switch (tag) {
|
|
||||||
case "AlbumArtist": return ["Artist 1", "Artist 2", "Artist 3"];
|
|
||||||
case "Album": return ["Album 1", "Album 2", "Album 3"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function listSongs$1(filter, window = null) {
|
|
||||||
return listQueue$1();
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchSongs$1(filter) {
|
|
||||||
return listQueue$1();
|
|
||||||
}
|
|
||||||
|
|
||||||
function albumArt$1(songUrl) {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function init$1() {}
|
|
||||||
|
|
||||||
var mpdMock = /*#__PURE__*/Object.freeze({
|
|
||||||
__proto__: null,
|
|
||||||
command: command$1,
|
|
||||||
status: status$1,
|
|
||||||
currentSong: currentSong$1,
|
|
||||||
listQueue: listQueue$1,
|
|
||||||
listPlaylists: listPlaylists$1,
|
|
||||||
listPath: listPath$1,
|
|
||||||
listTags: listTags$1,
|
|
||||||
listSongs: listSongs$1,
|
|
||||||
searchSongs: searchSongs$1,
|
|
||||||
albumArt: albumArt$1,
|
|
||||||
init: init$1
|
|
||||||
});
|
|
||||||
|
|
||||||
let ICONS={};
|
let ICONS={};
|
||||||
ICONS["library-music"] = `<svg viewBox="0 0 24 24">
|
ICONS["library-music"] = `<svg viewBox="0 0 24 24">
|
||||||
<path d="M4,6H2V20A2,2 0 0,0 4,22H18V20H4M18,7H15V12.5A2.5,2.5 0 0,1 12.5,15A2.5,2.5 0 0,1 10,12.5A2.5,2.5 0 0,1 12.5,10C13.07,10 13.58,10.19 14,10.5V5H18M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2Z"/>
|
<path d="M4,6H2V20A2,2 0 0,0 4,22H18V20H4M18,7H15V12.5A2.5,2.5 0 0,1 12.5,15A2.5,2.5 0 0,1 10,12.5A2.5,2.5 0 0,1 12.5,10C13.07,10 13.58,10.19 14,10.5V5H18M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2Z"/>
|
||||||
|
@ -673,43 +571,22 @@ function initIcons() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initMpd(app) {
|
|
||||||
try {
|
|
||||||
await init(app);
|
|
||||||
return mpd;
|
|
||||||
} catch (e) {
|
|
||||||
return mpdMock;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends HTMLElement {
|
class App extends HTMLElement {
|
||||||
static get observedAttributes() { return ["component"]; }
|
static get observedAttributes() { return ["component"]; }
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
initIcons();
|
initIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
this.mpd = await initMpd(this);
|
await waitForChildren(this);
|
||||||
|
|
||||||
const children = Array.from(this.querySelectorAll("*"));
|
window.addEventListener("hashchange", e => this._onHashChange());
|
||||||
const names = children.map(node => node.nodeName.toLowerCase())
|
this._onHashChange();
|
||||||
.filter(name => name.startsWith("cyp-"));
|
|
||||||
const unique = new Set(names);
|
|
||||||
|
|
||||||
const promises = [...unique].map(name => customElements.whenDefined(name));
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
|
await this._connect();
|
||||||
this.dispatchEvent(new CustomEvent("load"));
|
this.dispatchEvent(new CustomEvent("load"));
|
||||||
|
|
||||||
const onHashChange = () => {
|
|
||||||
const component = location.hash.substring(1) || "queue";
|
|
||||||
if (component != this.component) { this.component = component; }
|
|
||||||
};
|
|
||||||
window.addEventListener("hashchange", onHashChange);
|
|
||||||
onHashChange();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name, oldValue, newValue) {
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
@ -724,10 +601,49 @@ class App extends HTMLElement {
|
||||||
|
|
||||||
get component() { return this.getAttribute("component"); }
|
get component() { return this.getAttribute("component"); }
|
||||||
set component(component) { this.setAttribute("component", component); }
|
set component(component) { this.setAttribute("component", component); }
|
||||||
|
|
||||||
|
_onHashChange() {
|
||||||
|
const component = location.hash.substring(1) || "queue";
|
||||||
|
if (component != this.component) { this.component = component; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange(changed) { this.dispatchEvent(new CustomEvent("idle-change", {detail:changed})); }
|
||||||
|
|
||||||
|
_onClose(e) {
|
||||||
|
setTimeout(() => this._connect(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _connect() {
|
||||||
|
const attempts = 3;
|
||||||
|
for (let i=0;i<attempts;i++) {
|
||||||
|
try {
|
||||||
|
let mpd = await MPD.connect();
|
||||||
|
mpd.onChange = changed => this._onChange(changed);
|
||||||
|
mpd.onClose = e => this._onClose(e);
|
||||||
|
this.mpd = mpd;
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert(`Failed to connect to MPD after ${attempts} attempts. Please reload the page to try again.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("cyp-app", App);
|
customElements.define("cyp-app", App);
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise(resolve =>setTimeout(resolve, ms)); }
|
||||||
|
|
||||||
|
function waitForChildren(app) {
|
||||||
|
const children = Array.from(app.querySelectorAll("*"));
|
||||||
|
const names = children.map(node => node.nodeName.toLowerCase())
|
||||||
|
.filter(name => name.startsWith("cyp-"));
|
||||||
|
const unique = new Set(names);
|
||||||
|
|
||||||
|
const promises = [...unique].map(name => customElements.whenDefined(name));
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
const TAGS = ["cyp-song", "cyp-tag", "cyp-path"];
|
const TAGS = ["cyp-song", "cyp-tag", "cyp-path"];
|
||||||
|
|
||||||
class Selection {
|
class Selection {
|
||||||
|
@ -832,19 +748,21 @@ class Component extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Menu extends Component {
|
class Menu extends Component {
|
||||||
_onAppLoad() {
|
connectedCallback() {
|
||||||
/** @type HTMLElement[] */
|
/** @type HTMLElement[] */
|
||||||
this._tabs = Array.from(this.querySelectorAll("[data-for]"));
|
this._tabs = Array.from(this.querySelectorAll("[data-for]"));
|
||||||
|
|
||||||
this._tabs.forEach(tab => {
|
this._tabs.forEach(tab => {
|
||||||
tab.addEventListener("click", _ => this._app.setAttribute("component", tab.dataset.for));
|
tab.addEventListener("click", _ => this._app.setAttribute("component", tab.dataset.for));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAppLoad() {
|
||||||
this._app.addEventListener("queue-length-change", e => {
|
this._app.addEventListener("queue-length-change", e => {
|
||||||
this.querySelector(".queue-length").textContent = `(${e.detail})`;
|
this.querySelector(".queue-length").textContent = `(${e.detail})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_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);
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import * as mpd from "../mpd.js";
|
import MPD from "../mpd.js";
|
||||||
import * as mpdMock from "../mpd-mock.js";
|
|
||||||
import * as html from "../html.js";
|
import * as html from "../html.js";
|
||||||
|
|
||||||
function initIcons() {
|
function initIcons() {
|
||||||
|
@ -11,43 +10,22 @@ function initIcons() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initMpd(app) {
|
|
||||||
try {
|
|
||||||
await mpd.init(app);
|
|
||||||
return mpd;
|
|
||||||
} catch (e) {
|
|
||||||
return mpdMock;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class App extends HTMLElement {
|
class App extends HTMLElement {
|
||||||
static get observedAttributes() { return ["component"]; }
|
static get observedAttributes() { return ["component"]; }
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
initIcons();
|
initIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
this.mpd = await initMpd(this);
|
await waitForChildren(this);
|
||||||
|
|
||||||
const children = Array.from(this.querySelectorAll("*"));
|
window.addEventListener("hashchange", e => this._onHashChange());
|
||||||
const names = children.map(node => node.nodeName.toLowerCase())
|
this._onHashChange();
|
||||||
.filter(name => name.startsWith("cyp-"));
|
|
||||||
const unique = new Set(names);
|
|
||||||
|
|
||||||
const promises = [...unique].map(name => customElements.whenDefined(name));
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
|
await this._connect();
|
||||||
this.dispatchEvent(new CustomEvent("load"));
|
this.dispatchEvent(new CustomEvent("load"));
|
||||||
|
|
||||||
const onHashChange = () => {
|
|
||||||
const component = location.hash.substring(1) || "queue";
|
|
||||||
if (component != this.component) { this.component = component; }
|
|
||||||
}
|
|
||||||
window.addEventListener("hashchange", onHashChange);
|
|
||||||
onHashChange();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name, oldValue, newValue) {
|
attributeChangedCallback(name, oldValue, newValue) {
|
||||||
|
@ -62,6 +40,45 @@ class App extends HTMLElement {
|
||||||
|
|
||||||
get component() { return this.getAttribute("component"); }
|
get component() { return this.getAttribute("component"); }
|
||||||
set component(component) { this.setAttribute("component", component); }
|
set component(component) { this.setAttribute("component", component); }
|
||||||
|
|
||||||
|
_onHashChange() {
|
||||||
|
const component = location.hash.substring(1) || "queue";
|
||||||
|
if (component != this.component) { this.component = component; }
|
||||||
|
}
|
||||||
|
|
||||||
|
_onChange(changed) { this.dispatchEvent(new CustomEvent("idle-change", {detail:changed})); }
|
||||||
|
|
||||||
|
_onClose(e) {
|
||||||
|
setTimeout(() => this._connect(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _connect() {
|
||||||
|
const attempts = 3;
|
||||||
|
for (let i=0;i<attempts;i++) {
|
||||||
|
try {
|
||||||
|
let mpd = await MPD.connect();
|
||||||
|
mpd.onChange = changed => this._onChange(changed);
|
||||||
|
mpd.onClose = e => this._onClose(e);
|
||||||
|
this.mpd = mpd;
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert(`Failed to connect to MPD after ${attempts} attempts. Please reload the page to try again.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("cyp-app", App);
|
customElements.define("cyp-app", App);
|
||||||
|
|
||||||
|
function sleep(ms) { return new Promise(resolve =>setTimeout(resolve, ms)); }
|
||||||
|
|
||||||
|
function waitForChildren(app) {
|
||||||
|
const children = Array.from(app.querySelectorAll("*"));
|
||||||
|
const names = children.map(node => node.nodeName.toLowerCase())
|
||||||
|
.filter(name => name.startsWith("cyp-"));
|
||||||
|
const unique = new Set(names);
|
||||||
|
|
||||||
|
const promises = [...unique].map(name => customElements.whenDefined(name));
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
|
@ -1,19 +1,21 @@
|
||||||
import Component from "../component.js";
|
import Component from "../component.js";
|
||||||
|
|
||||||
class Menu extends Component {
|
class Menu extends Component {
|
||||||
_onAppLoad() {
|
connectedCallback() {
|
||||||
/** @type HTMLElement[] */
|
/** @type HTMLElement[] */
|
||||||
this._tabs = Array.from(this.querySelectorAll("[data-for]"));
|
this._tabs = Array.from(this.querySelectorAll("[data-for]"));
|
||||||
|
|
||||||
this._tabs.forEach(tab => {
|
this._tabs.forEach(tab => {
|
||||||
tab.addEventListener("click", _ => this._app.setAttribute("component", tab.dataset.for));
|
tab.addEventListener("click", _ => this._app.setAttribute("component", tab.dataset.for));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onAppLoad() {
|
||||||
this._app.addEventListener("queue-length-change", e => {
|
this._app.addEventListener("queue-length-change", e => {
|
||||||
this.querySelector(".queue-length").textContent = `(${e.detail})`;
|
this.querySelector(".queue-length").textContent = `(${e.detail})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_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);
|
||||||
|
|
302
app/js/mpd.js
302
app/js/mpd.js
|
@ -1,144 +1,170 @@
|
||||||
import * as parser from "./parser.js";
|
import * as parser from "./parser.js";
|
||||||
|
|
||||||
let ws, app;
|
|
||||||
let commandQueue = [];
|
|
||||||
let current;
|
|
||||||
let canTerminateIdle = false;
|
|
||||||
|
|
||||||
function onError(e) {
|
export default class MPD {
|
||||||
console.error(e);
|
static async connect() {
|
||||||
current && current.reject(e);
|
let response = await fetch("/ticket", {method:"POST"});
|
||||||
ws = null; // fixme
|
let ticket = (await response.json()).ticket;
|
||||||
}
|
|
||||||
|
|
||||||
function onClose(e) {
|
let ws = new WebSocket(createURL(ticket).href);
|
||||||
console.warn(e);
|
|
||||||
current && current.reject(e);
|
|
||||||
ws = null; // fixme
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMessage(e) {
|
return new Promise((resolve, reject) => {
|
||||||
if (!current) { return; }
|
let mpd;
|
||||||
|
let initialCommand = {resolve: () => resolve(mpd), reject};
|
||||||
let lines = JSON.parse(e.data);
|
mpd = new this(ws, initialCommand);
|
||||||
let last = lines.pop();
|
});
|
||||||
if (last.startsWith("OK")) {
|
|
||||||
current.resolve(lines);
|
|
||||||
} else {
|
|
||||||
console.warn(last);
|
|
||||||
current.reject(last);
|
|
||||||
}
|
}
|
||||||
current = null;
|
|
||||||
|
|
||||||
if (commandQueue.length > 0) {
|
constructor(/** @type WebSocket */ ws, initialCommand) {
|
||||||
advanceQueue();
|
this._ws = ws;
|
||||||
} else {
|
this._queue = [];
|
||||||
setTimeout(idle, 0); // only after resolution callbacks
|
this._current = initialCommand;
|
||||||
|
this._canTerminateIdle = false;
|
||||||
|
|
||||||
|
ws.addEventListener("message", e => this._onMessage(e));
|
||||||
|
ws.addEventListener("close", e => this._onClose(e));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function advanceQueue(){
|
onClose(_e) {}
|
||||||
current = commandQueue.shift();
|
onChange(_changed) {}
|
||||||
ws.send(current.cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function idle() {
|
command(cmd) {
|
||||||
if (current) { return; }
|
if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); }
|
||||||
|
|
||||||
canTerminateIdle = true;
|
return new Promise((resolve, reject) => {
|
||||||
let lines = await command("idle stored_playlist playlist player options mixer");
|
this._queue.push({cmd, resolve, reject});
|
||||||
canTerminateIdle = false;
|
|
||||||
let changed = parser.linesToStruct(lines).changed || [];
|
|
||||||
changed = [].concat(changed);
|
|
||||||
(changed.length > 0) && app.dispatchEvent(new CustomEvent("idle-change", {detail:changed}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function command(cmd) {
|
if (!this._current) {
|
||||||
if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); }
|
this._advanceQueue();
|
||||||
|
} else if (this._canTerminateIdle) {
|
||||||
|
this._ws.send("noidle");
|
||||||
|
this._canTerminateIdle = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
async status() {
|
||||||
commandQueue.push({cmd, resolve, reject});
|
let lines = await this.command("status");
|
||||||
|
return parser.linesToStruct(lines);
|
||||||
|
}
|
||||||
|
|
||||||
if (!current) {
|
async currentSong() {
|
||||||
advanceQueue();
|
let lines = await this.command("currentsong");
|
||||||
} else if (canTerminateIdle) {
|
return parser.linesToStruct(lines);
|
||||||
ws.send("noidle");
|
}
|
||||||
canTerminateIdle = false;
|
|
||||||
|
async listQueue() {
|
||||||
|
let lines = await this.command("playlistinfo");
|
||||||
|
return parser.songList(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPlaylists() {
|
||||||
|
let lines = await this.command("listplaylists");
|
||||||
|
let parsed = parser.linesToStruct(lines);
|
||||||
|
|
||||||
|
let list = parsed["playlist"];
|
||||||
|
if (!list) { return []; }
|
||||||
|
return (list instanceof Array ? list : [list]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPath(path) {
|
||||||
|
let lines = await this.command(`lsinfo "${escape(path)}"`);
|
||||||
|
return parser.pathContents(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTags(tag, filter = {}) {
|
||||||
|
let tokens = ["list", tag];
|
||||||
|
if (Object.keys(filter).length) {
|
||||||
|
tokens.push(serializeFilter(filter));
|
||||||
|
|
||||||
|
let fakeGroup = Object.keys(filter)[0]; // FIXME hack for MPD < 0.21.6
|
||||||
|
tokens.push("group", fakeGroup);
|
||||||
}
|
}
|
||||||
});
|
let lines = await this.command(tokens.join(" "));
|
||||||
}
|
let parsed = parser.linesToStruct(lines);
|
||||||
|
return [].concat(tag in parsed ? parsed[tag] : []);
|
||||||
export async function status() {
|
|
||||||
let lines = await command("status");
|
|
||||||
return parser.linesToStruct(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function currentSong() {
|
|
||||||
let lines = await command("currentsong");
|
|
||||||
return parser.linesToStruct(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listQueue() {
|
|
||||||
let lines = await command("playlistinfo");
|
|
||||||
return parser.songList(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPlaylists() {
|
|
||||||
let lines = await command("listplaylists");
|
|
||||||
let parsed = parser.linesToStruct(lines);
|
|
||||||
|
|
||||||
let list = parsed["playlist"];
|
|
||||||
if (!list) { return []; }
|
|
||||||
return (list instanceof Array ? list : [list]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPath(path) {
|
|
||||||
let lines = await command(`lsinfo "${escape(path)}"`);
|
|
||||||
return parser.pathContents(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTags(tag, filter = {}) {
|
|
||||||
let tokens = ["list", tag];
|
|
||||||
if (Object.keys(filter).length) {
|
|
||||||
tokens.push(serializeFilter(filter));
|
|
||||||
|
|
||||||
let fakeGroup = Object.keys(filter)[0]; // FIXME hack for MPD < 0.21.6
|
|
||||||
tokens.push("group", fakeGroup);
|
|
||||||
}
|
}
|
||||||
let lines = await command(tokens.join(" "));
|
|
||||||
let parsed = parser.linesToStruct(lines);
|
|
||||||
return [].concat(tag in parsed ? parsed[tag] : []);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listSongs(filter, window = null) {
|
async listSongs(filter, window = null) {
|
||||||
let tokens = ["find", serializeFilter(filter)];
|
let tokens = ["find", serializeFilter(filter)];
|
||||||
window && tokens.push("window", window.join(":"));
|
window && tokens.push("window", window.join(":"));
|
||||||
let lines = await command(tokens.join(" "));
|
let lines = await this.command(tokens.join(" "));
|
||||||
return parser.songList(lines);
|
return parser.songList(lines);
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchSongs(filter) {
|
|
||||||
let tokens = ["search", serializeFilter(filter, "contains")];
|
|
||||||
let lines = await command(tokens.join(" "));
|
|
||||||
return parser.songList(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function albumArt(songUrl) {
|
|
||||||
let data = [];
|
|
||||||
let offset = 0;
|
|
||||||
let params = ["albumart", `"${escape(songUrl)}"`, offset];
|
|
||||||
|
|
||||||
while (1) {
|
|
||||||
params[2] = offset;
|
|
||||||
try {
|
|
||||||
let lines = await command(params.join(" "));
|
|
||||||
data = data.concat(lines[2]);
|
|
||||||
let metadata = parser.linesToStruct(lines.slice(0, 2));
|
|
||||||
if (data.length >= Number(metadata["size"])) { return data; }
|
|
||||||
offset += Number(metadata["binary"]);
|
|
||||||
} catch (e) { return null; }
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
async searchSongs(filter) {
|
||||||
|
let tokens = ["search", serializeFilter(filter, "contains")];
|
||||||
|
let lines = await this.command(tokens.join(" "));
|
||||||
|
return parser.songList(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
async albumArt(songUrl) {
|
||||||
|
let data = [];
|
||||||
|
let offset = 0;
|
||||||
|
let params = ["albumart", `"${escape(songUrl)}"`, offset];
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
params[2] = offset;
|
||||||
|
try {
|
||||||
|
let lines = await this.command(params.join(" "));
|
||||||
|
data = data.concat(lines[2]);
|
||||||
|
let metadata = parser.linesToStruct(lines.slice(0, 2));
|
||||||
|
if (data.length >= Number(metadata["size"])) { return data; }
|
||||||
|
offset += Number(metadata["binary"]);
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
escape(...args) { return escape(...args); }
|
||||||
|
|
||||||
|
_onMessage(e) {
|
||||||
|
if (!this._current) { return; }
|
||||||
|
|
||||||
|
let lines = JSON.parse(e.data);
|
||||||
|
let last = lines.pop();
|
||||||
|
if (last.startsWith("OK")) {
|
||||||
|
this._current.resolve(lines);
|
||||||
|
} else {
|
||||||
|
console.warn(last);
|
||||||
|
this._current.reject(last);
|
||||||
|
}
|
||||||
|
this._current = null;
|
||||||
|
|
||||||
|
if (this._queue.length > 0) {
|
||||||
|
this._advanceQueue();
|
||||||
|
} else {
|
||||||
|
setTimeout(() => this._idle(), 0); // only after resolution callbacks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onClose(e) {
|
||||||
|
console.warn(e);
|
||||||
|
this._current && this._current.reject(e);
|
||||||
|
this._ws = null;
|
||||||
|
this.onClose(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
_advanceQueue() {
|
||||||
|
this._current = this._queue.shift();
|
||||||
|
this._ws.send(this._current.cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _idle() {
|
||||||
|
if (this._current) { return; }
|
||||||
|
|
||||||
|
this._canTerminateIdle = true;
|
||||||
|
let lines = await this.command("idle stored_playlist playlist player options mixer");
|
||||||
|
this._canTerminateIdle = false;
|
||||||
|
let changed = parser.linesToStruct(lines).changed || [];
|
||||||
|
changed = [].concat(changed);
|
||||||
|
(changed.length > 0) && this.onChange(changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function escape(str) {
|
||||||
|
return str.replace(/(['"\\])/g, "\\$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeFilter(filter, operator = "==") {
|
export function serializeFilter(filter, operator = "==") {
|
||||||
|
@ -153,28 +179,10 @@ export function serializeFilter(filter, operator = "==") {
|
||||||
return `"${escape(filterStr)}"`;
|
return `"${escape(filterStr)}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escape(str) {
|
function createURL(ticket) {
|
||||||
return str.replace(/(['"\\])/g, "\\$1");
|
let url = new URL(location.href);
|
||||||
}
|
url.protocol = "ws";
|
||||||
|
url.hash = "";
|
||||||
export async function init(a) {
|
url.searchParams.set("ticket", ticket);
|
||||||
app = a;
|
return url;
|
||||||
let response = await fetch("/ticket", {method:"POST"});
|
|
||||||
let ticket = (await response.json()).ticket;
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
current = {resolve, reject};
|
|
||||||
|
|
||||||
try {
|
|
||||||
let url = new URL(location.href);
|
|
||||||
url.protocol = "ws";
|
|
||||||
url.hash = "";
|
|
||||||
url.searchParams.set("ticket", ticket);
|
|
||||||
ws = new WebSocket(url.href);
|
|
||||||
} catch (e) { reject(e); }
|
|
||||||
|
|
||||||
ws.addEventListener("error", onError);
|
|
||||||
ws.addEventListener("message", onMessage);
|
|
||||||
ws.addEventListener("close", onClose);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue