diff --git a/app/app.css b/app/app.css index f09f782..0d7de98 100644 --- a/app/app.css +++ b/app/app.css @@ -7,7 +7,9 @@ html { background-color: var(--fg); } body { - display: flex; + margin: 0; +} +cyp-app { flex-direction: column; box-sizing: border-box; font-family: lato, sans-serif; @@ -20,6 +22,9 @@ body { overflow: hidden; height: 100vh; } +cyp-app:not([hidden]) { + display: flex; +} header, footer { z-index: 1; @@ -35,7 +40,6 @@ button { -webkit-appearance: none; -moz-appearance: none; appearance: none; - display: flex; flex-direction: row; align-items: center; display: inline-flex; @@ -46,6 +50,9 @@ button { line-height: 1; cursor: pointer; } +button:not([hidden]) { + display: flex; +} select { background-color: transparent; border: 1px solid var(--fg); @@ -74,24 +81,30 @@ select { fill: currentColor; } .flex-row { - display: flex; flex-direction: row; align-items: center; } -.flex-column { +.flex-row:not([hidden]) { display: flex; +} +.flex-column { flex-direction: column; } +.flex-column:not([hidden]) { + display: flex; +} .long-line { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .multiline { - display: flex; flex-direction: row; align-items: center; } +.multiline:not([hidden]) { + display: flex; +} .multiline h2 { font-weight: normal; } @@ -159,162 +172,173 @@ x-range:not([disabled]) .-thumb:hover { } main { flex-grow: 1; - overflow: hidden; + overflow: auto; } -nav ul { - margin: 0; - padding: 0; - list-style: none; - display: flex; +cyp-menu { flex-direction: row; align-items: center; + height: 56px; } -nav ul li { - flex: 1 0 0; +cyp-menu:not([hidden]) { display: flex; +} +cyp-menu button { + flex: 1 0 0; + height: 100%; flex-direction: column; align-items: center; - cursor: pointer; - padding: 4px 0 8px 0; + padding-top: 4px; border-top: 4px solid transparent; } -nav ul li .icon { +cyp-menu button:not([hidden]) { + display: flex; +} +cyp-menu button .icon { margin-right: var(--icon-spacing); } -nav ul li.active { +cyp-menu button.active { border-top-color: var(--primary); color: var(--primary); background-color: rgb(var(--primary-raw), 0.1); } @media (max-width: 480px) { - nav ul li { + cyp-menu button { flex-direction: row; justify-content: center; } - nav ul li:not([data-for=queue]) .icon { + cyp-menu button:not([data-for=queue]) .icon { margin-right: 0; } - nav ul li span:not([id]) { + cyp-menu button span:not([id]) { display: none; } } -#player { - display: flex; +cyp-player { flex-direction: row; align-items: center; align-items: stretch; } -#player:not([data-state=play]) .pause { +cyp-player:not([hidden]) { + display: flex; +} +cyp-player:not([data-state=play]) .pause { display: none; } -#player[data-state=play] .play { +cyp-player[data-state=play] .play { display: none; } -#player:not([data-flags~=random]) .random, -#player:not([data-flags~=repeat]) .repeat { +cyp-player:not([data-flags~=random]) .random, +cyp-player:not([data-flags~=repeat]) .repeat { opacity: 0.5; } -#player x-range { +cyp-player x-range { flex-grow: 1; --elapsed-color: var(--primary); } -#player .art { +cyp-player .art { margin-right: 0; height: 96px; } -#player .art img, -#player .art .icon { +cyp-player .art img, +cyp-player .art .icon { width: 96px; } -#player .info { +cyp-player .info { flex-grow: 2; flex-basis: 0; padding: 0 var(--icon-spacing); overflow: hidden; - display: flex; flex-direction: column; justify-content: space-around; } -#player .info h2 { +cyp-player .info:not([hidden]) { + display: flex; +} +cyp-player .info h2 { font-size: 125%; margin: 0; } -#player .info .title, -#player .info .subtitle { +cyp-player .info .title, +cyp-player .info .subtitle { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#player .timeline { - display: flex; +cyp-player .timeline { flex-direction: row; align-items: center; } -#player .timeline .duration, -#player .timeline .elapsed { +cyp-player .timeline:not([hidden]) { + display: flex; +} +cyp-player .timeline .duration, +cyp-player .timeline .elapsed { flex-basis: 5ch; text-align: center; } -#player .controls { +cyp-player .controls { flex-grow: 1; flex-basis: 0; max-width: 220px; - display: flex; flex-direction: column; justify-content: space-around; } -#player .controls .playback { +cyp-player .controls:not([hidden]) { display: flex; +} +cyp-player .controls .playback { flex-direction: row; align-items: center; justify-content: space-around; } -#player .controls .playback .icon { +cyp-player .controls .playback:not([hidden]) { + display: flex; +} +cyp-player .controls .playback .icon { width: 40px; } -#player .controls .playback .icon-play, -#player .controls .playback .icon-pause { +cyp-player .controls .playback .icon-play, +cyp-player .controls .playback .icon-pause { width: 64px; } -#player .controls .volume { - display: flex; +cyp-player .controls .volume { flex-direction: row; align-items: center; } -#player .controls .volume .mute { +cyp-player .controls .volume:not([hidden]) { + display: flex; +} +cyp-player .controls .volume .mute { margin-right: 4px; } -#player .misc { +cyp-player .misc { display: flex; flex-direction: column; align-self: stretch; justify-content: space-around; } -#player .misc .icon { +cyp-player .misc .icon { width: 32px; } @media (max-width: 519px) { - #player { + cyp-player { flex-wrap: wrap; justify-content: space-between; } - #player .info { + cyp-player .info { order: 1; flex-basis: 100%; height: 96px; } } -.component { - height: 100%; - display: flex; - flex-direction: column; -} .component header { - display: flex; flex-direction: row; align-items: center; padding: var(--spacing); } +.component header:not([hidden]) { + display: flex; +} .component header button { font-size: var(--font-size-large); font-weight: bold; @@ -331,10 +355,12 @@ nav ul li.active { padding: 0; } .component li { - display: flex; flex-direction: row; align-items: center; } +.component li:not([hidden]) { + display: flex; +} .component li .info { flex-grow: 1; overflow: hidden; @@ -363,79 +389,75 @@ nav ul li.active { .component li:nth-child(odd) { background-color: var(--bg-alt); } -#queue { - height: 100%; - display: flex; - flex-direction: column; -} -#queue header { - display: flex; +cyp-queue header { flex-direction: row; align-items: center; padding: var(--spacing); } -#queue header button { +cyp-queue header:not([hidden]) { + display: flex; +} +cyp-queue header button { font-size: var(--font-size-large); font-weight: bold; overflow: hidden; } -#queue header button .icon { +cyp-queue header button .icon { margin-right: var(--icon-spacing); } -#queue ul { +cyp-queue ul { flex-grow: 1; overflow: auto; list-style: none; margin: 0; padding: 0; } -#queue li { - display: flex; +cyp-queue li { flex-direction: row; align-items: center; } -#queue li .info { +cyp-queue li:not([hidden]) { + display: flex; +} +cyp-queue li .info { flex-grow: 1; overflow: hidden; } -#queue li .info .icon { +cyp-queue li .info .icon { color: var(--primary); margin-right: var(--icon-spacing); filter: drop-shadow(var(--text-shadow)); } -#queue li .info h2 { +cyp-queue li .info h2 { font-size: var(--font-size-large); margin: 0; } -#queue li .info h2, -#queue li .info div { +cyp-queue li .info h2, +cyp-queue li .info div { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -#queue li:not(.has-art) { +cyp-queue li:not(.has-art) { padding: 8px; } -#queue li button .icon { +cyp-queue li button .icon { width: 32px; } -#queue li:nth-child(odd) { +cyp-queue li:nth-child(odd) { background-color: var(--bg-alt); } -#queue .current { +cyp-queue .current { color: var(--primary); } -#library { - height: 100%; - display: flex; - flex-direction: column; -} #library header { - display: flex; flex-direction: row; align-items: center; padding: var(--spacing); } +#library header:not([hidden]) { + display: flex; +} #library header button { font-size: var(--font-size-large); font-weight: bold; @@ -452,10 +474,12 @@ nav ul li.active { padding: 0; } #library li { - display: flex; flex-direction: row; align-items: center; } +#library li:not([hidden]) { + display: flex; +} #library li .info { flex-grow: 1; overflow: hidden; @@ -521,17 +545,14 @@ nav ul li.active { font-size: 150%; margin: 4px 0; } -#fs { - height: 100%; - display: flex; - flex-direction: column; -} #fs header { - display: flex; flex-direction: row; align-items: center; padding: var(--spacing); } +#fs header:not([hidden]) { + display: flex; +} #fs header button { font-size: var(--font-size-large); font-weight: bold; @@ -548,10 +569,12 @@ nav ul li.active { padding: 0; } #fs li { - display: flex; flex-direction: row; align-items: center; } +#fs li:not([hidden]) { + display: flex; +} #fs li .info { flex-grow: 1; overflow: hidden; @@ -593,24 +616,23 @@ nav ul li.active { cursor: pointer; } #fs .info { - display: flex; flex-direction: row; align-items: center; } +#fs .info:not([hidden]) { + display: flex; +} #fs .info h2 { font-weight: normal; } -#playlists { - height: 100%; - display: flex; - flex-direction: column; -} #playlists header { - display: flex; flex-direction: row; align-items: center; padding: var(--spacing); } +#playlists header:not([hidden]) { + display: flex; +} #playlists header button { font-size: var(--font-size-large); font-weight: bold; @@ -627,10 +649,12 @@ nav ul li.active { padding: 0; } #playlists li { - display: flex; flex-direction: row; align-items: center; } +#playlists li:not([hidden]) { + display: flex; +} #playlists li .info { flex-grow: 1; overflow: hidden; @@ -660,24 +684,23 @@ nav ul li.active { background-color: var(--bg-alt); } #playlists .info { - display: flex; flex-direction: row; align-items: center; } +#playlists .info:not([hidden]) { + display: flex; +} #playlists .info h2 { font-weight: normal; } -#yt { - height: 100%; - display: flex; - flex-direction: column; -} #yt header { - display: flex; flex-direction: row; align-items: center; padding: var(--spacing); } +#yt header:not([hidden]) { + display: flex; +} #yt header button { font-size: var(--font-size-large); font-weight: bold; @@ -694,10 +717,12 @@ nav ul li.active { padding: 0; } #yt li { - display: flex; flex-direction: row; align-items: center; } +#yt li:not([hidden]) { + display: flex; +} #yt li .info { flex-grow: 1; overflow: hidden; @@ -755,36 +780,39 @@ nav ul li.active { background-position: 100% 100%; } } -#settings { +cyp-settings { font-size: var(--font-size-large); } -#settings dl { +cyp-settings dl { margin: var(--spacing); display: grid; grid-template-columns: max-content 1fr; align-items: center; grid-gap: var(--spacing); } -#settings dt { +cyp-settings dt { font-weight: bold; } -#settings dd { +cyp-settings dd { margin: 0; - display: flex; flex-direction: column; align-items: start; } -#settings label { +cyp-settings dd:not([hidden]) { display: flex; +} +cyp-settings label { flex-direction: row; align-items: center; } -#settings label [type=radio], -#settings label [type=checkbox] { +cyp-settings label:not([hidden]) { + display: flex; +} +cyp-settings label [type=radio], +cyp-settings label [type=checkbox] { margin: 0 4px 0 0; } .search { - display: flex; flex-direction: row; align-items: center; margin-left: auto; @@ -792,6 +820,9 @@ nav ul li.active { width: 32px; max-width: 20ch; } +.search:not([hidden]) { + display: flex; +} .search .icon { width: 32px; cursor: pointer; @@ -816,32 +847,48 @@ nav ul li.active { .art img { vertical-align: top; } -:root { +cyp-app { --font-size-large: 112.5%; --icon-spacing: 4px; --primary: rgb(var(--primary-raw)); --spacing: 8px; --box-shadow: 0 0 3px #000; } -:root[data-theme=light] { +cyp-app[theme=light] { --fg: #333; --bg: #f0f0f0; --bg-alt: #e0e0e0; --text-shadow: none; } -:root[data-theme=dark] { +cyp-app[theme=dark] { --fg: #f0f0f0; --bg: #333; --bg-alt: #555; --text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8); } -:root[data-color=dodgerblue] { +@media (prefers-color-scheme: dark) { + cyp-app[theme=auto] { + --fg: #f0f0f0; + --bg: #333; + --bg-alt: #555; + --text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8); + } +} +@media (prefers-color-scheme: light) { + cyp-app[theme=auto] { + --fg: #333; + --bg: #f0f0f0; + --bg-alt: #e0e0e0; + --text-shadow: none; + } +} +cyp-app[color=dodgerblue] { --primary-raw: 30, 144, 255; } -:root[data-color=darkorange] { +cyp-app[color=darkorange] { --primary-raw: 255, 140, 0; } -:root[data-color=limegreen] { +cyp-app[color=limegreen] { --primary-raw: 50, 205, 50; } @media (max-width: 480px) { diff --git a/app/css/app.less b/app/css/app.less index cc3a90e..91b69c5 100644 --- a/app/css/app.less +++ b/app/css/app.less @@ -5,6 +5,10 @@ html { } body { + margin: 0; +} + +cyp-app { .flex-column; box-sizing: border-box; @@ -57,7 +61,7 @@ select { @import "mixins.less"; @import "range.less"; @import "main.less"; -@import "nav.less"; +@import "menu.less"; @import "player.less"; @import "component.less"; @import "queue.less"; diff --git a/app/css/component.less b/app/css/component.less index 363dc79..fc81fa5 100644 --- a/app/css/component.less +++ b/app/css/component.less @@ -1,7 +1,4 @@ .component { - height: 100%; - .flex-column; - header { .flex-row; padding: var(--spacing); @@ -26,7 +23,7 @@ li { .flex-row; - .info { + .info { flex-grow: 1; overflow: hidden; diff --git a/app/css/main.less b/app/css/main.less index 394f547..c676bbc 100644 --- a/app/css/main.less +++ b/app/css/main.less @@ -1,4 +1,4 @@ main { flex-grow: 1; - overflow: hidden; + overflow: auto; } diff --git a/app/css/nav.less b/app/css/menu.less similarity index 82% rename from app/css/nav.less rename to app/css/menu.less index 457fabb..c96797a 100644 --- a/app/css/nav.less +++ b/app/css/menu.less @@ -1,17 +1,14 @@ -nav ul { - margin: 0; - padding: 0; - list-style: none; +cyp-menu { .flex-row; + height: 56px; - li { + button { flex: 1 0 0; + height: 100%; .flex-column; align-items: center; - - cursor: pointer; - padding: 4px 0 8px 0; + padding-top: 4px; border-top: 4px solid transparent; diff --git a/app/css/mixins.less b/app/css/mixins.less index dc0040c..7380221 100644 --- a/app/css/mixins.less +++ b/app/css/mixins.less @@ -1,11 +1,11 @@ .flex-row { - display: flex; + &:not([hidden]) { display: flex; } flex-direction: row; align-items: center; } .flex-column { - display: flex; + &:not([hidden]) { display: flex; } flex-direction: column; } diff --git a/app/css/player.less b/app/css/player.less index edab6ac..8cee6fa 100644 --- a/app/css/player.less +++ b/app/css/player.less @@ -1,4 +1,4 @@ -#player { +cyp-player { @art-size: 96px; .flex-row; align-items: stretch; @@ -36,7 +36,7 @@ .title, .subtitle { .long-line; } } - + .timeline { .flex-row; diff --git a/app/css/queue.less b/app/css/queue.less index f8f39ef..b2299f8 100644 --- a/app/css/queue.less +++ b/app/css/queue.less @@ -1,4 +1,4 @@ -#queue { +cyp-queue { .component; .current { color: var(--primary); } diff --git a/app/css/settings.less b/app/css/settings.less index 7c66706..98594f6 100644 --- a/app/css/settings.less +++ b/app/css/settings.less @@ -1,4 +1,4 @@ -#settings { +cyp-settings { font-size: var(--font-size-large); dl { @@ -26,4 +26,4 @@ margin: 0 4px 0 0; } } -} \ No newline at end of file +} diff --git a/app/css/variables.less b/app/css/variables.less index c15c662..4f97008 100644 --- a/app/css/variables.less +++ b/app/css/variables.less @@ -1,4 +1,4 @@ -:root { +cyp-app { --font-size-large: 112.5%; --icon-spacing: 4px; --primary: rgb(var(--primary-raw)); @@ -6,29 +6,40 @@ --box-shadow: 0 0 3px #000; } -:root[data-theme=light] { +.light() { --fg: #333; --bg: #f0f0f0; --bg-alt: #e0e0e0; --text-shadow: none; } -:root[data-theme=dark] { +.dark() { --fg: #f0f0f0; --bg: #333; --bg-alt: #555; --text-shadow: 0 1px 1px rgba(0, 0, 0, 0.8); } -:root[data-color=dodgerblue] { +cyp-app[theme=light] { .light(); } +cyp-app[theme=dark] { .dark(); } + +@media (prefers-color-scheme: dark) { + cyp-app[theme=auto] { .dark(); } +} + +@media (prefers-color-scheme: light) { + cyp-app[theme=auto] { .light(); } +} + +cyp-app[color=dodgerblue] { --primary-raw: 30, 144, 255; } -:root[data-color=darkorange] { +cyp-app[color=darkorange] { --primary-raw: 255, 140, 0; } -:root[data-color=limegreen] { +cyp-app[color=limegreen] { --primary-raw: 50, 205, 50; } diff --git a/app/index.html b/app/index.html index 242a05e..b8e5277 100644 --- a/app/index.html +++ b/app/index.html @@ -7,8 +7,9 @@ +
-
+

@@ -35,16 +36,16 @@
-
+
-
+
    -
    +
      @@ -64,11 +65,12 @@
      
       			
      -			
      +
      Theme
      @@ -89,25 +91,24 @@
      -
      +
      + + + + + + + + + +
      diff --git a/app/js/app.js b/app/js/app.js index 5e38ed5..8c68635 100644 --- a/app/js/app.js +++ b/app/js/app.js @@ -1,33 +1,18 @@ -import * as nav from "./nav.js"; -import * as mpd from "./lib/mpd.js"; -import * as player from "./player.js"; -import * as html from "./lib/html.js"; -import * as range from "./lib/range.js"; +import "./lib/range.js"; +import "./menu.js"; +import "./player.js"; +import "./queue.js"; + +import * as mpd from "./lib/mpd.js"; +import * as mpdMock from "./lib/mpd-mock.js"; +import * as html from "./lib/html.js"; -import * as queue from "./queue.js"; import * as library from "./library.js"; import * as fs from "./fs.js"; import * as playlists from "./playlists.js"; import * as yt from "./yt.js"; import * as settings from "./settings.js"; -const components = { queue, library, fs, playlists, yt, settings }; - -export function activate(what) { - location.hash = what; - - for (let id in components) { - let node = document.querySelector(`#${id}`); - if (what == id) { - node.style.display = ""; - components[id].activate(); - } else { - node.style.display = "none"; - } - } - nav.active(what); -} - function initIcons() { Array.from(document.querySelectorAll("[data-icon]")).forEach(node => { let icon = html.icon(node.dataset.icon); @@ -35,39 +20,49 @@ function initIcons() { }); } -function fromHash() { - let hash = location.hash.substring(1); - activate(hash || "queue"); -} - -function onHashChange(e) { - fromHash(); -} - -async function init() { - initIcons(); +async function mpdExecutor(resolve, reject) { try { await mpd.init(); + resolve(mpd); } catch (e) { + resolve(mpdMock); console.error(e); + reject(e); } - - nav.init(document.querySelector("nav")); - for (let id in components) { - let node = document.querySelector(`#${id}`); - components[id].init(node); - } - - player.init(document.querySelector("#player")); - window.addEventListener("hashchange", onHashChange); - fromHash(); } - -init(); - class App extends HTMLElement { - get mpd() { return mpd; } + constructor() { + super(); + initIcons(); + + this._mpd = new Promise(mpdExecutor); + + this._load(); + } + + get mpd() { return this._mpd; } + + async _load() { + const promises = ["cyp-player"].map(name => customElements.whenDefined(name)); + await Promise.all(promises); + + const onHashChange = () => { + const hash = location.hash.substring(1); + this._activate(hash || "queue"); + } + + window.addEventListener("hashchange", onHashChange); + onHashChange(); + } + + _activate(what) { + location.hash = what; + this.setAttribute("component", what); + + const component = this.querySelector(`cyp-${what}`); + // component.activate(); + } } customElements.define("cyp-app", App); diff --git a/app/js/component.js b/app/js/component.js index 91b9424..5cd709c 100644 --- a/app/js/component.js +++ b/app/js/component.js @@ -1,3 +1,32 @@ +const APP = "cyp-app"; + export default class Component extends HTMLElement { - get _app() { return this.closest("cyp-app"); } + constructor() { + super(); + + this._app.then(app => { + let mo = new MutationObserver(mrs => { + mrs.forEach(mr => this._onAppAttributeChange(mr)); + }); + mo.observe(app, {attributes:true}); + }); + } + + _onAppAttributeChange(mr) { + if (mr.attributeName != "component") { return; } + const component = mr.target.getAttribute(mr.attributeName); + const isThis = (this.nodeName.toLowerCase() == `cyp-${component}`); + this._onComponentChange(component, isThis); + } + + get _app() { + return customElements.whenDefined(APP) + .then(() => this.closest(APP)); + } + + get _mpd() { + return this._app.then(app => app.mpd); + } + + _onComponentChange(component) {} } diff --git a/app/js/lib/range.js b/app/js/lib/range.js deleted file mode 120000 index fcc7d2c..0000000 --- a/app/js/lib/range.js +++ /dev/null @@ -1 +0,0 @@ -../../../node_modules/custom-range/range.js \ No newline at end of file diff --git a/app/js/lib/range.js b/app/js/lib/range.js new file mode 100644 index 0000000..b267531 --- /dev/null +++ b/app/js/lib/range.js @@ -0,0 +1,170 @@ +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); diff --git a/app/js/menu.js b/app/js/menu.js new file mode 100644 index 0000000..8c90278 --- /dev/null +++ b/app/js/menu.js @@ -0,0 +1,31 @@ +import Component from "./component.js"; + +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 _listen() { + const app = await this._app; + let mo = new MutationObserver(_ => this._sync()) + mo.observe(app, {attributes:true}); + } + + async _activate(component) { + const app = await this._app; + app.setAttribute("component", component); + } + + _onComponentChange(component) { + this._tabs.forEach(tab => { + tab.classList.toggle("active", tab.dataset.for == component); + }); + } +} + +customElements.define("cyp-menu", Menu); diff --git a/app/js/nav.js b/app/js/nav.js deleted file mode 100644 index fb888c6..0000000 --- a/app/js/nav.js +++ /dev/null @@ -1,16 +0,0 @@ -import * as app from "./app.js"; - -let tabs = []; - -export function init(node) { - tabs = Array.from(node.querySelectorAll("[data-for]")); - tabs.forEach(tab => { - tab.addEventListener("click", e => app.activate(tab.dataset.for)); - }); -} - -export function active(id) { - tabs.forEach(tab => { - tab.classList.toggle("active", tab.dataset.for == id); - }); -} diff --git a/app/js/player.js b/app/js/player.js index d7338c7..1dfe891 100644 --- a/app/js/player.js +++ b/app/js/player.js @@ -1,140 +1,146 @@ -//import * as mpd from "./lib/mpd.js"; -import * as mpd from "./lib/mpd-mock.js"; import * as art from "./lib/art.js"; import * as html from "./lib/html.js"; import * as format from "./lib/format.js"; -import * as pubsub from "./lib/pubsub.js"; import Component from "./component.js"; const DELAY = 1000; -const DOM = {}; - -let current = {}; -let node; -let idleTimeout = null; -let toggledVolume = 0; - -function sync(data) { - 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 && current["volume"] > 0) { // muted - toggledVolume = current["volume"]; - html.clear(DOM.mute); - DOM.mute.appendChild(html.icon("volume-off")); - } - - if (data["volume"] > 0 && current["volume"] == 0) { // restored - toggledVolume = 0; - html.clear(DOM.mute); - DOM.mute.appendChild(html.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 = format.time(elapsed); - - if (data["file"] != current["file"]) { // changed song - if (data["file"]) { // playing at all? - let duration = Number(data["duration"]); - DOM.duration.textContent = format.time(duration); - DOM.progress.max = duration; - DOM.progress.disabled = false; - DOM.title.textContent = data["Title"] || data["file"].split("/").pop(); - DOM.subtitle.textContent = format.subtitle(data, {duration:false}); - } else { - DOM.title.textContent = ""; - DOM.subtitle.textContent = ""; - DOM.progress.value = 0; - DOM.progress.disabled = true; - } - - pubsub.publish("song-change", null, data); - } - - let artistNew = data["AlbumArtist"] || data["Artist"]; - let artistOld = current["AlbumArtist"] || current["Artist"]; - if (artistNew != artistOld || data["Album"] != current["Album"]) { // changed album (art) - html.clear(DOM.art); - art.get(artistNew, data["Album"], data["file"]).then(src => { - if (src) { - html.node("img", {src}, "", DOM.art); - } else { - html.icon("music", DOM.art); - } - }); - } - - let flags = []; - if (data["random"] == "1") { flags.push("random"); } - if (data["repeat"] == "1") { flags.push("repeat"); } - node.dataset.flags = flags.join(" "); - - node.dataset.state = data["state"]; - - current = data; -} - -function idle() { - idleTimeout = setTimeout(update, DELAY); -} - -function clearIdle() { - idleTimeout && clearTimeout(idleTimeout); - idleTimeout = null; -} - -async function command(cmd) { - clearIdle(); - let data = await mpd.commandAndStatus(cmd); - sync(data); - idle(); -} - -export async function update() { - clearIdle(); - let data = await mpd.status(); - sync(data); - idle(); -} - -export function init(n) { - node = n; - let all = node.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", e => command("play")); - DOM.pause.addEventListener("click", e => command("pause 1")); - DOM.prev.addEventListener("click", e => command("previous")); - DOM.next.addEventListener("click", e => command("next")); - - DOM.random.addEventListener("click", e => command(`random ${current["random"] == "1" ? "0" : "1"}`)); - DOM.repeat.addEventListener("click", e => command(`repeat ${current["repeat"] == "1" ? "0" : "1"}`)); - - DOM.volume.addEventListener("input", e => command(`setvol ${e.target.valueAsNumber}`)); - DOM.progress.addEventListener("input", e => command(`seekcur ${e.target.valueAsNumber}`)); - - DOM.mute.addEventListener("click", e => command(`setvol ${toggledVolume}`)); - - update(); -} class Player extends Component { + constructor() { + super(); + this._current = {}; + this._toggledVolume = 0; + this._idleTimeout = null; + this._dom = this._initDOM(); + 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) { + const mpd = await this._mpd; + this._clearIdle(); + const data = await mpd.commandAndStatus(cmd); + this._sync(data); + this._idle(); + } + + _idle() { + this._idleTimeout = setTimeout(() => this._update(), DELAY); + } + + _clearIdle() { + this._idleTimeout && clearTimeout(this._idleTimeout); + this._idleTimeout = null; + } + + async _update() { + const mpd = await this._mpd; + this._clearIdle(); + const data = await mpd.status(); + this._sync(data); + this._idle(); + } + + _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"]; + html.clear(DOM.mute); + DOM.mute.appendChild(html.icon("volume-off")); + } + + if (data["volume"] > 0 && this._current["volume"] == 0) { // restored + this._toggledVolume = 0; + html.clear(DOM.mute); + DOM.mute.appendChild(html.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 = format.time(elapsed); + + if (data["file"] != this._current["file"]) { // changed song + if (data["file"]) { // playing at all? + let duration = Number(data["duration"]); + DOM.duration.textContent = format.time(duration); + DOM.progress.max = duration; + DOM.progress.disabled = false; + DOM.title.textContent = data["Title"] || data["file"].split("/").pop(); + DOM.subtitle.textContent = format.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) + html.clear(DOM.art); + art.get(artistNew, data["Album"], data["file"]).then(src => { + if (src) { + html.node("img", {src}, "", DOM.art); + } else { + html.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; + } + + async _dispatchSongChange(detail) { + const app = await this._app; + const e = new CustomEvent("song-change", {detail}); + app.dispatchEvent(e); + } } -customElements.define("cyp-player", Player); \ No newline at end of file +customElements.define("cyp-player", Player); diff --git a/app/js/queue.js b/app/js/queue.js index e000b0c..78ebc10 100644 --- a/app/js/queue.js +++ b/app/js/queue.js @@ -1,61 +1,76 @@ -// import * as mpd from "./lib/mpd.js"; -import * as mpd from "./lib/mpd-mock.js"; import * as html from "./lib/html.js"; -import * as pubsub from "./lib/pubsub.js"; import * as ui from "./lib/ui.js"; -let node; -let currentId; +import Component from "./component.js"; -function updateCurrent() { - let all = Array.from(node.querySelectorAll("[data-song-id]")); - all.forEach(node => { - node.classList.toggle("current", node.dataset.songId == currentId); - }); +class Queue extends Component { + constructor() { + super(); + this._currentId = null; + + this.querySelector(".clear").addEventListener("click", async _ => { + const mpd = await this._mpd; + await mpd.command("clear"); + this._sync(); + }); + + this.querySelector(".save").addEventListener("click", async _ => { + let name = prompt("Save current queue as a playlist?", "name"); + if (name === null) { return; } + const mpd = await this._mpd; + mpd.command(`save "${mpd.escape(name)}"`); + }); + + this._app.then(app => { + app.addEventListener("song-change", this); + app.addEventListener("queue-change", this); + }) + this._sync(); + } + + handleEvent(e) { + switch (e.type) { + case "song-change": + this._currentId = e.detail["Id"]; + this._updateCurrent(); + break; + + case "queue-change": + this._sync(); + break; + } + } + + _onComponentChange(c, isThis) { + this.hidden = !isThis; + + isThis && this._sync(); + } + + async _sync() { + const mpd = await this._mpd; + let songs = await mpd.listQueue(); + this._buildSongs(songs); + + // FIXME pubsub? + document.querySelector("#queue-length").textContent = `(${songs.length})`; + } + + _updateCurrent() { + let all = Array.from(this.querySelectorAll("[data-song-id]")); + all.forEach(node => { + node.classList.toggle("current", node.dataset.songId == this._currentId); + }); + } + + _buildSongs(songs) { + let ul = this.querySelector("ul"); + html.clear(ul); + + songs.map(song => ui.song(ui.CTX_QUEUE, song, ul)); + + this._updateCurrent(); + } } -function buildSongs(songs) { - let ul = node.querySelector("ul"); - html.clear(ul); - - songs.map(song => ui.song(ui.CTX_QUEUE, song, ul)); - - updateCurrent(); -} - -function onSongChange(message, publisher, data) { - currentId = data["Id"]; - updateCurrent(); -} - -function onQueueChange(message, publisher, data) { - syncQueue(); -} - -async function syncQueue() { - let songs = await mpd.listQueue(); - buildSongs(songs); - document.querySelector("#queue-length").textContent = `(${songs.length})`; -} - -export async function activate() { - syncQueue(); -} - -export function init(n) { - node = n; - syncQueue(); - pubsub.subscribe("song-change", onSongChange); - pubsub.subscribe("queue-change", onQueueChange); - - node.querySelector(".clear").addEventListener("click", async e => { - await mpd.command("clear"); - syncQueue(); - }); - - node.querySelector(".save").addEventListener("click", e => { - let name = prompt("Save current queue as a playlist?", "name"); - if (name === null) { return; } - mpd.command(`save "${mpd.escape(name)}"`); - }); -} +customElements.define("cyp-queue", Queue); diff --git a/app/js/settings.js b/app/js/settings.js index 7eb499c..ff000f4 100644 --- a/app/js/settings.js +++ b/app/js/settings.js @@ -1,52 +1,70 @@ -import * as mpd from "./lib/mpd.js"; +import Component from "./component.js"; -let node; -let inputs = {}; const prefix = "cyp"; -function loadFromStorage(key, def) { - return localStorage.getItem(`${prefix}-${key}`) || def; +function loadFromStorage(key) { + return localStorage.getItem(`${prefix}-${key}`); } function saveToStorage(key, value) { return localStorage.setItem(`${prefix}-${key}`, value); } -function load() { - let theme = loadFromStorage("theme", "dark"); - inputs.theme.value = theme; - setTheme(theme); +class Settings extends Component { + constructor() { + super(); + this._inputs = { + theme: this.querySelector("[name=theme]"), + color: Array.from(this.querySelectorAll("[name=color]")) + }; - let color = loadFromStorage("color", "dodgerblue"); - inputs.color.forEach(input => { - input.checked = (input.value == color); - input.parentNode.style.color = input.value; - }); - setColor(color); + this._load(); + + 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)); + }); + } + + _onAppAttributeChange(mr) { + if (mr.attributeName == "theme") { this._syncTheme(); } + if (mr.attributeName == "color") { this._syncColor(); } + } + + async _syncTheme() { + const app = await this._app; + this._inputs.theme.value = app.getAttribute("theme"); + } + + async _syncColor() { + const app = await this._app; + this._inputs.color.forEach(input => { + input.checked = (input.value == app.getAttribute("color")); + input.parentNode.style.color = input.value; + }); + } + + async _load() { + const app = await this._app; + + const theme = loadFromStorage("theme"); + (theme ? app.setAttribute("theme", theme) : this._syncTheme()); + + const color = loadFromStorage("color"); + (color ? app.setAttribute("color", color) : this._syncColor()); + } + + async _setTheme(theme) { + const app = await this._app; + saveToStorage("theme", theme); + app.setAttribute("theme", theme); + } + + async _setColor(color) { + const app = await this._app; + saveToStorage("color", color); + app.setAttribute("color", color); + } } -function setTheme(theme) { - saveToStorage("theme", theme); - document.documentElement.dataset.theme = theme; -} - -function setColor(color) { - saveToStorage("color", color); - document.documentElement.dataset.color = color; -} - -export async function activate() {} - -export function init(n) { - node = n; - - inputs.theme = n.querySelector("[name=theme]"); - inputs.color = Array.from(n.querySelectorAll("[name=color]")); - - load(); - - inputs.theme.addEventListener("change", e => setTheme(e.target.value)); - inputs.color.forEach(input => { - input.addEventListener("click", e => setColor(e.target.value)); - }); -} +customElements.define("cyp-settings", Settings); diff --git a/index.js b/index.js index 64ed29d..cb628b3 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,6 @@ const cmd = "youtube-dl"; function downloadYoutube(q, response) { response.setHeader("Content-Type", "text/plain"); // necessary for firefox to read by chunks -// response.setHeader("Content-Type", "text/plain; charset=utf-8"); console.log("YouTube downloading", q); let args = [