Compare commits
No commits in common. "master" and "idle" have entirely different histories.
33 changed files with 660 additions and 1081 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,4 +2,3 @@ node_modules
|
|||
_youtube/*
|
||||
!_youtube/.empty
|
||||
cyp.service
|
||||
passwords.json
|
||||
|
|
12
README.md
12
README.md
|
@ -57,18 +57,6 @@ docker run --network=host -v "$(pwd)"/_youtube:/cyp/_youtube cyp
|
|||
docker run --network=host -e PORT=12345 cyp
|
||||
```
|
||||
|
||||
## Password-protected MPD
|
||||
|
||||
Create a `passwords.json` file in CYPs home directory. Specify passwords for available MPD servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"localhost:6600": "my-pass-1",
|
||||
"some.other.server.or.ip:12345": "my-pass-2
|
||||
}
|
||||
```
|
||||
|
||||
Make sure that hostnames and ports match those specified via the `server` querystring argument (defaults to `localhost:6600`).
|
||||
|
||||
## Technology
|
||||
- Connected to MPD via WebSockets (using the [ws2mpd](https://github.com/ondras/ws2mpd/) bridge)
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
.art {
|
||||
flex: none;
|
||||
.icon, img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
|
|
@ -84,7 +84,6 @@ select {
|
|||
@import "elements/range.less";
|
||||
@import "elements/playlist.less";
|
||||
@import "elements/search.less";
|
||||
@import "elements/filter.less";
|
||||
@import "elements/library.less";
|
||||
@import "elements/tag.less";
|
||||
@import "elements/back.less";
|
||||
|
|
|
@ -4,13 +4,14 @@ cyp-app {
|
|||
box-sizing: border-box;
|
||||
margin: 0 auto;
|
||||
max-width: 800px;
|
||||
height: calc(100px * var(--vh));
|
||||
height: 100vh;
|
||||
|
||||
font-family: lato, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.25;
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
text-shadow: var(--text-shadow);
|
||||
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
cyp-filter {
|
||||
.item;
|
||||
|
||||
.icon {
|
||||
width: 32px;
|
||||
margin-left: var(--icon-spacing);
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ cyp-player {
|
|||
}
|
||||
|
||||
.art {
|
||||
flex: none;
|
||||
width: @art-size;
|
||||
height: @art-size;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ cyp-search {
|
|||
.item;
|
||||
align-items: stretch;
|
||||
|
||||
button:first-of-type { // pseudo-class to override
|
||||
button:first-of-type {
|
||||
margin-left: var(--icon-spacing);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,5 +9,9 @@ cyp-tag {
|
|||
margin-right: var(--icon-spacing);
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
.icon {
|
||||
filter: drop-shadow(var(--text-shadow));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,8 +50,10 @@
|
|||
|
||||
padding: 8px;
|
||||
|
||||
|
||||
> .icon {
|
||||
margin-right: var(--icon-spacing);
|
||||
filter: drop-shadow(var(--text-shadow));
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
|
@ -34,12 +34,6 @@ cyp-app[theme=dark] { .dark(); }
|
|||
cyp-app[theme=auto] { .light(); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px), (max-height:640px) {
|
||||
cyp-app[theme] {
|
||||
--text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
cyp-app[color=dodgerblue] {
|
||||
--primary-raw: 30, 144, 255;
|
||||
}
|
||||
|
|
File diff suppressed because one or more lines are too long
903
app/cyp.js
903
app/cyp.js
File diff suppressed because one or more lines are too long
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M6,13H18V11H6M3,6V8H21V6M10,18H14V16H10V18Z" /></svg>
|
Before Width: | Height: | Size: 338 B |
|
@ -5,100 +5,9 @@
|
|||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Control Your Player</title>
|
||||
<link rel="stylesheet" href="cyp.css" />
|
||||
<style>
|
||||
/* Float cancel and delete buttons and add an equal width */
|
||||
.popupbtn {
|
||||
background-color: rgb(141, 0, 0);
|
||||
color: rgb(255, 255, 255);
|
||||
padding: 14px 20px;
|
||||
margin: 8px 0;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
opacity: 0.9;
|
||||
display: block !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.popupbtn:hover {
|
||||
opacity:1;
|
||||
}
|
||||
|
||||
/* Add padding and center-align text to the container */
|
||||
.popupcontainer {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The Modal (background) */
|
||||
.popupmodal {
|
||||
display: none; /* Hidden by default */
|
||||
position: fixed; /* Stay in place */
|
||||
z-index: 2; /* Sit on top */
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%; /* Full width */
|
||||
height: 100%; /* Full height */
|
||||
overflow: auto; /* Enable scroll if needed */
|
||||
background-color: #474e5d;
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
/* Modal Content/Box */
|
||||
.popupmodal-content {
|
||||
background-color: #fefefe;
|
||||
margin: 5% auto 15% auto; /* 5% from the top, 15% from the bottom and centered */
|
||||
border: 1px solid #888;
|
||||
width: 80%; /* Could be more or less, depending on screen size */
|
||||
}
|
||||
|
||||
/* Style the horizontal ruler */
|
||||
.popup-hr {
|
||||
border: 1px solid #f1f1f1;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
/* The Modal Close Button (x) */
|
||||
.popup-close {
|
||||
position: absolute;
|
||||
right: 35px;
|
||||
top: 15px;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
.popup-close:hover,
|
||||
.popup-close:focus {
|
||||
color: #f44336;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Clear floats */
|
||||
.popup-clearfix::after {
|
||||
content: "";
|
||||
clear: both;
|
||||
display: table;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" href="https://emojimage.toad.cz/🎵" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%22100%22>🎵</text></svg>" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="warningpopup" class="popupmodal">
|
||||
<span onclick="document.getElementById('warningpopup').style.display='none'" class="popup-close" title="x">×</span>
|
||||
<div class="popupmodal-content">
|
||||
<div class="popupcontainer">
|
||||
<h2>Warning</h2>
|
||||
<p>Be aware there may be other users listening to the same stream.</p>
|
||||
<p>Pausing or changing volume on the server will affect them as well.</p>
|
||||
<p>Please avoid server-side pausing or volume change if possible.</p>
|
||||
<p>You can find client-side volume and pause control in the Settings Panel.</p>
|
||||
<div class="popup-clearfix">
|
||||
<button type="button" onclick="document.getElementById('warningpopup').style.display='none'" class="popupbtn">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<cyp-app theme="dark" color="dodgerblue">
|
||||
<header>
|
||||
<cyp-player>
|
||||
|
@ -188,6 +97,6 @@
|
|||
</footer>
|
||||
</cyp-app>
|
||||
|
||||
<script type="module" src="cyp.js?2"></script>
|
||||
<script type="module" src="cyp.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -23,13 +23,9 @@ async function bytesToImage(bytes) {
|
|||
}
|
||||
|
||||
function resize(image) {
|
||||
while (Math.min(image.width, image.height) >= 2*conf.artSize) {
|
||||
let tmp = html.node("canvas", {width:image.width/2, height:image.height/2});
|
||||
tmp.getContext("2d").drawImage(image, 0, 0, tmp.width, tmp.height);
|
||||
image = tmp;
|
||||
}
|
||||
const canvas = html.node("canvas", {width:conf.artSize, height:conf.artSize});
|
||||
canvas.getContext("2d").drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export const artSize = 96 * (window.devicePixelRatio || 1);
|
||||
export const artSize = 96;
|
||||
export const ytPath = "_youtube";
|
||||
export let ytLimit = 3;
|
||||
|
||||
|
|
|
@ -11,10 +11,3 @@ import "./elements/library.js";
|
|||
import "./elements/tag.js";
|
||||
import "./elements/back.js";
|
||||
import "./elements/path.js";
|
||||
|
||||
function updateSize() {
|
||||
document.body.style.setProperty("--vh", window.innerHeight/100);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updateSize);
|
||||
updateSize();
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import MPD from "../mpd.js";
|
||||
import * as mpd from "../mpd.js";
|
||||
import * as mpdMock from "../mpd-mock.js";
|
||||
import * as html from "../html.js";
|
||||
|
||||
function initIcons() {
|
||||
|
@ -10,24 +11,43 @@ function initIcons() {
|
|||
});
|
||||
}
|
||||
|
||||
async function initMpd(app) {
|
||||
try {
|
||||
await mpd.init(app);
|
||||
return mpd;
|
||||
} catch (e) {
|
||||
return mpdMock;
|
||||
}
|
||||
}
|
||||
|
||||
class App extends HTMLElement {
|
||||
static get observedAttributes() { return ["component"]; }
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
initIcons();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await waitForChildren(this);
|
||||
this.mpd = await initMpd(this);
|
||||
|
||||
window.addEventListener("hashchange", e => this._onHashChange());
|
||||
this._onHashChange();
|
||||
const children = Array.from(this.querySelectorAll("*"));
|
||||
const names = children.map(node => node.nodeName.toLowerCase())
|
||||
.filter(name => name.startsWith("cyp-"));
|
||||
const unique = new Set(names);
|
||||
|
||||
const promises = [...unique].map(name => customElements.whenDefined(name));
|
||||
await Promise.all(promises);
|
||||
|
||||
await this._connect();
|
||||
this.dispatchEvent(new CustomEvent("load"));
|
||||
|
||||
this._initMediaHandler();
|
||||
const onHashChange = () => {
|
||||
const component = location.hash.substring(1) || "queue";
|
||||
if (component != this.component) { this.component = component; }
|
||||
}
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
onHashChange();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
|
@ -42,85 +62,6 @@ class App extends HTMLElement {
|
|||
|
||||
get component() { return this.getAttribute("component"); }
|
||||
set component(component) { this.setAttribute("component", component); }
|
||||
|
||||
_onHashChange() {
|
||||
const component = location.hash.substring(1) || "queue";
|
||||
if (component != this.component) { this.component = component; }
|
||||
}
|
||||
|
||||
_onChange(changed) { this.dispatchEvent(new CustomEvent("idle-change", {detail:changed})); }
|
||||
|
||||
_onClose(e) {
|
||||
setTimeout(() => this._connect(), 3000);
|
||||
}
|
||||
|
||||
async _connect() {
|
||||
const attempts = 3;
|
||||
for (let i=0;i<attempts;i++) {
|
||||
try {
|
||||
let mpd = await MPD.connect();
|
||||
mpd.onChange = changed => this._onChange(changed);
|
||||
mpd.onClose = e => this._onClose(e);
|
||||
this.mpd = mpd;
|
||||
return;
|
||||
} catch (e) {
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
alert(`Failed to connect to MPD after ${attempts} attempts. Please reload the page to try again.`);
|
||||
}
|
||||
|
||||
_initMediaHandler() {
|
||||
// check support mediaSession
|
||||
if (!('mediaSession' in navigator)) {
|
||||
console.log('mediaSession is not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// DOM (using media session controls are allowed only if there is audio/video tag)
|
||||
const audio = html.node("audio", {loop: true}, "", this);
|
||||
html.node("source", {src: 'https://raw.githubusercontent.com/anars/blank-audio/master/10-seconds-of-silence.mp3'}, '', audio);
|
||||
|
||||
// Init event session (play audio) on click (because restrictions by web browsers)
|
||||
window.addEventListener('click', () => {
|
||||
audio.play();
|
||||
}, {once: true});
|
||||
|
||||
// mediaSession define metadata
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: 'Control Your Player'
|
||||
});
|
||||
|
||||
// mediaSession define action handlers
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
this.mpd.command("play")
|
||||
audio.play()
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
this.mpd.command("pause 1")
|
||||
audio.pause()
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
this.mpd.command("previous")
|
||||
audio.play()
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
this.mpd.command("next")
|
||||
audio.play()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("cyp-app", App);
|
||||
|
||||
function sleep(ms) { return new Promise(resolve =>setTimeout(resolve, ms)); }
|
||||
|
||||
function waitForChildren(app) {
|
||||
const children = Array.from(app.querySelectorAll("*"));
|
||||
const names = children.map(node => node.nodeName.toLowerCase())
|
||||
.filter(name => name.startsWith("cyp-"));
|
||||
const unique = new Set(names);
|
||||
|
||||
const promises = [...unique].map(name => customElements.whenDefined(name));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
import * as html from "../html.js";
|
||||
|
||||
|
||||
const SELECTOR = ["cyp-tag", "cyp-path", "cyp-song"].join(", ");
|
||||
|
||||
export default class Filter extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._built = false;
|
||||
}
|
||||
|
||||
get value() { return this._input.value.trim(); }
|
||||
set value(value) { this._input.value = value; }
|
||||
get _input() { return this.querySelector("input"); }
|
||||
|
||||
connectedCallback() {
|
||||
if (this._built) { return; }
|
||||
|
||||
html.node("input", {type:"text"}, "", this);
|
||||
html.icon("filter-variant", this);
|
||||
|
||||
this._input.addEventListener("input", e => this._apply());
|
||||
this._built = true;
|
||||
}
|
||||
|
||||
_apply() {
|
||||
let value = this.value.toLowerCase();
|
||||
let all = [...this.parentNode.querySelectorAll(SELECTOR)];
|
||||
all.forEach(item => item.hidden = !item.matchPrefix(value));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("cyp-filter", Filter);
|
|
@ -5,21 +5,21 @@ 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"
|
||||
"AlbumArtist": "Artists"
|
||||
}
|
||||
|
||||
function nonempty(str) { return (str.length > 0); }
|
||||
|
||||
function createEnqueueCommand(node) {
|
||||
if (node instanceof Song || node instanceof Path) {
|
||||
if (node instanceof Song) {
|
||||
return `add "${escape(node.data["file"])}"`;
|
||||
} else if (node instanceof Path) {
|
||||
return `add "${escape(node.file)}"`;
|
||||
} else if (node instanceof Tag) {
|
||||
return [
|
||||
|
@ -44,8 +44,6 @@ class Library extends Component {
|
|||
if (query.length < 3) { return; }
|
||||
this._doSearch(query);
|
||||
}
|
||||
|
||||
this._filter = new Filter();
|
||||
}
|
||||
|
||||
_popState() {
|
||||
|
@ -80,9 +78,6 @@ class Library extends Component {
|
|||
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:""}));
|
||||
|
||||
|
@ -107,12 +102,11 @@ class Library extends Component {
|
|||
}
|
||||
|
||||
async _listTags(tag, filter = {}) {
|
||||
const values = (await this._mpd.listTags(tag, filter)).filter(nonempty);
|
||||
const values = await this._mpd.listTags(tag, filter);
|
||||
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));
|
||||
if ("AlbumArtist" in filter) { this._buildBack(); }
|
||||
values.filter(nonempty).forEach(value => this._buildTag(tag, value, filter));
|
||||
}
|
||||
|
||||
async _listPath(path) {
|
||||
|
@ -120,7 +114,6 @@ class Library extends Component {
|
|||
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));
|
||||
}
|
||||
|
@ -129,7 +122,6 @@ class Library extends Component {
|
|||
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)));
|
||||
}
|
||||
|
||||
|
@ -166,7 +158,7 @@ class Library extends Component {
|
|||
let results = new Map();
|
||||
songs.forEach(song => {
|
||||
let filter = {}, value;
|
||||
const artist = song["AlbumArtist"] || song["Artist"];
|
||||
const artist = song["AlbumArtist"] || song["Artist"]
|
||||
|
||||
if (tag == "Album") {
|
||||
value = song[tag];
|
||||
|
@ -185,7 +177,6 @@ class Library extends Component {
|
|||
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()});
|
||||
|
@ -224,11 +215,6 @@ class Library extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
_addFilter() {
|
||||
this.appendChild(this._filter);
|
||||
this._filter.value = "";
|
||||
}
|
||||
|
||||
_initCommands() {
|
||||
const sel = this.selection;
|
||||
|
||||
|
|
|
@ -1,23 +1,19 @@
|
|||
import Component from "../component.js";
|
||||
|
||||
class Menu extends Component {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
_onAppLoad() {
|
||||
/** @type HTMLElement[] */
|
||||
this._tabs = Array.from(this.querySelectorAll("[data-for]"));
|
||||
|
||||
this._tabs.forEach(tab => {
|
||||
tab.addEventListener("click", _ => this._app.setAttribute("component", tab.dataset.for));
|
||||
});
|
||||
}
|
||||
|
||||
_onAppLoad() {
|
||||
this._app.addEventListener("queue-length-change", e => {
|
||||
this.querySelector(".queue-length").textContent = `(${e.detail})`;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
_onComponentChange(component) {
|
||||
this._tabs.forEach(tab => {
|
||||
tab.classList.toggle("active", tab.dataset.for == component);
|
||||
|
|
|
@ -81,8 +81,8 @@ class Player extends Component {
|
|||
this._dispatchSongChange(data);
|
||||
}
|
||||
|
||||
let artistNew = data["Artist"] || data["AlbumArtist"];
|
||||
let artistOld = this._current.song["Artist"] || this._current.song["AlbumArtist"];
|
||||
let artistNew = data["AlbumArtist"] || data["Artist"];
|
||||
let artistOld = this._current.song["AlbumArtist"] || this._current.song["Artist"];
|
||||
let albumNew = data["Album"];
|
||||
let albumOld = this._current.song["Album"];
|
||||
|
||||
|
|
|
@ -91,21 +91,16 @@ class Queue extends Component {
|
|||
this._mpd.command(commands.reverse()); // move last first
|
||||
}, {label:"Down", icon:"arrow-down-bold"});
|
||||
|
||||
sel.addCommand(async items => {
|
||||
sel.addCommand(items => {
|
||||
let name = prompt("Save selected songs as a playlist?", "name");
|
||||
if (name === null) { return; }
|
||||
|
||||
name = escape(name);
|
||||
try { // might not exist
|
||||
await this._mpd.command(`rm "${name}"`);
|
||||
} catch (e) {}
|
||||
|
||||
const commands = items.map(item => {
|
||||
return `playlistadd "${name}" "${escape(item.file)}"`;
|
||||
});
|
||||
await this._mpd.command(commands);
|
||||
|
||||
sel.clear();
|
||||
this._mpd.command(commands); // FIXME notify?
|
||||
}, {label:"Save", icon:"content-save"});
|
||||
|
||||
sel.addCommand(async items => {
|
||||
|
|
|
@ -5,8 +5,7 @@ import Item from "../item.js";
|
|||
|
||||
const ICONS = {
|
||||
"AlbumArtist": "artist",
|
||||
"Album": "album",
|
||||
"Genre": "music"
|
||||
"Album": "album"
|
||||
}
|
||||
|
||||
export default class Tag extends Item {
|
||||
|
|
|
@ -41,19 +41,15 @@ class YT extends Component {
|
|||
|
||||
let url = `/youtube?q=${encodeURIComponent(query)}&limit=${encodeURIComponent(conf.ytLimit)}`;
|
||||
let response = await fetch(url);
|
||||
if (response.status == 200) {
|
||||
let results = await response.json();
|
||||
results.forEach(result => {
|
||||
let node = new Result(result.title);
|
||||
this.appendChild(node);
|
||||
node.addButton("download", () => this._download(result.id));
|
||||
});
|
||||
} else {
|
||||
let text = await response.text();
|
||||
alert(text);
|
||||
}
|
||||
let results = await response.json();
|
||||
|
||||
this._search.pending(false);
|
||||
|
||||
results.forEach(result => {
|
||||
let node = new Result(result.title);
|
||||
this.appendChild(node);
|
||||
node.addButton("download", () => this._download(result.id));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
@ -87,7 +83,7 @@ class YT extends Component {
|
|||
const wasHidden = this.hidden;
|
||||
this.hidden = !isThis;
|
||||
|
||||
if (!wasHidden && isThis) { this._clear(); }
|
||||
if (!wasHidden && isThis) { this._showRoot(); }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,16 +9,9 @@ export function time(sec) {
|
|||
|
||||
export function subtitle(data, options = {duration:true}) {
|
||||
let tokens = [];
|
||||
|
||||
if (data["Artist"]) {
|
||||
tokens.push(data["Artist"]);
|
||||
} else if (data["AlbumArtist"]) {
|
||||
tokens.push(data["AlbumArtist"]);
|
||||
}
|
||||
|
||||
data["Artist"] && tokens.push(data["Artist"]);
|
||||
data["Album"] && tokens.push(data["Album"]);
|
||||
options.duration && data["duration"] && tokens.push(time(Number(data["duration"])));
|
||||
|
||||
return tokens.join(SEPARATOR);
|
||||
}
|
||||
|
||||
|
|
114
app/js/icons.js
114
app/js/icons.js
|
@ -1,33 +1,15 @@
|
|||
let ICONS={};
|
||||
ICONS["playlist-music"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M15,6H3V8H15V6M15,10H3V12H15V10M3,16H11V14H3V16M17,6V14.18C16.69,14.07 16.35,14 16,14A3,3 0 0,0 13,17A3,3 0 0,0 16,20A3,3 0 0,0 19,17V8H22V6H17Z"/>
|
||||
ICONS["library-music"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M4,6H2V20A2,2 0 0,0 4,22H18V20H4M18,7H15V12.5A2.5,2.5 0 0,1 12.5,15A2.5,2.5 0 0,1 10,12.5A2.5,2.5 0 0,1 12.5,10C13.07,10 13.58,10.19 14,10.5V5H18M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2Z"/>
|
||||
</svg>`;
|
||||
ICONS["plus"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
|
||||
</svg>`;
|
||||
ICONS["folder"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z"/>
|
||||
</svg>`;
|
||||
ICONS["shuffle"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M14.83,13.41L13.42,14.82L16.55,17.95L14.5,20H20V14.5L17.96,16.54L14.83,13.41M14.5,4L16.54,6.04L4,18.59L5.41,20L17.96,7.46L20,9.5V4M10.59,9.17L5.41,4L4,5.41L9.17,10.58L10.59,9.17Z"/>
|
||||
</svg>`;
|
||||
ICONS["artist"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M11,14C12,14 13.05,14.16 14.2,14.44C13.39,15.31 13,16.33 13,17.5C13,18.39 13.25,19.23 13.78,20H3V18C3,16.81 3.91,15.85 5.74,15.12C7.57,14.38 9.33,14 11,14M11,12C9.92,12 9,11.61 8.18,10.83C7.38,10.05 7,9.11 7,8C7,6.92 7.38,6 8.18,5.18C9,4.38 9.92,4 11,4C12.11,4 13.05,4.38 13.83,5.18C14.61,6 15,6.92 15,8C15,9.11 14.61,10.05 13.83,10.83C13.05,11.61 12.11,12 11,12M18.5,10H20L22,10V12H20V17.5A2.5,2.5 0 0,1 17.5,20A2.5,2.5 0 0,1 15,17.5A2.5,2.5 0 0,1 17.5,15C17.86,15 18.19,15.07 18.5,15.21V10Z"/>
|
||||
</svg>`;
|
||||
ICONS["download"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/>
|
||||
</svg>`;
|
||||
ICONS["checkbox-marked-outline"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/>
|
||||
</svg>`;
|
||||
ICONS["magnify"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"/>
|
||||
</svg>`;
|
||||
ICONS["delete"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
|
||||
</svg>`;
|
||||
ICONS["rewind"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M11.5,12L20,18V6M11,18V6L2.5,12L11,18Z"/>
|
||||
</svg>`;
|
||||
ICONS["cancel"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,13.85 4.63,15.55 5.68,16.91L16.91,5.68C15.55,4.63 13.85,4 12,4M12,20A8,8 0 0,0 20,12C20,10.15 19.37,8.45 18.32,7.09L7.09,18.32C8.45,19.37 10.15,20 12,20Z"/>
|
||||
ICONS["playlist-music"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M15,6H3V8H15V6M15,10H3V12H15V10M3,16H11V14H3V16M17,6V14.18C16.69,14.07 16.35,14 16,14A3,3 0 0,0 13,17A3,3 0 0,0 16,20A3,3 0 0,0 19,17V8H22V6H17Z"/>
|
||||
</svg>`;
|
||||
ICONS["settings"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
|
@ -35,55 +17,73 @@ ICONS["settings"] = `<svg viewBox="0 0 24 24">
|
|||
ICONS["pause"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M14,19H18V5H14M6,19H10V5H6V19Z"/>
|
||||
</svg>`;
|
||||
ICONS["arrow-down-bold"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M9,4H15V12H19.84L12,19.84L4.16,12H9V4Z"/>
|
||||
</svg>`;
|
||||
ICONS["filter-variant"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M6,13H18V11H6M3,6V8H21V6M10,18H14V16H10V18Z"/>
|
||||
ICONS["artist"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M11,14C12,14 13.05,14.16 14.2,14.44C13.39,15.31 13,16.33 13,17.5C13,18.39 13.25,19.23 13.78,20H3V18C3,16.81 3.91,15.85 5.74,15.12C7.57,14.38 9.33,14 11,14M11,12C9.92,12 9,11.61 8.18,10.83C7.38,10.05 7,9.11 7,8C7,6.92 7.38,6 8.18,5.18C9,4.38 9.92,4 11,4C12.11,4 13.05,4.38 13.83,5.18C14.61,6 15,6.92 15,8C15,9.11 14.61,10.05 13.83,10.83C13.05,11.61 12.11,12 11,12M18.5,10H20L22,10V12H20V17.5A2.5,2.5 0 0,1 17.5,20A2.5,2.5 0 0,1 15,17.5A2.5,2.5 0 0,1 17.5,15C17.86,15 18.19,15.07 18.5,15.21V10Z"/>
|
||||
</svg>`;
|
||||
ICONS["volume-off"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4L9.91,6.09L12,8.18M4.27,3L3,4.27L7.73,9H3V15H7L12,20V13.27L16.25,17.53C15.58,18.04 14.83,18.46 14,18.7V20.77C15.38,20.45 16.63,19.82 17.68,18.96L19.73,21L21,19.73L12,10.73M19,12C19,12.94 18.8,13.82 18.46,14.64L19.97,16.15C20.62,14.91 21,13.5 21,12C21,7.72 18,4.14 14,3.23V5.29C16.89,6.15 19,8.83 19,12M16.5,12C16.5,10.23 15.5,8.71 14,7.97V10.18L16.45,12.63C16.5,12.43 16.5,12.21 16.5,12Z"/>
|
||||
</svg>`;
|
||||
ICONS["close"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
|
||||
</svg>`;
|
||||
ICONS["music"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M21,3V15.5A3.5,3.5 0 0,1 17.5,19A3.5,3.5 0 0,1 14,15.5A3.5,3.5 0 0,1 17.5,12C18.04,12 18.55,12.12 19,12.34V6.47L9,8.6V17.5A3.5,3.5 0 0,1 5.5,21A3.5,3.5 0 0,1 2,17.5A3.5,3.5 0 0,1 5.5,14C6.04,14 6.55,14.12 7,14.34V6L21,3Z"/>
|
||||
</svg>`;
|
||||
ICONS["repeat"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M17,17H7V14L3,18L7,22V19H19V13H17M7,7H17V10L21,6L17,2V5H5V11H7V7Z"/>
|
||||
</svg>`;
|
||||
ICONS["arrow-up-bold"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M15,20H9V12H4.16L12,4.16L19.84,12H15V20Z"/>
|
||||
</svg>`;
|
||||
ICONS["keyboard-backspace"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M21,11H6.83L10.41,7.41L9,6L3,12L9,18L10.41,16.58L6.83,13H21V11Z"/>
|
||||
</svg>`;
|
||||
ICONS["play"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M8,5.14V19.14L19,12.14L8,5.14Z"/>
|
||||
</svg>`;
|
||||
ICONS["plus"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
|
||||
</svg>`;
|
||||
ICONS["content-save"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z"/>
|
||||
</svg>`;
|
||||
ICONS["library-music"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M4,6H2V20A2,2 0 0,0 4,22H18V20H4M18,7H15V12.5A2.5,2.5 0 0,1 12.5,15A2.5,2.5 0 0,1 10,12.5A2.5,2.5 0 0,1 12.5,10C13.07,10 13.58,10.19 14,10.5V5H18M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2Z"/>
|
||||
ICONS["cancel"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,13.85 4.63,15.55 5.68,16.91L16.91,5.68C15.55,4.63 13.85,4 12,4M12,20A8,8 0 0,0 20,12C20,10.15 19.37,8.45 18.32,7.09L7.09,18.32C8.45,19.37 10.15,20 12,20Z"/>
|
||||
</svg>`;
|
||||
ICONS["fast-forward"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M13,6V18L21.5,12M4,18L12.5,12L4,6V18Z"/>
|
||||
</svg>`;
|
||||
ICONS["delete"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
|
||||
</svg>`;
|
||||
ICONS["volume-high"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M14,3.23V5.29C16.89,6.15 19,8.83 19,12C19,15.17 16.89,17.84 14,18.7V20.77C18,19.86 21,16.28 21,12C21,7.72 18,4.14 14,3.23M16.5,12C16.5,10.23 15.5,8.71 14,7.97V16C15.5,15.29 16.5,13.76 16.5,12M3,9V15H7L12,20V4L7,9H3Z"/>
|
||||
</svg>`;
|
||||
ICONS["chevron-double-right"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M5.59,7.41L7,6L13,12L7,18L5.59,16.59L10.17,12L5.59,7.41M11.59,7.41L13,6L19,12L13,18L11.59,16.59L16.17,12L11.59,7.41Z"/>
|
||||
ICONS["minus"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,13H5V11H19V13Z"/>
|
||||
</svg>`;
|
||||
ICONS["play"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M8,5.14V19.14L19,12.14L8,5.14Z"/>
|
||||
</svg>`;
|
||||
ICONS["magnify"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z"/>
|
||||
</svg>`;
|
||||
ICONS["arrow-down-bold"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M9,4H15V12H19.84L12,19.84L4.16,12H9V4Z"/>
|
||||
</svg>`;
|
||||
ICONS["music"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M21,3V15.5A3.5,3.5 0 0,1 17.5,19A3.5,3.5 0 0,1 14,15.5A3.5,3.5 0 0,1 17.5,12C18.04,12 18.55,12.12 19,12.34V6.47L9,8.6V17.5A3.5,3.5 0 0,1 5.5,21A3.5,3.5 0 0,1 2,17.5A3.5,3.5 0 0,1 5.5,14C6.04,14 6.55,14.12 7,14.34V6L21,3Z"/>
|
||||
</svg>`;
|
||||
ICONS["rewind"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M11.5,12L20,18V6M11,18V6L2.5,12L11,18Z"/>
|
||||
</svg>`;
|
||||
ICONS["album"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M12,11A1,1 0 0,0 11,12A1,1 0 0,0 12,13A1,1 0 0,0 13,12A1,1 0 0,0 12,11M12,16.5C9.5,16.5 7.5,14.5 7.5,12C7.5,9.5 9.5,7.5 12,7.5C14.5,7.5 16.5,9.5 16.5,12C16.5,14.5 14.5,16.5 12,16.5M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/>
|
||||
</svg>`;
|
||||
ICONS["minus_unused"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,13H5V11H19V13Z"/>
|
||||
ICONS["download"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z"/>
|
||||
</svg>`;
|
||||
ICONS["account-multiple"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M16,13C15.71,13 15.38,13 15.03,13.05C16.19,13.89 17,15 17,16.5V19H23V16.5C23,14.17 18.33,13 16,13M8,13C5.67,13 1,14.17 1,16.5V19H15V16.5C15,14.17 10.33,13 8,13M8,11A3,3 0 0,0 11,8A3,3 0 0,0 8,5A3,3 0 0,0 5,8A3,3 0 0,0 8,11M16,11A3,3 0 0,0 19,8A3,3 0 0,0 16,5A3,3 0 0,0 13,8A3,3 0 0,0 16,11Z"/>
|
||||
</svg>`;
|
||||
ICONS["close"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/>
|
||||
</svg>`;
|
||||
ICONS["content-save"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M15,9H5V5H15M12,19A3,3 0 0,1 9,16A3,3 0 0,1 12,13A3,3 0 0,1 15,16A3,3 0 0,1 12,19M17,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7L17,3Z"/>
|
||||
</svg>`;
|
||||
ICONS["arrow-up-bold"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M15,20H9V12H4.16L12,4.16L19.84,12H15V20Z"/>
|
||||
</svg>`;
|
||||
ICONS["chevron-double-right"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M5.59,7.41L7,6L13,12L7,18L5.59,16.59L10.17,12L5.59,7.41M11.59,7.41L13,6L19,12L13,18L11.59,16.59L16.17,12L11.59,7.41Z"/>
|
||||
</svg>`;
|
||||
ICONS["shuffle"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M14.83,13.41L13.42,14.82L16.55,17.95L14.5,20H20V14.5L17.96,16.54L14.83,13.41M14.5,4L16.54,6.04L4,18.59L5.41,20L17.96,7.46L20,9.5V4M10.59,9.17L5.41,4L4,5.41L9.17,10.58L10.59,9.17Z"/>
|
||||
</svg>`;
|
||||
ICONS["checkbox-marked-outline"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/>
|
||||
</svg>`;
|
||||
ICONS["repeat"] = `<svg viewBox="0 0 24 24">
|
||||
<path d="M17,17H7V14L3,18L7,22V19H19V13H17M7,7H17V10L21,6L17,2V5H5V11H7V7Z"/>
|
||||
</svg>`;
|
||||
export default ICONS;
|
||||
|
|
|
@ -19,8 +19,4 @@ export default class Item extends HTMLElement {
|
|||
_buildTitle(title) {
|
||||
return html.node("span", {className:"title"}, title, this);
|
||||
}
|
||||
|
||||
matchPrefix(prefix) {
|
||||
return this.textContent.match(/\w+/g).some(word => word.toLowerCase().startsWith(prefix));
|
||||
}
|
||||
}
|
||||
|
|
316
app/js/mpd.js
316
app/js/mpd.js
|
@ -1,170 +1,144 @@
|
|||
import * as parser from "./parser.js";
|
||||
|
||||
let ws, app;
|
||||
let commandQueue = [];
|
||||
let current;
|
||||
let canTerminateIdle = false;
|
||||
|
||||
export default class MPD {
|
||||
static async connect() {
|
||||
let response = await fetch("/ticket", {method:"POST"});
|
||||
let ticket = (await response.json()).ticket;
|
||||
function onError(e) {
|
||||
console.error(e);
|
||||
current && current.reject(e);
|
||||
ws = null; // fixme
|
||||
}
|
||||
|
||||
let ws = new WebSocket(createURL(ticket).href);
|
||||
function onClose(e) {
|
||||
console.warn(e);
|
||||
current && current.reject(e);
|
||||
ws = null; // fixme
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let mpd;
|
||||
let initialCommand = {resolve: () => resolve(mpd), reject};
|
||||
mpd = new this(ws, initialCommand);
|
||||
});
|
||||
function onMessage(e) {
|
||||
if (!current) { return; }
|
||||
|
||||
let lines = JSON.parse(e.data);
|
||||
let last = lines.pop();
|
||||
if (last.startsWith("OK")) {
|
||||
current.resolve(lines);
|
||||
} else {
|
||||
console.warn(last);
|
||||
current.reject(last);
|
||||
}
|
||||
current = null;
|
||||
|
||||
constructor(/** @type WebSocket */ ws, initialCommand) {
|
||||
this._ws = ws;
|
||||
this._queue = [];
|
||||
this._current = initialCommand;
|
||||
this._canTerminateIdle = false;
|
||||
|
||||
ws.addEventListener("message", e => this._onMessage(e));
|
||||
ws.addEventListener("close", e => this._onClose(e));
|
||||
}
|
||||
|
||||
onClose(_e) {}
|
||||
onChange(_changed) {}
|
||||
|
||||
command(cmd) {
|
||||
if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._queue.push({cmd, resolve, reject});
|
||||
|
||||
if (!this._current) {
|
||||
this._advanceQueue();
|
||||
} else if (this._canTerminateIdle) {
|
||||
this._ws.send("noidle");
|
||||
this._canTerminateIdle = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async status() {
|
||||
let lines = await this.command("status");
|
||||
return parser.linesToStruct(lines);
|
||||
}
|
||||
|
||||
async currentSong() {
|
||||
let lines = await this.command("currentsong");
|
||||
return parser.linesToStruct(lines);
|
||||
}
|
||||
|
||||
async listQueue() {
|
||||
let lines = await this.command("playlistinfo");
|
||||
return parser.songList(lines);
|
||||
}
|
||||
|
||||
async listPlaylists() {
|
||||
let lines = await this.command("listplaylists");
|
||||
let parsed = parser.linesToStruct(lines);
|
||||
|
||||
let list = parsed["playlist"];
|
||||
if (!list) { return []; }
|
||||
return (list instanceof Array ? list : [list]);
|
||||
}
|
||||
|
||||
async listPath(path) {
|
||||
let lines = await this.command(`lsinfo "${escape(path)}"`);
|
||||
return parser.pathContents(lines);
|
||||
}
|
||||
|
||||
async listTags(tag, filter = {}) {
|
||||
let tokens = ["list", tag];
|
||||
if (Object.keys(filter).length) {
|
||||
tokens.push(serializeFilter(filter));
|
||||
|
||||
let fakeGroup = Object.keys(filter)[0]; // FIXME hack for MPD < 0.21.6
|
||||
tokens.push("group", fakeGroup);
|
||||
}
|
||||
let lines = await this.command(tokens.join(" "));
|
||||
let parsed = parser.linesToStruct(lines);
|
||||
return [].concat(tag in parsed ? parsed[tag] : []);
|
||||
}
|
||||
|
||||
async listSongs(filter, window = null) {
|
||||
let tokens = ["find", serializeFilter(filter)];
|
||||
window && tokens.push("window", window.join(":"));
|
||||
let lines = await this.command(tokens.join(" "));
|
||||
return parser.songList(lines);
|
||||
}
|
||||
|
||||
async searchSongs(filter) {
|
||||
let tokens = ["search", serializeFilter(filter, "contains")];
|
||||
let lines = await this.command(tokens.join(" "));
|
||||
return parser.songList(lines);
|
||||
}
|
||||
|
||||
async albumArt(songUrl) {
|
||||
let data = [];
|
||||
let offset = 0;
|
||||
let params = ["albumart", `"${escape(songUrl)}"`, offset];
|
||||
|
||||
while (1) {
|
||||
params[2] = offset;
|
||||
try {
|
||||
let lines = await this.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;
|
||||
}
|
||||
|
||||
escape(...args) { return escape(...args); }
|
||||
|
||||
_onMessage(e) {
|
||||
if (!this._current) { return; }
|
||||
|
||||
let lines = JSON.parse(e.data);
|
||||
let last = lines.pop();
|
||||
if (last.startsWith("OK")) {
|
||||
this._current.resolve(lines);
|
||||
} else {
|
||||
console.warn(last);
|
||||
this._current.reject(last);
|
||||
}
|
||||
this._current = null;
|
||||
|
||||
if (this._queue.length > 0) {
|
||||
this._advanceQueue();
|
||||
} else {
|
||||
setTimeout(() => this._idle(), 0); // only after resolution callbacks
|
||||
}
|
||||
}
|
||||
|
||||
_onClose(e) {
|
||||
console.warn(e);
|
||||
this._current && this._current.reject(e);
|
||||
this._ws = null;
|
||||
this.onClose(e);
|
||||
}
|
||||
|
||||
_advanceQueue() {
|
||||
this._current = this._queue.shift();
|
||||
this._ws.send(this._current.cmd);
|
||||
}
|
||||
|
||||
async _idle() {
|
||||
if (this._current) { return; }
|
||||
|
||||
this._canTerminateIdle = true;
|
||||
let lines = await this.command("idle stored_playlist playlist player options mixer");
|
||||
this._canTerminateIdle = false;
|
||||
let changed = parser.linesToStruct(lines).changed || [];
|
||||
changed = [].concat(changed);
|
||||
(changed.length > 0) && this.onChange(changed);
|
||||
if (commandQueue.length > 0) {
|
||||
advanceQueue();
|
||||
} else {
|
||||
setTimeout(idle, 0); // only after resolution callbacks
|
||||
}
|
||||
}
|
||||
|
||||
function advanceQueue(){
|
||||
current = commandQueue.shift();
|
||||
ws.send(current.cmd);
|
||||
}
|
||||
|
||||
export function escape(str) {
|
||||
return str.replace(/(['"\\])/g, "\\$1");
|
||||
async function idle() {
|
||||
if (current) { return; }
|
||||
|
||||
canTerminateIdle = true;
|
||||
let lines = await command("idle stored_playlist playlist player options mixer");
|
||||
canTerminateIdle = false;
|
||||
let changed = parser.linesToStruct(lines).changed || [];
|
||||
changed = [].concat(changed);
|
||||
(changed.length > 0) && app.dispatchEvent(new CustomEvent("idle-change", {detail:changed}));
|
||||
}
|
||||
|
||||
export async function command(cmd) {
|
||||
if (cmd instanceof Array) { cmd = ["command_list_begin", ...cmd, "command_list_end"].join("\n"); }
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
commandQueue.push({cmd, resolve, reject});
|
||||
|
||||
if (!current) {
|
||||
advanceQueue();
|
||||
} else if (canTerminateIdle) {
|
||||
ws.send("noidle");
|
||||
canTerminateIdle = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
let lines = await command("status");
|
||||
return parser.linesToStruct(lines);
|
||||
}
|
||||
|
||||
export async function currentSong() {
|
||||
let lines = await command("currentsong");
|
||||
return parser.linesToStruct(lines);
|
||||
}
|
||||
|
||||
export async function listQueue() {
|
||||
let lines = await command("playlistinfo");
|
||||
return parser.songList(lines);
|
||||
}
|
||||
|
||||
export async function listPlaylists() {
|
||||
let lines = await command("listplaylists");
|
||||
let parsed = parser.linesToStruct(lines);
|
||||
|
||||
let list = parsed["playlist"];
|
||||
if (!list) { return []; }
|
||||
return (list instanceof Array ? list : [list]);
|
||||
}
|
||||
|
||||
export async function listPath(path) {
|
||||
let lines = await command(`lsinfo "${escape(path)}"`);
|
||||
return parser.pathContents(lines);
|
||||
}
|
||||
|
||||
export async function listTags(tag, filter = {}) {
|
||||
let tokens = ["list", tag];
|
||||
if (Object.keys(filter).length) {
|
||||
tokens.push(serializeFilter(filter));
|
||||
|
||||
let fakeGroup = Object.keys(filter)[0]; // FIXME hack for MPD < 0.21.6
|
||||
tokens.push("group", fakeGroup);
|
||||
}
|
||||
let lines = await command(tokens.join(" "));
|
||||
let parsed = parser.linesToStruct(lines);
|
||||
return [].concat(tag in parsed ? parsed[tag] : []);
|
||||
}
|
||||
|
||||
export async function listSongs(filter, window = null) {
|
||||
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 = ["search", 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) {
|
||||
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;
|
||||
}
|
||||
|
||||
export function serializeFilter(filter, operator = "==") {
|
||||
|
@ -179,10 +153,28 @@ export function serializeFilter(filter, operator = "==") {
|
|||
return `"${escape(filterStr)}"`;
|
||||
}
|
||||
|
||||
function createURL(ticket) {
|
||||
let url = new URL(location.href);
|
||||
url.protocol = "ws";
|
||||
url.hash = "";
|
||||
url.searchParams.set("ticket", ticket);
|
||||
return url;
|
||||
export function escape(str) {
|
||||
return str.replace(/(['"\\])/g, "\\$1");
|
||||
}
|
||||
|
||||
export async function init(a) {
|
||||
app = a;
|
||||
let response = await fetch("/ticket", {method:"POST"});
|
||||
let ticket = (await response.json()).ticket;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
current = {resolve, reject};
|
||||
|
||||
try {
|
||||
let url = new URL(location.href);
|
||||
url.protocol = "ws";
|
||||
url.hash = "";
|
||||
url.searchParams.set("ticket", ticket);
|
||||
ws = new WebSocket(url.href);
|
||||
} catch (e) { reject(e); }
|
||||
|
||||
ws.addEventListener("error", onError);
|
||||
ws.addEventListener("message", onMessage);
|
||||
ws.addEventListener("close", onClose);
|
||||
});
|
||||
}
|
||||
|
|
15
index.js
15
index.js
|
@ -15,9 +15,9 @@ function searchYoutube(q, limit, response) {
|
|||
|
||||
console.log("YouTube searching", q, limit);
|
||||
q = escape(`ytsearch${limit}:${q}`);
|
||||
const command = `set -o pipefail; ${cmd} -j ${q} | jq "{id,title}" | jq -s .`;
|
||||
const command = `${cmd} -j ${q} | jq "{id,title}" | jq -s .`;
|
||||
|
||||
require("child_process").exec(command, {shell:"bash"}, (error, stdout, stderr) => {
|
||||
require("child_process").exec(command, {}, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.log("error", error);
|
||||
response.writeHead(500);
|
||||
|
@ -36,7 +36,6 @@ function downloadYoutube(id, response) {
|
|||
let args = [
|
||||
"-f", "bestaudio",
|
||||
"-o", `${__dirname}/_youtube/%(title)s-%(id)s.%(ext)s`,
|
||||
"--",
|
||||
id
|
||||
]
|
||||
let child = require("child_process").spawn(cmd, args);
|
||||
|
@ -114,7 +113,6 @@ function onRequest(request, response) {
|
|||
}
|
||||
|
||||
function requestValidator(request) {
|
||||
if (request.resourceURL.query["server"]) { return false; }
|
||||
let ticket = request.resourceURL.query["ticket"];
|
||||
let index = tickets.indexOf(ticket);
|
||||
if (index > -1) {
|
||||
|
@ -126,12 +124,5 @@ function requestValidator(request) {
|
|||
}
|
||||
|
||||
let httpServer = require("http").createServer(onRequest).listen(port);
|
||||
let passwords = {};
|
||||
try {
|
||||
passwords = require("./passwords.json");
|
||||
console.log("loaded passwords.json file");
|
||||
} catch (e) {
|
||||
console.log("no passwords.json found");
|
||||
}
|
||||
require("ws2mpd").ws2mpd(httpServer, requestValidator, passwords);
|
||||
require("ws2mpd").ws2mpd(httpServer, requestValidator);
|
||||
require("ws2mpd").logging(false);
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"dependencies": {
|
||||
"custom-range": "^1.1.0",
|
||||
"node-static": "^0.7.11",
|
||||
"ws2mpd": "git+https://git.jerryxiao.com/Jerry/ws2mpd.git#b223edc357"
|
||||
"ws2mpd": "^2.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"less": "^3.9.0",
|
||||
|
|
Loading…
Reference in a new issue