selection wip

This commit is contained in:
Ondrej Zara 2020-03-09 14:26:39 +01:00
parent bb5e2d1fb6
commit b81d1edea2
No known key found for this signature in database
GPG Key ID: B0A5751E616840C5
17 changed files with 452 additions and 185 deletions

View File

@ -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;
}

View File

@ -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";

0
app/css/commands.less Normal file
View File

View File

@ -1,4 +0,0 @@
main {
flex-grow: 1;
overflow: auto;
}

View File

@ -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;
}
}
}

View File

@ -20,3 +20,11 @@
h2 { font-weight: normal; }
}
.selectable {
border-left: 4px solid transparent;
&.selected {
border-left-color: var(--primary);
}
}

30
app/css/song.less Normal file
View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
#yt {
cyp-yt {
.component;
header {

View File

@ -40,11 +40,12 @@
</header>
<main>
<cyp-queue>
<!--
<header>
<button class="clear" data-icon="close" title="Clear the queue"></button>
<button class="save" data-icon="content-save" title="Save the queue"></button>
</header>
<ul></ul>
-->
</cyp-queue>
<cyp-playlists>
<ul></ul>
@ -57,14 +58,14 @@
<header></header>
<ul></ul>
</section>
<section id="yt">
<cyp-yt>
<header>
<button class="download" data-icon="download">Download</button>
<button class="search-download" data-icon="magnify">Search &amp; Download</button>
<button class="clear" data-icon="close">Clear</button>
</header>
<pre></pre>
</section>
</cyp-yt>
<cyp-settings>
<dl>
<dt>Theme</dt>

View File

@ -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;
}
}
}

View File

@ -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");

View File

@ -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";

58
app/js/lib/selection.js Normal file
View File

@ -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();
}
}

View File

@ -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)}"`);

View File

@ -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) {

View File

@ -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();
}

View File

@ -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);