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 @@ - - - - - - - - - - - - - - + diff --git a/app/js/cyp.js b/app/js/cyp.js new file mode 100644 index 0000000..9f4885c --- /dev/null +++ b/app/js/cyp.js @@ -0,0 +1,13 @@ +import "./elements/range.js"; +import "./elements/app.js"; +import "./elements/menu.js"; +import "./elements/player.js"; +import "./elements/queue.js"; +import "./elements/playlists.js"; +import "./elements/settings.js"; +import "./elements/yt.js"; +import "./elements/song.js"; +import "./elements/library.js"; +import "./elements/tag.js"; +import "./elements/back.js"; +import "./elements/path.js"; diff --git a/package.json b/package.json index 0503ca9..17339b7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "ws2mpd": "^2.0.0" }, "devDependencies": { - "less": "^3.9.0" + "less": "^3.9.0", + "rollup": "^2.0.6" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1"