diff --git a/app/app.css b/app/app.css index 306335a..0c44f8b 100644 --- a/app/app.css +++ b/app/app.css @@ -9,6 +9,10 @@ html { body { margin: 0; } +main { + flex-grow: 1; + overflow: auto; +} cyp-app { flex-direction: column; box-sizing: border-box; @@ -27,9 +31,19 @@ cyp-app:not([hidden]) { } header, footer { + flex-shrink: 0; z-index: 1; box-shadow: var(--box-shadow); } +footer { + position: relative; + height: 56px; +} +@media (max-width: 480px) { + footer { + height: 40px; + } +} input, select, button { @@ -108,6 +122,12 @@ select { .multiline h2 { font-weight: normal; } +.selectable { + border-left: 4px solid transparent; +} +.selectable.selected { + border-left-color: var(--primary); +} x-range { --thumb-size: 8px; --thumb-color: #fff; @@ -170,49 +190,6 @@ x-range[disabled] { x-range:not([disabled]) .-thumb:hover { background-color: var(--thumb-hover-color); } -main { - flex-grow: 1; - overflow: auto; -} -cyp-menu { - flex-direction: row; - align-items: center; - height: 56px; -} -cyp-menu:not([hidden]) { - display: flex; -} -cyp-menu button { - flex: 1 0 0; - height: 100%; - flex-direction: column; - align-items: center; - padding-top: 4px; - border-top: 4px solid transparent; -} -cyp-menu button:not([hidden]) { - display: flex; -} -cyp-menu button .icon { - margin-right: var(--icon-spacing); -} -cyp-menu button.active { - border-top-color: var(--primary); - color: var(--primary); - background-color: rgb(var(--primary-raw), 0.1); -} -@media (max-width: 480px) { - cyp-menu button { - flex-direction: row; - justify-content: center; - } - cyp-menu button:not([data-for=queue]) .icon { - margin-right: 0; - } - cyp-menu button span:not([id]) { - display: none; - } -} cyp-player { flex-direction: row; align-items: center; @@ -693,80 +670,80 @@ cyp-playlists .info:not([hidden]) { cyp-playlists .info h2 { font-weight: normal; } -#yt header { +cyp-yt header { flex-direction: row; align-items: center; padding: var(--spacing); } -#yt header:not([hidden]) { +cyp-yt header:not([hidden]) { display: flex; } -#yt header button { +cyp-yt header button { font-size: var(--font-size-large); font-weight: bold; overflow: hidden; } -#yt header button .icon { +cyp-yt header button .icon { margin-right: var(--icon-spacing); } -#yt ul { +cyp-yt ul { flex-grow: 1; overflow: auto; list-style: none; margin: 0; padding: 0; } -#yt li { +cyp-yt li { flex-direction: row; align-items: center; } -#yt li:not([hidden]) { +cyp-yt li:not([hidden]) { display: flex; } -#yt li .info { +cyp-yt li .info { flex-grow: 1; overflow: hidden; } -#yt li .info .icon { +cyp-yt li .info .icon { color: var(--primary); margin-right: var(--icon-spacing); filter: drop-shadow(var(--text-shadow)); } -#yt li .info h2 { +cyp-yt li .info h2 { font-size: var(--font-size-large); margin: 0; } -#yt li .info h2, -#yt li .info div { +cyp-yt li .info h2, +cyp-yt li .info div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#yt li:not(.has-art) { +cyp-yt li:not(.has-art) { padding: 8px; } -#yt li button .icon { +cyp-yt li button .icon { width: 32px; } -#yt li:nth-child(odd) { +cyp-yt li:nth-child(odd) { background-color: var(--bg-alt); } -#yt header { +cyp-yt header { border-bottom: 1px solid var(--fg); } -#yt header button + button { +cyp-yt header button + button { margin-left: 16px; } -#yt .clear { +cyp-yt .clear { margin-left: auto; } -#yt pre { +cyp-yt pre { margin: 0.5em 0.5ch; flex-grow: 1; overflow: auto; white-space: pre-wrap; } -#yt.pending header { +cyp-yt.pending header { background-image: linear-gradient(var(--primary), var(--primary)); background-repeat: no-repeat; background-size: 25% 4px; @@ -896,3 +873,102 @@ cyp-app[color=limegreen] { --spacing: var(--icon-spacing); } } +cyp-song { + border-left: 4px solid transparent; + flex-direction: row; + align-items: center; +} +cyp-song.selected { + border-left-color: var(--primary); +} +cyp-song:not([hidden]) { + display: flex; +} +cyp-song .info { + flex-grow: 1; + overflow: hidden; +} +cyp-song .info .icon { + color: var(--primary); + margin-right: var(--icon-spacing); + filter: drop-shadow(var(--text-shadow)); +} +cyp-song .info h2 { + font-size: var(--font-size-large); + margin: 0; +} +cyp-song .info h2, +cyp-song .info div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +cyp-song:nth-child(odd) { + background-color: var(--bg-alt); +} +cyp-song:not(.has-art) { + padding: 8px; +} +cyp-menu, +cyp-commands { + flex-direction: row; + align-items: center; + height: 100%; +} +cyp-menu:not([hidden]), +cyp-commands:not([hidden]) { + display: flex; +} +cyp-menu button, +cyp-commands button { + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; +} +cyp-menu button:not([hidden]), +cyp-commands button:not([hidden]) { + display: flex; +} +@media (max-width: 480px) { + cyp-menu button, + cyp-commands button { + flex-direction: row; + } + cyp-menu button span:not([id]), + cyp-commands button span:not([id]) { + display: none; + } +} +cyp-menu button { + flex: 1 0 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; +} +cyp-menu button .icon { + margin-right: var(--icon-spacing); +} +cyp-menu button.active { + border-top-color: var(--primary); + color: var(--primary); + background-color: rgb(var(--primary-raw), 0.1); +} +cyp-commands { + position: absolute; + left: 0; + top: 0; + width: 100%; + transition: top 300ms; + background-color: var(--bg); +} +cyp-commands[hidden] { + display: flex; + top: 100%; +} +cyp-commands button { + flex: 0 0 80px; +} +cyp-commands button.last { + order: 1; + margin-left: auto; +} diff --git a/app/css/app.less b/app/css/app.less index 91b69c5..47b99db 100644 --- a/app/css/app.less +++ b/app/css/app.less @@ -8,6 +8,11 @@ body { margin: 0; } +main { + flex-grow: 1; + overflow: auto; +} + cyp-app { .flex-column; @@ -24,10 +29,19 @@ cyp-app { } header, footer { + flex-shrink: 0; z-index: 1; box-shadow: var(--box-shadow); } +footer { + position: relative; + height: 56px; + @media (max-width: 480px) { + height: 40px; + } +} + input, select, button { color: inherit; font: inherit; @@ -60,8 +74,6 @@ select { @import "icons.less"; @import "mixins.less"; @import "range.less"; -@import "main.less"; -@import "menu.less"; @import "player.less"; @import "component.less"; @import "queue.less"; @@ -73,3 +85,6 @@ select { @import "search.less"; @import "art.less"; @import "variables.less"; + +@import "song.less"; +@import "menu.less"; diff --git a/app/css/commands.less b/app/css/commands.less new file mode 100644 index 0000000..e69de29 diff --git a/app/css/main.less b/app/css/main.less index c676bbc..e69de29 100644 --- a/app/css/main.less +++ b/app/css/main.less @@ -1,4 +0,0 @@ -main { - flex-grow: 1; - overflow: auto; -} diff --git a/app/css/menu.less b/app/css/menu.less index c96797a..147aedf 100644 --- a/app/css/menu.less +++ b/app/css/menu.less @@ -1,33 +1,56 @@ -cyp-menu { +cyp-menu, cyp-commands { .flex-row; - height: 56px; + height: 100%; button { - flex: 1 0 0; height: 100%; .flex-column; align-items: center; - padding-top: 4px; - - border-top: 4px solid transparent; - - .icon { - margin-right: var(--icon-spacing); - } - - &.active { - border-top-color: var(--primary); - color: var(--primary); - background-color: rgb(var(--primary-raw), 0.1); - } + justify-content: center; @media (max-width: 480px) { flex-direction: row; - justify-content: center; - - &:not([data-for=queue]) .icon { margin-right: 0; } span:not([id]) { display: none; } } } } + +cyp-menu button { + flex: 1 0 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + + .icon { + margin-right: var(--icon-spacing); + } + + &.active { + border-top-color: var(--primary); + color: var(--primary); + background-color: rgb(var(--primary-raw), 0.1); + } +} + +cyp-commands { + position: absolute; + left: 0; + top: 0; + width: 100%; + transition: top 300ms; + + background-color: var(--bg); + + &[hidden] { + display: flex; + top: 100%; + } + + button { + flex: 0 0 80px; + &.last { + order: 1; + margin-left: auto; + } + } +} diff --git a/app/css/mixins.less b/app/css/mixins.less index 7380221..676cac4 100644 --- a/app/css/mixins.less +++ b/app/css/mixins.less @@ -20,3 +20,11 @@ h2 { font-weight: normal; } } + +.selectable { + border-left: 4px solid transparent; + + &.selected { + border-left-color: var(--primary); + } +} diff --git a/app/css/song.less b/app/css/song.less new file mode 100644 index 0000000..c653445 --- /dev/null +++ b/app/css/song.less @@ -0,0 +1,30 @@ +cyp-song { + .selectable; + .flex-row; + + .info { + flex-grow: 1; + overflow: hidden; + + .icon { + color: var(--primary); + margin-right: var(--icon-spacing); + filter: drop-shadow(var(--text-shadow)); + } + + h2 { + font-size: var(--font-size-large); + margin: 0; + } + + h2, div { .long-line; } + } + + &:nth-child(odd) { + background-color: var(--bg-alt); + } + + &:not(.has-art) { + padding: 8px; + } +} diff --git a/app/css/yt.less b/app/css/yt.less index 5064771..b668a08 100644 --- a/app/css/yt.less +++ b/app/css/yt.less @@ -1,4 +1,4 @@ -#yt { +cyp-yt { .component; header { diff --git a/app/index.html b/app/index.html index b4939de..94a2ca1 100644 --- a/app/index.html +++ b/app/index.html @@ -40,11 +40,12 @@
+ @@ -57,14 +58,14 @@
-
+

-			
+
Theme
diff --git a/app/js/app.js b/app/js/app.js index e16c3fb..3c75758 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -20,6 +20,16 @@ function initIcons() { }); } +async function initMpd() { + try { + await mpd.init(); + return mpd; + } catch (e) { + console.error(e); + return mpdMock; + } +} + class App extends HTMLElement { static get observedAttributes() { return ["component"]; } @@ -28,43 +38,33 @@ class App extends HTMLElement { initIcons(); - this._load(); + this._mpdPromise = initMpd().then(mpd => this.mpd = mpd); } - attributeChangedCallback(name, oldValue, newValue) { - switch (name) { - case "component": - const e = new CustomEvent("component-change"); - this.dispatchEvent(e); - break; - } - } - - async _load() { - try { - await mpd.init(); - this.mpd = mpd; - } catch (e) { - console.error(e); - this.mpd = mpdMock; - } - + async connectedCallback() { const promises = ["cyp-player"].map(name => customElements.whenDefined(name)); + promises.push(this._mpdPromise); + await Promise.all(promises); this.dispatchEvent(new CustomEvent("load")); const onHashChange = () => { const hash = location.hash.substring(1); - this._activate(hash || "queue"); + this.setAttribute("component", hash || "queue"); } window.addEventListener("hashchange", onHashChange); onHashChange(); } - _activate(what) { - location.hash = what; - this.setAttribute("component", what); + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case "component": + location.hash = newValue; + const e = new CustomEvent("component-change"); + this.dispatchEvent(e); + break; + } } } diff --git a/app/js/component.js b/app/js/component.js index f0133e9..2c305c5 100644 --- a/app/js/component.js +++ b/app/js/component.js @@ -1,4 +1,4 @@ -const APP = "cyp-app"; +import Selection from "./lib/selection.js"; export class HasApp extends HTMLElement { get _app() { return this.closest("cyp-app"); } @@ -8,7 +8,10 @@ export class HasApp extends HTMLElement { export default class Component extends HasApp { constructor() { super(); + this.selection = new Selection(this); + } + connectedCallback() { this._app.addEventListener("load", _ => this._onAppLoad()); this._app.addEventListener("component-change", _ => { const component = this._app.getAttribute("component"); diff --git a/app/js/fs.js b/app/js/fs.js index 81d0ee3..e56f4c8 100644 --- a/app/js/fs.js +++ b/app/js/fs.js @@ -1,8 +1,5 @@ -import * as app from "./app.js"; import * as mpd from "./lib/mpd.js"; import * as html from "./lib/html.js"; -import * as player from "./player.js"; -import * as format from "./lib/format.js"; import * as ui from "./lib/ui.js"; import Search from "./lib/search.js"; diff --git a/app/js/lib/selection.js b/app/js/lib/selection.js new file mode 100644 index 0000000..decb8c2 --- /dev/null +++ b/app/js/lib/selection.js @@ -0,0 +1,58 @@ +import * as html from "./html.js"; + +export default class Selection { + constructor(component) { + this._component = component; + this._items = new Set(); + this._node = html.node("cyp-commands", {hidden:true}); + + const button = this.addCommand(_ => this.clear(), {icon:"close", label:"Clear"}); + button.classList.add("last"); + } + + clear() { + const nodes = Array.from(this._items); + while (nodes.length) { this._remove(nodes.pop()); } + } + + toggle(node) { + if (this._items.has(node)) { + this._remove(node); + } else { + this._add(node); + } + } + + addCommand(cb, options) { + const button = html.button({icon:options.icon}, "", this._node); + html.node("span", {}, options.label, button); + button.addEventListener("click", _ => cb(this._items)); + return button; + } + + _add(node) { + const size = this._items.size; + this._items.add(node); + node.classList.add("selected"); + if (size == 0) { this._show(); } + } + + _remove(node) { + this._items.delete(node); + node.classList.remove("selected"); + if (this._items.size == 0) { this._hide(); } + + } + + _show() { + const parent = this._component.closest("cyp-app").querySelector("footer"); + parent.appendChild(this._node); + this._node.offsetWidth; // FIXME jde lepe? + this._node.hidden = false; + } + + _hide() { + this._node.hidden = true; + this._node.remove(); + } +} diff --git a/app/js/lib/ui.js b/app/js/lib/ui.js index a7d4539..81ba532 100644 --- a/app/js/lib/ui.js +++ b/app/js/lib/ui.js @@ -86,11 +86,10 @@ function playButton(type, what, parent) { await mpd.command("play"); pubsub.publish("queue-change"); } - player.update(); + button.closest("cyp-app").querySelector("cyp-player").update(); // FIXME nejde to lepe? }); return button; - } function deleteButton(type, id, parent) { @@ -108,7 +107,7 @@ function deleteButton(type, id, parent) { await mpd.command(`deleteid ${id}`); pubsub.publish("queue-change"); return; - case TYPE_PLAYLIST: + case TYPE_PLAYLIST: let ok = confirm(`Really delete playlist '${id}'?`); if (!ok) { return; } await mpd.command(`rm "${mpd.escape(id)}"`); diff --git a/app/js/player.js b/app/js/player.js index d4a180d..04b571c 100644 --- a/app/js/player.js +++ b/app/js/player.js @@ -14,8 +14,15 @@ class Player extends Component { this._dom = this._initDOM(); } + async update() { + this._clearIdle(); + const data = await this._mpd.status(); + this._sync(data); + this._idle(); + } + _onAppLoad() { - this._update(); + this.update(); } _initDOM() { @@ -50,7 +57,7 @@ class Player extends Component { } _idle() { - this._idleTimeout = setTimeout(() => this._update(), DELAY); + this._idleTimeout = setTimeout(() => this.update(), DELAY); } _clearIdle() { @@ -58,13 +65,6 @@ class Player extends Component { this._idleTimeout = null; } - async _update() { - this._clearIdle(); - const data = await this._mpd.status(); - this._sync(data); - this._idle(); - } - _sync(data) { const DOM = this._dom; if ("volume" in data) { diff --git a/app/js/queue.js b/app/js/queue.js index 0243b7d..1c7fc77 100644 --- a/app/js/queue.js +++ b/app/js/queue.js @@ -1,13 +1,13 @@ import * as html from "./lib/html.js"; -import * as ui from "./lib/ui.js"; +import * as format from "./lib/format.js"; -import Component from "./component.js"; +import Component, { HasApp } from "./component.js"; class Queue extends Component { constructor() { super(); this._currentId = null; - +/* this.querySelector(".clear").addEventListener("click", async _ => { await this._mpd.command("clear"); this._sync(); @@ -18,6 +18,7 @@ class Queue extends Component { if (name === null) { return; } this._mpd.command(`save "${this._mpd.escape(name)}"`); }); +*/ } handleEvent(e) { @@ -54,19 +55,72 @@ class Queue extends Component { } _updateCurrent() { - Array.from(this.querySelectorAll("[data-song-id]")).forEach(/** @param {HTMLElement} node */ node => { + Array.from(this.children).forEach(/** @param {HTMLElement} node */ node => { node.classList.toggle("current", node.dataset.songId == this._currentId); }); } _buildSongs(songs) { - let ul = this.querySelector("ul"); - html.clear(ul); + html.clear(this); - songs.map(song => ui.song(ui.CTX_QUEUE, song, ul)); + songs.forEach(song => this.appendChild(new Song(song))); this._updateCurrent(); } } customElements.define("cyp-queue", Queue); + +class Item extends HasApp { + constructor() { + super(); + this.addEventListener("click", e => this.parentNode.selection.toggle(this)); + } +} + +class Song extends Item { + constructor(data) { + super(); + this._data = data; + this.dataset.songId = data["Id"]; + } + + connectedCallback() { + let info = html.node("div", {className:"info"}, "", this); + + let lines = formatSongInfo(this._data); + html.node("h2", {}, lines.shift(), info); + lines.length && html.node("div", {}, lines.shift(), info); + +/* + playButton(TYPE_ID, id, node); + deleteButton(TYPE_ID, id, node); +*/ + } +} + +customElements.define("cyp-song", Song); + + +// FIXME vyfaktorovat nekam do haje +function formatSongInfo(data) { + let lines = []; + let tokens = []; + + if (data["Title"]) { + tokens.push(data["Title"]); + lines.push(tokens.join(" ")); + lines.push(format.subtitle(data)); + } else { + lines.push(fileName(data)); + lines.push("\u00A0"); + } + + return lines; +} + +// FIXME vyfaktorovat nekam do haje +function fileName(data) { + return data["file"].split("/").pop(); +} + diff --git a/app/js/yt.js b/app/js/yt.js index 2117a63..5db1a3c 100644 --- a/app/js/yt.js +++ b/app/js/yt.js @@ -1,8 +1,9 @@ -import * as mpd from "./lib/mpd.js"; import * as html from "./lib/html.js"; import * as conf from "./conf.js"; -let node; +import Component from "./component.js"; + + const decoder = new TextDecoder("utf-8"); function decodeChunk(byteArray) { @@ -10,54 +11,60 @@ function decodeChunk(byteArray) { return decoder.decode(byteArray).replace(/\u000d/g, "\n"); } -async function post(q) { - let pre = node.querySelector("pre"); - html.clear(pre); - - node.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; +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()); } - reader.releaseLock(); - node.classList.remove("pending"); + _download() { + let url = prompt("Please enter a YouTube URL:"); + if (!url) { return; } - if (response.status == 200) { - mpd.command(`update ${mpd.escape(conf.ytPath)}`); + this._post(url); + } + + _search() { + let q = prompt("Please enter a search string:"); + if (!q) { return; } + + this._post(`ytsearch:${q}`); + } + + _clear() { + html.clear(this.querySelector("pre")); + } + + async _post(q) { + let pre = this.querySelector("pre"); + html.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 ${this._mpd.escape(conf.ytPath)}`); + } + } + + _onComponentChange(c, isThis) { + this.hidden = !isThis; } } -function download() { - let url = prompt("Please enter a YouTube URL:"); - if (!url) { return; } - - post(url); -} - -function search() { - let q = prompt("Please enter a search string:"); - if (!q) { return; } - post(`ytsearch:${q}`); -} - -function clear() { - html.clear(node.querySelector("pre")); -} - -export async function activate() {} - -export function init(n) { - node = n; - node.querySelector(".download").addEventListener("click", e => download()); - node.querySelector(".search-download").addEventListener("click", e => search()); - node.querySelector(".clear").addEventListener("click", e => clear()); -} +customElements.define("cyp-yt", YT); \ No newline at end of file