This commit is contained in:
Ondrej Zara 2020-03-13 23:01:16 +01:00
parent c3871fa486
commit a57207f80e
No known key found for this signature in database
GPG key ID: B0A5751E616840C5
21 changed files with 166 additions and 421 deletions

View file

@ -28,12 +28,17 @@ footer {
}
}
input, select, button {
color: inherit;
input, select {
font: inherit;
}
select {
color: inherit;
}
button {
color: inherit;
font: inherit;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;

View file

@ -7,6 +7,7 @@ cyp-app {
height: 100vh;
font-family: lato, sans-serif;
font-size: 16px;
line-height: 1.25;
background-color: var(--bg);
color: var(--fg);

View file

@ -1,2 +1,27 @@
cyp-library {
nav {
.flex-column;
align-items: center;
button {
.font-large;
width: 200px;
margin-top: 2em;
text-decoration: underline;
.icon {
width: 32px;
margin-right: var(--icon-spacing);
}
}
}
form {
.item;
.flex-row;
button:first-of-type {
margin-left: 8px;
}
}
}

View file

@ -1,7 +1,7 @@
cyp-settings {
--spacing: 8px;
font-size: var(--font-size-large);
.font-large;
dl {
margin: var(--spacing);

View file

@ -5,12 +5,6 @@ cyp-song {
.flex-column;
min-width: 0; // bez tohoto se odmita zmensit
/* FIXME toto je relikt z .component
.icon {
color: var(--primary);
}
*/
.subtitle { .ellipsis; }
}

View file

@ -14,6 +14,11 @@
text-overflow: ellipsis;
}
.font-large {
font-size: 18px;
line-height: 24px;
}
.selectable {
cursor: pointer;
position: relative; // kotva pro selected::before
@ -59,11 +64,11 @@
}
.title {
font-weight: bold;
// FIXME take line-height, at vychazi celociselne
font-size: var(--font-size-large);
min-width: 0;
.font-large;
.ellipsis;
font-weight: bold;
min-width: 0;
}
button {

View file

@ -1,7 +1,6 @@
@breakpoint-menu: 480px;
cyp-app {
--font-size-large: 112.5%;
--icon-spacing: 4px;
--primary: rgb(var(--primary-raw));
--primary-tint: rgba(var(--primary-raw), 0.1);

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
import * as html from "./html.js";
import * as conf from "./conf.js";
let cache = {};
const cache = {};
const MIME = "image/jpeg";
const STORAGE_PREFIX = `art-${conf.artSize}` ;
@ -14,26 +14,26 @@ function load(key) {
}
async function bytesToImage(bytes) {
let blob = new Blob([bytes]);
let src = URL.createObjectURL(blob);
let image = html.node("img", {src});
const blob = new Blob([bytes]);
const src = URL.createObjectURL(blob);
const image = html.node("img", {src});
return new Promise(resolve => {
image.onload = () => resolve(image);
});
}
function resize(image) {
let canvas = html.node("canvas", {width:conf.artSize, height:conf.artSize});
let ctx = canvas.getContext("2d");
const canvas = html.node("canvas", {width:conf.artSize, height:conf.artSize});
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas;
}
export async function get(mpd, artist, album, songUrl = null) {
let key = `${artist}-${album}`;
const key = `${artist}-${album}`;
if (key in cache) { return cache[key]; }
let loaded = await load(key);
const loaded = await load(key);
if (loaded) {
cache[key] = loaded;
return loaded;
@ -43,18 +43,18 @@ export async function get(mpd, artist, album, songUrl = null) {
// promise to be returned in the meantime
let resolve;
let promise = new Promise(res => resolve = res);
const promise = new Promise(res => resolve = res);
cache[key] = promise;
try {
let data = await mpd.albumArt(songUrl);
let bytes = new Uint8Array(data);
let image = await bytesToImage(bytes);
let url = resize(image).toDataURL(MIME);
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);
} catch (e) {
} else {
cache[key] = null;
}
return cache[key];

View file

@ -47,13 +47,15 @@ class Library extends Component {
_showRoot() {
html.clear(this);
html.button({icon:"artist"}, "Artists and albums", this)
const nav = html.node("nav", {}, "", this);
html.button({icon:"artist"}, "Artists and albums", nav)
.addEventListener("click", _ => this._listTags("AlbumArtist"));
html.button({icon:"folder"}, "Files and directories", this)
html.button({icon:"folder"}, "Files and directories", nav)
.addEventListener("click", _ => this._listPath(""));
html.button({icon:"magnify"}, "Search", this)
html.button({icon:"magnify"}, "Search", nav)
.addEventListener("click", _ => this._showSearch());
}
@ -72,7 +74,7 @@ class Library extends Component {
path && this._buildBack(path);
paths["directory"].forEach(path => this._buildPath(path));
paths["file"].forEach(path => this._buildPath(path));
}
}
async _listSongs(filter) {
const songs = await this._mpd.listSongs(filter);
@ -82,7 +84,50 @@ class Library extends Component {
}
_showSearch() {
html.clear(this);
const form = html.node("form", {}, "", this);
const input = html.node("input", {type:"text"}, "", form);
html.button({icon:"magnify"}, "", form);
form.addEventListener("submit", e => {
e.preventDefault();
const q = input.value.trim();
if (q.length < 3) { return; }
this._doSearch(q, form);
});
input.focus();
}
async _doSearch(q, form) {
const songs1 = await this._mpd.searchSongs({"AlbumArtist": q});
const songs2 = await this._mpd.searchSongs({"Album": q});
const songs3 = await this._mpd.searchSongs({"Title": q});
html.clear(this);
this.appendChild(form);
this._aggregateSearch(songs1, "AlbumArtist");
this._aggregateSearch(songs2, "Album");
songs3.forEach(song => this.appendChild(new Song(song)));
}
_aggregateSearch(songs, tag) {
let results = new Map();
songs.forEach(song => {
let filter = {}, value;
const artist = song["AlbumArtist"] || song["Artist"]
if (tag == "Album") {
value = song[tag];
if (artist) { filter["AlbumArtist"] = artist; }
}
if (tag == "AlbumArtist") { value = artist; }
results.set(value, filter);
});
results.forEach((filter, value) => this._buildTag(tag, value, filter));
}
_buildTag(tag, value, filter) {
@ -150,11 +195,7 @@ class Library extends Component {
sel.addCommandAll();
sel.addCommand(async items => {
const commands = [
"clear",
...items.map(createEnqueueCommand),
"play"
];
const commands = ["clear",...items.map(createEnqueueCommand), "play"];
await this._mpd.command(commands);
this.selection.clear();
this._app.dispatchEvent(new CustomEvent("queue-change")); // fixme notification?

View file

@ -1,29 +1,20 @@
import Item from "../item.js";
import * as html from "../html.js";
import * as format from "../format.js";
function baseName(path) {
return path.split("/").pop();
}
export default class Path extends Item {
constructor(data) {
super();
this._data = data;
if ("directory" in this._data) {
this.file = data["directory"];
} else {
this.file = data["file"];
}
this._isDirectory = ("directory" in this._data);
}
get file() { return (this._isDirectory ? this._data["directory"] : this._data["file"]); }
connectedCallback() {
if ("directory" in this._data) {
this.appendChild(html.icon("folder"));
} else {
this.appendChild(html.icon("music"));
}
this._buildTitle(baseName(this.file));
this.appendChild(html.icon(this._isDirectory ? "folder" : "music"));
this._buildTitle(format.fileName(this.file));
}
}

View file

@ -1,9 +1,9 @@
import * as art from "../art.js";
import * as html from "../html.js";
import * as format from "../format.js";
import Component from "../component.js";
const DELAY = 1000;
class Player extends Component {
@ -104,7 +104,7 @@ class Player extends Component {
DOM.duration.textContent = format.time(duration);
DOM.progress.max = duration;
DOM.progress.disabled = false;
DOM.title.textContent = data["Title"] || data["file"].split("/").pop();
DOM.title.textContent = data["Title"] || format.fileName(data["file"]);
DOM.subtitle.textContent = format.subtitle(data, {duration:false});
} else {
DOM.title.textContent = "";
@ -118,6 +118,7 @@ class Player extends Component {
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(this._mpd, artistNew, data["Album"], data["file"]).then(src => {

View file

@ -11,7 +11,7 @@ function generateMoveCommands(items, diff, all) {
.map(item => {
let index = all.indexOf(item) + diff;
if (index < 0 || index >= all.length) { return null; } // this does not move
return `moveid ${item.data["Id"]} ${index}`;
return `moveid ${item.songId} ${index}`;
})
.filter(command => command);
}
@ -58,7 +58,7 @@ class Queue extends Component {
_updateCurrent() {
Array.from(this.children).forEach(/** @param {HTMLElement} node */ node => {
node.classList.toggle("current", node.dataset.songId == this._currentId);
node.classList.toggle("current", node.songId == this._currentId);
});
}
@ -71,7 +71,7 @@ class Queue extends Component {
this.appendChild(node);
node.addButton("play", async _ => {
await this._mpd.command(`playid ${song["Id"]}`);
await this._mpd.command(`playid ${node.songId}`);
});
});
@ -101,7 +101,7 @@ class Queue extends Component {
name = escape(name);
const commands = items.map(item => {
return `playlistadd "${name}" "${escape(item.data["file"])}"`;
return `playlistadd "${name}" "${escape(item.file)}"`;
});
await this._mpd.command(commands); // FIXME notify?
@ -110,7 +110,7 @@ class Queue extends Component {
sel.addCommand(async items => {
if (!confirm(`Remove these ${items.length} songs from the queue?`)) { return; }
const commands = items.map(item => `deleteid ${item.data["Id"]}`);
const commands = items.map(item => `deleteid ${item.songId}`);
await this._mpd.command(commands);
this._sync();

View file

@ -5,12 +5,14 @@ import Item from "../item.js";
export default class Song extends Item {
constructor(data) {
super();
this.data = data; // FIXME verejne?
this.dataset.songId = data["Id"]; // FIXME toto maji jen ve fronte
this._data = data;
}
get file() { return this._data["file"]; }
get songId() { return this._data["Id"]; }
connectedCallback() {
const data = this.data;
const data = this._data;
const block = html.node("div", {className:"multiline"}, "", this);
@ -29,13 +31,8 @@ export default class Song extends Item {
}
_buildTitle(data) {
return super._buildTitle(data["Title"] || fileName(data));
return super._buildTitle(data["Title"] || format.fileName(this.file));
}
}
customElements.define("cyp-song", Song);
// FIXME vyfaktorovat nekam do haje
function fileName(data) {
return data["file"].split("/").pop();
}

View file

@ -13,4 +13,8 @@ export function subtitle(data, options = {duration:true}) {
data["Album"] && tokens.push(data["Album"]);
options.duration && data["duration"] && tokens.push(time(Number(data["duration"])));
return tokens.join(SEPARATOR);
}
}
export function fileName(file) {
return file.split("/").pop();
}

View file

@ -1,70 +0,0 @@
import * as mpd from "./lib/mpd.js";
import * as html from "./lib/html.js";
import * as ui from "./lib/ui.js";
import Search from "./lib/search.js";
let node, search;
function buildHeader(path) {
let header = node.querySelector("header");
html.clear(header);
search.reset();
header.appendChild(search.getNode());
path.split("/").filter(x => x).forEach((name, index, all) => {
index && html.node("span", {}, " / ", header);
let button = html.button({icon:"folder"}, name, header);
let path = all.slice(0, index+1).join("/");
button.addEventListener("click", e => list(path));
});
}
function buildDirectory(data, parent) {
let path = data["directory"];
let name = path.split("/").pop();
let node = ui.group(ui.CTX_FS, name, path, parent);
node.addEventListener("click", e => list(path));
node.dataset.name = name;
return node;
}
function buildFile(data, parent) {
let node = ui.song(ui.CTX_FS, data, parent);
let name = data["file"].split("/").pop();
node.dataset.name = name;
return node;
}
function buildResults(results) {
let ul = node.querySelector("ul");
html.clear(ul);
results["directory"].forEach(directory => buildDirectory(directory, ul));
results["file"].forEach(file => buildFile(file, ul));
}
async function list(path) {
let results = await mpd.listPath(path);
buildResults(results);
buildHeader(path);
}
function onSearch(e) {
Array.from(node.querySelectorAll("[data-name]")).forEach(node => {
let name = node.dataset.name;
node.style.display = (search.match(name) ? "" : "none");
});
}
export async function activate() {
list("");
}
export function init(n) {
node = n;
search = new Search(node.querySelector(".search"));
search.addEventListener("input", onSearch);
}

View file

@ -1,111 +0,0 @@
import * as mpd from "./lib/mpd.js";
import * as html from "./lib/html.js";
import * as ui from "./lib/ui.js";
import * as format from "./lib/format.js";
import Search from "./lib/search.js";
let node, search;
function nonempty(x) { return x.length > 0; }
function buildHeader(filter) {
filter = filter || {};
let header = node.querySelector("header");
html.clear(header);
search.reset();
header.appendChild(search.getNode());
let artist = filter["AlbumArtist"];
if (artist) {
let artistFilter = {"AlbumArtist":artist};
let button = html.button({icon:"artist"}, artist, header);
button.addEventListener("click", e => listAlbums(artistFilter));
let album = filter["Album"];
if (album) {
html.node("span", {}, format.SEPARATOR, header);
let albumFilter = Object.assign({}, artistFilter, {"Album":album});
let button = html.button({icon:"album"}, album, header);
button.addEventListener("click", e => listSongs(albumFilter));
}
}
}
function buildAlbum(album, filter, parent) {
let childFilter = Object.assign({}, filter, {"Album": album});
let node = ui.group(ui.CTX_LIBRARY, album, childFilter, parent);
node.addEventListener("click", e => listSongs(childFilter));
node.dataset.name = album;
return node;
}
function buildArtist(artist, filter, parent) {
let childFilter = Object.assign({}, filter, {"AlbumArtist": artist});
let node = ui.group(ui.CTX_LIBRARY, artist, childFilter, parent);
node.addEventListener("click", e => listAlbums(childFilter));
node.dataset.name = artist;
return node;
}
function buildSongs(songs, filter) {
let ul = node.querySelector("ul");
html.clear(ul);
songs.map(song => {
let node = ui.song(ui.CTX_LIBRARY, song, ul);
node.dataset.name = song["Title"];
});
}
function buildAlbums(albums, filter) {
let ul = node.querySelector("ul");
html.clear(ul);
albums.filter(nonempty).map(album => buildAlbum(album, filter, ul));
}
function buildArtists(artists, filter) {
let ul = node.querySelector("ul");
html.clear(ul);
artists.filter(nonempty).map(artist => buildArtist(artist, filter, ul));
}
async function listSongs(filter) {
let songs = await mpd.listSongs(filter);
buildSongs(songs, filter);
buildHeader(filter);
}
async function listAlbums(filter) {
let albums = await mpd.listTags("Album", filter);
buildAlbums(albums, filter);
buildHeader(filter);
}
async function listArtists(filter) {
let artists = await mpd.listTags("AlbumArtist", filter);
buildArtists(artists, filter);
buildHeader(filter);
}
function onSearch(e) {
Array.from(node.querySelectorAll("[data-name]")).forEach(node => {
let name = node.dataset.name;
node.style.display = (search.match(name) ? "" : "none");
});
}
export async function activate() {
listArtists();
}
export function init(n) {
node = n;
search = new Search(node.querySelector(".search"));
search.addEventListener("input", onSearch);
}

View file

@ -16,7 +16,7 @@ export function status() {
Title: "Title of song",
Artist: "Artist of song",
Album: "Album of song",
Track: 6,
Track: "6",
state: "play",
Id: 2
}
@ -24,9 +24,9 @@ export function status() {
export function listQueue() {
return [
{Id:1, Track:5, Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30},
{Id:1, Track:"5", Title:"Title 1", Artist:"AAA", Album:"BBB", duration:30},
status(),
{Id:3, Track:7, Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230},
{Id:3, Track:"7", Title:"Title 3", Artist:"CCC", Album:"DDD", duration:230},
];
}
@ -64,6 +64,10 @@ export function listSongs(filter, window = null) {
return listQueue();
}
export function searchSongs(filter) {
return listQueue();
}
export function albumArt(songUrl) {
return null;
}

View file

@ -37,11 +37,11 @@ function processQueue() {
ws.send(current.cmd);
}
export function serializeFilter(filter) {
export function serializeFilter(filter, operator = "==") {
let tokens = ["("];
Object.entries(filter).forEach(([key, value], index) => {
index && tokens.push(" AND ");
tokens.push(`(${key} == "${escape(value)}")`);
tokens.push(`(${key} ${operator} "${escape(value)}")`);
});
tokens.push(")");
@ -109,23 +109,33 @@ export async function listTags(tag, filter = {}) {
}
export async function listSongs(filter, window = null) {
let tokens = ["find"];
tokens.push(serializeFilter(filter));
if (window) { tokens.push("window", window.join(":")); }
let tokens = ["find", ...serializeFilter(filter)];
window && tokens.push("window", window.join(":"));
let lines = await command(tokens.join(" "));
return parser.songList(lines);
}
export async function searchSongs(filter) {
let tokens = ["find", ...serializeFilter(filter, "contains")];
let lines = await command(tokens.join(" "));
return parser.songList(lines);
}
export async function albumArt(songUrl) {
let data = [];
let offset = 0;
let params = ["albumart", `"${escape(songUrl)}"`, offset];
while (1) {
let params = ["albumart", `"${escape(songUrl)}"`, offset];
let lines = await command(params.join(" "));
data = data.concat(lines[2]);
let metadata = parser.linesToStruct(lines.slice(0, 2));
if (data.length >= Number(metadata["size"])) { return data; }
offset += Number(metadata["binary"]);
params[2] = offset;
try {
let lines = await command(params.join(" "));
data = data.concat(lines[2]);
let metadata = parser.linesToStruct(lines.slice(0, 2));
if (data.length >= Number(metadata["size"])) { return data; }
offset += Number(metadata["binary"]);
} catch (e) { return null; }
}
return null;
}

View file

@ -25,7 +25,9 @@ export default class Selection {
addCommandAll() {
this.addCommand(_ => {
Array.from(this._component.children).forEach(node => this.add(node));
Array.from(this._component.children)
.filter(node => node.tagName.toLowerCase().startsWith("cyp-"))
.forEach(node => this.add(node));
}, {label:"Select all", icon:"checkbox-marked-outline"});
}

View file

@ -1,153 +0,0 @@
import * as mpd from "./mpd.js";
import * as html from "./html.js";
import * as format from "./format.js";
import * as art from "./art.js";
export const CTX_FS = 1;
export const CTX_QUEUE = 2;
export const CTX_LIBRARY = 3;
const TYPE_ID = 1;
const TYPE_URL = 2;
const TYPE_FILTER = 3;
const TYPE_PLAYLIST = 4;
const SORT = "-Track";
async function enqueue(type, what) {
switch (type) {
case TYPE_URL: return mpd.command(`add "${mpd.escape(what)}"`); break;
case TYPE_FILTER: return mpd.enqueueByFilter(what, SORT); break;
case TYPE_PLAYLIST: return mpd.command(`load "${mpd.escape(what)}"`); break;
}
}
async function fillArt(parent, filter) {
let artist = filter["AlbumArtist"];
let album = filter["Album"];
let src = null;
if (artist && album) {
src = await art.get(artist, album);
if (!src) {
let songs = await mpd.listSongs(filter, [0,1]);
if (songs.length) {
src = await art.get(artist, album, songs[0]["file"]);
}
}
}
if (src) {
html.node("img", {src}, "", parent);
} else {
html.icon(album ? "album" : "artist", parent);
}
}
function fileName(data) {
return data["file"].split("/").pop();
}
function formatSongInfo(ctx, data) {
let lines = [];
let tokens = [];
switch (ctx) {
case CTX_FS: lines.push(fileName(data)); break;
case CTX_LIBRARY:
case CTX_QUEUE:
if (data["Title"]) {
if (ctx == CTX_LIBRARY && data["Track"]) {
tokens.push(data["Track"].padStart(2, "0"));
}
tokens.push(data["Title"]);
lines.push(tokens.join(" "));
lines.push(format.subtitle(data));
} else {
lines.push(fileName(data));
lines.push("\u00A0");
}
break;
}
return lines;
}
function playButton(type, what, parent) {
let button = html.button({icon:"play", title:"Play"}, "", parent);
button.addEventListener("click", async e => {
if (type == TYPE_ID) {
await mpd.command(`playid ${what}`);
} else {
await mpd.command("clear");
await enqueue(type, what);
await mpd.command("play");
pubsub.publish("queue-change");
}
button.closest("cyp-app").querySelector("cyp-player").update(); // FIXME nejde to lepe?
});
return button;
}
function addButton(type, what, parent) {
let button = html.button({icon:"plus", title:"Add to queue"}, "", parent);
button.addEventListener("click", async e => {
e.stopPropagation();
await enqueue(type, what);
pubsub.publish("queue-change");
// fixme notification?
});
return button;
}
export function song(ctx, data, parent) {
let node = html.node("li", {className:"song"}, "", parent);
let info = html.node("div", {className:"info"}, "", node);
if (ctx == CTX_FS) { html.icon("music", info); }
let lines = formatSongInfo(ctx, data);
html.node("h2", {}, lines.shift(), info);
lines.length && html.node("div", {}, lines.shift(), info);
switch (ctx) {
case CTX_QUEUE:
let id = data["Id"];
node.dataset.songId = id;
playButton(TYPE_ID, id, node);
deleteButton(TYPE_ID, id, node);
break;
case CTX_LIBRARY:
case CTX_FS:
let url = data["file"];
playButton(TYPE_URL, url, node);
addButton(TYPE_URL, url, node);
break;
}
return node;
}
export function group(ctx, label, urlOrFilter, parent) {
let node = html.node("li", {className:"group"}, "", parent);
if (ctx == CTX_LIBRARY) {
node.classList.add("has-art");
let art = html.node("span", {className:"art"}, "", node);
fillArt(art, urlOrFilter);
}
let info = html.node("span", {className:"info"}, "", node);
if (ctx == CTX_FS) { html.icon("folder", info); }
html.node("h2", {}, label, info);
let type = (ctx == CTX_FS ? TYPE_URL : TYPE_FILTER);
playButton(type, urlOrFilter, node);
addButton(type, urlOrFilter, node);
return node;
}