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) { if (!this.firstChild) { return; } 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; } class MPD { static async connect() { let response = await fetch("/ticket", {method:"POST"}); let ticket = (await response.json()).ticket; let ws = new WebSocket(createURL(ticket).href); return new Promise((resolve, reject) => { let mpd; let initialCommand = {resolve: () => resolve(mpd), reject}; mpd = new this(ws, initialCommand); }); } constructor(/** @type WebSocket */ ws, initialCommand) { this._ws = ws; this._queue = []; this._current = initialCommand; this._canTerminateIdle = false; ws.addEventListener("message", e => this._onMessage(e)); ws.addEventListener("close", e => this._onClose(e)); } onClose(_e) {} onChange(_changed) {} command(cmd) { if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); } return new Promise((resolve, reject) => { this._queue.push({cmd, resolve, reject}); if (!this._current) { this._advanceQueue(); } else if (this._canTerminateIdle) { this._ws.send("noidle"); this._canTerminateIdle = false; } }); } async status() { let lines = await this.command("status"); return linesToStruct(lines); } async currentSong() { let lines = await this.command("currentsong"); return linesToStruct(lines); } 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 listSongs(filter, window = null) { let tokens = ["find", serializeFilter(filter)]; window && tokens.push("window", window.join(":")); let lines = await this.command(tokens.join(" ")); return songList(lines); } 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 = "==") { 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 createURL(ticket) { let url = new URL(location.href); url.protocol = "ws"; url.hash = ""; url.searchParams.set("ticket", ticket); return url; } let ICONS={}; ICONS["playlist-music"] = ` `; ICONS["folder"] = ` `; ICONS["shuffle"] = ` `; ICONS["artist"] = ` `; ICONS["download"] = ` `; ICONS["checkbox-marked-outline"] = ` `; ICONS["magnify"] = ` `; ICONS["delete"] = ` `; ICONS["rewind"] = ` `; ICONS["cancel"] = ` `; ICONS["settings"] = ` `; ICONS["pause"] = ` `; ICONS["arrow-down-bold"] = ` `; ICONS["filter-variant"] = ` `; ICONS["volume-off"] = ` `; ICONS["close"] = ` `; ICONS["music"] = ` `; ICONS["repeat"] = ` `; ICONS["arrow-up-bold"] = ` `; ICONS["keyboard-backspace"] = ` `; ICONS["play"] = ` `; ICONS["plus"] = ` `; ICONS["content-save"] = ` `; ICONS["library-music"] = ` `; ICONS["fast-forward"] = ` `; ICONS["volume-high"] = ` `; ICONS["chevron-double-right"] = ` `; ICONS["album"] = ` `; ICONS["minus_unused"] = ` `; 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 => { node.dataset.icon.split(" ").forEach(name => { let icon$1 = icon(name); node.insertBefore(icon$1, node.firstChild); }); }); } class App extends HTMLElement { static get observedAttributes() { return ["component"]; } constructor() { super(); initIcons(); } async connectedCallback() { await waitForChildren(this); window.addEventListener("hashchange", e => this._onHashChange()); this._onHashChange(); await this._connect(); this.dispatchEvent(new CustomEvent("load")); this.mediaSessionInit = false; this._initMediaHandler(); } 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) { 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 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.`); } _initMediaHandler() { // check support mediaSession if (!('mediaSession' in navigator)) { console.log('mediaSession is not supported'); return; } // DOM (using media session controls are allowed only if there is audio/video tag) const audio = node("audio", {loop: true}, "", this); node("source", {src: 'https://raw.githubusercontent.com/anars/blank-audio/master/10-seconds-of-silence.mp3'}, '', audio); // Init event session (play audio) on click (because restrictions by web browsers) window.addEventListener('click', () => { if (!this.mediaSessionInit) { audio.play(); this.mediaSessionInit = true; } }); // mediaSession define metadata navigator.mediaSession.metadata = new MediaMetadata({ title: 'Control Your Player' }); // mediaSession define action handlers navigator.mediaSession.setActionHandler('play', () => { this.mpd.command("play"); audio.play(); }); navigator.mediaSession.setActionHandler('pause', () => { this.mpd.command("pause 1"); audio.pause(); }); navigator.mediaSession.setActionHandler('previoustrack', () => { this.mpd.command("previous"); audio.play(); }); navigator.mediaSession.setActionHandler('nexttrack', () => { this.mpd.command("next"); audio.play(); }); } } 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"]; class Selection { constructor(component, mode) { this._component = component; /** @type {"single" | "multi"} */ this._mode = mode; this._items = []; this._node = node("cyp-commands", {hidden:true}); } appendTo(parent) { parent.appendChild(this._node); } 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() { this._node.hidden = false; } _hide() { this._node.hidden = true; } } customElements.define("cyp-commands", class extends HTMLElement {}); class Component extends HTMLElement { constructor(options = {}) { super(); if (options.selection) { this.selection = new Selection(this, options.selection); } } connectedCallback() { if (this.selection) { const parent = this._app.querySelector("footer"); this.selection.appendTo(parent); } 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); }); } get _app() { return this.closest("cyp-app"); } get _mpd() { return this._app.mpd; } _onAppLoad() {} _onComponentChange(_component, _isThis) {} } class Menu extends Component { connectedCallback() { super.connectedCallback(); /** @type HTMLElement[] */ this._tabs = Array.from(this.querySelectorAll("[data-for]")); this._tabs.forEach(tab => { tab.addEventListener("click", _ => this._app.setAttribute("component", tab.dataset.for)); }); } _onAppLoad() { this._app.addEventListener("queue-length-change", e => { this.querySelector(".queue-length").textContent = `(${e.detail})`; }); } _onComponentChange(component) { this._tabs.forEach(tab => { tab.classList.toggle("active", tab.dataset.for == component); }); } } customElements.define("cyp-menu", Menu); const artSize = 96; const ytPath = "_youtube"; let ytLimit = 3; function setYtLimit(limit) { ytLimit = limit; } 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) { while (Math.min(image.width, image.height) >= 2*artSize) { let tmp = node("canvas", {width:image.width/2, height:image.height/2}); tmp.getContext("2d").drawImage(image, 0, 0, tmp.width, tmp.height); image = tmp; } const canvas = node("canvas", {width:artSize, height:artSize}); canvas.getContext("2d").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 ELAPSED_PERIOD = 500; class Player extends Component { constructor() { super(); this._current = { song: {}, elapsed: 0, at: 0, volume: 0 }; this._toggleVolume = 0; const DOM = {}; const all = this.querySelectorAll("[class]"); [...all].forEach(node => DOM[node.className] = node); DOM.progress = DOM.timeline.querySelector("x-range"); DOM.volume = DOM.volume.querySelector("x-range"); this._dom = DOM; } handleEvent(e) { switch (e.type) { case "idle-change": let hasOptions = e.detail.includes("options"); let hasPlayer = e.detail.includes("player"); let hasMixer = e.detail.includes("mixer"); (hasOptions || hasPlayer || hasMixer) && this._updateStatus(); hasPlayer && this._updateCurrent(); break; } } _onAppLoad() { this._addEvents(); this._updateStatus(); this._updateCurrent(); this._app.addEventListener("idle-change", this); setInterval(() => this._updateElapsed(), ELAPSED_PERIOD); } async _updateStatus() { const data = await this._mpd.status(); this._updateFlags(data); this._updateVolume(data); // rebase the time sync this._current.elapsed = Number(data["elapsed"] || 0); this._current.at = performance.now(); } async _updateCurrent() { const data = await this._mpd.currentSong(); const DOM = this._dom; if (data["file"] != this._current.song["file"]) { // changed song if (data["file"]) { // is there a song at all? DOM.title.textContent = data["Title"] || fileName(data["file"]); DOM.subtitle.textContent = subtitle(data, {duration:false}); let duration = Number(data["duration"]); DOM.duration.textContent = time(duration); DOM.progress.max = duration; DOM.progress.disabled = 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.song["AlbumArtist"] || this._current.song["Artist"]; let albumNew = data["Album"]; let albumOld = this._current.song["Album"]; Object.assign(this._current.song, data); if (artistNew != artistOld || albumNew != albumOld) { // changed album (art) clear(DOM.art); let src = await get(this._mpd, artistNew, data["Album"], data["file"]); if (src) { node("img", {src}, "", DOM.art); } else { icon("music", DOM.art); } } } _updateElapsed() { const DOM = this._dom; let elapsed = 0; if (this._current.song["file"]) { elapsed = this._current.elapsed; if (this.dataset.state == "play") { elapsed += (performance.now() - this._current.at)/1000; } } DOM.progress.value = elapsed; DOM.elapsed.textContent = time(elapsed); this._app.style.setProperty("--progress", DOM.progress.value/DOM.progress.max); } _updateFlags(data) { let flags = []; if (data["random"] == "1") { flags.push("random"); } if (data["repeat"] == "1") { flags.push("repeat"); } if (data["volume"] === "0") { flags.push("mute"); } // strict, because volume might be missing this.dataset.flags = flags.join(" "); this.dataset.state = data["state"]; } _updateVolume(data) { const DOM = this._dom; if ("volume" in data) { let volume = Number(data["volume"]); DOM.mute.disabled = false; DOM.volume.disabled = false; DOM.volume.value = volume; if (volume == 0 && this._current.volume > 0) { this._toggleVolume = this._current.volume; } // muted if (volume > 0 && this._current.volume == 0) { this._toggleVolume = 0; } // restored this._current.volume = volume; } else { DOM.mute.disabled = true; DOM.volume.disabled = true; DOM.volume.value = 50; } } _addEvents() { const DOM = this._dom; DOM.play.addEventListener("click", _ => this._app.mpd.command("play")); DOM.pause.addEventListener("click", _ => this._app.mpd.command("pause 1")); DOM.prev.addEventListener("click", _ => this._app.mpd.command("previous")); DOM.next.addEventListener("click", _ => this._app.mpd.command("next")); DOM.random.addEventListener("click", _ => { let isRandom = this.dataset.flags.split(" ").includes("random"); this._app.mpd.command(`random ${isRandom ? "0" : "1"}`); }); DOM.repeat.addEventListener("click", _ => { let isRepeat = this.dataset.flags.split(" ").includes("repeat"); this._app.mpd.command(`repeat ${isRepeat ? "0" : "1"}`); }); DOM.progress.addEventListener("input", e => { let elapsed = e.target.valueAsNumber; this._current.elapsed = elapsed; this._current.at = performance.now(); this._app.mpd.command(`seekcur ${elapsed}`); }); DOM.volume.addEventListener("input", e => this._app.mpd.command(`setvol ${e.target.valueAsNumber}`)); DOM.mute.addEventListener("click", _ => this._app.mpd.command(`setvol ${this._toggleVolume}`)); } _dispatchSongChange(detail) { const e = new CustomEvent("song-change", {detail}); this._app.dispatchEvent(e); } } customElements.define("cyp-player", Player); class Item extends HTMLElement { 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); } matchPrefix(prefix) { return this.textContent.match(/\w+/g).some(word => word.toLowerCase().startsWith(prefix)); } } class Song extends Item { constructor(data) { super(); this._data = data; } get file() { return this._data["file"]; } get songId() { return this._data["Id"]; } set playing(playing) { this.classList.toggle("playing", playing); } connectedCallback() { const data = this._data; icon("music", this); icon("play", this); 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); } this.playing = false; } _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 "idle-change": e.detail.includes("playlist") && this._sync(); break; } } _onAppLoad() { this._app.addEventListener("idle-change", this); this._app.addEventListener("song-change", this); this._sync(); } _onComponentChange(c, isThis) { this.hidden = !isThis; } async _sync() { let songs = await this._mpd.listQueue(); this._buildSongs(songs); let e = new CustomEvent("queue-length-change", {detail:songs.length}); this._app.dispatchEvent(e); } _updateCurrent() { Array.from(this.children).forEach(/** @param {Song} node */ node => { node.playing = (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(items => { const commands = generateMoveCommands(items, -1, Array.from(this.children)); this._mpd.command(commands); }, {label:"Up", icon:"arrow-up-bold"}); sel.addCommand(items => { const commands = generateMoveCommands(items, +1, Array.from(this.children)); this._mpd.command(commands.reverse()); // move last first }, {label:"Down", icon:"arrow-down-bold"}); sel.addCommand(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)}"`; }); 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}`); this._mpd.command(commands); }, {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(); } handleEvent(e) { switch (e.type) { case "idle-change": e.detail.includes("stored_playlist") && this._sync(); break; } } _onAppLoad() { this._app.addEventListener("idle-change", this); this._sync(); } _onComponentChange(c, isThis) { this.hidden = !isThis; } 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(); // fixme notification? }, {label:"Play", icon:"play"}); sel.addCommand(async item => { const name = item.name; await this._mpd.command(`load "${escape(name)}"`); this.selection.clear(); // 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)}"`); }, {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]"), ytLimit: this.querySelector("[name=yt-limit]"), 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.ytLimit.addEventListener("change", e => this._setYtLimit(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()); const ytLimit$1 = loadFromStorage("ytLimit") || ytLimit; this._setYtLimit(ytLimit$1); } _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); } _setYtLimit(ytLimit) { saveToStorage("ytLimit", ytLimit); setYtLimit(ytLimit); } _onComponentChange(c, isThis) { this.hidden = !isThis; } } customElements.define("cyp-settings", Settings); class Search extends HTMLElement { constructor() { super(); this._built = false; } get value() { return this._input.value.trim(); } set value(value) { this._input.value = value; } get _input() { return this.querySelector("input"); } onSubmit() {} focus() { this._input.focus(); } pending(pending) { this.classList.toggle("pending", pending); } connectedCallback() { if (this._built) { return; } const form = node("form", {}, "", this); node("input", {type:"text"}, "", form); button({icon:"magnify"}, "", form); form.addEventListener("submit", e => { e.preventDefault(); this.onSubmit(); }); this._built = true; } } customElements.define("cyp-search", Search); class YtResult extends Item { constructor(title) { super(); this._title = title; } connectedCallback() { this.appendChild(icon("magnify")); this._buildTitle(this._title); } onClick() {} } customElements.define("cyp-yt-result", YtResult); const decoder = new TextDecoder("utf-8"); function decodeChunk(byteArray) { // \r => \n return decoder.decode(byteArray).replace(/\u000d/g, "\n"); } class YT extends Component { constructor() { super(); this._search = new Search(); this._search.onSubmit = _ => { let query = this._search.value; query && this._doSearch(query); }; } connectedCallback() { super.connectedCallback(); this._clear(); } _clear() { clear(this); this.appendChild(this._search); } async _doSearch(query) { this._clear(); this._search.pending(true); let url = `/youtube?q=${encodeURIComponent(query)}&limit=${encodeURIComponent(ytLimit)}`; let response = await fetch(url); if (response.status == 200) { let results = await response.json(); results.forEach(result => { let node = new YtResult(result.title); this.appendChild(node); node.addButton("download", () => this._download(result.id)); }); } else { let text = await response.text(); alert(text); } this._search.pending(false); } async _download(id) { this._clear(); let pre = node("pre", {}, "", this); this._search.pending(true); let body = new URLSearchParams(); body.set("id", id); 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._search.pending(false); if (response.status == 200) { this._mpd.command(`update ${escape(ytPath)}`); } } _onComponentChange(c, isThis) { const wasHidden = this.hidden; this.hidden = !isThis; if (!wasHidden && isThis) { this._clear(); } } } customElements.define("cyp-yt", YT); const ICONS$1 = { "AlbumArtist": "artist", "Album": "album", "Genre": "music" }; 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(mpd, 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 SELECTOR = ["cyp-tag", "cyp-path", "cyp-song"].join(", "); class Filter extends HTMLElement { constructor() { super(); this._built = false; } get value() { return this._input.value.trim(); } set value(value) { this._input.value = value; } get _input() { return this.querySelector("input"); } connectedCallback() { if (this._built) { return; } node("input", {type:"text"}, "", this); icon("filter-variant", this); this._input.addEventListener("input", e => this._apply()); this._built = true; } _apply() { let value = this.value.toLowerCase(); let all = [...this.parentNode.querySelectorAll(SELECTOR)]; all.forEach(item => item.hidden = !item.matchPrefix(value)); } } customElements.define("cyp-filter", Filter); const TAGS$1 = { "Album": "Albums", "AlbumArtist": "Artists", "Genre": "Genres" }; function nonempty(str) { return (str.length > 0); } function createEnqueueCommand(node) { if (node instanceof Song || 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(); this._search = new Search(); this._search.onSubmit = _ => { let query = this._search.value; if (query.length < 3) { return; } this._doSearch(query); }; this._filter = new Filter(); } _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:"music"}, "Genres", nav) .addEventListener("click", _ => this._pushState({type:"tags", tag:"Genre"})); 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.selection.clear(); 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)).filter(nonempty); clear(this); if ("AlbumArtist" in filter || "Genre" in filter) { this._buildBack(); } (values.length > 0) && this._addFilter(); values.forEach(value => this._buildTag(tag, value, filter)); } async _listPath(path) { let paths = await this._mpd.listPath(path); clear(this); path && this._buildBack(); (paths["directory"].length + paths["file"].length > 0) && this._addFilter(); 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.length > 0 && this._addFilter()); songs.forEach(song => this.appendChild(new Song(song))); } _showSearch(query = "") { clear(this); this.appendChild(this._search); this._search.value = query; this._search.focus(); query && this._search.onSubmit(); } async _doSearch(query) { let state = this._stateStack[this._stateStack.length-1]; state.query = query; clear(this); this.appendChild(this._search); this._search.pending(true); const songs1 = await this._mpd.searchSongs({"AlbumArtist": query}); const songs2 = await this._mpd.searchSongs({"Album": query}); const songs3 = await this._mpd.searchSongs({"Title": query}); this._search.pending(false); 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": case "Genre": 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})); } } _addFilter() { this.appendChild(this._filter); this._filter.value = ""; } _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(); // fixme notification? }, {label:"Play", icon:"play"}); sel.addCommand(async items => { const commands = items.map(createEnqueueCommand); await this._mpd.command(commands); this.selection.clear(); // fixme notification? }, {label:"Enqueue", icon:"plus"}); sel.addCommandCancel(); } } customElements.define("cyp-library", Library); function updateSize() { document.body.style.setProperty("--vh", window.innerHeight/100); } window.addEventListener("resize", updateSize); updateSize();