diff --git a/Makefile b/Makefile
index 5e35d38..c80c434 100644
--- a/Makefile
+++ b/Makefile
@@ -1,17 +1,22 @@
LESS := $(shell npm bin)/lessc
+ROLLUP := $(shell npm bin)/rollup
APP := app
CSS := $(APP)/cyp.css
+JS := $(APP)/cyp.js
ICONS := $(APP)/js/icons.js
SYSD_USER := ~/.config/systemd/user
SERVICE := cyp.service
-all: $(CSS)
+all: $(CSS) $(JS)
icons: $(ICONS)
$(ICONS): $(APP)/icons/*
$(APP)/svg2js.sh $(APP)/icons > $@
+$(JS): $(APP)/js/* $(APP)/js/elements/*
+ $(ROLLUP) -i $(APP)/js/cyp.js > $@
+
$(CSS): $(APP)/css/* $(APP)/css/elements/*
$(LESS) -x $(APP)/css/cyp.less > $@
@@ -25,7 +30,7 @@ watch: all
while inotifywait -e MODIFY -r $(APP)/css $(APP)/js ; do make $^ ; done
clean:
- rm -f $(SERVICE) $(CSS)
+ rm -f $(SERVICE) $(CSS) $(JS)
docker-image:
docker build -t cyp .
diff --git a/app/cyp.js b/app/cyp.js
new file mode 100644
index 0000000..114fd89
--- /dev/null
+++ b/app/cyp.js
@@ -0,0 +1,1741 @@
+class Range extends HTMLElement {
+ static get observedAttributes() { return ["min", "max", "value", "step", "disabled"]; }
+
+ constructor() {
+ super();
+
+ this._dom = {};
+
+ this.addEventListener("mousedown", this);
+ this.addEventListener("keydown", this);
+ }
+
+ get _valueAsNumber() {
+ let raw = (this.hasAttribute("value") ? Number(this.getAttribute("value")) : 50);
+ return this._constrain(raw);
+ }
+ get _minAsNumber() {
+ return (this.hasAttribute("min") ? Number(this.getAttribute("min")) : 0);
+ }
+ get _maxAsNumber() {
+ return (this.hasAttribute("max") ? Number(this.getAttribute("max")) : 100);
+ }
+ get _stepAsNumber() {
+ return (this.hasAttribute("step") ? Number(this.getAttribute("step")) : 1);
+ }
+
+ get value() { return String(this._valueAsNumber); }
+ get valueAsNumber() { return this._valueAsNumber; }
+ get min() { return this.hasAttribute("min") ? this.getAttribute("min") : ""; }
+ get max() { return this.hasAttribute("max") ? this.getAttribute("max") : ""; }
+ get step() { return this.hasAttribute("step") ? this.getAttribute("step") : ""; }
+ get disabled() { return this.hasAttribute("disabled"); }
+
+ set _valueAsNumber(value) { this.value = String(value); }
+ set min(min) { this.setAttribute("min", min); }
+ set max(max) { this.setAttribute("max", max); }
+ set value(value) { this.setAttribute("value", value); }
+ set step(step) { this.setAttribute("step", step); }
+ set disabled(disabled) {
+ disabled ? this.setAttribute("disabled", "") : this.removeAttribute("disabled");
+ }
+
+ connectedCallback() {
+ if (this.firstChild) { return; }
+
+ this.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ Array.from(this.querySelectorAll("[class^='-']")).forEach(node => {
+ let name = node.className.substring(1);
+ this._dom[name] = node;
+ });
+
+ this._update();
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ switch (name) {
+ case "min":
+ case "max":
+ case "value":
+ case "step":
+ this._update();
+ break;
+ }
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "mousedown":
+ if (this.disabled) { return; }
+ document.addEventListener("mousemove", this);
+ document.addEventListener("mouseup", this);
+ this._setToMouse(e);
+ break;
+
+ case "mousemove":
+ this._setToMouse(e);
+ break;
+
+ case "mouseup":
+ document.removeEventListener("mousemove", this);
+ document.removeEventListener("mouseup", this);
+ this.dispatchEvent(new CustomEvent("change"));
+ break;
+
+ case "keydown":
+ if (this.disabled) { return; }
+ this._handleKey(e.code);
+ this.dispatchEvent(new CustomEvent("input"));
+ this.dispatchEvent(new CustomEvent("change"));
+ break;
+ }
+ }
+
+ _handleKey(code) {
+ let min = this._minAsNumber;
+ let max = this._maxAsNumber;
+ let range = max - min;
+ let step = this._stepAsNumber;
+
+ switch (code) {
+ case "ArrowLeft":
+ case "ArrowDown":
+ this._valueAsNumber = this._constrain(this._valueAsNumber - step);
+ break;
+
+ case "ArrowRight":
+ case "ArrowUp":
+ this._valueAsNumber = this._constrain(this._valueAsNumber + step);
+ break;
+
+ case "Home": this._valueAsNumber = this._constrain(min); break;
+ case "End": this._valueAsNumber = this._constrain(max); break;
+
+ case "PageUp": this._valueAsNumber = this._constrain(this._valueAsNumber + range/10); break;
+ case "PageDown": this._valueAsNumber = this._constrain(this._valueAsNumber - range/10); break;
+ }
+ }
+
+ _constrain(value) {
+ const min = this._minAsNumber;
+ const max = this._maxAsNumber;
+ const step = this._stepAsNumber;
+
+ value = Math.max(value, min);
+ value = Math.min(value, max);
+
+ value -= min;
+ value = Math.round(value / step) * step;
+ value += min;
+ if (value > max) { value -= step; }
+
+ return value;
+ }
+
+ _update() {
+ let min = this._minAsNumber;
+ let max = this._maxAsNumber;
+ let frac = (this._valueAsNumber-min) / (max-min);
+ this._dom.thumb.style.left = `${frac * 100}%`;
+ this._dom.remaining.style.left = `${frac * 100}%`;
+ this._dom.elapsed.style.width = `${frac * 100}%`;
+ }
+
+ _setToMouse(e) {
+ let rect = this._dom.inner.getBoundingClientRect();
+ let x = e.clientX;
+ x = Math.max(x, rect.left);
+ x = Math.min(x, rect.right);
+
+ let min = this._minAsNumber;
+ let max = this._maxAsNumber;
+
+ let frac = (x-rect.left) / (rect.right-rect.left);
+ let value = this._constrain(min + frac * (max-min));
+ if (value == this._valueAsNumber) { return; }
+
+ this._valueAsNumber = value;
+ this.dispatchEvent(new CustomEvent("input"));
+ }
+}
+
+customElements.define('x-range', Range);
+
+function linesToStruct(lines) {
+ let result = {};
+ lines.forEach(line => {
+ let cindex = line.indexOf(":");
+ if (cindex == -1) { throw new Error(`Malformed line "${line}"`); }
+ let key = line.substring(0, cindex);
+ let value = line.substring(cindex+2);
+ if (key in result) {
+ let old = result[key];
+ if (old instanceof Array) {
+ old.push(value);
+ } else {
+ result[key] = [old, value];
+ }
+ } else {
+ result[key] = value;
+ }
+ });
+ return result;
+}
+
+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 = [];
+ }
+ batch.push(lines.shift());
+ }
+
+ if (batch.length) {
+ let song = linesToStruct(batch);
+ songs.push(song);
+ }
+
+ return songs;
+}
+
+function pathContents(lines) {
+ const prefixes = ["file", "directory", "playlist"];
+
+ let batch = [];
+ let result = {};
+ let batchPrefix = null;
+ prefixes.forEach(prefix => result[prefix] = []);
+
+ while (lines.length) {
+ let line = lines[0];
+ let prefix = line.split(":")[0];
+ if (prefixes.includes(prefix)) { // begin of a new batch
+ if (batch.length) { result[batchPrefix].push(linesToStruct(batch)); }
+ batchPrefix = prefix;
+ batch = [];
+ }
+ batch.push(lines.shift());
+ }
+
+ if (batch.length) { result[batchPrefix].push(linesToStruct(batch)); }
+
+ return result;
+}
+
+let ws;
+let commandQueue = [];
+let current;
+
+function onMessage(e) {
+ if (current) {
+ 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;
+ }
+ processQueue();
+}
+
+function onError(e) {
+ console.error(e);
+ current && current.reject(e);
+ ws = null; // fixme
+}
+
+function onClose(e) {
+ console.warn(e);
+ current && current.reject(e);
+ ws = null; // fixme
+}
+
+function processQueue() {
+ if (current || commandQueue.length == 0) { return; }
+ current = commandQueue.shift();
+ ws.send(current.cmd);
+}
+
+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 command(cmd) {
+ if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); }
+
+ return new Promise((resolve, reject) => {
+ commandQueue.push({cmd, resolve, reject});
+ processQueue();
+ });
+}
+
+async function commandAndStatus(cmd) {
+ let lines = await command([cmd, "status", "currentsong"]);
+ let status = linesToStruct(lines);
+ if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
+ return status;
+}
+
+async function status() {
+ let lines = await command(["status", "currentsong"]);
+ let status = linesToStruct(lines);
+ if (status["duration"] instanceof Array) { status["duration"] = status["duration"][0]; }
+ return status;
+}
+
+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) {
+ let tokens = ["find", serializeFilter(filter)];
+ window && tokens.push("window", window.join(":"));
+ let lines = await command(tokens.join(" "));
+ 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 function init() {
+ let response = await fetch("/ticket", {method:"POST"});
+ let ticket = (await response.json()).ticket;
+
+ return new Promise((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); }
+ current = {resolve, reject};
+
+ ws.addEventListener("error", onError);
+ ws.addEventListener("message", onMessage);
+ ws.addEventListener("close", onClose);
+ });
+}
+
+var mpd = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ serializeFilter: serializeFilter,
+ escape: escape,
+ command: command,
+ commandAndStatus: commandAndStatus,
+ status: status,
+ listQueue: listQueue,
+ listPlaylists: listPlaylists,
+ listPath: listPath,
+ listTags: listTags,
+ listSongs: listSongs,
+ searchSongs: searchSongs,
+ albumArt: albumArt,
+ init: init
+});
+
+function command$1(cmd) {
+ console.warn(`mpd-mock does not know "${cmd}"`);
+}
+
+function commandAndStatus$1(cmd) {
+ command$1(cmd);
+ return status$1();
+}
+
+function status$1() {
+ return {
+ volume: 50,
+ elapsed: 10,
+ duration: 70,
+ file: "name.mp3",
+ Title: "Title of song",
+ Artist: "Artist of song",
+ Album: "Album of song",
+ Track: "6",
+ state: "play",
+ Id: 2
+ }
+}
+
+function listQueue$1() {
+ return [
+ {Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30},
+ status$1(),
+ {Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230},
+ ];
+}
+
+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 null;
+}
+
+function init$1() {}
+
+var mpdMock = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ command: command$1,
+ commandAndStatus: commandAndStatus$1,
+ status: status$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={};
+ICONS["library-music"] = ``;
+ICONS["plus"] = ``;
+ICONS["folder"] = ``;
+ICONS["playlist-music"] = ``;
+ICONS["settings"] = ``;
+ICONS["pause"] = ``;
+ICONS["artist"] = ``;
+ICONS["volume-off"] = ``;
+ICONS["keyboard-backspace"] = ``;
+ICONS["cancel"] = ``;
+ICONS["fast-forward"] = ``;
+ICONS["delete"] = ``;
+ICONS["volume-high"] = ``;
+ICONS["minus"] = ``;
+ICONS["play"] = ``;
+ICONS["magnify"] = ``;
+ICONS["arrow-down-bold"] = ``;
+ICONS["music"] = ``;
+ICONS["rewind"] = ``;
+ICONS["album"] = ``;
+ICONS["download"] = ``;
+ICONS["account-multiple"] = ``;
+ICONS["close"] = ``;
+ICONS["content-save"] = ``;
+ICONS["arrow-up-bold"] = ``;
+ICONS["chevron-double-right"] = ``;
+ICONS["shuffle"] = ``;
+ICONS["checkbox-marked-outline"] = ``;
+ICONS["repeat"] = ``;
+
+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;
+}
+
+function icon(type, parent) {
+ let str = ICONS[type];
+ if (!str) {
+ console.error("Bad icon type '%s'", type);
+ return node("span", {}, "‽");
+ }
+
+ let tmp = node("div");
+ tmp.innerHTML = str;
+ let s = tmp.querySelector("svg");
+ if (!s) { throw new Error(`Bad icon source for type '${type}'`); }
+
+ s.classList.add("icon");
+ s.classList.add(`icon-${type}`);
+
+ parent && parent.appendChild(s);
+ return s;
+}
+
+function button(attrs, content, parent) {
+ let result = node("button", attrs, content, parent);
+ if (attrs && attrs.icon) {
+ let i = icon(attrs.icon);
+ result.insertBefore(i, result.firstChild);
+ }
+ return result;
+}
+
+function clear(node) {
+ while (node.firstChild) { node.firstChild.parentNode.removeChild(node.firstChild); }
+ return node;
+}
+
+function text(txt, parent) {
+ let n = document.createTextNode(txt);
+ parent && parent.appendChild(n);
+ return n;
+}
+
+function initIcons() {
+ Array.from(document.querySelectorAll("[data-icon]")).forEach(/** @param {HTMLElement} node */ node => {
+ let icon$1 = icon(node.dataset.icon);
+ node.insertBefore(icon$1, node.firstChild);
+ });
+}
+
+async function initMpd() {
+ try {
+ await init();
+ return mpd;
+ } catch (e) {
+ console.error(e);
+ return mpdMock;
+ }
+}
+
+class App extends HTMLElement {
+ static get observedAttributes() { return ["component"]; }
+
+ constructor() {
+ super();
+
+ initIcons();
+ }
+
+ async connectedCallback() {
+ this.mpd = await initMpd();
+
+ const children = Array.from(this.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));
+ await Promise.all(promises);
+
+ 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) {
+ switch (name) {
+ case "component":
+ location.hash = newValue;
+ const e = new CustomEvent("component-change");
+ this.dispatchEvent(e);
+ break;
+ }
+ }
+
+ get component() { return this.getAttribute("component"); }
+ set component(component) { return this.setAttribute("component", component); }
+}
+
+customElements.define("cyp-app", App);
+
+const TAGS = ["cyp-song", "cyp-tag", "cyp-path"];
+
+class Selection {
+ constructor(component, mode) {
+ this._component = component;
+ /** @type {"single" | "multi"} */
+ this._mode = mode;
+ this._items = []; // FIXME ukladat skutecne HTML? co kdyz nastane refresh?
+ this._node = node("cyp-commands", {hidden:true});
+ }
+
+ clear() {
+ while (this._items.length) { this.remove(this._items[0]); }
+ }
+
+ addCommand(cb, options) {
+ const button$1 = button({icon:options.icon}, "", this._node);
+ node("span", {}, options.label, button$1);
+ button$1.addEventListener("click", _ => {
+ const arg = (this._mode == "single" ? this._items[0] : this._items);
+ cb(arg);
+ });
+ return button$1;
+ }
+
+ addCommandAll() {
+ this.addCommand(_ => {
+ Array.from(this._component.querySelectorAll(TAGS.join(", ")))
+ .forEach(node => this.add(node));
+ }, {label:"Select all", icon:"checkbox-marked-outline"});
+ }
+
+ addCommandCancel() {
+ const button = this.addCommand(_ => this.clear(), {icon:"cancel", label:"Cancel"});
+ button.classList.add("last");
+ return button;
+ }
+
+ toggle(node) {
+ if (this._items.includes(node)) {
+ this.remove(node);
+ } else {
+ this.add(node);
+ }
+ }
+
+ add(node) {
+ if (this._items.includes(node)) { return; }
+ const length = this._items.length;
+ this._items.push(node);
+ node.classList.add("selected");
+
+ if (this._mode == "single" && length > 0) { this.remove(this._items[0]); }
+
+ if (length == 0) { this._show(); }
+ }
+
+ remove(node) {
+ const index = this._items.indexOf(node);
+ this._items.splice(index, 1);
+ node.classList.remove("selected");
+ if (this._items.length == 0) { this._hide(); }
+ }
+
+ _show() {
+ const parent = this._component.closest("cyp-app").querySelector("footer"); // FIXME jde lepe?
+ parent.appendChild(this._node);
+ this._node.offsetWidth; // FIXME jde lepe?
+ this._node.hidden = false;
+ }
+
+ _hide() {
+ this._node.hidden = true;
+ this._node.remove();
+ }
+}
+
+class HasApp extends HTMLElement {
+ get _app() { return this.closest("cyp-app"); }
+ get _mpd() { return this._app.mpd; }
+}
+
+class Component extends HasApp {
+ constructor(options = {}) {
+ super();
+ if (options.selection) { this.selection = new Selection(this, options.selection); }
+ }
+
+ connectedCallback() {
+ this._app.addEventListener("load", _ => this._onAppLoad());
+ this._app.addEventListener("component-change", _ => {
+ const component = this._app.component;
+ const isThis = (this.nodeName.toLowerCase() == `cyp-${component}`);
+ this._onComponentChange(component, isThis);
+ });
+ }
+
+ _onAppLoad() {}
+ _onComponentChange(_component, _isThis) {}
+}
+
+class Menu extends Component {
+ constructor() {
+ super();
+
+ this._tabs = Array.from(this.querySelectorAll("[data-for]"));
+ this._tabs.forEach(tab => {
+ tab.addEventListener("click", _ => this._activate(tab.dataset.for));
+ });
+ }
+
+ async _activate(component) {
+ const app = await this._app;
+ app.setAttribute("component", component);
+ }
+
+ _onComponentChange(component) {
+ this._tabs.forEach(/** @param {HTMLElement} tab */ tab => {
+ tab.classList.toggle("active", tab.dataset.for == component);
+ });
+ }
+}
+
+customElements.define("cyp-menu", Menu);
+
+const artSize = 96;
+const ytPath = "_youtube";
+
+const cache = {};
+const MIME = "image/jpeg";
+const STORAGE_PREFIX = `art-${artSize}` ;
+
+function store(key, data) {
+ localStorage.setItem(`${STORAGE_PREFIX}-${key}`, data);
+}
+
+function load(key) {
+ return localStorage.getItem(`${STORAGE_PREFIX}-${key}`);
+}
+
+async function bytesToImage(bytes) {
+ const blob = new Blob([bytes]);
+ const src = URL.createObjectURL(blob);
+ const image = node("img", {src});
+ return new Promise(resolve => {
+ image.onload = () => resolve(image);
+ });
+}
+
+function resize(image) {
+ const canvas = node("canvas", {width:artSize, height:artSize});
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
+ return canvas;
+}
+
+async function get(mpd, artist, album, songUrl = null) {
+ const key = `${artist}-${album}`;
+ if (key in cache) { return cache[key]; }
+
+ const loaded = await load(key);
+ if (loaded) {
+ cache[key] = loaded;
+ return loaded;
+ }
+
+ if (!songUrl) { return null; }
+
+ // promise to be returned in the meantime
+ let resolve;
+ const promise = new Promise(res => resolve = res);
+ cache[key] = promise;
+
+ const data = await mpd.albumArt(songUrl);
+ if (data) {
+ const bytes = new Uint8Array(data);
+ const image = await bytesToImage(bytes);
+ const url = resize(image).toDataURL(MIME);
+ store(key, url);
+ cache[key] = url;
+ resolve(url);
+ } else {
+ cache[key] = null;
+ }
+ return cache[key];
+}
+
+const SEPARATOR = " · ";
+
+function time(sec) {
+ sec = Math.round(sec);
+ let m = Math.floor(sec / 60);
+ let s = sec % 60;
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
+
+function subtitle(data, options = {duration:true}) {
+ let tokens = [];
+ data["Artist"] && tokens.push(data["Artist"]);
+ data["Album"] && tokens.push(data["Album"]);
+ options.duration && data["duration"] && tokens.push(time(Number(data["duration"])));
+ return tokens.join(SEPARATOR);
+}
+
+function fileName(file) {
+ return file.split("/").pop();
+}
+
+const DELAY = 1000;
+
+class Player extends Component {
+ constructor() {
+ super();
+ this._current = {};
+ this._toggledVolume = 0;
+ this._idleTimeout = null;
+ this._dom = this._initDOM();
+ }
+
+ async update() {
+ this._clearIdle();
+ const data = await this._mpd.status();
+ this._sync(data);
+ this._idle();
+ }
+
+ _onAppLoad() {
+ this.update();
+ }
+
+ _initDOM() {
+ const DOM = {};
+ 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;
+ if ("volume" in data) {
+ data["volume"] = Number(data["volume"]);
+
+ DOM.mute.disabled = false;
+ DOM.volume.disabled = false;
+ DOM.volume.value = data["volume"];
+
+ if (data["volume"] == 0 && this._current["volume"] > 0) { // muted
+ this._toggledVolume = this._current["volume"];
+ clear(DOM.mute);
+ DOM.mute.appendChild(icon("volume-off"));
+ }
+
+ if (data["volume"] > 0 && this._current["volume"] == 0) { // restored
+ this._toggledVolume = 0;
+ clear(DOM.mute);
+ DOM.mute.appendChild(icon("volume-high"));
+ }
+
+ } else {
+ DOM.mute.disabled = true;
+ DOM.volume.disabled = true;
+ DOM.volume.value = 50;
+ }
+
+ // changed time
+ let elapsed = Number(data["elapsed"] || 0);
+ DOM.progress.value = elapsed;
+ DOM.elapsed.textContent = time(elapsed);
+
+ if (data["file"] != this._current["file"]) { // changed song
+ if (data["file"]) { // playing at all?
+ let duration = Number(data["duration"]);
+ DOM.duration.textContent = time(duration);
+ DOM.progress.max = duration;
+ DOM.progress.disabled = false;
+ DOM.title.textContent = data["Title"] || fileName(data["file"]);
+ DOM.subtitle.textContent = subtitle(data, {duration:false});
+ } else {
+ DOM.title.textContent = "";
+ DOM.subtitle.textContent = "";
+ DOM.progress.value = 0;
+ DOM.progress.disabled = true;
+ }
+
+ this._dispatchSongChange(data);
+ }
+
+ let artistNew = data["AlbumArtist"] || data["Artist"];
+ let artistOld = this._current["AlbumArtist"] || this._current["Artist"];
+
+ if (artistNew != artistOld || data["Album"] != this._current["Album"]) { // changed album (art)
+ clear(DOM.art);
+ get(this._mpd, artistNew, data["Album"], data["file"]).then(src => {
+ if (src) {
+ node("img", {src}, "", DOM.art);
+ } else {
+ icon("music", DOM.art);
+ }
+ });
+ }
+
+ let flags = [];
+ if (data["random"] == "1") { flags.push("random"); }
+ if (data["repeat"] == "1") { flags.push("repeat"); }
+ this.dataset.flags = flags.join(" ");
+ this.dataset.state = data["state"];
+
+ this._current = data;
+ }
+
+ _dispatchSongChange(detail) {
+ const e = new CustomEvent("song-change", {detail});
+ this._app.dispatchEvent(e);
+ }
+}
+
+customElements.define("cyp-player", Player);
+
+class Item extends HasApp {
+ constructor() {
+ super();
+ this.addEventListener("click", _ => this.onClick());
+ }
+
+ addButton(icon, cb) {
+ button({icon}, "", this).addEventListener("click", e => {
+ e.stopPropagation(); // do not select
+ cb();
+ });
+ }
+
+ onClick() { this.parentNode.selection.toggle(this); }
+
+ _buildTitle(title) {
+ return node("span", {className:"title"}, title, this);
+ }
+}
+
+class Song extends Item {
+ constructor(data) {
+ super();
+ this._data = data;
+ }
+
+ get file() { return this._data["file"]; }
+ get songId() { return this._data["Id"]; }
+
+ connectedCallback() {
+ const data = this._data;
+
+ const block = node("div", {className:"multiline"}, "", this);
+
+ const title = this._buildTitle(data);
+ block.appendChild(title);
+ if (data["Track"]) {
+ const track = node("span", {className:"track"}, data["Track"].padStart(2, "0"));
+ title.insertBefore(text(" "), title.firstChild);
+ title.insertBefore(track, title.firstChild);
+ }
+
+ if (data["Title"]) {
+ const subtitle$1 = subtitle(data);
+ node("span", {className:"subtitle"}, subtitle$1, block);
+ }
+ }
+
+ _buildTitle(data) {
+ return super._buildTitle(data["Title"] || fileName(this.file));
+ }
+}
+
+customElements.define("cyp-song", Song);
+
+function generateMoveCommands(items, diff, all) {
+ const COMPARE = (a, b) => all.indexOf(a) - all.indexOf(b);
+
+ return items.sort(COMPARE)
+ .map(item => {
+ let index = all.indexOf(item) + diff;
+ if (index < 0 || index >= all.length) { return null; } // this does not move
+ return `moveid ${item.songId} ${index}`;
+ })
+ .filter(command => command);
+}
+
+class Queue extends Component {
+ constructor() {
+ super({selection:"multi"});
+ this._currentId = null;
+ this._initCommands();
+ }
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "song-change":
+ this._currentId = e.detail["Id"];
+ this._updateCurrent();
+ break;
+
+ case "queue-change":
+ this._sync();
+ break;
+ }
+ }
+
+ _onAppLoad() {
+ this._app.addEventListener("song-change", this);
+ this._app.addEventListener("queue-change", this);
+ this._sync();
+ }
+
+ _onComponentChange(c, isThis) {
+ this.hidden = !isThis;
+
+ isThis && this._sync();
+ }
+
+ async _sync() {
+ let songs = await this._mpd.listQueue();
+ this._buildSongs(songs);
+
+ // FIXME pubsub?
+ document.querySelector("#queue-length").textContent = `(${songs.length})`;
+ }
+
+ _updateCurrent() {
+ Array.from(this.children).forEach(/** @param {HTMLElement} node */ node => {
+ node.classList.toggle("current", node.songId == this._currentId);
+ });
+ }
+
+ _buildSongs(songs) {
+ clear(this);
+ this.selection.clear();
+
+ songs.forEach(song => {
+ const node = new Song(song);
+ this.appendChild(node);
+
+ node.addButton("play", async _ => {
+ await this._mpd.command(`playid ${node.songId}`);
+ });
+ });
+
+ this._updateCurrent();
+ }
+
+ _initCommands() {
+ const sel = this.selection;
+
+ sel.addCommandAll();
+
+ sel.addCommand(async items => {
+ const commands = generateMoveCommands(items, -1, Array.from(this.children));
+ await this._mpd.command(commands);
+ this._sync();
+ }, {label:"Up", icon:"arrow-up-bold"});
+
+ sel.addCommand(async items => {
+ const commands = generateMoveCommands(items, +1, Array.from(this.children));
+ await this._mpd.command(commands.reverse()); // move last first
+ this._sync();
+ }, {label:"Down", icon:"arrow-down-bold"});
+
+ sel.addCommand(async items => {
+ let name = prompt("Save selected songs as a playlist?", "name");
+ if (name === null) { return; }
+
+ name = escape(name);
+ const commands = items.map(item => {
+ return `playlistadd "${name}" "${escape(item.file)}"`;
+ });
+
+ await this._mpd.command(commands); // FIXME notify?
+ }, {label:"Save", icon:"content-save"});
+
+ sel.addCommand(async items => {
+ if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; }
+
+ const commands = items.map(item => `deleteid ${item.songId}`);
+ await this._mpd.command(commands);
+
+ this._sync();
+ }, {label:"Remove", icon:"delete"});
+
+ sel.addCommandCancel();
+ }
+}
+
+customElements.define("cyp-queue", Queue);
+
+class Playlist extends Item {
+ constructor(name) {
+ super();
+ this.name = name;
+ }
+
+ connectedCallback() {
+ icon("playlist-music", this);
+ this._buildTitle(this.name);
+ }
+}
+
+customElements.define("cyp-playlist", Playlist);
+
+class Playlists extends Component {
+ constructor() {
+ super({selection:"single"});
+ this._initCommands();
+ }
+
+ _onComponentChange(c, isThis) {
+ this.hidden = !isThis;
+ if (isThis) { this._sync(); }
+ }
+
+ async _sync() {
+ let lists = await this._mpd.listPlaylists();
+ this._buildLists(lists);
+ }
+
+ _buildLists(lists) {
+ clear(this);
+ this.selection.clear();
+
+ lists.forEach(name => this.appendChild(new Playlist(name)));
+ }
+
+ _initCommands() {
+ const sel = this.selection;
+
+ sel.addCommand(async item => {
+ const name = item.name;
+ const commands = ["clear", `load "${escape(name)}"`, "play"];
+ await this._mpd.command(commands);
+ this.selection.clear();
+ this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
+ }, {label:"Play", icon:"play"});
+
+ sel.addCommand(async item => {
+ const name = item.name;
+ await this._mpd.command(`load "${escape(name)}"`);
+ this.selection.clear();
+ this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
+ }, {label:"Enqueue", icon:"plus"});
+
+ sel.addCommand(async item => {
+ const name = item.name;
+ if (!confirm(`Really delete playlist '${name}'?`)) { return; }
+
+ await this._mpd.command(`rm "${escape(name)}"`);
+ this._sync();
+ }, {label:"Delete", icon:"delete"});
+
+ sel.addCommandCancel();
+ }
+}
+
+customElements.define("cyp-playlists", Playlists);
+
+const prefix = "cyp";
+
+function loadFromStorage(key) {
+ return localStorage.getItem(`${prefix}-${key}`);
+}
+
+function saveToStorage(key, value) {
+ return localStorage.setItem(`${prefix}-${key}`, value);
+}
+
+class Settings extends Component {
+ constructor() {
+ super();
+ this._inputs = {
+ theme: this.querySelector("[name=theme]"),
+ color: Array.from(this.querySelectorAll("[name=color]"))
+ };
+ }
+
+ _onAppLoad() {
+ let mo = new MutationObserver(mrs => {
+ mrs.forEach(mr => this._onAppAttributeChange(mr));
+ });
+ mo.observe(this._app, {attributes:true});
+
+ this._inputs.theme.addEventListener("change", e => this._setTheme(e.target.value));
+ this._inputs.color.forEach(input => {
+ input.addEventListener("click", e => this._setColor(e.target.value));
+ });
+
+ const theme = loadFromStorage("theme");
+ (theme ? this._app.setAttribute("theme", theme) : this._syncTheme());
+
+ const color = loadFromStorage("color");
+ (color ? this._app.setAttribute("color", color) : this._syncColor());
+ }
+
+ _onAppAttributeChange(mr) {
+ if (mr.attributeName == "theme") { this._syncTheme(); }
+ if (mr.attributeName == "color") { this._syncColor(); }
+ }
+
+ _syncTheme() {
+ this._inputs.theme.value = this._app.getAttribute("theme");
+ }
+
+ _syncColor() {
+ this._inputs.color.forEach(input => {
+ input.checked = (input.value == this._app.getAttribute("color"));
+ input.parentNode.style.color = input.value;
+ });
+ }
+
+ _setTheme(theme) {
+ saveToStorage("theme", theme);
+ this._app.setAttribute("theme", theme);
+ }
+
+ _setColor(color) {
+ saveToStorage("color", color);
+ this._app.setAttribute("color", color);
+ }
+
+ _onComponentChange(c, isThis) {
+ this.hidden = !isThis;
+ }
+}
+
+customElements.define("cyp-settings", Settings);
+
+const decoder = new TextDecoder("utf-8");
+
+function decodeChunk(byteArray) {
+ // \r => \n
+ return decoder.decode(byteArray).replace(/\u000d/g, "\n");
+}
+
+class YT extends Component {
+ _onAppLoad() {
+ this.querySelector(".download").addEventListener("click", _ => this._download());
+ this.querySelector(".search-download").addEventListener("click", _ => this._search());
+ this.querySelector(".clear").addEventListener("click", _ => this._clear());
+ }
+
+ _download() {
+ let url = prompt("Please enter a YouTube URL:");
+ if (!url) { return; }
+
+ this._post(url);
+ }
+
+ _search() {
+ let q = prompt("Please enter a search string:");
+ if (!q) { return; }
+
+ this._post(`ytsearch:${q}`);
+ }
+
+ _clear() {
+ clear(this.querySelector("pre"));
+ }
+
+ async _post(q) {
+ let pre = this.querySelector("pre");
+ clear(pre);
+
+ this.classList.add("pending");
+
+ let body = new URLSearchParams();
+ body.set("q", q);
+ let response = await fetch("/youtube", {method:"POST", body});
+
+ let reader = response.body.getReader();
+ while (true) {
+ let { done, value } = await reader.read();
+ if (done) { break; }
+ pre.textContent += decodeChunk(value);
+ pre.scrollTop = pre.scrollHeight;
+ }
+ reader.releaseLock();
+
+ this.classList.remove("pending");
+
+ if (response.status == 200) {
+ this._mpd.command(`update ${escape(ytPath)}`);
+ }
+ }
+
+ _onComponentChange(c, isThis) {
+ this.hidden = !isThis;
+ }
+}
+
+customElements.define("cyp-yt", YT);
+
+const ICONS$1 = {
+ "AlbumArtist": "artist",
+ "Album": "album"
+};
+
+class Tag extends Item {
+ constructor(type, value, filter) {
+ super();
+ this._type = type;
+ this._value = value;
+ this._filter = filter;
+ }
+
+ connectedCallback() {
+ node("span", {className:"art"}, "", this);
+ this._buildTitle(this._value);
+ }
+
+ createChildFilter() {
+ return Object.assign({[this._type]:this._value}, this._filter);
+ }
+
+ async fillArt(mpd) {
+ const parent = this.firstChild;
+ const filter = this.createChildFilter();
+
+ let artist = filter["AlbumArtist"];
+ let album = filter["Album"];
+ let src = null;
+
+ if (artist && album) {
+ src = await get(mpd, artist, album);
+ if (!src) {
+ let songs = await mpd.listSongs(filter, [0,1]);
+ if (songs.length) {
+ src = await get(artist, album, songs[0]["file"]);
+ }
+ }
+ }
+
+ if (src) {
+ node("img", {src}, "", parent);
+ } else {
+ const icon$1 = ICONS$1[this._type];
+ icon(icon$1, parent);
+ }
+ }
+}
+
+customElements.define("cyp-tag", Tag);
+
+class Path extends Item {
+ constructor(data) {
+ super();
+ this._data = data;
+ this._isDirectory = ("directory" in this._data);
+ }
+
+ get file() { return (this._isDirectory ? this._data["directory"] : this._data["file"]); }
+
+ connectedCallback() {
+ this.appendChild(icon(this._isDirectory ? "folder" : "music"));
+ this._buildTitle(fileName(this.file));
+ }
+}
+
+customElements.define("cyp-path", Path);
+
+class Back extends Item {
+ constructor(title) {
+ super();
+ this._title = title;
+ }
+
+ connectedCallback() {
+ this.appendChild(icon("keyboard-backspace"));
+ this._buildTitle(this._title);
+ }
+}
+
+customElements.define("cyp-back", Back);
+
+const TAGS$1 = {
+ "Album": "Albums",
+ "AlbumArtist": "Artists"
+};
+
+function nonempty(str) { return (str.length > 0); }
+
+function createEnqueueCommand(node) {
+ if (node instanceof Song) {
+ return `add "${escape(node.data["file"])}"`;
+ } else if (node instanceof Path) {
+ return `add "${escape(node.file)}"`;
+ } else if (node instanceof Tag) {
+ return [
+ "findadd",
+ serializeFilter(node.createChildFilter()),
+ // `sort ${SORT}` // MPD >= 0.22, not yet released
+ ].join(" ");
+ } else {
+ throw new Error(`Cannot create enqueue command for "${node.nodeName}"`);
+ }
+}
+
+class Library extends Component {
+ constructor() {
+ super({selection:"multi"});
+ this._stateStack = [];
+ this._initCommands();
+ }
+
+ _popState() {
+ this.selection.clear();
+
+ this._stateStack.pop();
+ if (this._stateStack.length > 0) {
+ let state = this._stateStack[this._stateStack.length-1];
+ this._showState(state);
+ } else {
+ this._showRoot();
+ }
+ }
+
+ _onAppLoad() {
+ this._showRoot();
+ }
+
+ _onComponentChange(c, isThis) {
+ const wasHidden = this.hidden;
+ this.hidden = !isThis;
+
+ if (!wasHidden && isThis) { this._showRoot(); }
+ }
+
+ _showRoot() {
+ this._stateStack = [];
+ clear(this);
+
+ const nav = node("nav", {}, "", this);
+
+ button({icon:"artist"}, "Artists and albums", nav)
+ .addEventListener("click", _ => this._pushState({type:"tags", tag:"AlbumArtist"}));
+
+ button({icon:"folder"}, "Files and directories", nav)
+ .addEventListener("click", _ => this._pushState({type:"path", path:""}));
+
+ button({icon:"magnify"}, "Search", nav)
+ .addEventListener("click", _ => this._pushState({type:"search"}));
+ }
+
+ _pushState(state) {
+ this._stateStack.push(state);
+ this._showState(state);
+ }
+
+ _showState(state) {
+ switch (state.type) {
+ case "tags": this._listTags(state.tag, state.filter); break;
+ case "songs": this._listSongs(state.filter); break;
+ case "path": this._listPath(state.path); break;
+ case "search": this._showSearch(state.query); break;
+ }
+ }
+
+ async _listTags(tag, filter = {}) {
+ const values = await this._mpd.listTags(tag, filter);
+ clear(this);
+
+ if ("AlbumArtist" in filter) { this._buildBack(); }
+ values.filter(nonempty).forEach(value => this._buildTag(tag, value, filter));
+ }
+
+ async _listPath(path) {
+ let paths = await this._mpd.listPath(path);
+ clear(this);
+
+ path && this._buildBack();
+ paths["directory"].forEach(path => this._buildPath(path));
+ paths["file"].forEach(path => this._buildPath(path));
+ }
+
+ async _listSongs(filter) {
+ const songs = await this._mpd.listSongs(filter);
+ clear(this);
+ this._buildBack();
+ songs.forEach(song => this.appendChild(new Song(song)));
+ }
+
+ _showSearch(query = "") {
+ clear(this);
+
+ const form = node("form", {}, "", this);
+ const input = node("input", {type:"text", value:query}, "", form);
+ button({icon:"magnify"}, "", form);
+ form.addEventListener("submit", e => {
+ e.preventDefault();
+ const query = input.value.trim();
+ if (query.length < 3) { return; }
+ this._doSearch(query, form);
+ });
+
+ input.focus();
+ if (query) { this._doSearch(query, form); }
+ }
+
+ async _doSearch(query, form) {
+ let state = this._stateStack[this._stateStack.length-1];
+ state.query = query;
+
+ const songs1 = await this._mpd.searchSongs({"AlbumArtist": query});
+ const songs2 = await this._mpd.searchSongs({"Album": query});
+ const songs3 = await this._mpd.searchSongs({"Title": query});
+ clear(this);
+ this.appendChild(form);
+
+ this._aggregateSearch(songs1, "AlbumArtist");
+ this._aggregateSearch(songs2, "Album");
+ songs3.forEach(song => this.appendChild(new Song(song)));
+ }
+
+ _aggregateSearch(songs, tag) {
+ let results = new Map();
+ songs.forEach(song => {
+ let filter = {}, value;
+ const artist = song["AlbumArtist"] || song["Artist"];
+
+ if (tag == "Album") {
+ value = song[tag];
+ if (artist) { filter["AlbumArtist"] = artist; }
+ }
+
+ if (tag == "AlbumArtist") { value = artist; }
+
+ results.set(value, filter);
+ });
+
+ results.forEach((filter, value) => this._buildTag(tag, value, filter));
+ }
+
+ _buildTag(tag, value, filter) {
+ let node;
+ switch (tag) {
+ case "AlbumArtist":
+ node = new Tag(tag, value, filter);
+ this.appendChild(node);
+ node.onClick = () => this._pushState({type:"tags", tag:"Album", filter:node.createChildFilter()});
+ break;
+
+ case "Album":
+ node = new Tag(tag, value, filter);
+ this.appendChild(node);
+ node.addButton("chevron-double-right", _ => this._pushState({type:"songs", filter:node.createChildFilter()}));
+ break;
+ }
+ node.fillArt(this._mpd);
+ }
+
+ _buildBack() {
+ const backState = this._stateStack[this._stateStack.length-2];
+ let title;
+ switch (backState.type) {
+ case "path": title = ".."; break;
+ case "search": title = "Search"; break;
+ case "tags": title = TAGS$1[backState.tag]; break;
+ }
+
+ const node = new Back(title);
+ this.appendChild(node);
+ node.onClick = () => this._popState();
+ }
+
+ _buildPath(data) {
+ let node = new Path(data);
+ this.appendChild(node);
+
+ if ("directory" in data) {
+ const path = data["directory"];
+ node.addButton("chevron-double-right", _ => this._pushState({type:"path", path}));
+ }
+ }
+
+ _initCommands() {
+ const sel = this.selection;
+
+ sel.addCommandAll();
+
+ sel.addCommand(async items => {
+ const commands = ["clear",...items.map(createEnqueueCommand), "play"];
+ await this._mpd.command(commands);
+ this.selection.clear();
+ this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
+ }, {label:"Play", icon:"play"});
+
+ sel.addCommand(async items => {
+ const commands = items.map(createEnqueueCommand);
+ await this._mpd.command(commands);
+ this.selection.clear();
+ this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?
+ }, {label:"Enqueue", icon:"plus"});
+
+ sel.addCommandCancel();
+ }
+}
+
+customElements.define("cyp-library", Library);
diff --git a/app/index.html b/app/index.html
index e3c279f..35e981f 100644
--- a/app/index.html
+++ b/app/index.html
@@ -88,25 +88,12 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+