253 lines
6.4 KiB
JavaScript
253 lines
6.4 KiB
JavaScript
import * as html from "../html.js";
|
|
import Component from "../component.js";
|
|
import Tag from "./tag.js";
|
|
import Path from "./path.js";
|
|
import Back from "./back.js";
|
|
import Song from "./song.js";
|
|
import Search from "./search.js";
|
|
import Filter from "./filter.js";
|
|
import { escape, serializeFilter } from "../mpd.js";
|
|
|
|
|
|
const SORT = "-Track";
|
|
const TAGS = {
|
|
"Album": "Albums",
|
|
"AlbumArtist": "Artists",
|
|
"Genre": "Genres"
|
|
}
|
|
|
|
function nonempty(str) { return (str.length > 0); }
|
|
|
|
function createEnqueueCommand(node) {
|
|
if (node instanceof Song || node instanceof Path) {
|
|
return `add "${escape(node.file)}"`;
|
|
} else if (node instanceof Tag) {
|
|
return [
|
|
"findadd",
|
|
serializeFilter(node.createChildFilter()),
|
|
// `sort ${SORT}` // MPD >= 0.22, not yet released
|
|
].join(" ");
|
|
} else {
|
|
throw new Error(`Cannot create enqueue command for "${node.nodeName}"`);
|
|
}
|
|
}
|
|
|
|
class Library extends Component {
|
|
constructor() {
|
|
super({selection:"multi"});
|
|
this._stateStack = [];
|
|
this._initCommands();
|
|
|
|
this._search = new Search();
|
|
this._search.onSubmit = _ => {
|
|
let query = this._search.value;
|
|
if (query.length < 3) { return; }
|
|
this._doSearch(query);
|
|
}
|
|
|
|
this._filter = new Filter();
|
|
}
|
|
|
|
_popState() {
|
|
this.selection.clear();
|
|
this._stateStack.pop();
|
|
|
|
if (this._stateStack.length > 0) {
|
|
let state = this._stateStack[this._stateStack.length-1];
|
|
this._showState(state);
|
|
} else {
|
|
this._showRoot();
|
|
}
|
|
}
|
|
|
|
_onAppLoad() {
|
|
this._showRoot();
|
|
}
|
|
|
|
_onComponentChange(c, isThis) {
|
|
const wasHidden = this.hidden;
|
|
this.hidden = !isThis;
|
|
|
|
if (!wasHidden && isThis) { this._showRoot(); }
|
|
}
|
|
|
|
_showRoot() {
|
|
this._stateStack = [];
|
|
html.clear(this);
|
|
|
|
const nav = html.node("nav", {}, "", this);
|
|
|
|
html.button({icon:"artist"}, "Artists and albums", nav)
|
|
.addEventListener("click", _ => this._pushState({type:"tags", tag:"AlbumArtist"}));
|
|
|
|
html.button({icon:"music"}, "Genres", nav)
|
|
.addEventListener("click", _ => this._pushState({type:"tags", tag:"Genre"}));
|
|
|
|
html.button({icon:"folder"}, "Files and directories", nav)
|
|
.addEventListener("click", _ => this._pushState({type:"path", path:""}));
|
|
|
|
html.button({icon:"magnify"}, "Search", nav)
|
|
.addEventListener("click", _ => this._pushState({type:"search"}));
|
|
}
|
|
|
|
_pushState(state) {
|
|
this.selection.clear();
|
|
this._stateStack.push(state);
|
|
|
|
this._showState(state);
|
|
}
|
|
|
|
_showState(state) {
|
|
switch (state.type) {
|
|
case "tags": this._listTags(state.tag, state.filter); break;
|
|
case "songs": this._listSongs(state.filter); break;
|
|
case "path": this._listPath(state.path); break;
|
|
case "search": this._showSearch(state.query); break;
|
|
}
|
|
}
|
|
|
|
async _listTags(tag, filter = {}) {
|
|
const values = (await this._mpd.listTags(tag, filter)).filter(nonempty);
|
|
html.clear(this);
|
|
|
|
if ("AlbumArtist" in filter || "Genre" in filter) { this._buildBack(); }
|
|
(values.length > 0) && this._addFilter();
|
|
values.forEach(value => this._buildTag(tag, value, filter));
|
|
}
|
|
|
|
async _listPath(path) {
|
|
let paths = await this._mpd.listPath(path);
|
|
html.clear(this);
|
|
|
|
path && this._buildBack();
|
|
(paths["directory"].length + paths["file"].length > 0) && this._addFilter();
|
|
paths["directory"].forEach(path => this._buildPath(path));
|
|
paths["file"].forEach(path => this._buildPath(path));
|
|
}
|
|
|
|
async _listSongs(filter) {
|
|
const songs = await this._mpd.listSongs(filter);
|
|
html.clear(this);
|
|
this._buildBack();
|
|
(songs.length > 0 && this._addFilter());
|
|
songs.forEach(song => this.appendChild(new Song(song)));
|
|
}
|
|
|
|
_showSearch(query = "") {
|
|
html.clear(this);
|
|
|
|
this.appendChild(this._search);
|
|
this._search.value = query;
|
|
this._search.focus();
|
|
|
|
query && this._search.onSubmit();
|
|
}
|
|
|
|
async _doSearch(query) {
|
|
let state = this._stateStack[this._stateStack.length-1];
|
|
state.query = query;
|
|
|
|
html.clear(this);
|
|
this.appendChild(this._search);
|
|
this._search.pending(true);
|
|
|
|
const songs1 = await this._mpd.searchSongs({"AlbumArtist": query});
|
|
const songs2 = await this._mpd.searchSongs({"Album": query});
|
|
const songs3 = await this._mpd.searchSongs({"Title": query});
|
|
|
|
this._search.pending(false);
|
|
|
|
this._aggregateSearch(songs1, "AlbumArtist");
|
|
this._aggregateSearch(songs2, "Album");
|
|
songs3.forEach(song => this.appendChild(new Song(song)));
|
|
}
|
|
|
|
_aggregateSearch(songs, tag) {
|
|
let results = new Map();
|
|
songs.forEach(song => {
|
|
let filter = {}, value;
|
|
const artist = song["AlbumArtist"] || song["Artist"]
|
|
|
|
if (tag == "Album") {
|
|
value = song[tag];
|
|
if (artist) { filter["AlbumArtist"] = artist; }
|
|
}
|
|
|
|
if (tag == "AlbumArtist") { value = artist; }
|
|
|
|
results.set(value, filter);
|
|
});
|
|
|
|
results.forEach((filter, value) => this._buildTag(tag, value, filter));
|
|
}
|
|
|
|
_buildTag(tag, value, filter) {
|
|
let node;
|
|
switch (tag) {
|
|
case "AlbumArtist":
|
|
case "Genre":
|
|
node = new Tag(tag, value, filter);
|
|
this.appendChild(node);
|
|
node.onClick = () => this._pushState({type:"tags", tag:"Album", filter:node.createChildFilter()});
|
|
break;
|
|
|
|
case "Album":
|
|
node = new Tag(tag, value, filter);
|
|
this.appendChild(node);
|
|
node.addButton("chevron-double-right", _ => this._pushState({type:"songs", filter:node.createChildFilter()}));
|
|
break;
|
|
}
|
|
node.fillArt(this._mpd);
|
|
}
|
|
|
|
_buildBack() {
|
|
const backState = this._stateStack[this._stateStack.length-2];
|
|
let title;
|
|
switch (backState.type) {
|
|
case "path": title = ".."; break;
|
|
case "search": title = "Search"; break;
|
|
case "tags": title = TAGS[backState.tag]; break;
|
|
}
|
|
|
|
const node = new Back(title);
|
|
this.appendChild(node);
|
|
node.onClick = () => this._popState();
|
|
}
|
|
|
|
_buildPath(data) {
|
|
let node = new Path(data);
|
|
this.appendChild(node);
|
|
|
|
if ("directory" in data) {
|
|
const path = data["directory"];
|
|
node.addButton("chevron-double-right", _ => this._pushState({type:"path", path}));
|
|
}
|
|
}
|
|
|
|
_addFilter() {
|
|
this.appendChild(this._filter);
|
|
this._filter.value = "";
|
|
}
|
|
|
|
_initCommands() {
|
|
const sel = this.selection;
|
|
|
|
sel.addCommandAll();
|
|
|
|
sel.addCommand(async items => {
|
|
const commands = ["clear",...items.map(createEnqueueCommand), "play"];
|
|
await this._mpd.command(commands);
|
|
this.selection.clear(); // fixme notification?
|
|
}, {label:"Play", icon:"play"});
|
|
|
|
sel.addCommand(async items => {
|
|
const commands = items.map(createEnqueueCommand);
|
|
await this._mpd.command(commands);
|
|
this.selection.clear(); // fixme notification?
|
|
}, {label:"Enqueue", icon:"plus"});
|
|
|
|
sel.addCommandCancel();
|
|
}
|
|
}
|
|
|
|
customElements.define("cyp-library", Library);
|